@guilz-dev/belay 0.1.0 → 0.2.0

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.
Files changed (62) hide show
  1. package/README.md +59 -12
  2. package/dist/adapters/shared/gate-runtime.js +12 -4
  3. package/dist/bundle/claude-runtime.mjs +155 -36
  4. package/dist/bundle/codex-runtime.mjs +155 -36
  5. package/dist/bundle/cursor-runtime.mjs +155 -36
  6. package/dist/cli.js +15 -4
  7. package/dist/commands/classify-for-report.js +3 -3
  8. package/dist/commands/doctor.js +1 -1
  9. package/dist/commands/explain.js +14 -14
  10. package/dist/commands/init-wizard.d.ts +5 -0
  11. package/dist/commands/init-wizard.js +24 -11
  12. package/dist/commands/recover.js +2 -2
  13. package/dist/core/approval.d.ts +3 -0
  14. package/dist/core/approval.js +18 -3
  15. package/dist/core/audit-query.js +5 -1
  16. package/dist/core/classify-tool.js +1 -1
  17. package/dist/core/config.d.ts +1 -1
  18. package/dist/core/config.js +2 -2
  19. package/dist/core/gate-contract.d.ts +1 -1
  20. package/dist/core/gate-contract.js +1 -1
  21. package/dist/core/gate-engine.js +2 -2
  22. package/dist/core/index.d.ts +2 -2
  23. package/dist/core/index.js +2 -2
  24. package/dist/core/judge-config.d.ts +5 -1
  25. package/dist/core/judge-config.js +17 -1
  26. package/dist/core/judge-doctor.js +2 -2
  27. package/dist/core/types.d.ts +5 -3
  28. package/dist/core/{v2 → verdict}/adapter.js +9 -3
  29. package/dist/core/{v2 → verdict}/egress-classify.js +3 -0
  30. package/dist/core/{v2 → verdict}/judge.js +10 -12
  31. package/dist/core/{v2 → verdict}/launcher-resolve.js +72 -1
  32. package/dist/core/{v2 → verdict}/verdict.js +16 -0
  33. package/dist/corpus/evaluate.js +2 -2
  34. package/dist/installer.js +1 -0
  35. package/dist/types.d.ts +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +2 -1
  38. package/package.json +5 -2
  39. package/skills/belay/SKILL.md +19 -5
  40. /package/dist/core/{v2 → verdict}/adapter.d.ts +0 -0
  41. /package/dist/core/{v2 → verdict}/containment.d.ts +0 -0
  42. /package/dist/core/{v2 → verdict}/containment.js +0 -0
  43. /package/dist/core/{v2 → verdict}/egress-classify.d.ts +0 -0
  44. /package/dist/core/{v2 → verdict}/fingerprint.d.ts +0 -0
  45. /package/dist/core/{v2 → verdict}/fingerprint.js +0 -0
  46. /package/dist/core/{v2 → verdict}/index.d.ts +0 -0
  47. /package/dist/core/{v2 → verdict}/index.js +0 -0
  48. /package/dist/core/{v2 → verdict}/judge-audit.d.ts +0 -0
  49. /package/dist/core/{v2 → verdict}/judge-audit.js +0 -0
  50. /package/dist/core/{v2 → verdict}/judge-factory.d.ts +0 -0
  51. /package/dist/core/{v2 → verdict}/judge-factory.js +0 -0
  52. /package/dist/core/{v2 → verdict}/judge-outbound.d.ts +0 -0
  53. /package/dist/core/{v2 → verdict}/judge-outbound.js +0 -0
  54. /package/dist/core/{v2 → verdict}/judge.d.ts +0 -0
  55. /package/dist/core/{v2 → verdict}/launcher-resolve.d.ts +0 -0
  56. /package/dist/core/{v2 → verdict}/overrides.d.ts +0 -0
  57. /package/dist/core/{v2 → verdict}/overrides.js +0 -0
  58. /package/dist/core/{v2 → verdict}/parser.d.ts +0 -0
  59. /package/dist/core/{v2 → verdict}/parser.js +0 -0
  60. /package/dist/core/{v2 → verdict}/types.d.ts +0 -0
  61. /package/dist/core/{v2 → verdict}/types.js +0 -0
  62. /package/dist/core/{v2 → verdict}/verdict.d.ts +0 -0
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Belay
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@guilz-dev/belay)](https://www.npmjs.com/package/@guilz-dev/belay)
4
+ [![skills.sh](https://skills.sh/b/guilz-dev/belay)](https://skills.sh/guilz-dev/belay)
4
5
  [![CI](https://github.com/guilz-dev/belay/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/guilz-dev/belay/actions/workflows/ci.yml)
5
6
  [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
7
 
@@ -8,10 +9,10 @@
8
9
 
9
10
  [Documentation (日本語)](./docs/README.ja.md)
10
11
 
11
- `@guilz-dev/belay` hooks into agent runtimes (Cursor, Claude Code) and inspects
12
- each shell command, subagent launch, and file mutation *before* it runs. Most
13
- actions pass through untouched. Only the irreversible-and-catastrophic ones are
14
- held back for one-shot human approval — and every decision is written to an
12
+ `@guilz-dev/belay` hooks into agent runtimes (Cursor, Claude Code, Codex) and
13
+ inspects each shell command, subagent launch, and file mutation *before* it runs.
14
+ Most actions pass through untouched. Only the irreversible-and-catastrophic ones
15
+ are held back for one-shot human approval — and every decision is written to an
15
16
  audit log.
16
17
 
17
18
  <p align="center">
@@ -21,6 +22,30 @@ audit log.
21
22
  > **0.0.x early release** — APIs and behavior may change. Cursor and Claude Code
22
23
  > are the supported adapters; Codex is experimental.
23
24
 
25
+ ## Supported agents
26
+
27
+ Belay works across three coding agents. Each one runs the **same classifier**,
28
+ wired in through that agent's native **hook** mechanism — no agent-specific
29
+ policy to maintain.
30
+
31
+ | Agent | Status | Hook config | belay config |
32
+ |-------|--------|-------------|--------------|
33
+ | **Cursor** | Supported | `.cursor/hooks.json` | `.cursor/belay.config.json` |
34
+ | **Claude Code** | Supported | `.claude/settings.json` | `.claude/belay.config.json` |
35
+ | **Codex** | Experimental | `.codex/config.toml` | `.codex/belay.config.json` |
36
+
37
+ Pick the adapter at install time with `--adapter cursor|claude|codex` (or let
38
+ `init-wizard` prompt). Hosts use different hook event names, but Belay registers
39
+ the same runners (`belay-tool-gate`, `belay-before-submit`, `belay-audit`) at
40
+ equivalent lifecycle points:
41
+
42
+ | Role | belay hook | Cursor | Claude Code | Codex |
43
+ |------|-----------|--------|-------------|-------|
44
+ | Gate shell / tools / file mutations | `belay-tool-gate` | `beforeShellExecution`, `preToolUse` | `PreToolUse` | `PreToolUse` |
45
+ | Gate subagent launches | `belay-tool-gate` | `subagentStart` | (via `PreToolUse`) | `SubagentStart` |
46
+ | One-shot approvals | `belay-before-submit` | `beforeSubmitPrompt` | `UserPromptSubmit` | `UserPromptSubmit` |
47
+ | Audit log | `belay-audit` | `postToolUse`, `stop`, `sessionEnd` | `PostToolUse` | `PostToolUse` |
48
+
24
49
  ## Why
25
50
 
26
51
  Static denylists don't work for agents. The same command (`rm`, `curl`, a
@@ -48,6 +73,7 @@ npx @guilz-dev/belay init-wizard
48
73
 
49
74
  # Or non-interactive
50
75
  npx @guilz-dev/belay init --adapter claude # Claude Code
76
+ npx @guilz-dev/belay init --adapter codex # Codex (experimental)
51
77
  npx @guilz-dev/belay init # Cursor (default)
52
78
  ```
53
79
 
@@ -64,10 +90,10 @@ verdict and `overrides.allow` to whitelist commands you trust.
64
90
 
65
91
  ## How it works
66
92
 
67
- Belay registers hooks on the host runtime (`.cursor/hooks.json` or
68
- `.claude/settings.json`) and gates shell execution, subagent launches, and file
69
- mutations through one shared classifier. It always forms its own judgment — it
70
- does not trust an assessment supplied by the agent.
93
+ Belay registers hooks on the host runtime (`.cursor/hooks.json`,
94
+ `.claude/settings.json`, or `.codex/config.toml`) and gates shell execution,
95
+ subagent launches, and file mutations through one shared classifier. It always
96
+ forms its own judgment — it does not trust an assessment supplied by the agent.
71
97
 
72
98
  Every gated action gets one of three verdicts:
73
99
 
@@ -84,7 +110,8 @@ When an action is denied, approve the **next matching action once** by sending:
84
110
  ```
85
111
 
86
112
  Approvals are one-shot and expire after 15 minutes by default. Every decision is
87
- written to `.cursor/belay/audit.ndjson` (or `.claude/belay/audit.ndjson`).
113
+ written to `.cursor/belay/audit.ndjson`, `.claude/belay/audit.ndjson`, or
114
+ `.codex/belay/audit.ndjson` (depending on the adapter).
88
115
 
89
116
  In **audit mode** (`mode: "audit"`), would-be denials are recorded
90
117
  (`wouldBlock: true`) but execution still continues, and no approval IDs are
@@ -122,12 +149,23 @@ and skill under `~/.cursor/`, so the gate is user-wide while `belay.config.json`
122
149
  approvals, and audit stay repo-local.
123
150
 
124
151
  **Skill-only.** The skill is just a UX layer (slash commands + guidance) and does
125
- **not** enable gating on its own:
152
+ **not** enable gating on its own. Install from [skills.sh](https://skills.sh/guilz-dev/belay)
153
+ or GitHub:
126
154
 
127
155
  ```bash
156
+ # Cursor
128
157
  npx skills add guilz-dev/belay --skill belay -a cursor -y
158
+
159
+ # Claude Code
160
+ npx skills add guilz-dev/belay --skill belay -a claude-code -y
161
+
162
+ # Codex
163
+ npx skills add guilz-dev/belay --skill belay -a codex -y
129
164
  ```
130
165
 
166
+ Running `npx skills add` also registers anonymous install telemetry on skills.sh,
167
+ which is how the skill appears in the directory leaderboard.
168
+
131
169
  Runtime enforcement still requires `belay init` in the target repository.
132
170
 
133
171
  ## Dogfood → enforce
@@ -187,8 +225,9 @@ load.
187
225
 
188
226
  Notable settings:
189
227
 
190
- - **`policy.unknownLocalEffect: "deny"`** — fail-closed classification for
191
- unrecognized local commands.
228
+ - **`policy.unknownLocalEffect: "allow_flagged"`** (fresh default) after Tier1
229
+ says recoverable, structurally unknown local commands run with an audit flag. Use
230
+ `"deny"` (via `belay dogfood`) to ask on those commands instead.
192
231
  - **`classifier.strictChains: true`** (default) — scans every `&&`, `|`, and `;`
193
232
  segment and keeps the strictest verdict. Override lists match exact command or
194
233
  segment keys only.
@@ -247,6 +286,14 @@ Belay state files are local runtime artifacts and should usually stay out of git
247
286
  .cursor/hooks/belay-*
248
287
  .cursor/skills/belay/
249
288
  .cursor/commands/belay-approve.md
289
+
290
+ .claude/belay/
291
+ .claude/belay.config.json
292
+ .claude/hooks/belay-*
293
+
294
+ .codex/belay/
295
+ .codex/belay.config.json
296
+ .codex/hooks/belay-*
250
297
  ```
251
298
 
252
299
  ## Library exports
@@ -8,7 +8,7 @@ import { fsScopeAllowlistPath, isCapabilityBrokerDemotionActive, loadFsScopeAllo
8
8
  import { resolveLayeredConfig, teamConfigPath } from '../../core/config-layers.js';
9
9
  import { classifyResultToGateVerdict, unnormalizedGateVerdict, } from '../../core/gate-contract.js';
10
10
  import { classifyGatedActionAsync, extractAgentAssessment, GateNormalizationError, gateEnabledForAction, normalizeGatedAction, } from '../../core/gate-engine.js';
11
- import { approvalCommandMatch, approvedApprovalsFile, buildRetryInstruction, canonicalStringify, classifierOptionsFromConfig, compactApprovals, configuredControlPlaneDir, createApprovalRecord, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
11
+ import { APPROVAL_EXECUTION_LEASE_MS, approvalCommandMatch, approvedApprovalsFile, buildRetryInstruction, canonicalStringify, classifierOptionsFromConfig, compactApprovals, configuredControlPlaneDir, createApprovalRecord, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
12
12
  import { notifyDeny } from '../../core/notify.js';
13
13
  import { isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
14
14
  import { protectedArtifactRoots } from '../layouts/protected-paths.js';
@@ -142,7 +142,15 @@ async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
142
142
  await deps.writeApprovals(approved.filePath, approved.state);
143
143
  return null;
144
144
  }
145
- const [approval] = approved.state.approvals.splice(index, 1);
145
+ const approval = approved.state.approvals[index];
146
+ if (approval.executionLeaseExpiresAt) {
147
+ await deps.writeApprovals(approved.filePath, approved.state);
148
+ return approval;
149
+ }
150
+ approved.state.approvals[index] = {
151
+ ...approval,
152
+ executionLeaseExpiresAt: new Date(Date.now() + APPROVAL_EXECUTION_LEASE_MS).toISOString(),
153
+ };
146
154
  await deps.writeApprovals(approved.filePath, approved.state);
147
155
  return approval;
148
156
  }
@@ -290,8 +298,8 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
290
298
  predictedAssessment: auditExtras.predictedAssessment,
291
299
  observedAssessment: auditExtras.observedAssessment,
292
300
  mode: ctx.config.mode,
293
- schemaVersion: result.v2 ? 2 : 1,
294
- ...(result.v2 ?? {}),
301
+ schemaVersion: result.axes ? 2 : 1,
302
+ ...(result.axes ?? {}),
295
303
  ...auditExtras.transactionalLayer,
296
304
  };
297
305
  if (result.reason === TRANSACTIONAL_ALREADY_APPLIED) {
@@ -52,7 +52,7 @@ function classifyResultToGateVerdict(params) {
52
52
  approvalId,
53
53
  user_message,
54
54
  agent_message,
55
- v2: result.v2
55
+ axes: result.axes
56
56
  };
57
57
  }
58
58
  function unnormalizedGateVerdict(params) {
@@ -128,7 +128,7 @@ var LEGACY_POLICY_V3 = {
128
128
  fenceWarnThreshold: DEFAULT_FENCE_WARN_THRESHOLD
129
129
  };
130
130
  var DEFAULT_POLICY_V3 = {
131
- unknownLocalEffect: "deny",
131
+ unknownLocalEffect: "allow_flagged",
132
132
  unparseableShell: "deny",
133
133
  codexUnmappedTool: "deny",
134
134
  confidenceThresholds: { ...DEFAULT_CONFIDENCE_THRESHOLDS },
@@ -745,16 +745,25 @@ import { mkdir as mkdir3, readFile as readFile2, writeFile as writeFile2 } from
745
745
  import path16 from "node:path";
746
746
 
747
747
  // src/core/approval.ts
748
+ var APPROVAL_EXECUTION_LEASE_MS = 6e4;
748
749
  function nowIso() {
749
750
  return (/* @__PURE__ */ new Date()).toISOString();
750
751
  }
751
752
  function isExpired(approval) {
752
753
  return Date.parse(approval.expiresAt) <= Date.now();
753
754
  }
755
+ function isExecutionLeaseExpired(approval) {
756
+ if (!approval.executionLeaseExpiresAt) {
757
+ return false;
758
+ }
759
+ return Date.parse(approval.executionLeaseExpiresAt) <= Date.now();
760
+ }
754
761
  function compactApprovals(state) {
755
762
  return {
756
763
  version: state.version,
757
- approvals: state.approvals.filter((approval) => !isExpired(approval))
764
+ approvals: state.approvals.filter(
765
+ (approval) => !isExpired(approval) && !isExecutionLeaseExpired(approval)
766
+ )
758
767
  };
759
768
  }
760
769
  function escapeRegex(value) {
@@ -763,8 +772,15 @@ function escapeRegex(value) {
763
772
  }
764
773
  function approvalCommandMatch(prompt, tokenPrefix) {
765
774
  const escapedPrefix = escapeRegex(tokenPrefix);
766
- const match = prompt.match(new RegExp(`^\\s*${escapedPrefix}\\s+(\\S+)\\s*$`, "i"));
767
- return match?.[1] ?? null;
775
+ const linePattern = new RegExp(`^\\s*${escapedPrefix}\\s+(\\S+)\\s*$`, "i");
776
+ for (const line of prompt.split(/\r?\n/)) {
777
+ if (!line.trim()) {
778
+ continue;
779
+ }
780
+ const match = line.match(linePattern);
781
+ return match?.[1] ?? null;
782
+ }
783
+ return null;
768
784
  }
769
785
  function buildRetryInstruction(tokenPrefix, approvalId) {
770
786
  return `To allow the next matching action once, send ${tokenPrefix} ${approvalId} and then retry the original action unchanged.`;
@@ -1503,7 +1519,7 @@ function matchesSensitivePath(filePath, patterns) {
1503
1519
  return false;
1504
1520
  }
1505
1521
 
1506
- // src/core/v2/judge-audit.ts
1522
+ // src/core/verdict/judge-audit.ts
1507
1523
  function judgeTraceAuditFields(trace) {
1508
1524
  if (!trace) {
1509
1525
  return {};
@@ -1518,7 +1534,7 @@ function judgeTraceAuditFields(trace) {
1518
1534
  };
1519
1535
  }
1520
1536
 
1521
- // src/core/v2/judge-outbound.ts
1537
+ // src/core/verdict/judge-outbound.ts
1522
1538
  var PATH_LIKE = /(?:^|[\s"'`=])(~\/[^\s"'`]+|\/[^\s"'`]+|\.\/[^\s"'`]+|\.\.\/[^\s"'`]+|[A-Za-z]:\\[^\s"'`]+)/g;
1523
1539
  var REDACTED_PLACEHOLDER = /^(?:<redacted>|\[REDACTED\]|<secret>|<high-entropy>|<approval-id>)$/i;
1524
1540
  var URL_CREDENTIALS_PATTERN2 = /\b[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/\s:@]+):([^@\s/]+)@/gi;
@@ -1587,16 +1603,16 @@ function scrubOutboundForJudge(text, options) {
1587
1603
  }
1588
1604
  }
1589
1605
 
1590
- // src/core/v2/judge.ts
1606
+ // src/core/verdict/judge.ts
1591
1607
  var SECRET_PATTERNS = [/\.env\b/i, /\.pem\b/i, /id_rsa\b/i, /credentials/i, /secrets?\b/i];
1592
1608
  var DESTRUCTIVE_VERBS = /\b(rm|rmtree|unlink|delete|truncate|shred|destroy|drop)\b/i;
1593
1609
  var GIT_PATTERNS = /\.git\b/i;
1594
- var TIER1_PROMPT = `Answer ONLY with JSON: {"external_change":boolean,"destroys_outside_repo":boolean,"destroys_history_or_secrets":boolean,"reason":string}
1595
- Default all three booleans to true when unsure. reason should be a short snake_case label.
1610
+ var TIER1_PROMPT = `Answer ONLY with JSON: {"external_change":boolean,"reason":string}
1611
+ reason should be a short snake_case label.
1596
1612
 
1597
- external_change: CREATE/MODIFY/DELETE on database, network endpoint, cloud, remote git, registry, queue. READ/QUERY/GET is NOT a change.
1598
- destroys_outside_repo: DELETE/OVERWRITE files OUTSIDE the current repository working tree.
1599
- destroys_history_or_secrets: DELETE/OVERWRITE .git directory OR secret/credential files (.env, *.pem, id_rsa, credentials).
1613
+ external_change: true if this operation CREATEs, MODIFYs, DELETEs, or SENDs data to any system outside the local machine and its git repository working tree.
1614
+ READ/QUERY/GET/list/describe/SELECT/HTTP GET is NOT a change. Local build, test, and package install inside the repo are NOT external changes.
1615
+ If unsure, answer true.
1600
1616
 
1601
1617
  Command/code:
1602
1618
  `;
@@ -1611,13 +1627,13 @@ function failClosedVerdict(reason) {
1611
1627
  function parseTier1Json(raw) {
1612
1628
  try {
1613
1629
  const parsed = JSON.parse(raw);
1614
- if (typeof parsed.external_change !== "boolean" || typeof parsed.destroys_outside_repo !== "boolean" || typeof parsed.destroys_history_or_secrets !== "boolean") {
1630
+ if (typeof parsed.external_change !== "boolean") {
1615
1631
  return null;
1616
1632
  }
1617
1633
  return {
1618
- external_change: parsed.external_change !== false,
1619
- destroys_outside_repo: parsed.destroys_outside_repo !== false,
1620
- destroys_history_or_secrets: parsed.destroys_history_or_secrets !== false,
1634
+ external_change: parsed.external_change,
1635
+ destroys_outside_repo: false,
1636
+ destroys_history_or_secrets: false,
1621
1637
  reason: typeof parsed.reason === "string" ? parsed.reason : "tier1_llm"
1622
1638
  };
1623
1639
  } catch {
@@ -1840,10 +1856,10 @@ function createOpenAiCompatibleJudge(options) {
1840
1856
  return judge;
1841
1857
  }
1842
1858
  function tier1RequiresAsk(verdict2) {
1843
- return verdict2.external_change || verdict2.destroys_outside_repo || verdict2.destroys_history_or_secrets;
1859
+ return verdict2.external_change || verdict2.destroys_history_or_secrets;
1844
1860
  }
1845
1861
 
1846
- // src/core/v2/judge-factory.ts
1862
+ // src/core/verdict/judge-factory.ts
1847
1863
  var FIXTURE_MODELS_URL = new URL("../../../fixtures/judge-models.json", import.meta.url);
1848
1864
  function resolveCloudModel(requested, pinned) {
1849
1865
  if (requested === "auto") {
@@ -1890,10 +1906,10 @@ function createJudgeFromConfig(config, options = {}) {
1890
1906
  return createDeterministicJudgeStub();
1891
1907
  }
1892
1908
 
1893
- // src/core/v2/verdict.ts
1909
+ // src/core/verdict/verdict.ts
1894
1910
  import path11 from "node:path";
1895
1911
 
1896
- // src/core/v2/containment.ts
1912
+ // src/core/verdict/containment.ts
1897
1913
  import path8 from "node:path";
1898
1914
  function expandHome(token) {
1899
1915
  if (token === "~" || token.startsWith("~/")) {
@@ -1985,7 +2001,7 @@ function cwdRelative(repoRoot, cwd) {
1985
2001
  return relativeWithinRepo(repoRoot, cwd) ?? cwd;
1986
2002
  }
1987
2003
 
1988
- // src/core/v2/egress-classify.ts
2004
+ // src/core/verdict/egress-classify.ts
1989
2005
  var EGRESS_TOOL_HEADS = /* @__PURE__ */ new Set([
1990
2006
  "aws",
1991
2007
  "curl",
@@ -2083,6 +2099,9 @@ function classifyAws(tokens) {
2083
2099
  if (/\bs3\s+rm\b/.test(joined)) {
2084
2100
  return "destructive";
2085
2101
  }
2102
+ if (/\bs3\s+mb\b/.test(joined)) {
2103
+ return "destructive";
2104
+ }
2086
2105
  if (/\bs3\s+sync\b/.test(joined)) {
2087
2106
  return "destructive";
2088
2107
  }
@@ -2193,15 +2212,59 @@ function classifyNetlify(tokens) {
2193
2212
  return "ambiguous";
2194
2213
  }
2195
2214
 
2196
- // src/core/v2/fingerprint.ts
2215
+ // src/core/verdict/fingerprint.ts
2197
2216
  function verdictFingerprint(cwdRelative2, commandRedacted) {
2198
2217
  return hashValue(`v2:${cwdRelative2}:${commandRedacted}`);
2199
2218
  }
2200
2219
 
2201
- // src/core/v2/launcher-resolve.ts
2220
+ // src/core/verdict/launcher-resolve.ts
2202
2221
  import { existsSync as existsSync4, readFileSync as readFileSync2 } from "node:fs";
2203
2222
  import path9 from "node:path";
2204
2223
  var MAX_RESOLVE_DEPTH = 8;
2224
+ var PNPM_BUILTIN_COMMANDS = /* @__PURE__ */ new Set([
2225
+ "add",
2226
+ "audit",
2227
+ "cache",
2228
+ "config",
2229
+ "deploy",
2230
+ "dlx",
2231
+ "exec",
2232
+ "fetch",
2233
+ "help",
2234
+ "import",
2235
+ "init",
2236
+ "install",
2237
+ "i",
2238
+ "licenses",
2239
+ "link",
2240
+ "list",
2241
+ "outdated",
2242
+ "pack",
2243
+ "patch",
2244
+ "patch-commit",
2245
+ "patch-remove",
2246
+ "publish",
2247
+ "prune",
2248
+ "rebuild",
2249
+ "remove",
2250
+ "rm",
2251
+ "store",
2252
+ "unlink",
2253
+ "update",
2254
+ "up",
2255
+ "why"
2256
+ ]);
2257
+ var PNPM_EXEC_LIKE_HEADS = /* @__PURE__ */ new Set([
2258
+ "vitest",
2259
+ "vite",
2260
+ "biome",
2261
+ "eslint",
2262
+ "jest",
2263
+ "mocha",
2264
+ "tsc",
2265
+ "tsx",
2266
+ "node"
2267
+ ]);
2205
2268
  function readPackageJson(dir) {
2206
2269
  const packagePath = path9.join(dir, "package.json");
2207
2270
  if (!existsSync4(packagePath)) {
@@ -2256,6 +2319,12 @@ function npmScriptName(tokens) {
2256
2319
  if (launcher[0] === "pnpm" && launcher[1] === "run" && launcher[2]) {
2257
2320
  return launcher[2];
2258
2321
  }
2322
+ if (launcher[0] === "pnpm" && launcher[1] === "test") {
2323
+ return "test";
2324
+ }
2325
+ if (launcher[0] === "pnpm" && launcher[1] && !launcher[1].startsWith("-") && !PNPM_BUILTIN_COMMANDS.has(launcher[1])) {
2326
+ return launcher[1];
2327
+ }
2259
2328
  if (launcher[0] === "npm" && launcher[1] && launcher[1] !== "run" && launcher[1] !== "install") {
2260
2329
  return null;
2261
2330
  }
@@ -2377,11 +2446,31 @@ function resolveLauncherRecipe(params) {
2377
2446
  const tokens = params.tokens;
2378
2447
  const scriptName = npmScriptName(tokens);
2379
2448
  if (scriptName) {
2380
- return resolveNpmRecipe(params.cwd, params.repoRoot, scriptName, forwardedArgs(tokens));
2449
+ const resolution = resolveNpmRecipe(
2450
+ params.cwd,
2451
+ params.repoRoot,
2452
+ scriptName,
2453
+ forwardedArgs(tokens)
2454
+ );
2455
+ if (tokens[0] === "pnpm" && tokens[1] && PNPM_EXEC_LIKE_HEADS.has(tokens[1]) && resolution.reason === "npm_script_undefined") {
2456
+ return {
2457
+ recipes: [tokens.slice(1).join(" ")],
2458
+ opaque: false,
2459
+ reason: "pnpm_exec_like"
2460
+ };
2461
+ }
2462
+ return resolution;
2381
2463
  }
2382
2464
  if (tokens[0] === "make" && tokens[1] && !tokens[1].startsWith("-")) {
2383
2465
  return resolveMakeRecipe(params.cwd, params.repoRoot, tokens[1]);
2384
2466
  }
2467
+ if (tokens[0] === "pnpm" && tokens[1] && PNPM_EXEC_LIKE_HEADS.has(tokens[1])) {
2468
+ return {
2469
+ recipes: [tokens.slice(1).join(" ")],
2470
+ opaque: false,
2471
+ reason: "pnpm_exec_like"
2472
+ };
2473
+ }
2385
2474
  return null;
2386
2475
  }
2387
2476
  function isRoutineLauncher(tokens) {
@@ -2397,7 +2486,7 @@ function matchesCustomCommand(normalizedCommand, key, pattern) {
2397
2486
  return normalizedCommand === trimmed || key === trimmed;
2398
2487
  }
2399
2488
 
2400
- // src/core/v2/overrides.ts
2489
+ // src/core/verdict/overrides.ts
2401
2490
  function matchesCustomPatterns(command, segment, patterns) {
2402
2491
  if (!patterns || patterns.length === 0) {
2403
2492
  return false;
@@ -2436,7 +2525,7 @@ function askFromCustomExternal(opacity) {
2436
2525
  };
2437
2526
  }
2438
2527
 
2439
- // src/core/v2/parser.ts
2528
+ // src/core/verdict/parser.ts
2440
2529
  import path10 from "node:path";
2441
2530
 
2442
2531
  // src/core/shell-substitution.ts
@@ -2660,7 +2749,7 @@ function hasUnbalancedDollarParen(command) {
2660
2749
  return depth > 0;
2661
2750
  }
2662
2751
 
2663
- // src/core/v2/parser.ts
2752
+ // src/core/verdict/parser.ts
2664
2753
  var ENV_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S+)$/;
2665
2754
  var TRANSPARENT_WRAPPERS = /* @__PURE__ */ new Set([
2666
2755
  "sudo",
@@ -2860,7 +2949,7 @@ function redactCommand(command) {
2860
2949
  return command.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]").replace(/sk-[A-Za-z0-9]{8,}/g, "sk-[REDACTED]").trim();
2861
2950
  }
2862
2951
 
2863
- // src/core/v2/verdict.ts
2952
+ // src/core/verdict/verdict.ts
2864
2953
  var DEFAULT_MAX_DEPTH = 8;
2865
2954
  var TIER0_EXTERNAL_KEYS = /* @__PURE__ */ new Set([
2866
2955
  "git push",
@@ -2943,6 +3032,7 @@ var LOCAL_ROUTINE_HEADS = /* @__PURE__ */ new Set([
2943
3032
  "make",
2944
3033
  "cmake"
2945
3034
  ]);
3035
+ var BELAY_SELF_COMMANDS = /* @__PURE__ */ new Set(["approve", "revoke"]);
2946
3036
  var FIND_DANGEROUS_FLAGS = /* @__PURE__ */ new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir"]);
2947
3037
  function isFindDangerous(tokens) {
2948
3038
  return tokens.some(
@@ -3090,6 +3180,11 @@ function tier0ExternalMatch(key, head, tokens) {
3090
3180
  }
3091
3181
  return false;
3092
3182
  }
3183
+ function isBelaySelfCommand(tokens) {
3184
+ const head = tokens[0] ?? "";
3185
+ const subcommand = tokens[1] ?? "";
3186
+ return head === "belay" && BELAY_SELF_COMMANDS.has(subcommand);
3187
+ }
3093
3188
  function tier0HighStakesRm(tokens, context) {
3094
3189
  const head = tokens[0] ?? "";
3095
3190
  if (head !== "rm") {
@@ -3321,6 +3416,16 @@ async function evaluateSegment(command, context, depth) {
3321
3416
  if (rmVerdict) {
3322
3417
  return rmVerdict;
3323
3418
  }
3419
+ if (isBelaySelfCommand(peeled)) {
3420
+ return allowVerdict({
3421
+ location: "unknown",
3422
+ opacity: "transparent",
3423
+ effect: "local_mutation",
3424
+ confidence: "deterministic",
3425
+ reason: "belay_control_plane_command",
3426
+ signals: ["belay_control_plane_command", segment.head]
3427
+ });
3428
+ }
3324
3429
  let effect = "unknown";
3325
3430
  if (READ_ONLY_KEYS.has(segment.key) || READ_ONLY_KEYS.has(segment.head)) {
3326
3431
  effect = "read_only";
@@ -3541,7 +3646,7 @@ async function verdict(command, context) {
3541
3646
  );
3542
3647
  }
3543
3648
 
3544
- // src/core/v2/adapter.ts
3649
+ // src/core/verdict/adapter.ts
3545
3650
  function buildVerdictContext(params) {
3546
3651
  const protectedArtifactRoots2 = [
3547
3652
  ...params.options?.protectedArtifactRoots ?? [],
@@ -3588,6 +3693,12 @@ function mapLegacyReason(result) {
3588
3693
  if (result.reason === "repo_local_mutation") {
3589
3694
  return "local_mutation";
3590
3695
  }
3696
+ if (result.reason === "tier1_not_restorable") {
3697
+ return "tier1_catastrophic";
3698
+ }
3699
+ if (result.reason === "tier0_restorable" || result.reason === "tier1_restorable") {
3700
+ return result.effect === "local_mutation" ? "local_mutation" : result.reason;
3701
+ }
3591
3702
  return result.reason;
3592
3703
  }
3593
3704
  function verdictToClassifyResult(result) {
@@ -3608,13 +3719,13 @@ function verdictToClassifyResult(result) {
3608
3719
  assessment,
3609
3720
  normalizedCommand: result.commandRedacted,
3610
3721
  summary: result.commandRedacted,
3611
- v2: {
3722
+ axes: {
3612
3723
  location: result.location,
3613
3724
  opacity: result.opacity,
3614
3725
  effect: result.effect,
3615
3726
  confidence: result.confidence,
3616
3727
  would: result.permission,
3617
- by: "v2",
3728
+ by: "verdict",
3618
3729
  commandRedacted: result.commandRedacted,
3619
3730
  commandFingerprint: result.fingerprint,
3620
3731
  signals: result.signals,
@@ -4064,7 +4175,7 @@ function normalizeGatedAction(params) {
4064
4175
  };
4065
4176
  }
4066
4177
  function applyShellPeripheralPolicy(command, action, result, options) {
4067
- if (options.brokerFsScope && result.verdict === "deny_pending_approval" && (result.reason === "outside_repo_mutation" || result.reason === "outside_repo_redirect" || result.reason === "repo_outside_mutation" || result.v2?.location === "repo_outside")) {
4178
+ if (options.brokerFsScope && result.verdict === "deny_pending_approval" && (result.reason === "outside_repo_mutation" || result.reason === "outside_repo_redirect" || result.reason === "repo_outside_mutation" || result.axes?.location === "repo_outside")) {
4068
4179
  const outsideRepoPaths = collectOutsideRepoPaths(command, action.cwd, action.repoRoot);
4069
4180
  if (outsideRepoPaths.length > 0 && options.fsScopeAllowlist && allPathsAllowlisted(outsideRepoPaths, options.fsScopeAllowlist)) {
4070
4181
  return {
@@ -4743,7 +4854,15 @@ async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
4743
4854
  await deps.writeApprovals(approved.filePath, approved.state);
4744
4855
  return null;
4745
4856
  }
4746
- const [approval] = approved.state.approvals.splice(index, 1);
4857
+ const approval = approved.state.approvals[index];
4858
+ if (approval.executionLeaseExpiresAt) {
4859
+ await deps.writeApprovals(approved.filePath, approved.state);
4860
+ return approval;
4861
+ }
4862
+ approved.state.approvals[index] = {
4863
+ ...approval,
4864
+ executionLeaseExpiresAt: new Date(Date.now() + APPROVAL_EXECUTION_LEASE_MS).toISOString()
4865
+ };
4747
4866
  await deps.writeApprovals(approved.filePath, approved.state);
4748
4867
  return approval;
4749
4868
  }
@@ -4859,8 +4978,8 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
4859
4978
  predictedAssessment: auditExtras.predictedAssessment,
4860
4979
  observedAssessment: auditExtras.observedAssessment,
4861
4980
  mode: ctx.config.mode,
4862
- schemaVersion: result.v2 ? 2 : 1,
4863
- ...result.v2 ?? {},
4981
+ schemaVersion: result.axes ? 2 : 1,
4982
+ ...result.axes ?? {},
4864
4983
  ...auditExtras.transactionalLayer
4865
4984
  };
4866
4985
  if (result.reason === TRANSACTIONAL_ALREADY_APPLIED) {