fleet-audit — Audit Findings to a Ledger Issue
SkillAI & modelsPublish the findings of an autonomous fleet audit as one continuously-rewritten GitHub issue per audit stream, and propose fixes as narrow remediation pull requests.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the fleet-audit — Audit Findings to a Ledger Issue skill
What this skill tells your AI
The instructions your AI receives, as published by gke-labs/kube-agents in agents/platform/skills/fleet-audit/SKILL.md and read by ahel’s review.
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 seven 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) |
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
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, 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 —
see the next paragraph. The gap is a deliberate deferral of the shell-sandbox design, not an
oversight.
Do not run the audit yourself in the session that received the request. A triggered run gets its
own process and its own turn budget. A session that improvises the audit 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. Answer with one line per
stream — the stream, and that it is queued for the next tick. 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"],
"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.
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 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>"]
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 with nine fields — status, issue_url, new, resolved, prs_opened, prs_closed,
partial, coverage_gaps, and silent_ok:
{"status":"OPENED","issue_url":"…","new":7,"resolved":0,"prs_opened":["…"],"prs_closed":[],"partial":false,"coverage_gaps":[],"silent_ok":false}— 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}— the existing ledger was rewritten.{"status":"CLEAN","issue_url":"…","new":0,"resolved":5,"prs_opened":[],"prs_closed":["…"],"partial":false,"coverage_gaps":[],"silent_ok":false}— zero findings; the ledger closed as completed and its open fixes closed with it.
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. Three things reach exit 2:
the document failed a field rule, the file named by --findings-file is missing or is not valid
JSON, or --audit is not one of the registered ids above. 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: any entry in
scope.skipped, any cluster carrying a limitations note, or any cluster whose checks_run is
short of the checks that apply to it. 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 —
statusis stillCLEAN, but the issue stays open and gains a comment naming the gaps. 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."
}
}
]
}
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. -
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.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 54
- Forks
- 36
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
fleet-audit-gke-labs- Source
- github.com/gke-labs/kube-agents