@guilz-dev/belay 0.6.0 → 0.8.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 (183) hide show
  1. package/README.md +39 -20
  2. package/dist/adapters/claude/runtime-entry.js +54 -25
  3. package/dist/adapters/shared/gate-runtime.d.ts +7 -0
  4. package/dist/adapters/shared/gate-runtime.js +443 -225
  5. package/dist/bundle/claude-runtime.mjs +13027 -7271
  6. package/dist/bundle/codex-runtime.mjs +12700 -6977
  7. package/dist/bundle/cursor-runtime.mjs +12695 -6977
  8. package/dist/cli.js +37 -1
  9. package/dist/commands/approve.js +34 -10
  10. package/dist/commands/doctor.js +58 -0
  11. package/dist/commands/dogfood.js +1 -1
  12. package/dist/commands/explain.js +2 -2
  13. package/dist/commands/harvest.d.ts +0 -1
  14. package/dist/commands/harvest.js +1 -4
  15. package/dist/commands/metrics.js +6 -0
  16. package/dist/commands/quality.js +1 -3
  17. package/dist/commands/recovery-checkpoints.d.ts +164 -0
  18. package/dist/commands/recovery-checkpoints.js +347 -0
  19. package/dist/commands/revoke.js +19 -7
  20. package/dist/commands/status.js +7 -0
  21. package/dist/config-io.js +49 -18
  22. package/dist/conformance/guarantee-table.js +137 -9
  23. package/dist/conformance/types.d.ts +2 -0
  24. package/dist/core/approval-replay.d.ts +1 -1
  25. package/dist/core/approval-replay.js +4 -1
  26. package/dist/core/approval-service.d.ts +6 -1
  27. package/dist/core/approval-service.js +86 -27
  28. package/dist/core/approval.d.ts +2 -0
  29. package/dist/core/approval.js +6 -3
  30. package/dist/core/audit-metrics.d.ts +1 -0
  31. package/dist/core/audit-metrics.js +12 -4
  32. package/dist/core/audit-types.d.ts +16 -0
  33. package/dist/core/capability/approval-state-mutation.d.ts +19 -0
  34. package/dist/core/capability/approval-state-mutation.js +76 -3
  35. package/dist/core/capability/approval-v3.d.ts +18 -1
  36. package/dist/core/capability/approval-v3.js +63 -23
  37. package/dist/core/capability/attestation.d.ts +3 -0
  38. package/dist/core/capability/attestation.js +7 -0
  39. package/dist/core/capability/boundary-driver-container.d.ts +12 -0
  40. package/dist/core/capability/boundary-driver-container.js +101 -34
  41. package/dist/core/capability/boundary-driver.js +1 -0
  42. package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
  43. package/dist/core/capability/boundary-grant-materialize.js +15 -0
  44. package/dist/core/capability/boundary-run.d.ts +25 -0
  45. package/dist/core/capability/boundary-run.js +37 -9
  46. package/dist/core/capability/boundary-session.d.ts +4 -0
  47. package/dist/core/capability/boundary-session.js +9 -0
  48. package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
  49. package/dist/core/capability/boundary-workspace-mount.js +68 -0
  50. package/dist/core/capability/gate-policy-shadow.js +23 -1
  51. package/dist/core/capability/grant-consumption.d.ts +4 -0
  52. package/dist/core/capability/grant-consumption.js +11 -0
  53. package/dist/core/capability/grant-lease.d.ts +36 -1
  54. package/dist/core/capability/grant-lease.js +213 -16
  55. package/dist/core/capability/grant-match.js +3 -0
  56. package/dist/core/capability/index.d.ts +2 -2
  57. package/dist/core/capability/index.js +1 -1
  58. package/dist/core/capability/policy-engine.d.ts +38 -1
  59. package/dist/core/capability/policy-engine.js +388 -15
  60. package/dist/core/capability/request.d.ts +12 -0
  61. package/dist/core/capability/resolver.d.ts +0 -1
  62. package/dist/core/capability/resolver.js +0 -1
  63. package/dist/core/config.d.ts +20 -0
  64. package/dist/core/config.js +62 -2
  65. package/dist/core/effect-ir/audit.d.ts +9 -0
  66. package/dist/core/effect-ir/audit.js +87 -0
  67. package/dist/core/effect-ir/build.d.ts +31 -0
  68. package/dist/core/effect-ir/build.js +281 -0
  69. package/dist/core/effect-ir/index.d.ts +13 -0
  70. package/dist/core/effect-ir/index.js +9 -0
  71. package/dist/core/effect-ir/normalize.d.ts +4 -0
  72. package/dist/core/effect-ir/normalize.js +110 -0
  73. package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
  74. package/dist/core/effect-ir/package-exec-eval.js +152 -0
  75. package/dist/core/effect-ir/package-exec.d.ts +29 -0
  76. package/dist/core/effect-ir/package-exec.js +292 -0
  77. package/dist/core/effect-ir/policy.d.ts +14 -0
  78. package/dist/core/effect-ir/policy.js +164 -0
  79. package/dist/core/effect-ir/shell-build.d.ts +43 -0
  80. package/dist/core/effect-ir/shell-build.js +77 -0
  81. package/dist/core/effect-ir/shell-lower.d.ts +14 -0
  82. package/dist/core/effect-ir/shell-lower.js +1509 -0
  83. package/dist/core/effect-ir/types.d.ts +62 -0
  84. package/dist/core/effect-ir/types.js +1 -0
  85. package/dist/core/egress/allowlist.d.ts +1 -0
  86. package/dist/core/egress/allowlist.js +8 -0
  87. package/dist/core/egress-approval.js +45 -51
  88. package/dist/core/gate-contract.d.ts +2 -0
  89. package/dist/core/gate-contract.js +2 -0
  90. package/dist/core/gate-engine.js +82 -25
  91. package/dist/core/git-resource-identity.d.ts +26 -0
  92. package/dist/core/git-resource-identity.js +284 -0
  93. package/dist/core/harvest.d.ts +3 -7
  94. package/dist/core/harvest.js +4 -27
  95. package/dist/core/index.d.ts +5 -1
  96. package/dist/core/index.js +3 -1
  97. package/dist/core/judge-cloud-consent.js +29 -15
  98. package/dist/core/network-endpoint.d.ts +10 -0
  99. package/dist/core/network-endpoint.js +51 -0
  100. package/dist/core/path-utils.js +42 -23
  101. package/dist/core/process-runner.d.ts +10 -0
  102. package/dist/core/process-runner.js +124 -0
  103. package/dist/core/recover-advice.js +2 -1
  104. package/dist/core/recovery/artifact-store.d.ts +29 -0
  105. package/dist/core/recovery/artifact-store.js +336 -0
  106. package/dist/core/recovery/checkpoint.d.ts +25 -0
  107. package/dist/core/recovery/checkpoint.js +260 -0
  108. package/dist/core/recovery/index.d.ts +3 -1
  109. package/dist/core/recovery/index.js +2 -0
  110. package/dist/core/recovery/operator-guidance.d.ts +7 -0
  111. package/dist/core/recovery/operator-guidance.js +39 -0
  112. package/dist/core/recovery/reconcile.d.ts +3 -0
  113. package/dist/core/recovery/reconcile.js +48 -0
  114. package/dist/core/recovery/resource-identity.d.ts +4 -0
  115. package/dist/core/recovery/resource-identity.js +34 -0
  116. package/dist/core/recovery/restore.d.ts +13 -0
  117. package/dist/core/recovery/restore.js +114 -0
  118. package/dist/core/recovery/snapshot-node.d.ts +27 -0
  119. package/dist/core/recovery/snapshot-node.js +261 -0
  120. package/dist/core/recovery/types.d.ts +85 -1
  121. package/dist/core/shell-substitution.d.ts +1 -0
  122. package/dist/core/shell-substitution.js +61 -0
  123. package/dist/core/standing-allow.d.ts +1 -2
  124. package/dist/core/standing-allow.js +3 -20
  125. package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
  126. package/dist/core/transactional/apply-observed-changes.js +275 -0
  127. package/dist/core/transactional/backend-selector.d.ts +8 -0
  128. package/dist/core/transactional/backend-selector.js +118 -0
  129. package/dist/core/transactional/backend.d.ts +55 -0
  130. package/dist/core/transactional/backend.js +1 -0
  131. package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
  132. package/dist/core/transactional/file-checkpoint-backend.js +278 -0
  133. package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
  134. package/dist/core/transactional/file-checkpoint-git.js +217 -0
  135. package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
  136. package/dist/core/transactional/file-checkpoint-staging.js +54 -0
  137. package/dist/core/transactional/file-clone.d.ts +15 -0
  138. package/dist/core/transactional/file-clone.js +139 -0
  139. package/dist/core/transactional/file-tree-path.d.ts +7 -0
  140. package/dist/core/transactional/file-tree-path.js +50 -0
  141. package/dist/core/transactional/file-tree.d.ts +41 -0
  142. package/dist/core/transactional/file-tree.js +186 -0
  143. package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
  144. package/dist/core/transactional/git-worktree-backend.js +50 -0
  145. package/dist/core/transactional/git-worktree.d.ts +9 -11
  146. package/dist/core/transactional/git-worktree.js +24 -119
  147. package/dist/core/transactional/index.d.ts +11 -0
  148. package/dist/core/transactional/index.js +10 -0
  149. package/dist/core/transactional/reasons.js +1 -0
  150. package/dist/core/transactional/runner.js +157 -25
  151. package/dist/core/transactional/snapshot-node.d.ts +26 -0
  152. package/dist/core/transactional/snapshot-node.js +87 -0
  153. package/dist/core/transactional/types.d.ts +17 -1
  154. package/dist/core/types.d.ts +13 -1
  155. package/dist/core/verdict/adapter.js +23 -15
  156. package/dist/core/verdict/containment.js +4 -0
  157. package/dist/core/verdict/egress-classify.d.ts +12 -0
  158. package/dist/core/verdict/egress-classify.js +746 -0
  159. package/dist/core/verdict/git-classifier.d.ts +12 -0
  160. package/dist/core/verdict/git-classifier.js +320 -0
  161. package/dist/core/verdict/launcher-resolve.js +79 -37
  162. package/dist/core/verdict/parser.d.ts +2 -0
  163. package/dist/core/verdict/parser.js +95 -1
  164. package/dist/core/verdict/types.d.ts +6 -2
  165. package/dist/core/verdict/verdict.js +103 -935
  166. package/dist/corpus/must-allow-commands.d.ts +2 -1
  167. package/dist/corpus/must-allow-commands.js +2 -1
  168. package/dist/corpus/runtime-match.d.ts +2 -1
  169. package/dist/corpus/runtime-match.js +2 -1
  170. package/dist/corpus/types.d.ts +2 -2
  171. package/dist/templates.js +7 -2
  172. package/dist/types.d.ts +11 -0
  173. package/dist/version.d.ts +1 -1
  174. package/dist/version.js +1 -1
  175. package/package.json +2 -2
  176. package/skills/belay/SKILL.md +8 -3
  177. package/skills/belay/belay-approve.md +10 -7
  178. package/dist/core/verdict/overrides.d.ts +0 -7
  179. package/dist/core/verdict/overrides.js +0 -37
  180. package/dist/core/verdict/shell-policy.d.ts +0 -25
  181. package/dist/core/verdict/shell-policy.js +0 -40
  182. package/dist/corpus/standing-allow-catalog.generated.d.ts +0 -13
  183. package/dist/corpus/standing-allow-catalog.generated.js +0 -99
