@holmes-lab/holmes-kit 0.1.17 → 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 +27 -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 +61 -13
- package/dist/holmes/cli/approve.js +374 -36
- package/dist/holmes/cli/doctor.js +262 -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 +496 -21
- package/dist/holmes/cli/init.js +14 -0
- package/dist/holmes/cli/mcp-launcher.js +6 -1
- 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 +39 -2
- package/dist/holmes/server/dashboard.js +2 -2
- 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
|
@@ -37,11 +37,17 @@ exports.grantRequest = grantRequest;
|
|
|
37
37
|
exports.denyRequest = denyRequest;
|
|
38
38
|
exports.holdRequest = holdRequest;
|
|
39
39
|
exports.renderPending = renderPending;
|
|
40
|
+
exports.subjectCells = subjectCells;
|
|
41
|
+
exports.subjectRoom = subjectRoom;
|
|
42
|
+
exports.decisionSubject = decisionSubject;
|
|
43
|
+
exports.decisionDetail = decisionDetail;
|
|
44
|
+
exports.renderNonTtyHint = renderNonTtyHint;
|
|
40
45
|
exports.runInteractive = runInteractive;
|
|
41
46
|
// @implements A-SPEC-246
|
|
42
47
|
const fs = __importStar(require("node:fs"));
|
|
43
48
|
const path = __importStar(require("node:path"));
|
|
44
49
|
const approval_queue_1 = require("../governance/approval-queue");
|
|
50
|
+
const screen_safe_1 = require("./screen-safe");
|
|
45
51
|
const approval_grants_1 = require("../governance/approval-grants");
|
|
46
52
|
/**
|
|
47
53
|
* `holmes-kit approve` — the surface where a human decides.
|
|
@@ -61,18 +67,43 @@ const approval_grants_1 = require("../governance/approval-grants");
|
|
|
61
67
|
* The moment the human must know scope syntax, this UI has no reason to exist.
|
|
62
68
|
*/
|
|
63
69
|
const DEFAULT_TTL_MINUTES = 30;
|
|
70
|
+
// @implements A-SPEC-262.1 — round-5: the guard bounded TTL below only. `--ttl 1e12` threw an
|
|
71
|
+
// uncaught RangeError out of the interactive loop (killing every remaining decision on the surface
|
|
72
|
+
// whose purpose is making decisions cheap), and `--ttl 1e10` minted a grant expiring in the year
|
|
73
|
+
// 21039 — permanent authority from a slipped digit, in the channel REQ-245 built on "a grant is
|
|
74
|
+
// only a grant when it is narrow". A week is already generous for a narrow grant.
|
|
75
|
+
const MAX_TTL_MINUTES = 7 * 24 * 60;
|
|
64
76
|
const DEFAULT_RATIONALE = 'approved via holmes-kit approve';
|
|
65
77
|
const findPending = (root, id) => {
|
|
66
78
|
const state = (0, approval_queue_1.readQueue)(root);
|
|
67
79
|
return { entry: state.pending.find((p) => p.id === id), state };
|
|
68
80
|
};
|
|
69
81
|
/** CLI-side event writer. The CLI runs in the operator's terminal, outside the session gates. */
|
|
82
|
+
/**
|
|
83
|
+
* A path this process may write: it exists and is a regular file, or it does not exist yet.
|
|
84
|
+
*
|
|
85
|
+
* Round-8: round-7 gave the queue's reader and its enqueue this guard and stopped there, so the two
|
|
86
|
+
* writes a DECISION makes — the audit append and the grant file — could still be aimed at a FIFO.
|
|
87
|
+
* Measured: a named pipe at the grant temp path left the shipped `approve --grant 1` blocked inside
|
|
88
|
+
* open(2) with no output at all, and that request could never be decided again. `lstat`, so a
|
|
89
|
+
* symlink is refused rather than followed somewhere this project does not own.
|
|
90
|
+
*/
|
|
91
|
+
const isWritablePath = (file) => {
|
|
92
|
+
try {
|
|
93
|
+
return fs.lstatSync(file).isFile();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return true;
|
|
97
|
+
} // absent is fine; it gets created
|
|
98
|
+
};
|
|
70
99
|
const appendEvent = (root, event) => {
|
|
71
100
|
try {
|
|
72
101
|
const file = path.join(root, approval_queue_1.QUEUE_RELPATH);
|
|
73
102
|
if (!fs.existsSync(path.join(root, '.ax')))
|
|
74
103
|
return false;
|
|
75
104
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
105
|
+
if (!isWritablePath(file))
|
|
106
|
+
return false;
|
|
76
107
|
fs.appendFileSync(file, JSON.stringify({ ...event, ts: new Date().toISOString() }) + '\n');
|
|
77
108
|
return true;
|
|
78
109
|
}
|
|
@@ -87,48 +118,115 @@ const appendEvent = (root, event) => {
|
|
|
87
118
|
* A TTL that is not a positive integer is refused — a grant with no meaningful expiry violates the
|
|
88
119
|
* narrowness rule the whole file channel rests on (REQ-245).
|
|
89
120
|
*/
|
|
121
|
+
/**
|
|
122
|
+
* @implements A-SPEC-262.1
|
|
123
|
+
* The id must DERIVE from the record it labels. Round-3: `foldQueue` takes `{id, kind, target}`
|
|
124
|
+
* verbatim, and nothing checked that `id === approvalRequestId(kind, target)` — so a record could
|
|
125
|
+
* pair a benign-looking id with a hostile target and the grant took its scope pattern from the
|
|
126
|
+
* fresh read. No escape byte is needed for that attack; the round-1 echo line even confirms the
|
|
127
|
+
* substituted subject, because the echo is rendered from the snapshot the human saw. Every
|
|
128
|
+
* legitimate entry satisfies this (enqueueApprovalRequest computes the id the same way), so a
|
|
129
|
+
* mismatch is a tampered or corrupt record and is refused rather than acted on.
|
|
130
|
+
*/
|
|
131
|
+
// Folded to rows at the source (round-5): a refusal that wraps is a refusal whose first clause can
|
|
132
|
+
// scroll away, and this one names the reason a decision was withheld.
|
|
133
|
+
const TAMPERED = ['큐 기록이 자기일관되지 않습니다 — id 가 kind·target 에서 나온 값이 아닙니다.',
|
|
134
|
+
' 위조되었거나 손상된 줄이므로 이 요청은 결정하지 않습니다'].join('\n');
|
|
135
|
+
function recordIsSelfConsistent(entry) {
|
|
136
|
+
return entry.id === (0, approval_queue_1.approvalRequestId)(entry.kind, entry.target);
|
|
137
|
+
}
|
|
90
138
|
function grantRequest(root, id, opts) {
|
|
91
139
|
const ttl = opts.ttlMinutes ?? DEFAULT_TTL_MINUTES;
|
|
92
|
-
if (typeof ttl !== 'number' || !Number.isFinite(ttl) || !Number.isInteger(ttl) || ttl <= 0) {
|
|
93
|
-
return { ok: false, reason: `TTL 은 1
|
|
140
|
+
if (typeof ttl !== 'number' || !Number.isFinite(ttl) || !Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_MINUTES) {
|
|
141
|
+
return { ok: false, reason: `TTL 은 1..${MAX_TTL_MINUTES} 사이의 정수(분)여야 합니다 — 받은 값: ${(0, screen_safe_1.rowField)(String(ttl), 40)}` };
|
|
94
142
|
}
|
|
95
143
|
const { entry } = findPending(root, id);
|
|
96
144
|
if (!entry)
|
|
97
|
-
return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다 — npx holmes-kit approve --list 로 확인하십시오` };
|
|
145
|
+
return { ok: false, reason: `대기 중인 요청 ${(0, screen_safe_1.rowField)(id, 40)} 가 없습니다 — npx holmes-kit approve --list 로 확인하십시오` };
|
|
146
|
+
if (!recordIsSelfConsistent(entry))
|
|
147
|
+
return { ok: false, reason: TAMPERED };
|
|
148
|
+
// A "does this match what the operator saw" parameter was written here too and then removed: the
|
|
149
|
+
// derivation check above already covers every substitution. A different (kind,target) yields a
|
|
150
|
+
// different id, so the lookup misses; the SAME id can only carry the same subject. An unexercised
|
|
151
|
+
// second guard is not defence in depth, it is a line nobody can prove still works (round-3
|
|
152
|
+
// mutation test: neutering it left the suite green).
|
|
98
153
|
const expires = new Date(Date.now() + ttl * 60_000).toISOString();
|
|
99
154
|
const grant = {
|
|
100
155
|
actor: opts.actor,
|
|
101
156
|
token: 'grant',
|
|
102
157
|
rationale: opts.rationale?.trim() ? opts.rationale : DEFAULT_RATIONALE,
|
|
103
|
-
|
|
158
|
+
// exact (round-14): the queue target is a LITERAL command the operator read on the decision
|
|
159
|
+
// row, not a glob. Without this a `*` in the target became a wildcard, so approving `rm -rf *`
|
|
160
|
+
// authorized `rm -rf /any/path` — the grant covering commands the operator never saw, breaking
|
|
161
|
+
// "the grant is for what the human saw" (round-3) in the scope layer.
|
|
162
|
+
scope: [{ kind: entry.kind, pattern: entry.target, exact: true }],
|
|
104
163
|
expires,
|
|
105
164
|
nonce: id,
|
|
106
165
|
};
|
|
166
|
+
// @implements A-SPEC-262.1 — AUDIT AND AUTHORITY STAND OR FALL TOGETHER.
|
|
167
|
+
// Round-3 wrote the grant first and discarded the append's failure: a live nonce with no record.
|
|
168
|
+
// Round-4 put the audit first and created the mirror: an orphan `granted` event with no grant,
|
|
169
|
+
// which foldQueue treats as decided — the request vanished from `--list` and the log claimed an
|
|
170
|
+
// approval that never happened. The temp file makes the ordering honest: everything that can fail
|
|
171
|
+
// happens BEFORE either half is committed, and the rename is intra-directory.
|
|
172
|
+
const dir = path.join(root, approval_grants_1.GRANTS_RELDIR);
|
|
173
|
+
const tmp = path.join(dir, `.${id}.json.tmp`);
|
|
107
174
|
try {
|
|
108
|
-
const dir = path.join(root, approval_grants_1.GRANTS_RELDIR);
|
|
109
175
|
fs.mkdirSync(dir, { recursive: true });
|
|
110
|
-
|
|
176
|
+
if (!isWritablePath(tmp) || !isWritablePath(path.join(dir, `${id}.json`))) {
|
|
177
|
+
return { ok: false, reason: '그랜트 파일 자리가 일반 파일이 아닙니다 — 큐 디렉터리를 확인하십시오' };
|
|
178
|
+
}
|
|
179
|
+
fs.writeFileSync(tmp, JSON.stringify(grant, null, 2));
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
// Round-7: budgeted at 200 columns on a one-row screen — measured 232 columns, three terminal
|
|
183
|
+
// rows, and three of them in a row scrolled the [A] confirmation off a 24-row terminal. The same
|
|
184
|
+
// class round-6 closed in findingContext, three files over.
|
|
185
|
+
return { ok: false, reason: `그랜트 파일을 쓸 수 없습니다:\n ${(0, screen_safe_1.rowField)(e instanceof Error ? e.message : String(e), 76)}` };
|
|
186
|
+
}
|
|
187
|
+
if (!appendEvent(root, { event: 'granted', id, actor: opts.actor, expires })) {
|
|
188
|
+
try {
|
|
189
|
+
fs.rmSync(tmp, { force: true });
|
|
190
|
+
}
|
|
191
|
+
catch { /* best effort: the temp name is not a grant */ }
|
|
192
|
+
return { ok: false, reason: '승인 기록(큐 이벤트)을 쓸 수 없어 그랜트를 발급하지 않았습니다 — .ax/approvals 쓰기 권한을 확인하십시오' };
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
fs.renameSync(tmp, path.join(dir, `${id}.json`));
|
|
111
196
|
}
|
|
112
197
|
catch (e) {
|
|
113
|
-
return { ok: false, reason: `그랜트 파일을
|
|
198
|
+
return { ok: false, reason: `그랜트 파일을 제자리에 놓지 못했습니다 — 큐에는 승인 기록이 남았으니 다시 실행하십시오\n ${(0, screen_safe_1.rowField)(e instanceof Error ? e.message : String(e), 76)}` };
|
|
114
199
|
}
|
|
115
|
-
appendEvent(root, { event: 'granted', id, actor: opts.actor });
|
|
116
200
|
return { ok: true, expires };
|
|
117
201
|
}
|
|
118
|
-
/** Deny, with a reason the agent will see in its next refusal. */
|
|
119
202
|
function denyRequest(root, id, reason, actor) {
|
|
120
203
|
const { entry } = findPending(root, id);
|
|
121
204
|
if (!entry)
|
|
122
|
-
return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다` };
|
|
123
|
-
|
|
205
|
+
return { ok: false, reason: `대기 중인 요청 ${(0, screen_safe_1.rowField)(id, 40)} 가 없습니다` };
|
|
206
|
+
// @implements A-SPEC-262.1 — round-4: the derivation gate guarded grant only, so a tampered record
|
|
207
|
+
// let the operator "deny" one subject while the denial was keyed to another id entirely.
|
|
208
|
+
if (!recordIsSelfConsistent(entry))
|
|
209
|
+
return { ok: false, reason: TAMPERED };
|
|
210
|
+
// The event IS the decision here (there is no second artefact), so a failed append is a failed
|
|
211
|
+
// decision — round-5: ✓ and exit 0 were printed over a read-only queue while nothing was recorded,
|
|
212
|
+
// and the agent, never seeing the denial, retried forever.
|
|
213
|
+
if (!appendEvent(root, { event: 'denied', id, reason, actor })) {
|
|
214
|
+
return { ok: false, reason: '거부 기록(큐 이벤트)을 쓸 수 없었습니다 — .ax/approvals 쓰기 권한을 확인하십시오. 아무것도 기록되지 않았습니다' };
|
|
215
|
+
}
|
|
124
216
|
return { ok: true };
|
|
125
217
|
}
|
|
126
218
|
/** Hold with a question — 추후 승인/거부. The agent carries the question to the user. */
|
|
127
219
|
function holdRequest(root, id, question, actor) {
|
|
128
220
|
const { entry } = findPending(root, id);
|
|
129
221
|
if (!entry)
|
|
130
|
-
return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다` };
|
|
131
|
-
|
|
222
|
+
return { ok: false, reason: `대기 중인 요청 ${(0, screen_safe_1.rowField)(id, 40)} 가 없습니다` };
|
|
223
|
+
// @implements A-SPEC-262.1 — round-4: the derivation gate guarded grant only, so a tampered record
|
|
224
|
+
// let the operator "deny" one subject while the denial was keyed to another id entirely.
|
|
225
|
+
if (!recordIsSelfConsistent(entry))
|
|
226
|
+
return { ok: false, reason: TAMPERED };
|
|
227
|
+
if (!appendEvent(root, { event: 'held', id, question, actor })) {
|
|
228
|
+
return { ok: false, reason: '보류 기록(큐 이벤트)을 쓸 수 없었습니다 — .ax/approvals 쓰기 권한을 확인하십시오. 아무것도 기록되지 않았습니다' };
|
|
229
|
+
}
|
|
132
230
|
return { ok: true };
|
|
133
231
|
}
|
|
134
232
|
/**
|
|
@@ -139,15 +237,33 @@ function holdRequest(root, id, question, actor) {
|
|
|
139
237
|
* teaches syntax, humans start typing it.
|
|
140
238
|
*/
|
|
141
239
|
function renderPending(state) {
|
|
142
|
-
if (state.pending.length === 0)
|
|
240
|
+
if (state.pending.length === 0) {
|
|
241
|
+
// Round-7: this early return made the malformed-line row below UNREACHABLE in exactly the case
|
|
242
|
+
// it exists for — a wholly corrupted queue printed "no requests waiting" and exited 0, which is
|
|
243
|
+
// what an agent would see after destroying it. approval-queue.ts promises the opposite:
|
|
244
|
+
// "an empty-looking queue must be distinguishable from a corrupted one".
|
|
245
|
+
if (state.malformedLines > 0) {
|
|
246
|
+
return ['승인 대기 중인 요청이 없습니다.',
|
|
247
|
+
`(큐에 읽을 수 없는 줄 ${state.malformedLines}건 — 손상 여부를 확인하십시오)`].join('\n');
|
|
248
|
+
}
|
|
143
249
|
return '승인 대기 중인 요청이 없습니다.';
|
|
250
|
+
}
|
|
144
251
|
const lines = [`◆ 승인 대기 ${state.pending.length}건`, ''];
|
|
145
252
|
state.pending.forEach((p, i) => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
lines.push(`
|
|
150
|
-
lines.push(`
|
|
253
|
+
// @implements A-SPEC-262.1 — every field here is AGENT-CONTROLLED (the target is the command it
|
|
254
|
+
// was blocked on). Round-1 forged two extra rows and hid the real ones behind an ANSI conceal,
|
|
255
|
+
// and the operator granted a `curl … | sh` they never saw. The template owns the line structure.
|
|
256
|
+
lines.push(`[${i + 1}] ${subjectCells(`[${i + 1}] `, p)}`);
|
|
257
|
+
lines.push(` 이유: ${(0, screen_safe_1.rowField)(p.why, screen_safe_1.ROW_COLS)}`);
|
|
258
|
+
// Round-6: this row composed THREE separately-budgeted fields plus twenty columns of template,
|
|
259
|
+
// and round-5's whole-line accounting only covered the line PREFIXES. Measured at 117 columns
|
|
260
|
+
// for an ordinary hold — no forgery, no wide characters, just a question of average length. A
|
|
261
|
+
// hold's question is the actionable part, so it gets a row instead of being squeezed onto one.
|
|
262
|
+
const since = p.firstTs ? ` · ${(0, screen_safe_1.rowField)(p.firstTs, 28)} 부터` : '';
|
|
263
|
+
lines.push(` 차단 ${p.count}회${since}${p.hold ? ' (보류 중)' : ''}`);
|
|
264
|
+
if (p.hold)
|
|
265
|
+
lines.push(` 질문: ${(0, screen_safe_1.rowField)(p.question ?? '(질문 없음)', screen_safe_1.ROW_COLS)}`);
|
|
266
|
+
lines.push(` id: ${(0, screen_safe_1.rowField)(p.id, 40)}`);
|
|
151
267
|
lines.push('');
|
|
152
268
|
});
|
|
153
269
|
if (state.malformedLines > 0)
|
|
@@ -155,46 +271,268 @@ function renderPending(state) {
|
|
|
155
271
|
return lines.join('\n');
|
|
156
272
|
}
|
|
157
273
|
/**
|
|
158
|
-
*
|
|
274
|
+
* @implements A-SPEC-262.1
|
|
275
|
+
* What a decision line must say. Round-1: `✓ 승인 — <expires> 까지 유효` named NOTHING, so an index
|
|
276
|
+
* that resolved against a shifted queue granted a different request than the operator read, with no
|
|
277
|
+
* way to see it from the screen. Every decision now echoes the request it acted on.
|
|
278
|
+
*/
|
|
279
|
+
/**
|
|
280
|
+
* @implements A-SPEC-262.1
|
|
281
|
+
* The kind cell and the subject, fitted to what is LEFT OF THE ROW after the caller's own prefix.
|
|
282
|
+
*
|
|
283
|
+
* Round-8: the budgets were global constants derived once, from `✓ 승인 — ` — and `✓ 거부 기록됨 — `
|
|
284
|
+
* is seven columns wider, so every denial echo ran to 88 columns and wrapped. Round-7 had widened
|
|
285
|
+
* the kind cell without re-measuring any line that carries it. A constant cannot know which prefix
|
|
286
|
+
* it will be printed behind, so the line computes its own room and the prefix is passed in. The
|
|
287
|
+
* floor keeps a forged kind from eating the subject entirely.
|
|
288
|
+
*/
|
|
289
|
+
function subjectCells(prefix, p) {
|
|
290
|
+
return `${(0, screen_safe_1.rowField)(p.kind, screen_safe_1.KIND_COLS)} ${(0, screen_safe_1.rowField)(p.target, subjectRoom(prefix, p))}`;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* @implements A-SPEC-262.1
|
|
294
|
+
* How many columns the SUBJECT gets behind `prefix` — the one place that knows, so a caller asking
|
|
295
|
+
* "will this fit?" and the caller that renders it cannot disagree.
|
|
296
|
+
*
|
|
297
|
+
* Round-9: `[A]`'s filter asked `isClipped(target, ROW_COLS)` with the constant 50 while the batch
|
|
298
|
+
* row it gates has 69 columns for `kind: 'shell'`. Every target between 51 and 69 columns was
|
|
299
|
+
* therefore refused as "not fitting a row" and told so on screen, while the list had already printed
|
|
300
|
+
* it whole — round-8's defect in the other direction, because round-8 fixed the renderer and left
|
|
301
|
+
* the predicate on the constant.
|
|
302
|
+
*/
|
|
303
|
+
function subjectRoom(prefix, p) {
|
|
304
|
+
// No floor: round-10 measured the unfloored value at a minimum of 49 across 40,000 hostile kinds
|
|
305
|
+
// and every prefix this screen uses (the widest is ten columns, and the kind cell is capped at
|
|
306
|
+
// twenty), so `Math.max(16, …)` could never fire. An unexercised guard is not defence in depth —
|
|
307
|
+
// it is a second thing to keep true (round-5).
|
|
308
|
+
const kind = (0, screen_safe_1.rowField)(p.kind, screen_safe_1.KIND_COLS);
|
|
309
|
+
return screen_safe_1.ROW_MAX - (0, screen_safe_1.displayWidth)(prefix) - (0, screen_safe_1.displayWidth)(kind) - 1;
|
|
310
|
+
}
|
|
311
|
+
function decisionSubject(p, prefix = '\u2713 \uc2b9\uc778 \u2014 ') {
|
|
312
|
+
return subjectCells(prefix, p);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* @implements A-SPEC-262.1
|
|
316
|
+
* The traceability cell, on a row of its own. Round-5: an 80-column row cannot hold the subject AND
|
|
317
|
+
* the id AND the expiry — the one-line form measured 81 columns and wrapped, and a wrapped decision
|
|
318
|
+
* line is how a queue scrolls its own evidence away. Splitting costs a row and keeps both facts
|
|
319
|
+
* whole; neither may be dropped, since the subject is what the human read and the id is what the
|
|
320
|
+
* grant is keyed by.
|
|
321
|
+
*/
|
|
322
|
+
function decisionDetail(p, tail = '') {
|
|
323
|
+
return ` ${(0, screen_safe_1.rowField)(p.id, 20)}${tail === '' ? '' : ` \u00b7 ${(0, screen_safe_1.rowField)(tail, 40)}`}`;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The non-TTY fallback's next-command hint. Pure.
|
|
327
|
+
*
|
|
328
|
+
* @implements A-SPEC-260
|
|
329
|
+
* Without a TTY the interactive loop cannot run, and the silent list-then-exit-0 left the operator
|
|
330
|
+
* with no idea what to type (measured 2026-08-23 — Claude Code's `!` runs with a non-TTY stdin).
|
|
331
|
+
* The examples carry the FIRST pending id read from the queue, never a placeholder the operator
|
|
332
|
+
* must transcribe: [noguess]. An empty queue yields an empty hint — nothing to act on, nothing to
|
|
333
|
+
* advise. `renderPending` itself stays hint-free: the explicit `--list` output is for scripts.
|
|
334
|
+
*/
|
|
335
|
+
function renderNonTtyHint(state) {
|
|
336
|
+
if (state.pending.length === 0)
|
|
337
|
+
return '';
|
|
338
|
+
// @implements A-SPEC-262.1 — round-2: the id is AGENT-CONTROLLED (foldQueue takes it verbatim and
|
|
339
|
+
// .ax/approvals has no write gate), and these three lines exist to be COPIED INTO A SHELL. A
|
|
340
|
+
// crafted id carrying a concealed `curl … | sh` rode into the operator's clipboard invisibly.
|
|
341
|
+
// Flattened, so what is pasted is what is read. The index is offered first: it needs no id at all.
|
|
342
|
+
const p = state.pending[0];
|
|
343
|
+
// Flattened so it cannot forge a line, then SHELL-QUOTED so pasting it cannot run anything: this
|
|
344
|
+
// block exists to be copied into a shell, and the id is agent-controlled (round-2).
|
|
345
|
+
// Round-5 weighed the row rule against A-SPEC-260's contract here and the contract wins: these
|
|
346
|
+
// three lines are PASTED INTO A SHELL, so they must stay one line AND carry the id in full. A
|
|
347
|
+
// clipped id is not a shorter command, it is a command naming a request that does not exist — or,
|
|
348
|
+
// worse, a different one.
|
|
349
|
+
//
|
|
350
|
+
// Round-9: §13 routed this through `rowField`, which DOUBLES BACKSLASHES — so the pasted command
|
|
351
|
+
// named an id that does not exist, and the refusal then rendered the doubling twice over. A real
|
|
352
|
+
// queue id is `req-` plus twelve hex digits (`recordIsSelfConsistent` refuses to decide anything
|
|
353
|
+
// else), so a well-formed id is printed verbatim and quoted, and anything else is shown in row
|
|
354
|
+
// form with a note: an id that cannot be decided is not a command worth pasting.
|
|
355
|
+
// One expression, not two arms: `rowField` is the identity on a well-formed id (twelve hex digits
|
|
356
|
+
// and a `req-` prefix are all printable ASCII), so a branch that printed it verbatim could not be
|
|
357
|
+
// distinguished from this by any input — an arm no test can reach is not a guarantee, it is a
|
|
358
|
+
// second thing to keep true.
|
|
359
|
+
const wellFormed = /^req-[0-9a-f]{12}$/.test(p.id);
|
|
360
|
+
const id = `${(0, screen_safe_1.safeRef)((0, screen_safe_1.rowField)(p.id, 40))}${wellFormed ? '' : ' # 위조된 id — 결정 불가'}`;
|
|
361
|
+
return [
|
|
362
|
+
'대화형 결정은 TTY에서만 동작합니다.',
|
|
363
|
+
'이 셸에서는 아래 비대화형 명령을 사용하십시오:',
|
|
364
|
+
' holmes-kit approve --grant 1 # 위 목록의 첫 항목 (번호로 참조 가능)',
|
|
365
|
+
` holmes-kit approve --grant ${id}`,
|
|
366
|
+
` holmes-kit approve --deny ${id} --reason "<사유>"`,
|
|
367
|
+
` holmes-kit approve --ask ${id} --question "<질문>"`,
|
|
368
|
+
].join('\n');
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* The interactive loop — one entry at a time, ONE KEY per decision.
|
|
159
372
|
*
|
|
160
373
|
* @implements A-SPEC-246
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
374
|
+
* @implements A-SPEC-262.1
|
|
375
|
+
* REQ-262 measured what the first cut cost: `[a]` then asked for a rationale and a TTL, so three
|
|
376
|
+
* approvals were nine inputs and the human left the surface to type elsewhere. Approving is now a
|
|
377
|
+
* single key with the defaults; the narrowing prompts moved to `[e]`, which is where someone who
|
|
378
|
+
* actually wants to narrow will look. `[v]` renders the SUBJECT (the spec body, the finding, the
|
|
379
|
+
* command) and re-asks the same item — looking is not deciding. `[A]` takes every remaining item,
|
|
380
|
+
* and it is only offered after `renderPending` has already put each item's summary on the screen
|
|
381
|
+
* (REQ-262 Constraints: nothing is approved unseen). `[d]` still demands a reason, because an
|
|
382
|
+
* unreasoned denial is invisible to the agent; `[q]` holds with a question; `[s]` leaves it.
|
|
164
383
|
*/
|
|
165
384
|
async function runInteractive(root, io, actor) {
|
|
166
385
|
const state = (0, approval_queue_1.readQueue)(root);
|
|
167
386
|
io.print(renderPending(state));
|
|
168
387
|
if (state.pending.length === 0)
|
|
169
388
|
return;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
389
|
+
const grantDefault = (p) => {
|
|
390
|
+
const r = grantRequest(root, p.id, { actor });
|
|
391
|
+
if (!r.ok) {
|
|
392
|
+
io.print(`\u2717 ${r.reason}`);
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
io.print(`\u2713 \uc2b9\uc778 \u2014 ${decisionSubject(p, '\u2713 \uc2b9\uc778 \u2014 ')}`);
|
|
396
|
+
io.print(decisionDetail(p, `${r.expires} \uae4c\uc9c0 \uc720\ud6a8`));
|
|
397
|
+
return true;
|
|
398
|
+
};
|
|
399
|
+
const queue = [...state.pending];
|
|
400
|
+
for (let i = 0; i < queue.length; i++) {
|
|
401
|
+
const p = queue[i];
|
|
402
|
+
// @implements A-SPEC-262.1 — round-2: THIS line was the seam round-1 missed. renderPending was
|
|
403
|
+
// sanitised; the header printed immediately above the prompt was not, so an erase-and-repaint
|
|
404
|
+
// target wiped the safe list, forged a benign header, and the operator pressed [a] on it.
|
|
405
|
+
io.print(`\n\u2500 ${subjectCells('\u2500 ', p)}`);
|
|
406
|
+
// The raw answer is kept: [A] and [a] are different keys, so lowercasing before the branch
|
|
407
|
+
// would erase the batch decision.
|
|
408
|
+
const raw = (await io.ask('[a]\uc2b9\uc778 [e]\uc0ac\uc720\u00b7\uae30\uac04 [v]\uc0c1\uc138 [d]\uac70\ubd80 [q]\ubcf4\ub958 [s]\uac74\ub108\ub700 [A]\uc804\uccb4 > ')).trim();
|
|
409
|
+
if (raw === 'A') {
|
|
410
|
+
// @implements A-SPEC-262.1 — CONFIRMED, with the count and the list. Round-1: this was the one
|
|
411
|
+
// decision in the loop with no second prompt, and it differs from [a] only by Shift — a
|
|
412
|
+
// Caps-Lock slip granted every item the operator had not looked at. The "summaries are already
|
|
413
|
+
// on screen" justification also fails on a queue longer than a screen, and right after a [v]
|
|
414
|
+
// scrolled them away, so the confirmation RE-PRINTS them.
|
|
415
|
+
// ROW BUDGET (round-5). The bound round-4 added was a code-point budget, and a code point is
|
|
416
|
+
// not a row: `⟪`, `⟫` and every Hangul character in the removal marker are TWO columns, so a
|
|
417
|
+
// 200-code-point line measured 302 columns — four wrapped rows for ONE item, and a full queue
|
|
418
|
+
// of them scrolls the earlier summaries away before the y/N prompt, which is exactly the flood
|
|
419
|
+
// this re-print exists to prevent. Two bounds now, both in the unit the screen uses:
|
|
420
|
+
// · each item must fit WHOLE on one row — an item that would be clipped is not batched at
|
|
421
|
+
// all, because a clipped subject is a subject the operator did not see; it stays pending
|
|
422
|
+
// for an individual decision, where [v] shows it in full.
|
|
423
|
+
// · at most BATCH_MAX items per confirmation, so the list itself always fits a screen. The
|
|
424
|
+
// rest are not lost: [A] again takes the next batch.
|
|
425
|
+
const BATCH_MAX = 20;
|
|
426
|
+
const rest = queue.slice(i);
|
|
427
|
+
const fits = (q) => !(0, screen_safe_1.isClipped)(q.target, subjectRoom(' - ', q));
|
|
428
|
+
const showable = rest.filter(fits);
|
|
429
|
+
const oversize = rest.length - showable.length;
|
|
430
|
+
const batch = showable.slice(0, BATCH_MAX);
|
|
431
|
+
const deferred = showable.length - batch.length;
|
|
432
|
+
if (batch.length === 0) {
|
|
433
|
+
io.print('\u2717 \ub0a8\uc740 \ud56d\ubaa9\uc740 \ud55c \ud589\uc5d0 \uc548 \ub2f4\uaca8 \uc77c\uad04 \ub300\uc0c1\uc774 \uc544\ub2d9\ub2c8\ub2e4 \u2014 [v]\ub85c \uac1c\ubcc4 \uacb0\uc815');
|
|
434
|
+
i--;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
io.print(`\u25c6 \ub2e4\uc74c ${batch.length}\uac74\uc744 \uc2b9\uc778\ud569\ub2c8\ub2e4:`);
|
|
438
|
+
for (const q of batch) {
|
|
439
|
+
io.print(` - ${subjectCells(' - ', q)}`);
|
|
440
|
+
}
|
|
441
|
+
if (oversize > 0) {
|
|
442
|
+
io.print(` (\ud55c \ud589\uc5d0 \uc548 \ub2f4\uae30\ub294 ${oversize}\uac74 \uc81c\uc678 \u2014 [v]\ub85c \uac1c\ubcc4 \uacb0\uc815)`);
|
|
443
|
+
}
|
|
444
|
+
if (deferred > 0) {
|
|
445
|
+
io.print(` (\ub098\uba38\uc9c0 ${deferred}\uac74\uc740 \ub2e4\uc74c [A] \uc5d0\uc11c \uc774\uc5b4\uc11c \ubb3b\uc2b5\ub2c8\ub2e4)`);
|
|
446
|
+
}
|
|
447
|
+
const ok = (await io.ask(`\uc774 ${batch.length}\uac74\uc744 \uc2b9\uc778\ud569\ub2c8\uae4c? [y/N] > `)).trim().toLowerCase();
|
|
448
|
+
if (ok !== 'y' && ok !== 'yes') {
|
|
449
|
+
io.print('\u2717 \uc77c\uad04 \uc2b9\uc778\uc744 \ucde8\uc18c\ud588\uc2b5\ub2c8\ub2e4 \u2014 \ud56d\ubaa9\ubcc4\ub85c \uacc4\uc18d\ud558\uc2ed\uc2dc\uc624');
|
|
450
|
+
i--;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
// @implements A-SPEC-262.1 — round-7: `granted` was built from `batch` BEFORE any grant ran,
|
|
454
|
+
// so an item whose grant FAILED (a read-only grants dir, or the round-4 tamper refusal) was
|
|
455
|
+
// spliced out of the queue and never asked again — the session ended, exit 0, nothing granted
|
|
456
|
+
// and nothing pending re-shown. That is the doctrine round-6 wrote three lines below, broken
|
|
457
|
+
// in the branch round-6 rewrote. Only what actually succeeded leaves the queue.
|
|
458
|
+
const granted = new Set();
|
|
459
|
+
for (const q of batch) {
|
|
460
|
+
io.print(`\n\u2500 ${subjectCells('\u2500 ', q)}`);
|
|
461
|
+
if (grantDefault(q))
|
|
462
|
+
granted.add(q.id);
|
|
463
|
+
}
|
|
464
|
+
// Not `return`: the excluded and deferred items are still undecided, and dropping the operator
|
|
465
|
+
// out of the loop would silently leave them for a future session.
|
|
466
|
+
const remaining = rest.filter((q) => !granted.has(q.id));
|
|
467
|
+
queue.splice(i, rest.length, ...remaining);
|
|
468
|
+
i--;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const answer = raw.toLowerCase();
|
|
173
472
|
if (answer === 'a') {
|
|
174
|
-
|
|
175
|
-
|
|
473
|
+
grantDefault(p);
|
|
474
|
+
}
|
|
475
|
+
else if (answer === 'e') {
|
|
476
|
+
const rationale = (await io.ask(`\uc0ac\uc720 (enter = "${DEFAULT_RATIONALE}") > `)).trim();
|
|
477
|
+
const ttlRaw = (await io.ask(`\uc720\ud6a8 \uc2dc\uac04(\ubd84, enter = ${DEFAULT_TTL_MINUTES}) > `)).trim();
|
|
176
478
|
const ttl = ttlRaw === '' ? DEFAULT_TTL_MINUTES : Number(ttlRaw);
|
|
177
479
|
const r = grantRequest(root, p.id, { actor, rationale, ttlMinutes: ttl });
|
|
178
|
-
|
|
480
|
+
// @implements A-SPEC-262.1 — round-6: this branch alone RETURNED on failure, so a mistyped TTL
|
|
481
|
+
// ("30분") ended the whole session with exit 0 and every remaining item undecided — measured on
|
|
482
|
+
// a real pty, and the operator cannot tell it from a normal end of queue. The same `return`
|
|
483
|
+
// fired on the round-4 tamper refusal, which means a single forged queue line placed first
|
|
484
|
+
// could abandon the session when the operator pressed the MOST careful key. A refused decision
|
|
485
|
+
// is one item's problem; it re-asks, exactly like an unknown key.
|
|
486
|
+
if (!r.ok) {
|
|
487
|
+
io.print(`\u2717 ${r.reason}`);
|
|
488
|
+
i--;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
io.print(`\u2713 \uc2b9\uc778 \u2014 ${decisionSubject(p, '\u2713 \uc2b9\uc778 \u2014 ')}`);
|
|
492
|
+
io.print(decisionDetail(p, `${r.expires} \uae4c\uc9c0 \uc720\ud6a8`));
|
|
493
|
+
}
|
|
494
|
+
else if (answer === 'v') {
|
|
495
|
+
const { renderDecisionContext } = require('./approve-context');
|
|
496
|
+
io.print(renderDecisionContext(root, p));
|
|
497
|
+
i--; // same item, asked again: seeing is not deciding
|
|
498
|
+
continue;
|
|
179
499
|
}
|
|
180
500
|
else if (answer === 'd') {
|
|
181
|
-
const reason = (await io.ask('
|
|
501
|
+
const reason = (await io.ask('\uac70\ubd80 \uc0ac\uc720(\uc5d0\uc774\uc804\ud2b8\uc5d0\uac8c \uc804\ub2ec\ub429\ub2c8\ub2e4) > ')).trim();
|
|
182
502
|
if (reason === '') {
|
|
183
|
-
io.print('
|
|
503
|
+
io.print('\u2717 \uc0ac\uc720 \uc5c6\ub294 \uac70\ubd80\ub294 \uc5d0\uc774\uc804\ud2b8\uac00 \ubcfc \uc218 \uc5c6\uc2b5\ub2c8\ub2e4 \u2014 \uac74\ub108\ub701\ub2c8\ub2e4');
|
|
184
504
|
continue;
|
|
185
505
|
}
|
|
186
506
|
const r = denyRequest(root, p.id, reason, actor);
|
|
187
|
-
|
|
507
|
+
if (r.ok) {
|
|
508
|
+
io.print(`\u2713 \uac70\ubd80 \u2014 ${decisionSubject(p, '\u2713 \uac70\ubd80 \u2014 ')}`);
|
|
509
|
+
io.print(decisionDetail(p, '\uac70\ubd80\uac00 \uae30\ub85d\ub410\uc2b5\ub2c8\ub2e4'));
|
|
510
|
+
}
|
|
511
|
+
else
|
|
512
|
+
io.print(`\u2717 ${r.reason}`);
|
|
188
513
|
}
|
|
189
514
|
else if (answer === 'q') {
|
|
190
|
-
const question = (await io.ask('
|
|
515
|
+
const question = (await io.ask('\uc9c8\ubb38(\uc5d0\uc774\uc804\ud2b8\uac00 \uc0ac\uc6a9\uc790\uc5d0\uac8c \uc804\ub2ec\ud569\ub2c8\ub2e4) > ')).trim();
|
|
191
516
|
if (question === '') {
|
|
192
|
-
io.print('
|
|
517
|
+
io.print('\u2717 \ube48 \uc9c8\ubb38 \u2014 \uac74\ub108\ub701\ub2c8\ub2e4');
|
|
193
518
|
continue;
|
|
194
519
|
}
|
|
195
520
|
const r = holdRequest(root, p.id, question, actor);
|
|
196
|
-
|
|
521
|
+
if (r.ok) {
|
|
522
|
+
io.print(`\u2713 \ubcf4\ub958 \u2014 ${decisionSubject(p, '\u2713 \ubcf4\ub958 \u2014 ')}`);
|
|
523
|
+
io.print(decisionDetail(p, '\uc9c8\ubb38\uc740 \ub2e4\uc74c \uac70\ubd80 \ubb38\uba74\uc5d0'));
|
|
524
|
+
}
|
|
525
|
+
else
|
|
526
|
+
io.print(`\u2717 ${r.reason}`);
|
|
527
|
+
}
|
|
528
|
+
else if (answer !== 's') {
|
|
529
|
+
// @implements A-SPEC-262.1 — a typo is NOT a decision. Round-1: any unrecognized input (the
|
|
530
|
+
// double-tap `aa` of the new one-key flow, a bare Enter) silently advanced, so the operator
|
|
531
|
+
// saw the next item's prompt and read it as "approved, next please" while the item stayed
|
|
532
|
+
// pending and the agent stayed blocked. Only [s] skips; everything else asks again.
|
|
533
|
+
io.print(`\u2717 \uc54c \uc218 \uc5c6\ub294 \ud0a4\uc785\ub2c8\ub2e4${raw === '' ? '' : `: '${(0, screen_safe_1.rowField)(raw, 20)}'`} \u2014 \ub2e4\uc2dc \uc785\ub825\ud558\uc2ed\uc2dc\uc624`);
|
|
534
|
+
i--;
|
|
535
|
+
continue;
|
|
197
536
|
}
|
|
198
|
-
// 's' and anything else: leave it for later.
|
|
199
537
|
}
|
|
200
538
|
}
|