@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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,100 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ <!-- @implements A-SPEC-209 -->
8
+ ## [0.8.1] - 2026-09-03
9
+
10
+ Codex hard-enforcement completed and verified on real codex-cli 0.152.1 (GOAL-codex-enforcement),
11
+ plus the README refreshed to the 0.8.x feature set.
12
+
13
+ ### Fixed
14
+
15
+ - **Codex marketplace manifest location** (REQ-533): `init --agent codex` now writes the manifest
16
+ where codex-cli 0.152.x actually reads it (`.claude-plugin/marketplace.json`), keeping the old
17
+ `.agents/plugins/` location for 0.151.x. Measured: the shipped wiring installs with no manual copy
18
+ — `codex plugin marketplace add` → `codex plugin add` → `installed, enabled`. `doctor` now flags a
19
+ wiring that has only the old location.
20
+
21
+ ### Verified
22
+
23
+ - **Codex hard gate, model-independently**: the codex PreToolUse payload was captured (Claude-shaped:
24
+ `tool_name` + `tool_input`) and replayed through the gate — an unauthorized shell code-write is
25
+ denied, a harmless read passes. A session cannot self-grant `HOLMES_AUTONOMOUS_APPROVAL` (blocked
26
+ like `HOLMES_ROLE`).
27
+
28
+ ## [0.8.0] - 2026-09-03
29
+
30
+ A hardening campaign (GOAL-hardening-2026-09) closing measured gate gaps, adding server-side CI
31
+ re-validation, a session banner with update notice, and — new this release — **autonomous spec
32
+ approval** so an agent in an explicitly-enabled autonomous mode can seal low/mid-risk specs
33
+ without the human elicitation TUI, while gate-behavior, architecture, taint, and upstream specs
34
+ still ask a human. Every slice was TDD'd (RED verified first) with two consecutive clean
35
+ adversarial rounds where a gate was changed.
36
+
37
+ ### Added
38
+
39
+ - **Autonomous spec approval** (REQ-532): with the out-of-band `HOLMES_AUTONOMOUS_APPROVAL`
40
+ switch set (an agent cannot set it — the pre-tool-use gate blocks that, like `HOLMES_ROLE`),
41
+ `spec_approve` seals a low/mid-risk spec itself, ledgered under an `autonomous:<client>` actor
42
+ so audits tell the channel apart from human (`elicitation:`) and operator (`env`) approvals.
43
+ The bound is conservative: `gate-behavior` breaking changes, architecture/gate/governance/taint
44
+ files, and every REQ/H-SPEC/C-SPEC stay on the human channel. Autonomy OFF is byte-identical to
45
+ before.
46
+ - **Session banner + update notice** (REQ-531): every session start emits an English intro line
47
+ (version + governance rule + npm page URL) to both the human transcript and the agent context
48
+ via a SessionStart hook, and the MCP server carries the same banner in its `instructions` for
49
+ harnesses without that hook. When the `~/.holmes` cache knows a newer published version, an
50
+ install-mode-aware update command is appended. The registry refresh is detached and fail-silent;
51
+ `HOLMES_NO_UPDATE_CHECK` or `CI` opts out.
52
+ - **Server-side CI re-validation** (REQ-530): a Gitea Actions workflow re-runs the pre-push
53
+ evidence check (npm ci → build → full suite → tarball install probe) so a `--no-verify` push or
54
+ a hook-less clone is still caught. The lockfile is now tracked (`npm ci` reproducible). NOTE:
55
+ wiring only until a runner is registered — an operator step.
56
+
57
+ ### Fixed
58
+
59
+ - **cd-relative shell writes are judged at the segment's effective directory** (REQ-528): a write
60
+ spelled from a subdirectory (`cd sub && cat > ../src/x.ts`) is judged at the real location, not
61
+ the project root — five bypasses (subshell, `sh -c`, heredoc-program, …) are sealed and three
62
+ legitimate out-of-tree writes are un-blocked. A quote-aware split closes a data-injection route
63
+ a transparent split would open.
64
+ - **A file's whole governing anchor SET is judged** (REQ-529): comma-list anchors past the first
65
+ id used to be absent from the stale gate and phaseCheck; every governing id now participates and
66
+ the refusal names the one that failed. String-value anchors in JSON/config files
67
+ (`"//": "@implements …"` in package.json) are honored; code-file fixture strings stay excluded.
68
+
69
+ ### Guardrail
70
+
71
+ - **`HOLMES_AUTONOMOUS_APPROVAL` joins the self-disarm family**: a session cannot read or set the
72
+ autonomy switch (same protection as `HOLMES_ROLE`/`HOLMES_GATE_BYPASS`).
73
+
74
+ ## [0.7.1] - 2026-09-02
75
+
76
+ An adversarial self-review of the seven-language work — 42 probes the test suite never pinned
77
+ (hostile syntax through every new lowering, taint false-positive/negative controls, import
78
+ boundary and escape attempts). The strong properties held: zero crashes, zero invariant
79
+ violations, zero out-of-scan escapes, and the sanitizer's exact-match fail-open was confirmed
80
+ correct. Four low-severity harvest items were fixed; every keep-as-is verdict carries its
81
+ measurement.
82
+
83
+ ### Fixed
84
+
85
+ - **Labeled statements are breakable** (REQ-527): Java (and TS) allow `break L` out of ANY
86
+ labeled statement; the lowering registered only loops and switches, so the legal
87
+ `L: { … break L; }` refused the whole function. The labeled statement now owns its label's
88
+ breaks (loops/switches keep consuming labels as before; `continue` stays loop-only, as in the
89
+ languages themselves).
90
+ - **Two taint sources stopped bleeding through substrings** (REQ-527): C++'s bare `cin` matched
91
+ inside ordinary identifiers (`racing`, `medicine`…), C#'s bare `Form` matched every `*Form`.
92
+ Now `std::cin`/`cin >>` and `Request.Form`/`.Form[` — the adversarial probes are regression
93
+ tests, and the positive controls prove the real reads still fire. **Corpus re-verification:
94
+ identical numbers everywhere** (violations 0, C++'s two genuine findings kept, all five
95
+ import-arrival counts unchanged — no legitimate signal was lost).
96
+ - **Two wrong-frame import resolutions refuse** (REQ-527): an absolute `#include "/…"` no longer
97
+ normalizes into the repository frame, and `super::` beyond a Rust crate root resolves to
98
+ nothing (rustc calls it an error too). Both could only ever hit scanned files — wrong
99
+ coordinates, not escapes — and now hit none.
100
+
7
101
  <!-- @implements A-SPEC-209 -->
