@holmes-lab/holmes-kit 0.1.18 → 0.2.1

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 (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/agents.js +5 -1
  4. package/dist/holmes/cli/approve-context.d.ts +2 -0
  5. package/dist/holmes/cli/approve-context.js +180 -0
  6. package/dist/holmes/cli/approve-ref.d.ts +27 -0
  7. package/dist/holmes/cli/approve-ref.js +40 -0
  8. package/dist/holmes/cli/approve-watch.d.ts +29 -0
  9. package/dist/holmes/cli/approve-watch.js +94 -0
  10. package/dist/holmes/cli/approve.d.ts +50 -13
  11. package/dist/holmes/cli/approve.js +354 -38
  12. package/dist/holmes/cli/codex-toml.d.ts +26 -0
  13. package/dist/holmes/cli/codex-toml.js +282 -0
  14. package/dist/holmes/cli/doctor.js +206 -0
  15. package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
  16. package/dist/holmes/cli/gitignore-merge.js +17 -1
  17. package/dist/holmes/cli/index.d.ts +23 -0
  18. package/dist/holmes/cli/index.js +490 -21
  19. package/dist/holmes/cli/init.js +92 -0
  20. package/dist/holmes/cli/interactive-prompt.js +4 -4
  21. package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
  22. package/dist/holmes/cli/screen-safe.d.ts +94 -0
  23. package/dist/holmes/cli/screen-safe.js +760 -0
  24. package/dist/holmes/governance/approval-queue.js +56 -4
  25. package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
  26. package/dist/holmes/governance/ledger-rechain.js +95 -0
  27. package/dist/holmes/governance/provenance-chain.d.ts +33 -6
  28. package/dist/holmes/governance/provenance-chain.js +91 -16
  29. package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
  30. package/dist/holmes/governance/provenance-ledger.js +10 -0
  31. package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
  32. package/dist/holmes/guardrail/risk-gate.js +10 -0
  33. package/dist/holmes/guardrail/write-target.js +7 -0
  34. package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
  35. package/dist/holmes/mcp/elicit-approval.js +79 -0
  36. package/dist/holmes/mcp/handlers.d.ts +7 -2
  37. package/dist/holmes/mcp/handlers.js +190 -24
  38. package/dist/holmes/mcp/server.js +26 -1
  39. package/dist/holmes/spec/id-collision.d.ts +39 -0
  40. package/dist/holmes/spec/id-collision.js +86 -0
  41. package/dist/holmes/spec/spec-store.js +9 -1
  42. package/package.json +1 -1
@@ -43,6 +43,7 @@ exports.queueHint = queueHint;
43
43
  const node_crypto_1 = require("node:crypto");
44
44
  const fs = __importStar(require("node:fs"));
45
45
  const path = __importStar(require("node:path"));
46
+ const screen_safe_1 = require("../cli/screen-safe");
46
47
  /**
47
48
  * The approval request queue — the review list a human batches decisions over.
48
49
  *
@@ -183,6 +184,22 @@ function enqueueApprovalRequest(root, req) {
183
184
  why: req.why,
184
185
  ts: new Date().toISOString(),
185
186
  };
187
+ // TYPE BEFORE WRITE, for the same reason as the read (round-7): `appendFileSync` on a FIFO with
188
+ // no reader blocks in open(2) forever. Guarding only the reader left the GATE wedged — measured:
189
+ // `queueHint` never returned, and every refusal in the pre-tool-use hook, the stop hook and the
190
+ // MCP handlers goes through it. A queue that cannot be appended to is a queue that cannot be
191
+ // used; say so by returning false, which is already the "no queue here" answer.
192
+ // lstat, not stat (round-8): `statSync` follows symlinks, so a link planted at the queue path
193
+ // made the gate append its JSON line into whatever the link pointed at — a shell rc file, in the
194
+ // probe. The queue is a file this project owns or it is not a queue.
195
+ //
196
+ // Round-11: `existsSync` FOLLOWS the link, so on a DANGLING symlink it returns false, the `&&`
197
+ // short-circuits before lstat runs, and `appendFileSync` then CREATES the link's target outside
198
+ // the project. `lstat` unconditionally instead: it stats the link itself, which exists and is
199
+ // not a regular file, so a link — dangling or not — is refused. A path that truly does not exist
200
+ // throws ENOENT and is created, which is the ordinary first-write case.
201
+ if (!isPlainFile(file))
202
+ return false;
186
203
  fs.appendFileSync(file, JSON.stringify(event) + '\n');
187
204
  return true;
188
205
  }
@@ -190,10 +207,33 @@ function enqueueApprovalRequest(root, req) {
190
207
  return false;
191
208
  }
192
209
  }
210
+ /**
211
+ * Is `file` a REGULAR FILE this project may write, judged by the link itself — never what it points
212
+ * at? `lstat` does not follow the symlink, so a link (dangling or live), a FIFO or a directory is
213
+ * refused; a path that does not exist yet throws and is treated as writable (it will be created).
214
+ * Round-11: `existsSync` here would follow the link and let a dangling one through (it resolves to
215
+ * "does not exist"), and the write would then create the target outside the project.
216
+ */
217
+ function isPlainFile(file) {
218
+ try {
219
+ return fs.lstatSync(file).isFile();
220
+ }
221
+ catch {
222
+ return true;
223
+ }
224
+ }
193
225
  /** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
194
226
  function readQueue(root) {
227
+ const file = path.join(root, exports.QUEUE_RELPATH);
195
228
  try {
196
- const raw = fs.readFileSync(path.join(root, exports.QUEUE_RELPATH), 'utf8');
229
+ // TYPE BEFORE READ. `readFileSync` on a FIFO blocks inside open(2) forever — no throw, no
230
+ // timeout, nothing a caller can interrupt. Round-7 measured it: `approve --list` hung, and so
231
+ // did `queueHint`, which every gate refusal calls — so a named pipe here wedges the pre-tool-use
232
+ // hook, the stop hook and the MCP handlers, not just the CLI. `approve-context.ts` has guarded
233
+ // this since the 2026-08-24 hang; the queue reader, which far more code depends on, did not.
234
+ if (!fs.lstatSync(file).isFile())
235
+ return { pending: [], malformedLines: 1, decisions: {} };
236
+ const raw = fs.readFileSync(file, 'utf8');
197
237
  return foldQueue(raw.split('\n'));
198
238
  }
199
239
  catch {
@@ -215,14 +255,26 @@ function queueHint(root, req) {
215
255
  // shows its reason and still re-files: a human may change their mind, and the re-filed entry is
216
256
  // how the reviewer sees the agent still wants it.
217
257
  const state = readQueue(root);
258
+ // @implements A-SPEC-262.1 — round-4: `question` and `reason` come out of queue EVENTS, and this
259
+ // text is read twice: printed in the operator's terminal AND carried in the agent's refusal. Raw,
260
+ // a held request could erase the screen and paint a line claiming the operator had approved. The
261
+ // decision surface's rule applies here too — line structure belongs to the template.
218
262
  const held = state.pending.find((p) => p.id === id && p.hold);
263
+ // Round-5: folded to terminal rows. This text is read in a terminal by the operator as well, and
264
+ // an unfolded hint carrying 300 columns of agent-authored question wraps unpredictably around
265
+ // whatever the gate printed before it.
219
266
  if (held) {
220
- return `\n[보류 운영자 질문: ${held.question ?? '(질문 없음)'}] 게이트는 닫혀 있습니다.`
221
- + ' 질문에 대한 답을 사용자에게 보고하십시오. 결정은 운영자가 npx holmes-kit approve 에서 내립니다.';
267
+ // Round-8: folded with NO margin, so the fold's boundary was the data's to choose and a held
268
+ // question forged a line at column 0 `[승인됨] 운영자가 승인했습니다` with zero escape bytes.
269
+ // Round-6 established that flattening is not enough once text is folded, and gave `[v]` a
270
+ // template-owned margin; this producer, read by the operator AND relayed by the agent, never got
271
+ // one. The first row keeps the template's own opening; the rest sit under the margin.
272
+ return (0, screen_safe_1.wrapColumns)(`\n[보류 — 운영자 질문: ${(0, screen_safe_1.flattenField)(held.question ?? '(질문 없음)', 300)}] 게이트는 닫혀 있습니다.`
273
+ + ' 이 질문에 대한 답을 사용자에게 보고하십시오. 결정은 운영자가 npx holmes-kit approve 에서 내립니다.', 76, ' \u2502 ');
222
274
  }
223
275
  const decided = state.decisions[id];
224
276
  const denialLine = decided?.event === 'denied'
225
- ? `\n[거부됨${decided.reason ? `: ${decided.reason}` : ''}] 같은 요청을 반복하지 말고 거부 사유를 해소해 사용자와 상의하십시오.`
277
+ ? (0, screen_safe_1.wrapColumns)(`\n[거부됨${decided.reason ? `: ${(0, screen_safe_1.flattenField)(decided.reason, 300)}` : ''}] 같은 요청을 반복하지 말고 거부 사유를 해소해 사용자와 상의하십시오.`, 76, ' \u2502 ')
226
278
  : '';
227
279
  return enqueueApprovalRequest(root, req)
228
280
  ? `${denialLine}\n[승인 대기 ${id}] 운영자: npx holmes-kit approve`
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Re-chain a forked ledger against an EXPLICIT mainline — never a greedy one.
3
+ *
4
+ * The 2026-08-23 incident repair measured the trap this module exists to avoid: picking whichever
5
+ * line appears first at a fork as "the mainline" orphaned 571 lines of real history. Here the
6
+ * mainline is handed in by the caller (the committed ledger at a named git ref), it must verify on
7
+ * its own, and every working line that is not in it is re-chained AT THE TAIL in timestamp order —
8
+ * bodies byte-preserved, only seq/prevHash/hash recomputed ([lossless]).
9
+ */
10
+ import { ProvenanceEvent } from './provenance-chain';
11
+ /**
12
+ * Is this event list acceptable as a MAINLINE? [현재 키로 검증] 또는 [keyed-looking 줄이 없고
13
+ * keyless로 검증] — 키 채택 이전의 keyless 이력은 유효한 정본이다(round-3). This arm is for
14
+ * COMMITTED candidates only; the working copy never gets it (round-4 — a keyless re-signed
15
+ * replacement would certify itself). ONE source, shared with the CLI's walk.
16
+ */
17
+ export declare function acceptsAsMainline(events: ProvenanceEvent[], key: string | undefined): boolean;
18
+ export type RechainResult = {
19
+ events: ProvenanceEvent[];
20
+ orphanCount: number;
21
+ alreadyVerified: boolean;
22
+ } | {
23
+ refused: string;
24
+ };
25
+ export declare function rechainLedger(mainline: ProvenanceEvent[], working: ProvenanceEvent[], key?: string): RechainResult;
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ // @implements A-SPEC-256.1
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.acceptsAsMainline = acceptsAsMainline;
5
+ exports.rechainLedger = rechainLedger;
6
+ /**
7
+ * Re-chain a forked ledger against an EXPLICIT mainline — never a greedy one.
8
+ *
9
+ * The 2026-08-23 incident repair measured the trap this module exists to avoid: picking whichever
10
+ * line appears first at a fork as "the mainline" orphaned 571 lines of real history. Here the
11
+ * mainline is handed in by the caller (the committed ledger at a named git ref), it must verify on
12
+ * its own, and every working line that is not in it is re-chained AT THE TAIL in timestamp order —
13
+ * bodies byte-preserved, only seq/prevHash/hash recomputed ([lossless]).
14
+ */
15
+ const provenance_chain_1 = require("./provenance-chain");
16
+ /**
17
+ * Is this event list acceptable as a MAINLINE? [현재 키로 검증] 또는 [keyed-looking 줄이 없고
18
+ * keyless로 검증] — 키 채택 이전의 keyless 이력은 유효한 정본이다(round-3). This arm is for
19
+ * COMMITTED candidates only; the working copy never gets it (round-4 — a keyless re-signed
20
+ * replacement would certify itself). ONE source, shared with the CLI's walk.
21
+ */
22
+ function acceptsAsMainline(events, key) {
23
+ return (0, provenance_chain_1.verifyChain)(events, key).ok
24
+ || (key !== undefined && !(0, provenance_chain_1.hasKeyedLookingEvents)(events) && (0, provenance_chain_1.verifyChain)(events, undefined).ok);
25
+ }
26
+ // The identity of a line is its BODY, not its position in the chain — serialized via bodyCanon,
27
+ // the ONE canonicalization for a body (a divergent second field list once destroyed records).
28
+ const bodyKeyOf = (e) => (0, provenance_chain_1.bodyCanon)(e);
29
+ // @implements A-SPEC-256.1 (round-7) — [lossless] means the ORIGINAL bytes: strip only the chain
30
+ // position (seq/prevHash/hash) and keep every other field EXACTLY as it was, including the ABSENCE
31
+ // of an optional field. `bodyOf` used to normalize (`?? ''`), so an event that omitted
32
+ // `authorization` (recordStaleBreak does) came back with `authorization:""` — a byte difference the
33
+ // hash tolerated but the on-disk line did not. chainNext hashes via canonicalize (same `?? ''`), so
34
+ // the hash is unchanged; only the serialized bytes are now faithful.
35
+ const bodyOf = (e) => {
36
+ const { seq: _s, prevHash: _p, hash: _h, ...body } = e;
37
+ return body;
38
+ };
39
+ function rechainLedger(mainline, working, key) {
40
+ // The working file is ALREADY-VERIFIED only when it verifies under the current key AND carries
41
+ // the mainline as a hash-prefix (round-4): the adoption arm belongs to COMMITTED mainline
42
+ // candidates only — applied to the working copy it certified a keyless re-signed replacement as
43
+ // "no change" (reproduced forgery), and a rolled-back verifying prefix as "no change" (leaving
44
+ // committed history unrestored — the working copy shorter than the committed mainline).
45
+ const mainlineIsPrefixOfWorking = mainline.length <= working.length
46
+ && mainline.every((e, i) => working[i] !== undefined && working[i].hash === e.hash && working[i].seq === e.seq);
47
+ if ((0, provenance_chain_1.verifyChain)(working, key).ok && mainlineIsPrefixOfWorking) {
48
+ return { events: working, orphanCount: 0, alreadyVerified: true };
49
+ }
50
+ if (!acceptsAsMainline(mainline, key)) {
51
+ const vm = (0, provenance_chain_1.verifyChain)(mainline, key);
52
+ return { refused: `본선이 스스로 검증되지 않습니다(brokenAt=${vm.brokenAt ?? '?'}: ${vm.detail ?? ''}) — 깨진 본선 위의 재연쇄는 정본을 위조합니다` };
53
+ }
54
+ // MULTISET difference, not set difference (round-1 adversarial finding): a body present once in
55
+ // the mainline and twice in the working copy carries a second, REAL occurrence — a set filter
56
+ // deleted it while the output still verified. Each mainline occurrence cancels exactly one
57
+ // working occurrence; everything beyond that count is an orphan: [lossless].
58
+ const mainCount = new Map();
59
+ for (const e of mainline) {
60
+ const k = bodyKeyOf(e);
61
+ mainCount.set(k, (mainCount.get(k) ?? 0) + 1);
62
+ }
63
+ const orphans = working.filter((e) => {
64
+ const k = bodyKeyOf(e);
65
+ const left = mainCount.get(k) ?? 0;
66
+ if (left > 0) {
67
+ mainCount.set(k, left - 1);
68
+ return false;
69
+ }
70
+ return true;
71
+ });
72
+ // @implements A-SPEC-256.1 (round-6) — on a KEYED ledger, a legitimate fork is made by a
73
+ // key-holding process, so its orphans are KEYED. A keyless-self-consistent orphan under a
74
+ // configured key is content inserted WITHOUT the key — a forgery verifyChain flags as
75
+ // 'keyless/tampered AFTER key adoption'. Re-signing it would launder the forgery into a valid
76
+ // keyed event, so we refuse rather than sign. (On a keyless ledger, keyless orphans are normal.)
77
+ // Only once the mainline itself has adopted a key: a keyless orphan there is content inserted
78
+ // after adoption WITHOUT the key (a forgery). If the mainline is entirely keyless (pre-adoption
79
+ // history), a keyless orphan is a legitimate legacy line and must NOT be refused (round-7).
80
+ if (key !== undefined && (0, provenance_chain_1.hasKeyedLookingEvents)(mainline)) {
81
+ // "Legitimate keyed fork" = VALIDLY SIGNED UNDER THE CURRENT KEY (round-12): keylessSelfConsistent
82
+ // only caught keyless forgeries, so a garbage-hash or foreign-key body slipped past and got
83
+ // re-signed with the real key. An orphan not keyed-self-consistent under this key is refused.
84
+ const forged = orphans.filter((o) => !(0, provenance_chain_1.keyedSelfConsistent)(o, key));
85
+ if (forged.length > 0) {
86
+ return { refused: `keyed 원장에 키 없이 삽입된 고아 ${forged.length}건이 있습니다 — 재서명하면 위조를 유효한 keyed 이벤트로 세탁합니다. 이 줄들을 조사·제거한 뒤 다시 실행하십시오(예: '${forged[0].summary}').` };
87
+ }
88
+ }
89
+ // Timestamp order, stable for ties (Array#sort is stable): the tail reads chronologically.
90
+ orphans.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
91
+ const events = [...mainline];
92
+ for (const o of orphans)
93
+ events.push((0, provenance_chain_1.chainNext)(events, bodyOf(o), key));
94
+ return { events, orphanCount: orphans.length, alreadyVerified: false };
95
+ }
@@ -93,6 +93,36 @@ export declare function approvalMarkers(approval: {
93
93
  expires?: string;
94
94
  nonce?: string;
95
95
  } | undefined): string;
96
+ /** Is this event signed WITHOUT a key? Recomputed over its OWN prevHash field, so it answers for
97
+ * the line itself, independent of chain linkage — a forked keyless chain is all self-consistent
98
+ * lines with broken linkage, while a keyed line fails this recompute. What fails it is
99
+ * keyed-or-tampered: either way, rewriting it without the key would be forgery. */
100
+ export declare function keylessSelfConsistent(e: ProvenanceEvent): boolean;
101
+ /** True iff any line looks keyed (or tampered) — the refusal predicate for keyless rewriters. */
102
+ export declare function hasKeyedLookingEvents(events: ProvenanceEvent[]): boolean;
103
+ /** Is this event VALIDLY SIGNED under the given key — its own hash recomputed over its own prevHash
104
+ * and body with that key? This is the true "legitimate on a keyed ledger" test (round-12): a
105
+ * keyless-self-consistent line, a foreign-key line, and a garbage-hash line ALL fail it, so it is
106
+ * the correct refusal predicate for orphans on a keyed history — not `keylessSelfConsistent`, which
107
+ * a garbage hash silently slips past. */
108
+ export declare function keyedSelfConsistent(e: ProvenanceEvent, key: string): boolean;
109
+ /** The canonical BODY serialization (all seven fields, seq excluded) — the ONE identity of a ledger
110
+ * line, shared by the rechain engine's orphan detection. */
111
+ export declare function bodyCanon(b: Omit<ProvenanceBody, 'seq'>): string;
112
+ /**
113
+ * Parse the JSONL ledger lines — the ONE parser shared by `load()` and the CLI's rechain (round-9).
114
+ *
115
+ * Tolerance is for a TORN TAIL ONLY: the file's last non-empty line may be a half-written record (a
116
+ * crash mid-append), and that is dropped. A parse failure on ANY EARLIER line, or a line that parses
117
+ * to a non-object (`123`, `"x"`, `[…]` — which would otherwise be laundered into a content-less event
118
+ * that still verifies), is CORRUPTION, not a torn tail: it throws rather than silently vanish, because
119
+ * a rechain that drops a mid-file line then 'repairs' the seq gap makes the loss permanent and
120
+ * invisible to verify(). `throwOnCorruption:false` restores best-effort leniency for readers that
121
+ * must degrade rather than abort (verify() then surfaces the resulting seam).
122
+ */
123
+ export declare function parseLedgerLines(raw: string, file?: string, opts?: {
124
+ throwOnCorruption?: boolean;
125
+ }): ProvenanceEvent[];
96
126
  export declare class ProvenanceChain {
97
127
  private readonly file;
98
128
  constructor(file: string);
@@ -116,12 +146,9 @@ export declare class ProvenanceChain {
116
146
  private appendUnlocked;
117
147
  /**
118
148
  * Records a broken stale hold into the chain itself. Bound so it can be handed to the lock layer,
119
- * which must not import this module (the lock is the lower layer).
120
- *
121
- * Written WITHOUT re-entering the lock: this fires from inside the acquire loop, where the hold has
122
- * just been released and not yet re-taken, so calling `append()` here would deadlock against the
123
- * budget. The event may therefore race a concurrent writer, which is acceptable for a diagnostic —
124
- * a missing note is better than a wedged harness, and `verify()` still reports any resulting seam.
149
+ * which must not import this module (the lock is the lower layer). Written WITHOUT re-entering the
150
+ * lock: this fires from inside the acquire loop, so calling `append()` here would deadlock against
151
+ * the budget. The event may race a concurrent writer, acceptable for a diagnostic.
125
152
  */
126
153
  staleBreakRecorder(): NonNullable<LockOptions['onStaleBreak']>;
127
154
  private recordStaleBreak;
@@ -44,6 +44,11 @@ exports.blankNonce = blankNonce;
44
44
  exports.nonceDeclared = nonceDeclared;
45
45
  exports.nonceFingerprint = nonceFingerprint;
46
46
  exports.approvalMarkers = approvalMarkers;
47
+ exports.keylessSelfConsistent = keylessSelfConsistent;
48
+ exports.hasKeyedLookingEvents = hasKeyedLookingEvents;
49
+ exports.keyedSelfConsistent = keyedSelfConsistent;
50
+ exports.bodyCanon = bodyCanon;
51
+ exports.parseLedgerLines = parseLedgerLines;
47
52
  exports.consumeNonceExclusively = consumeNonceExclusively;
48
53
  // @implements A-SPEC-125.2
49
54
  const crypto = __importStar(require("node:crypto"));
@@ -245,6 +250,85 @@ function approvalMarkers(approval) {
245
250
  const nonce = nonceDeclared(approval?.nonce) ? 'single-use' : 'no-nonce';
246
251
  return `approval[scope=${scope}; expiry=${expiry}; nonce=${nonce}]`;
247
252
  }
253
+ // @implements A-SPEC-256.1 / A-SPEC-256.2
254
+ /** Is this event signed WITHOUT a key? Recomputed over its OWN prevHash field, so it answers for
255
+ * the line itself, independent of chain linkage — a forked keyless chain is all self-consistent
256
+ * lines with broken linkage, while a keyed line fails this recompute. What fails it is
257
+ * keyed-or-tampered: either way, rewriting it without the key would be forgery. */
258
+ function keylessSelfConsistent(e) {
259
+ return computeHash(e.prevHash, e) === e.hash;
260
+ }
261
+ /** True iff any line looks keyed (or tampered) — the refusal predicate for keyless rewriters. */
262
+ function hasKeyedLookingEvents(events) {
263
+ return events.some((e) => !keylessSelfConsistent(e));
264
+ }
265
+ /** Is this event VALIDLY SIGNED under the given key — its own hash recomputed over its own prevHash
266
+ * and body with that key? This is the true "legitimate on a keyed ledger" test (round-12): a
267
+ * keyless-self-consistent line, a foreign-key line, and a garbage-hash line ALL fail it, so it is
268
+ * the correct refusal predicate for orphans on a keyed history — not `keylessSelfConsistent`, which
269
+ * a garbage hash silently slips past. */
270
+ function keyedSelfConsistent(e, key) {
271
+ return computeHash(e.prevHash, e, key) === e.hash;
272
+ }
273
+ /** The canonical BODY serialization (all seven fields, seq excluded) — the ONE identity of a ledger
274
+ * line, shared by the rechain engine's orphan detection. */
275
+ function bodyCanon(b) {
276
+ return JSON.stringify({
277
+ ts: b.ts, actor: b.actor, kind: b.kind, summary: b.summary,
278
+ inputs: b.inputs ?? [], rationale: b.rationale ?? '', authorization: b.authorization ?? '',
279
+ });
280
+ }
281
+ /**
282
+ * Parse the JSONL ledger lines — the ONE parser shared by `load()` and the CLI's rechain (round-9).
283
+ *
284
+ * Tolerance is for a TORN TAIL ONLY: the file's last non-empty line may be a half-written record (a
285
+ * crash mid-append), and that is dropped. A parse failure on ANY EARLIER line, or a line that parses
286
+ * to a non-object (`123`, `"x"`, `[…]` — which would otherwise be laundered into a content-less event
287
+ * that still verifies), is CORRUPTION, not a torn tail: it throws rather than silently vanish, because
288
+ * a rechain that drops a mid-file line then 'repairs' the seq gap makes the loss permanent and
289
+ * invisible to verify(). `throwOnCorruption:false` restores best-effort leniency for readers that
290
+ * must degrade rather than abort (verify() then surfaces the resulting seam).
291
+ */
292
+ function parseLedgerLines(raw, file = '<ledger>', opts = {}) {
293
+ const throwOnCorruption = opts.throwOnCorruption !== false;
294
+ const lines = raw.split('\n');
295
+ let lastNonEmpty = -1;
296
+ for (let i = lines.length - 1; i >= 0; i--) {
297
+ if (lines[i].trim()) {
298
+ lastNonEmpty = i;
299
+ break;
300
+ }
301
+ }
302
+ const out = [];
303
+ for (let i = 0; i < lines.length; i++) {
304
+ const line = lines[i];
305
+ if (!line.trim())
306
+ continue;
307
+ let parsed;
308
+ try {
309
+ parsed = JSON.parse(line);
310
+ }
311
+ catch {
312
+ if (i === lastNonEmpty)
313
+ continue; // torn tail — tolerated
314
+ if (throwOnCorruption)
315
+ throw new Error(`ledger ${file}: unparseable line ${i + 1} (mid-file corruption, not a torn tail)`);
316
+ continue;
317
+ }
318
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
319
+ // The LAST non-empty line is a torn tail regardless of whether it failed to parse or parsed to
320
+ // a non-object (a truncation can leave either) — tolerated consistently (round-12). Earlier
321
+ // non-object lines are corruption and throw in strict mode.
322
+ if (i === lastNonEmpty)
323
+ continue;
324
+ if (throwOnCorruption)
325
+ throw new Error(`ledger ${file}: line ${i + 1} is not a ledger event object (${Array.isArray(parsed) ? 'array' : typeof parsed})`);
326
+ continue;
327
+ }
328
+ out.push(parsed);
329
+ }
330
+ return out;
331
+ }
248
332
  class ProvenanceChain {
249
333
  file;
250
334
  constructor(file) {
@@ -261,16 +345,10 @@ class ProvenanceChain {
261
345
  return [];
262
346
  throw err;
263
347
  }
264
- const out = [];
265
- for (const line of raw.split('\n')) {
266
- if (!line.trim())
267
- continue;
268
- try {
269
- out.push(JSON.parse(line));
270
- }
271
- catch { /* torn tail line tolerated; verify() exposes real tampering */ }
272
- }
273
- return out;
348
+ // Readers degrade rather than abort: a dropped mid-file line leaves a seq gap that verify()
349
+ // reports. The rechain path parses STRICTLY (throwOnCorruption) because it repairs seq gaps and
350
+ // would otherwise make the drop permanent (round-9).
351
+ return parseLedgerLines(raw, this.file, { throwOnCorruption: false });
274
352
  }
275
353
  /** Append a new event chained onto the CURRENT on-disk tail. Returns the appended event.
276
354
  * The signing key comes from HOLMES_LEDGER_KEY (out-of-band env, never tool-settable in-session).
@@ -300,12 +378,9 @@ class ProvenanceChain {
300
378
  }
301
379
  /**
302
380
  * Records a broken stale hold into the chain itself. Bound so it can be handed to the lock layer,
303
- * which must not import this module (the lock is the lower layer).
304
- *
305
- * Written WITHOUT re-entering the lock: this fires from inside the acquire loop, where the hold has
306
- * just been released and not yet re-taken, so calling `append()` here would deadlock against the
307
- * budget. The event may therefore race a concurrent writer, which is acceptable for a diagnostic —
308
- * a missing note is better than a wedged harness, and `verify()` still reports any resulting seam.
381
+ * which must not import this module (the lock is the lower layer). Written WITHOUT re-entering the
382
+ * lock: this fires from inside the acquire loop, so calling `append()` here would deadlock against
383
+ * the budget. The event may race a concurrent writer, acceptable for a diagnostic.
309
384
  */
310
385
  staleBreakRecorder() { return this.recordStaleBreak; }
311
386
  recordStaleBreak = (info) => {
@@ -35,6 +35,13 @@ export interface LedgerVerifyResult {
35
35
  brokenAt?: number;
36
36
  }[];
37
37
  }
38
+ /**
39
+ * Is `name` a LIVE ledger file this store owns — the legacy `provenance.jsonl` or a canonical replica
40
+ * `provenance.<id>.jsonl` (no dots in <id>)? This is the ONE definition of "a ledger file"; the
41
+ * `ledger rechain` CLI reused a hand-copied regex that drifted once (a loose `.*` swept in backups
42
+ * like `provenance.bak.jsonl`), so both now share this single source (round-16).
43
+ */
44
+ export declare function isLedgerFilename(name: string): boolean;
38
45
  export declare class ProvenanceLedger {
39
46
  private readonly dir;
40
47
  /**
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ProvenanceLedger = void 0;
37
+ exports.isLedgerFilename = isLedgerFilename;
37
38
  // @implements A-SPEC-206
38
39
  // @implements A-SPEC-148
39
40
  const fs = __importStar(require("node:fs"));
@@ -42,6 +43,15 @@ const provenance_chain_1 = require("./provenance-chain");
42
43
  const replica_id_1 = require("./replica-id");
43
44
  const LEGACY = 'provenance.jsonl';
44
45
  const REPLICA_FILE = /^provenance\.([^.]+)\.jsonl$/;
46
+ /**
47
+ * Is `name` a LIVE ledger file this store owns — the legacy `provenance.jsonl` or a canonical replica
48
+ * `provenance.<id>.jsonl` (no dots in <id>)? This is the ONE definition of "a ledger file"; the
49
+ * `ledger rechain` CLI reused a hand-copied regex that drifted once (a loose `.*` swept in backups
50
+ * like `provenance.bak.jsonl`), so both now share this single source (round-16).
51
+ */
52
+ function isLedgerFilename(name) {
53
+ return name === LEGACY || REPLICA_FILE.test(name);
54
+ }
45
55
  /**
46
56
  * The project root that owns a ledger directory.
47
57
  *
@@ -4,10 +4,20 @@ export interface Approval {
4
4
  actor: string;
5
5
  token: string;
6
6
  rationale: string;
7
- /** If present, the approval authorizes ONLY actions matching some entry (deny-by-default within). */
7
+ /**
8
+ * If present, the approval authorizes ONLY actions matching some entry (deny-by-default within).
9
+ * `exact` (round-14): the pattern is a LITERAL target, compared by equality — `*` and a trailing
10
+ * `/` are ordinary characters, not wildcards. A grant DERIVED from a queue target (a real command
11
+ * the operator read on the decision row) sets this, because that target is a literal string, not a
12
+ * glob: without it, approving `rm -rf *` minted a grant that authorized `rm -rf /any/path` — the
13
+ * agent's choice within the wildcard, never the command the operator saw (round-3's doctrine, "the
14
+ * grant is for what the human saw", violated in the scope layer). Absent = glob, unchanged, for
15
+ * operator-authored patterns (the HOLMES_APPROVAL session key) where a `*` is intended.
16
+ */
8
17
  scope?: {
9
18
  kind: string;
10
19
  pattern: string;
20
+ exact?: boolean;
11
21
  }[];
12
22
  /** ISO 8601. If present, the approval is dead once the action timestamp passes it. */
13
23
  expires?: string;
@@ -56,6 +56,16 @@ function approvalCovers(a, action, now) {
56
56
  return false;
57
57
  if (s.kind !== '*' && s.kind !== action.kind)
58
58
  return false;
59
+ // A literal-target scope matches by equality — no wildcard, no directory prefix (round-14).
60
+ // A PRESENT `exact` that is not a strict boolean is malformed, and a malformed narrowing flag
61
+ // fails CLOSED — the entry matches nothing (round-15). Falling through to glob would be the
62
+ // wrong direction: `{pattern:'rm -rf *', exact:'true'}` would then authorize `rm -rf /any`, the
63
+ // exact widening round-14 closed, resurrected by a typed-wrong flag. This is the module's own
64
+ // doctrine ("a deadline we cannot read is one we assume passed") applied to authority width.
65
+ if (s.exact !== undefined && typeof s.exact !== 'boolean')
66
+ return false;
67
+ if (s.exact === true)
68
+ return s.pattern === action.target;
59
69
  return patternMatches(s.pattern, action.target);
60
70
  });
61
71
  }
@@ -256,6 +256,13 @@ function protectedFileKindOf(root, raw) {
256
256
  const parent = pp.basename(pp.dirname(resolved)).toLowerCase();
257
257
  if (parent === '.agents' && (name === 'hooks.json' || name === 'mcp_config.json'))
258
258
  return `.agents/${name}`;
259
+ // @implements A-SPEC-266 — codex 는 훅 게이트가 없어(HARNESS_ENFORCES.codex=false) `.codex/config.toml`
260
+ // 의 [mcp_servers.holmes-kit] 가 codex 지배의 **유일한** 경로다. 이 슬라이스가 그 배선을 죽은 JSON 에서
261
+ // 실제 거버넌스로 바꿨으므로, `.agents/*`·`.mcp.json` 과 정확히 같은 성질이 된다 — 세션이 고칠 수 있으면
262
+ // 세션이 codex 게이트를 끈다. A-SPEC-193 §8 의 원칙을 새로 자격을 갖춘 파일에 적용해 같은 self-disarm
263
+ // 보호를 준다. 구식 `.codex/mcp_config.json` 도 잠근다 — 그 파일을 되살려 배선을 흐릴 수 없게.
264
+ if (parent === '.codex' && (name === 'config.toml' || name === 'mcp_config.json'))
265
+ return `.codex/${name}`;
259
266
  // Example/sample/template/dist copies carry no secret — the same exclusion the hook's regex had.
260
267
  if (/^\.env(\.[\w-]+)?$/.test(name) && !/\.(example|sample|template|dist)$/.test(name))
261
268
  return '.env';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The decision comes INTO the session (REQ-263): an approval-shaped refusal (spec_approve without a
3
+ * covering HOLMES_APPROVAL) becomes an in-session question when the client advertises the MCP
4
+ * elicitation capability — the human answers at the point of decision, on the screen they are
5
+ * already looking at, and the tool call completes in place. No capability, an error, a timeout, or
6
+ * a dismissed window all degrade LOSSLESSLY to the current refusal + queue hint.
7
+ *
8
+ * TRUST MODEL (H-SPEC-263): an elicitation response is produced by the harness UI from a human —
9
+ * the model cannot forge it through tool arguments (the request travels server→client, off the tool
10
+ * surface). But the trust boundary sits at the harness, closer than the out-of-band env/grant
11
+ * channels an operator injects from a shell. Hence: (a) the kind allow-list below is conservative —
12
+ * soft-governance approvals only, hard-hitl (nonce-opened shell/config-write) stays out and
13
+ * widening it is a spec revision; (b) every grant is ledgered under an `elicitation:<client>` actor
14
+ * so audits can tell the channel apart; (c) grants are single-use by construction — the synthesized
15
+ * approval lives only inside the one call and is never persisted.
16
+ *
17
+ * PURE — no SDK import, no I/O. The server wires `Server.elicitInput` around these shapes; handlers
18
+ * receive an injected callback and never see the server instance, so tests drive every branch with
19
+ * a fake elicitor.
20
+ */
21
+ /** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
22
+ export declare const ELICITABLE_KINDS: ReadonlySet<string>;
23
+ export interface ElicitApprovalRequest {
24
+ kind: string;
25
+ target: string;
26
+ /** Human-facing one-liner: what is being approved (id, title, sealed-or-not). */
27
+ summary: string;
28
+ }
29
+ /** The human's decision. `null` from an elicitor means "the channel gave no answer — fall back". */
30
+ export interface ElicitDecision {
31
+ granted: boolean;
32
+ reason?: string;
33
+ }
34
+ export type Elicitor = (req: ElicitApprovalRequest) => Promise<ElicitDecision | null>;
35
+ /**
36
+ * The question form. Decisions only (REQ-263 Out): the single free-text field is `reason`, and it
37
+ * is subordinate to the decision — this channel never collects arbitrary input.
38
+ */
39
+ export declare function buildElicitRequest(req: ElicitApprovalRequest): {
40
+ message: string;
41
+ requestedSchema: {
42
+ type: 'object';
43
+ properties: {
44
+ decision: {
45
+ type: 'string';
46
+ enum: string[];
47
+ description: string;
48
+ };
49
+ reason: {
50
+ type: 'string';
51
+ description: string;
52
+ };
53
+ };
54
+ required: string[];
55
+ };
56
+ };
57
+ /**
58
+ * Map a client's ElicitResult to a decision — FAIL-CLOSED: only the exact shape
59
+ * `{action:'accept', content:{decision:'approve'}}` grants; every malformed shape (missing action,
60
+ * missing content, a decision outside the enum, a non-string decision) is `null`, the same fallback
61
+ * as a channel that never answered. `decline` is a human's explicit NO — a decision, not a
62
+ * fallback — and `cancel` (window dismissed) is no decision at all.
63
+ */
64
+ export declare function interpretElicitResult(r: {
65
+ action?: unknown;
66
+ content?: unknown;
67
+ } | null | undefined): ElicitDecision | null;