@webpieces/ai-hook-rules 0.4.736 → 0.4.738
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -5
- package/package.json +2 -2
- package/src/bin/codex-trust.js.map +1 -1
- package/src/bin/hook-registration.d.ts +49 -23
- package/src/bin/hook-registration.js +80 -6
- package/src/bin/hook-registration.js.map +1 -1
- package/src/bin/neighbour-hooks.d.ts +42 -0
- package/src/bin/neighbour-hooks.js +177 -0
- package/src/bin/neighbour-hooks.js.map +1 -0
- package/src/bin/settings-shape.d.ts +37 -0
- package/src/bin/settings-shape.js +17 -0
- package/src/bin/settings-shape.js.map +1 -0
- package/src/bin/setup.d.ts +2 -1
- package/src/bin/setup.js.map +1 -1
- package/src/bin/shim-deny-reason.js +19 -1
- package/src/bin/shim-deny-reason.js.map +1 -1
- package/src/bin/upgrade-shim.js +14 -0
- package/src/bin/upgrade-shim.js.map +1 -1
- package/src/core/excluded-paths.d.ts +23 -0
- package/src/core/excluded-paths.js +54 -0
- package/src/core/excluded-paths.js.map +1 -0
- package/src/core/l0-matrix.js +3 -2
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/l0-tooling-doc.js +1 -1
- package/src/core/l0-tooling-doc.js.map +1 -1
- package/src/core/l1-doc.js +20 -3
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l1-rows.js +1 -0
- package/src/core/l1-rows.js.map +1 -1
- package/src/core/l2-rows.js +9 -0
- package/src/core/l2-rows.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +36 -0
- package/src/core/rules/feature-branch-guard.js +95 -17
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/judged-tree.d.ts +66 -0
- package/src/core/rules/judged-tree.js +97 -0
- package/src/core/rules/judged-tree.js.map +1 -0
- package/src/core/rules/read-stale-guard.d.ts +8 -0
- package/src/core/rules/read-stale-guard.js +47 -16
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/runner.d.ts +0 -2
- package/src/core/runner.js +13 -23
- package/src/core/runner.js.map +1 -1
- package/src/core/target-tree.d.ts +82 -0
- package/src/core/target-tree.js +145 -0
- package/src/core/target-tree.js.map +1 -0
package/README.md
CHANGED
|
@@ -68,19 +68,40 @@ never its own release — governance was always the primary's. Both hooks are ab
|
|
|
68
68
|
guarantee is structural, `cd` into a subdirectory is simply allowed, and version skew between trees is
|
|
69
69
|
caught where it actually lives: the `trinary-version-skew` L1 row (`core/version-sync.ts`).
|
|
70
70
|
|
|
71
|
-
### Keeping the
|
|
71
|
+
### Keeping the four in step
|
|
72
72
|
|
|
73
|
-
The installed surface is
|
|
74
|
-
registering the two hooks,
|
|
73
|
+
The installed surface is four things — `.claude/webpieces/ai-hook.sh`, the `settings.json` entries
|
|
74
|
+
registering the two hooks, the ANCHORING of the hook entries the CONSUMER registers beside them, and
|
|
75
|
+
the `settings.json` `env` entry
|
|
75
76
|
`CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR=1` — and they only
|
|
76
|
-
work as a set. The guards binary compares all
|
|
77
|
-
on any mismatch, naming which one moved. **`pnpm exec wp-upgrade-shim`**
|
|
77
|
+
work as a set. The guards binary compares all four against the release it came from and fails closed
|
|
78
|
+
on any mismatch, naming which one moved. **`pnpm exec wp-upgrade-shim`** repairs all four
|
|
78
79
|
(rewriting an old relative registration to the absolute form rather than adding beside it, and removing
|
|
79
80
|
a leftover `guarantee-root.sh` registration) and is
|
|
80
81
|
allowed through while that block is up. Its NAME is older than its job — it has not been shim-only
|
|
81
82
|
since 2026-08-07 — and it is deliberately not renamed, because a rename with no functional change costs
|
|
82
83
|
every consumer and buys nothing.
|
|
83
84
|
|
|
85
|
+
#### Why webpieces anchors hooks it did not write
|
|
86
|
+
|
|
87
|
+
A consumer registers its own guards in the same `.claude/settings.json`, and it is told that file is
|
|
88
|
+
generated and must not be hand-edited — so it cannot fix its own registration without fighting
|
|
89
|
+
`wp-upgrade-shim` on the next bump. Registered RELATIVE (`node ".claude/hooks/guard-deploy.mjs"`) those
|
|
90
|
+
entries resolve against the hook process's cwd, so the moment that cwd is not the repo root node cannot
|
|
91
|
+
load the module and the guard dies before running a line. Per the hooks reference that non-zero exit is
|
|
92
|
+
a NON-BLOCKING error: the tool call runs anyway, with nothing on screen but a
|
|
93
|
+
`node:internal/modules/cjs/loader` fragment carrying no rule name and no verdict. Measured in a consumer
|
|
94
|
+
repo (issue #852) silently disarming a cleartext-credentials blocker, a `gcloud` blocker and a
|
|
95
|
+
raw-deploy blocker — the exact inverse of the fail-closed property those guards are documented to have.
|
|
96
|
+
|
|
97
|
+
That is the same defect webpieces reversed for its OWN hook, and the reasoning was never extended to the
|
|
98
|
+
entries beside it; the failure is a property of the FILE, not of who wrote the line. So each such entry
|
|
99
|
+
is anchored to the harness's own prefix — `$CLAUDE_PROJECT_DIR/…` for Claude Code, `$PWD/…` for Codex.
|
|
100
|
+
Only a token that is not already anchored, contains a `/`, does not escape the root, and **exists on
|
|
101
|
+
disk under the root** is rewritten; that existence test is what leaves `npm run build/foo` alone and
|
|
102
|
+
what guarantees the cure converges, since a token the repair declines to anchor is never reported as
|
|
103
|
+
drift. See `src/bin/neighbour-hooks.ts`.
|
|
104
|
+
|
|
84
105
|
#### Why webpieces manages `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR`
|
|
85
106
|
|
|
86
107
|
Set to `1`, Claude Code resets the shell's cwd to the project directory after every Bash call. That is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.738",
|
|
4
4
|
"description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/tooling/ai-hook-rules"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@webpieces/rules-config": "0.4.
|
|
28
|
+
"@webpieces/rules-config": "0.4.738",
|
|
29
29
|
"inversify": "7.10.4",
|
|
30
30
|
"reflect-metadata": "0.2.2"
|
|
31
31
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"codex-trust.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/codex-trust.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { homedir } from 'os';\n\nimport { toError } from '../core/to-error';\nimport { CODEX_REGISTRATION, HookCommand, HookEntry, isManagedCommand, readSettings } from './hook-registration';\n\n/**\n * CODEX HOOK TRUST — read it, report it, NEVER write it.\n *\n * ─── What Codex actually does ─────────────────────────────────────────────────────────────────────\n * Codex trusts a hook entry TOFU. On first sight of a new or changed entry it PROMPTS\n * (`Hooks need review / 1 hook is new or changed`) and, if the human accepts, records a hash in\n * `~/.codex/config.toml`:\n *\n * [hooks.state.\"<abs path to .codex/hooks.json>:pre_tool_use:<group>:<index>\"]\n * trusted_hash = \"sha256:…\"\n *\n * ─── Why this only ever REPORTS ───────────────────────────────────────────────────────────────────\n * The hash is NOT reproducible from outside Codex. Sixteen encodings were tried against a hooks.json we\n * wrote ourselves and none of them produced the recorded value. So an installer that tried to write one\n * would be guessing at a security decision on a human's behalf, and would either be ignored or — worse —\n * appear to succeed. Forging it is not on the table even if the encoding were known: the prompt IS the\n * mechanism, and the whole point of TOFU is that a person saw the command once.\n *\n * The consequence the installer must therefore SAY OUT LOUD: the prompt's third option is\n * `Continue without trusting (hooks won't run)`. That is one keystroke to a fully unguarded session,\n * with no later warning of any kind. Install-time verification cannot see that choice — it happens\n * afterwards, in another process — so this check reports the trust state it CAN read, names the file it\n * read it from, and stops there.\n *\n * Declining the prompt is a HUMAN's decision about their own machine, and it is deliberately left\n * standing. Tooling that tried to detect and refuse it afterwards would be voiding a setting a person\n * chose on purpose, and it has nothing to catch besides: the guards constrain the AGENT, while the\n * prompt is answered by the HUMAN, who the agent cannot impersonate. So there is no second mechanism\n * downstream of this one — this report is the whole of what webpieces knows about Codex trust.\n */\n\n/** What one repo's Codex trust looks like right now. Data-only → a class, per CLAUDE.md. */\nexport class CodexTrustStatus {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n /** `~/.codex/config.toml`, named even when it does not exist so the report can point at it. */\n readonly configPath: string,\n readonly configExists: boolean,\n /** `[projects.\"<root>\"] trust_level = \"trusted\"`. */\n readonly projectTrusted: boolean,\n /** The repo's `.codex/hooks.json`, absolute — the key every `hooks.state` entry is prefixed by. */\n readonly hooksPath: string,\n /** How many webpieces-managed PreToolUse entries that file registers. 0 ⇒ Codex is not armed. */\n readonly registeredEntries: number,\n /** How many of them `~/.codex/config.toml` records a `trusted_hash` for. */\n readonly trustedEntries: number,\n ) {}\n\n /** True when Codex is armed here AND every entry it would run is trusted. */\n fullyTrusted(): boolean {\n return this.registeredEntries > 0 && this.trustedEntries >= this.registeredEntries && this.projectTrusted;\n }\n\n /** True when there is nothing to say — this repo has not armed Codex at all. */\n notArmed(): boolean {\n return this.registeredEntries === 0;\n }\n\n /**\n * The report, as lines. It states what IS, then the one action that changes it — and the action is\n * always the human's, because nothing here can be repaired by a tool.\n */\n lines(): readonly string[] {\n if (this.notArmed()) return [];\n if (this.fullyTrusted()) {\n return [` ✅ Codex trusts all ${String(this.registeredEntries)} webpieces hook(s) in ${this.hooksPath}`];\n }\n const out: string[] = [];\n out.push(` ⚠️ Codex has NOT yet trusted the webpieces hooks in ${this.hooksPath}`);\n if (!this.configExists) {\n out.push(` ${this.configPath} does not exist yet — Codex has never run for this user.`);\n } else if (!this.projectTrusted) {\n out.push(' this project is not marked trusted in ~/.codex/config.toml.');\n }\n if (this.trustedEntries < this.registeredEntries) {\n out.push(` ${String(this.trustedEntries)} of ${String(this.registeredEntries)} hook entries carry a trusted_hash.`);\n }\n out.push(' Fix: run `codex` in this repo and choose \"Trust all\" when it asks about hooks.');\n out.push(' Its third option (\"Continue without trusting\") leaves the session UNGUARDED for its');\n out.push(' whole life, and nothing later in the run reports that — this message is the only notice.');\n return out;\n }\n}\n\n/**\n * Reads Codex's trust state. Never writes, never throws — an unreadable or absent config reports as\n * \"not trusted\", which is the true and safe answer.\n */\nexport class CodexTrustProbe {\n /** `~/.codex/config.toml`. `homeDir` is injectable so a unit test never reads the real one. */\n configPath(homeDir: string = homedir()): string {\n return path.join(homeDir, '.codex', 'config.toml');\n }\n\n read(projectRoot: string, homeDir: string = homedir()): CodexTrustStatus {\n const configPath = this.configPath(homeDir);\n const hooksPath = path.join(projectRoot, ...CODEX_REGISTRATION.settingsFiles[0].split('/'));\n const registered = this.registeredEntryCount(hooksPath);\n const raw = this.readOrEmpty(configPath);\n return new CodexTrustStatus(\n configPath, raw !== null, this.projectTrusted(raw ?? '', projectRoot),\n hooksPath, registered, this.trustedEntryCount(raw ?? '', hooksPath),\n );\n }\n\n /**\n * How many webpieces-managed PreToolUse commands `.codex/hooks.json` registers.\n *\n * It counts what WE own, not every hook in the file: a consumer's own unrelated hook is none of this\n * check's business, and counting it would make the report say \"1 of 3 trusted\" about hooks webpieces\n * neither wrote nor can advise on.\n */\n private registeredEntryCount(hooksPath: string): number {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(hooksPath)) return 0;\n const entries: readonly HookEntry[] = readSettings(hooksPath).hooks?.PreToolUse ?? [];\n return entries.reduce(\n (total: number, entry: HookEntry): number =>\n total + entry.hooks.filter((h: HookCommand): boolean => isManagedCommand(h.command)).length,\n 0,\n );\n } catch (err: unknown) {\n const error = toError(err);\n void error; // an unreadable/invalid hooks.json is \"not armed\"; the drift check is what reports that\n return 0;\n }\n }\n\n private readOrEmpty(configPath: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : null;\n } catch (err: unknown) {\n const error = toError(err);\n void error; // unreadable → treated as absent, which reports as \"not trusted\"\n return null;\n }\n }\n\n /**\n * `[projects.\"<root>\"]` carrying `trust_level = \"trusted\"`.\n *\n * A LINE SCAN, not a TOML parser, and deliberately: this package must load on a tree too broken to\n * build a DI container, so it takes no dependency it does not need, and the two shapes it reads are\n * both written by Codex itself in one fixed form. It over-reports \"not trusted\" for anything it\n * cannot recognise, which is the safe direction — the consequence is one advisory line too many,\n * never a session reported as guarded when it is not.\n */\n private projectTrusted(raw: string, projectRoot: string): boolean {\n return this.sectionBody(raw, `projects.\"${projectRoot}\"`)\n .some((line: string): boolean => /^\\s*trust_level\\s*=\\s*\"trusted\"\\s*$/.test(line));\n }\n\n /**\n * How many `hooks.state.\"<hooksPath>:pre_tool_use:<group>:<index>\"` sections carry a `trusted_hash`.\n *\n * The count is compared against the number of entries the file registers rather than matched\n * one-for-one to a specific group/index: the group and index are Codex's own numbering of a file it\n * re-reads on every launch, and an installer that assumed a numbering would report a false alarm the\n * first time Codex renumbered.\n */\n private trustedEntryCount(raw: string, hooksPath: string): number {\n const prefix = `hooks.state.\"${hooksPath}:pre_tool_use:`;\n let trusted = 0;\n let inEntry = false;\n for (const line of raw.split('\\n')) {\n const header = this.headerOf(line);\n if (header !== null) inEntry = header.startsWith(prefix);\n else if (inEntry && /^\\s*trusted_hash\\s*=\\s*\"/.test(line)) trusted += 1;\n }\n return trusted;\n }\n\n /** The body lines of one TOML section, or [] when the file carries no such header. */\n private sectionBody(raw: string, wanted: string): readonly string[] {\n const body: string[] = [];\n let inside = false;\n for (const line of raw.split('\\n')) {\n const header = this.headerOf(line);\n if (header !== null) inside = header === wanted;\n else if (inside) body.push(line);\n }\n return body;\n }\n\n /** The `<name>` of a `[<name>]` header line, or null for anything else. */\n private headerOf(line: string): string | null {\n const match = /^\\s*\\[([^\\]]+)\\]\\s*$/.exec(line);\n return match === null ? null : match[1];\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"codex-trust.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/codex-trust.ts"],"names":[],"mappings":";;;;AAAA,+CAAyB;AACzB,mDAA6B;AAC7B,2BAA6B;AAE7B,+CAA2C;AAC3C,2DAAyF;AAGzF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,4FAA4F;AAC5F,MAAa,gBAAgB;IAIZ;IACA;IAEA;IAEA;IAEA;IAEA;IAZb,yDAAyD;IACzD;IACI,+FAA+F;IACtF,UAAkB,EAClB,YAAqB;IAC9B,qDAAqD;IAC5C,cAAuB;IAChC,mGAAmG;IAC1F,SAAiB;IAC1B,iGAAiG;IACxF,iBAAyB;IAClC,4EAA4E;IACnE,cAAsB;QATtB,eAAU,GAAV,UAAU,CAAQ;QAClB,iBAAY,GAAZ,YAAY,CAAS;QAErB,mBAAc,GAAd,cAAc,CAAS;QAEvB,cAAS,GAAT,SAAS,CAAQ;QAEjB,sBAAiB,GAAjB,iBAAiB,CAAQ;QAEzB,mBAAc,GAAd,cAAc,CAAQ;IAChC,CAAC;IAEJ,6EAA6E;IAC7E,YAAY;QACR,OAAO,IAAI,CAAC,iBAAiB,GAAG,CAAC,IAAI,IAAI,CAAC,cAAc,IAAI,IAAI,CAAC,iBAAiB,IAAI,IAAI,CAAC,cAAc,CAAC;IAC9G,CAAC;IAED,gFAAgF;IAChF,QAAQ;QACJ,OAAO,IAAI,CAAC,iBAAiB,KAAK,CAAC,CAAC;IACxC,CAAC;IAED;;;OAGG;IACH,KAAK;QACD,IAAI,IAAI,CAAC,QAAQ,EAAE;YAAE,OAAO,EAAE,CAAC;QAC/B,IAAI,IAAI,CAAC,YAAY,EAAE,EAAE,CAAC;YACtB,OAAO,CAAC,wBAAwB,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,yBAAyB,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;QAC7G,CAAC;QACD,MAAM,GAAG,GAAa,EAAE,CAAC;QACzB,GAAG,CAAC,IAAI,CAAC,0DAA0D,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;QACrF,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;YACrB,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,CAAC,UAAU,0DAA0D,CAAC,CAAC;QAChG,CAAC;aAAM,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,CAAC;YAC9B,GAAG,CAAC,IAAI,CAAC,kEAAkE,CAAC,CAAC;QACjF,CAAC;QACD,IAAI,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,iBAAiB,EAAE,CAAC;YAC/C,GAAG,CAAC,IAAI,CAAC,QAAQ,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,OAAO,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,qCAAqC,CAAC,CAAC;QAC5H,CAAC;QACD,GAAG,CAAC,IAAI,CAAC,qFAAqF,CAAC,CAAC;QAChG,GAAG,CAAC,IAAI,CAAC,0FAA0F,CAAC,CAAC;QACrG,GAAG,CAAC,IAAI,CAAC,+FAA+F,CAAC,CAAC;QAC1G,OAAO,GAAG,CAAC;IACf,CAAC;CACJ;AAlDD,4CAkDC;AAED;;;GAGG;AACH,MAAa,eAAe;IACxB,+FAA+F;IAC/F,UAAU,CAAC,UAAkB,IAAA,YAAO,GAAE;QAClC,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,EAAE,aAAa,CAAC,CAAC;IACvD,CAAC;IAED,IAAI,CAAC,WAAmB,EAAE,UAAkB,IAAA,YAAO,GAAE;QACjD,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;QAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,GAAG,sCAAkB,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;QAC5F,MAAM,UAAU,GAAG,IAAI,CAAC,oBAAoB,CAAC,SAAS,CAAC,CAAC;QACxD,MAAM,GAAG,GAAG,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC;QACzC,OAAO,IAAI,gBAAgB,CACvB,UAAU,EAAE,GAAG,KAAK,IAAI,EAAE,IAAI,CAAC,cAAc,CAAC,GAAG,IAAI,EAAE,EAAE,WAAW,CAAC,EACrE,SAAS,EAAE,UAAU,EAAE,IAAI,CAAC,iBAAiB,CAAC,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,CACtE,CAAC;IACN,CAAC;IAED;;;;;;OAMG;IACK,oBAAoB,CAAC,SAAiB;QAC1C,8DAA8D;QAC9D,IAAI,CAAC;YACD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC;gBAAE,OAAO,CAAC,CAAC;YACxC,MAAM,OAAO,GAAyB,IAAA,gCAAY,EAAC,SAAS,CAAC,CAAC,KAAK,EAAE,UAAU,IAAI,EAAE,CAAC;YACtF,OAAO,OAAO,CAAC,MAAM,CACjB,CAAC,KAAa,EAAE,KAAgB,EAAU,EAAE,CACxC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAc,EAAW,EAAE,CAAC,IAAA,oCAAgB,EAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,EAC/F,CAAC,CACJ,CAAC;QACN,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC,CAAC,wFAAwF;YACpG,OAAO,CAAC,CAAC;QACb,CAAC;IACL,CAAC;IAEO,WAAW,CAAC,UAAkB;QAClC,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAClF,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC,CAAC,iEAAiE;YAC7E,OAAO,IAAI,CAAC;QAChB,CAAC;IACL,CAAC;IAED;;;;;;;;OAQG;IACK,cAAc,CAAC,GAAW,EAAE,WAAmB;QACnD,OAAO,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,aAAa,WAAW,GAAG,CAAC;aACpD,IAAI,CAAC,CAAC,IAAY,EAAW,EAAE,CAAC,qCAAqC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAC3F,CAAC;IAED;;;;;;;OAOG;IACK,iBAAiB,CAAC,GAAW,EAAE,SAAiB;QACpD,MAAM,MAAM,GAAG,gBAAgB,SAAS,gBAAgB,CAAC;QACzD,IAAI,OAAO,GAAG,CAAC,CAAC;QAChB,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,KAAK,MAAM,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACjC,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;YACnC,IAAI,MAAM,KAAK,IAAI;gBAAE,OAAO,GAAG,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;iBACpD,IAAI,OAAO,IAAI,0BAA0B,CAAC,IAAI,CAAC,IAAI,CAAC;gBAAE,OAAO,IAAI,CAAC,CAAC;QAC5E,CAAC;QACD,OAAO,OAAO,CAAC;IACnB,CAAC;IAED,sFAAsF;IAC9E,WAAW,CAAC,GAAW,EAAE,MAAc;QAC3C,MAAM,IAAI,GAAa,EAAE,CAAC;QAC1B,IAAI,MAAM,GAAG,KAAK,CAAC;QACnB,KAAK,MAAM,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACjC,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;YACnC,IAAI,MAAM,KAAK,IAAI;gBAAE,MAAM,GAAG,MAAM,KAAK,MAAM,CAAC;iBAC3C,IAAI,MAAM;gBAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACrC,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,2EAA2E;IACnE,QAAQ,CAAC,IAAY;QACzB,MAAM,KAAK,GAAG,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChD,OAAO,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC5C,CAAC;CACJ;AAvGD,0CAuGC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { homedir } from 'os';\n\nimport { toError } from '../core/to-error';\nimport { CODEX_REGISTRATION, isManagedCommand, readSettings } from './hook-registration';\nimport type { HookCommand, HookEntry } from './settings-shape';\n\n/**\n * CODEX HOOK TRUST — read it, report it, NEVER write it.\n *\n * ─── What Codex actually does ─────────────────────────────────────────────────────────────────────\n * Codex trusts a hook entry TOFU. On first sight of a new or changed entry it PROMPTS\n * (`Hooks need review / 1 hook is new or changed`) and, if the human accepts, records a hash in\n * `~/.codex/config.toml`:\n *\n * [hooks.state.\"<abs path to .codex/hooks.json>:pre_tool_use:<group>:<index>\"]\n * trusted_hash = \"sha256:…\"\n *\n * ─── Why this only ever REPORTS ───────────────────────────────────────────────────────────────────\n * The hash is NOT reproducible from outside Codex. Sixteen encodings were tried against a hooks.json we\n * wrote ourselves and none of them produced the recorded value. So an installer that tried to write one\n * would be guessing at a security decision on a human's behalf, and would either be ignored or — worse —\n * appear to succeed. Forging it is not on the table even if the encoding were known: the prompt IS the\n * mechanism, and the whole point of TOFU is that a person saw the command once.\n *\n * The consequence the installer must therefore SAY OUT LOUD: the prompt's third option is\n * `Continue without trusting (hooks won't run)`. That is one keystroke to a fully unguarded session,\n * with no later warning of any kind. Install-time verification cannot see that choice — it happens\n * afterwards, in another process — so this check reports the trust state it CAN read, names the file it\n * read it from, and stops there.\n *\n * Declining the prompt is a HUMAN's decision about their own machine, and it is deliberately left\n * standing. Tooling that tried to detect and refuse it afterwards would be voiding a setting a person\n * chose on purpose, and it has nothing to catch besides: the guards constrain the AGENT, while the\n * prompt is answered by the HUMAN, who the agent cannot impersonate. So there is no second mechanism\n * downstream of this one — this report is the whole of what webpieces knows about Codex trust.\n */\n\n/** What one repo's Codex trust looks like right now. Data-only → a class, per CLAUDE.md. */\nexport class CodexTrustStatus {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n /** `~/.codex/config.toml`, named even when it does not exist so the report can point at it. */\n readonly configPath: string,\n readonly configExists: boolean,\n /** `[projects.\"<root>\"] trust_level = \"trusted\"`. */\n readonly projectTrusted: boolean,\n /** The repo's `.codex/hooks.json`, absolute — the key every `hooks.state` entry is prefixed by. */\n readonly hooksPath: string,\n /** How many webpieces-managed PreToolUse entries that file registers. 0 ⇒ Codex is not armed. */\n readonly registeredEntries: number,\n /** How many of them `~/.codex/config.toml` records a `trusted_hash` for. */\n readonly trustedEntries: number,\n ) {}\n\n /** True when Codex is armed here AND every entry it would run is trusted. */\n fullyTrusted(): boolean {\n return this.registeredEntries > 0 && this.trustedEntries >= this.registeredEntries && this.projectTrusted;\n }\n\n /** True when there is nothing to say — this repo has not armed Codex at all. */\n notArmed(): boolean {\n return this.registeredEntries === 0;\n }\n\n /**\n * The report, as lines. It states what IS, then the one action that changes it — and the action is\n * always the human's, because nothing here can be repaired by a tool.\n */\n lines(): readonly string[] {\n if (this.notArmed()) return [];\n if (this.fullyTrusted()) {\n return [` ✅ Codex trusts all ${String(this.registeredEntries)} webpieces hook(s) in ${this.hooksPath}`];\n }\n const out: string[] = [];\n out.push(` ⚠️ Codex has NOT yet trusted the webpieces hooks in ${this.hooksPath}`);\n if (!this.configExists) {\n out.push(` ${this.configPath} does not exist yet — Codex has never run for this user.`);\n } else if (!this.projectTrusted) {\n out.push(' this project is not marked trusted in ~/.codex/config.toml.');\n }\n if (this.trustedEntries < this.registeredEntries) {\n out.push(` ${String(this.trustedEntries)} of ${String(this.registeredEntries)} hook entries carry a trusted_hash.`);\n }\n out.push(' Fix: run `codex` in this repo and choose \"Trust all\" when it asks about hooks.');\n out.push(' Its third option (\"Continue without trusting\") leaves the session UNGUARDED for its');\n out.push(' whole life, and nothing later in the run reports that — this message is the only notice.');\n return out;\n }\n}\n\n/**\n * Reads Codex's trust state. Never writes, never throws — an unreadable or absent config reports as\n * \"not trusted\", which is the true and safe answer.\n */\nexport class CodexTrustProbe {\n /** `~/.codex/config.toml`. `homeDir` is injectable so a unit test never reads the real one. */\n configPath(homeDir: string = homedir()): string {\n return path.join(homeDir, '.codex', 'config.toml');\n }\n\n read(projectRoot: string, homeDir: string = homedir()): CodexTrustStatus {\n const configPath = this.configPath(homeDir);\n const hooksPath = path.join(projectRoot, ...CODEX_REGISTRATION.settingsFiles[0].split('/'));\n const registered = this.registeredEntryCount(hooksPath);\n const raw = this.readOrEmpty(configPath);\n return new CodexTrustStatus(\n configPath, raw !== null, this.projectTrusted(raw ?? '', projectRoot),\n hooksPath, registered, this.trustedEntryCount(raw ?? '', hooksPath),\n );\n }\n\n /**\n * How many webpieces-managed PreToolUse commands `.codex/hooks.json` registers.\n *\n * It counts what WE own, not every hook in the file: a consumer's own unrelated hook is none of this\n * check's business, and counting it would make the report say \"1 of 3 trusted\" about hooks webpieces\n * neither wrote nor can advise on.\n */\n private registeredEntryCount(hooksPath: string): number {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(hooksPath)) return 0;\n const entries: readonly HookEntry[] = readSettings(hooksPath).hooks?.PreToolUse ?? [];\n return entries.reduce(\n (total: number, entry: HookEntry): number =>\n total + entry.hooks.filter((h: HookCommand): boolean => isManagedCommand(h.command)).length,\n 0,\n );\n } catch (err: unknown) {\n const error = toError(err);\n void error; // an unreadable/invalid hooks.json is \"not armed\"; the drift check is what reports that\n return 0;\n }\n }\n\n private readOrEmpty(configPath: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : null;\n } catch (err: unknown) {\n const error = toError(err);\n void error; // unreadable → treated as absent, which reports as \"not trusted\"\n return null;\n }\n }\n\n /**\n * `[projects.\"<root>\"]` carrying `trust_level = \"trusted\"`.\n *\n * A LINE SCAN, not a TOML parser, and deliberately: this package must load on a tree too broken to\n * build a DI container, so it takes no dependency it does not need, and the two shapes it reads are\n * both written by Codex itself in one fixed form. It over-reports \"not trusted\" for anything it\n * cannot recognise, which is the safe direction — the consequence is one advisory line too many,\n * never a session reported as guarded when it is not.\n */\n private projectTrusted(raw: string, projectRoot: string): boolean {\n return this.sectionBody(raw, `projects.\"${projectRoot}\"`)\n .some((line: string): boolean => /^\\s*trust_level\\s*=\\s*\"trusted\"\\s*$/.test(line));\n }\n\n /**\n * How many `hooks.state.\"<hooksPath>:pre_tool_use:<group>:<index>\"` sections carry a `trusted_hash`.\n *\n * The count is compared against the number of entries the file registers rather than matched\n * one-for-one to a specific group/index: the group and index are Codex's own numbering of a file it\n * re-reads on every launch, and an installer that assumed a numbering would report a false alarm the\n * first time Codex renumbered.\n */\n private trustedEntryCount(raw: string, hooksPath: string): number {\n const prefix = `hooks.state.\"${hooksPath}:pre_tool_use:`;\n let trusted = 0;\n let inEntry = false;\n for (const line of raw.split('\\n')) {\n const header = this.headerOf(line);\n if (header !== null) inEntry = header.startsWith(prefix);\n else if (inEntry && /^\\s*trusted_hash\\s*=\\s*\"/.test(line)) trusted += 1;\n }\n return trusted;\n }\n\n /** The body lines of one TOML section, or [] when the file carries no such header. */\n private sectionBody(raw: string, wanted: string): readonly string[] {\n const body: string[] = [];\n let inside = false;\n for (const line of raw.split('\\n')) {\n const header = this.headerOf(line);\n if (header !== null) inside = header === wanted;\n else if (inside) body.push(line);\n }\n return body;\n }\n\n /** The `<name>` of a `[<name>]` header line, or null for anything else. */\n private headerOf(line: string): string | null {\n const match = /^\\s*\\[([^\\]]+)\\]\\s*$/.exec(line);\n return match === null ? null : match[1];\n }\n}\n"]}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AiType } from '../core/agent-event';
|
|
2
|
+
import type { ClaudeSettings } from './settings-shape';
|
|
2
3
|
/**
|
|
3
4
|
* THE INSTALLED HOOK SURFACE — two hooks per harness, all ABSOLUTE, and the ONE place their spelling is
|
|
4
5
|
* defined. See `HarnessRegistration` below, which is what makes "per harness" data rather than four
|
|
@@ -48,9 +49,10 @@ import { AiType } from '../core/agent-event';
|
|
|
48
49
|
*
|
|
49
50
|
* ─── Why the registration is a DRIFT SURFACE, not just an install step ─────────────────────────────
|
|
50
51
|
* Nothing used to validate `.claude/settings.json` at all, so a settings file left on a superseded form
|
|
51
|
-
* silently changed who governs. The installed surface is
|
|
52
|
-
*
|
|
53
|
-
*
|
|
52
|
+
* silently changed who governs. The installed surface is FOUR things (ai-hook.sh, the registration, the
|
|
53
|
+
* managed `env` entry — see managed-env.ts — and the ANCHORING of the NEIGHBOUR hook commands the
|
|
54
|
+
* consumer registers beside ours, see neighbour-hooks.ts), all four are compared against this release,
|
|
55
|
+
* and `wp-upgrade-shim` repairs all four. A cure that fixes three of four is worse than no cure, because
|
|
54
56
|
* it reports success.
|
|
55
57
|
*/
|
|
56
58
|
/**
|
|
@@ -82,27 +84,14 @@ export declare class HookRegistrationEntry {
|
|
|
82
84
|
constructor(matcher: string, command: string);
|
|
83
85
|
sameAs(other: HookRegistrationEntry): boolean;
|
|
84
86
|
}
|
|
85
|
-
export interface HookCommand {
|
|
86
|
-
type: string;
|
|
87
|
-
command: string;
|
|
88
|
-
}
|
|
89
|
-
export interface HookEntry {
|
|
90
|
-
matcher: string;
|
|
91
|
-
hooks: HookCommand[];
|
|
92
|
-
}
|
|
93
87
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
88
|
+
* THE SETTINGS-FILE SHAPE lives in `./settings-shape`, a LEAF module, and is imported from THERE by
|
|
89
|
+
* everything that needs it — this module included. It is deliberately NOT re-exported here: a second
|
|
90
|
+
* import path for one type is the "two spellings of one thing" shim the compatibility policy rejects,
|
|
91
|
+
* and it would keep advertising `hook-registration` as a source of the shape, which is precisely the
|
|
92
|
+
* edge that has to stay broken (`neighbour-hooks.ts` needs the shape, this module imports the repairs
|
|
93
|
+
* FROM it, and `validate-no-file-import-cycles` counts even a type-only edge back as a cycle).
|
|
98
94
|
*/
|
|
99
|
-
export interface ClaudeSettings {
|
|
100
|
-
hooks?: {
|
|
101
|
-
PreToolUse?: HookEntry[];
|
|
102
|
-
};
|
|
103
|
-
env?: Record<string, string>;
|
|
104
|
-
[key: string]: unknown;
|
|
105
|
-
}
|
|
106
95
|
export declare const RULES_BIN = "wp-ai-rules-hook";
|
|
107
96
|
export declare const GUARDS_BIN = "wp-ai-guards-hook";
|
|
108
97
|
/**
|
|
@@ -225,6 +214,15 @@ export declare class HarnessRegistration {
|
|
|
225
214
|
entryFor(bin: string): HookRegistrationEntry;
|
|
226
215
|
/** This harness's settings files under one repo root, absolute. */
|
|
227
216
|
settingsPaths(projectRoot: string): readonly string[];
|
|
217
|
+
/**
|
|
218
|
+
* What `managedSurfaceDrift()` calls this harness's NEIGHBOUR hook commands — the entries a CONSUMER
|
|
219
|
+
* repo registers in the same file — when one of them still carries a repo-RELATIVE entry path.
|
|
220
|
+
*
|
|
221
|
+
* DERIVED rather than a constructor field, unlike `registrationSurface` beside it, because there is
|
|
222
|
+
* nothing here a harness could sensibly disagree about: it is the same file, said a second way. A
|
|
223
|
+
* constructor param would be a second place to keep in step for no decision.
|
|
224
|
+
*/
|
|
225
|
+
get neighbourSurface(): string;
|
|
228
226
|
}
|
|
229
227
|
export declare const CLAUDE_REGISTRATION: HarnessRegistration;
|
|
230
228
|
/**
|
|
@@ -330,6 +328,16 @@ export declare function applyManagedEnv(settings: ClaudeSettings): boolean;
|
|
|
330
328
|
export declare function repairRegistration(harness: HarnessRegistration, settings: ClaudeSettings): boolean;
|
|
331
329
|
export declare function readSettings(settingsPath: string): ClaudeSettings;
|
|
332
330
|
export declare function writeSettings(settingsPath: string, settings: ClaudeSettings): void;
|
|
331
|
+
/**
|
|
332
|
+
* True when any settings file ONE HARNESS owns under `root` carries a NEIGHBOUR hook — one the consumer
|
|
333
|
+
* repo wrote — whose entry path is repo-RELATIVE and would therefore fail to resolve from any cwd but
|
|
334
|
+
* the root. See neighbour-hooks.ts for the measured failure and why webpieces owns the repair.
|
|
335
|
+
*
|
|
336
|
+
* Judged ONLY where the file registers webpieces hooks, exactly like `registrationStaleAt` below: a
|
|
337
|
+
* settings file with no webpieces hooks in it is not a webpieces install, and webpieces rewriting
|
|
338
|
+
* somebody's unrelated hook lines there would be editing a file it was never given.
|
|
339
|
+
*/
|
|
340
|
+
export declare function neighbourHooksStaleAt(harness: HarnessRegistration, root: string | null): boolean;
|
|
333
341
|
/**
|
|
334
342
|
* True when any settings file ONE HARNESS owns under `root` carries a stale registration.
|
|
335
343
|
*
|
|
@@ -361,7 +369,25 @@ export declare class SettingsRepair {
|
|
|
361
369
|
readonly settingsPath: string;
|
|
362
370
|
readonly registration: boolean;
|
|
363
371
|
readonly env: boolean;
|
|
364
|
-
|
|
372
|
+
/**
|
|
373
|
+
* The NEW spelling of every NEIGHBOUR hook command this repair anchored — the consumer's own
|
|
374
|
+
* entries, which webpieces rewrites but does not author.
|
|
375
|
+
*
|
|
376
|
+
* The commands themselves rather than a count or a flag, because this is the one repair that
|
|
377
|
+
* edits lines webpieces did not write: the cure has to be able to show the consumer exactly what
|
|
378
|
+
* it changed in their file, or it is a silent edit to somebody else's hooks.
|
|
379
|
+
*/
|
|
380
|
+
readonly anchoredNeighbours: readonly string[];
|
|
381
|
+
constructor(settingsPath: string, registration: boolean, env: boolean,
|
|
382
|
+
/**
|
|
383
|
+
* The NEW spelling of every NEIGHBOUR hook command this repair anchored — the consumer's own
|
|
384
|
+
* entries, which webpieces rewrites but does not author.
|
|
385
|
+
*
|
|
386
|
+
* The commands themselves rather than a count or a flag, because this is the one repair that
|
|
387
|
+
* edits lines webpieces did not write: the cure has to be able to show the consumer exactly what
|
|
388
|
+
* it changed in their file, or it is a silent edit to somebody else's hooks.
|
|
389
|
+
*/
|
|
390
|
+
anchoredNeighbours: readonly string[]);
|
|
365
391
|
}
|
|
366
392
|
/**
|
|
367
393
|
* Rewrite every stale settings file under `root`, for EVERY harness; returns what changed, per file.
|
|
@@ -13,6 +13,7 @@ exports.applyManagedEnv = applyManagedEnv;
|
|
|
13
13
|
exports.repairRegistration = repairRegistration;
|
|
14
14
|
exports.readSettings = readSettings;
|
|
15
15
|
exports.writeSettings = writeSettings;
|
|
16
|
+
exports.neighbourHooksStaleAt = neighbourHooksStaleAt;
|
|
16
17
|
exports.registrationStaleAt = registrationStaleAt;
|
|
17
18
|
exports.envStaleAt = envStaleAt;
|
|
18
19
|
exports.repairRegistrationAt = repairRegistrationAt;
|
|
@@ -22,6 +23,7 @@ const fs = tslib_1.__importStar(require("fs"));
|
|
|
22
23
|
const path = tslib_1.__importStar(require("path"));
|
|
23
24
|
const to_error_1 = require("../core/to-error");
|
|
24
25
|
const shim_1 = require("./shim");
|
|
26
|
+
const neighbour_hooks_1 = require("./neighbour-hooks");
|
|
25
27
|
const managed_env_1 = require("./managed-env");
|
|
26
28
|
/**
|
|
27
29
|
* THE INSTALLED HOOK SURFACE — two hooks per harness, all ABSOLUTE, and the ONE place their spelling is
|
|
@@ -72,9 +74,10 @@ const managed_env_1 = require("./managed-env");
|
|
|
72
74
|
*
|
|
73
75
|
* ─── Why the registration is a DRIFT SURFACE, not just an install step ─────────────────────────────
|
|
74
76
|
* Nothing used to validate `.claude/settings.json` at all, so a settings file left on a superseded form
|
|
75
|
-
* silently changed who governs. The installed surface is
|
|
76
|
-
*
|
|
77
|
-
*
|
|
77
|
+
* silently changed who governs. The installed surface is FOUR things (ai-hook.sh, the registration, the
|
|
78
|
+
* managed `env` entry — see managed-env.ts — and the ANCHORING of the NEIGHBOUR hook commands the
|
|
79
|
+
* consumer registers beside ours, see neighbour-hooks.ts), all four are compared against this release,
|
|
80
|
+
* and `wp-upgrade-shim` repairs all four. A cure that fixes three of four is worse than no cure, because
|
|
78
81
|
* it reports success.
|
|
79
82
|
*/
|
|
80
83
|
/**
|
|
@@ -112,6 +115,14 @@ class HookRegistrationEntry {
|
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
exports.HookRegistrationEntry = HookRegistrationEntry;
|
|
118
|
+
/**
|
|
119
|
+
* THE SETTINGS-FILE SHAPE lives in `./settings-shape`, a LEAF module, and is imported from THERE by
|
|
120
|
+
* everything that needs it — this module included. It is deliberately NOT re-exported here: a second
|
|
121
|
+
* import path for one type is the "two spellings of one thing" shim the compatibility policy rejects,
|
|
122
|
+
* and it would keep advertising `hook-registration` as a source of the shape, which is precisely the
|
|
123
|
+
* edge that has to stay broken (`neighbour-hooks.ts` needs the shape, this module imports the repairs
|
|
124
|
+
* FROM it, and `validate-no-file-import-cycles` counts even a type-only edge back as a cycle).
|
|
125
|
+
*/
|
|
115
126
|
exports.RULES_BIN = 'wp-ai-rules-hook';
|
|
116
127
|
exports.GUARDS_BIN = 'wp-ai-guards-hook';
|
|
117
128
|
/**
|
|
@@ -223,6 +234,17 @@ class HarnessRegistration {
|
|
|
223
234
|
settingsPaths(projectRoot) {
|
|
224
235
|
return this.settingsFiles.map((file) => path.join(projectRoot, ...file.split('/')));
|
|
225
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* What `managedSurfaceDrift()` calls this harness's NEIGHBOUR hook commands — the entries a CONSUMER
|
|
239
|
+
* repo registers in the same file — when one of them still carries a repo-RELATIVE entry path.
|
|
240
|
+
*
|
|
241
|
+
* DERIVED rather than a constructor field, unlike `registrationSurface` beside it, because there is
|
|
242
|
+
* nothing here a harness could sensibly disagree about: it is the same file, said a second way. A
|
|
243
|
+
* constructor param would be a second place to keep in step for no decision.
|
|
244
|
+
*/
|
|
245
|
+
get neighbourSurface() {
|
|
246
|
+
return `${this.settingsFiles[0]} ${neighbour_hooks_1.NEIGHBOUR_SURFACE_SUFFIX}`;
|
|
247
|
+
}
|
|
226
248
|
}
|
|
227
249
|
exports.HarnessRegistration = HarnessRegistration;
|
|
228
250
|
exports.CLAUDE_REGISTRATION = new HarnessRegistration('claude-code', 'Claude Code', 'Write|Edit|MultiEdit', 'Write|Edit|MultiEdit|Bash|Read', ['.claude/settings.json', '.claude/settings.local.json'], '$CLAUDE_PROJECT_DIR', true, '.claude/settings.json hook registration');
|
|
@@ -448,6 +470,32 @@ function writeSettings(settingsPath, settings) {
|
|
|
448
470
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
449
471
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
450
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* True when any settings file ONE HARNESS owns under `root` carries a NEIGHBOUR hook — one the consumer
|
|
475
|
+
* repo wrote — whose entry path is repo-RELATIVE and would therefore fail to resolve from any cwd but
|
|
476
|
+
* the root. See neighbour-hooks.ts for the measured failure and why webpieces owns the repair.
|
|
477
|
+
*
|
|
478
|
+
* Judged ONLY where the file registers webpieces hooks, exactly like `registrationStaleAt` below: a
|
|
479
|
+
* settings file with no webpieces hooks in it is not a webpieces install, and webpieces rewriting
|
|
480
|
+
* somebody's unrelated hook lines there would be editing a file it was never given.
|
|
481
|
+
*/
|
|
482
|
+
// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings
|
|
483
|
+
function neighbourHooksStaleAt(harness, root) {
|
|
484
|
+
if (root === null)
|
|
485
|
+
return false;
|
|
486
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
487
|
+
try {
|
|
488
|
+
return harness.settingsPaths(root).some((p) => {
|
|
489
|
+
const settings = readSettings(p);
|
|
490
|
+
return registeredBins(settings).length > 0 && (0, neighbour_hooks_1.neighbourHooksStale)(harness.shimAnchor, settings, root);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
const error = (0, to_error_1.toError)(err);
|
|
495
|
+
void error; // best-effort: unreadable/invalid settings counts as "not stale" so it never wedges a call
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
451
499
|
/**
|
|
452
500
|
* True when any settings file ONE HARNESS owns under `root` carries a stale registration.
|
|
453
501
|
*
|
|
@@ -505,10 +553,21 @@ class SettingsRepair {
|
|
|
505
553
|
settingsPath;
|
|
506
554
|
registration;
|
|
507
555
|
env;
|
|
508
|
-
|
|
556
|
+
anchoredNeighbours;
|
|
557
|
+
constructor(settingsPath, registration, env,
|
|
558
|
+
/**
|
|
559
|
+
* The NEW spelling of every NEIGHBOUR hook command this repair anchored — the consumer's own
|
|
560
|
+
* entries, which webpieces rewrites but does not author.
|
|
561
|
+
*
|
|
562
|
+
* The commands themselves rather than a count or a flag, because this is the one repair that
|
|
563
|
+
* edits lines webpieces did not write: the cure has to be able to show the consumer exactly what
|
|
564
|
+
* it changed in their file, or it is a silent edit to somebody else's hooks.
|
|
565
|
+
*/
|
|
566
|
+
anchoredNeighbours) {
|
|
509
567
|
this.settingsPath = settingsPath;
|
|
510
568
|
this.registration = registration;
|
|
511
569
|
this.env = env;
|
|
570
|
+
this.anchoredNeighbours = anchoredNeighbours;
|
|
512
571
|
}
|
|
513
572
|
}
|
|
514
573
|
exports.SettingsRepair = SettingsRepair;
|
|
@@ -529,10 +588,18 @@ function repairRegistrationAt(root) {
|
|
|
529
588
|
const settings = readSettings(settingsPath);
|
|
530
589
|
const neededRegistration = registrationStale(harness, settings);
|
|
531
590
|
const neededEnv = harness.managesEnv && envStale(settings);
|
|
532
|
-
|
|
591
|
+
const changedManaged = repairRegistration(harness, settings);
|
|
592
|
+
// AFTER the managed repair, never before: repairRegistration() removes the retired
|
|
593
|
+
// guarantee-root entry and rewrites the two managed commands, so by the time the neighbour
|
|
594
|
+
// pass runs there is nothing webpieces-owned left for it to look at. Gated on
|
|
595
|
+
// registeredBins() for the same reason every other judgement here is — a settings file that
|
|
596
|
+
// registers no webpieces hooks is not a webpieces install, and rewriting somebody's
|
|
597
|
+
// unrelated hook lines in it would be webpieces editing a file it was never given.
|
|
598
|
+
const anchored = registeredBins(settings).length === 0 ? [] : (0, neighbour_hooks_1.anchorNeighbourHooks)(harness.shimAnchor, settings, root);
|
|
599
|
+
if (!changedManaged && anchored.length === 0)
|
|
533
600
|
continue;
|
|
534
601
|
writeSettings(settingsPath, settings);
|
|
535
|
-
repairs.push(new SettingsRepair(settingsPath, neededRegistration, neededEnv));
|
|
602
|
+
repairs.push(new SettingsRepair(settingsPath, neededRegistration, neededEnv, anchored));
|
|
536
603
|
}
|
|
537
604
|
}
|
|
538
605
|
return repairs;
|
|
@@ -573,6 +640,13 @@ function managedSurfaceDrift(root) {
|
|
|
573
640
|
for (const harness of exports.HARNESS_REGISTRATIONS) {
|
|
574
641
|
if (registrationStaleAt(harness, root))
|
|
575
642
|
drifted.push(harness.registrationSurface);
|
|
643
|
+
// A neighbour hook registered with a RELATIVE entry path dies from any cwd but the root, and per
|
|
644
|
+
// the hooks reference that non-zero exit is a NON-BLOCKING error — a SILENT UNGUARDED ALLOW. It
|
|
645
|
+
// is a drift surface for exactly the reason the managed registration is one: nothing else in
|
|
646
|
+
// this repo can see it, and the way it fails looks like a guard that PASSED. See
|
|
647
|
+
// neighbour-hooks.ts, and issue #852 where it silently disarmed three security guards.
|
|
648
|
+
if (neighbourHooksStaleAt(harness, root))
|
|
649
|
+
drifted.push(harness.neighbourSurface);
|
|
576
650
|
}
|
|
577
651
|
if (envStaleAt(root))
|
|
578
652
|
drifted.push(exports.ENV_SURFACE);
|