@holmes-lab/holmes-kit 0.7.0 → 0.8.1

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.
@@ -535,11 +535,14 @@ function evaluateHook(input, specsDir, opts) {
535
535
  // class as HOLMES_ROLE. The GEMINI_API_KEY spelling is included because that is the
536
536
  // ecosystem-compatible name the resolver honors; the narrowed usage forms below keep
537
537
  // `grep GEMINI_API_KEY src/…` free exactly as they do for the HOLMES names.
538
- const SECRET = String.raw `(?:HOLMES_(?:LEDGER_KEY|APPROVAL|ROLE|GATE_BYPASS|SEMANTIC_API_KEY)|GEMINI_API_KEY|GOOGLE_API_KEY)`;
538
+ // @implements A-SPEC-532.2 — HOLMES_AUTONOMOUS_APPROVAL is the out-of-band autonomy switch; a
539
+ // session that could set it would self-grant autonomous approval, the same self-disarm the
540
+ // ROLE/GATE_BYPASS names are blocked for.
541
+ const SECRET = String.raw `(?:HOLMES_(?:LEDGER_KEY|APPROVAL|ROLE|GATE_BYPASS|SEMANTIC_API_KEY|AUTONOMOUS_APPROVAL)|GEMINI_API_KEY|GOOGLE_API_KEY)`;
539
542
  // Which of the two harms this is. Setting a role or a bypass is not reading a secret, it is
540
543
  // self-granting authority — reporting both as "reads the environment" sends an operator to
541
544
  // hunt a leak that never happened.
542
- const GRANTS_SELF = new RegExp(String.raw `\b(?:HOLMES_(?:ROLE|GATE_BYPASS)|HOLMES_SEMANTIC_API_KEY|GEMINI_API_KEY|GOOGLE_API_KEY)\b`).test(command);
545
+ const GRANTS_SELF = new RegExp(String.raw `\b(?:HOLMES_(?:ROLE|GATE_BYPASS|AUTONOMOUS_APPROVAL)|HOLMES_SEMANTIC_API_KEY|GEMINI_API_KEY|GOOGLE_API_KEY)\b`).test(command);
543
546
  const usesSecret =
544
547
  // @implements A-SPEC-477 — the credential file is the same secret at rest; reading,
545
548
  // copying or redirecting it is the harvest in file form. Path-usage only, so mentioning
@@ -655,22 +658,53 @@ function evaluateHook(input, specsDir, opts) {
655
658
  return m[i];
656
659
  return undefined;
657
660
  };
658
- const cfgCandidates = [];
659
- const cfgCollect = (re) => {
660
- for (const m of command.matchAll(re)) {
661
+ // @implements A-SPEC-528.1 — every shell-write candidate carries the EFFECTIVE base of the
662
+ // segment it appears in. The rules below used to hand relative candidates to `resolvesInside`
663
+ // with the project root as the base; a `cd` earlier in the command moves that base, and the
664
+ // mismatch was measured both ways (2026-09-03 probe C1~C11): five in-project writes spelled
665
+ // from a subdirectory walked through, three legitimate out-of-tree writes were denied.
666
+ const segments = (0, write_target_1.shellSegments)(command, opts.projectRoot);
667
+ const isTilde = (p) => p === '~' || p.startsWith('~/');
668
+ // Relative candidate in a segment whose base is unknowable (dynamic cd argument): 'unknown',
669
+ // and the caller fails CLOSED — but only when such a candidate exists, so `cd "$X" && npm
670
+ // test` stays free.
671
+ const judgeCand = (c, roots) => {
672
+ if ((0, write_target_1.absoluteKindOf)(c.raw) !== null || isTilde(c.raw))
673
+ return (0, write_target_1.resolvesInside)(c.raw, roots) ? 'in' : 'out';
674
+ if (c.base === null)
675
+ return 'unknown';
676
+ const pp = (0, write_target_1.pathFlavorFor)(c.base, c.raw);
677
+ const abs = pp.resolve(c.base, c.raw);
678
+ const kind = (0, write_target_1.absoluteKindOf)(abs);
679
+ return (0, write_target_1.resolvesInside)(abs, roots, kind === 'drive' || kind === 'unc' ? path.win32 : path.posix) ? 'in' : 'out';
680
+ };
681
+ const collectSeg = (seg, re, list) => {
682
+ for (const m of seg.text.matchAll(re)) {
661
683
  const g = firstGroup(m);
662
684
  if (g !== undefined)
663
- cfgCandidates.push(g);
685
+ list.push({ raw: g, base: seg.base });
664
686
  }
665
687
  };
666
- cfgCollect(new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*${pathAlt(CONFIG_FILE)}`, 'g'));
667
- cfgCollect(new RegExp(`\\b(?:cp|mv|install|rsync|ln|dd|truncate|chmod|chown)\\b[^;|&]*?${pathAlt(CONFIG_FILE)}`, 'g'));
668
- cfgCollect(new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*?${pathAlt(CONFIG_FILE)}`, 'g'));
688
+ const cfgCandidates = [];
689
+ for (const seg of segments) {
690
+ collectSeg(seg, new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*${pathAlt(CONFIG_FILE)}`, 'g'), cfgCandidates);
691
+ collectSeg(seg, new RegExp(`\\b(?:cp|mv|install|rsync|ln|dd|truncate|chmod|chown)\\b[^;|&]*?${pathAlt(CONFIG_FILE)}`, 'g'), cfgCandidates);
692
+ collectSeg(seg, new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*?${pathAlt(CONFIG_FILE)}`, 'g'), cfgCandidates);
693
+ }
694
+ // Interpreter one-liner paths keep their whole-text matching (A-SPEC-448/450); a RELATIVE one
695
+ // is judged as a may-analysis over every determinate base plus the start base — deny-only
696
+ // over-approximation, sealed in A-SPEC-528.1 §3.
697
+ const detBases = [...new Set(segments.map((s) => s.base).filter((b) => b !== null)), opts.projectRoot];
698
+ const hasUnknownBase = segments.some((s) => s.base === null);
699
+ const expandInterp = (raw) => (0, write_target_1.absoluteKindOf)(raw) !== null || isTilde(raw)
700
+ ? [{ raw, base: opts.projectRoot }]
701
+ : [...detBases.map((b) => ({ raw, base: b })), ...(hasUnknownBase ? [{ raw, base: null }] : [])];
669
702
  // @implements A-SPEC-447 — one containment predicate, shared with the code rule below.
670
703
  const SESSION_ROOTS = [opts.projectRoot, path.join(os.homedir(), '.claude')];
671
704
  const CFG_RE = new RegExp(`${CONFIG_FILE}$`);
672
- const configWrite = [...cfgCandidates, ...interpreterWrites.filter((p) => CFG_RE.test(p))]
673
- .some((raw) => (0, write_target_1.resolvesInside)(raw, SESSION_ROOTS));
705
+ const cfgVerdicts = [...cfgCandidates, ...interpreterWrites.filter((p) => CFG_RE.test(p)).flatMap(expandInterp)]
706
+ .map((c) => judgeCand(c, SESSION_ROOTS));
707
+ const configWrite = cfgVerdicts.includes('in');
674
708
  // Residual, stated rather than papered over: an INTERPRETER can write these files too. A first
675
709
  // draft matched `node|python|perl|ruby` anywhere near the pattern and denied
676
710
  // `node -e "console.log(cfg.env.name)"` — reading a property named `env`. A rule that cannot
@@ -694,46 +728,55 @@ function evaluateHook(input, specsDir, opts) {
694
728
  // extension part and `pathAlt` supplies the prefix and the quoting alternatives.
695
729
  const EXT_TAIL = `\\.${CODE_EXT}`;
696
730
  const candidates = [];
697
- const collect = (re) => {
698
- for (const m of command.matchAll(re)) {
699
- const g = firstGroup(m);
700
- if (g !== undefined)
701
- candidates.push(g);
702
- }
703
- };
704
- collect(new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*${pathAlt(EXT_TAIL)}`, 'g'));
705
- collect(new RegExp(`\\btouch\\s+[^;|&]*?${pathAlt(EXT_TAIL)}`, 'g'));
706
- collect(new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*?${pathAlt(EXT_TAIL)}`, 'g'));
731
+ for (const seg of segments) {
732
+ collectSeg(seg, new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*${pathAlt(EXT_TAIL)}`, 'g'), candidates);
733
+ collectSeg(seg, new RegExp(`\\btouch\\s+[^;|&]*?${pathAlt(EXT_TAIL)}`, 'g'), candidates);
734
+ collectSeg(seg, new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*?${pathAlt(EXT_TAIL)}`, 'g'), candidates);
735
+ }
707
736
  // @implements A-SPEC-449 — the copy/move family. The CONFIG rule has carried it since
