@holmes-lab/holmes-kit 0.1.8 → 0.1.10
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 +99 -0
- package/README.md +48 -4
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/test-platform.d.ts +25 -0
- package/dist/holmes/cli/test-platform.js +38 -0
- package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
- package/dist/holmes/cpg/cpg-scanner.js +53 -1
- package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
- package/dist/holmes/cpg/forbidden-edges.js +140 -0
- package/dist/holmes/cpg/hash-cache.js +13 -5
- package/dist/holmes/cpg/language-parser-walk.js +70 -4
- package/dist/holmes/cpg/proposed-content.d.ts +51 -0
- package/dist/holmes/cpg/proposed-content.js +72 -0
- package/dist/holmes/cpg/required-calls.d.ts +62 -0
- package/dist/holmes/cpg/required-calls.js +93 -0
- package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
- package/dist/holmes/guardrail/cspec-change.js +70 -0
- package/dist/holmes/guardrail/risk-classifier.js +122 -0
- package/dist/holmes/guardrail/write-target.d.ts +42 -0
- package/dist/holmes/guardrail/write-target.js +69 -18
- package/dist/holmes/hooks/pre-tool-use.js +90 -5
- package/dist/holmes/hooks/stop.d.ts +17 -0
- package/dist/holmes/hooks/stop.js +39 -2
- package/dist/holmes/mcp/handlers.d.ts +41 -0
- package/dist/holmes/mcp/handlers.js +173 -3
- package/dist/holmes/mcp/tool-schemas.js +12 -0
- package/dist/holmes/project/dependencies.d.ts +15 -0
- package/dist/holmes/project/dependencies.js +58 -0
- package/dist/holmes/project/json-state.d.ts +24 -0
- package/dist/holmes/project/json-state.js +30 -0
- package/dist/holmes/reverse/scan.js +8 -1
- package/dist/holmes/review/scope.d.ts +29 -0
- package/dist/holmes/review/scope.js +44 -0
- package/dist/holmes/rtm/test-scope.d.ts +44 -0
- package/dist/holmes/rtm/test-scope.js +92 -2
- package/dist/holmes/server/dashboard.d.ts +77 -0
- package/dist/holmes/server/dashboard.js +703 -183
- package/dist/holmes/spec/approval-blockers.d.ts +21 -5
- package/dist/holmes/spec/approval-blockers.js +49 -6
- package/dist/holmes/spec/legacy-format.d.ts +14 -0
- package/dist/holmes/spec/legacy-format.js +15 -1
- package/dist/holmes/spec/nonfunctional.d.ts +70 -0
- package/dist/holmes/spec/nonfunctional.js +119 -0
- package/dist/holmes/spec/spec-parser.d.ts +25 -0
- package/dist/holmes/spec/spec-parser.js +46 -2
- package/dist/holmes/spec/spec-types.d.ts +4 -1
- package/dist/holmes/spec/spec-types.js +13 -1
- package/dist/holmes/testing/effects.d.ts +54 -0
- package/dist/holmes/testing/effects.js +107 -0
- package/package.json +3 -2
- package/playbooks/promote-slice/PLAYBOOK.md +20 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
1
2
|
/**
|
|
2
3
|
* Tools known to only READ. Everything else that carries a path is treated as a write.
|
|
3
4
|
*
|
|
@@ -8,6 +9,34 @@
|
|
|
8
9
|
* problem to the fourth tool; inverting it means an unrecognised tool fails CLOSED.
|
|
9
10
|
*/
|
|
10
11
|
export declare const READ_ONLY_TOOLS: readonly ["read", "grep", "glob", "ls", "notebookread", "webfetch", "websearch", "todowrite", "task", "bashoutput", "killshell"];
|
|
12
|
+
/**
|
|
13
|
+
* What KIND of absolute path this is, read from the syntax alone.
|
|
14
|
+
*
|
|
15
|
+
* @implements A-SPEC-220.1
|
|
16
|
+
* The gate used to ask `startsWith('/')` to mean "is absolute". That question is wrong on Windows:
|
|
17
|
+
* `C:/proj/src/app.ts` is absolute and answers false, so the whole identity block — symlink
|
|
18
|
+
* resolution, case folding, project containment — was skipped for every native Windows path. The
|
|
19
|
+
* block's own comment names NTFS as a case-insensitive filesystem while NTFS paths could not reach
|
|
20
|
+
* it.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately does NOT read `process.platform`. Platform is a property of where this process runs;
|
|
23
|
+
* the question here is what a STRING is, and keeping it that way is what lets a POSIX CI verify the
|
|
24
|
+
* Windows verdicts. A classifier that asked the platform could only be tested on the platform.
|
|
25
|
+
*
|
|
26
|
+
* `unc` is reported separately rather than folded into `drive` because a network path cannot be
|
|
27
|
+
* resolved to a local filesystem identity at all — the caller must refuse it, not gate it.
|
|
28
|
+
*/
|
|
29
|
+
export type AbsoluteKind = 'posix' | 'drive' | 'unc';
|
|
30
|
+
export declare function absoluteKindOf(p: string): AbsoluteKind | null;
|
|
31
|
+
/**
|
|
32
|
+
* Whether this path's syntax alone guarantees a case-insensitive filesystem.
|
|
33
|
+
*
|
|
34
|
+
* @implements A-SPEC-220.1
|
|
35
|
+
* A fallback, never the first answer: the caller probes the real filesystem (dev/ino identity across
|
|
36
|
+
* a case-flipped root) and only consults this when the probe cannot answer. A drive letter means
|
|
37
|
+
* NTFS or FAT, and both fold case — that is a fact the syntax carries, not a guess about the host.
|
|
38
|
+
*/
|
|
39
|
+
export declare function foldsCaseBySyntax(kind: AbsoluteKind | null): boolean;
|
|
11
40
|
/**
|
|
12
41
|
* Payload keys that name a file. Shared with normalization so the two cannot drift: a field the
|
|
13
42
|
* gate does not know about is a path the gate cannot see, which is how `notebook_path` slipped
|
|
@@ -29,6 +58,19 @@ export declare function writesFiles(toolName: string | undefined, input: unknown
|
|
|
29
58
|
* remainder rejoined lexically — a whole-path `realpath` throws on a file that does not exist yet,
|
|
30
59
|
* which is precisely when the gate has to answer.
|
|
31
60
|
*/
|
|
61
|
+
/**
|
|
62
|
+
* Which path grammar to reason in, chosen from the STRINGS rather than from where this process runs.
|
|
63
|
+
*
|
|
64
|
+
* @implements A-SPEC-220.2
|
|
65
|
+
* `node:path` binds its flavor to the host: on POSIX, `path.resolve('C:/proj', 'C:/proj/.ax/…')`
|
|
66
|
+
* treats a drive path as relative and resolves it under the cwd, so every containment answer about
|
|
67
|
+
* a Windows path is about the wrong location. That is invisible on Windows (where the ambient flavor
|
|
68
|
+
* is already win32) and it is precisely why the Windows verdicts were unverifiable from CI — the
|
|
69
|
+
* gap REQ-220 exists to close.
|
|
70
|
+
*
|
|
71
|
+
* A relative `raw` carries no grammar of its own, so the root decides.
|
|
72
|
+
*/
|
|
73
|
+
export declare function pathFlavorFor(root: string, raw: string): path.PlatformPath;
|
|
32
74
|
export declare function resolveTarget(root: string, raw: string, depth?: number): string;
|
|
33
75
|
/**
|
|
34
76
|
* Whether a write lands inside a governance directory.
|
|
@@ -34,7 +34,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.PATH_FIELDS = exports.READ_ONLY_TOOLS = void 0;
|
|
37
|
+
exports.absoluteKindOf = absoluteKindOf;
|
|
38
|
+
exports.foldsCaseBySyntax = foldsCaseBySyntax;
|
|
37
39
|
exports.writesFiles = writesFiles;
|
|
40
|
+
exports.pathFlavorFor = pathFlavorFor;
|
|
38
41
|
exports.resolveTarget = resolveTarget;
|
|
39
42
|
exports.isProtectedTarget = isProtectedTarget;
|
|
40
43
|
exports.specTargetOf = specTargetOf;
|
|
@@ -56,6 +59,30 @@ exports.READ_ONLY_TOOLS = [
|
|
|
56
59
|
'read', 'grep', 'glob', 'ls', 'notebookread',
|
|
57
60
|
'webfetch', 'websearch', 'todowrite', 'task', 'bashoutput', 'killshell',
|
|
58
61
|
];
|
|
62
|
+
function absoluteKindOf(p) {
|
|
63
|
+
if (typeof p !== 'string' || p.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
if (/^[/\\]{2}/.test(p))
|
|
66
|
+
return 'unc';
|
|
67
|
+
if (p.startsWith('/'))
|
|
68
|
+
return 'posix';
|
|
69
|
+
// A single letter before the colon — `http:` and `file:` are schemes, not drives. The separator
|
|
70
|
+
// is required: `C:src/app.ts` is drive-RELATIVE and therefore not an absolute path.
|
|
71
|
+
if (/^[A-Za-z]:[/\\]/.test(p))
|
|
72
|
+
return 'drive';
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether this path's syntax alone guarantees a case-insensitive filesystem.
|
|
77
|
+
*
|
|
78
|
+
* @implements A-SPEC-220.1
|
|
79
|
+
* A fallback, never the first answer: the caller probes the real filesystem (dev/ino identity across
|
|
80
|
+
* a case-flipped root) and only consults this when the probe cannot answer. A drive letter means
|
|
81
|
+
* NTFS or FAT, and both fold case — that is a fact the syntax carries, not a guess about the host.
|
|
82
|
+
*/
|
|
83
|
+
function foldsCaseBySyntax(kind) {
|
|
84
|
+
return kind === 'drive';
|
|
85
|
+
}
|
|
59
86
|
/**
|
|
60
87
|
* Payload keys that name a file. Shared with normalization so the two cannot drift: a field the
|
|
61
88
|
* gate does not know about is a path the gate cannot see, which is how `notebook_path` slipped
|
|
@@ -91,8 +118,27 @@ function writesFiles(toolName, input) {
|
|
|
91
118
|
* remainder rejoined lexically — a whole-path `realpath` throws on a file that does not exist yet,
|
|
92
119
|
* which is precisely when the gate has to answer.
|
|
93
120
|
*/
|
|
121
|
+
/**
|
|
122
|
+
* Which path grammar to reason in, chosen from the STRINGS rather than from where this process runs.
|
|
123
|
+
*
|
|
124
|
+
* @implements A-SPEC-220.2
|
|
125
|
+
* `node:path` binds its flavor to the host: on POSIX, `path.resolve('C:/proj', 'C:/proj/.ax/…')`
|
|
126
|
+
* treats a drive path as relative and resolves it under the cwd, so every containment answer about
|
|
127
|
+
* a Windows path is about the wrong location. That is invisible on Windows (where the ambient flavor
|
|
128
|
+
* is already win32) and it is precisely why the Windows verdicts were unverifiable from CI — the
|
|
129
|
+
* gap REQ-220 exists to close.
|
|
130
|
+
*
|
|
131
|
+
* A relative `raw` carries no grammar of its own, so the root decides.
|
|
132
|
+
*/
|
|
133
|
+
function pathFlavorFor(root, raw) {
|
|
134
|
+
const kind = absoluteKindOf(raw) ?? absoluteKindOf(root);
|
|
135
|
+
return kind === 'drive' || kind === 'unc' ? path.win32 : path.posix;
|
|
136
|
+
}
|
|
137
|
+
/** Compare paths in one spelling — win32 hands back backslashes that must not defeat a prefix test. */
|
|
138
|
+
const foldSep = (p) => p.replace(/\\/g, '/');
|
|
94
139
|
function resolveTarget(root, raw, depth = 0) {
|
|
95
|
-
const
|
|
140
|
+
const pp = pathFlavorFor(root, raw);
|
|
141
|
+
const abs = pp.resolve(root, raw);
|
|
96
142
|
// A DANGLING symlink still names where a write would land, and `realpath` fails on it — so the
|
|
97
143
|
// link is followed explicitly. Without this, `ln -s .ax/ledger/not-yet-there innocent` then
|
|
98
144
|
// writing `innocent` creates a file inside the protected directory while the gate sees a name
|
|
@@ -100,7 +146,7 @@ function resolveTarget(root, raw, depth = 0) {
|
|
|
100
146
|
if (depth < 8) {
|
|
101
147
|
try {
|
|
102
148
|
if (fs.lstatSync(abs).isSymbolicLink()) {
|
|
103
|
-
return resolveTarget(
|
|
149
|
+
return resolveTarget(pp.dirname(abs), fs.readlinkSync(abs), depth + 1);
|
|
104
150
|
}
|
|
105
151
|
}
|
|
106
152
|
catch { /* not a symlink, or unreadable — fall through to ancestor resolution */ }
|
|
@@ -109,13 +155,13 @@ function resolveTarget(root, raw, depth = 0) {
|
|
|
109
155
|
let cur = abs;
|
|
110
156
|
for (;;) {
|
|
111
157
|
try {
|
|
112
|
-
return
|
|
158
|
+
return pp.join(fs.realpathSync(cur), ...parts.reverse());
|
|
113
159
|
}
|
|
114
160
|
catch {
|
|
115
|
-
const parent =
|
|
161
|
+
const parent = pp.dirname(cur);
|
|
116
162
|
if (parent === cur)
|
|
117
163
|
return abs; // reached the filesystem root without an existing ancestor
|
|
118
|
-
parts.push(
|
|
164
|
+
parts.push(pp.basename(cur));
|
|
119
165
|
cur = parent;
|
|
120
166
|
}
|
|
121
167
|
}
|
|
@@ -165,13 +211,14 @@ function specTargetOf(root, specsDir, raw) {
|
|
|
165
211
|
const resolved = resolveTarget(root, raw);
|
|
166
212
|
if (!/\.md$/i.test(resolved))
|
|
167
213
|
return null;
|
|
168
|
-
|
|
214
|
+
// @implements A-SPEC-220.2 — one spelling for every comparison below.
|
|
215
|
+
const t = foldSep(resolved).toLowerCase();
|
|
169
216
|
// The WIRED store and the default layout both count. A deployment can move its specs, but
|
|
170
217
|
// `<root>/.ax/specs` stays a spec store by the project's own convention — judging only the wired
|
|
171
218
|
// one would hand back exactly the bypass this section closes for anyone who writes to the other.
|
|
172
219
|
for (const dir of [specsDir, path.join('.ax', 'specs')]) {
|
|
173
|
-
const b = resolveTarget(root, dir).toLowerCase();
|
|
174
|
-
if (t.startsWith(b +
|
|
220
|
+
const b = foldSep(resolveTarget(root, dir)).toLowerCase();
|
|
221
|
+
if (t.startsWith(b + '/'))
|
|
175
222
|
return resolved;
|
|
176
223
|
}
|
|
177
224
|
return null;
|
|
@@ -188,13 +235,15 @@ function specTargetOf(root, specsDir, raw) {
|
|
|
188
235
|
* through a dangling link creates exactly the file it names.
|
|
189
236
|
*/
|
|
190
237
|
function protectedFileKindOf(root, raw) {
|
|
238
|
+
// @implements A-SPEC-220.2
|
|
239
|
+
const pp = pathFlavorFor(root, raw);
|
|
191
240
|
const resolved = resolveTarget(root, raw);
|
|
192
241
|
const base = resolveTarget(root, '.');
|
|
193
|
-
const foldedTarget = resolved.toLowerCase();
|
|
194
|
-
const foldedBase = base.toLowerCase();
|
|
195
|
-
if (foldedTarget !== foldedBase && !foldedTarget.startsWith(foldedBase +
|
|
242
|
+
const foldedTarget = foldSep(resolved).toLowerCase();
|
|
243
|
+
const foldedBase = foldSep(base).toLowerCase();
|
|
244
|
+
if (foldedTarget !== foldedBase && !foldedTarget.startsWith(foldedBase + '/'))
|
|
196
245
|
return null;
|
|
197
|
-
const name =
|
|
246
|
+
const name = pp.basename(resolved).toLowerCase();
|
|
198
247
|
if (name === '.mcp.json')
|
|
199
248
|
return '.mcp.json';
|
|
200
249
|
// @implements A-SPEC-193 §8 (round 13) — 하네스가 늘면 **집행 지점도 는다**. Antigravity 는
|
|
@@ -202,7 +251,7 @@ function protectedFileKindOf(root, raw) {
|
|
|
202
251
|
// 읽는다. 그 둘은 `.claude/settings*` 및 `.mcp.json` 과 정확히 같은 성질의 파일이다 — 세션이
|
|
203
252
|
// 고칠 수 있으면 세션이 제 게이트를 끌 수 있다(실측: 배선 직후 두 파일 모두 allow 였다).
|
|
204
253
|
// `.agents` 전체를 잠그지는 않는다: 규칙과 스킬은 평범한 저작물이고, 잠그면 진짜 작업이 막힌다.
|
|
205
|
-
const parent =
|
|
254
|
+
const parent = pp.basename(pp.dirname(resolved)).toLowerCase();
|
|
206
255
|
if (parent === '.agents' && (name === 'hooks.json' || name === 'mcp_config.json'))
|
|
207
256
|
return `.agents/${name}`;
|
|
208
257
|
// Example/sample/template/dist copies carry no secret — the same exclusion the hook's regex had.
|
|
@@ -211,13 +260,15 @@ function protectedFileKindOf(root, raw) {
|
|
|
211
260
|
return null;
|
|
212
261
|
}
|
|
213
262
|
function protectedKindOf(root, raw) {
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
263
|
+
// @implements A-SPEC-220.2
|
|
264
|
+
const pp = pathFlavorFor(root, raw);
|
|
265
|
+
const target = foldSep(resolveTarget(root, raw)).toLowerCase();
|
|
266
|
+
const base = foldSep(resolveTarget(root, '.')).toLowerCase();
|
|
267
|
+
if (target !== base && !target.startsWith(base + '/'))
|
|
217
268
|
return null; // outside the project
|
|
218
269
|
for (const d of PROTECTED_DIRS) {
|
|
219
|
-
const dir =
|
|
220
|
-
if (target === dir || target.startsWith(dir +
|
|
270
|
+
const dir = foldSep(pp.join(base, d)).toLowerCase();
|
|
271
|
+
if (target === dir || target.startsWith(dir + '/'))
|
|
221
272
|
return d;
|
|
222
273
|
}
|
|
223
274
|
return null;
|
|
@@ -58,6 +58,10 @@ const tspec_state_1 = require("../guardrail/tspec-state");
|
|
|
58
58
|
const identity_1 = require("../governance/identity");
|
|
59
59
|
const role_policy_1 = require("../governance/role-policy");
|
|
60
60
|
const risk_classifier_1 = require("../guardrail/risk-classifier");
|
|
61
|
+
const proposed_content_1 = require("../cpg/proposed-content");
|
|
62
|
+
const forbidden_edges_1 = require("../cpg/forbidden-edges");
|
|
63
|
+
const required_calls_1 = require("../cpg/required-calls");
|
|
64
|
+
const language_parser_1 = require("../cpg/language-parser");
|
|
61
65
|
const write_target_1 = require("../guardrail/write-target");
|
|
62
66
|
const anchors_1 = require("../guardrail/anchors");
|
|
63
67
|
const governance_history_1 = require("../guardrail/governance-history");
|
|
@@ -224,7 +228,15 @@ function normalizeHookInput(raw) {
|
|
|
224
228
|
|| ['file_path', 'notebook_path'].some((f) => f in ti && typeof ti[f] !== 'string');
|
|
225
229
|
const out = {};
|
|
226
230
|
// Assigned individually so an undefined never becomes a present-but-undefined key downstream.
|
|
227
|
-
|
|
231
|
+
// @implements A-SPEC-232 — surrounding whitespace is trimmed HERE, at the input boundary, not
|
|
232
|
+
// in each gate. Measured 2026-08-22: `src/app.ts ` walked past No-Spec-No-Code entirely, because
|
|
233
|
+
// action classification matches the extension at the end of the string and an unrecognised action
|
|
234
|
+
// passes — the same inversion `write-target.ts` applied to tool names, never applied to path
|
|
235
|
+
// spelling. Inside a name it is left alone: `my file.ts` is a real filename, and merging distinct
|
|
236
|
+
// files would be worse than the defect. A path of only whitespace is no path, so the field is
|
|
237
|
+
// omitted rather than left present-but-empty (the direction REQ-144 set).
|
|
238
|
+
const fpRaw = str(ti.file_path ?? ti.TargetFile);
|
|
239
|
+
const fp = fpRaw === undefined ? undefined : (fpRaw.trim() || undefined);
|
|
228
240
|
if (fp !== undefined)
|
|
229
241
|
out.file_path = fp;
|
|
230
242
|
const c = str(ti.content ?? ti.CodeContent);
|
|
@@ -238,7 +250,8 @@ function normalizeHookInput(raw) {
|
|
|
238
250
|
out.command = cmd;
|
|
239
251
|
// @implements A-SPEC-163 — a path field the gate does not preserve is a path the gate cannot see,
|
|
240
252
|
// which is how a NotebookEdit reached protected files unexamined.
|
|
241
|
-
const
|
|
253
|
+
const nbRaw = str(ti.notebook_path);
|
|
254
|
+
const nb = nbRaw === undefined ? undefined : (nbRaw.trim() || undefined);
|
|
242
255
|
if (nb !== undefined)
|
|
243
256
|
out.notebook_path = nb;
|
|
244
257
|
return { tool_name: str(r.tool_name) ?? '', tool_input: out, ...(malformed ? { malformed: true } : {}) };
|
|
@@ -612,7 +625,23 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
612
625
|
// .mcp.json` → allow, and likewise for `.ax/roles`, `.ax/ledger` and No-Spec-No-Code on `src/app.ts`
|
|
613
626
|
// — the whole gate off for one character of case. Identity now decides, and the target is rewritten
|
|
614
627
|
// into the PROJECT'S OWN SPELLING so every check downstream reasons about one string.
|
|
615
|
-
|
|
628
|
+
// @implements A-SPEC-220.1 — the entry condition used to be `norm.startsWith('/')`, which asks
|
|
629
|
+
// "is this POSIX-absolute" while meaning "is this absolute". `C:/proj/src/app.ts` answers false,
|
|
630
|
+
// so identity resolution, case folding and containment were all skipped on native Windows — the
|
|
631
|
+
// very filesystem (NTFS) the case-folding comment below names.
|
|
632
|
+
const absKind = (0, write_target_1.absoluteKindOf)(norm);
|
|
633
|
+
if (absKind === 'unc') {
|
|
634
|
+
// A network path has no local filesystem identity to resolve, so containment cannot be decided.
|
|
635
|
+
// A-SPEC-191 §17/§29 both began as "the gate did not refuse to govern — it concluded there was
|
|
636
|
+
// nothing to govern"; an undecidable target is refused, not allowed.
|
|
637
|
+
return {
|
|
638
|
+
permissionDecision: 'deny',
|
|
639
|
+
permissionDecisionReason: '[Holmes-Kit] 네트워크 경로(UNC)는 로컬 파일시스템 정체로 해석할 수 없어'
|
|
640
|
+
+ ' 프로젝트 안팎을 판정할 수 없습니다 — 판정할 수 없는 대상은 허용하지 않습니다.'
|
|
641
|
+
+ ' 프로젝트를 로컬 드라이브에 두거나 드라이브 문자로 매핑한 경로를 사용하십시오.',
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (absKind === 'posix' || absKind === 'drive') {
|
|
616
645
|
const inside = (t, r) => t === r || t.startsWith(r + '/');
|
|
617
646
|
const asProject = (abs, base) => (abs.length <= base.length ? projectRoot : `${projectRoot}/${abs.slice(base.length + 1)}`);
|
|
618
647
|
const realTarget = realOf(norm).replace(/\/+$/, '');
|
|
@@ -622,8 +651,11 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
622
651
|
// files folded together, and a case-insensitive volume on Linux must not keep the bypass.
|
|
623
652
|
const caseBlind = (() => {
|
|
624
653
|
const flipped = projectRoot.replace(/[a-zA-Z]/, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()));
|
|
654
|
+
// @implements A-SPEC-220.1 — the probe cannot answer when the root has no letter to flip.
|
|
655
|
+
// A drive letter means NTFS or FAT, both case-folding; that is carried by the syntax, so it is
|
|
656
|
+
// a fact here rather than a guess about the host.
|
|
625
657
|
if (flipped === projectRoot)
|
|
626
|
-
return process.platform === 'darwin' || process.platform === 'win32';
|
|
658
|
+
return (0, write_target_1.foldsCaseBySyntax)(absKind) || process.platform === 'darwin' || process.platform === 'win32';
|
|
627
659
|
if (flipped === projectRoot)
|
|
628
660
|
return false;
|
|
629
661
|
try {
|
|
@@ -632,7 +664,13 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
632
664
|
return a.dev === b.dev && a.ino === b.ino;
|
|
633
665
|
}
|
|
634
666
|
catch {
|
|
635
|
-
|
|
667
|
+
// @implements A-SPEC-220.1 — the probe THREW, which means it has no answer, not that the
|
|
668
|
+
// filesystem is case-sensitive. Conflating the two is why a drive-letter path kept failing
|
|
669
|
+
// both containment tests: neither spelling exists to stat from a POSIX CI, and a Windows
|
|
670
|
+
// project reached over a path this process cannot resolve is the same situation. Only the
|
|
671
|
+
// syntax fact is used here — a drive letter is NTFS or FAT, both case-folding — so POSIX
|
|
672
|
+
// inputs, for which this is always false, keep their previous verdict exactly.
|
|
673
|
+
return (0, write_target_1.foldsCaseBySyntax)(absKind);
|
|
636
674
|
}
|
|
637
675
|
})();
|
|
638
676
|
const fold = (s) => (caseBlind ? s.toLowerCase() : s);
|
|
@@ -762,6 +800,53 @@ function evaluateHook(input, specsDir, opts) {
|
|
|
762
800
|
return { permissionDecision: 'allow' };
|
|
763
801
|
// Read specs synchronously via a fresh walk (hook must be sync)
|
|
764
802
|
const specs = readSpecsSync(specsDir);
|
|
803
|
+
// @implements A-SPEC-230 — structural constraints move from "checked by the suite" to "enforced at
|
|
804
|
+
// the write". One parse of the proposed file, not a scan: both engines already judge file by file,
|
|
805
|
+
// and measured 2026-08-22 that is 2ms (18ms for the 109KB outlier) against 3.3s + 1.5s for the
|
|
806
|
+
// two full scans.
|
|
807
|
+
//
|
|
808
|
+
// ONLY APPROVED C-SPECs supply rules. If a draft could enforce, anyone could block anyone else's
|
|
809
|
+
// writes by dropping one in — enforcement without approval.
|
|
810
|
+
{
|
|
811
|
+
const proposed = typeof input.tool_input.content === 'string'
|
|
812
|
+
? { content: input.tool_input.content, mode: 'full' }
|
|
813
|
+
: typeof input.tool_input.new_string === 'string'
|
|
814
|
+
? { content: input.tool_input.new_string, mode: 'fragment' }
|
|
815
|
+
: null;
|
|
816
|
+
if (proposed && (0, write_target_1.specTargetOf)(opts.projectRoot, specsDir, p) === null) {
|
|
817
|
+
const forbidden = [];
|
|
818
|
+
const required = [];
|
|
819
|
+
for (const sp of specs) {
|
|
820
|
+
if (sp.type !== 'C-SPEC' || sp.status !== 'approved')
|
|
821
|
+
continue;
|
|
822
|
+
forbidden.push(...(0, forbidden_edges_1.parseForbiddenEdges)(sp.sections?.['Forbidden Edges'] ?? '').rules);
|
|
823
|
+
required.push(...(0, required_calls_1.parseRequiredCalls)(sp.sections?.['Layer Rules'] ?? '').rules);
|
|
824
|
+
}
|
|
825
|
+
if (forbidden.length > 0 || required.length > 0) {
|
|
826
|
+
const { violations } = (0, proposed_content_1.checkProposedContent)({
|
|
827
|
+
forbidden,
|
|
828
|
+
required,
|
|
829
|
+
// @implements A-SPEC-231 — the path is normalised before the prefix test. Adversarial
|
|
830
|
+
// pass 3, 2026-08-22: `./src/a/x.ts` and `src//a/x.ts` both walked past a rule scoped to
|
|
831
|
+
// `src/a/`. The same evasion class was already paid for on the command side, where
|
|
832
|
+
// `normalizeCommandPaths` exists because those spellings "slipped every protected-path
|
|
833
|
+
// regex". A rule must not depend on how the caller spelled the path.
|
|
834
|
+
sourcePath: path.posix.normalize(relPath.trim()).replace(/^\.\//, ''),
|
|
835
|
+
content: proposed.content,
|
|
836
|
+
mode: proposed.mode,
|
|
837
|
+
parser: new language_parser_1.TreeSitterTsParser(),
|
|
838
|
+
});
|
|
839
|
+
if (violations.length > 0) {
|
|
840
|
+
return {
|
|
841
|
+
permissionDecision: 'deny',
|
|
842
|
+
permissionDecisionReason: '[Holmes-Kit] 구조 제약 위반 — 승인된 C-SPEC 이 금지한 형태입니다:\n'
|
|
843
|
+
+ violations.map((v) => ' - ' + v).join('\n')
|
|
844
|
+
+ '\n' + proposed_content_1.CSPEC_GATE_LIMITS,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
765
850
|
// @implements A-SPEC-166
|
|
766
851
|
// What this change CLAIMS to implement, judged independently of what the file already carried.
|
|
767
852
|
// Measured 2026-08-08: the same unapproved `@implements` was denied in a new file and allowed in
|
|
@@ -83,6 +83,23 @@ export declare function decideStopGuard(wantsBlock: boolean, priorConsecutiveBlo
|
|
|
83
83
|
};
|
|
84
84
|
/** Test seam for the same reason as the two exports below — the CLI sets this on a real run. */
|
|
85
85
|
export declare function __setWiredSpecsForTest(v: string | undefined): void;
|
|
86
|
+
/**
|
|
87
|
+
* `readGuardCount`, degraded to 0 if it throws.
|
|
88
|
+
*
|
|
89
|
+
* @implements A-SPEC-237
|
|
90
|
+
* The shape check in `readGuardState` removes the corruption we KNOW about. This removes the path
|
|
91
|
+
* an unknown one would take. In the same block, three other risky reads — provenance, findings and
|
|
92
|
+
* debt — already sit inside a try/catch; `readGuardCount` alone did not, and that asymmetry was the
|
|
93
|
+
* route this defect travelled to the outer fail-open handler.
|
|
94
|
+
*
|
|
95
|
+
* Zero means "the counter is unknown", never "there is nothing to block". The gate still evaluates.
|
|
96
|
+
*
|
|
97
|
+
* REDUNDANT WITH THE SHAPE CHECK, ON PURPOSE — and the mutation results say so plainly. Removing
|
|
98
|
+
* either layer alone leaves every case green, because each fully covers the corruption we know
|
|
99
|
+
* about; removing BOTH fails two. That is what defence in depth looks like from a mutation run, and
|
|
100
|
+
* it is recorded here rather than left as an unexplained survivor for the next reader to rediscover.
|
|
101
|
+
*/
|
|
102
|
+
export declare function guardCountOrZero(sessionId: string, read?: (id: string) => number): number;
|
|
86
103
|
export declare function readGuardCount(sessionId: string): number;
|
|
87
104
|
/**
|
|
88
105
|
* Exported for the §19 race discriminator: the persistence layer was the untested half (round-11),
|
|
@@ -39,9 +39,11 @@ exports.evaluateStop = evaluateStop;
|
|
|
39
39
|
exports.stopDebtAction = stopDebtAction;
|
|
40
40
|
exports.decideStopGuard = decideStopGuard;
|
|
41
41
|
exports.__setWiredSpecsForTest = __setWiredSpecsForTest;
|
|
42
|
+
exports.guardCountOrZero = guardCountOrZero;
|
|
42
43
|
exports.readGuardCount = readGuardCount;
|
|
43
44
|
exports.writeGuardCount = writeGuardCount;
|
|
44
45
|
const fs = __importStar(require("node:fs"));
|
|
46
|
+
const json_state_1 = require("../project/json-state");
|
|
45
47
|
const node_child_process_1 = require("node:child_process");
|
|
46
48
|
const path = __importStar(require("node:path"));
|
|
47
49
|
const test_scope_1 = require("../rtm/test-scope");
|
|
@@ -222,12 +224,47 @@ const GUARD_STATE = () => guardStatePath() ?? path.join(process.cwd(), '.ax', 'l
|
|
|
222
224
|
// every session into a single project-level file. One cell per session, oldest evicted.
|
|
223
225
|
const GUARD_SESSIONS_MAX = 32;
|
|
224
226
|
function readGuardState() {
|
|
227
|
+
let parsed;
|
|
225
228
|
try {
|
|
226
|
-
|
|
229
|
+
parsed = JSON.parse(fs.readFileSync(GUARD_STATE(), 'utf8'));
|
|
227
230
|
}
|
|
228
231
|
catch {
|
|
229
232
|
return {};
|
|
230
233
|
}
|
|
234
|
+
// @implements A-SPEC-237
|
|
235
|
+
// The catch above sees only a parse FAILURE. `null`, `42`, `"x"`, `true` and `[1,2]` parse
|
|
236
|
+
// fine, and the failure arrives later at `s.sessions?.[…]` as a TypeError. Measured 2026-08-23,
|
|
237
|
+
// end to end: a project that answered `{"decision":"block"}` with eight article violations
|
|
238
|
+
// produced NO OUTPUT and exit 0 once this file contained the four characters `null`. The file is
|
|
239
|
+
// gitignored and written by the hook itself, so one echo disabled governance leaving no trace.
|
|
240
|
+
if (!(0, json_state_1.isJsonStateObject)(parsed))
|
|
241
|
+
return {};
|
|
242
|
+
return parsed;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* `readGuardCount`, degraded to 0 if it throws.
|
|
246
|
+
*
|
|
247
|
+
* @implements A-SPEC-237
|
|
248
|
+
* The shape check in `readGuardState` removes the corruption we KNOW about. This removes the path
|
|
249
|
+
* an unknown one would take. In the same block, three other risky reads — provenance, findings and
|
|
250
|
+
* debt — already sit inside a try/catch; `readGuardCount` alone did not, and that asymmetry was the
|
|
251
|
+
* route this defect travelled to the outer fail-open handler.
|
|
252
|
+
*
|
|
253
|
+
* Zero means "the counter is unknown", never "there is nothing to block". The gate still evaluates.
|
|
254
|
+
*
|
|
255
|
+
* REDUNDANT WITH THE SHAPE CHECK, ON PURPOSE — and the mutation results say so plainly. Removing
|
|
256
|
+
* either layer alone leaves every case green, because each fully covers the corruption we know
|
|
257
|
+
* about; removing BOTH fails two. That is what defence in depth looks like from a mutation run, and
|
|
258
|
+
* it is recorded here rather than left as an unexplained survivor for the next reader to rediscover.
|
|
259
|
+
*/
|
|
260
|
+
function guardCountOrZero(sessionId, read = readGuardCount) {
|
|
261
|
+
try {
|
|
262
|
+
const n = read(sessionId);
|
|
263
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
231
268
|
}
|
|
232
269
|
function readGuardCount(sessionId) {
|
|
233
270
|
const s = readGuardState();
|
|
@@ -377,7 +414,7 @@ if (require.main === module) {
|
|
|
377
414
|
findingsUnreadable = true;
|
|
378
415
|
} // @implements A-SPEC-191 (§4a) — list() absorbs ENOENT; a throw means the ledger EXISTS and cannot be read, which must block, not launder
|
|
379
416
|
const out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable });
|
|
380
|
-
const guard = decideStopGuard(out.block,
|
|
417
|
+
const guard = decideStopGuard(out.block, guardCountOrZero(sessionId));
|
|
381
418
|
const persisted = writeGuardCount(sessionId, guard.nextCount);
|
|
382
419
|
// @implements A-SPEC-134 — the cap-yield is no longer silent: a clean turn clears any debt, a
|
|
383
420
|
// give-up records the unresolved articles so WRITE_CODE is blocked until the constitution is
|
|
@@ -104,6 +104,46 @@ declare function makeRawHandlers(store: SpecStore): {
|
|
|
104
104
|
remaining: import("../spec/validator").Finding[];
|
|
105
105
|
reason?: undefined;
|
|
106
106
|
}>;
|
|
107
|
+
/**
|
|
108
|
+
* Move a document to `outdated` — the only path there.
|
|
109
|
+
*
|
|
110
|
+
* @implements A-SPEC-222
|
|
111
|
+
* `outdated` was in SPEC_STATUSES with no code writing it and no document carrying it: a
|
|
112
|
+
* declared state nothing could reach. That mattered the moment 23 deprecated REQs needed
|
|
113
|
+
* cleaning up, because the only transition tool sends everything to `draft`, and calling a
|
|
114
|
+
* retired document "draft" is a worse lie than the non-canonical word it replaced.
|
|
115
|
+
*
|
|
116
|
+
* Retirement withdraws authority rather than granting it, so it does NOT need the approval key
|
|
117
|
+
* by default. Two cases invert that, and the threshold follows the RISK rather than the name of
|
|
118
|
+
* the act:
|
|
119
|
+
*
|
|
120
|
+
* - A sealed document. The code gate demands an approved T-SPEC naming the target A-SPEC;
|
|
121
|
+
* retiring that T-SPEC removes the demand. Unguarded, retirement is an approval bypass.
|
|
122
|
+
* - A document an APPROVED spec depends on. That chain is holding something up right now.
|
|
123
|
+
*/
|
|
124
|
+
spec_retire(a: {
|
|
125
|
+
root?: string;
|
|
126
|
+
id: string;
|
|
127
|
+
reason?: string;
|
|
128
|
+
}): Promise<{
|
|
129
|
+
ok: boolean;
|
|
130
|
+
reason: string;
|
|
131
|
+
retired?: undefined;
|
|
132
|
+
id?: undefined;
|
|
133
|
+
dependents?: undefined;
|
|
134
|
+
} | {
|
|
135
|
+
ok: boolean;
|
|
136
|
+
retired: boolean;
|
|
137
|
+
id: string;
|
|
138
|
+
dependents: never[];
|
|
139
|
+
reason: string;
|
|
140
|
+
} | {
|
|
141
|
+
ok: boolean;
|
|
142
|
+
retired: boolean;
|
|
143
|
+
id: string;
|
|
144
|
+
dependents: string[];
|
|
145
|
+
reason?: undefined;
|
|
146
|
+
}>;
|
|
107
147
|
spec_approve(a: {
|
|
108
148
|
root?: string;
|
|
109
149
|
id: string;
|
|
@@ -304,6 +344,7 @@ declare function makeRawHandlers(store: SpecStore): {
|
|
|
304
344
|
}>;
|
|
305
345
|
unrequestedSymbols: string[];
|
|
306
346
|
coverageGaps: string[];
|
|
347
|
+
obligationGaps: string[];
|
|
307
348
|
}>;
|
|
308
349
|
review_prepare(a: {
|
|
309
350
|
root: string;
|