@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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval request queue — the review list a human batches decisions over.
|
|
3
|
+
*
|
|
4
|
+
* @implements A-SPEC-244
|
|
5
|
+
* WHY THIS EXISTS. Six gate sites refuse with "an out-of-band approval is required" and leave no
|
|
6
|
+
* trace. Dogfooded on a real Python repository (Notion, 2026-08-23): the agent retried the same
|
|
7
|
+
* blocked action six times, the operator had no list of what was waiting, and the narrower the
|
|
8
|
+
* approval token's scope, the more round-trips each decision cost — least privilege and usability in
|
|
9
|
+
* direct conflict (UX-3). Batch review needs a list, and the list must be written by the refusing
|
|
10
|
+
* gate itself; a list humans maintain by hand goes stale the day it is written.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THIS IS NOT. Authority. Nothing in this file opens any gate: a forged `granted` event changes
|
|
13
|
+
* no verdict anywhere, which is the property that lets the queue ship before its directory has any
|
|
14
|
+
* write protection. Authority arrives in the next slice as nonce-bound grant files, verified by the
|
|
15
|
+
* same `approvalCovers`/`consumeNonceExclusively` the env channel already uses.
|
|
16
|
+
*
|
|
17
|
+
* Storage is an append-only jsonl event log folded into state — the idiom `FindingsLedger` and the
|
|
18
|
+
* provenance chain already use, so a reader learns no new model.
|
|
19
|
+
*/
|
|
20
|
+
export declare const QUEUE_RELPATH: string;
|
|
21
|
+
export interface PendingRequest {
|
|
22
|
+
id: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
target: string;
|
|
25
|
+
why: string;
|
|
26
|
+
count: number;
|
|
27
|
+
firstTs: string;
|
|
28
|
+
lastTs: string;
|
|
29
|
+
hold?: boolean;
|
|
30
|
+
question?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface QueueState {
|
|
33
|
+
pending: PendingRequest[];
|
|
34
|
+
/** Broken lines are counted, never swallowed: an empty-looking queue must be distinguishable from a corrupted one. */
|
|
35
|
+
malformedLines: number;
|
|
36
|
+
/**
|
|
37
|
+
* The LAST decision per request id.
|
|
38
|
+
*
|
|
39
|
+
* @implements A-SPEC-246
|
|
40
|
+
* Exposed so a refusal can show the agent what a human already decided — measured six consecutive
|
|
41
|
+
* retries of one blocked action, because the agent had no way to see that a human had said no.
|
|
42
|
+
* An invisible decision cannot stop a retry loop.
|
|
43
|
+
*/
|
|
44
|
+
decisions: Record<string, {
|
|
45
|
+
event: 'granted' | 'denied';
|
|
46
|
+
reason?: string;
|
|
47
|
+
ts: string;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Deterministic request id from the action's identity.
|
|
52
|
+
*
|
|
53
|
+
* @implements A-SPEC-244
|
|
54
|
+
* The same refusal must produce the same id or repeats cannot fold — measured six consecutive
|
|
55
|
+
* stop-hook blocks for one violation, which as six list rows would drown the review list. Random
|
|
56
|
+
* ids cannot fold, and randomness is a material this repository already avoids for determinism.
|
|
57
|
+
* The timestamp rides on the EVENT, never in the id.
|
|
58
|
+
*/
|
|
59
|
+
export declare function approvalRequestId(kind: string, target: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* Fold queue events into the current pending list. Pure.
|
|
62
|
+
*
|
|
63
|
+
* An unknown event kind — a future version's vocabulary — neither throws nor flips any existing
|
|
64
|
+
* entry's pending state: an old CLI reading a new queue must not silently mis-report what is
|
|
65
|
+
* waiting.
|
|
66
|
+
*/
|
|
67
|
+
export declare function foldQueue(lines: string[]): QueueState;
|
|
68
|
+
/**
|
|
69
|
+
* Append a request event. Fire-and-forget.
|
|
70
|
+
*
|
|
71
|
+
* @implements A-SPEC-244
|
|
72
|
+
* Every failure is swallowed into `false`: this runs INSIDE gate verdict paths, and the moment a
|
|
73
|
+
* dead queue could change a verdict, killing the queue becomes a way to manipulate the gate. The
|
|
74
|
+
* caller uses the boolean only to decide whether to print the review hint.
|
|
75
|
+
*/
|
|
76
|
+
export declare function enqueueApprovalRequest(root: string, req: {
|
|
77
|
+
kind: string;
|
|
78
|
+
target: string;
|
|
79
|
+
why: string;
|
|
80
|
+
}): boolean;
|
|
81
|
+
/** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
|
|
82
|
+
export declare function readQueue(root: string): QueueState;
|
|
83
|
+
/**
|
|
84
|
+
* The refusal-message suffix pointing the operator at the review CLI.
|
|
85
|
+
*
|
|
86
|
+
* Appended ONLY when the enqueue succeeded: a hint naming an id that was never written would send
|
|
87
|
+
* the operator to an empty list.
|
|
88
|
+
*/
|
|
89
|
+
export declare function queueHint(root: string, req: {
|
|
90
|
+
kind: string;
|
|
91
|
+
target: string;
|
|
92
|
+
why: string;
|
|
93
|
+
}): string;
|
|
@@ -0,0 +1,230 @@
|
|
|
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.QUEUE_RELPATH = void 0;
|
|
37
|
+
exports.approvalRequestId = approvalRequestId;
|
|
38
|
+
exports.foldQueue = foldQueue;
|
|
39
|
+
exports.enqueueApprovalRequest = enqueueApprovalRequest;
|
|
40
|
+
exports.readQueue = readQueue;
|
|
41
|
+
exports.queueHint = queueHint;
|
|
42
|
+
// @implements A-SPEC-244
|
|
43
|
+
const node_crypto_1 = require("node:crypto");
|
|
44
|
+
const fs = __importStar(require("node:fs"));
|
|
45
|
+
const path = __importStar(require("node:path"));
|
|
46
|
+
/**
|
|
47
|
+
* The approval request queue — the review list a human batches decisions over.
|
|
48
|
+
*
|
|
49
|
+
* @implements A-SPEC-244
|
|
50
|
+
* WHY THIS EXISTS. Six gate sites refuse with "an out-of-band approval is required" and leave no
|
|
51
|
+
* trace. Dogfooded on a real Python repository (Notion, 2026-08-23): the agent retried the same
|
|
52
|
+
* blocked action six times, the operator had no list of what was waiting, and the narrower the
|
|
53
|
+
* approval token's scope, the more round-trips each decision cost — least privilege and usability in
|
|
54
|
+
* direct conflict (UX-3). Batch review needs a list, and the list must be written by the refusing
|
|
55
|
+
* gate itself; a list humans maintain by hand goes stale the day it is written.
|
|
56
|
+
*
|
|
57
|
+
* WHAT THIS IS NOT. Authority. Nothing in this file opens any gate: a forged `granted` event changes
|
|
58
|
+
* no verdict anywhere, which is the property that lets the queue ship before its directory has any
|
|
59
|
+
* write protection. Authority arrives in the next slice as nonce-bound grant files, verified by the
|
|
60
|
+
* same `approvalCovers`/`consumeNonceExclusively` the env channel already uses.
|
|
61
|
+
*
|
|
62
|
+
* Storage is an append-only jsonl event log folded into state — the idiom `FindingsLedger` and the
|
|
63
|
+
* provenance chain already use, so a reader learns no new model.
|
|
64
|
+
*/
|
|
65
|
+
exports.QUEUE_RELPATH = path.join('.ax', 'approvals', 'queue.jsonl');
|
|
66
|
+
/**
|
|
67
|
+
* Deterministic request id from the action's identity.
|
|
68
|
+
*
|
|
69
|
+
* @implements A-SPEC-244
|
|
70
|
+
* The same refusal must produce the same id or repeats cannot fold — measured six consecutive
|
|
71
|
+
* stop-hook blocks for one violation, which as six list rows would drown the review list. Random
|
|
72
|
+
* ids cannot fold, and randomness is a material this repository already avoids for determinism.
|
|
73
|
+
* The timestamp rides on the EVENT, never in the id.
|
|
74
|
+
*/
|
|
75
|
+
function approvalRequestId(kind, target) {
|
|
76
|
+
return 'req-' + (0, node_crypto_1.createHash)('sha256').update(`${kind}:${target}`).digest('hex').slice(0, 12);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Fold queue events into the current pending list. Pure.
|
|
80
|
+
*
|
|
81
|
+
* An unknown event kind — a future version's vocabulary — neither throws nor flips any existing
|
|
82
|
+
* entry's pending state: an old CLI reading a new queue must not silently mis-report what is
|
|
83
|
+
* waiting.
|
|
84
|
+
*/
|
|
85
|
+
function foldQueue(lines) {
|
|
86
|
+
const pending = new Map();
|
|
87
|
+
const decisions = {};
|
|
88
|
+
let malformedLines = 0;
|
|
89
|
+
for (const raw of Array.isArray(lines) ? lines : []) {
|
|
90
|
+
const line = String(raw ?? '').trim();
|
|
91
|
+
if (line === '')
|
|
92
|
+
continue;
|
|
93
|
+
let e;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(line);
|
|
96
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
97
|
+
malformedLines++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
e = parsed;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
malformedLines++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const id = typeof e.id === 'string' ? e.id : undefined;
|
|
107
|
+
switch (e.event) {
|
|
108
|
+
case 'requested': {
|
|
109
|
+
if (!id) {
|
|
110
|
+
malformedLines++;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
const prev = pending.get(id);
|
|
114
|
+
const ts = typeof e.ts === 'string' ? e.ts : '';
|
|
115
|
+
if (prev) {
|
|
116
|
+
prev.count += 1;
|
|
117
|
+
prev.lastTs = ts || prev.lastTs;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
pending.set(id, {
|
|
121
|
+
id,
|
|
122
|
+
kind: String(e.kind ?? ''),
|
|
123
|
+
target: String(e.target ?? ''),
|
|
124
|
+
why: String(e.why ?? ''),
|
|
125
|
+
count: 1,
|
|
126
|
+
firstTs: ts,
|
|
127
|
+
lastTs: ts,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'granted':
|
|
133
|
+
case 'denied':
|
|
134
|
+
if (id) {
|
|
135
|
+
pending.delete(id);
|
|
136
|
+
decisions[id] = {
|
|
137
|
+
event: e.event,
|
|
138
|
+
...(typeof e.reason === 'string' ? { reason: e.reason } : {}),
|
|
139
|
+
ts: typeof e.ts === 'string' ? e.ts : '',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
case 'held': {
|
|
144
|
+
const entry = id ? pending.get(id) : undefined;
|
|
145
|
+
if (entry) {
|
|
146
|
+
entry.hold = true;
|
|
147
|
+
if (typeof e.question === 'string')
|
|
148
|
+
entry.question = e.question;
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
// Future vocabulary: preserved by ignoring, never by guessing.
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { pending: [...pending.values()], malformedLines, decisions };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Append a request event. Fire-and-forget.
|
|
161
|
+
*
|
|
162
|
+
* @implements A-SPEC-244
|
|
163
|
+
* Every failure is swallowed into `false`: this runs INSIDE gate verdict paths, and the moment a
|
|
164
|
+
* dead queue could change a verdict, killing the queue becomes a way to manipulate the gate. The
|
|
165
|
+
* caller uses the boolean only to decide whether to print the review hint.
|
|
166
|
+
*/
|
|
167
|
+
function enqueueApprovalRequest(root, req) {
|
|
168
|
+
try {
|
|
169
|
+
// @implements A-SPEC-244
|
|
170
|
+
// Only under an EXISTING .ax. The constitution suite (§25a) caught the first cut creating
|
|
171
|
+
// .ax/approvals in a bare directory on a config-write refusal — the hook planting a governance
|
|
172
|
+
// marker where governance was never opted into, the exact defect A-SPEC-191 §25 exists to stop.
|
|
173
|
+
// An ungoverned directory gets no queue and no hint; it is not part of the system.
|
|
174
|
+
if (!fs.existsSync(path.join(root, '.ax')))
|
|
175
|
+
return false;
|
|
176
|
+
const file = path.join(root, exports.QUEUE_RELPATH);
|
|
177
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
178
|
+
const event = {
|
|
179
|
+
event: 'requested',
|
|
180
|
+
id: approvalRequestId(req.kind, req.target),
|
|
181
|
+
kind: req.kind,
|
|
182
|
+
target: req.target,
|
|
183
|
+
why: req.why,
|
|
184
|
+
ts: new Date().toISOString(),
|
|
185
|
+
};
|
|
186
|
+
fs.appendFileSync(file, JSON.stringify(event) + '\n');
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
|
|
194
|
+
function readQueue(root) {
|
|
195
|
+
try {
|
|
196
|
+
const raw = fs.readFileSync(path.join(root, exports.QUEUE_RELPATH), 'utf8');
|
|
197
|
+
return foldQueue(raw.split('\n'));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return { pending: [], malformedLines: 0, decisions: {} };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The refusal-message suffix pointing the operator at the review CLI.
|
|
205
|
+
*
|
|
206
|
+
* Appended ONLY when the enqueue succeeded: a hint naming an id that was never written would send
|
|
207
|
+
* the operator to an empty list.
|
|
208
|
+
*/
|
|
209
|
+
function queueHint(root, req) {
|
|
210
|
+
const id = approvalRequestId(req.kind, req.target);
|
|
211
|
+
// @implements A-SPEC-246
|
|
212
|
+
// Decisions are read BEFORE filing, so a human's answer reaches the agent in the very refusal
|
|
213
|
+
// that would otherwise just say "waiting". A hold shows its question INSTEAD of the waiting line —
|
|
214
|
+
// the question is the actionable part, and the agent's job is to carry it to the user. A denial
|
|
215
|
+
// shows its reason and still re-files: a human may change their mind, and the re-filed entry is
|
|
216
|
+
// how the reviewer sees the agent still wants it.
|
|
217
|
+
const state = readQueue(root);
|
|
218
|
+
const held = state.pending.find((p) => p.id === id && p.hold);
|
|
219
|
+
if (held) {
|
|
220
|
+
return `\n[보류 — 운영자 질문: ${held.question ?? '(질문 없음)'}] 게이트는 닫혀 있습니다.`
|
|
221
|
+
+ ' 이 질문에 대한 답을 사용자에게 보고하십시오. 결정은 운영자가 npx holmes-kit approve 에서 내립니다.';
|
|
222
|
+
}
|
|
223
|
+
const decided = state.decisions[id];
|
|
224
|
+
const denialLine = decided?.event === 'denied'
|
|
225
|
+
? `\n[거부됨${decided.reason ? `: ${decided.reason}` : ''}] 같은 요청을 반복하지 말고 거부 사유를 해소해 사용자와 상의하십시오.`
|
|
226
|
+
: '';
|
|
227
|
+
return enqueueApprovalRequest(root, req)
|
|
228
|
+
? `${denialLine}\n[승인 대기 ${id}] 운영자: npx holmes-kit approve`
|
|
229
|
+
: denialLine;
|
|
230
|
+
}
|
|
@@ -257,14 +257,21 @@ function scopeAxToProject(cmd, projectRoot) {
|
|
|
257
257
|
// `state` joined the protected set with P3 (REQ-134): it holds the constitution-debt and the
|
|
258
258
|
// last-green baseline, so a single-file `rm` there would bypass the WRITE_CODE debt gate — measured
|
|
259
259
|
// in the campaign completeness critique. Deleting governance state is now hard-hitl like every other.
|
|
260
|
-
|
|
260
|
+
// @implements A-SPEC-245
|
|
261
|
+
// ONE alternation, five regexes. The list of protected .ax directories was spelled out in five
|
|
262
|
+
// separate literals here, and adding 'approvals' to only one of them was measured immediately:
|
|
263
|
+
// 'echo x > .ax/approvals/grants/forged.json' passed while the Write gate refused the same file —
|
|
264
|
+
// the exact two-copies drift this repository has paid for with isTestFile and a restated section
|
|
265
|
+
// splitter. The directories are now written once; each rule keeps its own shape around them.
|
|
266
|
+
const PROTECTED_AX_ALT = 'specs|decisions|ledger|cpg_cache|state|roles|approvals';
|
|
267
|
+
const PROTECTED_AX_PATH_RE = new RegExp(String.raw `(^|/)\.ax/(${PROTECTED_AX_ALT})(/|$)`);
|
|
261
268
|
// A removal verb + a protected .ax path anywhere in a shell command — the command form of
|
|
262
269
|
// the governance-path delete, which the structured `kind:'delete'` branch alone would miss.
|
|
263
270
|
// Not just removal: RELOCATING or CLOBBERING a protected .ax path is equally destructive to it, so
|
|
264
271
|
// mv/cp/git-mv and a `>` truncation count too (review F4 — `mv .ax/specs /tmp` evaded the rm-only rule).
|
|
265
272
|
const REMOVAL_VERB_RE = /\b(rm|rmdir|unlink|git\s+rm|git\s+mv|mv|cp)\b/i;
|
|
266
|
-
const CLOBBER_AX_RE =
|
|
267
|
-
const CMD_PROTECTED_AX_RE =
|
|
273
|
+
const CLOBBER_AX_RE = new RegExp(String.raw `>\s*['"]?[^\s;&|]*\.ax/(${PROTECTED_AX_ALT})\b`, 'i');
|
|
274
|
+
const CMD_PROTECTED_AX_RE = new RegExp(String.raw `(^|[\s'"=(/])\.ax/(${PROTECTED_AX_ALT})(/|\b)`);
|
|
268
275
|
/**
|
|
269
276
|
* Normalize a command string for protected-path matching: collapse `//` runs and `./` segments so
|
|
270
277
|
* `./.ax//ledger/x` matches the same rules as `.ax/ledger/x` (adversarial probe: those forms slipped
|
|
@@ -285,11 +292,11 @@ function normalizeCommandPaths(cmd) {
|
|
|
285
292
|
}
|
|
286
293
|
// File-mutating shell verbs beyond redirect/removal: these WRITE a target path, so a protected path
|
|
287
294
|
// as their argument is the same governance breach as `>` or `rm` (probe: tee/cp/install slipped).
|
|
288
|
-
const WRITE_VERB_AX_RE =
|
|
295
|
+
const WRITE_VERB_AX_RE = new RegExp(String.raw `\b(?:tee|cp|install|dd|ln|mv|rsync)\b[^;&|\n]*\.ax/(?:${PROTECTED_AX_ALT})\b`, 'i');
|
|
289
296
|
// An interpreter one-liner naming a protected path: `node -e "...appendFileSync('.ax/ledger/...')"`,
|
|
290
297
|
// `python3 -c "open('.ax/ledger/...','a')"`. Read-only one-liners are not distinguishable from writes
|
|
291
298
|
// here, so this is deliberately conservative on the .ax governance surface only.
|
|
292
|
-
const INTERPRETER_AX_RE =
|
|
299
|
+
const INTERPRETER_AX_RE = new RegExp(String.raw `\b(?:node|python3?|ruby|perl|deno|bun)\b[^;&|\n]*-(?:e|c|-eval)\b[^\n]*\.ax/(?:${PROTECTED_AX_ALT})\b`, 'i');
|
|
293
300
|
/**
|
|
294
301
|
* Commands that only read. Everything else over a governance path is a write.
|
|
295
302
|
*
|
|
@@ -168,6 +168,8 @@ function resolveTarget(root, raw, depth = 0) {
|
|
|
168
168
|
}
|
|
169
169
|
/** Governance directories, relative to the project root. */
|
|
170
170
|
const PROTECTED_DIRS = [
|
|
171
|
+
// @implements A-SPEC-245 — grant files ARE approvals; a session that can write them mints its own.
|
|
172
|
+
path.join('.ax', 'approvals'),
|
|
171
173
|
path.join('.ax', 'ledger'),
|
|
172
174
|
path.join('.ax', 'roles'),
|
|
173
175
|
path.join('.ax', 'cpg_cache'),
|
|
@@ -69,6 +69,8 @@ const rtm_check_1 = require("../rtm/rtm-check");
|
|
|
69
69
|
const ledger_store_1 = require("../governance/ledger-store");
|
|
70
70
|
const provenance_chain_1 = require("../governance/provenance-chain");
|
|
71
71
|
const risk_gate_1 = require("../guardrail/risk-gate");
|
|
72
|
+
const approval_queue_1 = require("../governance/approval-queue");
|
|
73
|
+
const approval_grants_1 = require("../governance/approval-grants");
|
|
72
74
|
const approval_blockers_1 = require("../spec/approval-blockers");
|
|
73
75
|
// Resolve per-rm-target git facts (H1 git-aware guardrail): is each recursive-rm target a
|
|
74
76
|
// regenerable build artifact (gitignored), version-controlled source (tracked), or escaping the
|
|
@@ -399,13 +401,21 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
399
401
|
// the approval is single-use its nonce must not already be spent. An out-of-scope, expired, or
|
|
400
402
|
// replayed token denies exactly as an absent one — one token stops being a master key.
|
|
401
403
|
if (assessment.level === 'hard-hitl') {
|
|
402
|
-
|
|
404
|
+
// @implements A-SPEC-245 — env first, then the grant files. The grant's mandatory nonce rides
|
|
405
|
+
// the SAME atomic ledger consumption below, so hard-hitl single-use needs no new machinery.
|
|
406
|
+
const resolved = (0, approval_grants_1.resolveApproval)(opts.projectRoot, approval, { kind: 'shell', target: command }, nowTs);
|
|
407
|
+
const covers = resolved !== undefined;
|
|
408
|
+
const acting = resolved?.approval ?? approval;
|
|
403
409
|
const deny = (why) => ({
|
|
404
410
|
permissionDecision: 'deny',
|
|
405
411
|
permissionDecisionReason: `[Holmes-Kit] hard-hitl risk: ${assessment.reasons.join('; ')} — ${why}`,
|
|
406
412
|
});
|
|
407
|
-
|
|
408
|
-
|
|
413
|
+
// @implements A-SPEC-244 — the refusal itself files the review request. The queue kind is the
|
|
414
|
+
// approval SCOPE kind ('shell'), so the reviewing CLI can mint a covering grant mechanically.
|
|
415
|
+
if (!covers) {
|
|
416
|
+
return deny('requires an approval that covers this command'
|
|
417
|
+
+ (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'shell', target: command, why: assessment.reasons.join('; ') }));
|
|
418
|
+
}
|
|
409
419
|
// @implements A-SPEC-141
|
|
410
420
|
// Check and spend in ONE atomic operation. The previous shape asked `isNonceConsumed(...)`
|
|
411
421
|
// and only afterwards appended the consumption record, so two concurrent agents both saw
|
|
@@ -420,16 +430,16 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
420
430
|
// creating `.ax/ledger` is the wiring working, not a plant. Refusing there would deny the
|
|
421
431
|
// first legitimate use in every new project. The single-use guarantee still holds: the record
|
|
422
432
|
// lands in the resolved root, which is the same file every later call resolves to.
|
|
423
|
-
if ((0, provenance_chain_1.blankNonce)(
|
|
433
|
+
if ((0, provenance_chain_1.blankNonce)(acting?.nonce)) {
|
|
424
434
|
return deny('승인이 단일 사용(nonce)을 선언했으나 값이 비어 있습니다 — 1회성을 집행할 수 없어 거부합니다');
|
|
425
435
|
}
|
|
426
|
-
if (
|
|
436
|
+
if (acting?.nonce) {
|
|
427
437
|
let won;
|
|
428
438
|
try {
|
|
429
|
-
won = (0, provenance_chain_1.consumeNonceExclusively)(
|
|
430
|
-
ts: nowTs, actor:
|
|
439
|
+
won = (0, provenance_chain_1.consumeNonceExclusively)(acting.nonce, ledgerFile, {
|
|
440
|
+
ts: nowTs, actor: acting.actor, kind: 'nonce-consumed',
|
|
431
441
|
summary: `consumed single-use approval for: ${(0, provenance_chain_1.redactTarget)('command', command)}`.slice(0, 200),
|
|
432
|
-
inputs: [(0, provenance_chain_1.nonceFingerprint)(
|
|
442
|
+
inputs: [(0, provenance_chain_1.nonceFingerprint)(acting.nonce)], rationale: acting.rationale, authorization: (0, provenance_chain_1.authorizationRef)(acting.actor, acting.token),
|
|
433
443
|
});
|
|
434
444
|
}
|
|
435
445
|
catch (err) {
|
|
@@ -441,6 +451,9 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
441
451
|
}
|
|
442
452
|
if (!won)
|
|
443
453
|
return deny('single-use approval already consumed (replay)');
|
|
454
|
+
// The ledger consumption is the authority; removing the file is cleanup for the next reader.
|
|
455
|
+
if (resolved?.source === 'grant')
|
|
456
|
+
(0, approval_grants_1.consumeGrantFile)(opts.projectRoot, acting.nonce);
|
|
444
457
|
}
|
|
445
458
|
}
|
|
446
459
|
// Bash BYPASS gates (adversarial review: `cat > evil.ts` created ungoverned files, `echo x >
|
|
@@ -715,13 +728,18 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
715
728
|
// this threat and closed only the shell entrance; a plain Write was measured as ALLOWED. Unlike
|
|
716
729
|
// specs, no approval seal covers this file, so the write itself must be the thing that is gated.
|
|
717
730
|
if ((0, write_target_1.protectedKindOf)(opts.projectRoot, norm) === path.join('.ax', 'roles')) {
|
|
718
|
-
|
|
731
|
+
// @implements A-SPEC-245 — grant files answer here too; consumed on the allowed write below.
|
|
732
|
+
const rolesResolved = (0, approval_grants_1.resolveApproval)(opts.projectRoot, weApproval, { kind: 'config-write', target: norm }, nowTs);
|
|
733
|
+
if (rolesResolved === undefined) {
|
|
719
734
|
return {
|
|
720
735
|
permissionDecision: 'deny',
|
|
721
736
|
permissionDecisionReason: '[Holmes-Kit] 역할 정책(.ax/roles/)은 이 게이트가 집행하는 권한 자체를 정의합니다 —'
|
|
722
|
-
+ ' 에이전트가 스스로 고칠 수 없습니다. config-write를 포함한 대역외 승인이 필요합니다.'
|
|
737
|
+
+ ' 에이전트가 스스로 고칠 수 없습니다. config-write를 포함한 대역외 승인이 필요합니다.'
|
|
738
|
+
+ (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'config-write', target: norm, why: '역할 정책(.ax/roles/) 쓰기' }),
|
|
723
739
|
};
|
|
724
740
|
}
|
|
741
|
+
if (rolesResolved.source === 'grant' && rolesResolved.approval.nonce)
|
|
742
|
+
(0, approval_grants_1.consumeGrantFile)(opts.projectRoot, rolesResolved.approval.nonce);
|
|
725
743
|
}
|
|
726
744
|
// @implements A-SPEC-191 §22 — 신원으로 묻는다. 철자 정규식은 프로젝트 안 링크 하나로 빗나갔다.
|
|
727
745
|
const configKind = (0, write_target_1.protectedFileKindOf)(opts.projectRoot, norm);
|
|
@@ -731,12 +749,16 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
731
749
|
if (configKind !== null || (0, write_target_1.isProtectedTarget)(opts.projectRoot, norm)) {
|
|
732
750
|
// @implements A-SPEC-133 — a token scoped to config-write authorizes this; a spec-only or
|
|
733
751
|
// out-of-scope token does not, so config forgery cannot ride a narrow grant.
|
|
734
|
-
|
|
752
|
+
const cfgResolved = (0, approval_grants_1.resolveApproval)(opts.projectRoot, weApproval, { kind: 'config-write', target: norm }, nowTs);
|
|
753
|
+
if (cfgResolved === undefined) {
|
|
735
754
|
// @implements A-SPEC-193 §8 — 무엇을 막았는지 이름한다. 목록을 문장에 박아 두면 하네스가
|
|
736
755
|
// 늘 때마다 문면이 거짓이 된다(REQ-155: 엉뚱한 파일을 지목하는 진단의 값은 음수다).
|
|
737
756
|
const kindName = configKind ?? (0, write_target_1.protectedKindOf)(opts.projectRoot, norm) ?? '설정';
|
|
738
|
-
return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${kindName} 은(는) 훅·승인 설정입니다 — HOLMES_APPROVAL 을 위조하거나 거버넌스를 끌 수 있으므로 config-write 를 포함한 대역외 승인이 필요합니다`
|
|
757
|
+
return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${kindName} 은(는) 훅·승인 설정입니다 — HOLMES_APPROVAL 을 위조하거나 거버넌스를 끌 수 있으므로 config-write 를 포함한 대역외 승인이 필요합니다`
|
|
758
|
+
+ (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'config-write', target: norm, why: `${kindName} 쓰기` }) };
|
|
739
759
|
}
|
|
760
|
+
if (cfgResolved.source === 'grant' && cfgResolved.approval.nonce)
|
|
761
|
+
(0, approval_grants_1.consumeGrantFile)(opts.projectRoot, cfgResolved.approval.nonce);
|
|
740
762
|
}
|
|
741
763
|
// Spec-file edit → graph-aware blast-radius risk (Tier-2 push-feed): a change to a widely-depended-on
|
|
742
764
|
// or foundational (REQ) spec ripples across the graph, so its risk is assessed from the spec
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PendingRequest } from '../governance/approval-queue';
|
|
1
2
|
import { Spec } from '../spec/spec-parser';
|
|
2
3
|
/**
|
|
3
4
|
* @implements A-SPEC-100.2
|
|
@@ -57,6 +58,10 @@ export declare function evaluateStop(specs: Spec[], evidence?: StopEvidence): {
|
|
|
57
58
|
block: boolean;
|
|
58
59
|
reason?: string;
|
|
59
60
|
articles?: string[];
|
|
61
|
+
violations?: {
|
|
62
|
+
article: string;
|
|
63
|
+
detail: string;
|
|
64
|
+
}[];
|
|
60
65
|
};
|
|
61
66
|
/**
|
|
62
67
|
* @implements A-SPEC-134
|
|
@@ -76,6 +81,40 @@ export declare function stopDebtAction(evaluation: {
|
|
|
76
81
|
articles: string[];
|
|
77
82
|
};
|
|
78
83
|
export declare const MAX_CONSECUTIVE_BLOCKS = 3;
|
|
84
|
+
/**
|
|
85
|
+
* Should the stop gate keep re-blocking a turn, or has every unresolved debt already been queued
|
|
86
|
+
* for the owner's decision?
|
|
87
|
+
*
|
|
88
|
+
* @implements A-SPEC-247
|
|
89
|
+
* Measured (Notion UX-2): the stop hook re-blocks a violation whose only fix is an owner approval on
|
|
90
|
+
* every turn — six in a row — even when the request is already waiting in the queue, giving the
|
|
91
|
+
* agent nothing to do and the user the same wall repeatedly. This answers "is anything left that the
|
|
92
|
+
* agent or a NON-waiting approval could resolve?".
|
|
93
|
+
*
|
|
94
|
+
* ASYMMETRY IS THE POINT, and it is what keeps this from becoming amnesty: acknowledged is true ONLY
|
|
95
|
+
* when nothing is blocking AND at least one thing is waiting. A single non-waiting violation — a
|
|
96
|
+
* code debt, or an approval debt nobody queued — and the gate blocks exactly as before. Waiting is
|
|
97
|
+
* read from the pending list (REQ-244's fold), not re-derived. A violation whose detail names no
|
|
98
|
+
* spec id cannot be matched to a request, so it blocks — the safe direction.
|
|
99
|
+
*/
|
|
100
|
+
export declare function acknowledgeStop(violations: {
|
|
101
|
+
article: string;
|
|
102
|
+
detail: string;
|
|
103
|
+
}[], pending: PendingRequest[]): {
|
|
104
|
+
acknowledged: boolean;
|
|
105
|
+
blocking: string[];
|
|
106
|
+
waiting: string[];
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* One-line health of the env approval token, for the server's startup log.
|
|
110
|
+
*
|
|
111
|
+
* @implements A-SPEC-247
|
|
112
|
+
* Measured (Notion UX-4): an invalid token gives no signal until an approval is attempted, so the
|
|
113
|
+
* operator round-trips without knowing why. This is a LOG, not a gate — an absent or invalid token
|
|
114
|
+
* is a legitimate read-only posture, and the server must start regardless. The reason distinguishes
|
|
115
|
+
* "not JSON" from "wrong shape" so the operator knows which to fix.
|
|
116
|
+
*/
|
|
117
|
+
export declare function describeTokenHealth(raw: string | undefined): string;
|
|
79
118
|
export declare function decideStopGuard(wantsBlock: boolean, priorConsecutiveBlocks: number, cap?: number): {
|
|
80
119
|
block: boolean;
|
|
81
120
|
nextCount: number;
|