Skip to content

Commit 94b7668

Browse files
committed
feat: check the two agent policy files ban the same commands
Parity between the two files was only ever asserted, which is how they drifted. The check expands every prefix_rule into its concrete argv prefixes and matches the two sets in both directions, following each engine's own semantics: Claude Code matches a prefix of the command string, a prefix_rule matches whole argv tokens. A Claude entry ending mid-token cannot be closed by enumeration, so those are reported as structural differences and have to be declared in the script's DELIBERATE list. An undeclared one fails the check, and so does a declaration that has gone stale. Wired into pnpm check and into CI, so every repo generated from this template inherits the guard rather than the drift. Refs #38
1 parent 98cc059 commit 94b7668

5 files changed

Lines changed: 315 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ jobs:
4040

4141
- name: Format check
4242
run: pnpm format
43+
44+
- name: Agent policy parity
45+
run: pnpm check:policy

AGENTS.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@ Implication: when changing files, ask "does this default make sense for _every_
2727
| `pnpm install` | Install deps and wire husky hooks via the `prepare` script |
2828
| `pnpm lint` | `oxlint . --deny-warnings` |
2929
| `pnpm format` | `oxfmt --check .` (note: `format` is the check, not fix) |
30-
| `pnpm check` | Runs `lint` + `format` — the CI gate |
30+
| `pnpm check` | Runs `lint` + `format` + `check:policy` — the CI gate |
31+
| `pnpm check:policy` | Proves the two agent policy files ban the same commands |
3132
| `pnpm lint:fix` | Auto-fix lint |
3233
| `pnpm format:fix` | Auto-fix format |
3334
| `pnpm check:fix` | Auto-fix lint + format |
3435
| `pnpm skills:update`| Update project-scoped agent skills via the skills.sh CLI |
3536
| `pnpm taze` | Interactive dependency upgrade check |
3637
| `pnpm taze:w` | Write upgrade results |
3738

38-
There is no test suite — this is config-only. CI runs `pnpm lint` and `pnpm format` on PR.
39+
There is no test suite — this is config-only. CI runs `pnpm lint`, `pnpm format` and `pnpm check:policy` on PR.
3940

4041
## Architecture / conventions
4142

@@ -79,6 +80,11 @@ Downstream repos keep the `deny` list as-is and swap the `pnpm` lines in `allow`
7980
codex execpolicy check --pretty --rules .codex/rules/default.rules -- git push --force
8081
```
8182

83+
**Parity between the two is machine-checked, not eyeballed.** `pnpm check:policy` (`scripts/check-policy-parity.js`, part of `pnpm check` and of CI) expands every `prefix_rule` into its concrete argv prefixes — the cartesian product over its alternation lists — and matches the two sets in both directions, so "we changed both files" becomes a number rather than a claim. Two things it encodes are worth knowing before editing either file:
84+
85+
- **The languages differ, so a few gaps cannot be closed.** Claude Code matches a prefix of the command _string_; a `prefix_rule` matches whole argv _tokens_. `Bash(aws iam delete-:*)` therefore bans every delete verb AWS will ever ship, and the Codex side can only enumerate the ones it ships today. Such a difference is legal but must be **declared** — in the `DELIBERATE` list in the script and in the `.codex/rules/default.rules` header — and the check fails both on an undeclared one and on a declaration that has gone stale.
86+
- **Neither language normalises flag order or case.** `rm -rf /` and `rm -fr /` are separate bans; `rm -r -f /` and `redis-cli FlushAll` are neither, and enumerating permutations never ends. The check proves the two files list the **same spellings** — it does not claim the set of spellings is complete. Same caveat as the two below, and for the same reason.
87+
8288
## Branching model
8389

8490
The default here is a **`dev` integration branch**: branch off `dev`, PR into `dev`, roll `dev` up into `main`, and release-please releases from `main`. That is what most kirchDev repos run, so the template runs it too — a variant that ships switched off is a variant nobody notices is broken.

CLAUDE.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@ Implication: when changing files, ask "does this default make sense for _every_
2727
| `pnpm install` | Install deps and wire husky hooks via the `prepare` script |
2828
| `pnpm lint` | `oxlint . --deny-warnings` |
2929
| `pnpm format` | `oxfmt --check .` (note: `format` is the check, not fix) |
30-
| `pnpm check` | Runs `lint` + `format` — the CI gate |
30+
| `pnpm check` | Runs `lint` + `format` + `check:policy` — the CI gate |
31+
| `pnpm check:policy` | Proves the two agent policy files ban the same commands |
3132
| `pnpm lint:fix` | Auto-fix lint |
3233
| `pnpm format:fix` | Auto-fix format |
3334
| `pnpm check:fix` | Auto-fix lint + format |
3435
| `pnpm skills:update`| Update project-scoped agent skills via the skills.sh CLI |
3536
| `pnpm taze` | Interactive dependency upgrade check |
3637
| `pnpm taze:w` | Write upgrade results |
3738

38-
There is no test suite — this is config-only. CI runs `pnpm lint` and `pnpm format` on PR.
39+
There is no test suite — this is config-only. CI runs `pnpm lint`, `pnpm format` and `pnpm check:policy` on PR.
3940

4041
## Architecture / conventions
4142

@@ -79,6 +80,11 @@ Downstream repos keep the `deny` list as-is and swap the `pnpm` lines in `allow`
7980
codex execpolicy check --pretty --rules .codex/rules/default.rules -- git push --force
8081
```
8182

