Approvals
A scan says content looks clean; an approval records that a named person read it and accepted it. ai-rulez approve
writes the approval into ai-rulez.lock, bound to the content’s exact digest, and [governance] makes
selected content unusable until it is approved. An approval is a human assertion, never a safety proof, and on its own
it is not authentication (see Approvals are assertions).
Example
Section titled “Example”Require approval for every hook and skill:
# .ai-rulez/config.toml (excerpt)[governance]require_approval = ["kind:hook", "kind:skill"]approvers = ["alice@example.org", "bob@example.org"]max_age = "365d"enforce = trueAfter ai-rulez lock, nothing is approved yet and the lock check fails:
$ ai-rulez approve --listKIND ID DIGEST STATUS REVIEWER EXPIRES ASSURANCEhook PreToolUse:Bash:0 b99ff8c5372c… missing - - -skill deploy 70094bee7309… missing - - -2 require approval: 0 ok, 0 stale, 2 missing, 0 expired, 0 unauthorized, 0 insufficient
$ ai-rulez lock --checkai-rulez.lock does not match /work/acme/.ai-rulez: approval: AR710 missing: hook:PreToolUse:Bash:0 requires approval and has none; review it, then run `ai-rulez approve hook:PreToolUse:Bash:0` approval: AR710 missing: skill:deploy requires approval and has none; review it, then run `ai-rulez approve skill:deploy`...Review the content, then approve it:
$ ai-rulez approve --diff skill:deployskill:deploy sha256:70094bee730997ae05d11499b29571a05dde6cb5f17a4af98ed29465ba50d855 SKILL.md 112 bytes scripts/run.sh 28 bytes (executable) scan: no findings
$ ai-rulez approve skill:deploy --note "read scripts/run.sh" --yes...approved skill:deploy at 70094bee7309… by alice@example.org (assurance=asserted, expires 2027-11-05)
$ ai-rulez approve hook:PreToolUse:Bash:0 --reviewer bob@example.org --yesapproved hook:PreToolUse:Bash:0 at b99ff8c5372c… by bob@example.org (assurance=asserted, expires 2027-11-05)Each approval is an [[approval]] table in the lock:
[[approval]]kind = 'skill'id = 'deploy'digest = 'sha256:70094bee730997ae05d11499b29571a05dde6cb5f17a4af98ed29465ba50d855'reviewer = 'alice@example.org'assurance = 'asserted'approved_at = '2026-11-05T00:00:00Z'expires = '2027-11-05'note = 'read scripts/run.sh'Commit the lock. When the skill changes, its approval goes stale and the check fails again:
approval: AR711 stale: skill:deploy was approved at sha256:70094bee7309… but its content is now sha256:015acba08ec1…; review the change, then run `ai-rulez approve skill:deploy`Recording an approval
Section titled “Recording an approval”| Topic | Behavior |
|---|---|
| Naming | kind:id or kind:domain/id; a bare id works when unambiguous. Kinds are the pinned item kinds (rule, context, skill, agent, command, check, hook, role, settings, verifier, rubric, local-include, local-skill, local-marketplace, local-plugin) and the lock’s remote entries: include, installed-skill, source, served (a served skill, with its serve view as the domain), marketplace, installed-plugin, and role-output (Role outputs) |
| Pinned only | only content in the lock can be approved: run ai-rulez lock first. approve never fetches |
| What it shows | the files behind the item, the security scan findings and any earlier approval. Content with an error-level finding is refused unless you name its code with --accept; accepted codes are stored with the record |
| Confirmation | without --yes it asks on a terminal and refuses elsewhere, so a script cannot approve by accident |
| Reviewer | --reviewer, else $AI_RULEZ_REVIEWER, else the git user.email. A free-form string, compared lower-cased and trimmed; github:alice, @alice and alice are one reviewer. Use a handle if you do not want an email in a committed file |
| Expiry | --expires YYYY-MM-DD, else today plus [governance] max_age, else none. An approval holds through its expiry date, judged by the wall clock |
| Time | approved_at is now, or --at, or SOURCE_DATE_EPOCH, for reproducible runs. It is re-emitted byte for byte when the lock is rewritten, so ai-rulez lock never makes an approval diff |
| Re-approving | replaces that reviewer’s earlier record of the item; other reviewers’ records stay |
| Removing | --revoke <item> removes the records of an item (--reviewer limits it to one reviewer); --prune removes records of content that no longer exists or whose digest changed |
| Output safety | control, bidirectional and zero-width characters in printed paths, findings and notes are shown as \u{XXXX}. --note and --reason are scanned for secrets and refused when they hold one |
When an approval stops applying
Section titled “When an approval stops applying”An approval is bound to the digest the lock pins (hashing scheme). It goes stale
(AR711) on any change to the bytes of the item or of any file its digest covers (a skill’s scripts and resources, a
hook’s script, a remote tree) and on an executable-bit flip. A CRLF-only edit of a markdown or other text file does not
change the digest; the same edit of a script does. A remote source that moves to a new commit with an identical tree
keeps its approval.
ai-rulez lock keeps every approval. Stale ones stay until the content is approved again or approve --prune removes
them; approvals of content that no longer exists are dropped with a warning (AR715). A renamed item must be
approved again under its new name.
Requiring approvals
Section titled “Requiring approvals”[governance]require_approval = ["remote", "kind:hook", "kind:mcp_server"]exempt = ["skill:acme-internal/*"]min_approvers = 2approvers = ["alice@example.org", "bob@example.org", "@acme/security"]approvers_from = "CODEOWNERS"min_assurance = "review-linked"forbid_self_approval = truemax_age = "365d"enforce = true
[governance.teams]"@acme/security" = ["carol@example.org", "github:dave"]| Key | Type | Default | Meaning |
|---|---|---|---|
require_approval |
list | [] |
What needs approval: remote (includes, installed skills, skill sources and remote served skills), local (every authored item), all, or kind:<kind> with a kind from the naming row above or mcp_server (the pinned [[mcp_servers]] declarations) |
exempt |
list | [] |
Globs over kind:id or kind:domain/id (* any run of characters, ? one) that never need approval |
min_approvers |
int | 1 |
Distinct reviewers needed for one digest |
approvers |
list | any reviewer | Allowlist of reviewer strings. An @org/team entry stands for its members (Who may approve) |
approvers_from |
string | none | "CODEOWNERS" (in .github/, the repository root or docs/) or a path to a CODEOWNERS file inside the project: only owners of an item’s path may approve it |
[governance.teams] |
table | none | Maps "@org/team" to the reviewers it stands for, offline |
min_assurance |
string | "asserted" |
The weakest assurance level that counts |
forbid_self_approval |
bool | false |
An approval by an author of the content does not count (Self-approval) |
max_age |
string | none | Default and maximum lifetime of an approval, 365d or a Go duration such as 720h. Past approved_at plus max_age an approval is expired (AR712) whatever its expires says |
enforce |
bool | false |
Make lock --check, generate --locked and mcp --serve-skills fail. validate reports either way |
[governance] in the machine-local overlay is ignored with a warning. An organization policy can set a
floor under it: enforce on, selectors that exempt cannot narrow, a minimum min_approvers and min_assurance,
forbid_self_approval, approvers_from = "CODEOWNERS", and the only approvers that count. A repository that loosens
one is clamped and reported as AR740.
Under enforce the check never fails open: a missing ai-rulez.lock, or one that pins no content, is AR710.
Where it is enforced
Section titled “Where it is enforced”| Where | Behavior |
|---|---|
validate |
AR710 to AR719 at their default severities, with or without enforce |
lock --check, generate --locked |
with enforce, exit 2 for content whose approval is missing, stale, expired, unauthorized or insufficient. lock --diff --format json lists them with scope: "approval" |
mcp --serve-skills |
with enforce, a selected served skill without a valid approval of its served digest is refused with its AR71x code. Provenance gains approved and approvers |
catalog --format json --schema-version 2 |
items[].approval is {required, status, reviewers, assurance, expires} (schema/catalog.schema.json) |
approve --list --format json |
the policy and the status of every item (schema/approve-list.schema.json); --all also lists content that needs no approval |
sbom |
per-component ai-rulez:approval status (SBOM) |
| Code | Name | Meaning |
|---|---|---|
AR710 |
approval-missing |
Required, no record |
AR711 |
approval-stale |
Records exist, none for the current digest |
AR712 |
approval-expired |
Every record of the current digest is past expires or max_age |
AR713 |
approver-not-authorized |
Every record of the current digest is by a reviewer outside approvers or CODEOWNERS |
AR714 |
approval-insufficient |
Fewer distinct valid reviewers than min_approvers, or none at min_assurance |
AR715 |
approval-orphan |
A record names content that no longer exists (warning) |
AR716 |
approval-with-change |
An approval arrived in the same change as its content, or by an author of it (Approvals are assertions) |
AR717 |
approval-denied |
The digest is on the deny list |
AR718 |
approval-unverified |
Every approval of the current digest claims an assurance that could not be verified |
AR719 |
approver-unresolved |
approvers_from or a team cannot be resolved, so nobody is authorized by it |
Assurance levels
Section titled “Assurance levels”| Level | Written by | The record carries | How it is checked |
|---|---|---|---|
asserted |
approve |
a free-form reviewer string | not at all: it is as strong as the review of the change that adds it |
review-linked |
approve --from-github-review |
github:<login> and ref, the URL of an approving pull request review |
offline, the ref must be a well-formed review link of the origin repository; verify --approvals --online asks the forge |
signed |
approve --sign |
a DSSE attestation under .ai-rulez/attestations/; the signer is the reviewer |
offline, against [[signing.trust]] entries with subject = "approval" |
With min_assurance = "signed" an asserted record never counts. A record that claims a level it cannot back (a
signed one whose attestation does not verify, a review-linked one without a ref) does not count (AR718).
approve --list shows the weakest assurance among the approvals that apply.
Who may approve
Section titled “Who may approve”Without approvers and approvers_from, any reviewer string counts. With both set, both must hold.
approversis an allowlist; an@org/teamentry expands through[governance.teams], or from the forge withapprove --resolve-teams(a token withread:org, see Forge client).validateand the skills server never call the forge, so list teams in[governance.teams]for them.approvers_from = "CODEOWNERS"resolves the owners of the item’s source path: the file of a rule, the directory of a skill, or.ai-rulez/ai-rulez.lockfor content without a source file (includes, installed skills, sources, served skills, role outputs). Owners can be@user,@org/teamor an email.
CODEOWNERS is matched with GitHub’s rules: gitignore-like patterns, the last matching line wins, a pattern without a
slash matches at any depth, a leading / anchors, a trailing / owns everything below, docs/* owns only the files
directly in docs/, ** crosses directories. Lines with ! or [abc] are ignored, as GitHub does.
Everything fails closed: a CODEOWNERS file that cannot be found, a path no line covers and a team with no known members
authorize nobody, and validate says why (AR719).
Review-linked approvals
Section titled “Review-linked approvals”$ git fetch origin pull/42/head$ ai-rulez approve include:shared --from-github-review 42 --yesapproved include:shared at 51e0a1b2c3d4… by github:alice (assurance=review-linked, expires 2027-11-05)--from-github-review <pr> records one review-linked approval per qualifying reviewer of the pull request
(--reviewer github:alice picks one). The repository comes from the git origin (or GITHUB_REPOSITORY) and its host
must be on the forge allowlist. A review counts when all of these hold:
- it is the reviewer’s latest decisive review and it is
APPROVED(a laterCHANGES_REQUESTEDorDISMISSEDwithdraws it); - the reviewer is named by
approversor CODEOWNERS for the item, or is anOWNER,MEMBERorCOLLABORATORof the repository with awrite,maintainoradminrole (the role is read when the token has push access; otherwise the association is the evidence). A drive-by approval on a public repository approves nothing; - the review was made on the pull request’s final head commit. A review of an earlier head approves nothing: push, then ask for a new review;
- the content at that commit has the digest being approved. Authored content is recomputed from the files of that commit, never read from the lock committed there; content only the lock describes (remote includes, installed skills, remote served skills, role outputs) is taken from the lock at that commit. The head must be in the local clone;
- the reviewer passes
approversandapprovers_from, and withforbid_self_approvalis not the pull request author.
Approving in the same pull request that changes the content is still AR716: record the approval from the merged pull
request, in a later change (see the workflow).
Signed approvals
Section titled “Signed approvals”$ ai-rulez approve hook:PreToolUse:Bash:0 --sign --key approver.key --yes...approved hook:PreToolUse:Bash:0 at b99ff8c5372c… by key:sha256:c49716c6aeb4b89ba6176d8b7e7951b9a8801e92498d9104b4e4c58f8d8e0ac9 (assurance=signed, expires 2027-11-05)[[approval]]kind = 'hook'id = 'PreToolUse:Bash:0'digest = 'sha256:b99ff8c5372c10a70d8862804dbbbc53d26999e3a78f97ebfea20b4cd1312fd3'reviewer = 'key:sha256:c49716c6aeb4b89ba6176d8b7e7951b9a8801e92498d9104b4e4c58f8d8e0ac9'assurance = 'signed'approved_at = '2026-11-05T00:00:00Z'expires = '2027-11-05'attestation = 'sha256:a001cd034d1a7a18282a2ec965cf38aaa5908df98c90c40d76a5d9186c6923de'--sign signs an in-toto statement (predicate https://github.com/Goldziher/ai-rulez/attestations/approval/v1) over the
item digest, the expiry and the accepted findings, and writes the bundle to
.ai-rulez/attestations/<sha256>.sigstore.json. Commit it with the lock. --key signs offline; --keyless uses a
Fulcio certificate and a public Rekor entry, with the same flags as sign. The verified signer
replaces the reviewer string: key:<fingerprint> for a key, the certificate identity for keyless. --reviewer does
not combine with --sign. With approvers set, list the signer as it is recorded (key:sha256:… or the identity).
Name who may sign approvals with [[signing.trust]] entries whose subject is "approval":
[governance]require_approval = ["remote"]min_assurance = "signed"approvers = ["https://github.com/acme/ai-config/.github/workflows/approve.yml@refs/heads/main"]
[[signing.trust]]subject = "approval"identity = "https://github.com/acme/ai-config/.github/workflows/approve.yml@refs/heads/main"issuer = "https://token.actions.githubusercontent.com"A signed record counts only when its bundle verifies against those entries, covers exactly this item and digest, and
agrees with the record (reviewer, approved_at, expiry, accepted findings), so editing the lock after signing breaks it
(AR718). For keyless signing, approvers and CODEOWNERS are checked before anything reaches the public log. The
approval set is part of the signed lock subject: sign the lock after the last
approve.
Deny list
Section titled “Deny list”$ ai-rulez approve --revoke hook:PreToolUse:Bash:0 --deny --reason "runs curl | sh"revoked 1 approval(s) of hook:PreToolUse:Bash:0denied hook:PreToolUse:Bash:0 sha256:b99ff8c5372c10a70d8862804dbbbc53d26999e3a78f97ebfea20b4cd1312fd3[[deny]]digest = 'sha256:b99ff8c5372c10a70d8862804dbbbc53d26999e3a78f97ebfea20b4cd1312fd3'reason = 'runs curl | sh'A denied digest can be neither approved nor used, whether or not [governance] selects the item:
approverefuses it,validatereportsAR717for any pinned content with that digest, and the skills server never serves a denied skill;ai-rulez lockrefuses to pin it: change the content and lock again;- the entry names the digest, not the item, so it survives re-locking and blocks a rollback to the old bytes.
Remove an entry by editing the lock in a reviewed change. An organization policy’s sources.deny_digests is a second,
organization-wide list (AR747, see Organization policy).
Role outputs
Section titled “Role outputs”A role whose outputs are pinned (pin = true, or lock --roles) has an approval
subject of kind role-output (role-output:backend): the rendering the role produces, not only the sources it selects.
- Only
require_approval = ["kind:role-output"]selects it;all,localandremotedo not. approve role-output:backendrenders the role in memory, shows the files and scans them. It refuses when the rendering differs from the pin: runai-rulez lock --rolesfirst.role:backend(the role declaration) androle-output:backendare separate subjects.
Self-approval
Section titled “Self-approval”With forbid_self_approval = true an approval does not count when its reviewer authored a commit that touched the item
since a base revision:
approvecounts commit authors since--base <rev>, else the branch’s upstream, elseorigin/HEAD, and refuses when none can be found;approve --verify-base <rev>andvalidate --approvals-base <rev>report such approvals asAR716;- with
--from-github-review, the pull request author’s own review is skipped.
Authors are matched by commit email (with .mailmap) or the GitHub noreply address of a github: login against the
reviewer string. It catches honest mistakes, not collusion. A key signature names no person, so with
forbid_self_approval it is AR716 unless its trust entry names the owner:
[[signing.trust]]subject = "approval"key_file = ".ai-rulez/keys/alice.pub"reviewer = "alice@example.org"Verifying approvals
Section titled “Verifying approvals”$ ai-rulez verify --approvalsOK signed hook:PreToolUse:Bash:0 key:sha256:c49716c6aeb4b89ba6176d8b7e7951b9a8801e92498d9104b4e4c58f8d8e0ac9SKIP review-linked include:shared github:alice: needs --online: a review link cannot be verified offline
$ ai-rulez verify --approvals --onlineOK signed hook:PreToolUse:Bash:0 key:sha256:c49716c6aeb4b89ba6176d8b7e7951b9a8801e92498d9104b4e4c58f8d8e0ac9OK review-linked include:shared github:aliceverify --approvals re-checks the approvals above asserted that apply to the current content: signed ones against
their attestations offline, and with --online review-linked ones against the forge (the review still exists, still
approves, and was made on content with the approved digest). Exit 0 every checked approval holds, 2 one does not
(AR718), 1 the check could not run, including an unreachable forge or a missing token. --format json follows
schema/verify-approvals.schema.json. Run it in CI wherever min_assurance = "review-linked" is the policy.
Approval-bot workflow
Section titled “Approval-bot workflow”The strongest setup needs no person to hold a signing identity: a workflow records approvals from the reviews of a
merged pull request, in a follow-up pull request that a code owner of ai-rulez.lock merges.
name: record approvalson: pull_request: types: [closed]permissions: contents: write # push the approval branch pull-requests: write # open the follow-up pull requestjobs: approve: if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 - run: git fetch origin "pull/${{ github.event.pull_request.number }}/head" - uses: Goldziher/ai-rulez@v5 with: version: 5.12.2 - name: Record the approvals of the merged pull request env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | ai-rulez approve --list --format json | jq -r '.items[] | select(.status != "ok") | .ref' > pending.txt xargs -r ai-rulez approve --from-github-review "${{ github.event.pull_request.number }}" --yes < pending.txt ai-rulez verify --approvals --online - uses: peter-evans/create-pull-request@v6 with: branch: approvals/${{ github.event.pull_request.number }} title: "chore: record approvals of #${{ github.event.pull_request.number }}" add-paths: .ai-rulez/ai-rulez.lock- Pin the actions by commit.
- Put
ai-rulez.lockunder CODEOWNERS and require that owner’s review on the follow-up pull request. - To sign instead, add
id-token: write, callapprove --sign --keyless, and trust the workflow in[[signing.trust]]withsubject = "approval". The identity is the workflow file at a ref: protect both. - A pull request from a fork gets a read-only
GITHUB_TOKEN. Do not widen it, and never run pull request code in a workflow that holds a write token.
Approvals are assertions
Section titled “Approvals are assertions”An asserted approval is a string in a committed file. ai-rulez does not know who ran approve, and anyone who can
edit the lock can add a record for any reviewer. Its strength is the review of the change to the lock. The
[governance] table itself is changed by the same pull request it gates, which is why an organization
policy can set a floor under it.
Run this on every pull request whose base branch is trusted:
ai-rulez approve --verify-base origin/main # exit 2 on AR716ai-rulez validate --approvals-base origin/mainBoth compare the lock with the one at the merge base and report (AR716):
- every new
[[approval]]whose item is also new or changed in the same change; - a change to the CODEOWNERS file
approvers_fromnames, or to the[governance]table; - a new approval whose reviewer did not own the item in CODEOWNERS as it was at the merge base.
An approval of content the base already pinned at the same digest is a later review and passes. An unknown revision is
an error (exit 1), never a pass. A finding means the change cannot vouch for itself: land the content first and
approve it in a following change, or require a second reviewer through code owners on ai-rulez.lock. Treat any pull
request that touches [[approval]], [[deny]] or [governance] as sensitive.
Where approvals must hold against a hostile change, set min_assurance = "review-linked" or "signed" and run
verify --approvals --online in CI.
Troubleshooting
Section titled “Troubleshooting”| Message | Cause | Fix |
|---|---|---|
not a terminal: refusing to approve without --yes |
approve in a script or CI |
pass --yes |
… is not in [governance] approvers |
the reviewer, or the signing key’s key:sha256:… identity, is not allowlisted |
add it to approvers, or approve as an allowlisted reviewer |
… changed while it was being reviewed; nothing was written |
the content’s digest changed between the review and the write | review it again, then re-run approve |
AR711 stale after ai-rulez lock |
the content changed since it was approved | approve --diff <item>, then approve <item> |
AR717 … is on the deny list … and cannot be approved |
the digest is in [[deny]] or the policy’s deny_digests |
change the content |
AR719 approver-unresolved |
no CODEOWNERS file, no line covers the path, or a team has no known members | add the CODEOWNERS line or [governance.teams] entry, or use --resolve-teams |
AR716 on a pull request |
the approval arrived with the content it approves | approve in a later change |