8
102
  ## [0.7.0] - 2026-09-02
9
103
 
package/README.md CHANGED
@@ -14,16 +14,19 @@
14
14
 
15
15
  ---
16
16
 
17
- ### 🛡️ Currently Supported Features (v0.3.x Production Features)
17
+ ### 🛡️ Currently Supported Features (v0.8.x Production Features)
18
18
 
19
- - 📋 **Requirements & Specification Governance**: Strict **"No Spec, No Code"** enforcement with 4-tier spec chain traceability (`REQ ➔ H-SPEC ➔ A-SPEC ➔ T-SPEC`) and `// @implements A-SPEC-XXX` line 1 code anchors.
19
+ - 📋 **Requirements & Specification Governance**: Strict **"No Spec, No Code"** enforcement with 4-tier spec chain traceability (`REQ ➔ H-SPEC ➔ A-SPEC ➔ T-SPEC`) and `// @implements A-SPEC-XXX` code anchors (comma-lists and every anchor in a file participate in the gate).
20
+ - 🤖 **Autonomous Approval** *(new in 0.8.0)*: with the out-of-band `HOLMES_AUTONOMOUS_APPROVAL` switch on, an agent seals **low/mid-risk** specs itself (ledgered under an `autonomous:<client>` actor) — while `gate-behavior` changes, architecture/gate/taint files, and every upstream `REQ`/`H-SPEC`/`C-SPEC` still ask a human through the in-session TUI. The switch is env-only; a session cannot set it (blocked like `HOLMES_ROLE`). Off = byte-identical to before.
21
+ - 🪧 **Session Banner + Update Notice** *(new in 0.8.0)*: every session start emits an English intro (version + governance rule + npm URL) to both the human transcript and the agent context (SessionStart hook + MCP `instructions`); when a newer published version is cached, an install-mode-aware update command is appended. Registry check is detached, fail-silent, and opts out via `HOLMES_NO_UPDATE_CHECK`/`CI`.
22
+ - 🧱 **Deterministic Gate, Hardened** *(new in 0.8.0)*: shell writes are judged at the segment's **effective working directory** (`cd sub && cat > ../src/x.ts` is sealed, legitimate out-of-tree scratch writes are freed); the governing anchor is the **whole set**, not the first match. Every gate change ships with two consecutive clean adversarial rounds.
20
23
  - 🧠 **3-Tier Semantic Layer** *(new in 0.3.0)*: knowledge-graph semantic search with an explicit consent ladder — `none` (default, **zero egress**), `local` (bge-m3, no egress, optional module), `cloud` (gemini-embedding-001, opt-in via `GEMINI_API_KEY`). Measured on 305 traceability cases: recall 0.486 (lexical) → 0.667 (local) → **0.887 (cloud)**; on lexical-zero requests: 0% → 52% → **92%**. Surfaced only additively — rerank, evidence (`semCos`), and `semanticAlternates` — never as a hard filter.
21
24
  - 🎯 **Graded Impact Surface** *(new in 0.3.0)*: `rankedImpact` (personalized-PageRank over the spec/code graph) beat its pre-registered naive baseline on **both recall and precision across 3 corpora (×1.6–×17)** — the necessary condition for any better-than-a-person phrasing, measured before claimed.
