Imported from gke-labs/kube-agents (
agents/platform/skills/fleet-audit/SKILL.md). Install upstream withnpx skills add gke-labs/kube-agents --skill fleet-audit. Copyright stays with the author.
fleet-audit — Audit Findings to a Ledger Issue
Every autonomous audit watchdog ends the same way: findings must reach a human somewhere durable, reviewable, and de-duplicated. This skill is that ending, in two tiers:
- Tier 1 — the ledger. Each audit stream owns exactly one open GitHub issue, rewritten in full on every run and closed as completed when the fleet comes back clean. An operator watches one issue per stream instead of drowning in chat logs.
- Tier 2 — the fixes. When a finding's remediation is a file in this repository, it travels separately as a narrow pull request carrying only that fix, linked back to the ledger.
The split is the point. A report is not a change, so a report is not a pull request — and a fix is not a report, so it carries a real diff a reviewer can read in one screen.
./skills/fleet-audit/scripts/audit_report.py owns every deterministic operation: credential
minting, label creation, issue creation and rewriting, branch handling, staging, committing,
pushing, pull-request creation, closing, the run-over-run delta, and every timestamp. Your job is
to inspect the fleet read-only and emit a findings.json. You never hand-write an issue body or a
PR body, never invent a timestamp, and never call gh issue create or gh pr create yourself —
that is precisely why every ledger looks the same and why the delta between runs is computable.
Audit streams
Only these registered audit ids may own a ledger. Any other id is rejected before a single git or gh
command runs. The issue title is [audit] <human name> — <n> findings (<c> critical) (singular
1 finding when there is exactly one), where the human name is the one cron/jobs.json gives that
watchdog — not a prettified form of the audit id:
| Audit id | Rendered ledger title |
|---|---|
compliance-audit |
[audit] Security & RBAC Posture Audit — 7 findings (2 critical) |
security-patch-orchestrator |
[audit] Upgrade & Patch Readiness Audit — 7 findings (2 critical) |
obtainability-audit |
[audit] Workload Reliability Audit — 7 findings (2 critical) |
fleet-wide-cost-analysis |
[audit] Fleet Waste Audit — 7 findings (2 critical) |
fleet-consistency-drift |
[audit] Fleet Consistency Drift Audit — 7 findings (2 critical) |
ai-security-audit |
[audit] AI Workload Security Audit — 7 findings (2 critical) |
stockout-prevention |
[audit] Fleet Stockout Prevention & Capacity Audit — 7 findings (2 critical) |
gcp-networking-fabric-audit |
[audit] GCP Networking Fabric & VPC IPAM Audit — 7 findings (2 critical) |
gce-compute-fleet-audit |
[audit] GCE Compute Engine and MIG Fleet Audit — 7 findings (2 critical) |
The mapping lives in AUDITS at the top of audit_report.py and mirrors cron/jobs.json; a test
fails if the two drift apart. Do not restate a title anywhere else.
Running a stream on demand
An audit request arrives in one of two distinct forms, distinguished by the request itself:
1. Asked to run an audit stream per its SOP: Run the audit directly
When you are delegated a task or kanban card to execute an audit stream following its standard operating procedure (e.g. "Run the security and RBAC posture audit, following the compliance-audit SOP" or a card naming the stream and SOP):
- You are the audit worker. You have been given a dedicated worker session and turn budget for this specific audit stream.
- Execute the audit following its SOP (mapped in
AUDITSat the top ofaudit_report.py, e.g.governance/compliance_audit_sop.mdforcompliance-audit) directly. Use the two-command lifecycle below:./skills/fleet-audit/scripts/audit_report.py start --audit <stream> [--repo "<owner>/<repo>"]- Enumerate clusters and run the checks per the SOP.
./skills/fleet-audit/scripts/audit_report.py finish --audit <stream> ...
- Do not reach for
hermes cron runor say "queued for the next cron tick": This request is an explicit on-demand audit execution, not a request to trigger the scheduled cron job. Execute the SOP directly and report the ledger issue URL in your result. - Alignment with
AGENTS.md:AGENTS.md("'Run the<x>cron job now' → trigger the schedule, do not re-enact it") addresses requests asking to trigger the background cron job or to run multiple/all audits in a single session. When delegated a task to execute a single audit stream per its SOP, you are the dedicated worker session for that stream; execute the audit directly. - A card whose result is "queued for later" must NEVER be marked
done(#1876): If an audit cannot be run in this session due to missing credentials or infrastructure failure, callkanban_block(or ask for input); never callkanban_completeclaimingdonewhen zero findings or ledger were produced.
2. Asked to run or trigger the scheduled cron job, or to run multiple/all audits
When a request asks to trigger or run the scheduled cron job (e.g. "Run the compliance-audit cron job now", "Trigger the compliance-audit schedule"), or asks to run multiple or all scheduled audits in one request (e.g. "Run all scheduled audits"):
Each stream's cron job id is its audit id, so an operator asking for a run off-schedule is asking for one command per stream:
HERMES_HOME=/opt/data/profiles/platform /opt/hermes/.venv/bin/hermes cron run compliance-audit
Every stream's cron job lives in this profile's own roster, ticked once a minute by the Chat Agent's
profile-cron-tick. hermes cron run marks the job due rather than running it here; the next tick
picks it up within a minute and runs it through the identical path the 06:20 tick uses, with the
stream's prompt verbatim, its skills preloaded, and this profile's max_turns.
cronjob(action='run') is not the route. Where the session cannot take a detached result — a
one-shot hermes -z, a stateless HTTP turn, a Kanban worker, a nested cron run — or where the
dispatch pool is full, it
executes the job synchronously inside the session that calls it, which is the re-enactment the next
paragraph exists to prevent. Elsewhere it hands the run to the background delegation executor and
returns a handle; that is closer to what you want, but hermes cron run is the one route that
behaves identically on every runtime and always runs in a fresh process.
Your shell cannot reach that command in the sandbox pod, and there is no substitute yet. It runs on the gateway pod,
where hermes and /opt/data/profiles are; your shell runs in the sandbox pod, which has neither, so
command not found there is the split working as designed rather than a broken install. When you hit
it, say the on-demand trigger is unavailable and that the stream will run on its 06:20 schedule. That
does not license either fallback: not cronjob(action='run'), and not running the audit yourself inline.
Never do the audit in the session that received the request when asked to trigger the cron job or run all audits. A triggered run gets its
own process and its own turn budget. A session that improvises multiple audits instead has neither — and
when the request is "run them all", it has one turn budget for work the schedule spreads across
every stream and two days. That is not a hypothetical failure mode: on 2026-08-03 a single worker
asked to run all five streams that existed then issued zero kubectl commands, hand-typed five
empty findings documents, and published a fleet-wide all-clear.
The scheduler holds a per-job lock for the length of a run, so a stream already in flight is not
started a second time and cannot write its ledger issue twice. cronjob(action='runs') shows what
is running and what each attempt did.
Each run reports on itself. Your own answer is a roll-up, not a copy. When triggering cron jobs, answer with one line per
stream — the stream, and that it is queued for the next tick (or that the on-demand trigger is unavailable). The reports arrive through each run's
own deliver setting; repeating them here sends the same content twice.
The two-command lifecycle
Run both commands from your normal working directory — the profile directory, where ./skills/...
resolves. You are not in a git checkout, and you do not need to be. The audit crons start in the
profile directory; the harness establishes its own workspace at
/opt/data/gitops/<audit-id>/<owner>__<name> and resolves every remediation.path against it. The
workspace is keyed by audit id because the audit streams share the volume with each other and with
every kanban worker: each one gets a tree nobody else writes in, so a colliding schedule can no
longer reset another stream's working copy out from under it. The repository comes from the
$GITOPS_STATE_CONFIGMAP ConfigMap, which the operator manages and which is readable before any
workspace exists.
What that workspace contains depends on the install, and start tells you which one you have as its
mode field:
content— the workspace is an empty directory. The repository lives in the credential broker, which owns the only checkout; you write manifests into the directory and the harness hands the bytes over. Nothing on your side is a git repository, so there is nothing here to read the repository out of: uselistandfetch(below) for that.directory— the workspace is a clone of the GitOps repository on the shared volume, and the harness runscheckout,add,commitandpushinside it.
Everything else is identical, including where you write manifests and what finish publishes. Where
the two differ, this file says which mode it is talking about.
Step 1 — start
Before inspecting anything, claim the workspace:
./skills/fleet-audit/scripts/audit_report.py start \
--audit <audit-id> \
[--repo "<owner>/<repo>"]
This resolves the target repository (using --repo if specified, falling back to the single
configured repo in $GITOPS_STATE_CONFIGMAP, or failing if ambiguous across multiple repos), mints
a repo-scoped GitHub token, establishes a clean workspace, ensures the audit's labels exist, locates
the stream's open ledger issue, and clears any findings document a crashed run left behind. If the
user asked for a specific repository that is not yet registered, instruct the user or cluster
administrator to add it to $GITOPS_STATE_CONFIGMAP. It creates no branch — there is no report
branch. It prints exactly one JSON line:
{
"issue": 128,
"repo": "acme/fleet",
"mode": "content",
"workspace": "/opt/data/gitops/compliance-audit/acme__fleet",
"findings_path": "/opt/data/scratch/findings_compliance-audit.json",
"pending_remediation_requests": ["netpol-missing-payments"],
"carried": [
{
"id": "cluster-admin-binding.prod-us-east._.clusterrolebinding-debug-binding",
"check": "cluster-admin-binding",
"cluster": "prod-us-east",
"namespace": "",
"object": "ClusterRoleBinding/debug-binding",
"title": "ClusterRoleBinding debug-binding grants cluster-admin to a non-system subject"
}
],
"context_repos": ["acme/terraform-live"],
"declared_intent_repos": ["acme/fleet", "acme/terraform-live"],
"declared_intent_searched": [],
"declared_intent_sources": [],
"declared_intent_unsearched": [],
"declarations_path": "/opt/data/scratch/declarations_compliance-audit.json",
"sop": "governance/compliance_audit_sop.md",
"checks": ["privileged-container", "host-namespace", "…"],
"checks_contract": "Run every check above against every cluster you can read. …"
}
Write your findings to the findings_path it gives you. Do not pick your own path.
checks is your stream's full roster, handed over so coverage never depends on how far into the SOP
you read. It is the work list, not a substitute for the SOP — the slug says which check, the SOP
says what the check is and what counts as a violation, so read the whole file before you start.
sop names it.
workspace is where your manifests go. Every remediation.path is resolved against it, so a
manifest written anywhere else is a file the harness will never find — the finding degrades to a
manual one and no pull request opens. start scrubs that directory before handing it to you;
finish does not, which is what lets the files you write in between survive.
mode is content or directory, and it changes one thing you can see: in content mode the
workspace is empty rather than a checkout. Read it rather than guessing from what is on disk.
pending_remediation_requests lists the findings a repository writer has already asked to be fixed,
parsed from the ledger's comments. Write those manifests during inspection — if the finding is
still reproducing at finish, its pull request opens immediately instead of a week later.
carried lists every finding the open ledger carries — its id, the check that found it, where it
is, and its title — read off the ledger body finish will compare your document against. These
are the findings you are answering for. For each one, this run ends one of four ways: you report
it again; you re-ran its check on that cluster, saw it gone, and say so under
resolved_because with the same check, cluster, namespace and object;
it is a posture now covered by a declaration and sits under declared; or you did not run that
check there and your checks_run does not claim you did. A run whose
checks_run says the check ran and whose document neither reports nor explains the finding is
held — see The clean run. On a stream that passes --manifest-file there is
a fifth ending for a finding the collector still emits: resolved_because does not release it, and
only the collector no longer emitting it or a declared entry does. Empty when there is no open ledger or its body could
not be read (start says so on stderr).
context_repos names the repositories registered for declared intent: the context_repos key
of $GITOPS_STATE_CONFIGMAP, added by an administrator by hand, as owner/name slugs. A stream
whose SOP has a declared-intent step (today obtainability-audit, §4a) searches them before it
reports a posture as a finding. They are read and nothing else: the key is separate from
managed_repos, the harness never merges the two, so the broker's push gate, the repository
resolver and the sweep never see them. The list is empty when nothing is registered or the key
could not be read, which start says on stderr; the GitOps clone is searched either way. A private
context repository is readable through the broker's content-mode clone only (inspect_repository.py clone and open): the broker mints a contents: read token for it per clone and installs it
nowhere. The sandbox's own CLI credential and the directory-mode clone cover managed repositories
alone.
declared_intent_repos is the set that step must account for: the GitOps repository plus every
context_repos slug, one entry each. start writes the same set to a run record beside the
findings document (/opt/data/scratch/run_<audit-id>.json), before it prints, and finish measures
the document's declared_intent_searched against that record rather than against the ConfigMap as
it stands at finish time. Every stream prints it; only a stream with a declared-intent step is held
to it.
declared_intent_searched, declared_intent_sources and declarations_path are the harness's own
half of that step, already done by the time start prints. On a stream with a declared-intent step,
start reads every repository in declared_intent_repos it can — each context_repos entry
through inspect_repository.py clone at the entry's ref when it has one and at its own default
branch otherwise (the copy runs without GITOPS_BASE_BRANCH and CREDENTIAL_PROXY_BASE_BRANCH,
which name the GitOps repository's branch and which a directory-mode clone with no --ref would
otherwise check out), the GitOps repository
from the clone it just reset or through the same script in content mode — for declares:
frontmatter in OKF notes, within the paths each repository's .kube-agents/intent.yaml names
(declared_intent_sources lists each as {repo, ref, paths}, paths empty when the whole tree
was read). In content mode the copy is bounded the same way: .kube-agents/ first, then only the
named paths, so the sibling script's file and byte caps count notes rather than manifests; a
repository with no usable intent file (one naming a path the broker refuses included), or whose
file names a path with nothing behind it, is copied whole under those caps. start files what it found
at declarations_path and lists each repository it read completely — every note under the
searched paths arrived and was read; one the broker withheld or the harness could not decode costs
the repository its entry, a symlink there being no note in either mode — as owner/name@sha. finish unions that list into the document's and
moves every finding a filed declaration covers to declared itself. A slug in declared_intent_repos missing
from declared_intent_searched is one the harness could not read; declared_intent_unsearched
lists each as {repo, ref}, stderr says why, and the SOP says what the worker does about it — its
own copy at that ref. An entry whose ref failed the branch-name check carries the value under
refused_ref instead of a ref: the harness skipped that repository rather than reading its
default branch in the pin's place, the worker copies nothing either, and the ledger names it as
not searched until the entry is corrected. On every other stream the three lists are empty and the
file at declarations_path holds none.
Step 2 — Inspect the fleet (reasoning phase)
Enumerate the clusters in scope and inspect them read-only (kubectl get/describe,
gcloud ... describe/list). For every deviation you intend to report, capture the exact command you
ran and the output that proves it.
Keep a per-cluster tally as you go: for each check in the roster start printed, the slug and the
exact command you issued for it, appended the moment that check completes. finish requires it as
checks_run and rejects a slug with no command. Reconstructing the tally afterwards from memory is
how a check that never ran gets recorded as one that did — and now that each entry carries a command
that gets published, reconstructing it from memory is also how you end up publishing a command you
never issued.
If a remediation is a declarative file, write that file under the workspace directory start
reported and name its repo-relative path in the finding. The harness puts it on a branch of its
own.
Directory mode only: do not leave unrelated uncommitted work in that tree during an audit. Opening a remediation pull request there requires switching branches, and the harness forces the switch. It snapshots and restores every path you declared, and returns you to the branch you started on — but a file it was never told about is not covered by that guarantee. Content mode switches no branch and writes nothing back into the workspace, so this does not apply.
Reading the repository in content mode
The clone is gone, so three commands stand in for it. grep searches inside the files, list names
them, and fetch copies the ones you name into the workspace:
./skills/fleet-audit/scripts/audit_report.py grep --audit <audit-id> --pattern 'namespace: payments'
./skills/fleet-audit/scripts/audit_report.py list --audit <audit-id> --prefix clusters/prod-us-east
./skills/fleet-audit/scripts/audit_report.py fetch --audit <audit-id> --path clusters/prod-us-east/payments-netpol.yaml
grep runs broker-side and answers with matching lines; it is a fixed string unless --regex, and
--prefix narrows it. Use it when what you know is what a file says. list answers with paths and
sizes, never content — use it when what you know is where the file lives. The broker caps what each
returns, so read truncated on both and pass --prefix on a large repository. fetch writes
each file into the workspace at its repo-relative path, which is exactly where a remediation editing
that file has to end up; fetch it, edit it in place, and name the same path in the finding.
All three print sha, the commit of the tree the broker answered from. There is no git on this
side to ask, and the declared-intent record (declared_intent_searched, below) names each repository
as owner/name@sha; take the sha from the command whose answer you searched.
All three take --branch, and a second round needs it. Without it they answer from the base, so a
file the remediation branch has already changed — by an earlier run or by a reviewer — comes back as
the base has it, and committing the edit onto that branch reverts the change. The revert
fast-forwards, so nothing objects. Pass the remediation branch whenever the remote already has one;
a branch it does not have falls back to the base, which is what a first round wants anyway.
All three exit 2 in directory mode, where the clone already holds the file.
Step 3 — finish
./skills/fleet-audit/scripts/audit_report.py finish \
--audit <audit-id> \
--findings-file <findings_path> \
[--repo "<owner>/<repo>"] \
[--manifest-file <path> | --no-collector-manifest "<why>"]
The last pair is optional and belongs to a stream whose SOP runs a collector (the repository's
collector-manifest design says what the manifest holds): --manifest-file names the manifest the
collector wrote and --no-collector-manifest publishes without one, reporting the reason as a
coverage gap. An SOP that mentions neither runs finish without them, exactly as before.
A stream with a collector runs it before Step 2's inspection, not after: the SOP names the script
and the path to write its manifest to, the manifest's commands are that cluster's checks_run
and its candidates are the findings the collector vouches for, and Step 3 passes the same file as
--manifest-file. Today that is the drift stream — governance/fleet_consistency_drift_sop.md §4
says how to read its manifest and what is still yours to write.
The script validates the document, reconciles every finding against the pull requests already open
for this stream, rewrites (or opens) the ledger issue, comments the delta, opens pull requests for
the fixes that qualify, and closes the ones whose findings have stopped reproducing. It prints one
JSON line — status, issue_url, new, resolved, prs_opened, prs_closed,
partial, coverage_gaps, silent_ok, declared, the number of postures a repository
declaration kept off the ledger (it never decides silence), postures_withheld, the ids of the
posture findings finish held back because the document recorded no complete declared-intent
search (empty everywhere but on a declaring stream that skipped the step; see
declared_intent_searched), and unaccounted, the ids of the previous
findings a clean run was refused its close over (empty on every other outcome; see
The clean run). With --manifest-file the line also carries
unpublished_candidates, wholly_unpublished_checks and uncorroborated_findings — which are
absent on every other run:
{"status":"OPENED","issue_url":"…","new":7,"resolved":0,"prs_opened":["…"],"prs_closed":[],"partial":false,"coverage_gaps":[],"silent_ok":false,"declared":0,"postures_withheld":[],"unaccounted":[]}— the stream had no open ledger.{"status":"UPDATED","issue_url":"…","new":2,"resolved":3,"prs_opened":[],"prs_closed":["…"],"partial":false,"coverage_gaps":[],"silent_ok":false,"declared":0,"postures_withheld":[],"unaccounted":[]}— the existing ledger was rewritten.{"status":"CLEAN","issue_url":"…","new":0,"resolved":5,"prs_opened":[],"prs_closed":["…"],"partial":false,"coverage_gaps":[],"silent_ok":false,"declared":0,"postures_withheld":[],"unaccounted":[]}— zero findings; the ledger closed as completed and its open fixes closed with it.{"status":"HELD","issue_url":"…","new":0,"resolved":0,"prs_opened":[],"prs_closed":[],"partial":false,"coverage_gaps":[],"silent_ok":false,"declared":0,"postures_withheld":[],"unaccounted":["cluster-admin-binding.prod-us-east._.clusterrolebinding-debug-binding"]}— zero findings, but the ledger was not closed: it carried findings whose checks this run's ownchecks_runsays ran again, and the document neither reports nor explains them. Not a clean result; report it as The clean run says.
Add --dry-run to validate and print the rendered ledger body — and every PR body it would open —
to stdout with zero git or gh side effects. It applies the same grouping and the same
degradation as the real run, so the branch names it names are the branch names it would create. It
resolves every remediation.path against the same workspace directory the real run uses, not against
the directory you happen to be standing in, so "the manifest is missing" is a finding of the dry run
and not a surprise at publish time. Use it whenever you are unsure your document is well formed.
Exit 0 means published. Exit 2 means the run was rejected before publishing anything — fix what
the message names and re-run; never delete the finding that tripped it. What reaches exit 2: the
document failed a field rule, the file named by --findings-file is missing or is not valid JSON,
--audit is not one of the registered ids above, the document contradicts the collector manifest
named by --manifest-file, that manifest is missing or malformed, --manifest-file was given an
empty path, or --no-collector-manifest was given a blank reason. A manifest that finished before
this run's start opened reaches exit 2 too: the collector writes to a fixed path that is not
scrubbed between runs, so a run whose collector never ran finds the previous one's manifest sitting
there, and cross-checking against a week-old reading of the fleet is worse than cross-checking
against nothing. Re-run the collector. Exit 1 is fatal and means
something else broke.
Partial coverage
partial is true exactly when the run could not speak for the whole fleet. Four of the six
sources are in the document: any entry in scope.skipped, any cluster carrying a limitations
note, any cluster whose checks_run is short of the checks that apply to it, or — on a stream
with a declared-intent step — posture checks that ran without a complete search record
(declared_intent_searched). The other two belong to the run rather
than to the document, so a document that reads as complete can still produce them: a collector
manifest waived with --no-collector-manifest, whose reason becomes the gap, and a previous ledger
body finish could not read and therefore left as it was. coverage_gaps says which, and why — so
partial is true if and only if coverage_gaps is non-empty, and you can report from either.
A check the cluster's shape rules out is not a gap. Declaring it in that cluster's
checks_not_applicable (below) takes it out of the denominator, so a cluster that ran everything
that can apply to it is a fully covered cluster. Without that, a fleet of Autopilot clusters is
permanently partial: the ledger never closes, resolved is pinned at 0, and no stale remediation
pull request is ever cleaned up.
It does not mean "the description was truncated." A ledger too long for GitHub's body limit says so
in its own body and still carries true totals in its title; the audit saw everything, so nothing
about what the run may conclude changes. Coverage is the only thing partial tracks.
A gap changes what the run is allowed to conclude, because a finding's absence from an unread cluster is not evidence that it was fixed. Over a partial run the harness:
- reports
resolved: 0and posts no "resolved" delta, rather than announcing fixes it cannot see; - closes no remediation pull request as stale, so a fix survives to the next complete run;
- does not close the ledger, even with zero findings — the issue stays open and gains a comment
naming the gaps.
statusisCLEANwhere the run accounted for every finding the previous ledger held, andHELDwhere it did not, which is a separate refusal that a gap neither causes nor prevents (see The clean run). The stream self-heals the day the fleet is fully readable again.
A partial run is never [SILENT] — finish returns silent_ok: false for it. Report the issue URL
and say which clusters were not covered. See The clean run for the full rule.
The findings document
{
"audit": "compliance-audit",
"scope": {
"clusters": [
{
"name": "prod-us-east",
"location": "us-east1",
"project": "acme-prod",
"checks_run": [
{
"check": "privileged-container",
"command": "kubectl --context prod-us-east get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{\"/\"}{.metadata.name}{\"\\t\"}{.spec.containers[*].securityContext.privileged}{\"\\n\"}{end}'"
},
{
"check": "netpol-missing",
"command": "kubectl --context prod-us-east get networkpolicy -A -o custom-columns=NS:.metadata.namespace --no-headers"
},
{
"check": "workload-identity-off",
"command": "gcloud container clusters describe prod-us-east --location us-east1 --project acme-prod --format='value(workloadIdentityConfig.workloadPool)'"
}
]
},
{
"name": "prod-autopilot",
"location": "us-central1",
"project": "acme-prod",
"checks_run": [
{
"check": "netpol-missing",
"command": "kubectl --context prod-autopilot get networkpolicy -A -o custom-columns=NS:.metadata.namespace --no-headers"
},
{
"check": "workload-identity-off",
"command": "gcloud container clusters describe prod-autopilot --location us-central1 --project acme-prod --format='value(workloadIdentityConfig.workloadPool)'"
}
],
"checks_not_applicable": [
{
"check": "legacy-metadata",
"reason": "GKE Autopilot: no user-managed node pools to carry a metadata setting."
},
{
"check": "hostpath-mount",
"reason": "GKE Autopilot: hostPath volumes are rejected by the admission webhook."
}
],
"limitations": "RBAC denied `list clusterrolebindings`; check 2.4 did not run."
}
],
"skipped": [{ "cluster": "dr-west", "reason": "control plane unreachable" }]
},
"findings": [
{
"id": "netpol-missing-payments",
"severity": "critical",
"title": "payments namespace has no NetworkPolicy",
"cluster": "prod-us-east",
"namespace": "payments",
"object": "Namespace/payments",
"evidence": {
"command": "kubectl --context prod-us-east get networkpolicy -n payments",
"excerpt": "No resources found in payments namespace."
},
"impact": "All east-west traffic into the PCI namespace is unrestricted.",
"recommendation": {
"action": "Apply a namespace default-deny NetworkPolicy, then allow the two known callers.",
"rationale": "Default-deny at the namespace is the smallest change that closes the exposure. A mesh AuthorizationPolicy would only cover injected pods, and payments runs two that are not.",
"risk": "Unlabelled cross-namespace traffic breaks on apply. Run `kubectl -n payments get pods --show-labels` first to confirm the callers."
},
"remediation": {
"kind": "manifest",
"path": "clusters/prod-us-east/payments-netpol.yaml",
"note": "Apply a default-deny NetworkPolicy."
}
}
],
"declared": [
{
"check": "no-hpa",
"cluster": "prod-us-east",
"namespace": "payments",
"object": "Deployment/api",
"title": "api is pinned at three replicas by Terraform",
"declaration": {
"repo": "acme/terraform-live",
"path": "clusters/prod-us-east/payments.tf",
"excerpt": "replicas = 3 # fixed: the upstream rate limit is per-instance"
}
}
],
"declared_intent_searched": [
"acme/fleet@3f2a9c1d8e7b6a5f4c3d2e1f0a9b8c7d6e5f4a3b",
"acme/terraform-live@8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d"
]
}
(The declared entry and the declared_intent_searched list are illustrative and cross streams: a
real compliance document would be rejected for carrying either. declared[].check is validated
against the stream's declarable set in AUDITS — its posture checks, a subset of the roster — and
only obtainability-audit has one today, because only its SOP has a step that writes the list. A
non-empty declared or declared_intent_searched on any other stream exits 2; [] validates
everywhere.)
Field rules the validator enforces — a violation exits 2 naming the offending finding index and field, and publishes nothing:
-
auditmust equal the--auditargument. An audit may only write to its own ledger. -
scope.clustersmust be non-empty. An audit that enumerated nothing is a failure, not a clean run — if you could not list the fleet, say so loudly instead of reporting zero findings. -
checks_runis required on every cluster (the example above shows three entries per cluster for brevity; a real run carries one per check it ran). Each entry is an object with two required fields:check— the backticked slug from the SOP heading that defines it (netpol-missing, not "2.6" and not prose). An unknown slug or a duplicate is rejected.command— the literal invocation you issued on that cluster for that check, with its--context/--projectand the namespace or resource it targeted. It must name one ofkubectl,gcloud,gsutil,bq,helm, orcurl;echo,cat,python3 -c, a call back intoaudit_report.py, and anything under eight characters are all rejected. One command per entry — the one that produced the evidence, not a summary of your approach.
An empty list is rejected too, unless that cluster's
limitationssays why nothing ran. Enumerating a cluster and checking nothing on it is not a clean cluster — it is an audit that did not happen, and without this field the harness cannot tell the two apart. See Scope, skipped, and limitations. -
checks_not_applicableis optional, and says which checks the cluster's shape rules out. Each entry is an object with two required fields:check— the same slugschecks_runuses. An unknown slug, a duplicate, or a slug that also appears in this cluster'schecks_runis rejected: a check either ran or could not.reason— why the check cannot apply here, naming the property of the cluster that rules it out ("GKE Autopilot: no user-managed node pools to carry a metadata setting"). Anything under sixteen characters is rejected, which is enough to stop "N/A" and "n/a — autopilot".
These checks leave the coverage denominator instead of counting as missing, so a cluster that ran everything that can apply to it is fully covered. That is the difference between a fleet whose ledger can close and one that is permanently partial. Use it only for a check the cluster's shape forbids — a check you could have run and did not is a
limitationsnote and a real gap. Every entry is published in the ledger under Not applicable, with its reason, where a reviewer who knows the cluster can call an excuse for what it is. -
resolved_becauseis optional, and is how a run that found nothing says why a finding the ledger was carrying is gone. One entry per previous finding — take the identity fromstart'scarriedlist — carrying the same four identity fields a finding has and areasonof at least sixteen characters saying what the command showed:"resolved_because": [ { "check": "cluster-admin-binding", "cluster": "prod-us-east", "object": "ClusterRoleBinding/debug-binding", "reason": "kubectl get clusterrolebinding debug-binding returned NotFound; the binding was deleted on 2026-09-16." } ]checkmust be a slug in the SOP's roster,clustermust be inscope.clusters,namespaceis omitted for a cluster-scoped object, and the entry's identity may not also be a finding in the same document. Each entry's id andreasonare published in the run's closing (or held-open) comment, next to the evidence table, so a retired finding carries the sentence that retired it; nothing renders in the ledger body. The entry exists sofinishcan tell "fixed" from "not written down" before it closes one (see The clean run). Write it only for a finding you re-ran the check for and saw gone — it is a statement in a public issue, the same as achecks_runcommand. A posture that is now covered by a declaration needs no entry: list it underdeclaredand it is accounted for. -
checkis required, and is the backticked slug in the heading of the SOP check that produced the finding. Anything outside that SOP's roster is rejected. -
Do not write an
id. The harness derives it as<check>.<cluster>.<namespace>.<object>— one grammar for all audit streams — lowercasing each part, replacing every run of non-alphanumerics with-, and substituting_for an absent namespace. Anyidin the document is discarded.This used to be the model's job, specified in prose, and it was the wrong job to give it. A join key re-derived by inference is not a key: on 2026-08-03 one stream spelled the same nine findings three different ways in three consecutive runs, and because
compute_deltajoins on this string, the third run announced four unfixed criticals — three internet-reachable control planes among them — as resolved, on a ledger whose whole purpose is to say what is still broken. Derivation is what makes "the same problem keeps the same id" a property of the code rather than a request.What you still control is
check,cluster,namespaceandobject, because identity is those four. Name the durable object the check judged — the owning controller, never the pod, whose name carries a random suffix — and never put a timestamp, counter, version, or run id in it. Two findings agreeing on all four are the same finding, and the document is refused rather than silently collapsed.The derived id still has to satisfy
^[a-z0-9]([a-z0-9._-]{0,98}[a-z0-9])?$with no..run and no.locksuffix, and is shortened to fit: the id is the join key of the ledger's hidden delta block and of theaudit-persists:<id>marker — both line-anchored regexes a space or a newline would break — and an operator types it by hand in/remediate <id>. An id that had to be shortened ends in-<six hex characters>, a digest of the id it was shortened from, because trimming alone lands two long objects in one long-named namespace on the same string and the duplicate-identity refusal above would then reject the whole document over two findings that are genuinely different. The digest is a function of that finding's four fields and nothing else, so it is the same next week. -
severityis one ofcritical,major,minor. -
namespacemay be empty for cluster-scoped objects. -
evidence.commandis required and non-empty. -
recommendationis required on every finding, with all three ofaction,rationale, andrisknon-empty. See below. -
remediation.kindismanifest,gcloud, ormanual.pathis required formanifest(repo-relative, no.., no absolute paths, no glob metacharacters) and forbidden for the other two. Forgcloud, put the exact command innote— it is rendered as a runnable block. -
declaredis optional, and is not a list of findings. Each entry is a posture a check would have flagged that a linked repository declares on purpose — seedeclaredbelow. It carriescheck,cluster,namespace,objectandtitleunder the same rules as a finding, plus adeclarationobject whoserepois anowner/nameslug, whosepathfollows the remediation-path rules, and whoseexcerptis the non-empty lines that pin the property. Noseverity, noremediation, noid. The document is rejected when an entry's four identity fields match a finding's — a posture is reported or declared, never both — when itsclusteris not inscope.clusters, or when itscheckis not one of the stream's declarable posture checks (a declared fault is a declared bug, and a stream with no declared-intent step has none). A document without the key validates as before. -
A
pathis discovered, never invented. Editing an object means writing over its existing declaration. Creating one means writing beside a sibling already applied to the same cluster and namespace — search the repository fornamespace: <namespace>, then open the hits and confirm one declares an object you observed on the target cluster before writing beside it. Agrepfor a name is kind-blind and matches label lines and shared prefixes, so a hit is not a declaration until you have read it. In directory mode that search is agrepover the clone; in content mode it is thegrepsubcommand, which the broker runs over its own checkout, andfetchto read the hits it named. The parent directory must already exist in the repository; if no sibling can be confirmed, or the hits straddle two directories you cannot tell apart, the finding iskind: manualwith no path. The harness cannot check this for you: it validates the shape of a path, not whether anything reconciles it, and it will create missing parents and commit the file happily. A manifest in a directory the deploying tool does not apply merges clean, closes the finding for exactly one run, and changes nothing on the cluster — then returns next run aspr-merged-persists, where neither documented explanation fits.
Scope, skipped, and limitations
A cluster appears in exactly one scope list. Ask one question:
Could you read it? Yes →
scope.clusters; name any check that did not run there in that cluster'slimitations, and any check that cannot run there in itschecks_not_applicable. No →scope.skipped, with a reason.
Nothing goes in both, and nothing in scope.skipped may appear in a finding. The validator enforces
both halves. This matters because the alternative produces false all-clears: put an Autopilot
cluster in scope.skipped because one node-level check cannot apply there, and every real finding
on a cluster you did audit gets suppressed along with it.
limitations is optional, and non-empty when present. The rendered scope table grows a
limitations column only when at least one cluster carries one.
checks_run is not optional, and it is what the scope table counts. Every cluster carries the
list of checks that ran against it; the table renders it as 7/11, marked ⚠ where it falls short,
on every run whether or not anything was missed — a column that only appears on bad days is a column
nobody reads on good ones. A cluster with declared inapplicable checks renders as 7/7 (4 n/a),
counted against what applies rather than against the full roster. A shortfall of what does apply
is a coverage gap in its own right: it makes the run partial exactly as an unreadable cluster
does, is named in coverage_gaps, and so the ledger will not close on it. That is the point. A run
that skipped eight of eleven checks and found nothing has not found nothing; it has not looked, and
before this field existed it published as CLEAN and closed the ledger.
Which means the two ways to defeat all of this are to claim a check you did not run, or to park one
in checks_not_applicable that you simply did not get to. The harness runs as a subprocess of you;
it cannot see your tool calls, so it cannot verify either claim — an inflated checks_run converts
a partial audit straight back into a false all-clear, and a padded checks_not_applicable does the
same by shrinking the denominator until the shortfall disappears. Publication is what makes both
expensive and, more importantly, falsifiable: every command you name is published under How
this run checked the fleet, and every exclusion with its reason under Not applicable, where a
reviewer or the next run can re-run the one and contest the other. Record each entry as its check
completes and paste the command you actually issued. Never add entries in advance, never round the
list up to the roster because the SOP happens to define that many, never write a command you did not
run, and never write a reason that does not name a property of the cluster — a fabricated one is a
lie with your name on it in a public issue, which is a worse outcome for you than an honest 7/11.
An honest shortfall costs you nothing. It marks the run partial, keeps the ledger open, and gets
picked up next run. That is the system working.
recommendation
Three fields, all required, all load-bearing for the human who has to decide:
action— what to do. Imperative, one or two sentences.rationale— why this fix and not the obvious alternative. Name the alternative you considered and why you rejected it. A rationale that restates the action is not a rationale.risk— what breaks on apply, and the read-only check to run first.
declared
A finding says the fleet is wrong; a declared posture says the fleet is what somebody meant. The
list exists because the audits judge live state against generic practice, and a platform team that
pinned a replica count in Terraform on purpose was getting the same no-hpa finding every morning
until someone suppressed it by hand. The SOP's declared-intent step (obtainability_audit_sop.md
§4a, the pilot) has two halves. The harness reads every repository in declared_intent_repos for
OKF notes whose frontmatter carries a declares: list — items of {check, namespace, object} plus
an optional cluster, object as Kind/name — within the paths each repository's
.kube-agents/intent.yaml names, and finish moves every finding one covers here itself: a
case-blind lookup on (check, cluster, namespace, object), then on the fleet-wide
(check, namespace, object), compared as the finding id is (deployment/api joins Deployment/api),
the finding's cluster and title kept and the note's repo, path and title as the declaration.
For hpa-cannot-scale, the one slug that names both a posture and a fault, the join moves only the
min == max shape, read off the severity the SOP fixes for it (major); a declaration matching the
minor dangling-target fault is reported on stderr and not applied.
The worker's half is the provisioning/ pins HCL and YAML make in the GitOps clone, which have no
machine-readable form yet; a match there is moved here by the worker with the lines that pin the
property as excerpt. A posture a declares: note covers is written to findings like any other
and the join moves it; a candidate the worker leaves out because it found the note itself gives the
join nothing to move, and the declaration never reaches the ledger.
What the shape enforces:
- It is not a finding. No id is derived, so a declared posture never enters the hidden delta block: it is not announced as new when the declaration appears and not announced as resolved when it goes, and nothing about it is ever promoted to a pull request. A finding that moves here does read as resolved in that run's delta — that is the intended outcome, and the ledger's Declared intent section says where it went.
- It is refutable. Every entry names
repo:pathand quotes the lines that pin the property, and the ledger renders both, so a reviewer who disagrees changes or removes the declaration and the posture returns as a finding on the next run. A declaration the worker did not read is not one it may cite. - It justifies posture, never a fault. Which checks may move here is the stream's
declarableset inAUDITS, four for the pilot, and the validator rejects any other check with exit 2. A drain-blocking budget declared in a repository is a declared bug and stays a finding, and a document that lists it underdeclaredpublishes nothing.
declared_intent_searched
A top-level list of owner/name@sha strings: each repository the declared-intent step searched,
at the commit it was read at. It is the record that the step ran, and it is what makes a skipped
step visible. Nothing else in the document can: a run that skipped the search and published every
posture as a finding, and a run that skipped it and left a candidate out, both validated as complete
before this field existed.
What finish does with it:
- Complete means every repository
startnamed. The list, sha stripped and case-folded, must cover every slug instart'sdeclared_intent_repos— the GitOps repository and everycontext_reposentry — measured against the run recordstartwrote, not the ConfigMap at finish time. Extra repositories are allowed. The sha is checked for shape only (7 to 40 lowercase hex characters), so the record is as forgeable as a paddedchecks_runand carries less; it makes a skipped step visible, not impossible. - The harness's own search counts first.
startrecords each repository it read completely in the run record, andfinishunions that list into the document's before it measures, so a repository the harness read needs no entry from the worker and a document with no key at all is complete when the harness read every repository. What the worker owes is the rest: the repositoriesstartlisted underdeclared_intent_reposand not underdeclared_intent_searched. - It is owed whenever a declarable check ran. Keyed on
checks_run, not on the postures infindings, for the reason above: a candidate left out without a search reads exactly like one a declaration covered. A run on which none of the four checks ran anywhere owes nothing. - Anything less is no search, and the postures are withheld. A union of the worker's list and
start's that misses a repository, or no run record:finish— real and--dry-run— takes every finding whose check is declarable out of the document, the dangling-targethpa-cannot-scalefault included because it shares its slug with themin == maxposture, and adds onecoverage_gapssentence naming each withheld entry and the repositories not searched. The faults publish;declared[]entries publish.partialstaysbool(coverage_gaps), so the ledger does not close,resolvedis0, no stale pull request is retired, and the withheld ids enter no delta block and no remediation pull request. The ledger names the withheld postures under Declared intent not searched below the Scope table, the clean comment lists them, and the JSON line carries their ids aspostures_withheld. - A complete record renders. One line under Scope,
Declared-intent search: owner/name@sha, …, so a reader can see what was read.
Where the sha comes from is the SOP's §4a, and it is the same for the harness and the worker: in
content mode list, grep and fetch print it for the GitOps repository and
inspect_repository.py clone and open print it for a context copy; in directory mode it is
git -C <dir> rev-parse HEAD on the GitOps clone and on the workspace that clone named for
the context copy.
The withhold binds the direct-ask path too: remediate --finding <id> applies it against the same
record and refuses a withheld id by name, because a pull request for a posture the ledger says was
held back would contradict the ledger. It applies the harness's declarations the same way, and
refuses an id a declaration covers for the same reason. A /remediate comment naming a withheld
posture is deferred, not refused, and one naming a declared posture is refused with the declaring
file named — see the answers list under Remediation pull requests.
Evidence rules
A finding with no reproducible command is dropped, not softened. If you cannot produce the exact
read-only command that a reviewer can paste into a terminal to see the same thing you saw, the
finding does not go in the file. Do not downgrade it to minor, do not hedge the title, do not write
"appears to". Omit it.
Corollaries:
-
evidence.excerptis the real output, copied. Never paraphrase it and never synthesise plausible-looking output. The harness trims long excerpts and long commands for you. -
Never paste a Secret's
data:, a token, a password, or a private key into an excerpt. Report that the Secret exists and what is wrong with it; the command inevidence.commandis how a reviewer sees the rest, under their own credentials.The harness redacts high-confidence credential shapes as a backstop — a
data:/stringData:block, an environment variable whose name ends in a credential word, a field named like a secret carrying a value on the same line, a self-identifying token prefix, a PEM header, anAuthorization:value — replacing them with[redacted by audit_report.py]. It is deliberately conservative and does not touch bare base64, a boolean, or an absolute path, because legitimate audit output is full of all three. Treat the backstop as a seatbelt, not a licence: it will not catch a credential that looks like ordinary output. -
Report what the command showed, not what you infer it implies. Inference belongs in
impact. -
One finding per object. Do not roll up "12 namespaces lack NetworkPolicies" into one finding — each gets its own stable id so each can resolve independently.
What the ledger says about each finding
Every finding renders in exactly one state, computed fresh each run from whether it still reproduces and what pull request sits on its branch. Nothing is stored between runs.
| State | Rendered as | Meaning | What the harness does |
|---|---|---|---|
open |
open |
Reproduces; no pull request | Nothing, unless it qualifies for auto-promotion |
pr-open |
fix proposed |
Reproduces; a fix is open on its branch | Labels re-asserted. The pull request itself is untouched |
pr-merged-persists |
⚠ fix merged, still reproduces |
Reproduces; the fix merged anyway | Comments once on the merged PR; never reopens it |
refused |
fix refused |
Reproduces; a human closed the fix | Nothing. The close stands until someone says /remediate |
withdrawn |
fix withdrawn, awaiting re-proposal |
Reproduces; the harness closed the fix | Treats it as having no pull request — it is promotable again |
Every row above says "reproduces", and that is not an accident: a finding that stopped reproducing
is not in the document at all, so it has no row in the ledger to carry a state. Two further states
exist in the code — resolved and resolved-merged — but neither is ever rendered here. A
resolution is announced in the delta comment, by id and title recovered from the previous body, and
the finding's open pull request is closed as stale. A resolution whose fix had already merged is
the ordinary, expected ending, so nothing extra is closed and nothing extra is said.
Three of the five are easy to misread:
pr-openis not refreshed. An open pull request's diff is left exactly as it is, because a reviewer may have pushed onto it and a nightly force-push would silently discard their work. The ledger links it; the diff is whatever a human last made it. Its labels are the single exception, re-asserted on every run that finds it still open: they are the harness's own index of what it still owns rather than anything a reviewer authored, and a strippedagent:auditor aseverity:frozen at what the group used to be loses the pull request from the views triage works from. Labels only — no push, no rewritten body, so the promise above still holds.refusedis a human decision, not a rejected command. It means someone closed the remediation pull request without merging it. That is a considered "no", and the harness never overrules it by re-opening the same fix tomorrow morning.withdrawnis the other half of that. A closed unmerged pull request is two different events, and the discriminator is theaudit:stale-closedlabel the harness applies when it closes one as stale. Its finding is promotable again on the usual terms; arefusedone is not. Do not strip that label — without it the close reads as a human rejection and the finding is never re-proposed.
The escape hatch for a refused finding is /remediate <id> from someone with write access, and it
must be written after the close. An older command in the thread is reported as superseded
rather than honoured: comments are never edited away, so a March request would otherwise re-open an
April close every morning forever. Post a fresh one.
pr-merged-persists is the state worth reading twice: a fix merged and the deviation is still
there. Either the remediation was incomplete or something outside this repository reverted it.
Below the findings, a ledger whose document carried a declared list renders a Declared intent
table: one row per entry, with the check, the cluster, the object, repo:path, and the title and
excerpt. Rows have no state and no id — nothing in that table is tracked between runs — and the
table is capped at 50 rows and says how many it left out. On a clean run the ledger closes without
being rewritten, so the all-clear comment lists the declared postures instead, with the same
repo:path pointers — and that comment is the last ledger record: a later clean run with nothing
but declarations opens no ledger and posts nothing, because the ledger tracks findings and a
standing declaration is the same every morning. From then on the declaration in the repository is
the record, and finish reports the count as `dec
Truncated - read the full file at https://github.com/gke-labs/kube-agents/blob/dc9f56401f13288041cd5751b3504e1748821860/agents/platform/skills/fleet-audit/SKILL.md.