|
| 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