@@ -0,0 +1,62 @@
1
+ import type { CapabilityAction, CapabilityEvidenceLevel, CapabilityResource } from '../capability/request.js';
2
+ import type { VerdictOpacity } from '../verdict/types.js';
3
+ export declare const EFFECT_PLAN_VERSION: 1;
4
+ export type EffectTag = 'fs.read' | 'fs.write' | 'process.exec' | 'network.connect' | 'network.acquire' | 'git.ref.write' | 'secret.read' | 'control_plane.write' | 'read_only' | 'indeterminate';
5
+ export type LauncherPhase = 'invoke' | 'resolve' | 'acquire' | 'cache_read' | 'cache_write' | 'delegate';
6
+ export type PackageExecLauncher = 'npx' | 'npm' | 'pnpm';
7
+ export type EffectPlanDisposition = 'effects' | 'effect_free';
8
+ export type AnalysisCompleteness = 'complete' | 'partial';
9
+ export interface EffectPlanPolicyProjection {
10
+ permission: 'allow' | 'ask';
11
+ hookVerdict: 'allow' | 'allow_flagged' | 'deny_pending_approval';
12
+ reason: string;
13
+ }
14
+ export interface EffectEvidence {
15
+ level: CapabilityEvidenceLevel;
16
+ signals: readonly string[];
17
+ basis: readonly string[];
18
+ }
19
+ export interface EffectProvenance {
20
+ segment?: string;
21
+ launcher?: PackageExecLauncher;
22
+ phase?: LauncherPhase;
23
+ innerCommand?: string;
24
+ innerArgv?: readonly string[];
25
+ }
26
+ export interface EffectRequirement {
27
+ tag: EffectTag;
28
+ action: CapabilityAction;
29
+ resource: CapabilityResource;
30
+ evidence: EffectEvidence;
31
+ provenance: EffectProvenance;
32
+ /** All contributing sources after requirement de-duplication. */
33
+ provenances?: readonly EffectProvenance[];
34
+ }
35
+ export interface ExecEffectNode {
36
+ kind: 'exec';
37
+ commandRedacted: string;
38
+ segmentHead: string;
39
+ requirements: readonly EffectRequirement[];
40
+ }
41
+ export interface LauncherEffectNode {
42
+ kind: 'launcher';
43
+ launcher: PackageExecLauncher | 'npm' | 'pnpm' | 'make';
44
+ phase: LauncherPhase;
45
+ opaque: boolean;
46
+ reason: string;
47
+ children: readonly EffectNode[];
48
+ }
49
+ export interface MergeEffectNode {
50
+ kind: 'merge';
51
+ children: readonly EffectNode[];
52
+ }
53
+ export type EffectNode = ExecEffectNode | LauncherEffectNode | MergeEffectNode;
54
+ export interface EffectPlan {
55
+ version: typeof EFFECT_PLAN_VERSION;
56
+ root: EffectNode;
57
+ inputFingerprint: string;
58
+ opacity: VerdictOpacity;
59
+ disposition: EffectPlanDisposition;
60
+ completeness: AnalysisCompleteness;
61
+ signals: readonly string[];
62
+ }
@@ -0,0 +1 @@
1
+ export const EFFECT_PLAN_VERSION = 1;
@@ -3,5 +3,6 @@ import type { EgressAllowlistEntry, EgressAllowlistFile } from './types.js';
3
3
  export declare function egressAllowlistPath(config: BelayConfigV3, repoLocalStateDir: string): string;
