@holmes-lab/holmes-kit 0.1.11 → 0.1.13

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.
@@ -0,0 +1,142 @@
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.GRANTS_RELDIR = void 0;
37
+ exports.readGrants = readGrants;
38
+ exports.pickCoveringGrant = pickCoveringGrant;
39
+ exports.resolveApproval = resolveApproval;
40
+ exports.consumeGrantFile = consumeGrantFile;
41
+ // @implements A-SPEC-245
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ const risk_gate_1 = require("../guardrail/risk-gate");
45
+ /**
46
+ * The grant file channel — approval delivery with no file editing and no reconnect.
47
+ *
48
+ * @implements A-SPEC-245
49
+ * WHY THIS EXISTS. The only delivery channel was the MCP server's environment. Dogfooded cost of ONE
50
+ * human approval (Notion, 2026-08-23, UX-1): edit `.mcp.json` by hand (the hook blocks the agent
51
+ * doing it) → reconnect MCP → retry — three to four round trips. And the edit left the token behind:
52
+ * the same report found HOLMES_APPROVAL committed to the target repository since 8/18. The forgery
53
+ * defence's root of trust, sitting in plaintext in git.
54
+ *
55
+ * WHAT A GRANT IS. An ordinary `Approval` — no new format — read from
56
+ * `.ax/approvals/grants/<nonce>.json` at JUDGEMENT TIME, so nothing needs restarting. Judgement is
57
+ * the ONE existing `approvalCovers`; single-use for hard-hitl rides the ONE existing ledger nonce
58
+ * consumption. This module adds reading a directory, nothing else.
59
+ *
60
+ * WHY GRANTS MUST BE NARROW. A file is only a grant when it carries `scope` AND `expires` AND
61
+ * `nonce`. The unscoped session master key stays env-only: a masterless key rolling around as a
62
+ * file recreates the committed-token incident this channel exists to remove.
63
+ *
64
+ * TRUST MODEL. The session cannot write `.ax/approvals` — the same identity-based Write gate and
65
+ * shell-command gate that protect `.ax/ledger`. A hand that can create a grant file is by
66
+ * definition outside the session: the operator.
67
+ */
68
+ exports.GRANTS_RELDIR = path.join('.ax', 'approvals', 'grants');
69
+ /** A grant must be narrow: all three of scope (non-empty), expires, nonce. */
70
+ const isNarrow = (a) => Array.isArray(a.scope) && a.scope.length > 0 &&
71
+ typeof a.expires === 'string' && a.expires !== '' &&
72
+ typeof a.nonce === 'string' && a.nonce !== '';
73
+ /**
74
+ * Read the grant files. Filename order, so selection is deterministic.
75
+ *
76
+ * Ignored files are COUNTED, not silently skipped: "I dropped a grant in and nothing opened" must
77
+ * be diagnosable, and a silent skip makes an inert grant indistinguishable from no grant.
78
+ */
79
+ function readGrants(root) {
80
+ const dir = path.join(root, exports.GRANTS_RELDIR);
81
+ let names;
82
+ try {
83
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort();
84
+ }
85
+ catch {
86
+ return { grants: [], ignored: 0 };
87
+ }
88
+ const grants = [];
89
+ let ignored = 0;
90
+ for (const name of names) {
91
+ try {
92
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
93
+ const a = parsed;
94
+ if ((0, risk_gate_1.isValidApproval)(a) && isNarrow(a))
95
+ grants.push(a);
96
+ else
97
+ ignored++;
98
+ }
99
+ catch {
100
+ ignored++;
101
+ }
102
+ }
103
+ return { grants, ignored };
104
+ }
105
+ /** First grant that covers the action — judged by `approvalCovers` and nothing else. Pure. */
106
+ function pickCoveringGrant(grants, action, now) {
107
+ for (const g of Array.isArray(grants) ? grants : []) {
108
+ if ((0, risk_gate_1.approvalCovers)(g, action, now))
109
+ return g;
110
+ }
111
+ return undefined;
112
+ }
113
+ /**
114
+ * Resolve the approval for an action: env first, then the grant files.
115
+ *
116
+ * @implements A-SPEC-245
117
+ * Env-first IS the backward compatibility: an adopter whose flows run on the env token sees not one
118
+ * changed verdict, message, or consumed file from this slice.
119
+ */
120
+ function resolveApproval(root, envApproval, action, now) {
121
+ if ((0, risk_gate_1.approvalCovers)(envApproval, action, now))
122
+ return { approval: envApproval, source: 'env' };
123
+ const grant = pickCoveringGrant(readGrants(root).grants, action, now);
124
+ return grant ? { approval: grant, source: 'grant' } : undefined;
125
+ }
126
+ /**
127
+ * Best-effort single-use: remove the grant file after the act it authorized succeeded.
128
+ *
129
+ * hard-hitl does not rely on this — its replay is refused atomically by the ledger nonce
130
+ * consumption the grant's mandatory nonce rides through. For the other sites, a failed removal
131
+ * leaves a reuse window whose ceiling is the grant's own `expires`; stated rather than hidden.
132
+ * The nonce is flattened to a basename so a traversal-shaped nonce cannot reach outside the dir.
133
+ */
134
+ function consumeGrantFile(root, nonce) {
135
+ try {
136
+ const name = path.basename(String(nonce)) + '.json';
137
+ fs.rmSync(path.join(root, exports.GRANTS_RELDIR, name), { force: true });
138
+ }
139
+ catch {
140
+ /* the act already succeeded; a stuck file is bounded by expires */
141
+ }
142
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The approval request queue — the review list a human batches decisions over.
3
+ *
4
+ * @implements A-SPEC-244
5
+ * WHY THIS EXISTS. Six gate sites refuse with "an out-of-band approval is required" and leave no
6
+ * trace. Dogfooded on a real Python repository (Notion, 2026-08-23): the agent retried the same
7
+ * blocked action six times, the operator had no list of what was waiting, and the narrower the
8
+ * approval token's scope, the more round-trips each decision cost — least privilege and usability in
9
+ * direct conflict (UX-3). Batch review needs a list, and the list must be written by the refusing
10
+ * gate itself; a list humans maintain by hand goes stale the day it is written.
11
+ *
12
+ * WHAT THIS IS NOT. Authority. Nothing in this file opens any gate: a forged `granted` event changes
13
+ * no verdict anywhere, which is the property that lets the queue ship before its directory has any
14
+ * write protection. Authority arrives in the next slice as nonce-bound grant files, verified by the
15
+ * same `approvalCovers`/`consumeNonceExclusively` the env channel already uses.
16
+ *
17
+ * Storage is an append-only jsonl event log folded into state — the idiom `FindingsLedger` and the
18
+ * provenance chain already use, so a reader learns no new model.
19
+ */
20
+ export declare const QUEUE_RELPATH: string;
21
+ export interface PendingRequest {
22
+ id: string;
23
+ kind: string;
24
+ target: string;
25
+ why: string;
26
+ count: number;
27
+ firstTs: string;
28
+ lastTs: string;
29
+ hold?: boolean;
30
+ question?: string;
31
+ }
32
+ export interface QueueState {
33
+ pending: PendingRequest[];
34
+ /** Broken lines are counted, never swallowed: an empty-looking queue must be distinguishable from a corrupted one. */
35
+ malformedLines: number;
36
+ /**
37
+ * The LAST decision per request id.
38
+ *
39
+ * @implements A-SPEC-246
40
+ * Exposed so a refusal can show the agent what a human already decided — measured six consecutive
41
+ * retries of one blocked action, because the agent had no way to see that a human had said no.
42
+ * An invisible decision cannot stop a retry loop.
43
+ */
44
+ decisions: Record<string, {
45
+ event: 'granted' | 'denied';
46
+ reason?: string;
47
+ ts: string;
48
+ }>;
49
+ }
50
+ /**
51
+ * Deterministic request id from the action's identity.
52
+ *
53
+ * @implements A-SPEC-244
54
+ * The same refusal must produce the same id or repeats cannot fold — measured six consecutive
55
+ * stop-hook blocks for one violation, which as six list rows would drown the review list. Random
56
+ * ids cannot fold, and randomness is a material this repository already avoids for determinism.
57
+ * The timestamp rides on the EVENT, never in the id.
58
+ */
59
+ export declare function approvalRequestId(kind: string, target: string): string;
60
+ /**
61
+ * Fold queue events into the current pending list. Pure.
62
+ *
63
+ * An unknown event kind — a future version's vocabulary — neither throws nor flips any existing
64
+ * entry's pending state: an old CLI reading a new queue must not silently mis-report what is
65
+ * waiting.
66
+ */
67
+ export declare function foldQueue(lines: string[]): QueueState;
68
+ /**
69
+ * Append a request event. Fire-and-forget.
70
+ *
71
+ * @implements A-SPEC-244
72
+ * Every failure is swallowed into `false`: this runs INSIDE gate verdict paths, and the moment a
73
+ * dead queue could change a verdict, killing the queue becomes a way to manipulate the gate. The
74
+ * caller uses the boolean only to decide whether to print the review hint.
75
+ */
76
+ export declare function enqueueApprovalRequest(root: string, req: {
77
+ kind: string;
78
+ target: string;
79
+ why: string;
80
+ }): boolean;
81
+ /** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
82
+ export declare function readQueue(root: string): QueueState;
83
+ /**
84
+ * The refusal-message suffix pointing the operator at the review CLI.
85
+ *
86
+ * Appended ONLY when the enqueue succeeded: a hint naming an id that was never written would send
87
+ * the operator to an empty list.
88
+ */
89
+ export declare function queueHint(root: string, req: {
90
+ kind: string;
91
+ target: string;
92
+ why: string;
93
+ }): string;
@@ -0,0 +1,230 @@
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.QUEUE_RELPATH = void 0;
37
+ exports.approvalRequestId = approvalRequestId;
38
+ exports.foldQueue = foldQueue;
39
+ exports.enqueueApprovalRequest = enqueueApprovalRequest;
40
+ exports.readQueue = readQueue;
41
+ exports.queueHint = queueHint;
42
+ // @implements A-SPEC-244
43
+ const node_crypto_1 = require("node:crypto");
44
+ const fs = __importStar(require("node:fs"));
45
+ const path = __importStar(require("node:path"));
46
+ /**
47
+ * The approval request queue — the review list a human batches decisions over.
48
+ *
49
+ * @implements A-SPEC-244
50
+ * WHY THIS EXISTS. Six gate sites refuse with "an out-of-band approval is required" and leave no
51
+ * trace. Dogfooded on a real Python repository (Notion, 2026-08-23): the agent retried the same
52
+ * blocked action six times, the operator had no list of what was waiting, and the narrower the
53
+ * approval token's scope, the more round-trips each decision cost — least privilege and usability in
54
+ * direct conflict (UX-3). Batch review needs a list, and the list must be written by the refusing
55
+ * gate itself; a list humans maintain by hand goes stale the day it is written.
56
+ *
57
+ * WHAT THIS IS NOT. Authority. Nothing in this file opens any gate: a forged `granted` event changes
58
+ * no verdict anywhere, which is the property that lets the queue ship before its directory has any
59
+ * write protection. Authority arrives in the next slice as nonce-bound grant files, verified by the
60
+ * same `approvalCovers`/`consumeNonceExclusively` the env channel already uses.
61
+ *
62
+ * Storage is an append-only jsonl event log folded into state — the idiom `FindingsLedger` and the
63
+ * provenance chain already use, so a reader learns no new model.
64
+ */
65
+ exports.QUEUE_RELPATH = path.join('.ax', 'approvals', 'queue.jsonl');
66
+ /**
67
+ * Deterministic request id from the action's identity.
68
+ *
69
+ * @implements A-SPEC-244
70
+ * The same refusal must produce the same id or repeats cannot fold — measured six consecutive
71
+ * stop-hook blocks for one violation, which as six list rows would drown the review list. Random
72
+ * ids cannot fold, and randomness is a material this repository already avoids for determinism.
73
+ * The timestamp rides on the EVENT, never in the id.
74
+ */
75
+ function approvalRequestId(kind, target) {
76
+ return 'req-' + (0, node_crypto_1.createHash)('sha256').update(`${kind}:${target}`).digest('hex').slice(0, 12);
77
+ }
78
+ /**
79
+ * Fold queue events into the current pending list. Pure.
80
+ *
81
+ * An unknown event kind — a future version's vocabulary — neither throws nor flips any existing
82
+ * entry's pending state: an old CLI reading a new queue must not silently mis-report what is
83
+ * waiting.
84
+ */
85
+ function foldQueue(lines) {
86
+ const pending = new Map();
87
+ const decisions = {};
88
+ let malformedLines = 0;
89
+ for (const raw of Array.isArray(lines) ? lines : []) {
90
+ const line = String(raw ?? '').trim();
91
+ if (line === '')
92
+ continue;
93
+ let e;
94
+ try {
95
+ const parsed = JSON.parse(line);
96
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
97
+ malformedLines++;
98
+ continue;
99
+ }
100
+ e = parsed;
101
+ }
102
+ catch {
103
+ malformedLines++;
104
+ continue;
105
+ }
106
+ const id = typeof e.id === 'string' ? e.id : undefined;
107
+ switch (e.event) {
108
+ case 'requested': {
109
+ if (!id) {
110
+ malformedLines++;
111
+ break;
112
+ }
113
+ const prev = pending.get(id);
114
+ const ts = typeof e.ts === 'string' ? e.ts : '';
115
+ if (prev) {
116
+ prev.count += 1;
117
+ prev.lastTs = ts || prev.lastTs;
118
+ }
119
+ else {
120
+ pending.set(id, {
121
+ id,
122
+ kind: String(e.kind ?? ''),
123
+ target: String(e.target ?? ''),
124
+ why: String(e.why ?? ''),
125
+ count: 1,
126
+ firstTs: ts,
127
+ lastTs: ts,
128
+ });
129
+ }
130
+ break;
131
+ }
132
+ case 'granted':
133
+ case 'denied':
134
+ if (id) {
135
+ pending.delete(id);
136
+ decisions[id] = {
137
+ event: e.event,
138
+ ...(typeof e.reason === 'string' ? { reason: e.reason } : {}),
139
+ ts: typeof e.ts === 'string' ? e.ts : '',
140
+ };
141
+ }
142
+ break;
143
+ case 'held': {
144
+ const entry = id ? pending.get(id) : undefined;
145
+ if (entry) {
146
+ entry.hold = true;
147
+ if (typeof e.question === 'string')
148
+ entry.question = e.question;
149
+ }
150
+ break;
151
+ }
152
+ default:
153
+ // Future vocabulary: preserved by ignoring, never by guessing.
154
+ break;
155
+ }
156
+ }
157
+ return { pending: [...pending.values()], malformedLines, decisions };
158
+ }
159
+ /**
160
+ * Append a request event. Fire-and-forget.
161
+ *
162
+ * @implements A-SPEC-244
163
+ * Every failure is swallowed into `false`: this runs INSIDE gate verdict paths, and the moment a
164
+ * dead queue could change a verdict, killing the queue becomes a way to manipulate the gate. The
165
+ * caller uses the boolean only to decide whether to print the review hint.
166
+ */
167
+ function enqueueApprovalRequest(root, req) {
168
+ try {
169
+ // @implements A-SPEC-244
170
+ // Only under an EXISTING .ax. The constitution suite (§25a) caught the first cut creating
171
+ // .ax/approvals in a bare directory on a config-write refusal — the hook planting a governance
172
+ // marker where governance was never opted into, the exact defect A-SPEC-191 §25 exists to stop.
173
+ // An ungoverned directory gets no queue and no hint; it is not part of the system.
174
+ if (!fs.existsSync(path.join(root, '.ax')))
175
+ return false;
176
+ const file = path.join(root, exports.QUEUE_RELPATH);
177
+ fs.mkdirSync(path.dirname(file), { recursive: true });
178
+ const event = {
179
+ event: 'requested',
180
+ id: approvalRequestId(req.kind, req.target),
181
+ kind: req.kind,
182
+ target: req.target,
183
+ why: req.why,
184
+ ts: new Date().toISOString(),
185
+ };
186
+ fs.appendFileSync(file, JSON.stringify(event) + '\n');
187
+ return true;
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ }
193
+ /** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
194
+ function readQueue(root) {
195
+ try {
196
+ const raw = fs.readFileSync(path.join(root, exports.QUEUE_RELPATH), 'utf8');
197
+ return foldQueue(raw.split('\n'));
198
+ }
199
+ catch {
200
+ return { pending: [], malformedLines: 0, decisions: {} };
201
+ }
202
+ }
203
+ /**
204
+ * The refusal-message suffix pointing the operator at the review CLI.
205
+ *
206
+ * Appended ONLY when the enqueue succeeded: a hint naming an id that was never written would send
207
+ * the operator to an empty list.
208
+ */
209
+ function queueHint(root, req) {
210
+ const id = approvalRequestId(req.kind, req.target);
211
+ // @implements A-SPEC-246
212
+ // Decisions are read BEFORE filing, so a human's answer reaches the agent in the very refusal
213
+ // that would otherwise just say "waiting". A hold shows its question INSTEAD of the waiting line —
214
+ // the question is the actionable part, and the agent's job is to carry it to the user. A denial
215
+ // shows its reason and still re-files: a human may change their mind, and the re-filed entry is
216
+ // how the reviewer sees the agent still wants it.
217
+ const state = readQueue(root);
218
+ const held = state.pending.find((p) => p.id === id && p.hold);
219
+ if (held) {
220
+ return `\n[보류 — 운영자 질문: ${held.question ?? '(질문 없음)'}] 게이트는 닫혀 있습니다.`
221
+ + ' 이 질문에 대한 답을 사용자에게 보고하십시오. 결정은 운영자가 npx holmes-kit approve 에서 내립니다.';
222
+ }
223
+ const decided = state.decisions[id];
224
+ const denialLine = decided?.event === 'denied'
225
+ ? `\n[거부됨${decided.reason ? `: ${decided.reason}` : ''}] 같은 요청을 반복하지 말고 거부 사유를 해소해 사용자와 상의하십시오.`
226
+ : '';
227
+ return enqueueApprovalRequest(root, req)
228
+ ? `${denialLine}\n[승인 대기 ${id}] 운영자: npx holmes-kit approve`
229
+ : denialLine;
230
+ }
@@ -257,14 +257,21 @@ function scopeAxToProject(cmd, projectRoot) {
257
257
  // `state` joined the protected set with P3 (REQ-134): it holds the constitution-debt and the
258
258
  // last-green baseline, so a single-file `rm` there would bypass the WRITE_CODE debt gate — measured
259
259
  // in the campaign completeness critique. Deleting governance state is now hard-hitl like every other.
260
- const PROTECTED_AX_PATH_RE = /(^|\/)\.ax\/(specs|decisions|ledger|cpg_cache|state|roles)(\/|$)/;
260
+ // @implements A-SPEC-245
261
+ // ONE alternation, five regexes. The list of protected .ax directories was spelled out in five
262
+ // separate literals here, and adding 'approvals' to only one of them was measured immediately:
263
+ // 'echo x > .ax/approvals/grants/forged.json' passed while the Write gate refused the same file —
264
+ // the exact two-copies drift this repository has paid for with isTestFile and a restated section
265
+ // splitter. The directories are now written once; each rule keeps its own shape around them.
266
+ const PROTECTED_AX_ALT = 'specs|decisions|ledger|cpg_cache|state|roles|approvals';
267
+ const PROTECTED_AX_PATH_RE = new RegExp(String.raw `(^|/)\.ax/(${PROTECTED_AX_ALT})(/|$)`);
261
268
  // A removal verb + a protected .ax path anywhere in a shell command — the command form of
262
269
  // the governance-path delete, which the structured `kind:'delete'` branch alone would miss.
263
270
  // Not just removal: RELOCATING or CLOBBERING a protected .ax path is equally destructive to it, so
264
271
  // mv/cp/git-mv and a `>` truncation count too (review F4 — `mv .ax/specs /tmp` evaded the rm-only rule).
265
272
  const REMOVAL_VERB_RE = /\b(rm|rmdir|unlink|git\s+rm|git\s+mv|mv|cp)\b/i;
266
- const CLOBBER_AX_RE = />\s*['"]?[^\s;&|]*\.ax\/(specs|decisions|ledger|cpg_cache|state|roles)\b/i;
267
- const CMD_PROTECTED_AX_RE = /(^|[\s'"=(/])\.ax\/(specs|decisions|ledger|cpg_cache|state|roles)(\/|\b)/;
273
+ const CLOBBER_AX_RE = new RegExp(String.raw `>\s*['"]?[^\s;&|]*\.ax/(${PROTECTED_AX_ALT})\b`, 'i');
274
+ const CMD_PROTECTED_AX_RE = new RegExp(String.raw `(^|[\s'"=(/])\.ax/(${PROTECTED_AX_ALT})(/|\b)`);
268
275
  /**
269
276
  * Normalize a command string for protected-path matching: collapse `//` runs and `./` segments so
270
277
  * `./.ax//ledger/x` matches the same rules as `.ax/ledger/x` (adversarial probe: those forms slipped
@@ -285,11 +292,11 @@ function normalizeCommandPaths(cmd) {
285
292
  }
286
293
  // File-mutating shell verbs beyond redirect/removal: these WRITE a target path, so a protected path
287
294
  // as their argument is the same governance breach as `>` or `rm` (probe: tee/cp/install slipped).
288
- const WRITE_VERB_AX_RE = /\b(?:tee|cp|install|dd|ln|mv|rsync)\b[^;&|\n]*\.ax\/(?:specs|decisions|ledger|cpg_cache|state|roles)\b/i;
295
+ const WRITE_VERB_AX_RE = new RegExp(String.raw `\b(?:tee|cp|install|dd|ln|mv|rsync)\b[^;&|\n]*\.ax/(?:${PROTECTED_AX_ALT})\b`, 'i');
289
296
  // An interpreter one-liner naming a protected path: `node -e "...appendFileSync('.ax/ledger/...')"`,
290
297
  // `python3 -c "open('.ax/ledger/...','a')"`. Read-only one-liners are not distinguishable from writes
291
298
  // here, so this is deliberately conservative on the .ax governance surface only.
292
- const INTERPRETER_AX_RE = /\b(?:node|python3?|ruby|perl|deno|bun)\b[^;&|\n]*-(?:e|c|-eval)\b[^\n]*\.ax\/(?:specs|decisions|ledger|cpg_cache|state|roles)\b/i;
299
+ const INTERPRETER_AX_RE = new RegExp(String.raw `\b(?:node|python3?|ruby|perl|deno|bun)\b[^;&|\n]*-(?:e|c|-eval)\b[^\n]*\.ax/(?:${PROTECTED_AX_ALT})\b`, 'i');
293
300
  /**
294
301
  * Commands that only read. Everything else over a governance path is a write.
295
302
  *
@@ -168,6 +168,8 @@ function resolveTarget(root, raw, depth = 0) {
168
168
  }
169
169
  /** Governance directories, relative to the project root. */
170
170
  const PROTECTED_DIRS = [
171
+ // @implements A-SPEC-245 — grant files ARE approvals; a session that can write them mints its own.
172
+ path.join('.ax', 'approvals'),
171
173
  path.join('.ax', 'ledger'),
172
174
  path.join('.ax', 'roles'),
173
175
  path.join('.ax', 'cpg_cache'),
@@ -69,6 +69,8 @@ const rtm_check_1 = require("../rtm/rtm-check");
69
69
  const ledger_store_1 = require("../governance/ledger-store");
70
70
  const provenance_chain_1 = require("../governance/provenance-chain");
71
71
  const risk_gate_1 = require("../guardrail/risk-gate");
72
+ const approval_queue_1 = require("../governance/approval-queue");
73
+ const approval_grants_1 = require("../governance/approval-grants");
72
74
  const approval_blockers_1 = require("../spec/approval-blockers");
73
75
  // Resolve per-rm-target git facts (H1 git-aware guardrail): is each recursive-rm target a
74
76
  // regenerable build artifact (gitignored), version-controlled source (tracked), or escaping the
@@ -399,13 +401,21 @@ function evaluateHook(input, specsDir, opts) {
399
401
  // the approval is single-use its nonce must not already be spent. An out-of-scope, expired, or
400
402
  // replayed token denies exactly as an absent one — one token stops being a master key.
401
403
  if (assessment.level === 'hard-hitl') {
402
- const covers = (0, risk_gate_1.approvalCovers)(approval, { kind: 'shell', target: command }, nowTs);
404
+ // @implements A-SPEC-245 — env first, then the grant files. The grant's mandatory nonce rides
405
+ // the SAME atomic ledger consumption below, so hard-hitl single-use needs no new machinery.
406
+ const resolved = (0, approval_grants_1.resolveApproval)(opts.projectRoot, approval, { kind: 'shell', target: command }, nowTs);
407
+ const covers = resolved !== undefined;
408
+ const acting = resolved?.approval ?? approval;
403
409
  const deny = (why) => ({
404
410
  permissionDecision: 'deny',
405
411
  permissionDecisionReason: `[Holmes-Kit] hard-hitl risk: ${assessment.reasons.join('; ')} — ${why}`,
406
412
  });
407
- if (!covers)
408
- return deny('requires an approval that covers this command');
413
+ // @implements A-SPEC-244 — the refusal itself files the review request. The queue kind is the
414
+ // approval SCOPE kind ('shell'), so the reviewing CLI can mint a covering grant mechanically.
415
+ if (!covers) {
416
+ return deny('requires an approval that covers this command'
417
+ + (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'shell', target: command, why: assessment.reasons.join('; ') }));
418
+ }
409
419
  // @implements A-SPEC-141
410
420
  // Check and spend in ONE atomic operation. The previous shape asked `isNonceConsumed(...)`
411
421
  // and only afterwards appended the consumption record, so two concurrent agents both saw
@@ -420,16 +430,16 @@ function evaluateHook(input, specsDir, opts) {
420
430
  // creating `.ax/ledger` is the wiring working, not a plant. Refusing there would deny the
421
431
  // first legitimate use in every new project. The single-use guarantee still holds: the record
422
432
  // lands in the resolved root, which is the same file every later call resolves to.
423
- if ((0, provenance_chain_1.blankNonce)(approval?.nonce)) {
433
+ if ((0, provenance_chain_1.blankNonce)(acting?.nonce)) {
424
434
  return deny('승인이 단일 사용(nonce)을 선언했으나 값이 비어 있습니다 — 1회성을 집행할 수 없어 거부합니다');
425
435
  }
426
- if (approval?.nonce) {
436
+ if (acting?.nonce) {
427
437
  let won;
428
438
  try {
429
- won = (0, provenance_chain_1.consumeNonceExclusively)(approval.nonce, ledgerFile, {
430
- ts: nowTs, actor: approval.actor, kind: 'nonce-consumed',
439
+ won = (0, provenance_chain_1.consumeNonceExclusively)(acting.nonce, ledgerFile, {
440
+ ts: nowTs, actor: acting.actor, kind: 'nonce-consumed',
431
441
  summary: `consumed single-use approval for: ${(0, provenance_chain_1.redactTarget)('command', command)}`.slice(0, 200),
432
- inputs: [(0, provenance_chain_1.nonceFingerprint)(approval.nonce)], rationale: approval.rationale, authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
442
+ inputs: [(0, provenance_chain_1.nonceFingerprint)(acting.nonce)], rationale: acting.rationale, authorization: (0, provenance_chain_1.authorizationRef)(acting.actor, acting.token),
433
443
  });
434
444
  }
435
445
  catch (err) {
@@ -441,6 +451,9 @@ function evaluateHook(input, specsDir, opts) {
441
451
  }
442
452
  if (!won)
443
453
  return deny('single-use approval already consumed (replay)');
454
+ // The ledger consumption is the authority; removing the file is cleanup for the next reader.
455
+ if (resolved?.source === 'grant')
456
+ (0, approval_grants_1.consumeGrantFile)(opts.projectRoot, acting.nonce);
444
457
  }
445
458
  }
446
459
  // Bash BYPASS gates (adversarial review: `cat > evil.ts` created ungoverned files, `echo x >
@@ -715,13 +728,18 @@ function evaluateHook(input, specsDir, opts) {
715
728
  // this threat and closed only the shell entrance; a plain Write was measured as ALLOWED. Unlike
716
729
  // specs, no approval seal covers this file, so the write itself must be the thing that is gated.
717
730
  if ((0, write_target_1.protectedKindOf)(opts.projectRoot, norm) === path.join('.ax', 'roles')) {
718
- if (!(0, risk_gate_1.approvalCovers)(weApproval, { kind: 'config-write', target: norm }, nowTs)) {
731
+ // @implements A-SPEC-245 grant files answer here too; consumed on the allowed write below.
732
+ const rolesResolved = (0, approval_grants_1.resolveApproval)(opts.projectRoot, weApproval, { kind: 'config-write', target: norm }, nowTs);
733
+ if (rolesResolved === undefined) {
719
734
  return {
720
735
  permissionDecision: 'deny',
721
736
  permissionDecisionReason: '[Holmes-Kit] 역할 정책(.ax/roles/)은 이 게이트가 집행하는 권한 자체를 정의합니다 —'
722
- + ' 에이전트가 스스로 고칠 수 없습니다. config-write를 포함한 대역외 승인이 필요합니다.',
737
+ + ' 에이전트가 스스로 고칠 수 없습니다. config-write를 포함한 대역외 승인이 필요합니다.'
738
+ + (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'config-write', target: norm, why: '역할 정책(.ax/roles/) 쓰기' }),
723
739
  };
724
740
  }
741
+ if (rolesResolved.source === 'grant' && rolesResolved.approval.nonce)
742
+ (0, approval_grants_1.consumeGrantFile)(opts.projectRoot, rolesResolved.approval.nonce);
725
743
  }
726
744
  // @implements A-SPEC-191 §22 — 신원으로 묻는다. 철자 정규식은 프로젝트 안 링크 하나로 빗나갔다.
727
745
  const configKind = (0, write_target_1.protectedFileKindOf)(opts.projectRoot, norm);
@@ -731,12 +749,16 @@ function evaluateHook(input, specsDir, opts) {
731
749
  if (configKind !== null || (0, write_target_1.isProtectedTarget)(opts.projectRoot, norm)) {
732
750
  // @implements A-SPEC-133 — a token scoped to config-write authorizes this; a spec-only or
733
751
  // out-of-scope token does not, so config forgery cannot ride a narrow grant.
734
- if (!(0, risk_gate_1.approvalCovers)(weApproval, { kind: 'config-write', target: norm }, nowTs)) {
752
+ const cfgResolved = (0, approval_grants_1.resolveApproval)(opts.projectRoot, weApproval, { kind: 'config-write', target: norm }, nowTs);
753
+ if (cfgResolved === undefined) {
735
754
  // @implements A-SPEC-193 §8 — 무엇을 막았는지 이름한다. 목록을 문장에 박아 두면 하네스가
736
755
  // 늘 때마다 문면이 거짓이 된다(REQ-155: 엉뚱한 파일을 지목하는 진단의 값은 음수다).
737
756
  const kindName = configKind ?? (0, write_target_1.protectedKindOf)(opts.projectRoot, norm) ?? '설정';
738
- return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${kindName} 은(는) 훅·승인 설정입니다 — HOLMES_APPROVAL 을 위조하거나 거버넌스를 끌 수 있으므로 config-write 를 포함한 대역외 승인이 필요합니다` };
757
+ return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${kindName} 은(는) 훅·승인 설정입니다 — HOLMES_APPROVAL 을 위조하거나 거버넌스를 끌 수 있으므로 config-write 를 포함한 대역외 승인이 필요합니다`
758
+ + (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'config-write', target: norm, why: `${kindName} 쓰기` }) };
739
759
  }
760
+ if (cfgResolved.source === 'grant' && cfgResolved.approval.nonce)
761
+ (0, approval_grants_1.consumeGrantFile)(opts.projectRoot, cfgResolved.approval.nonce);
740
762
  }
741
763
  // Spec-file edit → graph-aware blast-radius risk (Tier-2 push-feed): a change to a widely-depended-on
742
764
  // or foundational (REQ) spec ripples across the graph, so its risk is assessed from the spec