@holmes-lab/holmes-kit 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ <!-- @implements A-SPEC-209 -->
8
+ ## [0.1.13] - 2026-08-23
9
+
10
+ The HITL approval overhaul — a human decides in the CLI, and never types a line of scope
11
+ syntax. Driven by dogfooding feedback from governing a real 46-spec Python repo.
12
+
13
+ ### Added
14
+ - **Approval request queue (REQ-244)**: every refusal whose only fix is an owner approval now
15
+ files a structured request into `.ax/approvals/queue.jsonl`, and the refusal text carries the
16
+ request id. Six gate sites — hard-hitl shell, config-write, role-policy, spec_approve, sealed
17
+ spec_retire, review-resolve. Repeats fold by content-hash id (six blocks, one row). The queue
18
+ is information, never authority: a forged `granted` event opens no gate.
19
+ - **Grant file channel (REQ-245)**: approvals delivered by dropping a file into
20
+ `.ax/approvals/grants/`, read at judgement time — no `.mcp.json` editing, no MCP reconnect. A
21
+ grant is an ordinary Approval, judged by the existing `approvalCovers`; hard-hitl replay is
22
+ refused atomically by the existing ledger nonce consumption. Grants must be narrow (scope +
23
+ expires + nonce), so the committed-token failure the dogfooding found cannot recur. The session
24
+ cannot mint its own — `.ax/approvals` joined the protected directories on both the Write and
25
+ shell surfaces.
26
+ - **`holmes-kit approve` (REQ-246)**: the decision surface. Interactive on a TTY — 승인/거부/질문/
27
+ 건너뛰기 per request — or `--grant`/`--deny`/`--ask` for a CI/remote operator. The grant is
28
+ derived mechanically from the queue entry (kind→scope.kind, target→pattern, id→nonce); the human
29
+ types a decision, an optional rationale, and a TTL, and never scope grammar. Denials and holds
30
+ reach the agent in its next refusal, so a retry loop stops.
31
+ - **Stop-gate pending-awareness + startup token health (REQ-247)**: the stop hook no longer
32
+ re-blocks a debt already queued for the owner — it says so once and lets the turn finish — while
33
+ a single non-waiting or code-resolvable violation still blocks. The MCP server logs the approval
34
+ token's health at startup (valid, invalid with reason, or absent), to stderr only.
35
+
36
+ ### Changed
37
+ - **codex hook coverage is measured, not inferred (REQ-248, backlog)**: codex-cli 0.149.0 hook
38
+ protocol is identical to ours and our shipped hook is IO-compatible, but codex loads hooks
39
+ through its plugin system, not `config.toml` — so `init --agent codex` wires MCP only, and codex
40
+ stays governed by the server-side approval gates until plugin packaging lands.
41
+ `HARNESS_ENFORCES.codex` stays false: enforcement not observed is not written as present.
42
+
7
43
  <!-- @implements A-SPEC-209 -->
8
44
  ## [0.1.12] - 2026-08-23
9
45
 
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- b820c0b-mt57zbc7
1
+ 0c506c6-mt5buls8
@@ -14,8 +14,15 @@ export type Agent = (typeof AGENTS)[number];
14
14
  * - `claude` — PreToolUse/Stop 훅. 이 저장소가 처음부터 배선해 온 하네스.
15
15
  * - `antigravity` — PreToolUse/Stop 훅이 있고 `deny`/`continue` 로 막을 수 있다(설치본의
16
16
  * `agy-customizations` 규약 문서와 실제 대화 기록에서 실측).
