@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 +36 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +9 -2
- package/dist/holmes/cli/agents.js +9 -2
- package/dist/holmes/cli/approve.d.ts +46 -0
- package/dist/holmes/cli/approve.js +200 -0
- package/dist/holmes/cli/index.js +78 -1
- package/dist/holmes/governance/approval-grants.d.ts +63 -0
- package/dist/holmes/governance/approval-grants.js +142 -0
- package/dist/holmes/governance/approval-queue.d.ts +93 -0
- package/dist/holmes/governance/approval-queue.js +230 -0
- package/dist/holmes/guardrail/risk-classifier.js +12 -5
- package/dist/holmes/guardrail/write-target.js +2 -0
- package/dist/holmes/hooks/pre-tool-use.js +34 -12
- package/dist/holmes/hooks/stop.d.ts +39 -0
- package/dist/holmes/hooks/stop.js +100 -1
- package/dist/holmes/mcp/handlers.js +70 -11
- package/dist/holmes/mcp/server.js +9 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
+
}
|
package/dist/holmes/cli/index.js
CHANGED
|
@@ -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
|
+
}
|