708
737
  // A-SPEC-191 §28 ("a gate that names one syntax for an act is a gate over that syntax, not
709
738
  // over the act"); the code rule never got it, and `cp /tmp/e.ts src/a.ts` walked through.
710
739
  // Only the TARGET counts: `cp src/a.ts /tmp/backup.ts` reads the project and writes outside,
711
740
  // which is an ordinary backup and not this gate's business.
712
- for (const seg of command.split(/[;|&]+/)) {
713
- if (!/\b(?:cp|mv|install|rsync|ln|dd)\b/.test(seg))
714
- continue;
715
- const of = seg.match(new RegExp(`\\bof=${pathAlt(EXT_TAIL)}`));
716
- if (of) {
717
- const g = firstGroup(of);
718
- if (g !== undefined) {
719
- candidates.push(g);
741
+ // (A data heredoc body may still carry separators inside one segment, so the per-segment
742
+ // text is split the way the whole command used to be — the base is the segment's either way.)
743
+ for (const seg of segments) {
744
+ for (const sub of seg.text.split(/[;|&]+/)) {
745
+ if (!/\b(?:cp|mv|install|rsync|ln|dd)\b/.test(sub))
720
746
  continue;
747
+ const of = sub.match(new RegExp(`\\bof=${pathAlt(EXT_TAIL)}`));
748
+ if (of) {
749
+ const g = firstGroup(of);
750
+ if (g !== undefined) {
751
+ candidates.push({ raw: g, base: seg.base });
752
+ continue;
753
+ }
721
754
  }
755
+ const all = [...sub.matchAll(new RegExp(pathAlt(EXT_TAIL), 'g'))]
756
+ .map(firstGroup).filter((x) => x !== undefined);
757
+ if (all.length >= 2)
758
+ candidates.push({ raw: all[all.length - 1], base: seg.base }); // last path is the destination
722
759
  }
723
- const all = [...seg.matchAll(new RegExp(pathAlt(EXT_TAIL), 'g'))]
724
- .map(firstGroup).filter((x) => x !== undefined);
725
- if (all.length >= 2)
726
- candidates.push(all[all.length - 1]); // last path is the destination
727
760
  }
728
761
  // @implements A-SPEC-447 — same predicate as the config rule; it lived in two copies, which
729
762
  // is how one of two gets fixed.
730
763
  const CODE_RE = new RegExp(`\\.${CODE_EXT}$`);
731
- const shellCodeWrite = [...candidates, ...interpreterWrites.filter((p) => CODE_RE.test(p))]
732
- .some((raw) => (0, write_target_1.resolvesInside)(raw, [opts.projectRoot]));
733
- if (shellCodeWrite) {
764
+ const codeVerdicts = [...candidates, ...interpreterWrites.filter((p) => CODE_RE.test(p)).flatMap(expandInterp)]
765
+ .map((c) => judgeCand(c, [opts.projectRoot]));
766
+ // @implements A-SPEC-528.1 — an unresolvable base under a governed relative write candidate
767
+ // fails CLOSED: the gate cannot know where the file lands, and guessing is the bypass this
768
+ // slice closes. Config candidates get the same fail-closed unconditionally (the config rule
769
+ // has never depended on governance); code candidates stay dormant on a spec-less repo.
770
+ const UNRESOLVED_BASE_DENY = '[Holmes-Kit] shell write to a relative path cannot be located: an earlier cd has a target the gate cannot resolve statically (dynamic argument) — use an absolute path, the gated Write/Edit tools, or out-of-band approval';
771
+ if (cfgVerdicts.includes('unknown')) {
772
+ return { permissionDecision: 'deny', permissionDecisionReason: UNRESOLVED_BASE_DENY };
773
+ }
774
+ if (codeVerdicts.includes('in') || codeVerdicts.includes('unknown')) {
734
775
  const governed = readSpecsSync(specsDir).some((s) => s.status === 'approved');
735
776
  if (governed) {
736
- return { permissionDecision: 'deny', permissionDecisionReason: '[Holmes-Kit] shell write to a project code file bypasses the No-Spec-No-Code gate — use the Write/Edit tools (gated) or supply out-of-band approval' };
777
+ return codeVerdicts.includes('in')
778
+ ? { permissionDecision: 'deny', permissionDecisionReason: '[Holmes-Kit] shell write to a project code file bypasses the No-Spec-No-Code gate — use the Write/Edit tools (gated) or supply out-of-band approval' }
779
+ : { permissionDecision: 'deny', permissionDecisionReason: UNRESOLVED_BASE_DENY };
737
780
  }
738
781
  }
739
782
  }
@@ -1231,10 +1274,32 @@ function evaluateHook(input, specsDir, opts) {
1231
1274
  // edit to that id. Only when the file carries no on-disk anchor (new file, or first anchoring) does
1232
1275
  // the incoming payload supply it. Found live: editing a test file whose fixture named an unapproved
1233
1276
  // spec was denied even though the file's own anchor is approved.
1234
- const ANCHOR_RE = /@implements\s+(A-SPEC-\d{3,}(?:\.\d+)?)/;
1235
- const payloadAnchor = ANCHOR_RE.exec([input.tool_input.content ?? '', input.tool_input.new_string ?? ''].join('\n'));
1236
- const diskAnchor = ANCHOR_RE.exec(onDiskContent);
1237
- const m = diskAnchor ?? payloadAnchor;
1277
+ // @implements A-SPEC-529.1
1278
+ // The GOVERNING anchor is a SET, not the first regex match. Comma lists are legal (S-503.1), so
1279
+ // `@implements A-SPEC-100, A-SPEC-200` claims both — and every id after the first used to be
1280
+ // absent from the stale gate and from phaseCheck (measured 2026-09-03: a broken seal on the
1281
+ // second id passed). The shared parser is consumed through `stripStringLiterals`, the same
1282
+ // preprocessing the claim gate got in S-504.1, so a fixture string anchor no longer governs.
1283
+ // Disk-anchor precedence is preserved as a set: a non-empty disk set governs, else the payload.
1284
+ const { anchorSpecIds: govAnchorIds, stripStringLiterals: govStrip } = require('../rtm/anchor-ids');
1285
+ const uniq = (xs) => [...new Set(xs)];
1286
+ // @implements A-SPEC-529.2 — the preprocessing branches on FILE FORMAT. A-SPEC-529.1 stripped
1287
+ // string literals so a code fixture like `const s = "@implements A-SPEC-999"` could not become
1288
+ // the governing anchor; but a JSON file's ONLY way to carry an anchor is a string value
1289
+ // (`"//": "@implements A-SPEC-209"` in package.json), and stripping over-dropped it — measured
1290
+ // 2026-09-03, a release package.json edit was refused with "A-SPEC(unspecified)". Code formats
1291
+ // (line-comment anchors) still strip; JSON/YAML/TOML/config and unknown extensions read raw
1292
+ // (over-inclusion is safe — an anchored-but-unapproved id is still refused downstream).
1293
+ const GOV_CODE_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|cc|cxx|cpp|hh|hpp|rb|php|swift)$/i;
1294
+ const govPrep = (s) => (GOV_CODE_EXT.test(relPath) ? govStrip(s) : s);
1295
+ const diskGovIds = uniq(govAnchorIds(govPrep(onDiskContent)));
1296
+ const payloadGovIds = uniq(govAnchorIds(govPrep([input.tool_input.content ?? '', input.tool_input.new_string ?? ''].join('\n'))));
1297
+ const governingIds = diskGovIds.length > 0 ? diskGovIds : payloadGovIds;
1298
+ // The single "primary" id kept for message templates and phaseCheck's targetAspecId default: the
1299
+ // first governing id, which is exactly what `m?.[1]` used to be for a single-anchor file.
1300
+ const m = governingIds.length > 0
1301
+ ? Object.assign([`@implements ${governingIds[0]}`, governingIds[0]], { index: 0, input: '', groups: undefined })
1302
+ : null;
1238
1303
  // RE-ANCHORING scope (review C6) moved into judgeScope below (S-508.1): every payload anchor the
1239
1304
  // disk does not carry is an ADMISSION, judged against the incoming spec's gate — the C6 property
1240
1305
  // (a rewrite of the anchor line is judged under the NEW spec) is preserved there, now for the
@@ -1268,11 +1333,15 @@ function evaluateHook(input, specsDir, opts) {
1268
1333
  }
1269
1334
  return null;
1270
1335
  };
1271
- // The qualifying T-SPEC matters for WRITE_CODE too a stale test spec must not bless code.
1272
- const staleTarget = sealProblem(m?.[1]) ?? (action === 'WRITE_CODE'
1273
- ? (specs.filter((s) => s.type === 'T-SPEC' && s.status === 'approved' && m?.[1] && s.dependsOn.includes(m[1]))
1274
- .map((s) => sealProblem(s.id)).find(Boolean) ?? null)
1275
- : null);
1336
+ // @implements A-SPEC-529.1 the stale gate runs over the WHOLE governing set: any anchored spec
1337
+ // whose seal is broken, and (for WRITE_CODE) any of that spec's qualifying T-SPECs, blocks — and
1338
+ // the message names the one that failed, not the first anchor. The single-anchor path is a set
1339
+ // of one, byte-identical to the previous `m?.[1]` behaviour.
1340
+ const staleTarget = (governingIds.map((id) => sealProblem(id)).find(Boolean) ?? null)
1341
+ ?? (action === 'WRITE_CODE'
1342
+ ? (specs.filter((s) => s.type === 'T-SPEC' && s.status === 'approved' && governingIds.some((id) => s.dependsOn.includes(id)))
1343
+ .map((s) => sealProblem(s.id)).find(Boolean) ?? null)
1344
+ : null);
1276
1345
  // @implements A-SPEC-133 — the override is authorized by a token covering the code write.
1277
1346
  const codeWriteCovered = (0, risk_gate_1.approvalCovers)(weApproval, { kind: 'code-write', target: relPath }, nowTs);
1278
1347
  if (staleTarget && !codeWriteCovered) {
@@ -1306,7 +1375,21 @@ function evaluateHook(input, specsDir, opts) {
1306
1375
  };
1307
1376
  }
1308
1377
  }
1309
- const res = (0, phase_1.phaseCheck)(action, { specs, targetAspecId: m?.[1] });
1378
+ // @implements A-SPEC-529.1 phaseCheck runs for EVERY governing id; the first failure is the
1379
+ // verdict, and its id flows into the refusal template (so the author sees the anchor that is
1380
+ // actually unsatisfied, not merely the first one on the file). An empty set keeps the prior
1381
+ // single call with an undefined target. Deterministic: governing-set order is document order.
1382
+ const phaseTargets = governingIds.length > 0 ? governingIds : [undefined];
1383
+ let failedTarget;
1384
+ let res = (0, phase_1.phaseCheck)(action, { specs, targetAspecId: phaseTargets[0] });
1385
+ for (const tid of phaseTargets) {
1386
+ const r = (0, phase_1.phaseCheck)(action, { specs, targetAspecId: tid });
1387
+ if (r.decision === 'deny') {
1388
+ res = r;
1389
+ failedTarget = tid;
1390
+ break;
1391
+ }
1392
+ }
1310
1393
  if (res.decision === 'deny') {
1311
1394
  // @implements A-SPEC-175
1312
1395
  // When the spec tree is gone from a project the ledger remembers approving, the generic
@@ -1315,7 +1398,7 @@ function evaluateHook(input, specsDir, opts) {
1315
1398
  // the phase gate's own verdict is still what denied this.
1316
1399
  const lost = specs.length === 0 && !fs.existsSync(specsDir)
1317
1400
  && (0, governance_history_1.hasGovernanceHistory)(path.resolve(specsDir, '..', '..'));
1318
- const targetSpecId = m?.[1] ?? 'A-SPEC-XXX';
1401
+ const targetSpecId = failedTarget ?? m?.[1] ?? 'A-SPEC-XXX';
1319
1402
  const prescriptiveGuide = ` — Next Action (DO NOT write workaround scripts in /tmp): Step 1: Call 'spec_next({})' to verify slice state. Step 2: Call 'spec_approve({ id: "${targetSpecId}" })' to seal spec. Step 3: Ensure '// @implements ${targetSpecId}' is on line 1 of target file.`;
1320
1403
  return {
1321
1404
  permissionDecision: 'deny',
@@ -0,0 +1,23 @@
1
+ import { InstallMode } from '../update/update-notice';
2
+ export interface SessionStartInput {
3
+ version: string;
4
+ home: string;
5
+ now: number;
6
+ env: NodeJS.ProcessEnv;
7
+ readFile: (p: string) => string;
8
+ mode: InstallMode;
9
+ }
10
+ export interface SessionStartOutput {
11
+ hookSpecificOutput: {
12
+ hookEventName: 'SessionStart';
13
+ additionalContext: string;
14
+ };
15
+ shouldRefresh: boolean;
16
+ }
17
+ /** node_modules → local dependency; anything else is treated as a source checkout (conservative:
18
+ * a source install shows no update command, which is safer than a wrong one). A global npx pin is
19
+ * detected by the caller from argv/exec path; here the package root alone distinguishes local-dep. */
20
+ export declare function detectInstallMode(packageRoot: string): InstallMode;
21
+ /** Pure: build the banner output and decide whether a refresh should fire. No I/O beyond the
22
+ * injected readFile; the caller performs the detached spawn when shouldRefresh is true. */
23
+ export declare function buildSessionStartOutput(input: SessionStartInput): SessionStartOutput;
@@ -0,0 +1,111 @@
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.detectInstallMode = detectInstallMode;
37
+ exports.buildSessionStartOutput = buildSessionStartOutput;
38
+ // @implements A-SPEC-531.2
39
+ // The SessionStart banner hook. It emits the intro (+ optional update notice) as additionalContext,
40
+ // which Claude Code delivers to BOTH the human transcript and the agent context — the "human and
41
+ // agent" delivery REQ-531 asks for. The update refresh is a DETACHED, fire-and-forget child so the
42
+ // session start is never delayed; its result shows up on the NEXT session.
43
+ //
44
+ // FAIL-OPEN: this is a banner, not a gate. Any error yields an empty, harmless output and exit 0 —
45
+ // the session must always start.
46
+ const fs = __importStar(require("node:fs"));
47
+ const os = __importStar(require("node:os"));
48
+ const path = __importStar(require("node:path"));
49
+ const node_child_process_1 = require("node:child_process");
50
+ const update_notice_1 = require("../update/update-notice");
51
+ /** node_modules → local dependency; anything else is treated as a source checkout (conservative:
52
+ * a source install shows no update command, which is safer than a wrong one). A global npx pin is
53
+ * detected by the caller from argv/exec path; here the package root alone distinguishes local-dep. */
54
+ function detectInstallMode(packageRoot) {
55
+ const folded = packageRoot.replace(/\\/g, '/');
56
+ return /(?:^|\/)node_modules\//.test(folded) ? 'local-dep' : 'source';
57
+ }
58
+ /** Pure: build the banner output and decide whether a refresh should fire. No I/O beyond the
59
+ * injected readFile; the caller performs the detached spawn when shouldRefresh is true. */
60
+ function buildSessionStartOutput(input) {
61
+ const cached = (0, update_notice_1.readCache)(input.home, input.readFile);
62
+ const additionalContext = (0, update_notice_1.composeBanner)({ current: input.version, cached, mode: input.mode, npmUrl: update_notice_1.NPM_URL });
63
+ const shouldRefresh = (0, update_notice_1.shouldQuery)(input.env) && (0, update_notice_1.cacheIsStale)(cached, input.now);
64
+ return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext }, shouldRefresh };
65
+ }
66
+ // The package root, derived from this file's location (dist/holmes/hooks/session-start.js → 3 up).
67
+ function pkgRootFromEntry() {
68
+ return path.resolve(__dirname, '..', '..', '..');
69
+ }
70
+ function pkgVersion() {
71
+ try {
72
+ const raw = fs.readFileSync(path.join(pkgRootFromEntry(), 'package.json'), 'utf8');
73
+ return JSON.parse(raw).version ?? '0.0.0';
74
+ }
75
+ catch {
76
+ return '0.0.0';
77
+ }
78
+ }
79
+ // CLI entry: read the hook JSON on stdin (unused beyond triggering), emit the banner, and — when
80
+ // allowed — fire the detached refresh. Wrapped so any failure is an empty output + exit 0.
81
+ if (require.main === module) {
82
+ let buf = '';
83
+ process.stdin.on('data', (c) => (buf += c));
84
+ process.stdin.on('end', () => {
85
+ try {
86
+ const out = buildSessionStartOutput({
87
+ version: pkgVersion(),
88
+ home: os.homedir(),
89
+ now: Date.now(),
90
+ env: process.env,
91
+ readFile: (p) => fs.readFileSync(p, 'utf8'),
92
+ mode: detectInstallMode(pkgRootFromEntry()),
93
+ });
94
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: out.hookSpecificOutput }));
95
+ if (out.shouldRefresh) {
96
+ // Detached, unref'd child so the session start does not wait on the network. The refresh
97
+ // subcommand queries the registry (dist-tags.latest), times out fast, and writes the cache;
98
+ // any failure there is silent. Reads only public metadata — no spec text, no egress.
99
+ try {
100
+ const child = (0, node_child_process_1.spawn)(process.execPath, [__filename, '--refresh'], { detached: true, stdio: 'ignore' });
101
+ child.unref();
102
+ }
103
+ catch { /* refresh is best-effort */ }
104
+ }
105
+ }
106
+ catch {
107
+ // Fail-open: emit nothing and let the session start.
108
+ process.stdout.write('{}');
109
+ }
110
+ });
111
+ }
@@ -130,6 +130,7 @@ const package_1 = require("../review/package");
130
130
  const risk_classifier_1 = require("../guardrail/risk-classifier");
