@holmes-lab/holmes-kit 0.1.11 → 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 +52 -0
- package/README.md +26 -49
- 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/docs/install-guide.md +131 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,58 @@ 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
|
+
|
|
43
|
+
<!-- @implements A-SPEC-209 -->
|
|
44
|
+
## [0.1.12] - 2026-08-23
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- **Install Guide (`docs/install-guide.md`, shipped in the package)**: install paths organised by
|
|
48
|
+
account type and privileges — a decision table first, commands second — because the same wrong
|
|
49
|
+
command (`npm install -g` against a protected prefix) was run three times by a real adopter
|
|
50
|
+
before the right one. Troubleshooting is keyed to the exact error text npm prints (`EPERM mkdir`,
|
|
51
|
+
`notarget`, `better-sqlite3` build failures, `spawn sh ENOENT`), and each entry says why
|
|
52
|
+
elevation is the wrong fix.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
- **README Quickstart leads with the situation table**, and steps 2–3 use `npx holmes-kit …` —
|
|
56
|
+
the bare `holmes-kit` they previously showed only exists on PATH after a global install, which
|
|
57
|
+
is not the primary path.
|
|
58
|
+
|
|
7
59
|
<!-- @implements A-SPEC-209 -->
|
|
8
60
|
## [0.1.11] - 2026-08-23
|
|
9
61
|
|
package/README.md
CHANGED
|
@@ -51,58 +51,35 @@ Holmes-Kit prioritizes **CLI-based AI Coding Agents** where OS-level process hoo
|
|
|
51
51
|
|
|
52
52
|
## ⚡ Quickstart (3-Minute Setup)
|
|
53
53
|
|
|
54
|
-
### 1. Install
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
```powershell
|
|
79
|
-
npm config set prefix "$env:APPDATA\npm" # Windows
|
|
80
|
-
# then ensure %APPDATA%\npm is on PATH
|
|
81
|
-
```
|
|
82
|
-
```bash
|
|
83
|
-
npm config set prefix "$HOME/.npm-global" # macOS / Linux
|
|
84
|
-
export PATH="$HOME/.npm-global/bin:$PATH"
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
A Node version manager (`nvm`, `fnm`, `volta`) does the same thing by keeping the whole
|
|
88
|
-
toolchain under your home directory.
|
|
89
|
-
</details>
|
|
90
|
-
|
|
91
|
-
**Prerequisites** — Node.js `>= 20.0.0`, plus a native binary for two kinds of dependency:
|
|
92
|
-
|
|
93
|
-
| Dependency | How the binary arrives |
|
|
94
|
-
|---|---|
|
|
95
|
-
| 8 tree-sitter grammars | Prebuilt binaries ship inside the packages (`darwin-arm64`, `darwin-x64`, `linux-x64`, `win32-x64`) — nothing is compiled |
|
|
96
|
-
| `better-sqlite3` | Downloaded at install time by `prebuild-install`, **falling back to compiling** if no prebuild matches your platform and Node ABI |
|
|
97
|
-
|
|
98
|
-
Only the second can require a toolchain. If it has to compile, you need a C++ build environment:
|
|
99
|
-
Xcode Command Line Tools on macOS, Visual Studio Build Tools on Windows, or
|
|
100
|
-
`apk add --no-cache python3 make g++` on Alpine.
|
|
54
|
+
### 1. Install — find your row first
|
|
55
|
+
|
|
56
|
+
The same wrong command was run three times by a real adopter before the right one; a table beats
|
|
57
|
+
prose read top-to-bottom.
|
|
58
|
+
|
|
59
|
+
| Which situation are you in? | Privileges | Command |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| **Using it in one project** (most people) | none | `npm install --save-dev @holmes-lab/holmes-kit` |
|
|
62
|
+
| Company-managed PC / restricted account | none | same — no system directory is touched |
|
|
63
|
+
| CI / container | none | same, plus `--prefer-online` right after a release |
|
|
64
|
+
| CLI across many projects (`-g`) | depends | run `npm config get prefix` first — see below |
|
|
65
|
+
|
|
66
|
+
**Before `npm install -g`**: if `npm config get prefix` names a protected directory
|
|
67
|
+
(`C:\Program Files\nodejs`, `/usr/local`), `-g` dies with `EPERM` **before any package file
|
|
68
|
+
arrives** — no package version can fix that, and elevation is the wrong fix (it runs native
|
|
69
|
+
install scripts with system privileges, and it did not even work in the reported case). Move the
|
|
70
|
+
prefix to user space instead — one-time setup, exact commands in the
|
|
71
|
+
**[Install Guide](docs/install-guide.md)**, along with troubleshooting keyed to the exact error
|
|
72
|
+
text (`EPERM mkdir`, `notarget`, `better-sqlite3` build failures).
|
|
73
|
+
|
|
74
|
+
**Prerequisites** — Node.js `>= 20.0.0`. The 8 tree-sitter grammars ship prebuilt binaries for
|
|
75
|
+
macOS/Linux/Windows and compile nothing; `better-sqlite3` downloads a prebuild at install time,
|
|
76
|
+
falling back to compiling — only that fallback needs a C++ toolchain (VS Build Tools / Xcode CLT /
|
|
77
|
+
`apk add python3 make g++`).
|
|
101
78
|
|
|
102
79
|
### 2. Initialize in Your Project
|
|
103
80
|
```bash
|
|
104
81
|
cd /path/to/your/project
|
|
105
|
-
holmes-kit init
|
|
82
|
+
npx holmes-kit init # drop the npx prefix if you installed with -g
|
|
106
83
|
```
|
|
107
84
|
*An interactive prompt will ask which AI agent harnesses to wire into your project:*
|
|
108
85
|
```text
|
|
@@ -114,7 +91,7 @@ holmes-kit init
|
|
|
114
91
|
|
|
115
92
|
### 3. Verify Health
|
|
116
93
|
```bash
|
|
117
|
-
holmes-kit doctor
|
|
94
|
+
npx holmes-kit doctor
|
|
118
95
|
```
|
|
119
96
|
*If everything is green, your project is governed and ready for AI pair-programming!*
|
|
120
97
|
|
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;
|