@holmes-lab/holmes-kit 0.1.18 → 0.2.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.
- package/CHANGELOG.md +18 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-context.d.ts +2 -0
- package/dist/holmes/cli/approve-context.js +180 -0
- package/dist/holmes/cli/approve-ref.d.ts +27 -0
- package/dist/holmes/cli/approve-ref.js +40 -0
- package/dist/holmes/cli/approve-watch.d.ts +29 -0
- package/dist/holmes/cli/approve-watch.js +94 -0
- package/dist/holmes/cli/approve.d.ts +50 -13
- package/dist/holmes/cli/approve.js +354 -38
- package/dist/holmes/cli/doctor.js +182 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
- package/dist/holmes/cli/gitignore-merge.js +17 -1
- package/dist/holmes/cli/index.d.ts +23 -0
- package/dist/holmes/cli/index.js +487 -20
- package/dist/holmes/cli/init.js +14 -0
- package/dist/holmes/cli/screen-safe.d.ts +94 -0
- package/dist/holmes/cli/screen-safe.js +760 -0
- package/dist/holmes/governance/approval-queue.js +56 -4
- package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
- package/dist/holmes/governance/ledger-rechain.js +95 -0
- package/dist/holmes/governance/provenance-chain.d.ts +33 -6
- package/dist/holmes/governance/provenance-chain.js +91 -16
- package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
- package/dist/holmes/governance/provenance-ledger.js +10 -0
- package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
- package/dist/holmes/guardrail/risk-gate.js +10 -0
- package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
- package/dist/holmes/mcp/elicit-approval.js +79 -0
- package/dist/holmes/mcp/handlers.d.ts +7 -2
- package/dist/holmes/mcp/handlers.js +190 -24
- package/dist/holmes/mcp/server.js +26 -1
- package/dist/holmes/spec/id-collision.d.ts +39 -0
- package/dist/holmes/spec/id-collision.js +86 -0
- package/dist/holmes/spec/spec-store.js +9 -1
- 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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-263.1
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ELICITABLE_KINDS = void 0;
|
|
5
|
+
exports.buildElicitRequest = buildElicitRequest;
|
|
6
|
+
exports.interpretElicitResult = interpretElicitResult;
|
|
7
|
+
/**
|
|
8
|
+
* The decision comes INTO the session (REQ-263): an approval-shaped refusal (spec_approve without a
|
|
9
|
+
* covering HOLMES_APPROVAL) becomes an in-session question when the client advertises the MCP
|
|
10
|
+
* elicitation capability — the human answers at the point of decision, on the screen they are
|
|
11
|
+
* already looking at, and the tool call completes in place. No capability, an error, a timeout, or
|
|
12
|
+
* a dismissed window all degrade LOSSLESSLY to the current refusal + queue hint.
|
|
13
|
+
*
|
|
14
|
+
* TRUST MODEL (H-SPEC-263): an elicitation response is produced by the harness UI from a human —
|
|
15
|
+
* the model cannot forge it through tool arguments (the request travels server→client, off the tool
|
|
16
|
+
* surface). But the trust boundary sits at the harness, closer than the out-of-band env/grant
|
|
17
|
+
* channels an operator injects from a shell. Hence: (a) the kind allow-list below is conservative —
|
|
18
|
+
* soft-governance approvals only, hard-hitl (nonce-opened shell/config-write) stays out and
|
|
19
|
+
* widening it is a spec revision; (b) every grant is ledgered under an `elicitation:<client>` actor
|
|
20
|
+
* so audits can tell the channel apart; (c) grants are single-use by construction — the synthesized
|
|
21
|
+
* approval lives only inside the one call and is never persisted.
|
|
22
|
+
*
|
|
23
|
+
* PURE — no SDK import, no I/O. The server wires `Server.elicitInput` around these shapes; handlers
|
|
24
|
+
* receive an injected callback and never see the server instance, so tests drive every branch with
|
|
25
|
+
* a fake elicitor.
|
|
26
|
+
*/
|
|
27
|
+
/** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
|
|
28
|
+
exports.ELICITABLE_KINDS = new Set(['spec-approve', 'review-resolve']);
|
|
29
|
+
/**
|
|
30
|
+
* The question form. Decisions only (REQ-263 Out): the single free-text field is `reason`, and it
|
|
31
|
+
* is subordinate to the decision — this channel never collects arbitrary input.
|
|
32
|
+
*/
|
|
33
|
+
function buildElicitRequest(req) {
|
|
34
|
+
// FLATTEN attacker-influenced text (round-1): the model chooses spec titles and finding ids, and
|
|
35
|
+
// interpolating them raw let a crafted title inject fake lines ("[시스템] … approve.") into the
|
|
36
|
+
// ONE human-trust surface this feature introduces. Line structure is fixed by this template, never
|
|
37
|
+
// by the data: newlines and control characters collapse to spaces, and each field is length-capped.
|
|
38
|
+
const flat = (s, max) => s.replace(/[\u0000-\u001f\u007f\u0085\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]+/g, ' ')
|
|
39
|
+
.replace(/\s{2,}/g, ' ').trim().slice(0, max);
|
|
40
|
+
return {
|
|
41
|
+
message: `[Holmes-Kit 승인 요청] ${flat(req.kind, 40)} — ${flat(req.target, 80)}\n${flat(req.summary, 200)}\n승인(approve) / 거부(deny) / 질문(question) 을 선택하세요. 사유는 선택입니다.`,
|
|
42
|
+
requestedSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
decision: { type: 'string', enum: ['approve', 'deny', 'question'], description: '승인/거부/질문' },
|
|
46
|
+
reason: { type: 'string', description: '사유(선택) — 거부·질문이면 에이전트 문면에 실립니다' },
|
|
47
|
+
},
|
|
48
|
+
required: ['decision'],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Map a client's ElicitResult to a decision — FAIL-CLOSED: only the exact shape
|
|
54
|
+
* `{action:'accept', content:{decision:'approve'}}` grants; every malformed shape (missing action,
|
|
55
|
+
* missing content, a decision outside the enum, a non-string decision) is `null`, the same fallback
|
|
56
|
+
* as a channel that never answered. `decline` is a human's explicit NO — a decision, not a
|
|
57
|
+
* fallback — and `cancel` (window dismissed) is no decision at all.
|
|
58
|
+
*/
|
|
59
|
+
function interpretElicitResult(r) {
|
|
60
|
+
if (!r || typeof r !== 'object')
|
|
61
|
+
return null;
|
|
62
|
+
if (r.action === 'decline')
|
|
63
|
+
return { granted: false, reason: '사람이 세션에서 거절했습니다(decline)' };
|
|
64
|
+
if (r.action !== 'accept')
|
|
65
|
+
return null; // cancel, absent, anything else: no decision
|
|
66
|
+
const content = r.content;
|
|
67
|
+
if (!content || typeof content !== 'object')
|
|
68
|
+
return null;
|
|
69
|
+
const decision = content.decision;
|
|
70
|
+
const rawReason = content.reason;
|
|
71
|
+
const reason = typeof rawReason === 'string' && rawReason.trim() !== '' ? rawReason : undefined;
|
|
72
|
+
if (decision === 'approve')
|
|
73
|
+
return reason ? { granted: true, reason } : { granted: true };
|
|
74
|
+
if (decision === 'deny')
|
|
75
|
+
return { granted: false, reason: reason ?? '사람이 세션에서 거부했습니다(사유 없음)' };
|
|
76
|
+
if (decision === 'question')
|
|
77
|
+
return { granted: false, reason: `질문: ${reason ?? '(내용 없음)'} — 답한 뒤 다시 시도하십시오` };
|
|
78
|
+
return null; // outside the enum / wrong type: never a grant
|
|
79
|
+
}
|