@holmes-lab/holmes-kit 0.1.0

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.
Files changed (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,1262 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_SPECS_DIR = void 0;
37
+ exports.resolveRmSignals = resolveRmSignals;
38
+ exports.readApprovalFromEnv = readApprovalFromEnv;
39
+ exports.dumpsEnvInCommandPosition = dumpsEnvInCommandPosition;
40
+ exports.normalizeHookInput = normalizeHookInput;
41
+ exports.decideOnGateError = decideOnGateError;
42
+ exports.isGovernedProject = isGovernedProject;
43
+ exports.gateErrorDecision = gateErrorDecision;
44
+ exports.wiredSpecsDir = wiredSpecsDir;
45
+ exports.evaluateHook = evaluateHook;
46
+ exports.readSpecsSync = readSpecsSync;
47
+ // @implements A-SPEC-194, A-SPEC-195
48
+ const registry_1 = require("../messages/registry");
49
+ const root_1 = require("../project/root");
50
+ const fs = __importStar(require("node:fs"));
51
+ const path = __importStar(require("node:path"));
52
+ const node_child_process_1 = require("node:child_process");
53
+ const phase_1 = require("../guardrail/phase");
54
+ const spec_parser_1 = require("../spec/spec-parser");
55
+ const spec_digest_1 = require("../spec/spec-digest");
56
+ const constitution_debt_1 = require("../governance/constitution-debt");
57
+ const tspec_state_1 = require("../guardrail/tspec-state");
58
+ const identity_1 = require("../governance/identity");
59
+ const role_policy_1 = require("../governance/role-policy");
60
+ const risk_classifier_1 = require("../guardrail/risk-classifier");
61
+ const write_target_1 = require("../guardrail/write-target");
62
+ const anchors_1 = require("../guardrail/anchors");
63
+ const governance_history_1 = require("../guardrail/governance-history");
64
+ const rtm_check_1 = require("../rtm/rtm-check");
65
+ const ledger_store_1 = require("../governance/ledger-store");
66
+ const provenance_chain_1 = require("../governance/provenance-chain");
67
+ const risk_gate_1 = require("../guardrail/risk-gate");
68
+ const approval_blockers_1 = require("../spec/approval-blockers");
69
+ // Resolve per-rm-target git facts (H1 git-aware guardrail): is each recursive-rm target a
70
+ // regenerable build artifact (gitignored), version-controlled source (tracked), or escaping the
71
+ // project? This is the I/O half — the classifier stays pure and merely consumes these signals.
72
+ //
73
+ // OPTIONAL BY DESIGN: git is a REFINEMENT layer, never a requirement. Outside a git repo (or with git
74
+ // unavailable), this returns undefined and the classifier falls back to its git-independent floor
75
+ // (catastrophic targets) + static name-allowlist — i.e. exactly the pre-H1 behavior. So the guardrail
76
+ // works fully in non-git / air-gapped contexts; git only SHARPENS the verdict when present.
77
+ // FAIL-OPEN and shell-injection-safe: uses execFileSync (arg arrays, no shell); any failure (not a
78
+ // git repo, git missing, odd target) yields no signal for that target. Metachar/tilde targets are
79
+ // skipped (the classifier's catastrophic floor handles them and they must never reach a subprocess).
80
+ function resolveRmSignals(command, cwd) {
81
+ const targets = (0, risk_classifier_1.rmCommandTargets)(command);
82
+ if (targets.length === 0)
83
+ return undefined;
84
+ let repoRoot;
85
+ let baseReal;
86
+ try {
87
+ repoRoot = (0, node_child_process_1.execFileSync)('git', ['rev-parse', '--show-toplevel'], { cwd, stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() })
88
+ .toString().trim();
89
+ // git realpaths the toplevel (resolves symlinks like macOS /var -> /private/var); resolve the
90
+ // cwd the same way so in-project containment comparisons don't spuriously fail on a symlinked path.
91
+ baseReal = fs.realpathSync(cwd);
92
+ }
93
+ catch {
94
+ return undefined; // not a git repo / git unavailable -> static fallback
95
+ }
96
+ const signals = {};
97
+ for (const raw of targets) {
98
+ const unq = raw.replace(/^["']/, '').replace(/["']$/, '');
99
+ if (!unq || unq.startsWith('~') || /[^A-Za-z0-9._/-]/.test(unq))
100
+ continue; // floor handles these; never shell them
101
+ let resolved = path.resolve(baseReal, unq);
102
+ // Resolve symlinks on the real target so a lexically-in-project path that SYMLINKS outside the
103
+ // repo (e.g. `rm -rf link` where link -> /etc) is correctly seen as escaping (inProject=false →
104
+ // dangerous). path.resolve alone is lexical and would be fooled. Non-existent targets keep the
105
+ // lexical path (nothing to delete yet); realpath errors fail safe to the lexical value.
106
+ try {
107
+ if (fs.existsSync(resolved))
108
+ resolved = fs.realpathSync(resolved);
109
+ }
110
+ catch { /* keep lexical */ }
111
+ const inProject = resolved === repoRoot || resolved.startsWith(repoRoot + path.sep);
112
+ let gitIgnored = false;
113
+ let tracked = false;
114
+ try {
115
+ (0, node_child_process_1.execFileSync)('git', ['check-ignore', '-q', '--', unq], { cwd, stdio: 'ignore', env: (0, root_1.cleanSubprocessEnv)() });
116
+ gitIgnored = true;
117
+ }
118
+ catch { /* exit!=0 -> not ignored/unknown */ }
119
+ try {
120
+ (0, node_child_process_1.execFileSync)('git', ['ls-files', '--error-unmatch', '--', unq], { cwd, stdio: 'ignore', env: (0, root_1.cleanSubprocessEnv)() });
121
+ tracked = true;
122
+ }
123
+ catch { /* not tracked */ }
124
+ signals[raw] = { gitIgnored, tracked, inProject };
125
+ }
126
+ return Object.keys(signals).length ? signals : undefined;
127
+ }
128
+ // Reads the out-of-band approval marker from HOLMES_APPROVAL. The AI session driving this
129
+ // hook process cannot set its own environment, so this env var is an out-of-band channel —
130
+ // a malformed/absent value must resolve to "no approval", never crash the hook (fail-closed
131
+ // for hard-hitl means the ABSENCE of a valid approval denies; a parse error is absence).
132
+ /**
133
+ * @implements A-SPEC-149
134
+ * The out-of-band approval channel, read at the ENTRY only. `evaluateHook` no longer calls this on
135
+ * its own — an exported HOLMES_APPROVAL used to reach the decision directly and turn a denial into
136
+ * permission with nothing recording that it had. Exported so the entry's contract (parse, and treat
137
+ * malformed input as absent) is testable at the layer that owns it.
138
+ */
139
+ function readApprovalFromEnv() {
140
+ const raw = process.env.HOLMES_APPROVAL;
141
+ if (!raw)
142
+ return undefined;
143
+ try {
144
+ return JSON.parse(raw);
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ }
150
+ /**
151
+ * True when `env`/`printenv` runs as an actual COMMAND (an environment dump) rather than appearing as
152
+ * a mere word — `grep -rn env src/` and `git commit -m "set the env"` must stay allowed, while
153
+ * `env`, `/usr/bin/env`, `X=1 env`, `command env`, `(env)`, `sh -c 'env'` must not. Each segment is
154
+ * stripped of leading assignments/wrappers and, for a nested shell, its `-c` body is re-examined.
155
+ * `env FOO=1 cmd` is a RUNNER (assignment then command), not a dump.
156
+ */
157
+ function dumpsEnvInCommandPosition(command, depth = 0) {
158
+ if (depth > 3)
159
+ return false;
160
+ for (const rawSeg of command.split(/[;&|\n]+/)) {
161
+ let seg = rawSeg.trim().replace(/^[({\s]+/, '').replace(/[)}\s]+$/, '');
162
+ // strip leading VAR=value assignments and neutral wrappers
163
+ for (;;) {
164
+ const next = seg.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S*)\s+|(?:command|eval|exec|builtin|nohup|time|sudo)\s+)/, '');
165
+ if (next === seg)
166
+ break;
167
+ seg = next;
168
+ }
169
+ const tokens = seg.split(/\s+/).filter(Boolean);
170
+ if (tokens.length === 0)
171
+ continue;
172
+ if (tokens[0].startsWith('#'))
173
+ continue; // comment / shebang line
174
+ const head = tokens[0].replace(/^['"]|['"]$/g, '').replace(/^.*\//, ''); // /usr/bin/env -> env
175
+ if (head === 'printenv')
176
+ return true;
177
+ if (head === 'env') {
178
+ // POSIX: `env [-flags] [NAME=VAL...] [command]`. With a COMMAND it is a runner, not a dump —
179
+ // `#!/usr/bin/env node` and `env FOO=1 node x` must stay allowed (dogfooding caught this
180
+ // over-block: the shebang is the most common line in a Node project). Only a bare `env`, or
181
+ // one carrying just flags/assignments, actually prints the environment.
182
+ const runsCommand = tokens.slice(1).some((t) => !t.startsWith('-') && !t.includes('='));
183
+ if (!runsCommand)
184
+ return true;
185
+ }
186
+ if (/^(?:sh|bash|zsh|ksh|dash)$/.test(head)) {
187
+ // nested shell: inspect the -c body (quotes already split away by the tokenizer)
188
+ const ci = tokens.findIndex((t) => /^-\w*c$/.test(t.replace(/^-+/, '-')));
189
+ if (ci >= 0) {
190
+ const body = seg.slice(seg.indexOf(tokens[ci]) + tokens[ci].length).trim().replace(/^['"]|['"]$/g, '');
191
+ if (dumpsEnvInCommandPosition(body, depth + 1))
192
+ return true;
193
+ }
194
+ }
195
+ }
196
+ return false;
197
+ }
198
+ // Does this Write/Edit promote a spec to status:approved? Uses the SAME YAML loader as downstream
199
+ // (parseSpec) on a full Write body so quoting/comments (`status: "approved"`, `status: approved # x`)
200
+ // cannot slip a bespoke regex (verification review — that reopened the self-approval bypass). An Edit
201
+ // carries only a fragment (not a parseable document), so a quote/comment-tolerant regex backs it up.
202
+ function incomingSetsApproved(ti) {
203
+ if (typeof ti.content === 'string' && ti.content.trim()) {
204
+ try {
205
+ if ((0, spec_parser_1.parseSpec)(ti.content).status === 'approved')
206
+ return true;
207
+ }
208
+ catch { /* fall through to regex */ }
209
+ }
210
+ const text = `${ti.content ?? ''}\n${ti.new_string ?? ''}`;
211
+ return /^\s*status\s*:\s*["']?approved["']?\s*(?:#.*)?$/im.test(text);
212
+ }
213
+ function normalizeHookInput(raw) {
214
+ const str = (v) => (typeof v === 'string' ? v : undefined);
215
+ if (!raw || typeof raw !== 'object')
216
+ return { tool_name: '', tool_input: {} };
217
+ const r = raw;
218
+ const tiRaw = r.tool_input;
219
+ const tiIsObject = !!tiRaw && typeof tiRaw === 'object' && !Array.isArray(tiRaw);
220
+ const ti = (tiIsObject ? tiRaw : {});
221
+ // Present-but-wrong-typed is not the same as absent. A path that arrived as an array or an object
222
+ // is a request the gate cannot evaluate, and REQ-144 fixed the direction that must take.
223
+ const malformed = !tiIsObject
224
+ || ['file_path', 'notebook_path'].some((f) => f in ti && typeof ti[f] !== 'string');
225
+ const out = {};
226
+ // Assigned individually so an undefined never becomes a present-but-undefined key downstream.
227
+ const fp = str(ti.file_path);
228
+ if (fp !== undefined)
229
+ out.file_path = fp;
230
+ const c = str(ti.content);
231
+ if (c !== undefined)
232
+ out.content = c;
233
+ const ns = str(ti.new_string);
234
+ if (ns !== undefined)
235
+ out.new_string = ns;
236
+ const cmd = str(ti.command);
237
+ if (cmd !== undefined)
238
+ out.command = cmd;
239
+ // @implements A-SPEC-163 — a path field the gate does not preserve is a path the gate cannot see,
240
+ // which is how a NotebookEdit reached protected files unexamined.
241
+ const nb = str(ti.notebook_path);
242
+ if (nb !== undefined)
243
+ out.notebook_path = nb;
244
+ return { tool_name: str(r.tool_name) ?? '', tool_input: out, ...(malformed ? { malformed: true } : {}) };
245
+ }
246
+ /**
247
+ * Which way to fail when the gate itself throws.
248
+ *
249
+ * @implements A-SPEC-144
250
+ * The entry point used to answer `allow` unconditionally, with the comment "to avoid blocking the
251
+ * workflow". The tension is real — an unrelated bug that denies every tool call bricks a session —
252
+ * but resolving it toward a silent grant makes every future bug in the gate a bypass instead of a
253
+ * visible failure, which is the opposite of the posture the rest of this codebase takes.
254
+ *
255
+ * So: a governed project DENIES, because a gate that cannot evaluate must not grant (the same rule
256
+ * REQ-141 applied to the ledger). An ungoverned project ALLOWS, because there was no policy to apply
257
+ * and inventing a denial there is its own failure mode. And an operator — never the session — can
258
+ * escape via `HOLMES_GATE_BYPASS`.
259
+ */
260
+ function decideOnGateError(opts) {
261
+ if (opts.bypass) {
262
+ return { permissionDecision: 'allow', permissionDecisionReason: `[Holmes-Kit] 내부 오류를 HOLMES_GATE_BYPASS로 우회했습니다: ${opts.message}` };
263
+ }
264
+ if (!opts.governed)
265
+ return { permissionDecision: 'allow' };
266
+ // Names an INTERNAL ERROR on purpose. Imitating a policy denial would send an operator to fix a
267
+ // spec chain that was never the problem.
268
+ return {
269
+ permissionDecision: 'deny',
270
+ permissionDecisionReason: `[Holmes-Kit] 내부 오류로 판정할 수 없습니다 — 게이트는 판정 불가 시 허용하지 않습니다: ${opts.message}`
271
+ + ' → `holmes-kit doctor`로 설치를 점검하세요. 운영자 판단으로 통과시키려면 환경에 HOLMES_GATE_BYPASS=1을 설정하십시오(세션이 스스로 설정할 수 없는 대역외 채널).',
272
+ };
273
+ }
274
+ /**
275
+ * Is this project governed? Computed on the ERROR path, so it must not re-run the logic that just
276
+ * failed: presence of a configured spec root or a non-empty spec directory, nothing more. Any
277
+ * problem reading it means NOT governed — the conservative direction here is the one that keeps an
278
+ * unrelated failure from bricking a project that never asked for gating.
279
+ */
280
+ function isGovernedProject(specsDir, env = process.env) {
281
+ try {
282
+ if (env.HOLMES_SPECS)
283
+ return true;
284
+ const stack = [specsDir];
285
+ while (stack.length) {
286
+ const d = stack.pop();
287
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
288
+ if (e.isDirectory())
289
+ stack.push(path.join(d, e.name));
290
+ else if (e.name.endsWith('.md'))
291
+ return true;
292
+ }
293
+ }
294
+ return false;
295
+ }
296
+ catch {
297
+ return false;
298
+ }
299
+ }
300
+ /**
301
+ * The whole error-path decision, assembled from an environment.
302
+ *
303
+ * @implements A-SPEC-144
304
+ * Exported and env-injected so the WIRING is tested rather than trusted. Mutation testing found the
305
+ * gap: hardcoding `bypass: true` and inverting the governance check both left every test green,
306
+ * because the tests exercised the pure helpers directly and nothing exercised the code that reads
307
+ * the environment. An untested wire is where a fix quietly stops applying.
308
+ */
309
+ function gateErrorDecision(message, env = process.env, argv = process.argv, projectRoot) {
310
+ // @implements A-SPEC-190 §11 (round 9) — the ERROR path must read the same wiring the normal path
311
+ // does. `--specs` reached `evaluateHook` only, so a project wired with `--specs-dir docs/specs`
312
+ // looked UNGOVERNED the moment the gate hit an internal error — and 'not governed' is the branch
313
+ // that fails OPEN. The one path where being wrong is most expensive was the one still guessing.
314
+ //
315
+ // @implements A-SPEC-191 §17 (round 11) — and against the PROJECT's tree, not the shell's: a
316
+ // subdirectory session hit this same fail-open on the error path too (measured with a malformed
317
+ // payload). The caller passes the resolved root; the default keeps the old shape for callers that
318
+ // have none.
319
+ const wired = wiredSpecsDir(argv, env);
320
+ const specsDir = path.isAbsolute(wired) || projectRoot === undefined ? wired : path.join(projectRoot, wired);
321
+ return decideOnGateError({
322
+ governed: isGovernedProject(specsDir, env),
323
+ // Environment ONLY. The session authors the payload and the operator authors the environment,
324
+ // so an agent cannot grant itself relief from a gate it happens to be able to crash.
325
+ bypass: !!env.HOLMES_GATE_BYPASS,
326
+ message,
327
+ });
328
+ }
329
+ /**
330
+ * @implements A-SPEC-190 (round 8)
331
+ * Which spec directory this hook enforces against. The wiring writes it on the hook's own command
332
+ * line because the hook entry in settings carries no environment: `init --specs-dir docs/specs`
333
+ * reached the MCP server (HOLMES_SPECS in .mcp.json) and NOT the gate, so a project could be wired
334
+ * `governed` while the gate read an empty `.ax/specs` and refused nothing. Precedence is
335
+ * argv > env > default: the wiring is more specific than an ambient variable.
336
+ */
337
+ function wiredSpecsDir(argv, env) {
338
+ const i = argv.indexOf('--specs');
339
+ const fromArgv = i >= 0 && argv[i + 1] ? argv[i + 1] : undefined;
340
+ return fromArgv ?? (env.HOLMES_SPECS || undefined) ?? exports.DEFAULT_SPECS_DIR;
341
+ }
342
+ /** Default spec root. Used to tell "never governed" apart from an explicitly configured HOLMES_SPECS. */
343
+ exports.DEFAULT_SPECS_DIR = '.ax/specs';
344
+ // @implements A-SPEC-100.2
345
+ function evaluateHook(input, specsDir, opts) {
346
+ // @implements A-SPEC-133 — resolved once so every gate in this call shares one timestamp.
347
+ const nowTs = opts?.now ?? new Date().toISOString();
348
+ const ledgerFile = path.join(opts.projectRoot, '.ax', 'ledger', 'provenance.jsonl');
349
+ // @implements A-SPEC-125.4
350
+ // Bash/shell risk gate: this branch runs BEFORE the Write/Edit phaseCheck logic below and
351
+ // is intentionally fail-CLOSED — a classifier throw on the Bash surface denies, never
352
+ // allows, unlike the fail-open Write/Edit path further down.
353
+ // Case-insensitive tool_name match: an exact 'Bash' check would let a differently-cased
354
+ // 'bash'/'BASH' invocation fall through to the unconditional allow below, bypassing the
355
+ // whole gate on a hard-hitl command. A safety gate must not hinge on casing (defense in
356
+ // depth — the harness normally sets tool_name, but the gate defends regardless).
357
+ if ((input.tool_name ?? '').toLowerCase() === 'bash' && typeof input.tool_input.command === 'string' && input.tool_input.command.length > 0) {
358
+ const command = input.tool_input.command;
359
+ // Git-aware rm signals (H1): resolved only when a resolver is injected (the CLI wires the real
360
+ // git-backed one; unit tests omit it and exercise the pure static path). Fail-open: a throwing
361
+ // resolver must never block a command, so a resolution error degrades to no signals.
362
+ let rmTargetSignals;
363
+ try {
364
+ rmTargetSignals = opts?.resolveRmSignals?.(command);
365
+ }
366
+ catch {
367
+ rmTargetSignals = undefined;
368
+ }
369
+ // projectRoot scopes the protected-`.ax` rules to THIS repository's governance directory; without
370
+ // it the substring match gated `.ax` paths in any other project on the machine.
371
+ const action = {
372
+ kind: 'shell', target: command, command, rmTargetSignals,
373
+ projectRoot: opts.projectRoot.replace(/\\/g, '/').replace(/\/+$/, ''),
374
+ };
375
+ const assess = opts?.assess ?? risk_classifier_1.assessRisk;
376
+ const approval = opts.approval;
377
+ let assessment;
378
+ try {
379
+ assessment = assess(action);
380
+ }
381
+ catch {
382
+ return { permissionDecision: 'deny', permissionDecisionReason: '[Holmes-Kit] risk classification failed on a shell command — denied (fail-closed)' };
383
+ }
384
+ // @implements A-SPEC-133
385
+ // Hard-hitl needs an approval that COVERS this specific command (scope + not expired), and if
386
+ // the approval is single-use its nonce must not already be spent. An out-of-scope, expired, or
387
+ // replayed token denies exactly as an absent one — one token stops being a master key.
388
+ if (assessment.level === 'hard-hitl') {
389
+ const covers = (0, risk_gate_1.approvalCovers)(approval, { kind: 'shell', target: command }, nowTs);
390
+ const deny = (why) => ({
391
+ permissionDecision: 'deny',
392
+ permissionDecisionReason: `[Holmes-Kit] hard-hitl risk: ${assessment.reasons.join('; ')} — ${why}`,
393
+ });
394
+ if (!covers)
395
+ return deny('requires an approval that covers this command');
396
+ // @implements A-SPEC-141
397
+ // Check and spend in ONE atomic operation. The previous shape asked `isNonceConsumed(...)`
398
+ // and only afterwards appended the consumption record, so two concurrent agents both saw
399
+ // "not consumed" and both proceeded — measured, the same single-use approval was honoured
400
+ // twice in 20 of 20 barrier-synchronized trials. That is a TOCTOU on the authorization gate,
401
+ // not a logging problem, so the fix belongs here rather than around the append.
402
+ // @implements A-SPEC-191 §15 (round 11) — NOT a refusal here, deliberately, and this is the
403
+ // reasoning: the MCP consumers refuse a nonce they cannot record in a bound project (§13b),
404
+ // and round-10 flagged the hook for lacking the same rule. But this consumer's anchor now
405
+ // walks UP (§14a), so a subdirectory resolves to the real project and the only case left is a
406
+ // directory with no project above it at all — a fresh project's first guarded command, where
407
+ // creating `.ax/ledger` is the wiring working, not a plant. Refusing there would deny the
408
+ // first legitimate use in every new project. The single-use guarantee still holds: the record
409
+ // lands in the resolved root, which is the same file every later call resolves to.
410
+ if ((0, provenance_chain_1.blankNonce)(approval?.nonce)) {
411
+ return deny('승인이 단일 사용(nonce)을 선언했으나 값이 비어 있습니다 — 1회성을 집행할 수 없어 거부합니다');
412
+ }
413
+ if (approval?.nonce) {
414
+ let won;
415
+ try {
416
+ won = (0, provenance_chain_1.consumeNonceExclusively)(approval.nonce, ledgerFile, {
417
+ ts: nowTs, actor: approval.actor, kind: 'nonce-consumed',
418
+ summary: `consumed single-use approval for: ${(0, provenance_chain_1.redactTarget)('command', command)}`.slice(0, 200),
419
+ inputs: [(0, provenance_chain_1.nonceFingerprint)(approval.nonce)], rationale: approval.rationale, authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
420
+ });
421
+ }
422
+ catch (err) {
423
+ // FAIL CLOSED. A gate that cannot prove exclusivity must not grant — degrading to an
424
+ // allow here would restore exactly the race this exists to remove. The wording is
425
+ // deliberately DIFFERENT from the replay wording: an operator debugging a wedged agent
426
+ // needs to tell "someone already used this token" from "the ledger was busy".
427
+ return deny(`could not obtain exclusive ledger access to spend the single-use approval (${err instanceof Error ? err.message : String(err)})`);
428
+ }
429
+ if (!won)
430
+ return deny('single-use approval already consumed (replay)');
431
+ }
432
+ }
433
+ // Bash BYPASS gates (adversarial review: `cat > evil.ts` created ungoverned files, `echo x >
434
+ // .claude/settings.json` forged hook config, and `echo $HOLMES_LEDGER_KEY` harvested the very
435
+ // secrets the out-of-band channel depends on). All override-able by a valid approval.
436
+ if (!(0, risk_gate_1.isValidApproval)(approval)) {
437
+ // (a) Reading/dumping the out-of-band secrets: possession of the approval token or ledger key
438
+ // lets the agent forge approvals / re-sign history. Best-effort (a determined exfil via an
439
+ // interpreter remains — documented residual); raises the cost from one echo to a gated act.
440
+ // Environment-dump detection. Widened after a 68-vector corpus showed 29 escapes (nested shells
441
+ // `sh -c env`, `env -0`, `/usr/bin/env`, `node -p`, `ps e`, newline-separated commands, …).
442
+ // HONEST SCOPE: this raises the cost of harvesting the out-of-band secrets; it is NOT a boundary
443
+ // (an interpreter can always read its own environ). Nothing security-critical may depend on it —
444
+ // which is why ART-4 evidence is additive-only, so stealing the key still unlocks nothing.
445
+ // The by-name rule matches how the secret is USED, not that it is named. Matching the bare
446
+ // word anywhere denied `grep HOLMES_APPROVAL src/*.ts` — reading a source file, not the
447
+ // environment — which is a false positive developers hit constantly on their own config
448
+ // names. The three forms below are the ones that matter, and nothing is lost by narrowing:
449
+ // `env | grep …`, `printenv …`, `set | …`, `/proc/*/environ` and interpreter reads are all
450
+ // caught by the separate rules underneath.
451
+ // @implements A-SPEC-154
452
+ // Measured 2026-08-08: LEDGER_KEY and APPROVAL were refused while ROLE and GATE_BYPASS were
453
+ // allowed, though all four carry the same property — a session must not be able to set them
454
+ // for itself. A reviewer could run `HOLMES_ROLE=maintainer claude -p '...'` and get a fully
455
+ // capable child; the role gate never lied, the discipline just evaporated. The detection
456
+ // shapes below are inherited unchanged, which is deliberate: they were narrowed because
457
+ // matching the bare word denied `grep HOLMES_APPROVAL src/*.ts`, and re-widening for the new
458
+ // names would bring that false positive back wearing a new label.
459
+ const SECRET = String.raw `HOLMES_(?:LEDGER_KEY|APPROVAL|ROLE|GATE_BYPASS)`;
460
+ // Which of the two harms this is. Setting a role or a bypass is not reading a secret, it is
461
+ // self-granting authority — reporting both as "reads the environment" sends an operator to
462
+ // hunt a leak that never happened.
463
+ const GRANTS_SELF = new RegExp(String.raw `\bHOLMES_(?:ROLE|GATE_BYPASS)\b`).test(command);
464
+ const usesSecret = new RegExp(String.raw `\$\{?\s*${SECRET}\b`).test(command) || // $VAR / ${VAR} — a read
465
+ new RegExp(String.raw `\b${SECRET}\s*=`).test(command) || // assignment — a self-issued approval / forged key
466
+ // A quoted key only counts as a LOOKUP when something indexes or calls with it —
467
+ // `env['VAR']`, `getenv("VAR")`, `ENVIRON["VAR"]`. A quoted bare word after a space is a
468
+ // search pattern (`grep "VAR" file`), which is the false positive being removed.
469
+ new RegExp(String.raw `[[(]\s*['"\`]${SECRET}['"\`]`).test(command);
470
+ const dumpsEnv = usesSecret ||
471
+ dumpsEnvInCommandPosition(command) || // env/printenv as a COMMAND
472
+ /(^|[;&|\n]\s*)(?:set|export\s+-p|declare\s+-x|typeset\s+-x)\s*(?:$|[;&|\n>])/.test(command) ||
473
+ /\$\{!\s*\w/.test(command) || // ${!VAR*} indirection
474
+ /\/proc\/(?:self|\d+|\$\$|\$\{?\w+\}?)\/environ/.test(command) || // /proc/*/environ incl. $$
475
+ /\bps\b[^;&|\n]*\s-?e\w*\b/.test(command) || // ps e / ps -e www
476
+ // Interpreter access to the environment, in any dash form (-e/-c/-p/--eval/--print/eval).
477
+ // @implements A-SPEC-163
478
+ // The bare `\bENV\b` alternative is GONE. It was narrowed once already (it denied
479
+ // `python3 -m venv .venv`) and still fired on any property named `env`: measured
480
+ // 2026-08-08, `node -e "const s=require('./x.json'); console.log(s.env)"` — reading one
481
+ // JSON key — was refused as an environment dump. The remaining alternatives
482
+ // (`process.env`, `os.environ`, `ENVIRON`, `getenv`) name real environment access; Perl's
483
+ // `%ENV`/`$ENV{…}` are the loss, and they are rarer than the false positives were.
484
+ /\b(?:node|python[0-9.]*|ruby|perl|deno|bun|php|awk|gawk)\b[^\n]*?(?:process\s*\.\s*env|os\s*\.\s*environ|from\s+os\s+import\s+environ|ENVIRON|getenv)/i.test(command) ||
485
+ // @implements A-SPEC-163
486
+ // Perl's `$ENV{…}` and Ruby's bare `ENV` still have to be caught, but CASE-SENSITIVELY and
487
+ // not after a dot. The old alternative was `\bENV\b` under /i, which also matched `s.env` —
488
+ // reading one JSON key was refused as an environment dump (measured 2026-08-08). Requiring
489
+ // upper case and a non-dot boundary keeps the real reads and drops the property access.
490
+ /\b(?:node|python[0-9.]*|ruby|perl|deno|bun|php|awk|gawk)\b[^\n]*?(?<![.\w])ENV\b/.test(command);
491
+ if (dumpsEnv) {
492
+ return {
493
+ permissionDecision: 'deny',
494
+ permissionDecisionReason: GRANTS_SELF
495
+ ? '[Holmes-Kit] 명령이 HOLMES_ROLE/HOLMES_GATE_BYPASS를 지정하거나 읽습니다 — 세션이 스스로 권한을 부여하는 경로입니다.'
496
+ + ' 역할은 에이전트를 기동하는 사람이 대역외로 정합니다. 운영자 판단으로 실행하려면 config-write를 포함한 승인이 필요합니다.'
497
+ : '[Holmes-Kit] command reads or dumps process environment (holds the out-of-band HOLMES_APPROVAL / HOLMES_LEDGER_KEY secrets) — requires out-of-band approval',
498
+ };
499
+ }
500
+ // (b) Shell-writing hook/approval CONFIG (the F6a surface, previously Write/Edit-only).
501
+ // @implements A-SPEC-191 §28 (round 13) — the rule matched only redirection and `tee`, and
502
+ // `cp`, `mv`, `install`, `ln -f`, `dd of=`, `rsync` and `sed -i` write the same file just as
503
+ // well. A gate that names one syntax for an act is a gate over that syntax, not over the act.
504
+ const CONFIG_FILE = '(?:\\.claude\\/settings[\\w.-]*\\.json|\\.mcp\\.json|\\.env(?!\\.(?:example|sample|template|dist))(?:\\.[\\w-]+)?)';
505
+ const configWrite = new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*['"]?[^\\s;|&]*${CONFIG_FILE}\\b`).test(command)
506
+ || new RegExp(`\\b(?:cp|mv|install|rsync|ln|dd|truncate|chmod|chown)\\b[^;|&]*${CONFIG_FILE}\\b`).test(command)
507
+ || new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*${CONFIG_FILE}\\b`).test(command);
508
+ // Residual, stated rather than papered over: an INTERPRETER can write these files too. A first
509
+ // draft matched `node|python|perl|ruby` anywhere near the pattern and denied
510
+ // `node -e "console.log(cfg.env.name)"` — reading a property named `env`. A rule that cannot
511
+ // tell reading from writing costs more than the hole it closes; the same residual is already
512
+ // recorded for the code-write rule below.
513
+ if (configWrite) {
514
+ return { permissionDecision: 'deny', permissionDecisionReason: registry_1.MESSAGES.INTEGRITY_CONFIG_EDIT('.claude/settings, .env, .mcp.json', 'A-SPEC-191') };
515
+ }
516
+ // (c) Shell-writing PROJECT CODE files (redirect/tee/touch/sed -i to a relative code path)
517
+ // bypasses the No-Spec-No-Code Write/Edit gate entirely. Enforced only when this repo HAS
518
+ // governed specs (a spec-less repo has no phase gate to bypass — keeps the guardrail
519
+ // side-effect-free on ordinary ungoverned coding). Residual: interpreter-mediated writes.
520
+ const CODE_EXT = '(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|cc|cxx|cpp|hh|hpp|rb|php|swift)';
521
+ const shellCodeWrite = new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*['"]?(?![/~])[\\w@.-][\\w@./-]*\\.${CODE_EXT}\\b`).test(command) ||
522
+ new RegExp(`\\btouch\\s+[^;|&]*(?<![/~])\\b[\\w@.-][\\w@./-]*\\.${CODE_EXT}\\b`).test(command) ||
523
+ new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*\\.${CODE_EXT}\\b`).test(command);
524
+ if (shellCodeWrite) {
525
+ const governed = readSpecsSync(specsDir).some((s) => s.status === 'approved');
526
+ if (governed) {
527
+ return { permissionDecision: 'deny', permissionDecisionReason: '[Holmes-Kit] shell write to a project code file bypasses the No-Spec-No-Code gate — use the Write/Edit tools (gated) or supply out-of-band approval' };
528
+ }
529
+ }
530
+ }
531
+ // auto/notify/confirm, or hard-hitl with a valid approval: this slice blocks only
532
+ // hard-hitl; confirm is advisory here.
533
+ return { permissionDecision: 'allow' };
534
+ }
535
+ // @implements A-SPEC-191 §27 (round 13) — `PATH_FIELDS` exists because `notebook_path` slipped
536
+ // past a gate that knew only `file_path`, and this line still knew only `file_path`: a valid
537
+ // NotebookEdit was denied in EVERY project with "편집 대상 경로를 읽을 수 없습니다", which names
538
+ // the wrong fault (the path was there and readable) and blocks a legitimate tool outright.
539
+ const p1 = typeof input.tool_input.file_path === 'string' ? input.tool_input.file_path : undefined;
540
+ const p2 = typeof input.tool_input.notebook_path === 'string' ? input.tool_input.notebook_path : undefined;
541
+ const isNb = input.tool_name?.toLowerCase() === 'notebookedit';
542
+ const p = isNb ? (p2 ?? p1) : (p1 ?? p2);
543
+ // @implements A-SPEC-163
544
+ // Inverted: a tool NOT known to be read-only, carrying a path, is a write. The membership test
545
+ // this replaces let `MultiEdit`, `NotebookEdit` and a lower-case `write` reach the very files
546
+ // `Write` was denied (measured 2026-08-08), and adding those names would only postpone the next
547
+ // tool. An unrecognised name now fails closed.
548
+ // @implements A-SPEC-163 — a payload the gate cannot evaluate denies, whichever tool sent it.
549
+ if (input.malformed) {
550
+ // The SAME wording the path-missing branch already used. Inventing a second phrase for the same
551
+ // condition would split what a reader (and a playbook trigger, which quotes these verbatim)
552
+ // has to recognise.
553
+ return {
554
+ permissionDecision: 'deny',
555
+ permissionDecisionReason: '[Holmes-Kit] 편집 대상 경로를 읽을 수 없어 판정할 수 없습니다 (file_path 누락 또는 문자열이 아님)',
556
+ };
557
+ }
558
+ const writesCode = (0, write_target_1.writesFiles)(input.tool_name, input.tool_input);
559
+ // @implements A-SPEC-144
560
+ // A code-writing tool with NO readable target must not pass in a governed project. Measured
561
+ // end-to-end after normalization landed: dropping a non-string `file_path` stopped the exception
562
+ // but left the outcome identical — `evaluateHook` saw no target and allowed. That is the same
563
+ // bypass wearing a quieter face, and it is worse, because there is no longer an error line to see.
564
+ // You cannot check whether an approved spec covers a file you cannot name, so the honest answer is
565
+ // no. An UNGOVERNED project is unaffected: the escape below already returns allow before this.
566
+ if (writesCode && !p) {
567
+ return {
568
+ permissionDecision: 'deny',
569
+ permissionDecisionReason: '[Holmes-Kit] 편집 대상 경로를 읽을 수 없어 판정할 수 없습니다 (file_path 누락 또는 문자열이 아님)'
570
+ + ' — 이름을 알 수 없는 파일이 승인된 스펙에 덮이는지 확인할 방법이 없으므로 허용하지 않습니다.',
571
+ };
572
+ }
573
+ if (!p || !writesCode)
574
+ return { permissionDecision: 'allow' };
575
+ const weApproval = opts.approval;
576
+ let norm = p.replace(/\\/g, '/');
577
+ // Holmes-Kit governs THIS project. An absolute path outside the project root (scratchpads, temp
578
+ // dirs, other checkouts) is not governed surface — gating it blocked ordinary harness scratch
579
+ // files (live over-block found in dogfooding). Relative paths are always in-project.
580
+ const projectRoot = opts.projectRoot.replace(/\\/g, '/').replace(/\/+$/, '');
581
+ // @implements A-SPEC-191 §17 (round 11) — "inside the project" is a fact about the RESOLVED
582
+ // location, not about how the caller spelled the path. Measured: with the project reached through
583
+ // a symlinked ancestor (macOS `/var` → `/private/var`, a symlinked home, `/tmp`), the root came
584
+ // back real-pathed while the target kept the caller's spelling, so EVERY file in the project read
585
+ // as "outside the project" and this line allowed the whole tree. The gate did not refuse to
586
+ // govern — it concluded there was nothing to govern.
587
+ // @implements A-SPEC-191 §29 (round 13) — the walk stopped after 64 ancestors and returned the
588
+ // UNRESOLVED path, which then failed both containment tests and took the early allow: a deep new
589
+ // path under a symlinked ancestor (macOS `/tmp` → `/private/tmp` is the ordinary case) switched
590
+ // the gate off. A bound that runs out must fail toward the gate, not away from it — so the walk
591
+ // is not bounded by a count at all; it ends at the filesystem root, which always terminates.
592
+ const realOf = (abs) => {
593
+ let head = abs;
594
+ const tail = [];
595
+ for (;;) {
596
+ try {
597
+ return [fs.realpathSync(head).replace(/\\/g, '/'), ...tail].join('/');
598
+ }
599
+ catch { /* walk up */ }
600
+ const parent = path.dirname(head);
601
+ if (parent === head)
602
+ return abs;
603
+ tail.unshift(path.basename(head));
604
+ head = parent;
605
+ }
606
+ };
607
+ // @implements A-SPEC-191 §20 (round 11) — §17 said "the resolved location, not the spelling", and
608
+ // resolved only SYMLINKS. On a case-insensitive filesystem (APFS, NTFS) `fs.realpathSync` keeps
609
+ // each segment's letter case, so flipping one ancestor's case produced a path that is the SAME FILE
610
+ // (measured: identical dev/ino) yet failed both containment tests and took the early allow.
611
+ // Measured on a fixture project: `/private/tmp/…/proj/.mcp.json` → deny, `/PRIVATE/tmp/…/proj/
612
+ // .mcp.json` → allow, and likewise for `.ax/roles`, `.ax/ledger` and No-Spec-No-Code on `src/app.ts`
613
+ // — the whole gate off for one character of case. Identity now decides, and the target is rewritten
614
+ // into the PROJECT'S OWN SPELLING so every check downstream reasons about one string.
615
+ if (norm.startsWith('/')) {
616
+ const inside = (t, r) => t === r || t.startsWith(r + '/');
617
+ const asProject = (abs, base) => (abs.length <= base.length ? projectRoot : `${projectRoot}/${abs.slice(base.length + 1)}`);
618
+ const realTarget = realOf(norm).replace(/\/+$/, '');
619
+ const realRoot = realOf(projectRoot).replace(/\/+$/, '');
620
+ // Case-blindness is a property of THIS filesystem, probed on the project root itself rather than
621
+ // assumed from the platform — a case-sensitive volume mounted on macOS must not have two distinct
622
+ // files folded together, and a case-insensitive volume on Linux must not keep the bypass.
623
+ const caseBlind = (() => {
624
+ const flipped = projectRoot.replace(/[a-zA-Z]/, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()));
625
+ if (flipped === projectRoot)
626
+ return process.platform === 'darwin' || process.platform === 'win32';
627
+ if (flipped === projectRoot)
628
+ return false;
629
+ try {
630
+ const a = fs.statSync(projectRoot);
631
+ const b = fs.statSync(flipped);
632
+ return a.dev === b.dev && a.ino === b.ino;
633
+ }
634
+ catch {
635
+ return false;
636
+ }
637
+ })();
638
+ const fold = (s) => (caseBlind ? s.toLowerCase() : s);
639
+ // 접기는 해석된 위치에만 적용한다 — 철자 그대로의 norm 을 접어 '안'이라 부르면, 프로젝트
640
+ // 밖을 가리키는 링크가 이름만으로 안에 들어온다. 해석이 먼저, 접기는 그 위에서.
641
+ const canonical = inside(norm, projectRoot) ? asProject(norm, projectRoot)
642
+ : inside(realTarget, realRoot) ? asProject(realTarget, realRoot)
643
+ : inside(fold(realTarget), fold(realRoot)) ? asProject(realTarget, realRoot)
644
+ : null;
645
+ if (canonical === null)
646
+ return { permissionDecision: 'allow' };
647
+ norm = canonical;
648
+ }
649
+ // @implements A-SPEC-191 §20e — an in-project target whose spelling cannot be decided is not a
650
+ // permitted one. A NUL byte truncates the path for any consumer that hands it to a syscall, and a
651
+ // trailing slash makes `.mcp.json/` miss every pattern that names `.mcp.json`. Both were measured
652
+ // reaching `allow` where the plain spelling denied. The slash is normalisation (same file); the NUL
653
+ // is a refusal, in the direction REQ-144 already set for unreadable targets.
654
+ if (norm.includes('\u0000')) {
655
+ return {
656
+ permissionDecision: 'deny',
657
+ permissionDecisionReason: '[Holmes-Kit] 편집 대상 경로에 NUL 바이트가 있어 어느 파일인지 판정할 수 없습니다'
658
+ + ' — 이름을 확정할 수 없는 대상은 허용하지 않습니다.',
659
+ };
660
+ }
661
+ norm = norm.length > 1 ? norm.replace(/\/+$/, '') : norm;
662
+ // Everything downstream that reasons about WHERE a file sits — classifyAction's test rules and the
663
+ // Files-to-Touch scope check — must see the PROJECT-RELATIVE path. Handing classifyAction an
664
+ // absolute one let a project checked out under `/…/test/` read as one big test tree, which
665
+ // DOWNGRADES the gate (WRITE_TEST does not require an approved T-SPEC).
666
+ const relPath = norm.startsWith(projectRoot + '/') ? norm.slice(projectRoot.length + 1) : norm;
667
+ // F6a: writing hook/approval CONFIG (.claude/settings*, .env, .mcp.json) can inject HOLMES_APPROVAL
668
+ // or turn the hooks off — it forges the very out-of-band channel the guardrail relies on. Gate it as
669
+ // hard-hitl so the model cannot self-provision approval or disable governance from tool input.
670
+ // The L4 ledgers are APPEND-ONLY audit evidence — hand-editing them via Write/Edit is history
671
+ // rewriting (append goes through the progress-ledger/decision-ledger APIs). Gate like the other
672
+ // governance surfaces (adversarial finding HIGH-3: a Write could forge/truncate the audit trail).
673
+ // @implements A-SPEC-153
674
+ // The role policy is the OTHER entrance to the same door. `HOLMES_ROLE` is out-of-band precisely so
675
+ // a session cannot claim its own role — but if it can WRITE `.ax/roles/`, it simply declares a role
676
+ // that may do everything, or a `default:` granting it without any claim at all. REQ-152 identified
677
+ // this threat and closed only the shell entrance; a plain Write was measured as ALLOWED. Unlike
678
+ // specs, no approval seal covers this file, so the write itself must be the thing that is gated.
679
+ if ((0, write_target_1.protectedKindOf)(opts.projectRoot, norm) === path.join('.ax', 'roles')) {
680
+ if (!(0, risk_gate_1.approvalCovers)(weApproval, { kind: 'config-write', target: norm }, nowTs)) {
681
+ return {
682
+ permissionDecision: 'deny',
683
+ permissionDecisionReason: '[Holmes-Kit] 역할 정책(.ax/roles/)은 이 게이트가 집행하는 권한 자체를 정의합니다 —'
684
+ + ' 에이전트가 스스로 고칠 수 없습니다. config-write를 포함한 대역외 승인이 필요합니다.',
685
+ };
686
+ }
687
+ }
688
+ // @implements A-SPEC-191 §22 — 신원으로 묻는다. 철자 정규식은 프로젝트 안 링크 하나로 빗나갔다.
689
+ const configKind = (0, write_target_1.protectedFileKindOf)(opts.projectRoot, norm);
690
+ // @implements A-SPEC-163 — identity, not spelling. Measured: `.ax//ledger//p.jsonl`,
691
+ // `.ax/./ledger/p.jsonl` and `.AX/roles/policy.yaml` all reached the protected file while the
692
+ // regex saw a different string. Adding `//` to the pattern would only move the next variant.
693
+ if (configKind !== null || (0, write_target_1.isProtectedTarget)(opts.projectRoot, norm)) {
694
+ // @implements A-SPEC-133 — a token scoped to config-write authorizes this; a spec-only or
695
+ // out-of-scope token does not, so config forgery cannot ride a narrow grant.
696
+ if (!(0, risk_gate_1.approvalCovers)(weApproval, { kind: 'config-write', target: norm }, nowTs)) {
697
+ // @implements A-SPEC-193 §8 — 무엇을 막았는지 이름한다. 목록을 문장에 박아 두면 하네스가
698
+ // 늘 때마다 문면이 거짓이 된다(REQ-155: 엉뚱한 파일을 지목하는 진단의 값은 음수다).
699
+ const kindName = configKind ?? (0, write_target_1.protectedKindOf)(opts.projectRoot, norm) ?? '설정';
700
+ return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${kindName} 은(는) 훅·승인 설정입니다 — HOLMES_APPROVAL 을 위조하거나 거버넌스를 끌 수 있으므로 config-write 를 포함한 대역외 승인이 필요합니다` };
701
+ }
702
+ }
703
+ // Spec-file edit → graph-aware blast-radius risk (Tier-2 push-feed): a change to a widely-depended-on
704
+ // or foundational (REQ) spec ripples across the graph, so its risk is assessed from the spec
705
+ // dependency graph and escalated to out-of-band approval when the impact reaches the hard-hitl
706
+ // threshold. Uses only the already-loaded specs (cheap); non-spec edits skip this entirely.
707
+ // @implements A-SPEC-191 §23 (round 12) — identity, like every other protected surface. The
708
+ // resolved path is also what the disk is read with below: `existsSync(p)` on the caller's raw
709
+ // spelling answered "not approved" for `<file>/` and skipped the seal-breaking branch entirely.
710
+ const specTarget = (0, write_target_1.specTargetOf)(opts.projectRoot, specsDir, norm);
711
+ if (specTarget !== null) {
712
+ const p = specTarget;
713
+ // F1: a spec cannot SELF-APPROVE. `status: approved` is what unlocks every downstream gate
714
+ // (phaseCheck trusts the on-disk status). If the incoming write promotes a spec to approved and it
715
+ // is not ALREADY approved on disk, that is a new approval — an out-of-band act, never a raw tool
716
+ // write. Authoring/editing draft|review specs stays free; only the approval transition is gated.
717
+ // The status is read with the SAME YAML loader as downstream (parseSpec) so a quoted/commented
718
+ // `status: "approved"` cannot slip a bespoke regex (verification review — that reopened the bypass);
719
+ // a tolerant regex backs it up for Edit fragments that aren't a parseable full document.
720
+ let onDiskApproved = false;
721
+ try {
722
+ onDiskApproved = fs.existsSync(p) && (0, spec_parser_1.parseSpec)(fs.readFileSync(p, 'utf8')).status === 'approved';
723
+ }
724
+ catch { /* treat as not approved */ }
725
+ // @implements A-SPEC-133 — spec approval/edit is authorized by a token covering spec-write.
726
+ const specWriteCovered = (0, risk_gate_1.approvalCovers)(weApproval, { kind: 'spec-write', target: norm }, nowTs);
727
+ if (incomingSetsApproved(input.tool_input) && !specWriteCovered) {
728
+ if (!onDiskApproved) {
729
+ return { permissionDecision: 'deny', permissionDecisionReason: registry_1.MESSAGES.SEAL_SELF_APPROVAL_RESTRICTED(path.basename(p, '.md'), 'A-SPEC-133') };
730
+ }
731
+ }
732
+ // @implements A-SPEC-132
733
+ // Sealed-content forgery rule: once a spec is approved on disk, editing its BODY or its seal
734
+ // fields is an approval-scope act — it either drifts the sealed content or forges the seal, both
735
+ // of which the whole P1 mechanism exists to prevent. Only spec_approve (out-of-band) may. Draft
736
+ // authoring stays free (onDiskApproved is false). Measured origin: an Edit rewriting an approved
737
+ // A-SPEC's Behavior section returned `allow` with no approval anywhere.
738
+ if (onDiskApproved && !specWriteCovered) {
739
+ return {
740
+ permissionDecision: 'deny',
741
+ permissionDecisionReason: registry_1.MESSAGES.SEAL_APPROVED_SPEC_MUTATION(path.basename(p, '.md'), undefined, 'A-SPEC-132'),
742
+ };
743
+ }
744
+ const m = /(REQ|H-SPEC|A-SPEC|C-SPEC|T-SPEC)-\d{3,}(?:\.\d+)?/.exec(path.basename(p));
745
+ if (m) {
746
+ const br = (0, rtm_check_1.computeBlastRadius)(readSpecsSync(specsDir), m[0]);
747
+ const brAction = { kind: 'edit', target: p, blastRadius: br };
748
+ const assessment = (opts?.assess ?? risk_classifier_1.assessRisk)(brAction);
749
+ if (assessment.level === 'hard-hitl' && !(0, risk_gate_1.isValidApproval)(opts.approval)) {
750
+ return {
751
+ permissionDecision: 'deny',
752
+ permissionDecisionReason: `[Holmes-Kit] high blast-radius spec edit: ${assessment.reasons.join('; ')} — requires out-of-band approval`,
753
+ };
754
+ }
755
+ }
756
+ return { permissionDecision: 'allow' };
757
+ }
758
+ // @implements A-SPEC-167 — the body is passed so a shebang can be seen. `Edit` carries only a
759
+ // fragment, so the shebang check simply does not fire there; the path signals still answer.
760
+ const action = (0, phase_1.classifyAction)(relPath, input.tool_input.content);
761
+ if (!action)
762
+ return { permissionDecision: 'allow' };
763
+ // Read specs synchronously via a fresh walk (hook must be sync)
764
+ const specs = readSpecsSync(specsDir);
765
+ // @implements A-SPEC-166
766
+ // What this change CLAIMS to implement, judged independently of what the file already carried.
767
+ // Measured 2026-08-08: the same unapproved `@implements` was denied in a new file and allowed in
768
+ // an existing one, because the gate looked at the file's anchors rather than the change's. Only
769
+ // ADDED claims are checked — re-judging the file's existing ones would block the very edits
770
+ // needed to fix a spec that moved back to draft.
771
+ if (typeof input.tool_input.content === 'string') {
772
+ let previous;
773
+ try {
774
+ previous = fs.readFileSync(norm, 'utf8');
775
+ }
776
+ catch {
777
+ previous = undefined;
778
+ }
779
+ const added = (0, anchors_1.newlyClaimed)(input.tool_input.content, previous);
780
+ // An anchor naming a spec that does not exist is worse than one naming a draft — it claims
781
+ // authority from a document nobody can read. Unknown is treated as unapproved, the direction
782
+ // REQ-144 established for everything the gate cannot resolve.
783
+ const unapproved = added.filter((id) => specs.find((x) => x.id === id)?.status !== 'approved');
784
+ if (unapproved.length > 0) {
785
+ // @implements A-SPEC-182
786
+ // This is the SECOND place the gate refuses over an unapproved A-SPEC, and it fires before
787
+ // phaseCheck. The census over 244 governed runs found both wordings in the field — the
788
+ // phaseCheck one in 99 runs, this one in 18 — so improving only the first would leave the
789
+ // same inconsistency the REQ is about, just smaller. Same computation, same sentence.
790
+ const byId = new Map(specs.map((s) => [s.id, s]));
791
+ // Group by REASON, then cap the number of distinct reasons — not a positional slice.
792
+ // Two review findings meet here: the naive per-spec form grew a 139-character refusal to 2019
793
+ // by repeating one sentence five times, and the positional cap that replaced it silently
794
+ // dropped a THIRD spec's different blocker while telling the author the rest were "the same".
795
+ // Specs stuck for the same reason are named together; every distinct reason is shown.
796
+ const DISTINCT_REASON_CAP = 3;
797
+ // Group by REASON. The key is the summary SPLIT on the spec's own id and kept as parts, so no
798
+ // sentinel is inserted into user-controlled text — review found a `‹self›` marker being
799
+ // replaced inside a section NAME, and before that an id deleted outright so the author was
800
+ // told `id "" fails …`. Splitting loses nothing and can collide with nothing.
801
+ // Split on the spec's OWN id at a token boundary. A raw `split(id)` fired mid-token whenever
802
+ // the id was a prefix of another id in the same sentence — measured: `A-SPEC-901` whose parent
803
+ // `A-SPEC-9011` is missing rendered `depends_on "해당 스펙1" not found`, destroying the one
804
+ // fact the author needed. This repository's own dotted ids (`A-SPEC-100.1`) make the prefix
805
+ // relation ordinary, not exotic.
806
+ const selfRe = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.-])`, 'g');
807
+ const byReason = new Map();
808
+ for (const id of unapproved) {
809
+ const summary = (0, approval_blockers_1.blockerSummary)(byId.get(id), (x) => byId.get(x) ?? null, id);
810
+ if (summary === null)
811
+ continue;
812
+ const key = JSON.stringify(summary.slice(summary.indexOf(']') + 1).trim().split(selfRe(id)));
813
+ byReason.set(key, [...(byReason.get(key) ?? []), id]);
814
+ }
815
+ const groups = [...byReason];
816
+ // Rejoin with the spec's own id when the group is one spec; with a neutral phrase when it is
817
+ // several, because substituting the whole list produced `id "A-SPEC-90, A-SPEC-91" fails …` —
818
+ // a quoted id no spec has. The `[…]` label already names them.
819
+ // A single-spec group rejoins the sentence with the RAW id — a 20,000-char id made a
820
+ // 20,291-char refusal straight past every budget (round-1). tspec-state's rule 1 applies:
821
+ // an id longer than ID_MAX is never quoted; the […] label already carries the (folded) name.
822
+ // budgets IMPORTED from tspec-state (r5-192: hand-copied 80/1200 were the same second-truth
823
+ // family this REQ eliminated for ID_BUDGET/SPEC_STATUSES/ACTIONS).
824
+ // The blocker SENTENCE is spec-authored text (field echoes, allowed-value lists) — one
825
+ // pathological spec reproduced a 20,185-char refusal with every id list dutifully budgeted
826
+ // (round-1 [12]). Whole-sentence cap, REQ-183's REASON discipline.
827
+ const REASON_MAX = tspec_state_1.REASON_BUDGET;
828
+ // SHARED across groups (round-3: per-group caps let 3 pathological groups compose a
829
+ // 3,808-char refusal — tspec-state's discipline decrements ONE budget across specs).
830
+ let reasonLeft = REASON_MAX;
831
+ // SKIP, never cut (round-4): truncation mid-sentence sliced spec ids into other (or
832
+ // nonexistent) ids, split astral chars, and — worst — one pathological first group zeroed
833
+ // the budget so later SHORT sentences vanished under a FALSE '잘림' label. clampBlockers'
834
+ // discipline instead: an oversized sentence is replaced whole by an honest omission label,
835
+ // and fitting sentences keep rendering out of the shared budget.
836
+ const OMIT_OVER = ' (사유가 예산을 넘어 생략 — 해당 스펙을 개별 확인하십시오)';
837
+ const OMIT_SPENT = ' (문면 예산 소진 — 해당 스펙을 개별 확인하십시오)';
838
+ const shown0 = groups.slice(0, DISTINCT_REASON_CAP);
839
+ // @implements A-SPEC-192 §5R (round 9) — a SHARE, not a race. First-come-full-draw made the
840
+ // budget a cliff: one 1,199-character sentence (just under the cap) took the whole allowance
841
+ // and every short sibling vanished behind '예산 소진', while making that same sentence TEN
842
+ // characters LONGER flipped it to the omission label and brought the siblings back — a
843
+ // refusal that improves when the input gets worse. Each group is guaranteed its equal share
844
+ // first; whatever the short ones do not use is then offered to the rest in order.
845
+ const bodies = new Map();
846
+ for (const [key, ids] of shown0) {
847
+ bodies.set(key, JSON.parse(key).join(ids.length === 1 && ids[0].length <= tspec_state_1.ID_MAX ? ids[0] : '해당 스펙'));
848
+ }
849
+ // @implements A-SPEC-192 §8R (round 10, 2nd) — the SAME algorithm the sibling surface uses, and
850
+ // only that one. The share/remainder pair moved the cliff instead of removing it, and its two
851
+ // accounting units disagreed; here the share branch had additionally become dead code, which a
852
+ // mutation battery could only report as "equivalent" — a rule nothing enforces. One rule:
853
+ // spend the budget SHORTEST-FIRST on the sentences as they will be printed.
854
+ const priced = shown0
855
+ .map(([key], i) => ({ key, i, cost: (bodies.get(key) ?? '').length }))
856
+ .filter((g) => g.cost > 0 && g.cost <= REASON_MAX)
857
+ .sort((a, b) => a.cost - b.cost || a.i - b.i);
858
+ const allowance = new Map();
859
+ let pool = REASON_MAX;
860
+ for (const g of priced) {
861
+ if (g.cost <= pool) {
862
+ allowance.set(g.key, pool);
863
+ pool -= g.cost;
864
+ }
865
+ else
866
+ allowance.set(g.key, 0);
867
+ }
868
+ reasonLeft = pool;
869
+ const render = (key, ids) => {
870
+ void ids;
871
+ const body = bodies.get(key) ?? '';
872
+ if (body.length > REASON_MAX)
873
+ return OMIT_OVER;
874
+ if (body.length > (allowance.get(key) ?? 0))
875
+ return OMIT_SPENT;
876
+ return body;
877
+ };
878
+ const shown = shown0;
879
+ const hidden = groups.slice(DISTINCT_REASON_CAP);
880
+ // @implements A-SPEC-192 — the three id lists ride REQ-183's budget: bounded naming, honest counts.
881
+ const why = shown.map(([key, ids]) => ` [${(0, tspec_state_1.listOrCount)(ids, tspec_state_1.ID_BUDGET, '개', Infinity)}] ${render(key, ids)}`).join('')
882
+ // Name the specs whose reasons did not fit. Saying only "N more" sends the author back to
883
+ // check all of them — the round-trip this REQ exists to remove.
884
+ + (hidden.length > 0
885
+ ? ` (${(0, tspec_state_1.listOrCount)(hidden.flatMap(([, ids]) => ids), tspec_state_1.ID_BUDGET, '개', Infinity)}는 사유가 각각 달라 개별 확인이 필요하다)` : '');
886
+ return {
887
+ permissionDecision: 'deny',
888
+ permissionDecisionReason: `[Holmes-Kit] 이 변경이 새로 주장하는 ${(0, tspec_state_1.listOrCount)(unapproved, tspec_state_1.ID_BUDGET, '개', Infinity)}이(가) approved가 아닙니다`
889
+ + ` — 기존 파일이라도 승인되지 않은 스펙을 주장할 수 없습니다.${why}`,
890
+ };
891
+ }
892
+ }
893
+ // @implements A-SPEC-175
894
+ // Ungoverned-project escape hatch. No-Spec-No-Code has no referent on a project that never opted
895
+ // into governance, and without this, pointing the Write|Edit matcher at a spec-less project denies
896
+ // 100% of source writes — a brick, not a gate. Found by installing into a fresh target and probing.
897
+ // The condition is deliberately narrow, because "no specs" must not become a way to disarm:
898
+ // spec dir absent, and NO approval in the ledger -> never governed -> allow
899
+ // spec dir present, HOLMES_SPECS set, or the ledger remembers -> deny, as before
900
+ //
901
+ // This comment used to justify the gap with two claims. Measured 2026-08-12 on an installed
902
+ // tarball: the first held — `rm -rf .ax/specs`, `rm -rf .ax`, `mv`, `git clean -fd .ax` and
903
+ // `git checkout -- .` are all denied by the shell risk gate. The second — "the Stop hook
904
+ // re-verifies the constitution" — was FALSE: with the spec tree gone the Stop hook produced no
905
+ // output and exited 0, because there was no constitution left to verify. Every route that is not a
906
+ // shell command (an editor, Finder, git run outside the session, a branch without specs, an
907
+ // unmounted drive) therefore switched governance off in silence. The ledger is what remembers.
908
+ // @implements A-SPEC-191 §26 (round 13) — this compared the specs directory to the DEFAULT as a
909
+ // STRING, and §17 (round 11) made the CLI pass an absolute path. So on every shipped invocation
910
+ // the comparison was false and A-SPEC-175's ungoverned escape was dead: a project with no specs
911
+ // had every code write denied with nothing the author could do about it, while the unit tests —
912
+ // which pass the relative default — kept the branch green. Compare the resolved LOCATIONS, and
913
+ // ask the filesystem about the same absolute path the rest of this function uses.
914
+ const specsDirAbs = path.isAbsolute(specsDir) ? specsDir : path.resolve(opts.projectRoot, specsDir);
915
+ const defaultSpecsAbs = path.resolve(opts.projectRoot, exports.DEFAULT_SPECS_DIR);
916
+ if (specs.length === 0 && specsDirAbs === defaultSpecsAbs && !fs.existsSync(specsDirAbs)
917
+ && !(0, governance_history_1.hasGovernanceHistory)(opts.projectRoot)) {
918
+ return { permissionDecision: 'allow' };
919
+ }
920
+ // Extract @implements A-SPEC-xxx from all available marker sources:
921
+ // - tool_input.content (Write payloads)
922
+ // - tool_input.new_string (Edit payloads)
923
+ // - the current on-disk contents of file_path, if it exists (Edit only changes a fragment,
924
+ // so the marker may already be present elsewhere in the file being edited)
925
+ let onDiskContent = '';
926
+ try {
927
+ if (fs.existsSync(p)) {
928
+ onDiskContent = fs.readFileSync(p, 'utf8');
929
+ }
930
+ }
931
+ catch {
932
+ // Fail-open: if the file can't be read (permissions, race, etc.), just skip this source
933
+ }
934
+ // Marker precedence: an EXISTING file's OWN on-disk anchor governs it — a fragment that merely
935
+ // MENTIONS another spec id (a test fixture's anchor string, a doc snippet) must not re-anchor the
936
+ // edit to that id. Only when the file carries no on-disk anchor (new file, or first anchoring) does
937
+ // the incoming payload supply it. Found live: editing a test file whose fixture named an unapproved
938
+ // spec was denied even though the file's own anchor is approved.
939
+ const ANCHOR_RE = /@implements\s+(A-SPEC-\d{3,}(?:\.\d+)?)/;
940
+ const payloadAnchor = ANCHOR_RE.exec([input.tool_input.content ?? '', input.tool_input.new_string ?? ''].join('\n'));
941
+ const diskAnchor = ANCHOR_RE.exec(onDiskContent);
942
+ const m = diskAnchor ?? payloadAnchor;
943
+ // RE-ANCHORING must be scope-checked against the NEW spec (review C6: on-disk precedence meant an
944
+ // edit that rewrites the anchor line was judged under the OLD spec, so a file could be re-attributed
945
+ // to any spec without that spec's Files-to-Touch ever being consulted — which then mis-credits
946
+ // coverage evidence). Both the current and the incoming anchor must accept the path.
947
+ const anchorsToScope = [m?.[1], payloadAnchor?.[1] !== m?.[1] ? payloadAnchor?.[1] : undefined].filter(Boolean);
948
+ // @implements A-SPEC-132
949
+ // Stale-aware gate (rev.1): a target A-SPEC whose seal is PRESENT and BROKEN (edited after
950
+ // approval) or STALE (a parent moved) is not a trustworthy approval, so code must not be written
951
+ // against it — even though its status still reads 'approved'. The denial names staleness and
952
+ // spec_approve, deliberately DISTINCT from phaseCheck's "…이 approved가 아닙니다" (REQ-130's lesson:
953
+ // two different failures must not share the same words). An ABSENT seal falls through — the backfill
954
+ // closes that window and the Stop hook's unsealed-approval error blocks the turn regardless.
955
+ const resolveSpec = (id) => specs.find((s) => s.id === id) ?? null;
956
+ const sealProblem = (id) => {
957
+ if (!id)
958
+ return null;
959
+ const spec = specs.find((s) => s.id === id);
960
+ if (!spec || spec.status !== 'approved')
961
+ return null; // phaseCheck owns "not approved"
962
+ const seal = (0, spec_digest_1.sealOf)(spec);
963
+ if (!seal.approvedDigest)
964
+ return null; // absent seal: backfill + Stop hook territory
965
+ if ((0, spec_digest_1.specDigest)(spec) !== seal.approvedDigest)
966
+ return `${id}는 승인 이후 내용이 변경되었습니다(post-approval-edit)`;
967
+ for (const pid of spec.dependsOn) {
968
+ const parent = resolveSpec(pid);
969
+ if (!parent || parent.status !== 'approved')
970
+ continue;
971
+ const parentSeal = (0, spec_digest_1.sealOf)(parent).approvedDigest;
972
+ if (parentSeal && seal.parentDigests[pid] !== parentSeal)
973
+ return `${id}의 부모 ${pid}가 재승인되어 체인이 stale입니다(stale-parent)`;
974
+ }
975
+ return null;
976
+ };
977
+ // The qualifying T-SPEC matters for WRITE_CODE too — a stale test spec must not bless code.
978
+ const staleTarget = sealProblem(m?.[1]) ?? (action === 'WRITE_CODE'
979
+ ? (specs.filter((s) => s.type === 'T-SPEC' && s.status === 'approved' && m?.[1] && s.dependsOn.includes(m[1]))
980
+ .map((s) => sealProblem(s.id)).find(Boolean) ?? null)
981
+ : null);
982
+ // @implements A-SPEC-133 — the override is authorized by a token covering the code write.
983
+ const codeWriteCovered = (0, risk_gate_1.approvalCovers)(weApproval, { kind: 'code-write', target: relPath }, nowTs);
984
+ if (staleTarget && !codeWriteCovered) {
985
+ return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${staleTarget} — spec_approve로 재승인 후 진행하세요` };
986
+ }
987
+ // @implements A-SPEC-152
988
+ // Role gate — BEFORE the spec gate on purpose: if this identity may not perform the action at all,
989
+ // there is no point discussing whether a spec covers it, and answering with the spec reason would
990
+ // send the reader to fix the wrong thing.
991
+ //
992
+ // OPT-IN: `loadRolePolicy` returns null when the project has no `.ax/roles/`, and then this is a
993
+ // no-op — a project that never asked for roles must behave exactly as before. Once a policy
994
+ // exists it is fail-closed: no role, an unregistered role, and a role without the action are all
995
+ // refusals. The claim comes from the ENVIRONMENT only; a role in the payload would be the session
996
+ // asserting its own authority, which is what ART-5 forbids on the spec axis.
997
+ const roleIssue = (0, role_policy_1.roleCheck)(action, new identity_1.EnvIdentityProvider().current(), (0, role_policy_1.loadRolePolicy)(projectRoot));
998
+ if (roleIssue && !codeWriteCovered) {
999
+ return { permissionDecision: 'deny', permissionDecisionReason: roleIssue };
1000
+ }
1001
+ // @implements A-SPEC-134
1002
+ // Constitution-debt gate: after the Stop gate gave up its block cap on an unresolved constitution,
1003
+ // new CODE must not be layered on top of it. WRITE_TEST and spec authoring stay open — you must be
1004
+ // able to fix the tests/specs that clear the debt. A covering approval overrides; a clean Stop
1005
+ // clears the debt and code resumes.
1006
+ if (action === 'WRITE_CODE' && !codeWriteCovered) {
1007
+ const debt = (0, constitution_debt_1.readDebt)(projectRoot);
1008
+ if (debt && debt.length > 0) {
1009
+ return {
1010
+ permissionDecision: 'deny',
1011
+ permissionDecisionReason: `[Holmes-Kit] constitution-debt outstanding (${debt.join(', ')}) — resolve the constitution (a clean turn clears it) before writing new code, or supply a covering approval`,
1012
+ };
1013
+ }
1014
+ }
1015
+ const res = (0, phase_1.phaseCheck)(action, { specs, targetAspecId: m?.[1] });
1016
+ if (res.decision === 'deny') {
1017
+ // @implements A-SPEC-175
1018
+ // When the spec tree is gone from a project the ledger remembers approving, the generic
1019
+ // "no approved A-SPEC" is true but useless — the specs are not unwritten, they are missing, and
1020
+ // the fix is to restore them or to leave governance properly. Appended rather than substituted:
1021
+ // the phase gate's own verdict is still what denied this.
1022
+ const lost = specs.length === 0 && !fs.existsSync(specsDir)
1023
+ && (0, governance_history_1.hasGovernanceHistory)(path.resolve(specsDir, '..', '..'));
1024
+ return {
1025
+ permissionDecision: 'deny',
1026
+ permissionDecisionReason: `[Holmes-Kit] ${res.remediation?.message} → ${res.remediation?.next_action} (${res.remediation?.discipline})`
1027
+ + (lost ? `\n${governance_history_1.GOVERNANCE_LOST_HINT}` : ''),
1028
+ };
1029
+ }
1030
+ // Marker self-attestation mitigation: an @implements anchor is a claim the writer makes. When the
1031
+ // anchored A-SPEC's `Files to Touch` names CONCRETE paths, the file must fall under one of them —
1032
+ // so anchoring evil.ts (or an unrelated edit) to a random approved spec fails when that spec scoped
1033
+ // its files. Applies to EVERY governed code Write/Edit, not just new files: the earlier new-file-only
1034
+ // scoping left "create it some other way, then edit freely" open, and a stale anchor blessed
1035
+ // unrelated edits forever. The legitimate flow is to extend the spec's Files-to-Touch FIRST
1036
+ // (editing an approved spec's sections is allowed; only the approval transition is gated).
1037
+ // Still bounded: only when concrete paths are listed (a prose/TODO section imposes nothing), and a
1038
+ // valid out-of-band approval overrides. Verified 0/93 violations across this repo's anchored files.
1039
+ for (const anchorId of anchorsToScope) {
1040
+ const aspec = specs.find((s) => s.id === anchorId);
1041
+ const ftt = aspec?.sections['Files to Touch'] ?? '';
1042
+ const listed = ftt.match(/[\w@.-]+(?:\/[\w@.*-]+)+/g) ?? []; // concrete path-like tokens only
1043
+ if (listed.length === 0)
1044
+ continue; // prose-only section imposes nothing
1045
+ const rel = relPath;
1046
+ const covered = listed.some((t) => {
1047
+ const tok = t.replace(/\/?\*+$/, ''); // dir/** -> dir
1048
+ const dir = /\.[A-Za-z0-9]+$/.test(tok) ? tok.replace(/\/[^/]*$/, '') : tok; // file -> its dir
1049
+ return rel === tok || rel.startsWith(tok + '/') || rel.startsWith(dir + '/');
1050
+ });
1051
+ if (!covered && !codeWriteCovered) { // @implements A-SPEC-133 — same code-write coverage as the stale gate
1052
+ return {
1053
+ permissionDecision: 'deny',
1054
+ permissionDecisionReason: `[Holmes-Kit] ${rel} is anchored to ${anchorId} but falls outside its 'Files to Touch' scope — extend that section first (spec edit is allowed) or supply out-of-band approval`,
1055
+ };
1056
+ }
1057
+ }
1058
+ return { permissionDecision: 'allow' };
1059
+ }
1060
+ // Synchronous spec loader for hook context (fail-open: directory read errors are skipped)
1061
+ function readSpecsSync(root) {
1062
+ const out = [];
1063
+ const walk = (d) => {
1064
+ if (!fs.existsSync(d))
1065
+ return;
1066
+ // Wrap the entire directory read in try/catch to handle permission errors, ENOTDIR, etc.
1067
+ try {
1068
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
1069
+ const q = path.join(d, e.name);
1070
+ try {
1071
+ if (e.isDirectory())
1072
+ walk(q);
1073
+ else if (e.name.endsWith('.md')) {
1074
+ const spec = (0, spec_parser_1.parseSpec)(fs.readFileSync(q, 'utf8'));
1075
+ // Only include valid specs with non-empty id
1076
+ if (spec.id) {
1077
+ out.push(spec);
1078
+ }
1079
+ }
1080
+ }
1081
+ catch {
1082
+ // Skip individual files/entries that fail to read or parse
1083
+ }
1084
+ }
1085
+ }
1086
+ catch {
1087
+ // Skip directories that cannot be read (permission denied, not a directory, symlink loop, etc.)
1088
+ // This ensures the walk is fully fail-open and never throws.
1089
+ }
1090
+ };
1091
+ walk(root);
1092
+ return out;
1093
+ }
1094
+ // CLI entry point: Claude Code passes hook JSON via stdin
1095
+ // Wrapped in fail-open error handling: any error emits allow + exits 0 to avoid blocking workflows
1096
+ if (require.main === module) {
1097
+ let buf = '';
1098
+ process.stdin.on('data', (c) => (buf += c));
1099
+ process.stdin.on('end', () => {
1100
+ try {
1101
+ // @implements A-SPEC-144 — normalize BEFORE deciding: most of the measured bypasses were a
1102
+ // wrong type reaching code that assumed a string, not an interesting failure of the gate.
1103
+ const input = normalizeHookInput(JSON.parse(buf || '{}'));
1104
+ // @implements A-SPEC-149
1105
+ // The ONE place process context is read. `evaluateHook` no longer reaches for `process.cwd()`
1106
+ // or `process.env` on its own, so a caller always states which project it is judging and under
1107
+ // whose authority — and a unit test's verdict stops depending on the shell it ran in.
1108
+ // @implements A-SPEC-191 §13 — the ledger a single-use approval is spent in must be the
1109
+ // PROJECT's, not the shell's. Round-9 measured it: running an agent from a subdirectory made
1110
+ // `process.cwd()` the anchor, so a consumed nonce opened again and a stray `.ax/ledger` grew
1111
+ // outside the project. The walk stops at the same marker every consumer uses.
1112
+ // @implements A-SPEC-191 §24 (round 12) — `resolveProjectRoot` answers `{root: cwd, marker:
1113
+ // 'given'}` when it finds NOTHING, and this took that answer as a project. On the first-class
1114
+ // `--specs-dir docs/specs` deployment (no `.ax` anywhere) a session started in a subdirectory
1115
+ // therefore declared that subdirectory the project, and every file of the real project became
1116
+ // "outside the project" — config-write, No-Spec-No-Code and the scope check all off from the
1117
+ // first turn. §19/§21 taught `stop.ts` exactly this and the lesson never crossed to here.
1118
+ // Look for the marker, then for the wired spec directory (the deployment's own evidence), and
1119
+ // only then fall back to cwd — as an UNANCHORED root, which mints nothing (see below).
1120
+ const hookRoot = (() => {
1121
+ try {
1122
+ const { resolveProjectRoot } = require('../project/root');
1123
+ const r = resolveProjectRoot(process.cwd());
1124
+ if (r.marker !== 'given')
1125
+ return { root: r.root, anchored: true };
1126
+ }
1127
+ catch { /* fall through to the wired-specs walk */ }
1128
+ const wired = wiredSpecsDir(process.argv, process.env);
1129
+ if (!path.isAbsolute(wired)) {
1130
+ let dir = process.cwd();
1131
+ for (let hop = 0; hop < 64; hop++) {
1132
+ if (fs.existsSync(path.join(dir, wired)))
1133
+ return { root: dir, anchored: true };
1134
+ const parent = path.dirname(dir);
1135
+ if (parent === dir)
1136
+ break;
1137
+ dir = parent;
1138
+ }
1139
+ }
1140
+ return { root: process.cwd(), anchored: false };
1141
+ })();
1142
+ const hookProjectRoot = hookRoot.root;
1143
+ // @implements A-SPEC-191 §17 (round 11) — and the SPECS the gate judges against come from the
1144
+ // same tree. Round-10 moved the project root here and left this path relative, so a session
1145
+ // opened in a subdirectory looked for `<sub>/.ax/specs`, found nothing, and `isGovernedProject`
1146
+ // answered false — which is the branch that fails OPEN. Measured on a real governed project:
1147
+ // No-Spec-No-Code, Files-to-Touch scope, spec promotion and the phase gate all flipped to
1148
+ // allow on the FIRST turn, with no approval, no shell trick and no planted marker; only the
1149
+ // path-pattern rules (.mcp.json, .ax/roles) still bit, which is exactly what makes a
1150
+ // spot-check look healthy. Same one-line shape the Stop hook already carries.
1151
+ const specsDirWired = (() => {
1152
+ const wired = wiredSpecsDir(process.argv, process.env);
1153
+ return path.isAbsolute(wired) ? wired : path.join(hookProjectRoot, wired);
1154
+ })();
1155
+ const out = evaluateHook(input, specsDirWired, {
1156
+ projectRoot: hookProjectRoot,
1157
+ approval: readApprovalFromEnv(),
1158
+ resolveRmSignals: (command) => resolveRmSignals(command, hookProjectRoot),
1159
+ });
1160
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', ...out } }));
1161
+ // N3 provenance: record the audit-critical decision classes on the tamper-evident chain —
1162
+ // every DENY, and every ALLOW that was unlocked by an out-of-band approval. Fail-open: a
1163
+ // provenance write failure never changes the already-computed verdict.
1164
+ try {
1165
+ // @implements A-SPEC-191 §25 (round 13) — the `anchored` flag §24 introduced was never read,
1166
+ // so a hook that found NEITHER a marker NOR the wired spec directory still wrote
1167
+ // `<cwd>/.ax/ledger/` and minted the very marker the next resolution stops at. §19 removed
1168
+ // exactly this from `stop.ts`; it stayed here because round-12's first draft of the guard
1169
+ // was reverted wholesale after it broke three audit-line tests. The guard was not wrong —
1170
+ // its FIXTURES were: a project's audit chain belongs to a project, and `holmes-kit init`
1171
+ // creates `.ax/specs` before any hook runs, so there is no first turn that needs minting.
1172
+ // A directory that carries `.ax` already is a project and is written to as before.
1173
+ if (!hookRoot.anchored && !fs.existsSync(path.join(hookProjectRoot, '.ax'))) {
1174
+ throw new Error('unanchored: no project to record against');
1175
+ }
1176
+ const approval = readApprovalFromEnv();
1177
+ const valid = (0, risk_gate_1.isValidApproval)(approval);
1178
+ // @implements A-SPEC-191 §11 — 'unlocked' must mean an approval OPENED something. The old
1179
+ // predicate (allow + well-formed approval) filed an `approved-action` for every ordinary
1180
+ // allow while any approval sat in the environment: r8 measured a Write, unblocked by nobody,
1181
+ // recorded as approved under a token that had expired in 2000. Under a session key that made
1182
+ // the whole log 'approved-action' and hid the real openings. Ask the counterfactual instead:
1183
+ // would this same call have been denied with no approval in hand?
1184
+ const withoutApproval = out.permissionDecision === 'allow' && valid
1185
+ ? evaluateHook(input, specsDirWired, {
1186
+ projectRoot: hookProjectRoot,
1187
+ approval: undefined,
1188
+ resolveRmSignals: (command) => resolveRmSignals(command, process.cwd()),
1189
+ })
1190
+ : undefined;
1191
+ const unlocked = withoutApproval?.permissionDecision === 'deny';
1192
+ if (out.permissionDecision === 'deny' || unlocked) {
1193
+ const target = input.tool_input?.file_path
1194
+ ? (0, provenance_chain_1.redactTarget)('path', input.tool_input.file_path)
1195
+ : (0, provenance_chain_1.redactTarget)('command', input.tool_input?.command ?? '');
1196
+ // @implements A-SPEC-148 — writes go to THIS replica's chain; the legacy file is read-only now.
1197
+ new ledger_store_1.FileLedgerStore(path.join(hookProjectRoot, path.dirname(provenance_chain_1.PROVENANCE_FILE))).append({
1198
+ ts: new Date().toISOString(),
1199
+ actor: unlocked && approval ? approval.actor : 'agent',
1200
+ kind: out.permissionDecision === 'deny' ? 'gate-deny' : 'approved-action',
1201
+ // REDACTED. Both fields used to carry the raw command; an audit of this repository's own
1202
+ // ledger found 15 of 29 records leaking absolute home paths and 6 matching secret-ish
1203
+ // patterns. That is structural: the gate denies `echo $HOLMES_APPROVAL` and
1204
+ // `HOLMES_LEDGER_KEY=…`, and denials are precisely what gets recorded — so the audit
1205
+ // trail was the one artefact guaranteed to accumulate the secrets the gate protects.
1206
+ // `summary` keeps the human-readable classification, which is the auditable part.
1207
+ summary: out.permissionDecision === 'deny'
1208
+ ? (out.permissionDecisionReason ?? 'denied')
1209
+ : `approved action: ${input.tool_name} ${target}`.slice(0, 300),
1210
+ // @implements A-SPEC-133 — a master-key use becomes an audit line: the approval's
1211
+ // narrowing (or its absence) travels with the record (r7-191: promised, never shipped).
1212
+ inputs: [input.tool_name, target,
1213
+ ...(unlocked ? [(0, provenance_chain_1.approvalMarkers)(approval)] : []), // 한 항목·마지막 자리 — 표지가 갈라져 자기모순이 되지 않는다
1214
+ ].filter(Boolean),
1215
+ // round-9: a deny inherited the actor/rationale/authorization of an ambient approval that
1216
+ // did NOT cover it, so the audit line credited an authorizer who authorized nothing.
1217
+ rationale: unlocked && approval ? approval.rationale : '',
1218
+ // NEVER the raw token (review #1): a fingerprint identifies the credential without
1219
+ // storing the replayable secret; invalid approval attempts record nothing of it.
1220
+ authorization: unlocked && approval ? (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token) : '',
1221
+ });
1222
+ }
1223
+ }
1224
+ catch { /* provenance must never break the gate */ }
1225
+ if (out.permissionDecision === 'deny') {
1226
+ // Belt-and-suspenders: Claude Code's exit-code-2 contract feeds STDERR back to the
1227
+ // model as the block reason, while permissionDecisionReason in the stdout JSON is
1228
+ // read on the exit-0 (allow) channel. Write the reason to both so the remediation
1229
+ // string reaches the model regardless of which channel a given CC version honors.
1230
+ process.stderr.write(`${out.permissionDecisionReason ?? ''}\n`);
1231
+ process.exit(2);
1232
+ }
1233
+ process.exit(0);
1234
+ }
1235
+ catch (err) {
1236
+ // @implements A-SPEC-144
1237
+ // An error must never GRANT. This used to answer `allow` unconditionally, which made a wrong
1238
+ // type in `file_path` a working bypass of No-Spec-No-Code and every future bug in the gate a
1239
+ // silent one. Direction is decided by governance, and the escape is the operator's alone.
1240
+ const message = err instanceof Error ? err.message : String(err);
1241
+ // The error path resolves the project the same way the normal path does — the failure that
1242
+ // brought us here must not also decide which tree we are judging (§17).
1243
+ const errProjectRoot = (() => {
1244
+ try {
1245
+ const { resolveProjectRoot } = require('../project/root');
1246
+ return resolveProjectRoot(process.cwd()).root;
1247
+ }
1248
+ catch {
1249
+ return process.cwd();
1250
+ }
1251
+ })();
1252
+ const decision = gateErrorDecision(message, process.env, process.argv, errProjectRoot);
1253
+ process.stderr.write(`[Holmes-Kit PreToolUse Hook] Error: ${message}\n`);
1254
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', ...decision } }));
1255
+ if (decision.permissionDecision === 'deny') {
1256
+ process.stderr.write(`${decision.permissionDecisionReason ?? ''}\n`);
1257
+ process.exit(2);
1258
+ }
1259
+ process.exit(0);
1260
+ }
1261
+ });
1262
+ }