AI Governance Command
Generate and enforce a policy gate for AI coding agents operating on this repo: real-time session hooks for Copilot and Claude Code, plus a merge-time GitHub Actions check that catches anything the hooks miss.
Read references/ai-governance.md before responding.
Interactive Wizard (fires when no arguments are provided)
When invoked with no arguments, ask before proceeding:
Q1 — Mode?
What do you need?
1. generate — scaffold policy, evaluator, hooks, and merge-time check
2. check — dry-run the evaluator against a path, command, or diff
3. audit — scan repos in an org for policy presence, tier, and drift
4. explain — plain-English translation of an existing .ai-governance.yaml
Enter 1-4 or mode name:
Q2 — Context (after mode selected, one at a time):
- generate:
Which default policy pack? (terraform / kubernetes / generic / blank) - check:
Give me a file path, a command string, or a diff range (e.g. main...HEAD) to test: - audit:
Which org or repo list should I scan? - explain:
Path to the .ai-governance.yaml to explain (default: ./.ai-governance.yaml):
Then proceed into the relevant mode below.
Mode: generate
Scaffold the policy file, evaluator, session hooks, and merge-time check for this repo.
Steps:
-
Detect which AI tools are configured (
.github/copilot/,.claude/,.cursor/,.codex/if present) and which CI system is in use — reuse the scan approachsetup-agents.mdalready implements; do not reimplement it. -
Write
.ai-governance.yamlwith the chosen default pack. All packs share this shape; onlyprotected_pathsdiffers:generic:
version: 1source: localenforcement: auditprotected_paths:- ".github/workflows/**"- ".github/hooks/**"- ".claude/settings.json"- ".ai-governance.yaml"- ".ai-governance/**"- "**/secrets/**"denied_commands:- "rm -rf"- "git push --force"max_diff_files: 25require_disclosure: trueterraform (adds):
"**/*.tfstate","iam/**"toprotected_paths; adds"terraform apply"todenied_commands.kubernetes (adds):
"**/rbac/**","**/*secret*.yaml"toprotected_paths; adds"kubectl delete"todenied_commands.blank: same shape, empty
protected_paths/denied_commands— for a team that wants to author its own list from the CODEOWNERS-mandatory paths onward. -
Copy
examples/ai-governance/evaluate.shinto.ai-governance/evaluate.shin the target repo, executable bit set (chmod +x). -
Check for
yqv4 (command -v yq && yq --version). If it is missing, stop and ask the user to install it: Homebrew (brew install yq) on macOS, their distribution's package, or a release binary from github.com/mikefarah/yq/releases checked against its published checksum. Never install it yourself. It needssudo, and this binary parses the file that decides what the agent may touch, so where it comes from is the user's call. In CI,ubuntu-latestalready ships yq v4, andexamples/ai-governance/ai-governance-check.ymlpins and verifies its own copy. -
Write
.github/hooks/preToolUse.json:{"version": 1,"hooks": {"preToolUse": [{"type": "command", "bash": ".ai-governance/evaluate.sh --mode=hook --platform=copilot --event=preToolUse", "timeoutSec": 10}]}}And
.github/hooks/postToolUse.json(same shape,--event=postToolUse). This one is not a second gate.--event=postToolUseshort-circuits before the policy is even loaded: it appends onecompletedline to the audit log, emits no decision, and exits 0. It never re-evaluatesprotected_pathsordenied_commands— the call has already run by then, and re-deciding would duplicate the audit linepreToolUsealready wrote for the same call underauditorwarn:{"version": 1,"hooks": {"postToolUse": [{"type": "command", "bash": ".ai-governance/evaluate.sh --mode=hook --platform=copilot --event=postToolUse", "timeoutSec": 10}]}}On Claude Code, prefer the plugin: with
platform-guardrails@platform-skillsenabled, thePreToolUse/PostToolUsegate already ships from the plugin and switches on in any project that commits.ai-governance.yaml. Skip the settings merge below in that case (a second wiring would evaluate every call twice), and keep only the policy file and.ai-governance/assets in the repository. -
Merge, never overwrite
.claude/settings.json.Claude Code's
settings.jsonhook registration is three levels deep, not two: each event key holds an array of matcher-group objects, and each matcher group holds its own innerhooksarray of handler objects.matcheris optional — omitting it means "match every tool call", which is what this policy wants (every call gets evaluated, not just one tool):{"hooks": {"PreToolUse": [{"hooks": [{"type": "command", "command": ".ai-governance/evaluate.sh --mode=hook --platform=claude --event=PreToolUse"}]}]}}Merge into that structure with
jq, appending a new matcher group whether or not the event key already has entries, and skipping the append if this exact command is already registered so re-runninggenerateis idempotent:PRE_CMD='.ai-governance/evaluate.sh --mode=hook --platform=claude --event=PreToolUse'POST_CMD='.ai-governance/evaluate.sh --mode=hook --platform=claude --event=PostToolUse'mkdir -p .claude[[ -f .claude/settings.json ]] || echo '{}' > .claude/settings.jsonjq --arg pre "$PRE_CMD" --arg post "$POST_CMD" 'def add_group($event; $cmd):if [ (.hooks[$event] // [])[] | (.hooks // [])[]? | select(.command == $cmd) ] | length > 0then .else .hooks[$event] = ((.hooks[$event] // []) + [{"hooks": [{"type": "command", "command": $cmd}]}])end;add_group("PreToolUse"; $pre) | add_group("PostToolUse"; $post)' .claude/settings.json > .claude/settings.json.tmp \&& mv .claude/settings.json.tmp .claude/settings.json(.hooks[$event] // [])is what makes this safe on a file that has nohookskey, hashooksbut not this event, or already has other matcher groups registered for this event. Never write.hooks.PreToolUse = [...]— that discards any matcher group the user already had. -
Write
.github/workflows/ai-governance-check.yml(copy fromexamples/ai-governance/ai-governance-check.yml) — this fetches the base branch's copy of the evaluator and policy before running, not the PR's own copy (see references/ai-governance.md's "Trusted verifier" section for why).Bootstrap ordering — state this explicitly, don't let the user find out from a failing check: the workflow's
git show origin/<base>:.ai-governance/evaluate.shhas nothing to read until a copy of the evaluator and policy already exists on the base branch. The very first PR that runsgenerateis, by definition, the PR that would create those files — so if that same PR also enables the required check, the check fails on its own PR before anything is onmainto fetch. Sequence it in two stages:- Land
.ai-governance.yaml,.ai-governance/evaluate.sh, the hook configs, and the workflow file in a normal PR, merged without the check marked as required yet — it will run (and may fail-open on thegit show, which is fine for this one merge) but nothing blocks on it. - Only after that PR is on the base branch, do step 9's branch-protection change, making the check required from that point on. Every PR after this one has a real base-branch copy to fetch.
- Land
-
Add
.ai-governance/audit.logto.gitignore— it can contain command text and file paths that may carry secrets, and must never be committed:grep -qxF '.ai-governance/audit.log' .gitignore 2>/dev/null || echo '.ai-governance/audit.log' >> .gitignore -
Print the exact branch-protection step (Settings → Branches → the target branch's ruleset → require the
ai-governancecheck) and the exact CODEOWNERS lines to add — and print it as a follow-up step to run after the bootstrap PR above has merged, not alongside this PR:.ai-governance.yaml @your-org/platform-team.ai-governance/ @your-org/platform-team.github/hooks/ @your-org/platform-team.github/workflows/ai-governance-check.yml @your-org/platform-team.claude/settings.json @your-org/platform-teamState explicitly: this command cannot set branch protection or write CODEOWNERS itself — no repo-admin scope is assumed.
Validation:
printf '%s\0' ".github/workflows/release.yml" | .ai-governance/evaluate.sh --mode=ci --policy=.ai-governance.yaml
# Under enforcement: block → {"permissionDecision":"deny","permissionDecisionReason":"..."}, exit 2
# Under enforcement: warn → {"permissionDecision":"allow"}, plus a would_deny_warn line in .ai-governance/audit.log
# (the workflow reads those lines and posts the PR comment)
# Under enforcement: audit → {"permissionDecision":"allow"}, plus a would_deny line in .ai-governance/audit.log
One exception to the tier table, worth stating when you hand this over: a diff that touches a governance asset (.ai-governance.yaml, .ai-governance/**, .github/hooks/**, .github/workflows/ai-governance-check.yml, .claude/settings.json) and another protected path denies under every tier, audit included. Otherwise a single PR could switch off the gate and change what it was guarding at the same time. Changing a governance asset on its own is an ordinary protected_paths hit and follows the tier as normal.
Mode: check
Dry-run the evaluator against a path, a command string, or a diff range, without a live agent session.
Steps:
- Accept a path (file edit test), a command string (bash test), or a diff range (
check --diff main...HEAD). - Always pass
--platform=none. That is the evaluator's platform-neutral renderer: it prints{"decision":…,"rule":…,"reason":…}instead of a Copilot or Claude envelope, because a platform engineer tuning policy needs the rule that fired, not a vendor transport format. It also writes nothing to.ai-governance/audit.log, so a dry run never leaves fake entries in the real audit trail:Exit code is# File edit test — an edit payload, so this is write intentecho '{"toolName":"edit","toolArgs":{"path":".github/workflows/release.yml","content":"x"}}' \| .ai-governance/evaluate.sh --mode=hook --platform=none# → {"decision":"deny","rule":"protected_paths","reason":"protected_paths: .github/workflows/** (.github/workflows/release.yml)"}# Command string test — denied_commands, not protected_pathsecho '{"toolName":"bash","toolArgs":{"command":"terraform apply -auto-approve"}}' \| .ai-governance/evaluate.sh --mode=hook --platform=none# → {"decision":"deny","rule":"denied_commands","reason":"denied_commands: terraform apply"}# Diff range test — every rule evaluated in one pass, codes joined and deduped.# --base-ref is required here, not optional: without it, `require_disclosure`# has no commit range to inspect and is itself a violation (see point 4 below),# not a silently-skipped rule.git diff -z --name-only main...HEAD \| .ai-governance/evaluate.sh --mode=ci --platform=none --base-ref=main# → {"decision":"deny","rule":"protected_paths,max_diff_files","reason":"protected_paths: iam/** (iam/policy.json); max_diff_files: 31 > 25"}0for allow and2for deny, so this is scriptable. - Report each decision with the matching rule (which
protected_pathsglob ordenied_commandsentry fired), so the policy can be tuned before rollout. - Three things to say out loud when reporting, because they change what the result means:
- A
denyhere is what the rules say, not what the current tier does.--platform=nonereports the tier-resolved decision, so underenforcement: auditorwarna matching rule renders asallow— runcheckagainst a copy of the policy withenforcement: blockto see the rule set on its own. protected_pathsonly fires on write intent. AReadagainst a protected path is allowed by design, so testing with a read-shaped payload will always come backallow.- If
require_disclosure: trueand--base-refis omitted from a--mode=ciinvocation, that is itself arequire_disclosurefinding — not a rule that silently didn't run. Always pass--base-refwhen disclosure enforcement is on.
- A
Mode: audit
Scan repos in an org for policy presence, enforcement tier, and hook/policy drift — no infrastructure required.
Steps:
- Accept an org or list of repos (
gh apivia existing auth, same pattern astriage.md's comment-fetching). - For each repo: check for
.ai-governance.yamlpresence, currentenforcementtier, and whether the generated hook files match what the current policy would generate (hash comparison, not full regeneration): Ask the API for the raw file rather than fetching base64.contentand decoding it — the decode flag is-don GNU coreutils and-Don macOS/BSD, so a decode step here is a portability bug waiting on whoever runs it next:gh api "orgs/<org>/repos" --paginate --jq '.[].full_name' | while read -r repo; dopolicy="$(gh api "repos/$repo/contents/.ai-governance.yaml" \-H "Accept: application/vnd.github.raw" 2>/dev/null)"if [[ -z "$policy" ]]; thenecho "$repo: not adopted"elsetier="$(echo "$policy" | yq eval '.enforcement' -)"echo "$repo: enforcement=$tier"fidone - Report: adoption rate, tier breakdown, and any repo where hooks are stale relative to its own policy file.
This is the fleet-visibility value normally associated with durable audit ingestion (Phase 4), delivered here via gh api with zero infrastructure — it degrades in reach, not in kind, without it.
Mode: explain
Translate an existing .ai-governance.yaml into plain English — same shape as opa.md's explain mode.
Steps:
- Read the policy file.
- Describe in plain language: what paths are protected and why they matter, what commands are denied and their risk, what enforcement tier is active and what a developer will actually see when a rule fires under that tier, and whether
require_disclosureis active. - If
enforcement: audit, explicitly say so: "no rule currently blocks anything — violations are logged only."