83+
**Parity between the two is machine-checked, not eyeballed.** `pnpm check:policy` (`scripts/check-policy-parity.js`, part of `pnpm check` and of CI) expands every `prefix_rule` into its concrete argv prefixes — the cartesian product over its alternation lists — and matches the two sets in both directions, so "we changed both files" becomes a number rather than a claim. Two things it encodes are worth knowing before editing either file:
84+
85+
- **The languages differ, so a few gaps cannot be closed.** Claude Code matches a prefix of the command _string_; a `prefix_rule` matches whole argv _tokens_. `Bash(aws iam delete-:*)` therefore bans every delete verb AWS will ever ship, and the Codex side can only enumerate the ones it ships today. Such a difference is legal but must be **declared** — in the `DELIBERATE` list in the script and in the `.codex/rules/default.rules` header — and the check fails both on an undeclared one and on a declaration that has gone stale.
86+
- **Neither language normalises flag order or case.** `rm -rf /` and `rm -fr /` are separate bans; `rm -r -f /` and `redis-cli FlushAll` are neither, and enumerating permutations never ends. The check proves the two files list the **same spellings** — it does not claim the set of spellings is complete. Same caveat as the two below, and for the same reason.
87+
8288
## Branching model
8389

8490
The default here is a **`dev` integration branch**: branch off `dev`, PR into `dev`, roll `dev` up into `main`, and release-please releases from `main`. That is what most kirchDev repos run, so the template runs it too — a variant that ships switched off is a variant nobody notices is broken.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@
3434
"lint:fix": "oxlint . --fix --deny-warnings",
3535
"format": "oxfmt --check .",
3636
"format:fix": "oxfmt .",
37-
"check": "pnpm lint && pnpm format",
37+
"check": "pnpm lint && pnpm format && pnpm check:policy",
3838
"check:fix": "pnpm lint:fix && pnpm format:fix",
39+
"check:policy": "node scripts/check-policy-parity.js",
3940
"skills:update": "pnpm dlx skills@latest update -p -y",
4041
"taze": "taze",
4142
"taze:w": "taze -w",