17
- * - `codex` — 기계에 설치되어 있지 않아 규약을 **재지 못했다**. 재지 못한 것을 있다고
18
- * 적지 않는다. 도구(MCP)와 지침만 준다.
17
+ * - `codex` — 실측했다(codex-cli 0.149.0, 2026-08-23).규약 자체는 우리 것과 **동일**하다:
18
+ * 바이너리 스키마가 `PreToolUse` / `permissionDecision: allow|deny|ask` / `stop_hook_active` /
19
+ * `hookSpecificOutput` 를 그대로 쓰고, codex 입력 형식으로 우리 `pre-tool-use.js` 를 먹이면 정확히
20
+ * `deny` 를 낸다. 그런데도 `false` 인 이유는 **배선 경로**다: codex 의 훅은 `config.toml` 의
21
+ * `[[hooks]]` 로 로드되지 않는다(잘못된 타입을 넣어도 `--strict-config` 가 통과 — 스키마 밖이다).
22
+ * `hook_runtime` 트레이스는 실제 셸 도구 실행에도 진입하지 않았고, 바이너리 문자열이 훅을
23
+ * `Plugins`/`RemotePlugin` 시스템(`codex plugin`, `--with-hooks` scaffold)으로 로드한다고 말한다.
24
+ * 즉 codex 훅 집행은 **plugin 패키징**이 필요하며 MCP·config 배선으로는 닿지 않는다(REQ-248 로 남김).
25
+ * 그때까지 codex 는 MCP 서버로만 지배되며, 그 경로는 실증됐다(spec_approve 등 서버측 승인 게이트).
19
26
  */
20
27
  export declare const HARNESS_ENFORCES: Record<Agent, boolean>;