131
131
  const risk_gate_1 = require("../guardrail/risk-gate");
132
132
  const elicit_approval_1 = require("./elicit-approval");
133
+ const autonomy_1 = require("../governance/autonomy");
133
134
  const anchor_comment_1 = require("../rtm/anchor-comment");
134
135
  const consistency_lints_1 = require("../cpg/consistency-lints");
135
136
  const approval_queue_1 = require("../governance/approval-queue");
@@ -669,6 +670,15 @@ function makeRawHandlers(store, opts) {
669
670
  token: crypto.randomUUID(),
670
671
  rationale: reason ?? 'elicitation grant',
671
672
  });
673
+ // @implements A-SPEC-532.2 — the autonomous channel: the SAME synthesized-Approval shape that
674
+ // rides the existing seal path, but its actor names `autonomous:<client>` so an audit can tell a
675
+ // self-approved seal from a human-approved (elicitation) or operator (env/grant) one. Single-use
676
+ // by construction — it exists only inside this call, persisted nowhere.
677
+ const autonomousApproval = () => ({
678
+ actor: `autonomous:${opts?.clientName?.() ?? 'unknown'}`,
679
+ token: crypto.randomUUID(),
680
+ rationale: `autonomous grant (${'HOLMES_AUTONOMOUS_APPROVAL'} enabled, spec grade auto)`,
681
+ });
672
682
  /**
673
683
  * Where the audit record for a governance act belongs — resolved BEFORE the act writes anything.
674
684
  *
@@ -1134,26 +1144,38 @@ function makeRawHandlers(store, opts) {
1134
1144
  if (approveResolved === undefined) {
1135
1145
  const target = await store.read(a.id).catch(() => null);
1136
1146
  if (target) {
1137
- const resealing = typeof target.spec.frontmatter.approved_digest === 'string';
1138
- // The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
1139
- // title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
1140
- // more consequential act) as a first approval. The cap cuts the title, never the marker.
1141
- const out = await tryElicit('spec-approve', a.id, `${a.id} ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
1142
- if (out.kind === 'answered' && out.decision.granted) {
1143
- approveResolved = { approval: elicitApproval(out.decision.reason), source: 'elicitation' };
1147
+ // @implements A-SPEC-532.2 the autonomous gate sits BEFORE the human ask: when the
1148
+ // out-of-band autonomy switch is on AND the spec is low/mid-risk (never gate-behavior, an
1149
+ // architecture/taint file, or an upstream REQ/H/C those stay human), the agent seals it
1150
+ // itself. The switch is env-only and an agent cannot set it (pre-tool-use blocks that,
1151
+ // A-SPEC-532.2). Off, or a hitl-classed spec, falls straight through to the elicitor
1152
+ // unchanged the autonomous-OFF path is byte-identical to before.
1153
+ if ((0, autonomy_1.autonomousApprovalEnabled)(process.env)
1154
+ && (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec])) === 'auto') {
1155
+ approveResolved = { approval: autonomousApproval(), source: 'autonomous' };
1144
1156
  }
1145
- else if (out.kind === 'answered') {
1146
- // The human ANSWERED (deny/question/decline): the answer is the message, and no queue
1147
- // entry is filed a decided request is not a pending one (REQ-246 visibility).
1148
- return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${out.decision.reason ?? '(사유 없음)'}. 사유를 해소한 다시 시도하십시오.` };
1149
- }
1150
- else if (out.kind === 'expired') {
1151
- // @implements A-SPEC-497.1 only the expiry earns a name: the notice LEADS the same
1152
- // fail-closed refusal + queue path, so the semantics stay refusal+queue and only the
1153
- // message learned to say what happened.
1154
- elicitExpiredMs = out.waitedMs;
1157
+ else {
1158
+ const resealing = typeof target.spec.frontmatter.approved_digest === 'string';
1159
+ // The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
1160
+ // title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
1161
+ // more consequential act) as a first approval. The cap cuts the title, never the marker.
1162
+ const out = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
1163
+ if (out.kind === 'answered' && out.decision.granted) {
1164
+ approveResolved = { approval: elicitApproval(out.decision.reason), source: 'elicitation' };
1165
+ }
1166
+ else if (out.kind === 'answered') {
1167
+ // The human ANSWERED (deny/question/decline): the answer is the message, and no queue
1168
+ // entry is filed — a decided request is not a pending one (REQ-246 visibility).
1169
+ return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${out.decision.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
1170
+ }
1171
+ else if (out.kind === 'expired') {
1172
+ // @implements A-SPEC-497.1 — only the expiry earns a name: the notice LEADS the same
1173
+ // fail-closed refusal + queue path, so the semantics stay refusal+queue and only the
1174
+ // message learned to say what happened.
1175
+ elicitExpiredMs = out.waitedMs;
1176
+ }
1177
+ // silent: the channel gave no answer — fall through to the byte-identical refusal.
1155
1178
  }
1156
- // silent: the channel gave no answer — fall through to the byte-identical refusal.
1157
1179
  }
1158
1180
  }
1159
1181
  if (approveResolved === undefined) {
@@ -0,0 +1,8 @@
1
+ import { InstallMode } from '../update/update-notice';
2
+ export interface ServerInstructionsInput {
3
+ version: string;
4
+ home: string;
5
+ mode: InstallMode;
6
+ readFile: (p: string) => string;
7
+ }
8
+ export declare function buildServerInstructions(input: ServerInstructionsInput): string;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildServerInstructions = buildServerInstructions;
4
+ // @implements A-SPEC-531.2
5
+ // The MCP server's `instructions` banner. A harness with no SessionStart hook (codex, antigravity)
6
+ // still gets the intro (+ optional update notice) delivered to the agent through the server's
7
+ // initialize response. PURE and fail-soft — a cache read failure yields the intro only, and never
8
+ // throws, so server construction is never blocked by the banner.
9
+ const update_notice_1 = require("../update/update-notice");
10
+ function buildServerInstructions(input) {
11
+ const cached = (0, update_notice_1.readCache)(input.home, input.readFile); // readCache already swallows a throw → null
12
+ return (0, update_notice_1.composeBanner)({ current: input.version, cached, mode: input.mode, npmUrl: update_notice_1.NPM_URL });
13
+ }
@@ -54,7 +54,27 @@ const PKG_VERSION = (() => {
54
54
  return '0.0.0';
55
55
  }
56
56
  })();
57
- const server = new index_js_1.Server({ name: 'holmes-kit', version: PKG_VERSION }, { capabilities: { tools: {} } });
57
+ // @implements A-SPEC-531.2 the banner rides `instructions` so a harness without a SessionStart
58
+ // hook still delivers the intro (+ update notice) to the agent. Fail-soft: any failure omits it and
59
+ // the server starts normally.
60
+ const SERVER_INSTRUCTIONS = (() => {
61
+ try {
62
+ const { buildServerInstructions } = require('./server-instructions');
63
+ const { detectInstallMode } = require('../hooks/session-start');
64
+ const os = require('node:os');
65
+ const fs = require('node:fs');
66
+ return buildServerInstructions({
67
+ version: PKG_VERSION,
68
+ home: os.homedir(),
69
+ mode: detectInstallMode(require('node:path').resolve(__dirname, '..', '..', '..')),
70
+ readFile: (p) => fs.readFileSync(p, 'utf8'),
71
+ });
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ })();
77
+ const server = new index_js_1.Server({ name: 'holmes-kit', version: PKG_VERSION }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
58
78
  const fullProfile = process.env.HOLMES_MCP_PROFILE === 'full';
59
79
  // Register each handler as a tool with its real typed inputSchema so MCP
60
80
  // clients can marshal complex (array/object) arguments; fall back to a
@@ -413,6 +413,10 @@ function addImportEdges(scanned, graph, opts) {
413
413
  else if (head === 'super') {
414
414
  baseDir = path.posix.dirname(path.posix.dirname(fromFile));
415
415
  while (segs[0] === 'super') {
416
+ // @implements A-SPEC-527.1 — super:: above the crate root is an rustc error; resolving
417
+ // it pinned '.' and produced edges to repo-root files (adversarial sweep). Refuse.
418
+ if (baseDir === '.' || baseDir === '/')
419
+ return null;
416
420
  segs.shift();
417
421
  baseDir = path.posix.dirname(baseDir);
418
422
  }
@@ -447,6 +451,11 @@ function addImportEdges(scanned, graph, opts) {
447
451
  if (/\.cs$/.test(fromFile))
448
452
  return uniqueSuffix(`${spec.split('.').join('/')}.cs`);
449
453
  if (/\.(cpp|cc|cxx|hpp|h)$/.test(fromFile)) {
454
+ // @implements A-SPEC-527.1 — an ABSOLUTE include is not a repository coordinate: joining
455
+ // it into the repo frame let `#include "/etc/passwd"` match a coincidentally-shaped
456
+ // scanned file (adversarial sweep). Absolute means absolute; it resolves to nothing here.
457
+ if (spec.startsWith('/'))
458
+ return null;
450
459
  // extension preserved: the dotted split below would butcher `util/env.h`
451
460
  const relative = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
452
461
  if (known.has(relative))
@@ -51,7 +51,9 @@ const JAVA_VOCABULARY = {
51
51
  sanitizers: ['escapeHtml', 'quoteReplacement', 'encode'],
52
52
  };
53
53
  const CSHARP_VOCABULARY = {
54
- sources: ['GetEnvironmentVariable', 'ReadLine', 'QueryString', 'Form'],
54
+ // @implements A-SPEC-527.1 — bare 'Form' matched every *Form identifier (WinForms world);
55
+ // the request-shaped spellings keep the actual web input reads.
56
+ sources: ['GetEnvironmentVariable', 'ReadLine', 'QueryString', 'Request.Form', '.Form['],
55
57
  sinks: ['Start', 'ExecuteReader', 'ExecuteNonQuery', 'ExecuteScalar', 'Deserialize'],
56
58
  sanitizers: ['HtmlEncode', 'UrlEncode', 'EscapeDataString'],
57
59
  };
@@ -61,7 +63,9 @@ const RUST_VOCABULARY = {
61
63
  sanitizers: ['escape', 'quote'],
62
64
  };
63
65
  const CPP_VOCABULARY = {
64
- sources: ['getenv', 'argv', 'cin', 'fgets'],
66
+ // @implements A-SPEC-527.1 'cin' alone bled into English identifiers (racing, medicine…),
67
+ // measured by the adversarial sweep; the qualified spellings keep the real reads.
68
+ sources: ['getenv', 'argv', 'std::cin', 'cin >>', 'fgets'],
65
69
  sinks: ['system', 'popen', 'exec', 'execl', 'execlp', 'execle', 'execv', 'execvp', 'ShellExecute'],
66
70
  sanitizers: ['escape', 'quote'],
67
71
  };
@@ -0,0 +1,28 @@
1
+ export declare const NPM_URL = "https://www.npmjs.com/package/@holmes-lab/holmes-kit";
2
+ /** How this install was wired — decides how (or whether) to phrase the update command. */
3
+ export type InstallMode = 'global-npx' | 'local-dep' | 'source';
4
+ export interface UpdateCache {
5
+ latest: string;
6
+ checkedAt: number;
7
+ }
8
+ export interface BannerInput {
9
+ current: string;
10
+ cached: UpdateCache | null;
11
+ mode: InstallMode;
12
+ npmUrl: string;
13
+ }
14
+ /** -1 if a<b, 0 if equal, 1 if a>b — NUMERIC per field, so 0.9.0 < 0.10.0 (a string compare fails). */
15
+ export declare function compareSemver(a: string, b: string): -1 | 0 | 1;
16
+ /** The cache file, read only — no network, no write. Anything unreadable/malformed/mis-shaped → null. */
17
+ export declare function readCache(home: string, readFile: (p: string) => string): UpdateCache | null;
18
+ /** The one-line update guidance, branched by install mode. `source` returns null (git updates it). */
19
+ export declare function installModeGuide(mode: InstallMode, latest: string, current: string): string | null;
20
+ /**
21
+ * The banner: always an English intro line (version + governance rule + npm page). If the cache
22
+ * knows a newer version AND the install mode has an update path, a second guidance line follows.
23
+ */
24
+ export declare function composeBanner(input: BannerInput): string;
25
+ /** Whether a network refresh is allowed at all. Opt-out via HOLMES_NO_UPDATE_CHECK or CI. */
26
+ export declare function shouldQuery(env: NodeJS.ProcessEnv): boolean;
27
+ /** Whether the cache is old enough to refresh. A missing cache is stale. */
28
+ export declare function cacheIsStale(cached: UpdateCache | null, now: number, ttlMs?: number): boolean;