22
- - 🐞 **Causal Defect Localization & CPG**: AST Code Property Graph (CPG) & Dataflow Taint reachability analysis across 7 languages (TS/JS, Python, Go, Rust, Java, C/C++, C#).
25
+ - 🐞 **Causal Defect Localization & CPG** *(equalized in 0.5–0.7)*: AST Code Property Graph (CFG/DDG/CDG) & Dataflow Taint reachability across 7 languages (TS/JS, Python, Go, Rust, Java, C/C++, C#) — **42 language×layer cells graded on measured evidence** (11 corpora, 39,344 functions, zero invariant violations; C++ conditional on 67.9% parse coverage, disclosed in the matrix).
23
26
  - 📏 **Measured, Not Claimed** *(new in 0.3.x)*: performance is judged against a pre-registered modeled-human band (R 0.67–0.78 / P ≈0.9±). Current official grade: **band entry on recall; division-of-labor precision 0.727 = 81% of the modeled human — reproduced by an independent context-free judge on a fresh blind window.** No superhuman claims until both metrics exceed the band.
24
27
  - 🧪 **Self-Healing & Diagnostic Doctor**: Automated integrity checks and self-healing auto-fix remediation (`holmes-kit doctor --fix` & `spec_remediate`) — wiring-handshake checks run on Windows natively as of 0.3.2.
25
28
  - 🔔 **Approval UX** *(new in 0.3.1)*: in-session approval dialogs forewarn their 120s deadline and, on expiry, the refusal says exactly where the decision went (`npx holmes-kit approve` out-of-band queue) — no more silently dead dialogs.
26
- - 🚦 **CI/CD Governance Gate Runner**: Non-interactive headless CI/CD build gate (`holmes-kit ci`) for GitHub Actions and GitLab CI pipelines.
29
+ - 🚦 **Push & Server-Side Re-Validation** *(hardened in 0.8.0)*: a local `pre-push` evidence gate (test-run ledger head == push HEAD, green, executed > 0) plus a **server-side CI workflow** that re-runs `npm ci → build → full suite → tarball install probe`, so a `--no-verify` push or a hook-less clone is still caught.
27
30
  - 📊 **Automated RTM & Taint Heatmap**: Interactive standalone HTML/SVG report generation (`generateRtmHeatmap`) for spec coverage and security dataflow reachability.
28
31
  - 🤖 **CLI-First AI Harness Matrix**: Native process hook gating for Claude Code, Antigravity CLI (AGY), Codex CLI, and Google Antigravity SDK.
29
32
 
@@ -46,7 +49,7 @@ Holmes-Kit prioritizes **CLI-based AI Coding Agents** where OS-level process hoo
46
49
  | :--- | :--- | :--- |
47
50
  | 🤖 **Claude Code CLI** | 🥇 Tier 1 (Native) | OS PreToolUse & Stop hooks (`.claude/settings.local.json`), MCP server (`.mcp.json`) |
48
51
  | 🚀 **Antigravity CLI (AGY)** | 🥇 Tier 1 (Native) | AGY Hooks (`hooks.json`), MCP config (`.agents/mcp_config.json`), Governance Skills |
49
- | 💻 **Codex CLI / Agentic Shell** | 🥇 Tier 1 (Native) | Codex MCP integration (`.codex/config.toml`), plugin-packaged gate hooks (installed via Codex plugin marketplace) |
52
+ | 💻 **Codex CLI / Agentic Shell** | 🥇 Tier 1 (Native) | Codex MCP integration (`.codex/config.toml`), plugin-packaged gate hooks at the marketplace path Codex reads (`.claude-plugin/marketplace.json`, codex-cli 0.152.x). Hard-gate enforcement verified on real codex-cli: the captured PreToolUse payload is judged and an unauthorized code-write is denied. |
50
53
  | 🧩 **Google Antigravity SDK** | 🥇 Tier 1 (Native) | Autonomous Agent SDK bindings and cryptographic provenance verification |
51
54
 
52
55
  > **Note**: Holmes-Kit focuses strictly on CLI-based autonomous agents to guarantee 100% deterministic OS hook gating (`deny` enforcement) before file modifications occur.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 7cb32c4-mtjifuwa
1
+ a14dfa1-mtkqq7jw
@@ -204,19 +204,29 @@ function agentFiles(agent, opts) {
204
204
  // The MCP wiring stays in `.codex/config.toml` (merged by init.ts).
205
205
  // Wiring is not installation: the user still runs `codex plugin marketplace add` and
206
206
  // `codex plugin add`, which is why HARNESS_ENFORCES.codex is false.
207
+ // @implements A-SPEC-533.1 — the marketplace manifest, built ONCE and emitted at BOTH
208
+ // locations. codex-cli 0.152.x reads `<root>/.claude-plugin/marketplace.json` (measured
209
+ // 2026-09-03: the `.agents/plugins/` path A-SPEC-442 wrote for 0.151.0 fails with "does not
210
+ // contain a supported manifest"); the old path stays for 0.151.x backward compatibility.
211
+ // One source so the two files cannot drift in format or source.path.
212
+ const marketplaceJson = `${JSON.stringify({
213
+ name: exports.CODEX_MARKETPLACE,
214
+ interface: { displayName: 'Holmes-Kit (local)' },
215
+ plugins: [{
216
+ name: 'holmes-kit',
217
+ source: { source: 'local', path: `./${exports.CODEX_PLUGIN_DIR.split(path.sep).join('/')}` },
218
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
219
+ category: 'Developer Tools',
220
+ }],
221
+ }, null, 2)}\n`;
207
222
  return [
208
223
  {
209
- path: path.join(target, '.agents', 'plugins', 'marketplace.json'),
210
- content: `${JSON.stringify({
211
- name: exports.CODEX_MARKETPLACE,
212
- interface: { displayName: 'Holmes-Kit (local)' },
213
- plugins: [{
214
- name: 'holmes-kit',
215
- source: { source: 'local', path: `./${exports.CODEX_PLUGIN_DIR.split(path.sep).join('/')}` },
216
- policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
217
- category: 'Developer Tools',
218
- }],
219
- }, null, 2)}\n`,
224
+ path: path.join(target, '.claude-plugin', 'marketplace.json'), // codex 0.152.x reads here
225
+ content: marketplaceJson,
226
+ },
227
+ {
228
+ path: path.join(target, '.agents', 'plugins', 'marketplace.json'), // 0.151.x (backward compat)
229
+ content: marketplaceJson,
220
230
  },
221
231
  {
222
232
  path: path.join(target, exports.CODEX_PLUGIN_DIR, '.codex-plugin', 'plugin.json'),
@@ -876,9 +876,14 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
876
876
  // it made the tool agree with the mistake.
877
877
  const cdxPluginJson = path.join(target, agents_1.CODEX_PLUGIN_DIR, '.codex-plugin', 'plugin.json');
878
878
  const cdxHooksJson = path.join(target, agents_1.CODEX_PLUGIN_DIR, 'hooks.json');
879
- const cdxMarketplace = path.join(target, '.agents', 'plugins', 'marketplace.json');
879
+ // @implements A-SPEC-533.3 — codex-cli 0.152.x reads the marketplace manifest at
880
+ // `.claude-plugin/marketplace.json` (measured 2026-09-03); 0.151.x read `.agents/plugins/`.
881
+ // Judge the gate against the location codex ACTUALLY reads, so a pre-A-SPEC-533.1 wiring (old
882
+ // location only) is caught instead of passing as "wiring is correct" when it cannot install.
883
+ const cdxMarketplaceNew = path.join(target, '.claude-plugin', 'marketplace.json');
884
+ const cdxMarketplaceOld = path.join(target, '.agents', 'plugins', 'marketplace.json');
880
885
  const installCmds = `codex plugin marketplace add "${target}" 후 codex plugin add holmes-kit@${agents_1.CODEX_MARKETPLACE}`;
881
- if (fs.existsSync(cdxPluginJson) && fs.existsSync(cdxHooksJson) && fs.existsSync(cdxMarketplace)) {
886
+ if (fs.existsSync(cdxPluginJson) && fs.existsSync(cdxHooksJson) && (fs.existsSync(cdxMarketplaceNew) || fs.existsSync(cdxMarketplaceOld))) {
882
887
  try {
883
888
  const h = JSON.parse(fs.readFileSync(cdxHooksJson, 'utf8'));
884
889
  const manifest = JSON.parse(fs.readFileSync(cdxPluginJson, 'utf8'));
@@ -895,6 +900,12 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
895
900
  // The one path measured to load. `./hooks/hooks.json` parses fine and never fires.
896
901
  add('codex gate', 'FAIL', `플러그인 매니페스트의 hooks 가 './hooks.json' 이 아닙니다: ${String(manifest.hooks)} — Codex 가 적재하지 않습니다`, 'holmes-kit init --target <dir> --agent codex --force 로 다시 배선하십시오.');
897
902
  }
903
+ else if (!fs.existsSync(cdxMarketplaceNew)) {
904
+ // @implements A-SPEC-533.3 — hooks are correct, but the marketplace manifest is only at
905
+ // the old `.agents/plugins/` location; codex 0.152.x reads `.claude-plugin/` and cannot
906
+ // install this. Judged AFTER the stale-hook / manifest FAILs so those still win.
907
+ add('codex gate', 'WARN', `codex 0.152.x 가 읽는 .claude-plugin/marketplace.json 이 없습니다 — 구 위치(.agents/plugins/marketplace.json)만 있어 codex plugin marketplace add 가 "manifest 없음" 으로 실패합니다 (0.151.x 는 .agents/plugins/, 0.152.x 는 .claude-plugin/)`, 'holmes-kit init --target <dir> --agent codex --force 로 다시 배선하면 두 위치에 매니페스트가 놓입니다.');
908
+ }
898
909
  else {
899
910
  add('codex gate', 'WARN', '플러그인 배선은 옳습니다 — 그러나 설치되기 전까지 게이트는 집행되지 않습니다(Codex 는 설치된 마켓플레이스에서만 플러그인을 적재하며, doctor 는 설치 여부를 오프라인에서 확인할 수 없습니다)', `${installCmds} 를 실행한 뒤 새 세션에서 사용하십시오.`);
900
911
  }
@@ -97,6 +97,9 @@ function buildHookPlan(packageRoot, matcher, specsDir = '.ax/specs') {
97
97
  preToolUseMatcher: matcher,
98
98
  preToolUseCommand: q(path.join(packageRoot, 'dist', 'holmes', 'hooks', 'pre-tool-use.js')),
99
99
  stopCommand: q(path.join(packageRoot, 'dist', 'holmes', 'hooks', 'stop.js')),
100
+ // @implements A-SPEC-531.2 — the SessionStart banner runs the same node entry; it takes no
101
+ // `--specs` arg (the banner reads only the package version and the ~/.holmes cache).
102
+ sessionStartCommand: `node "${path.join(packageRoot, 'dist', 'holmes', 'hooks', 'session-start.js')}"`,
100
103
  };
101
104
  }
102
105
  /**
@@ -32,6 +32,7 @@ export interface HookPlan {
32
32
  preToolUseMatcher: string;
33
33
  preToolUseCommand: string;
34
34
  stopCommand: string;
35
+ sessionStartCommand?: string;
35
36
  }
36
37
  /** A hook entry belongs to holmes-kit iff its command references our hook scripts. */
37
38
  export declare function isHolmesCommand(command: string): boolean;
@@ -12,7 +12,7 @@ exports.mergeMcpServers = mergeMcpServers;
12
12
  exports.removeMcpServer = removeMcpServer;
13
13
  /** A hook entry belongs to holmes-kit iff its command references our hook scripts. */
14
14
  function isHolmesCommand(command) {
15
- return /dist[/\\]holmes[/\\]hooks[/\\](pre-tool-use|stop)\.js/.test(command) || /\bholmes-(kit|mcp|pre-tool-use|stop)\b/.test(command);
15
+ return /dist[/\\]holmes[/\\]hooks[/\\](pre-tool-use|stop|session-start)\.js/.test(command) || /\bholmes-(kit|mcp|pre-tool-use|stop)\b/.test(command);
16
16
  }
17
17
  /**
18
18
  * The script path out of a hook command line, e.g. `node "/pkg/dist/.../stop.js"` -> `/pkg/dist/.../stop.js`.
@@ -43,6 +43,11 @@ function mergeHooks(existing, plan) {
43
43
  };
44
44
  upsert('PreToolUse', { matcher: plan.preToolUseMatcher, hooks: [{ type: 'command', command: plan.preToolUseCommand }] });
45
45
  upsert('Stop', { hooks: [{ type: 'command', command: plan.stopCommand }] }); // Stop takes NO matcher
46
+ // @implements A-SPEC-531.2 — the banner hook, wired like Stop (no matcher). Guarded so an older
47
+ // caller that builds a plan without it is unchanged (no empty SessionStart group appears).
48
+ if (plan.sessionStartCommand) {
49
+ upsert('SessionStart', { hooks: [{ type: 'command', command: plan.sessionStartCommand }] });
50
+ }
46
51
  out.hooks = hooks;
47
52
  return out;
48
53
  }
@@ -325,8 +325,31 @@ function cfgOf(ast, fn, source) {
325
325
  return lowerSeq(stmtChildren(i), ctx);
326
326
  case 'labeled_statement': {
327
327
  const [labelNode, inner] = [kids.of(i)[0], stmtChildren(i).at(-1)];
328
- ctx.pendingLabel = labelName(labelNode);
329
- return lowerStmt(inner, ctx);
328
+ const lname = labelName(labelNode);
329
+ // @implements A-SPEC-527.1 — Java (and TS) allow `break L` out of ANY labeled
330
+ // statement, not only loops and switches. Loop/switch inners keep consuming the label
331
+ // themselves (their break machinery owns the exits); for every OTHER statement the
332
+ // labeled statement itself becomes the breakable, and its parked breaks exit past it.
333
+ // `continue` is deliberately NOT registered — the language keeps it loop-only too.
334
+ const LABEL_CONSUMERS = new Set([
335
+ 'while_statement', 'do_statement', 'for_statement', 'for_in_statement',
336
+ 'enhanced_for_statement', 'foreach_statement', 'for_range_loop',
337
+ 'switch_statement', 'switch_expression', 'expression_switch_statement',
338
+ 'type_switch_statement', 'select_statement',
339
+ 'for_expression', 'while_expression', 'loop_expression',
340
+ ]);
341
+ if (LABEL_CONSUMERS.has(ast.nodes[inner].type)) {
342
+ ctx.pendingLabel = lname;
343
+ return lowerStmt(inner, ctx);
344
+ }
345
+ const owner = { label: lname, target: -1 };
346
+ ctx.breakT.push({ label: lname, owner });
347
+ const f = lowerStmt(inner, ctx);
348
+ ctx.breakT.pop();
349
+ const exits = [...f.exits];
350
+ for (const b of claimBreaks(owner))
351
+ exits.push({ from: b.point, kind: 'break' });
352
+ return { entry: f.entry, exits };
330
353
  }
331
354
  case 'elif_clause':
332
355
  case 'if_statement': {
@@ -0,0 +1,14 @@
1
+ import { Spec } from '../spec/spec-parser';
2
+ /** The out-of-band switch. An agent cannot read or set it — the pre-tool-use gate blocks that
3
+ * (A-SPEC-532.2), the same self-disarm protection HOLMES_APPROVAL and HOLMES_GATE_BYPASS have. */
4
+ export declare const AUTONOMY_ENV = "HOLMES_AUTONOMOUS_APPROVAL";
5
+ export type ApprovalAutonomy = 'auto' | 'hitl';
6
+ export declare function isHighRiskPath(p: string): boolean;
7
+ /**
8
+ * The autonomy verdict for approving THIS spec. `resolveParent` is accepted for future
9
+ * parent-aware rules (kept in the signature so callers wire it once); the current bound is decided
10
+ * from the spec itself.
11
+ */
12
+ export declare function specApprovalAutonomy(spec: Spec, _resolveParent: (id: string) => Spec | null): ApprovalAutonomy;
13
+ /** Whether autonomous approval is enabled at all — the out-of-band switch, read at the wiring layer. */
14
+ export declare function autonomousApprovalEnabled(env: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AUTONOMY_ENV = void 0;
4
+ exports.isHighRiskPath = isHighRiskPath;
5
+ exports.specApprovalAutonomy = specApprovalAutonomy;
6
+ exports.autonomousApprovalEnabled = autonomousApprovalEnabled;
7
+ const scope_judgment_1 = require("../guardrail/scope-judgment");
8
+ /** The out-of-band switch. An agent cannot read or set it — the pre-tool-use gate blocks that
9
+ * (A-SPEC-532.2), the same self-disarm protection HOLMES_APPROVAL and HOLMES_GATE_BYPASS have. */
10
+ exports.AUTONOMY_ENV = 'HOLMES_AUTONOMOUS_APPROVAL';
11
+ /**
12
+ * Path prefixes whose files govern the gate, the ledger, taint boundaries, or this session's own
13
+ * wiring. A spec touching one of these must not self-approve even at a mid grade — changing them is
14
+ * exactly the "structural / irreversible" class the owner drew the line at.
15
+ */
16
+ const HIGH_RISK_PREFIXES = [
17
+ 'src/holmes/hooks/',
18
+ 'src/holmes/guardrail/',
19
+ 'src/holmes/governance/',
20
+ '.ax/roles',
21
+ '.claude',
22
+ ];
23
+ const TAINT_MARKERS = ['taint', 'dataflow-taint', 'flow-sensitive'];
24
+ function isHighRiskPath(p) {
25
+ const s = p.replace(/^\.\//, '').replace(/^["'`]|["'`]$/g, '');
26
+ if (HIGH_RISK_PREFIXES.some((pre) => s.startsWith(pre)))
27
+ return true;
28
+ if (s === '.mcp.json' || s.endsWith('/.mcp.json'))
29
+ return true;
30
+ // A taint/security boundary file anywhere under rtm/ — the flow engine and its vocabulary.
31
+ if (s.startsWith('src/holmes/rtm/') && TAINT_MARKERS.some((m) => s.includes(m)))
32
+ return true;
33
+ return false;
34
+ }
35
+ /** The declared breaking-change grade of an A-SPEC, or null when absent/blank. */
36
+ function breakingGrade(spec) {
37
+ const raw = spec.frontmatter?.breaking_change;
38
+ if (typeof raw !== 'string' || raw.trim() === '')
39
+ return null;
40
+ // `<grade>: <reason>` or a bare `none` — the grade is the token before the first colon.
41
+ return raw.trim().split(':')[0].trim();
42
+ }
43
+ // Grades at or below code-interface are auto; gate-behavior (and anything unrecognised) is not.
44
+ const AUTO_GRADES = new Set(['none', 'persisted-artifact', 'derived-artifact', 'code-interface']);
45
+ /**
46
+ * The autonomy verdict for approving THIS spec. `resolveParent` is accepted for future
47
+ * parent-aware rules (kept in the signature so callers wire it once); the current bound is decided
48
+ * from the spec itself.
49
+ */
50
+ function specApprovalAutonomy(spec, _resolveParent) {
51
+ switch (spec.type) {
52
+ case 'T-SPEC':
53
+ return 'auto'; // tests are low risk
54
+ case 'REQ':
55
+ case 'H-SPEC':
56
+ case 'C-SPEC':
57
+ return 'hitl'; // wide blast radius / structural constraint
58
+ case 'A-SPEC': {
59
+ const grade = breakingGrade(spec);
60
+ if (grade === null || !AUTO_GRADES.has(grade))
61
+ return 'hitl'; // undeclared or gate-behavior
62
+ const paths = (0, scope_judgment_1.fttPathTokens)(spec.sections['Files to Touch'] ?? '');
63
+ if (paths.some(isHighRiskPath))
64
+ return 'hitl'; // gate/governance/taint file
65
+ return 'auto';
66
+ }
67
+ default:
68
+ return 'hitl'; // unknown kind: fail-safe to human
69
+ }
70
+ }
71
+ /** Whether autonomous approval is enabled at all — the out-of-band switch, read at the wiring layer. */
72
+ function autonomousApprovalEnabled(env) {
73
+ const v = env[exports.AUTONOMY_ENV];
74
+ return typeof v === 'string' && v !== '';
75
+ }
@@ -153,4 +153,29 @@ export interface PathImpl {
153
153
  resolve: (...parts: string[]) => string;
154
154
  sep: string;
155
155
  }
156
+ /**
157
+ * @implements A-SPEC-528.1
158
+ * One shell command, cut at its separators, with the EFFECTIVE working directory each piece runs
159
+ * in. The shell-write rules used to hand every relative candidate to `resolvesInside` with the
160
+ * project root as the base; a `cd` earlier in the command moves that base, and the mismatch was
161
+ * measured both ways (2026-09-03 probe): five in-project writes spelled from a subdirectory were
162
+ * allowed, three legitimate out-of-tree writes were denied.
163
+ *
164
+ * `base: null` means the gate cannot know where the piece runs (dynamic cd argument, `cd -`,
165
+ * `popd`, ambiguous multi-token target). The consumer fails CLOSED on a governed relative write
166
+ * candidate in such a piece — and stays silent when no candidate follows.
167
+ *
168
+ * The split is QUOTE-AWARE on purpose: a separator inside quotes is data, and a transparent split
169
+ * would let `printf 'cd /\n'` place a fake `cd` at a segment start and push the base out of the
170
+ * project (injection found while designing this — the honest-revision note in H-SPEC-528).
171
+ * Command strings are re-opened explicitly instead: `sh -c '…'`/`eval '…'` programs and
172
+ * shell-stdin heredoc bodies recurse (their cds stay LOCAL to the child shell, matching real
173
+ * semantics); data heredoc bodies keep their text for candidate matching but never move the base.
174
+ * Pure: no fs, no env reads beyond `os.homedir()` for `~`, no `process.platform`.
175
+ */
176
+ export interface ShellSegment {
177
+ text: string;
178
+ base: string | null;
179
+ }
180
+ export declare function shellSegments(command: string, startBase: string, depth?: number): ShellSegment[];
156
181
  export declare function resolvesInside(raw: string, roots: readonly string[], impl?: PathImpl): boolean;
@@ -43,6 +43,7 @@ exports.isProtectedTarget = isProtectedTarget;
43
43
  exports.specTargetOf = specTargetOf;
44
44
  exports.protectedFileKindOf = protectedFileKindOf;
45
45
  exports.protectedKindOf = protectedKindOf;
46
+ exports.shellSegments = shellSegments;
46
47
  exports.resolvesInside = resolvesInside;
47
48
  // @implements A-SPEC-163
48
49
  const fs = __importStar(require("node:fs"));
@@ -300,6 +301,148 @@ function protectedKindOf(root, raw) {
300
301
  }
301
302
  return null;
302
303
  }
304
+ const HEREDOC_RE = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/;
305
+ const SHELL_STDIN_RE = /(?:^|[\s;|&(])(?:sh|bash|zsh|dash|ksh)\b[^<\n]*<</;
306
+ const PROG_STRING_RE = /\b(?:(?:sh|bash|zsh|dash|ksh)\b[^\n;|&]*?-c|eval)\s+(?:'([^']*)'|"([^"]*)")/g;
307
+ function shellSegments(command, startBase, depth = 0) {
308
+ if (depth > 3)
309
+ return [{ text: command, base: null }];
310
+ const out = [];
311
+ let base = startBase;
312
+ const pieces = [];
313
+ {
314
+ const lines = command.split('\n');
315
+ let cur = [];
316
+ const flush = () => { if (cur.length > 0) {
317
+ pieces.push({ text: cur.join('\n'), kind: 'cmd' });
318
+ cur = [];
319
+ } };
320
+ for (let i = 0; i < lines.length; i++) {
321
+ const line = lines[i];
322
+ const hd = HEREDOC_RE.exec(line);
323
+ if (!hd) {
324
+ cur.push(line);
325
+ continue;
326
+ }
327
+ cur.push(line);
328
+ flush();
329
+ const body = [];
330
+ for (i++; i < lines.length && lines[i].replace(/^\t+/, '') !== hd[2]; i++)
331
+ body.push(lines[i]);
332
+ pieces.push({ text: body.join('\n'), kind: SHELL_STDIN_RE.test(line) ? 'prog' : 'data' });
333
+ }
334
+ flush();
335
+ }
336
+ // Quote-aware split at ; & | and newlines — separators inside quotes are data.
337
+ const splitTop = (text) => {
338
+ const parts = [];
339
+ let acc = '';
340
+ let quote = null;
341
+ for (let k = 0; k < text.length; k++) {
342
+ const ch = text[k];
343
+ if (quote !== null) {
344
+ acc += ch;
345
+ if (ch === quote)
346
+ quote = null;
347
+ continue;
348
+ }
349
+ if (ch === "'" || ch === '"') {
350
+ quote = ch;
351
+ acc += ch;
352
+ continue;
353
+ }
354
+ if (ch === '\\' && k + 1 < text.length) {
355
+ acc += ch + text[++k];
356
+ continue;
357
+ }
358
+ if (ch === ';' || ch === '&' || ch === '|' || ch === '\n') {
359
+ if (acc.trim() !== '')
360
+ parts.push(acc);
361
+ acc = '';
362
+ continue;
363
+ }
364
+ acc += ch;
365
+ }
366
+ if (acc.trim() !== '')
367
+ parts.push(acc);
368
+ return parts;
369
+ };
370
+ const applyCd = (part) => {
371
+ // `(`/`{` open groups whose leading cd still runs (a brace group shares the CURRENT shell; a
372
+ // subshell's cd is over-approximated by design — REQ-528 Out). `builtin cd` and `command cd`
373
+ // ARE the real cd — round-1 adversarial harvest: the unprefixed matcher left the base behind
374
+ // while the shell moved.
375
+ const lead = part.replace(/^[\s({]+/, '').replace(/^(?:builtin|command(?:\s+-p)?)\s+/, '');
376
+ if (/^popd\b/.test(lead)) {
377
+ base = null;
378
+ return;
379
+ }
380
+ const m = /^(?:cd|chdir|pushd)(?:\s+([\s\S]*))?$/.exec(lead);
381
+ if (!m)
382
+ return;
383
+ const rawArg = (m[1] ?? '').trim();
384
+ if (rawArg === '') {
385
+ base = os.homedir();
386
+ return;
387
+ }
388
+ if (/[$`]/.test(rawArg)) {
389
+ base = null;
390
+ return;
391
+ }
392
+ const words = rawArg.match(/(?:[^\s'"]+|'[^']*'|"[^"]*")+/g) ?? [];
393
+ if (words.length !== 1) {
394
+ base = null;
395
+ return;
396
+ }
397
+ const arg = words[0].replace(/^"([^"]*)"$/, '$1').replace(/^'([^']*)'$/, '$1');
398
+ if (arg === '' || arg === '-') {
399
+ base = null;
400
+ return;
401
+ }
402
+ if (arg === '~') {
403
+ base = os.homedir();
404
+ return;
405
+ }
406
+ if (arg.startsWith('~/')) {
407
+ base = path.join(os.homedir(), arg.slice(2));
408
+ return;
409
+ }
410
+ if (base === null) {
411
+ const kind = absoluteKindOf(arg);
412
+ base = kind === null ? null : pathFlavorFor(arg, arg).resolve(arg);
413
+ return;
414
+ }
415
+ base = pathFlavorFor(base, arg).resolve(base, arg);
416
+ };
417
+ for (const piece of pieces) {
418
+ if (piece.kind === 'data') {
419
+ out.push({ text: piece.text, base });
420
+ continue;
421
+ }
422
+ if (piece.kind === 'prog') {
423
+ // A shell reading its program from stdin is a CHILD — its cds do not move the parent base.
424
+ out.push(...shellSegments(piece.text, base ?? startBase, depth + 1)
425
+ .map((s) => (base === null ? { ...s, base: null } : s)));
426
+ continue;
427
+ }
428
+ for (const part of splitTop(piece.text)) {
429
+ let rem = part;
430
+ for (const m of part.matchAll(PROG_STRING_RE)) {
431
+ const prog = m[1] ?? m[2];
432
+ if (prog === undefined || prog === '')
433
+ continue;
434
+ // Same child-shell rule as stdin programs; blank the program out of the parent text so its
435
+ // candidates are judged once, at the child's own bases.
436
+ out.push(...shellSegments(prog, base ?? startBase, depth + 1)
437
+ .map((s) => (base === null ? { ...s, base: null } : s)));
438
+ rem = rem.replace(`'${prog}'`, "''").replace(`"${prog}"`, '""');
439
+ }
440
+ applyCd(rem);
441
+ out.push({ text: rem, base });
442
+ }
443
+ }
444
+ return out;
445
+ }
303
446
  function resolvesInside(raw, roots, impl = path) {
304
447
  if (typeof raw !== 'string' || raw.length === 0 || roots.length === 0)
305
448
  return false;