21
28
  export interface AgentWiringOptions {
@@ -53,8 +53,15 @@ exports.AGENTS = ['claude', 'antigravity', 'codex'];
53
53
  * - `claude` — PreToolUse/Stop 훅. 이 저장소가 처음부터 배선해 온 하네스.
54
54
  * - `antigravity` — PreToolUse/Stop 훅이 있고 `deny`/`continue` 로 막을 수 있다(설치본의
55
55
  * `agy-customizations` 규약 문서와 실제 대화 기록에서 실측).
56
- * - `codex` — 기계에 설치되어 있지 않아 규약을 **재지 못했다**. 재지 못한 것을 있다고
57
- * 적지 않는다. 도구(MCP)와 지침만 준다.
56
+ * - `codex` — 실측했다(codex-cli 0.149.0, 2026-08-23).규약 자체는 우리 것과 **동일**하다:
57
+ * 바이너리 스키마가 `PreToolUse` / `permissionDecision: allow|deny|ask` / `stop_hook_active` /
58
+ * `hookSpecificOutput` 를 그대로 쓰고, codex 입력 형식으로 우리 `pre-tool-use.js` 를 먹이면 정확히
59
+ * `deny` 를 낸다. 그런데도 `false` 인 이유는 **배선 경로**다: codex 의 훅은 `config.toml` 의
60
+ * `[[hooks]]` 로 로드되지 않는다(잘못된 타입을 넣어도 `--strict-config` 가 통과 — 스키마 밖이다).
61
+ * `hook_runtime` 트레이스는 실제 셸 도구 실행에도 진입하지 않았고, 바이너리 문자열이 훅을
62
+ * `Plugins`/`RemotePlugin` 시스템(`codex plugin`, `--with-hooks` scaffold)으로 로드한다고 말한다.
63
+ * 즉 codex 훅 집행은 **plugin 패키징**이 필요하며 MCP·config 배선으로는 닿지 않는다(REQ-248 로 남김).
64
+ * 그때까지 codex 는 MCP 서버로만 지배되며, 그 경로는 실증됐다(spec_approve 등 서버측 승인 게이트).
58
65
  */
59
66
  exports.HARNESS_ENFORCES = {
60
67
  claude: true,
@@ -0,0 +1,46 @@
1
+ import { QueueState } from '../governance/approval-queue';
2
+ export type DecisionResult = {
3
+ ok: true;
4
+ expires?: string;
5
+ } | {
6
+ ok: false;
7
+ reason: string;
8
+ };
9
+ /**
10
+ * Approve: mint the narrow grant this request was filed for.
11
+ *
12
+ * @implements A-SPEC-246
13
+ * A TTL that is not a positive integer is refused — a grant with no meaningful expiry violates the
14
+ * narrowness rule the whole file channel rests on (REQ-245).
15
+ */
16
+ export declare function grantRequest(root: string, id: string, opts: {
17
+ actor: string;
18
+ ttlMinutes?: number;
19
+ rationale?: string;
20
+ }): DecisionResult;
21
+ /** Deny, with a reason the agent will see in its next refusal. */
22
+ export declare function denyRequest(root: string, id: string, reason: string, actor: string): DecisionResult;
23
+ /** Hold with a question — 추후 승인/거부. The agent carries the question to the user. */
24
+ export declare function holdRequest(root: string, id: string, question: string, actor: string): DecisionResult;
25
+ /**
26
+ * The review screen. Pure.
27
+ *
28
+ * What a decision needs and nothing else: what (kind·target), why, how often it was blocked, since
29
+ * when, and any standing question. Deliberately NO scope grammar anywhere — the moment the screen
30
+ * teaches syntax, humans start typing it.
31
+ */
32
+ export declare function renderPending(state: QueueState): string;
33
+ export interface ApproveIO {
34
+ print: (s: string) => void;
35
+ /** Ask one question, resolve with the raw answer. Injected so tests script the conversation. */
36
+ ask: (prompt: string) => Promise<string>;
37
+ }
38
+ /**
39
+ * The interactive loop — one entry at a time, one letter per decision.
40
+ *
41
+ * @implements A-SPEC-246
42
+ * [a] mints the mechanical grant (optional rationale + TTL, enter accepts defaults), [d] requires a
43
+ * reason because an unreasoned denial is invisible to the agent in any useful way, [q] holds with a
44
+ * question the agent will carry to the user, [s] leaves the entry for later.
45
+ */
46
+ export declare function runInteractive(root: string, io: ApproveIO, actor: string): Promise<void>;
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.grantRequest = grantRequest;
37
+ exports.denyRequest = denyRequest;
38
+ exports.holdRequest = holdRequest;
39
+ exports.renderPending = renderPending;
40
+ exports.runInteractive = runInteractive;
41
+ // @implements A-SPEC-246
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ const approval_queue_1 = require("../governance/approval-queue");
45
+ const approval_grants_1 = require("../governance/approval-grants");
46
+ /**
47
+ * `holmes-kit approve` — the surface where a human decides.
48
+ *
49
+ * @implements A-SPEC-246
50
+ * The queue makes the review list (REQ-244) and grant files deliver approvals with no editing and
51
+ * no reconnect (REQ-245); what was missing is the surface where the decision happens. Without it
52
+ * the operator hand-writes grant JSON — scope grammar, expiry format, nonce rules — the exact
53
+ * "human types values into files" this programme exists to zero out.
54
+ *
55
+ * THE DECISION API IS THE HEART; THE TUI IS A SHELL. `grantRequest`/`denyRequest`/`holdRequest`
56
+ * write the files and events; both the interactive loop and the flags call the same three, so a
57
+ * CI or remote operator without a TTY is never locked out.
58
+ *
59
+ * A GRANT IS DERIVED, NEVER TYPED. kind→scope.kind, target→pattern, id→nonce, now+TTL→expires —
60
+ * all from the queue entry. The human's inputs are the decision, an optional rationale, and a TTL.
61
+ * The moment the human must know scope syntax, this UI has no reason to exist.
62
+ */
63
+ const DEFAULT_TTL_MINUTES = 30;
64
+ const DEFAULT_RATIONALE = 'approved via holmes-kit approve';
65
+ const findPending = (root, id) => {
66
+ const state = (0, approval_queue_1.readQueue)(root);
67
+ return { entry: state.pending.find((p) => p.id === id), state };
68
+ };
69
+ /** CLI-side event writer. The CLI runs in the operator's terminal, outside the session gates. */
70
+ const appendEvent = (root, event) => {
71
+ try {
72
+ const file = path.join(root, approval_queue_1.QUEUE_RELPATH);
73
+ if (!fs.existsSync(path.join(root, '.ax')))
74
+ return false;
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ fs.appendFileSync(file, JSON.stringify({ ...event, ts: new Date().toISOString() }) + '\n');
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ };
83
+ /**
84
+ * Approve: mint the narrow grant this request was filed for.
85
+ *
86
+ * @implements A-SPEC-246
87
+ * A TTL that is not a positive integer is refused — a grant with no meaningful expiry violates the
88
+ * narrowness rule the whole file channel rests on (REQ-245).
89
+ */
90
+ function grantRequest(root, id, opts) {
91
+ 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 이상의 정수(분)여야 합니다 — 받은 값: ${String(ttl)}` };
94
+ }
95
+ const { entry } = findPending(root, id);
96
+ if (!entry)
97
+ return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다 — npx holmes-kit approve --list 로 확인하십시오` };
98
+ const expires = new Date(Date.now() + ttl * 60_000).toISOString();
99
+ const grant = {
100
+ actor: opts.actor,
101
+ token: 'grant',
102
+ rationale: opts.rationale?.trim() ? opts.rationale : DEFAULT_RATIONALE,
103
+ scope: [{ kind: entry.kind, pattern: entry.target }],
104
+ expires,
105
+ nonce: id,
106
+ };
107
+ try {
108
+ const dir = path.join(root, approval_grants_1.GRANTS_RELDIR);
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(grant, null, 2) + '\n');
111
+ }
112
+ catch (e) {
113
+ return { ok: false, reason: `그랜트 파일을 쓸 수 없습니다: ${e instanceof Error ? e.message : String(e)}` };
114
+ }
115
+ appendEvent(root, { event: 'granted', id, actor: opts.actor });
116
+ return { ok: true, expires };
117
+ }
118
+ /** Deny, with a reason the agent will see in its next refusal. */
119
+ function denyRequest(root, id, reason, actor) {
120
+ const { entry } = findPending(root, id);
121
+ if (!entry)
122
+ return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다` };
123
+ appendEvent(root, { event: 'denied', id, reason, actor });
124
+ return { ok: true };
125
+ }
126
+ /** Hold with a question — 추후 승인/거부. The agent carries the question to the user. */
127
+ function holdRequest(root, id, question, actor) {
128
+ const { entry } = findPending(root, id);
129
+ if (!entry)
130
+ return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다` };
131
+ appendEvent(root, { event: 'held', id, question, actor });
132
+ return { ok: true };
133
+ }
134
+ /**
135
+ * The review screen. Pure.
136
+ *
137
+ * What a decision needs and nothing else: what (kind·target), why, how often it was blocked, since
138
+ * when, and any standing question. Deliberately NO scope grammar anywhere — the moment the screen
139
+ * teaches syntax, humans start typing it.
140
+ */
141
+ function renderPending(state) {
142
+ if (state.pending.length === 0)
143
+ return '승인 대기 중인 요청이 없습니다.';
144
+ const lines = [`◆ 승인 대기 ${state.pending.length}건`, ''];
145
+ state.pending.forEach((p, i) => {
146
+ lines.push(`[${i + 1}] ${p.kind} ${p.target}`);
147
+ lines.push(` 이유: ${p.why}`);
148
+ const since = p.firstTs ? ` · ${p.firstTs} 부터` : '';
149
+ lines.push(` 차단 ${p.count}회${since}${p.hold ? ` (보류 중 — 질문: ${p.question ?? ''})` : ''}`);
150
+ lines.push(` id: ${p.id}`);
151
+ lines.push('');
152
+ });
153
+ if (state.malformedLines > 0)
154
+ lines.push(`(큐에 읽을 수 없는 줄 ${state.malformedLines}건 — 손상 여부를 확인하십시오)`);
155
+ return lines.join('\n');
156
+ }
157
+ /**
158
+ * The interactive loop — one entry at a time, one letter per decision.
159
+ *
160
+ * @implements A-SPEC-246
161
+ * [a] mints the mechanical grant (optional rationale + TTL, enter accepts defaults), [d] requires a
162
+ * reason because an unreasoned denial is invisible to the agent in any useful way, [q] holds with a
163
+ * question the agent will carry to the user, [s] leaves the entry for later.
164
+ */
165
+ async function runInteractive(root, io, actor) {
166
+ const state = (0, approval_queue_1.readQueue)(root);
167
+ io.print(renderPending(state));
168
+ if (state.pending.length === 0)
169
+ return;
170
+ for (const p of state.pending) {
171
+ io.print(`\n─ ${p.kind} ${p.target}`);
172
+ const answer = (await io.ask('[a]승인 [d]거부 [q]질문 남기고 보류 [s]건너뛰기 > ')).trim().toLowerCase();
173
+ if (answer === 'a') {
174
+ const rationale = (await io.ask(`사유 (enter = "${DEFAULT_RATIONALE}") > `)).trim();
175
+ const ttlRaw = (await io.ask(`유효 시간(분, enter = ${DEFAULT_TTL_MINUTES}) > `)).trim();
176
+ const ttl = ttlRaw === '' ? DEFAULT_TTL_MINUTES : Number(ttlRaw);
177
+ const r = grantRequest(root, p.id, { actor, rationale, ttlMinutes: ttl });
178
+ io.print(r.ok ? `✓ 승인 — ${r.expires} 까지 유효` : `✗ ${r.reason}`);
179
+ }
180
+ else if (answer === 'd') {
181
+ const reason = (await io.ask('거부 사유(에이전트에게 전달됩니다) > ')).trim();
182
+ if (reason === '') {
183
+ io.print('✗ 사유 없는 거부는 에이전트가 볼 수 없습니다 — 건너뜁니다');
184
+ continue;
185
+ }
186
+ const r = denyRequest(root, p.id, reason, actor);
187
+ io.print(r.ok ? '✓ 거부 기록됨' : `✗ ${r.reason}`);
188
+ }
189
+ else if (answer === 'q') {
190
+ const question = (await io.ask('질문(에이전트가 사용자에게 전달합니다) > ')).trim();
191
+ if (question === '') {
192
+ io.print('✗ 빈 질문 — 건너뜁니다');
193
+ continue;
194
+ }
195
+ const r = holdRequest(root, p.id, question, actor);
196
+ io.print(r.ok ? '✓ 보류 — 질문이 다음 거부 문면에 실립니다' : `✗ ${r.reason}`);
197
+ }
198
+ // 's' and anything else: leave it for later.
199
+ }
200
+ }
@@ -39,6 +39,7 @@ exports.main = main;
39
39
  // @implements A-SPEC-213