scripts/check-policy-parity.js

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Proves the two agent policy files ban the same commands.
5+
*
6+
* `.claude/settings.json` (Claude Code) and `.codex/rules/default.rules`
7+
* (Codex) carry the same block list in two languages, and nothing but this
8+
* check stops them drifting apart — the same destructive command refused
9+
* under one tool and merely prompted under the other.
10+
*
11+
* The comparison is mechanical, never by inspection: every `prefix_rule`
12+
* block is expanded into its concrete argv prefixes (the cartesian product
13+
* over its alternation lists), and the two sets are matched in both
14+
* directions.
15+
*
16+
* Matching follows each engine's own semantics, which are not the same:
17+
*
18+
* - Claude Code matches a prefix of the command *string*, so
19+
* `Bash(aws iam delete-:*)` bans `aws iam delete-role` mid-token. An
20+
* entry without the `:*` suffix matches that one command exactly.
21+
* - A `prefix_rule` matches whole argv *tokens*, so it covers a command
22+
* only when its tokens are a leading run of the command's tokens.
23+
*
24+
* That asymmetry is why a Claude entry ending mid-token can never be closed
25+
* by enumeration. Such an entry is reported as a structural difference and
26+
* has to be listed in DELIBERATE below, with its reason. Anything else is a
27+
* gap, and a gap fails the check.
28+
*/
29+
30+
import { readFileSync } from 'node:fs';
31+
import { dirname, join } from 'node:path';
32+
import { fileURLToPath } from 'node:url';
33+
34+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
35+
const CLAUDE_FILE = '.claude/settings.json';
36+
const CODEX_FILE = '.codex/rules/default.rules';
37+
38+
/**
39+
* Claude `deny` entries that no `prefix_rule` can express, each with the
40+
* reason it stays open. An entry here still has to be a *structural*
41+
* difference — one the Codex side extends but cannot close. A stale entry
42+
* fails the check rather than lingering as a false claim.
43+
*/
44+
const DELIBERATE = [
45+
{
46+
deny: 'Bash(aws iam delete-:*)',
47+
reason:
48+
'Bans every "aws iam delete-…" verb, present and future, by matching ' +
49+
'mid-token. A prefix_rule matches whole argv tokens and has no ' +
50+
'open-ended form, so the Codex rule enumerates the verbs AWS ships ' +
51+
'today. That enumeration is a snapshot; closing the rest is a limit ' +
52+
'of the rule language, not a decision.'
53+
}
54+
];
55+
56+
function read(file) {
57+
return readFileSync(join(root, file), 'utf8');
58+
}
59+
60+
/** Slice the balanced `open`…`close` run starting at `start`. */
61+
function sliceBalanced(text, start, open, close) {
62+
let depth = 0;
63+
for (let i = start; i < text.length; i += 1) {
64+
if (text[i] === open) {
65+
depth += 1;
66+
} else if (text[i] === close) {
67+
depth -= 1;
68+
if (depth === 0) {
69+
return text.slice(start, i + 1);
70+
}
71+
}
72+
}
73+
return null;
74+
}
75+
76+
function lineOf(text, index) {
77+
return text.slice(0, index).split('\n').length;
78+
}
79+
80+
/** Every `Bash(...)` entry in the deny list, as a prefix plus its tokens. */
81+
function parseClaudeDeny(text) {
82+
const denied = JSON.parse(text).permissions?.deny ?? [];
83+
const entries = [];
84+
const unparsed = [];
85+
86+
for (const raw of denied) {
87+
const match = /^Bash\((?<spec>.*)\)$/s.exec(raw);
88+
if (!match) {
89+
unparsed.push(raw);
90+
continue;
91+
}
92+
93+
const spec = match.groups.spec;
94+
const open = spec.endsWith(':*');
95+
const prefix = open ? spec.slice(0, -2) : spec;
96+
entries.push({ raw, prefix, open, tokens: prefix.split(' ') });
97+
}
98+
99+
return { entries, unparsed };
100+
}
101+
102+
/** The cartesian product of a pattern's slots, each an argv token run. */
103+
function expand(pattern) {
104+
let rows = [[]];
105+
for (const slot of pattern) {
106+
const options = Array.isArray(slot) ? slot : [slot];
107+
rows = rows.flatMap((row) => options.map((token) => [...row, token]));
108+
}
109+
return rows;
110+
}
111+
112+
/** Every forbidding `prefix_rule`, expanded into concrete argv prefixes. */
113+
function parseCodexRules(text) {
114+
const rules = [];
115+
const finder = /prefix_rule\s*\(/g;
116+
let found;
117+
118+
while ((found = finder.exec(text)) !== null) {
119+
const parenAt = found.index + found[0].length - 1;
120+
const block = sliceBalanced(text, parenAt, '(', ')');
121+
if (block === null) {
122+
throw new Error(
123+
`${CODEX_FILE}:${lineOf(text, found.index)} — unbalanced prefix_rule()`
124+
);
125+
}
126+
127+
if (!/decision\s*=\s*"forbidden"/.test(block)) {
128+
continue;
129+
}
130+
131+
const head = /pattern\s*=\s*/.exec(block);
132+
if (!head) {
133+
throw new Error(
134+
`${CODEX_FILE}:${lineOf(text, found.index)} — rule has no pattern`
135+
);
136+
}
137+
138+
const literal = sliceBalanced(block, head.index + head[0].length, '[', ']');
139+
if (literal === null) {
140+
throw new Error(
141+
`${CODEX_FILE}:${lineOf(text, found.index)} — unbalanced pattern`
142+
);
143+
}
144+
145+
const pattern = JSON.parse(literal.replaceAll(/,(?=\s*[\]}])/g, ''));
146+
rules.push({
147+
line: lineOf(text, found.index),
148+
prefixes: expand(pattern).map((tokens) => ({
149+
tokens,
150+
command: tokens.join(' ')
151+
}))
152+
});
153+
}
154+
155+
return rules;
156+
}
157+
158+
/** Does a Claude entry ban this whole command string? */
159+
function claudeCovers(entry, command) {
160+
return entry.open
161+
? command.startsWith(entry.prefix)
162+
: command === entry.prefix;
163+
}
164+
165+
/** Are `tokens` a leading run of `of`? */
166+
function isTokenPrefix(tokens, of) {
167+
return (
168+
tokens.length <= of.length && tokens.every((token, i) => token === of[i])
169+
);
170+
}
171+
172+
const claudeText = read(CLAUDE_FILE);
173+
const codexText = read(CODEX_FILE);
174+
const { entries: denies, unparsed } = parseClaudeDeny(claudeText);
175+
const rules = parseCodexRules(codexText);
176+
const prefixes = rules.flatMap((rule) =>
177+
rule.prefixes.map((prefix) => ({ ...prefix, line: rule.line }))
178+
);
179+
180+
// Codex forbids it — does Claude?
181+
const missingFromClaude = prefixes.filter(
182+
(prefix) => !denies.some((entry) => claudeCovers(entry, prefix.command))
183+
);
184+
185+
// Claude denies it — does Codex? A miss the Codex side merely *extends* is
186+
// structural: enumeration cannot close a Claude entry that ends mid-token.
187+
const missingFromCodex = [];
188+
const structural = [];
189+
190+
for (const entry of denies) {
191+
if (prefixes.some((prefix) => isTokenPrefix(prefix.tokens, entry.tokens))) {
192+
continue;
193+
}
194+
195+
const last = entry.tokens.length - 1;
196+
const extending = prefixes.filter(
197+
(prefix) =>
198+
prefix.tokens.length > last &&
199+
isTokenPrefix(entry.tokens.slice(0, last), prefix.tokens) &&
200+
prefix.tokens[last].startsWith(entry.tokens[last])
201+
);
202+
203+
if (extending.length > 0) {
204+
structural.push({ entry, extending });
205+
} else {
206+
missingFromCodex.push(entry);
207+
}
208+
}
209+
210+
const declared = new Map(DELIBERATE.map((item) => [item.deny, item]));
211+
const undeclared = structural.filter((item) => !declared.has(item.entry.raw));
212+
const stale = DELIBERATE.filter(
213+
(item) => !structural.some((found) => found.entry.raw === item.deny)
214+
);
215+
216+
const problems = [];
217+
218+
console.log(
219+
`${CLAUDE_FILE}: ${denies.length} deny entries\n` +
220+
`${CODEX_FILE}: ${rules.length} forbidding prefix_rule blocks, ` +
221+
`${prefixes.length} argv prefixes\n`
222+
);
223+
224+
if (unparsed.length > 0) {
225+
problems.push(
226+
`${unparsed.length} deny entries are not Bash(...) and were not compared:\n` +
227+
unparsed.map((raw) => ` ${raw}`).join('\n')
228+
);
229+
}
230+
231+
if (missingFromClaude.length > 0) {
232+
problems.push(
233+
`Forbidden by Codex, only prompted under Claude — ${missingFromClaude.length}:\n` +
234+
missingFromClaude
235+
.map((p) => ` ${p.command} (${CODEX_FILE}:${p.line})`)
236+
.join('\n')
237+
);
238+
}
239+
240+
if (missingFromCodex.length > 0) {
241+
problems.push(
242+
`Denied by Claude, only prompted under Codex — ${missingFromCodex.length}:\n` +
243+
missingFromCodex.map((entry) => ` ${entry.raw}`).join('\n')
244+
);
245+
}
246+
247+
if (undeclared.length > 0) {
248+
problems.push(
249+
`Structural differences not declared in DELIBERATE — ${undeclared.length}:\n` +
250+
undeclared
251+
.map(
252+
(item) =>
253+
` ${item.entry.raw} — Codex extends it with ` +
254+
`${item.extending.length} enumerated commands but cannot match ` +
255+
`the open prefix itself. Close it, or record it in DELIBERATE ` +
256+
`in this script and in the ${CODEX_FILE} header.`
257+
)
258+
.join('\n')
259+
);
260+
}
261+
262+
if (stale.length > 0) {
263+
problems.push(
264+
`DELIBERATE entries that are no longer a structural difference — ${stale.length}:\n` +
265+
stale
266+
.map(
267+
(item) =>
268+
` ${item.deny} — Codex now covers it (or the deny entry is ` +
269+
`gone). Remove it from DELIBERATE and from the ${CODEX_FILE} header.`
270+
)
271+
.join('\n')
272+
);
273+
}
274+
275+
if (problems.length > 0) {
276+
console.error(`${problems.join('\n\n')}\n`);
277+
console.error('Policy parity check failed.');
278+
process.exitCode = 1;
279+
} else {
280+
if (structural.length > 0) {
281+
console.log(`Deliberate structural differences — ${structural.length}:`);
282+
for (const item of structural) {
283+
console.log(` ${item.entry.raw}`);
284+
console.log(` ${declared.get(item.entry.raw).reason}`);
285+
console.log(
286+
` Codex enumerates ${item.extending.length}: ` +
287+
`${item.extending.map((p) => p.tokens.at(-1)).join(', ')}`
288+
);
289+
}
290+
console.log('');
291+
}
292+
293+
console.log('Policy parity check passed: both files ban the same commands.');
294+
}

0 commit comments

Comments
 (0)