4
4
  export declare function loadEgressAllowlist(filePath: string): Promise<EgressAllowlistFile>;
5
5
  export declare function saveEgressAllowlist(filePath: string, state: EgressAllowlistFile): Promise<void>;
6
+ export declare function mutateEgressAllowlist(filePath: string, mutate: (state: EgressAllowlistFile) => EgressAllowlistFile): Promise<EgressAllowlistFile>;
6
7
  export declare function isHostAllowlisted(host: string, allowlist: EgressAllowlistFile): boolean;
7
8
  export declare function addDomainToAllowlist(allowlist: EgressAllowlistFile, entry: EgressAllowlistEntry): EgressAllowlistFile;
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { withStateFileLock } from '../capability/approval-state-mutation.js';
4
5
  import { belayStateDir } from '../config.js';
5
6
  export function egressAllowlistPath(config, repoLocalStateDir) {
6
7
  return path.join(belayStateDir(config, repoLocalStateDir), 'egress-allowlist.json');
@@ -19,6 +20,13 @@ export async function saveEgressAllowlist(filePath, state) {
19
20
  await mkdir(path.dirname(filePath), { recursive: true });
20
21
  await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
21
22
  }
23
+ export async function mutateEgressAllowlist(filePath, mutate) {
24
+ return withStateFileLock(filePath, async () => {
25
+ const updated = mutate(await loadEgressAllowlist(filePath));
26
+ await saveEgressAllowlist(filePath, updated);
27
+ return updated;
28
+ });
29
+ }
22
30
  export function isHostAllowlisted(host, allowlist) {
23
31
  const normalized = host.toLowerCase();
24
32
  return allowlist.domains.some((entry) => entry.host.toLowerCase() === normalized);
@@ -1,23 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { compactApprovals, createApprovalRecord } from './approval.js';
3
3
  import { issueApprovalToken } from './approval-token.js';
4
+ import { mutateApprovalStateWithRetry } from './capability/approval-state-mutation.js';
4
5
  import { configuredControlPlaneDir } from './config.js';
5
- import { addDomainToAllowlist, loadEgressAllowlist, saveEgressAllowlist, } from './egress/allowlist.js';
6
+ import { addDomainToAllowlist, mutateEgressAllowlist } from './egress/allowlist.js';
6
7
  import { parseHostFromSummary } from './egress/fingerprint.js';
7
8
  import { notifyDeny } from './notify.js';
8
9
  export async function ensurePendingEgressApproval(params) {
9
10
  const { config, repoRoot, policyResult, store } = params;
10
- const pending = await store.loadPending();
11
- pending.state = compactApprovals(pending.state);
12
- const existing = pending.state.approvals.find((approval) => approval.kind === 'egress' &&
13
- approval.fingerprint === policyResult.fingerprint &&
14
- approval.repoRoot === repoRoot);
15
- if (existing) {
16
- await store.writePending(pending.filePath, pending.state);
17
- return { approvalId: existing.approvalId, approval: existing, created: false };
18
- }
19
11
  const approvalId = `belay_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
20
- const approval = createApprovalRecord({
12
+ const candidate = createApprovalRecord({
21
13
  kind: 'egress',
22
14
  fingerprint: policyResult.fingerprint,
23
15
  repoRoot,
@@ -26,44 +18,47 @@ export async function ensurePendingEgressApproval(params) {
26
18
  approvalTtlMinutes: config.approvalTtlMinutes,
27
19
  approvalId,
28
20
  });
29
- pending.state.approvals.push(approval);
30
- await store.writePending(pending.filePath, pending.state);
31
- return { approvalId, approval, created: true };
32
- }
33
- const consumeLocks = new Map();
34
- async function withConsumeLock(key, fn) {
35
- const previous = consumeLocks.get(key) ?? Promise.resolve();
36
- let release;
37
- const gate = new Promise((resolve) => {
38
- release = resolve;
21
+ const outcome = await mutateApprovalStateWithRetry({
22
+ load: store.loadPending,
23
+ write: store.writePending,
24
+ mutate: (state) => {
25
+ const compacted = compactApprovals(state);
26
+ const existing = compacted.approvals.find((approval) => approval.kind === 'egress' &&
27
+ approval.fingerprint === policyResult.fingerprint &&
28
+ approval.repoRoot === repoRoot);
29
+ if (existing) {
30
+ return {
31
+ state: compacted,
32
+ result: { approvalId: existing.approvalId, approval: existing, created: false },
33
+ };
34
+ }
35
+ compacted.approvals.push(candidate);
36
+ return {
37
+ state: compacted,
38
+ result: { approvalId, approval: candidate, created: true },
39
+ };
40
+ },
39
41
  });
40
- consumeLocks.set(key, previous.then(() => gate));
41
- await previous;
42
- try {
43
- return await fn();
44
- }
45
- finally {
46
- release();
47
- if (consumeLocks.get(key) === gate) {
48
- consumeLocks.delete(key);
49
- }
42
+ if (!outcome) {
43
+ throw new Error('Failed to persist pending egress approval');
50
44
  }
45
+ return outcome;
51
46
  }
52
47
  export async function consumeApprovedEgress(params) {
53
- const lockKey = `${params.repoRoot}:${params.fingerprint}`;
54
- return withConsumeLock(lockKey, async () => {
55
- const approved = await params.store.loadApproved();
56
- approved.state = compactApprovals(approved.state);
57
- const index = approved.state.approvals.findIndex((approval) => approval.kind === 'egress' &&
58
- approval.fingerprint === params.fingerprint &&
59
- approval.repoRoot === params.repoRoot);
60
- if (index === -1) {
61
- await params.store.writeApproved(approved.filePath, approved.state);
62
- return null;
63
- }
64
- const [approval] = approved.state.approvals.splice(index, 1);
65
- await params.store.writeApproved(approved.filePath, approved.state);
66
- return approval;
48
+ return mutateApprovalStateWithRetry({
49
+ load: params.store.loadApproved,
50
+ write: params.store.writeApproved,
51
+ mutate: (state) => {
52
+ const compacted = compactApprovals(state);
53
+ const index = compacted.approvals.findIndex((approval) => approval.kind === 'egress' &&
54
+ approval.fingerprint === params.fingerprint &&
55
+ approval.repoRoot === params.repoRoot);
56
+ if (index === -1) {
57
+ return null;
58
+ }
59
+ const [approval] = compacted.approvals.splice(index, 1);
60
+ return { state: compacted, result: approval ?? null };
61
+ },
67
62
  });
68
63
  }
69
64
  export async function notifyEgressDeny(params) {
@@ -98,8 +93,8 @@ export async function recordEgressApproval(params) {
98
93
  const { recordApproval } = await import('./approval-service.js');
99
94
  const pending = await params.store.loadPending();
100
95
  const match = pending.state.approvals.find((approval) => approval.approvalId === params.approvalId);
101
- const host = match ? parseHostFromSummary(match.summary) : null;
102
- if (params.scope === 'domain' && match && !host) {
96
+ const pendingHost = match ? parseHostFromSummary(match.summary) : null;
97
+ if (params.scope === 'domain' && match && !pendingHost) {
103
98
  return {
104
99
  ok: false,
105
100
  message: `Cannot add domain to egress allowlist: could not parse host from summary "${match.summary}".`,
@@ -112,16 +107,15 @@ export async function recordEgressApproval(params) {
112
107
  requireSignedToken: params.requireSignedToken ?? false,
113
108
  store: params.store,
114
109
  });
110
+ const host = result.approval ? parseHostFromSummary(result.approval.summary) : pendingHost;
115
111
  if (!result.ok || params.scope !== 'domain' || !host) {
116
112
  return { ok: result.ok, message: result.message };
117
113
  }
118
- const allowlist = await loadEgressAllowlist(params.store.allowlistPath);
119
- const updated = addDomainToAllowlist(allowlist, {
114
+ await mutateEgressAllowlist(params.store.allowlistPath, (allowlist) => addDomainToAllowlist(allowlist, {
120
115
  host,
121
116
  approvedAt: new Date().toISOString(),
122
117
  approvalId: params.approvalId,
123
- });
124
- await saveEgressAllowlist(params.store.allowlistPath, updated);
118
+ }));
125
119
  return {
126
120
  ok: true,
127
121
  message: `${result.message} Domain ${host} added to egress allowlist.`,
@@ -31,6 +31,8 @@ export interface GateVerdict extends GatePermissionResponse {
31
31
  axes?: ClassifyResult['axes'];
32
32
  capabilityRequests?: ClassifyResult['capabilityRequests'];
33
33
  authorizationDecision?: ClassifyResult['authorizationDecision'];
34
+ effectPlan?: ClassifyResult['effectPlan'];
35
+ effectPlanPolicyDecisions?: ClassifyResult['effectPlanPolicyDecisions'];
34
36
  boundaryProfile?: string;
35
37
  }
36
38
  export declare function isGatedAction(value: unknown): value is GatedAction;
@@ -28,6 +28,8 @@ export function classifyResultToGateVerdict(params) {
28
28
  axes: result.axes,
29
29
  capabilityRequests: result.capabilityRequests,
30
30
  authorizationDecision: result.authorizationDecision,
31
+ effectPlan: result.effectPlan,
32
+ effectPlanPolicyDecisions: result.effectPlanPolicyDecisions,
31
33
  boundaryProfile: result.boundaryProfile,
32
34
  };
33
35
  }
@@ -4,9 +4,12 @@ import { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, } from
4
4
  import { classifySubagent } from './classify-subagent.js';
5
5
  import { classifyToolUse } from './classify-tool.js';
6
6
  import { classifierOptionsFromConfig } from './config.js';
7
+ import { buildCapabilityEffectPlan } from './effect-ir/build.js';
8
+ import { evaluateEffectPlanPolicy } from './effect-ir/policy.js';
9
+ import { buildShellEffectPlan } from './effect-ir/shell-build.js';
7
10
  import { GATE_CONTRACT_VERSION } from './gate-contract.js';
8
11
  import { mergeAgentAssessment } from './judgment.js';
9
- import { classifyShell } from './verdict/adapter.js';
12
+ import { buildVerdictContext, classifyShell } from './verdict/adapter.js';
10
13
  export class GateNormalizationError extends Error {
11
14
  reason = 'normalization_failed';
12
15
  constructor(message) {
@@ -167,16 +170,6 @@ function applyFsScopePeripheralPolicy(outsideRepoPaths, result, options) {
167
170
  }
168
171
  return result;
169
172
  }
170
- function applySandboxOutsideBoundary(command, action, result, options) {
171
- const outsideRepoPaths = collectOutsideRepoPaths(command, action.cwd, action.repoRoot, options.trustedWorkspaceRoots);
172
- return applySandboxFsScopeBoundary(outsideRepoPaths, result, options, {
173
- redirect: command.includes('>'),
174
- });
175
- }
176
- function applyShellPeripheralPolicy(command, action, result, options) {
177
- const outsideRepoPaths = collectOutsideRepoPaths(command, action.cwd, action.repoRoot, options.trustedWorkspaceRoots);
178
- return applyFsScopePeripheralPolicy(outsideRepoPaths, result, options);
179
- }
180
173
  function outsideRepoPathsForToolAction(action, options) {
181
174
  const payload = action.payload ?? {};
182
175
  const paths = new Set(collectOutsideRepoPathsFromToolPayload(payload, action.cwd, action.repoRoot, options.trustedWorkspaceRoots));
@@ -198,50 +191,114 @@ function applyToolSandboxPolicies(action, result, options) {
198
191
  return next;
199
192
  }
200
193
  export async function classifyGatedAction(action, config, extraOptions = {}) {
194
+ const options = { ...classifierOptionsFromConfig(config), ...extraOptions };
201
195
  const limitResult = checkGatedActionLimits(action);
202
196
  if (limitResult) {
203
- return limitResult;
197
+ if (action.kind === 'shell') {
198
+ return projectShellLimitResult(action, limitResult, config, options);
199
+ }
200
+ return attachEffectPlan(action, limitResult);
204
201
  }
205
- const options = { ...classifierOptionsFromConfig(config), ...extraOptions };
206
202
  if (action.kind === 'shell') {
207
203
  const command = action.command ?? shellCommandFromPayload(action.payload ?? {});
208
204
  if (!command) {
209
205
  throw new GateNormalizationError('Shell gated action requires a command.');
210
206
  }
211
- let result = await classifyShell(command, action.cwd, action.repoRoot, config, options);
212
- result = applySandboxOutsideBoundary(command, action, result, options);
213
- result = applyShellPeripheralPolicy(command, action, result, options);
207
+ const result = await classifyShell(command, action.cwd, action.repoRoot, config, options);
214
208
  if (!action.agentAssessment) {
215
209
  return result;
216
210
  }
217
211
  const merged = mergeAgentAssessment(result.assessment, action.agentAssessment);
218
- if (!merged.mismatch) {
219
- return { ...result, assessment: merged.assessment };
220
- }
221
212
  return {
222
213
  ...result,
223
- verdict: 'deny_pending_approval',
224
- reason: 'agent_assessment_mismatch',
225
214
  assessment: merged.assessment,
226
215
  };
227
216
  }
228
217
  if (action.kind === 'subagent') {
229
- return classifySubagent(action.payload ?? {}, action.repoRoot, options, config);
218
+ return attachEffectPlan(action, classifySubagent(action.payload ?? {}, action.repoRoot, options, config));
230
219
  }
231
220
  let result = await classifyToolUse(action.payload ?? {}, action.repoRoot, action.cwd, config, options);
232
221
  result = applyToolSandboxPolicies(action, result, options);
233
222
  if (!action.agentAssessment) {
234
- return result;
223
+ return attachEffectPlan(action, result);
235
224
  }
236
225
  const merged = mergeAgentAssessment(result.assessment, action.agentAssessment);
237
226
  if (!merged.mismatch) {
238
- return { ...result, assessment: merged.assessment };
227
+ return attachEffectPlan(action, { ...result, assessment: merged.assessment });
239
228
  }
240
- return {
229
+ return attachEffectPlan(action, {
241
230
  ...result,
242
231
  verdict: 'deny_pending_approval',
243
232
  reason: 'agent_assessment_mismatch',
244
233
  assessment: merged.assessment,
234
+ });
235
+ }
236
+ function projectShellLimitResult(action, result, config, options) {
237
+ const summary = result.summary ?? 'oversized shell input';
238
+ const effectPlan = buildShellEffectPlan({
239
+ inputFingerprint: result.fingerprint,
240
+ segments: [
241
+ {
242
+ commandRedacted: summary,
243
+ segmentHead: 'input-limit',
244
+ requirements: [
245
+ {
246
+ tag: 'indeterminate',
247
+ action: 'indeterminate',
248
+ resource: { kind: 'unknown' },
249
+ evidence: {
250
+ level: 'indeterminate',
251
+ signals: ['input_too_large', 'analysis_budget_exceeded'],
252
+ basis: ['gate:input_limit'],
253
+ },
254
+ provenance: { segment: summary },
255
+ },
256
+ ],
257
+ completeness: 'partial',
258
+ opacity: 'opaque',
259
+ signals: ['input_too_large', 'analysis_budget_exceeded'],
260
+ },
261
+ ],
262
+ });
263
+ const policy = evaluateEffectPlanPolicy(effectPlan, buildVerdictContext({
264
+ cwd: action.cwd,
265
+ repoRoot: action.repoRoot,
266
+ config,
267
+ options,
268
+ }));
269
+ return {
270
+ ...result,
271
+ verdict: policy.projection.hookVerdict,
272
+ axes: {
273
+ location: 'unknown',
274
+ opacity: effectPlan.opacity,
275
+ effect: 'unknown',
276
+ confidence: 'deterministic',
277
+ would: policy.projection.permission,
278
+ by: 'effect_plan',
279
+ signals: [...effectPlan.signals],
280
+ },
281
+ capabilityRequests: policy.capabilityRequests,
282
+ authorizationDecision: policy.authorizationDecision,
283
+ effectPlan,
284
+ effectPlanPolicyDecisions: policy.decisions,
285
+ effectPlanProjection: policy.projection,
286
+ };
287
+ }
288
+ function attachEffectPlan(action, result) {
289
+ if (result.effectPlan) {
290
+ return result;
291
+ }
292
+ const summary = result.normalizedCommand ?? result.summary ?? action.command ?? action.kind;
293
+ return {
294
+ ...result,
295
+ effectPlan: buildCapabilityEffectPlan({
296
+ actionKind: action.kind,
297
+ summary,
298
+ inputFingerprint: result.fingerprint,
299
+ requests: result.capabilityRequests ?? [],
300
+ effectFree: result.reason === 'read_only',
301
+ }),
245
302
  };
246
303
  }
247
304
  export async function classifyGatedActionAsync(action, config, extraOptions = {}) {
@@ -0,0 +1,26 @@
1
+ export interface GitResourceIdentity {
2
+ repositoryRoot: string;
3
+ gitDir: string;
4
+ commonDir: string;
5
+ gitEntryPath: string;
6
+ }
7
+ export type GitResourceIdentityInspection = {
8
+ status: 'resolved';
9
+ identity: GitResourceIdentity;
10
+ } | {
11
+ status: 'absent';
12
+ } | {
13
+ status: 'invalid';
14
+ boundaryPath: string;
15
+ metadataRoots: string[];
16
+ };
17
+ /**
18
+ * Inspects the Git boundary containing a path without executing Git or a shell.
19
+ * Malformed or unreadable metadata is distinct from an absent Git boundary.
20
+ */
21
+ export declare function inspectGitResourceIdentity(targetPath: string): GitResourceIdentityInspection;
22
+ /** Resolves only structurally valid Git metadata; otherwise returns null. */
23
+ export declare function resolveGitResourceIdentity(targetPath: string): GitResourceIdentity | null;
24
+ export declare function sameGitResourceIdentity(leftPath: string, rightPath: string): boolean;
25
+ /** Protects control data for any proven repository and any malformed Git boundary. */
26
+ export declare function isGitMetadataPath(targetPath: string, _repositoryPath?: string): boolean;