40
40
  // @implements A-SPEC-215
41
41
  const fs = __importStar(require("node:fs"));
42
+ const os = __importStar(require("node:os"));
42
43
  const path = __importStar(require("node:path"));
43
44
  const init_1 = require("./init");
44
45
  const doctor_1 = require("./doctor");
@@ -73,7 +74,17 @@ const KNOWN_FLAGS = {
73
74
  skills: ['help', 'target'],
74
75
  ci: ['help', 'target', 'specs-dir', 'json'],
75
76
  serve: ['help', 'target', 'port'],
77
+ approve: ['help', 'target', 'list', 'grant', 'deny', 'ask', 'reason', 'question', 'ttl', 'rationale'],
76
78
  };
79
+ const APPROVE_USAGE = `holmes-kit approve — 승인 대기 요청의 결재 (HITL)
80
+
81
+ 대화형(TTY): 항목마다 [a]승인 [d]거부 [q]질문 [s]건너뛰기
82
+ --list 대기 목록만 출력
83
+ --grant <id> 해당 요청의 좁은 그랜트 발급 (--ttl <분>, --rationale <문구>)
84
+ --deny <id> --reason <문구> 거부 — 사유가 에이전트의 다음 거부 문면에 실립니다
85
+ --ask <id> --question <문구> 질문 남기고 보류 — 에이전트가 사용자에게 전달합니다
86
+ --target <dir> 프로젝트 루트 (기본: 현재 디렉터리)
87
+ `;
77
88
  class UnknownFlagError extends Error {
78
89
  }
79
90
  /**
@@ -82,7 +93,7 @@ class UnknownFlagError extends Error {
82
93
  * what you'd do" spelling performed a REAL write (round-3 HIGH; same class inverted --remove and
83
94
  * --force). A boolean flag must never consume a token.
84
95
  */
85
- const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json']);
96
+ const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list']);
86
97
  function parseFlags(argv, known) {
87
98
  const flags = {};
88
99
  const positionals = [];
@@ -163,6 +174,14 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
163
174
  --remove remove holmes-kit wiring (holmes-owned entries only)
164
175
  --force re-wire an already-wired target (requires HOLMES_APPROVAL)
165
176
 
177
+ holmes-kit approve review pending approval requests (HITL)
178
+ (no flags, TTY) interactive: [a]승인 [d]거부 [q]질문 [s]건너뛰기 per request
179
+ --list print the pending queue and exit
180
+ --grant <id> [--ttl <min>] [--rationale <text>] mint the narrow grant for one request
181
+ --deny <id> --reason <text> record a denial the agent will see
182
+ --ask <id> --question <text> hold with a question the agent relays
183
+ --target <dir> project root (default: cwd)
184
+
166
185
  holmes-kit doctor [flags] diagnose the install
167
186
  --target <dir> also verify that target's wiring points at this install
168
187
  --json machine-readable output
@@ -328,6 +347,64 @@ async function main(argv) {
328
347
  return res.ok ? 0 : 1;
329
348
  }
330
349
  // @implements A-SPEC-215
350
+ if (cmd === 'approve') {
351
+ // @implements A-SPEC-246 — the surface where a human decides. Interactive on a TTY with no
352
+ // decision flags; every interactive decision has a flag equivalent so a CI or remote operator
353
+ // is never locked out.
354
+ const { grantRequest, denyRequest, holdRequest, renderPending, runInteractive } = require('./approve');
355
+ const { readQueue } = require('../governance/approval-queue');
356
+ const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
357
+ const actor = (() => { try {
358
+ return os.userInfo().username || 'operator';
359
+ }
360
+ catch {
361
+ return 'operator';
362
+ } })();
363
+ if (flags.help) {
364
+ process.stdout.write(APPROVE_USAGE);
365
+ return 0;
366
+ }
367
+ if (typeof flags.grant === 'string') {
368
+ const opts = { actor };
369
+ if (typeof flags.ttl === 'string')
370
+ opts.ttlMinutes = Number(flags.ttl);
371
+ if (typeof flags.rationale === 'string')
372
+ opts.rationale = flags.rationale;
373
+ const r = grantRequest(root, flags.grant, opts);
374
+ process.stdout.write(r.ok ? `✓ 승인 — ${r.expires} 까지 유효\n` : `✗ ${r.reason}\n`);
375
+ return r.ok ? 0 : 1;
376
+ }
377
+ if (typeof flags.deny === 'string') {
378
+ const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
379
+ if (reason === '') {
380
+ process.stderr.write('✗ --deny 는 --reason 이 필요합니다 — 사유 없는 거부는 에이전트가 볼 수 없습니다\n');
381
+ return 1;
382
+ }
383
+ const r = denyRequest(root, flags.deny, reason, actor);
384
+ process.stdout.write(r.ok ? '✓ 거부 기록됨\n' : `✗ ${r.reason}\n`);
385
+ return r.ok ? 0 : 1;
386
+ }
387
+ if (typeof flags.ask === 'string') {
388
+ const question = typeof flags.question === 'string' ? flags.question.trim() : '';
389
+ if (question === '') {
390
+ process.stderr.write('✗ --ask 는 --question 이 필요합니다\n');
391
+ return 1;
392
+ }
393
+ const r = holdRequest(root, flags.ask, question, actor);
394
+ process.stdout.write(r.ok ? '✓ 보류 — 질문이 다음 거부 문면에 실립니다\n' : `✗ ${r.reason}\n`);
395
+ return r.ok ? 0 : 1;
396
+ }
397
+ if (flags.list || !process.stdin.isTTY) {
398
+ process.stdout.write(renderPending(readQueue(root)) + '\n');
399
+ return 0;
400
+ }
401
+ const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
402
+ void runInteractive(root, {
403
+ print: (t) => process.stdout.write(t + '\n'),
404
+ ask: (q) => rl.question(q),
405
+ }, actor).finally(() => rl.close());
406
+ return 0;
407
+ }
331
408
  if (cmd === 'serve') {
332
409
  if (typeof flags.target === 'string') {
333
410
  const t = path.resolve(flags.target);
@@ -0,0 +1,63 @@
1
+ import { Approval } from '../guardrail/risk-gate';
2
+ /**
3
+ * The grant file channel — approval delivery with no file editing and no reconnect.
4
+ *
5
+ * @implements A-SPEC-245
6
+ * WHY THIS EXISTS. The only delivery channel was the MCP server's environment. Dogfooded cost of ONE
7
+ * human approval (Notion, 2026-08-23, UX-1): edit `.mcp.json` by hand (the hook blocks the agent
8
+ * doing it) → reconnect MCP → retry — three to four round trips. And the edit left the token behind:
9
+ * the same report found HOLMES_APPROVAL committed to the target repository since 8/18. The forgery
10
+ * defence's root of trust, sitting in plaintext in git.
11
+ *
12
+ * WHAT A GRANT IS. An ordinary `Approval` — no new format — read from
13
+ * `.ax/approvals/grants/<nonce>.json` at JUDGEMENT TIME, so nothing needs restarting. Judgement is
14
+ * the ONE existing `approvalCovers`; single-use for hard-hitl rides the ONE existing ledger nonce
15
+ * consumption. This module adds reading a directory, nothing else.
16
+ *
17
+ * WHY GRANTS MUST BE NARROW. A file is only a grant when it carries `scope` AND `expires` AND
18
+ * `nonce`. The unscoped session master key stays env-only: a masterless key rolling around as a
19
+ * file recreates the committed-token incident this channel exists to remove.
20
+ *
21
+ * TRUST MODEL. The session cannot write `.ax/approvals` — the same identity-based Write gate and
22
+ * shell-command gate that protect `.ax/ledger`. A hand that can create a grant file is by
23
+ * definition outside the session: the operator.
24
+ */
25
+ export declare const GRANTS_RELDIR: string;
26
+ /**
27
+ * Read the grant files. Filename order, so selection is deterministic.
28
+ *
29
+ * Ignored files are COUNTED, not silently skipped: "I dropped a grant in and nothing opened" must
30
+ * be diagnosable, and a silent skip makes an inert grant indistinguishable from no grant.
31
+ */
32
+ export declare function readGrants(root: string): {
33
+ grants: Approval[];
34
+ ignored: number;
35
+ };
36
+ /** First grant that covers the action — judged by `approvalCovers` and nothing else. Pure. */
37
+ export declare function pickCoveringGrant(grants: Approval[], action: {
38
+ kind: string;
39
+ target: string;
40
+ }, now: string): Approval | undefined;
41
+ /**
42
+ * Resolve the approval for an action: env first, then the grant files.
43
+ *
44
+ * @implements A-SPEC-245
45
+ * Env-first IS the backward compatibility: an adopter whose flows run on the env token sees not one
46
+ * changed verdict, message, or consumed file from this slice.
47
+ */
48
+ export declare function resolveApproval(root: string, envApproval: Approval | undefined, action: {
49
+ kind: string;
50
+ target: string;
51
+ }, now: string): {
52
+ approval: Approval;
53
+ source: 'env' | 'grant';
54
+ } | undefined;
55
+ /**
56
+ * Best-effort single-use: remove the grant file after the act it authorized succeeded.
57
+ *
58
+ * hard-hitl does not rely on this — its replay is refused atomically by the ledger nonce
59
+ * consumption the grant's mandatory nonce rides through. For the other sites, a failed removal
60
+ * leaves a reuse window whose ceiling is the grant's own `expires`; stated rather than hidden.
61
+ * The nonce is flattened to a basename so a traversal-shaped nonce cannot reach outside the dir.
62
+ */
63
+ export declare function consumeGrantFile(root: string, nonce: string): void;
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GRANTS_RELDIR = void 0;
37
+ exports.readGrants = readGrants;
38
+ exports.pickCoveringGrant = pickCoveringGrant;
39
+ exports.resolveApproval = resolveApproval;
40
+ exports.consumeGrantFile = consumeGrantFile;
41
+ // @implements A-SPEC-245
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ const risk_gate_1 = require("../guardrail/risk-gate");
45
+ /**
46
+ * The grant file channel — approval delivery with no file editing and no reconnect.
47
+ *
48
+ * @implements A-SPEC-245
49
+ * WHY THIS EXISTS. The only delivery channel was the MCP server's environment. Dogfooded cost of ONE
50
+ * human approval (Notion, 2026-08-23, UX-1): edit `.mcp.json` by hand (the hook blocks the agent
51
+ * doing it) → reconnect MCP → retry — three to four round trips. And the edit left the token behind:
52
+ * the same report found HOLMES_APPROVAL committed to the target repository since 8/18. The forgery
53
+ * defence's root of trust, sitting in plaintext in git.
54
+ *
55
+ * WHAT A GRANT IS. An ordinary `Approval` — no new format — read from
56
+ * `.ax/approvals/grants/<nonce>.json` at JUDGEMENT TIME, so nothing needs restarting. Judgement is
57
+ * the ONE existing `approvalCovers`; single-use for hard-hitl rides the ONE existing ledger nonce
58
+ * consumption. This module adds reading a directory, nothing else.
59
+ *
60
+ * WHY GRANTS MUST BE NARROW. A file is only a grant when it carries `scope` AND `expires` AND
61
+ * `nonce`. The unscoped session master key stays env-only: a masterless key rolling around as a
62
+ * file recreates the committed-token incident this channel exists to remove.
63
+ *
64
+ * TRUST MODEL. The session cannot write `.ax/approvals` — the same identity-based Write gate and
65
+ * shell-command gate that protect `.ax/ledger`. A hand that can create a grant file is by
66
+ * definition outside the session: the operator.
67
+ */
68
+ exports.GRANTS_RELDIR = path.join('.ax', 'approvals', 'grants');
69
+ /** A grant must be narrow: all three of scope (non-empty), expires, nonce. */
70
+ const isNarrow = (a) => Array.isArray(a.scope) && a.scope.length > 0 &&
71
+ typeof a.expires === 'string' && a.expires !== '' &&
72
+ typeof a.nonce === 'string' && a.nonce !== '';
73
+ /**
74
+ * Read the grant files. Filename order, so selection is deterministic.
75
+ *
76
+ * Ignored files are COUNTED, not silently skipped: "I dropped a grant in and nothing opened" must
77
+ * be diagnosable, and a silent skip makes an inert grant indistinguishable from no grant.
78
+ */
79
+ function readGrants(root) {
80
+ const dir = path.join(root, exports.GRANTS_RELDIR);
81
+ let names;
82
+ try {
83
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort();
84
+ }
85
+ catch {
86
+ return { grants: [], ignored: 0 };
87
+ }
88
+ const grants = [];
89
+ let ignored = 0;
90
+ for (const name of names) {
91
+ try {
92
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
93
+ const a = parsed;
94
+ if ((0, risk_gate_1.isValidApproval)(a) && isNarrow(a))
95
+ grants.push(a);
96
+ else
97
+ ignored++;
98
+ }
99
+ catch {
100
+ ignored++;
101
+ }
102
+ }
103
+ return { grants, ignored };
104
+ }
105
+ /** First grant that covers the action — judged by `approvalCovers` and nothing else. Pure. */
106
+ function pickCoveringGrant(grants, action, now) {
107
+ for (const g of Array.isArray(grants) ? grants : []) {
108
+ if ((0, risk_gate_1.approvalCovers)(g, action, now))
109
+ return g;
110
+ }
111
+ return undefined;
112
+ }
113
+ /**
114
+ * Resolve the approval for an action: env first, then the grant files.
115
+ *
116
+ * @implements A-SPEC-245
117
+ * Env-first IS the backward compatibility: an adopter whose flows run on the env token sees not one
118
+ * changed verdict, message, or consumed file from this slice.
119
+ */
120
+ function resolveApproval(root, envApproval, action, now) {
121
+ if ((0, risk_gate_1.approvalCovers)(envApproval, action, now))
122
+ return { approval: envApproval, source: 'env' };
123
+ const grant = pickCoveringGrant(readGrants(root).grants, action, now);
124
+ return grant ? { approval: grant, source: 'grant' } : undefined;
125
+ }
126
+ /**
127
+ * Best-effort single-use: remove the grant file after the act it authorized succeeded.
128
+ *
129
+ * hard-hitl does not rely on this — its replay is refused atomically by the ledger nonce
130
+ * consumption the grant's mandatory nonce rides through. For the other sites, a failed removal
131
+ * leaves a reuse window whose ceiling is the grant's own `expires`; stated rather than hidden.
132
+ * The nonce is flattened to a basename so a traversal-shaped nonce cannot reach outside the dir.
133
+ */
134
+ function consumeGrantFile(root, nonce) {
135
+ try {
136
+ const name = path.basename(String(nonce)) + '.json';
137
+ fs.rmSync(path.join(root, exports.GRANTS_RELDIR, name), { force: true });
138
+ }
139
+ catch {
140
+ /* the act already succeeded; a stuck file is bounded by expires */
141
+ }
142
+ }