@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
@@ -1,12 +1,200 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import path from 'node:path';
3
+ import { isGitMetadataPath } from '../git-resource-identity.js';
3
4
  import { matchesSensitivePath } from '../glob.js';
5
+ import { parseNetworkEndpoint } from '../network-endpoint.js';
4
6
  import { canonicalPath, pathWithinRoot, resolveWorkspaceRootMatch } from '../path-utils.js';
7
+ import { tokenizeShell } from '../shell-tokenizer.js';
5
8
  import { BOUNDARY_GRANT_ISSUER_CONTAINER, isPathWithinBoundaryMount, materializeContainerBoundaryGrant, } from './boundary-grant-materialize.js';
6
9
  import { boundaryVerifiedAllowEnabled } from './boundary-profile.js';
7
10
  import { isGrantScopeTooBroad } from './grant.js';
8
11
  import { broadGrantTargetsRequest, grantMatchesRequest } from './grant-match.js';
9
12
  import { CAPABILITY_REQUEST_VERSION, } from './request.js';
13
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '0:0:0:0:0:0:0:1']);
14
+ const DESTRUCTIVE_EFFECT_SIGNALS = new Set([
15
+ 'git_history_destructive',
16
+ 'git.destructive_history_write',
17
+ 'git.destructive_worktree_write',
18
+ ]);
19
+ const HIGH_STAKES_EFFECT_PATHS = [
20
+ /(?:^|[/\\])(?:\.env(?:\..*)?|credentials?(?:\.json)?|secrets?|authorized_keys|id_(?:rsa|dsa|ecdsa|ed25519)|[^/\\]+\.pem|\.ssh[/\\]config|\.zshrc|\.bashrc|\.git-credentials|\.npmrc|\.netrc|\.kube[/\\]config|\.docker[/\\]config\.json|\.gnupg(?:[/\\].*)?|\.pypirc)(?:$|[/\\])/i,
21
+ /(?:^|[/\\])application_default_credentials\.json$/i,
22
+ /(?:^|[/\\])\.config[/\\]gcloud[/\\](?:application_default_credentials\.json|credentials\.db|access_tokens\.db|legacy_credentials(?:[/\\].*)?)$/i,
23
+ /(?:^|[/\\])\.aws[/\\](?:credentials|config)$/i,
24
+ /(?:^|[/\\])\.azure[/\\](?:accessTokens\.json|azureProfile\.json|msal_token_cache\.(?:bin|json)|service_principal_entries\.json)$/i,
25
+ /(?:^|[/\\])\.terraform\.d[/\\]credentials\.tfrc\.json$/i,
26
+ /(?:^|[/\\])\.config[/\\]gh[/\\]hosts\.ya?ml$/i,
27
+ /(?:^|[/\\])\.oci[/\\]config$/i,
28
+ /(?:^|[/\\])\.config[/\\]doctl[/\\]config\.ya?ml$/i,
29
+ ];
30
+ function effectDecision(effectDisposition, reason, matchedRule, signals) {
31
+ return {
32
+ effectDisposition,
33
+ outcome: effectDisposition === 'ask' ? 'require_approval' : 'allow',
34
+ reason,
35
+ signals: [...signals],
36
+ matchedRule,
37
+ };
38
+ }
39
+ function effectPathIsLocal(target, context) {
40
+ const resolved = resolveCapabilityPath(target, context.cwd);
41
+ return (resolveWorkspaceRootMatch(context.repoRoot, [...(context.trustedWorkspaceRoots ?? [])], resolved) !== null);
42
+ }
43
+ function effectPathIsHighStakes(target, context) {
44
+ const resolved = resolveCapabilityPath(target, context.cwd);
45
+ if (HIGH_STAKES_EFFECT_PATHS.some((pattern) => pattern.test(resolved))) {
46
+ return true;
47
+ }
48
+ if (context.protectedArtifactRoots?.some((root) => pathWithinRoot(canonicalPath(root), resolved))) {
49
+ return true;
50
+ }
51
+ if (isGitMetadataPath(resolved, context.repoRoot)) {
52
+ return true;
53
+ }
54
+ const workspaceMatch = resolveWorkspaceRootMatch(context.repoRoot, [...(context.trustedWorkspaceRoots ?? [])], resolved);
55
+ if (!workspaceMatch) {
56
+ return false;
57
+ }
58
+ return matchesSensitivePath(workspaceMatch.relativePath.replaceAll('\\', '/'), [
59
+ ...(context.sensitivePaths ?? []),
60
+ ]);
61
+ }
62
+ /**
63
+ * Policy for a lowered shell effect. It intentionally ignores executable
64
+ * command names, segment heads, command text, and config allowlists.
65
+ */
66
+ export function evaluateEffectRequirementPolicy(requirement, context) {
67
+ const signals = [...requirement.evidence.signals];
68
+ const capabilityRequest = context.capabilityRequest ?? {
69
+ version: CAPABILITY_REQUEST_VERSION,
70
+ principal: { repoRoot: context.repoRoot },
71
+ action: requirement.action,
72
+ resource: requirement.resource,
73
+ context: {
74
+ cwd: context.cwd,
75
+ inputFingerprint: 'effect-plan',
76
+ hookKind: 'shell',
77
+ analysisBasis: [...requirement.evidence.basis],
78
+ },
79
+ evidence: {
80
+ level: requirement.evidence.level,
81
+ signals,
82
+ },
83
+ };
84
+ if (signals.includes('forged_grant')) {
85
+ return effectDecision('ask', 'grant_forgery', 'forbid.grant_forgery', [
86
+ ...signals,
87
+ 'grant_forgery',
88
+ ]);
89
+ }
90
+ if (context.grants?.some((grant) => isGrantScopeTooBroad(grant) && broadGrantTargetsRequest(grant, capabilityRequest))) {
91
+ return effectDecision('ask', 'grant_scope_too_broad', 'forbid.broad_grant', [
92
+ ...signals,
93
+ 'grant_scope_too_broad',
94
+ ]);
95
+ }
96
+ const matchedGrant = requirement.resource.kind === 'executable'
97
+ ? null
98
+ : findFreshGrant(capabilityRequest, [...(context.grants ?? [])]);
99
+ if (matchedGrant) {
100
+ return effectDecision('allow', 'capability_grant', 'grant.exact', grantDecisionSignals(capabilityRequest, matchedGrant));
101
+ }
102
+ if (requirement.evidence.level === 'indeterminate' ||
103
+ requirement.action === 'indeterminate' ||
104
+ requirement.tag === 'indeterminate') {
105
+ return effectDecision('ask', 'indeterminate_effect', 'effect.indeterminate', [
106
+ ...signals,
107
+ 'indeterminate',
108
+ ]);
109
+ }
110
+ if (signals.some((signal) => DESTRUCTIVE_EFFECT_SIGNALS.has(signal))) {
111
+ return effectDecision('ask', 'git_history_destructive', 'effect.destructive', signals);
112
+ }
113
+ if (signals.includes('belay_control_plane_command')) {
114
+ return effectDecision('allow', 'belay_control_plane_command', 'effect.belay_control_plane_command', signals);
115
+ }
116
+ switch (requirement.action) {
117
+ case 'fs.read':
118
+ if (requirement.resource.kind === 'path' &&
119
+ effectPathIsHighStakes(requirement.resource.path, context)) {
120
+ return effectDecision('ask', 'high_stakes_path', 'effect.fs_read_high_stakes', [
121
+ ...signals,
122
+ 'sensitive_path_read',
123
+ ]);
124
+ }
125
+ return effectDecision('allow', 'read_only', 'effect.fs_read', signals);
126
+ case 'fs.write':
127
+ if (requirement.resource.kind === 'package-cache') {
128
+ return effectDecision('allow_flagged', 'repo_local_mutation', 'effect.package_cache_write', signals);
129
+ }
130
+ if (requirement.resource.kind !== 'path') {
131
+ return effectDecision('ask', 'indeterminate_effect', 'effect.fs_write_unknown', signals);
132
+ }
133
+ if (effectPathIsHighStakes(requirement.resource.path, context)) {
134
+ return effectDecision('ask', 'high_stakes_path', 'effect.fs_write_high_stakes', [
135
+ ...signals,
136
+ 'tier1_catastrophic',
137
+ ...(!effectPathIsLocal(requirement.resource.path, context)
138
+ ? ['outside_repo_secret_credential_path']
139
+ : []),
140
+ ]);
141
+ }
142
+ return effectPathIsLocal(requirement.resource.path, context)
143
+ ? effectDecision('allow_flagged', 'repo_local_mutation', 'effect.fs_write_local', signals)
144
+ : effectDecision('ask', 'outside_repo_mutation', 'effect.fs_write_outside', signals);
145
+ case 'process.exec':
146
+ if (requirement.resource.kind !== 'executable') {
147
+ return effectDecision('ask', 'indeterminate_effect', 'effect.process_unknown', signals);
148
+ }
149
+ if (requirement.resource.operation === 'inspect') {
150
+ return effectDecision('allow', 'read_only', 'effect.process_inspect', signals);
151
+ }
152
+ if (requirement.resource.operation === 'spawn') {
153
+ return effectDecision('allow_flagged', 'repo_local_mutation', 'effect.process_spawn', signals);
154
+ }
155
+ return effectDecision('ask', 'high_stakes_path', 'effect.process_signal', signals);
156
+ case 'network.connect': {
157
+ if (requirement.tag === 'network.acquire' ||
158
+ requirement.resource.kind !== 'network' ||
159
+ !requirement.resource.mode ||
160
+ !requirement.resource.payload) {
161
+ return effectDecision('ask', 'external_effect', 'effect.network_unknown', signals);
162
+ }
163
+ const loopback = LOOPBACK_HOSTS.has(requirement.resource.host.replace(/^\[|\]$/g, '').toLowerCase());
164
+ if (requirement.resource.payload === 'secret') {
165
+ return effectDecision('ask', 'high_stakes_path', 'effect.network_secret_payload', [
166
+ ...signals,
167
+ 'secret_payload_send',
168
+ ]);
169
+ }
170
+ if (loopback && requirement.resource.mode === 'mutate') {
171
+ return effectDecision('allow_flagged', 'repo_local_mutation', 'effect.network_loopback_mutation', signals);
172
+ }
173
+ if (requirement.resource.mode === 'read' && requirement.resource.payload === 'none') {
174
+ return effectDecision('allow', 'read_only', 'effect.network_read', signals);
175
+ }
176
+ return effectDecision('ask', 'external_effect', 'effect.network_remote_mutation', signals);
177
+ }
178
+ case 'git.ref.write':
179
+ if (requirement.resource.kind !== 'git-ref' || !requirement.resource.scope) {
180
+ return effectDecision('ask', 'indeterminate_effect', 'effect.git_ref_unknown', signals);
181
+ }
182
+ if (requirement.resource.scope === 'remote') {
183
+ return effectDecision('ask', 'external_effect', 'effect.git_ref_remote', signals);
184
+ }
185
+ if (requirement.resource.repoPath &&
186
+ !effectPathIsLocal(requirement.resource.repoPath, context)) {
187
+ return effectDecision('ask', 'outside_repo_mutation', 'effect.git_ref_outside', signals);
188
+ }
189
+ return effectDecision('allow_flagged', 'repo_local_mutation', 'effect.git_ref_local', signals);
190
+ case 'secret.read':
191
+ return effectDecision('ask', 'high_stakes_path', 'effect.secret_read', signals);
192
+ case 'control_plane.write':
193
+ return effectDecision('ask', 'control_plane_mutation', 'effect.control_plane_write', signals);
194
+ default:
195
+ return effectDecision('ask', 'indeterminate_effect', 'effect.unknown', signals);
196
+ }
197
+ }
10
198
  function enrichAuthWithMaterializedGrants(request, config, auth) {
11
199
  if (!auth?.attestation || !boundaryVerifiedAllowEnabled(auth.attestation)) {
12
200
  return auth;
@@ -116,21 +304,27 @@ function resourcePathForShellAnalysis(analysis) {
116
304
  const resolved = targets.map((target) => analysis.resolvedPathTargets?.length
117
305
  ? canonicalPath(target)
118
306
  : resolveCapabilityPath(target, analysis.cwd));
307
+ const [first] = resolved;
308
+ if (!first) {
309
+ return null;
310
+ }
119
311
  if (analysis.effect === 'local_mutation') {
120
312
  const outside = resolved.find((candidate) => !pathWithinRoot(repoRoot, candidate));
121
- return outside ?? resolved[resolved.length - 1];
313
+ return outside ?? resolved.at(-1) ?? first;
122
314
  }
123
- return resolved[0];
315
+ return first;
124
316
  }
125
317
  function resourceForShellAnalysis(analysis) {
126
318
  if (isGitRefWrite(analysis)) {
127
319
  return { kind: 'git-ref', ref: 'push' };
128
320
  }
129
- if (analysis.egressClass === 'ambiguous' ||
130
- analysis.egressClass === 'read' ||
131
- analysis.egressClass === 'destructive' ||
132
- analysis.location === 'external') {
133
- return { kind: 'network', host: '*', protocol: 'unknown' };
321
+ if (isNetworkShellAnalysis(analysis)) {
322
+ const exact = exactNetworkResources(analysis);
323
+ return ((exact.length === 1 ? exact[0] : undefined) ?? {
324
+ kind: 'network',
325
+ host: '*',
326
+ protocol: 'unknown',
327
+ });
134
328
  }
135
329
  const resourcePath = resourcePathForShellAnalysis(analysis);
136
330
  if (resourcePath) {
@@ -138,8 +332,114 @@ function resourceForShellAnalysis(analysis) {
138
332
  }
139
333
  return { kind: 'executable', command: analysis.segmentHead };
140
334
  }
335
+ function isNetworkShellAnalysis(analysis) {
336
+ return (analysis.egressClass === 'ambiguous' ||
337
+ analysis.egressClass === 'read' ||
338
+ analysis.egressClass === 'destructive' ||
339
+ analysis.location === 'external');
340
+ }
341
+ const SCP_STYLE_COMMANDS = new Set(['git', 'rsync', 'scp']);
342
+ const PACKAGE_SPEC_COMMANDS = new Set(['bun', 'npm', 'npx', 'pnpm', 'yarn']);
343
+ const NETWORK_VALUE_OPTIONS = new Set(['--registry', '--url']);
344
+ const GIT_OPTIONS_WITH_VALUE = new Set([
345
+ '-C',
346
+ '-b',
347
+ '-c',
348
+ '-o',
349
+ '--branch',
350
+ '--config',
351
+ '--depth',
352
+ '--filter',
353
+ '--git-dir',
354
+ '--namespace',
355
+ '--origin',
356
+ '--reference',
357
+ '--separate-git-dir',
358
+ '--upload-pack',
359
+ '--work-tree',
360
+ ]);
361
+ function gitRemoteOperand(tokens) {
362
+ let cursor = 1;
363
+ while (cursor < tokens.length) {
364
+ const token = tokens[cursor];
365
+ if (!token) {
366
+ return null;
367
+ }
368
+ if (GIT_OPTIONS_WITH_VALUE.has(token)) {
369
+ cursor += 2;
370
+ continue;
371
+ }
372
+ if (token.startsWith('-')) {
373
+ cursor += 1;
374
+ continue;
375
+ }
376
+ break;
377
+ }
378
+ const subcommand = tokens[cursor];
379
+ if (!subcommand) {
380
+ return null;
381
+ }
382
+ const positionals = [];
383
+ let skipOptionValue = false;
384
+ for (const token of tokens.slice(cursor + 1)) {
385
+ if (skipOptionValue) {
386
+ skipOptionValue = false;
387
+ continue;
388
+ }
389
+ if (GIT_OPTIONS_WITH_VALUE.has(token)) {
390
+ skipOptionValue = true;
391
+ continue;
392
+ }
393
+ if (token.startsWith('-')) {
394
+ continue;
395
+ }
396
+ positionals.push(token);
397
+ }
398
+ if (['clone', 'fetch', 'ls-remote', 'pull', 'push'].includes(subcommand)) {
399
+ return positionals[0] ?? null;
400
+ }
401
+ if (subcommand === 'remote' && positionals[0] === 'add') {
402
+ return positionals[2] ?? null;
403
+ }
404
+ if (subcommand === 'submodule' && positionals[0] === 'add') {
405
+ return positionals[1] ?? null;
406
+ }
407
+ return null;
408
+ }
409
+ function exactNetworkResources(analysis) {
410
+ const resources = new Map();
411
+ const shellTokens = tokenizeShell(analysis.command);
412
+ const gitRemote = analysis.segmentHead === 'git' ? gitRemoteOperand(shellTokens) : null;
413
+ for (const rawToken of shellTokens) {
414
+ const optionSeparator = rawToken.startsWith('-') ? rawToken.indexOf('=') : -1;
415
+ const optionName = optionSeparator === -1 ? null : rawToken.slice(0, optionSeparator);
416
+ const token = optionSeparator !== -1 && optionName && NETWORK_VALUE_OPTIONS.has(optionName)
417
+ ? rawToken.slice(optionSeparator + 1)
418
+ : rawToken;
419
+ const endpoint = parseNetworkEndpoint(token, {
420
+ allowHostedGitShorthand: PACKAGE_SPEC_COMMANDS.has(analysis.segmentHead),
421
+ allowScpStyle: SCP_STYLE_COMMANDS.has(analysis.segmentHead) &&
422
+ (analysis.segmentHead !== 'git' || rawToken === gitRemote),
423
+ });
424
+ if (!endpoint) {
425
+ continue;
426
+ }
427
+ const resource = { kind: 'network', ...endpoint };
428
+ resources.set(`${resource.host}:${resource.port ?? ''}:${resource.protocol}`, resource);
429
+ }
430
+ return [...resources.values()];
431
+ }
141
432
  export function buildShellCapabilityRequest(analysis) {
142
- return {
433
+ const [request] = buildShellCapabilityRequests(analysis);
434
+ if (!request) {
435
+ throw new Error('shell analysis produced no capability request');
436
+ }
437
+ return request;
438
+ }
439
+ export function buildShellCapabilityRequests(analysis) {
440
+ const resources = isNetworkShellAnalysis(analysis) ? exactNetworkResources(analysis) : [];
441
+ const selectedResources = resources.length > 0 ? resources : [resourceForShellAnalysis(analysis)];
442
+ return selectedResources.map((resource) => ({
143
443
  version: CAPABILITY_REQUEST_VERSION,
144
444
  principal: {
145
445
  adapter: analysis.adapter,
@@ -147,7 +447,7 @@ export function buildShellCapabilityRequest(analysis) {
147
447
  sessionHash: hashSession(`${analysis.repoRoot}:${analysis.cwd}`),
148
448
  },
149
449
  action: actionForShellAnalysis(analysis),
150
- resource: resourceForShellAnalysis(analysis),
450
+ resource,
151
451
  context: {
152
452
  cwd: analysis.cwd,
153
453
  inputFingerprint: analysis.inputFingerprint,
@@ -163,7 +463,7 @@ export function buildShellCapabilityRequest(analysis) {
163
463
  level: evidenceLevelForOpacity(analysis.opacity),
164
464
  signals: [...analysis.signals],
165
465
  },
166
- };
466
+ }));
167
467
  }
168
468
  function actionForFileMutation(analysis) {
169
469
  if (analysis.locationLabel === 'control_plane') {
@@ -233,6 +533,16 @@ function shellLocationFromRequest(request) {
233
533
  function isRepoLocalShellLocation(request) {
234
534
  return shellLocationFromRequest(request) === 'repo_local';
235
535
  }
536
+ function isRepoLocalPackageExec(request) {
537
+ if (request.action !== 'process.exec' || request.resource.kind !== 'executable') {
538
+ return false;
539
+ }
540
+ if (!request.evidence.signals.includes('package_exec.local_bin_resolved')) {
541
+ return false;
542
+ }
543
+ const commandPath = canonicalPath(request.resource.command);
544
+ return path.isAbsolute(commandPath) && pathWithinRoot(request.principal.repoRoot, commandPath);
545
+ }
236
546
  function isRepoLocalRoutineWrite(request, sensitivePaths) {
237
547
  if (!isRepoLocalShellLocation(request)) {
238
548
  return false;
@@ -297,6 +607,14 @@ function builtInRule(request, context) {
297
607
  matchedRule: 'builtin.subagent',
298
608
  };
299
609
  }
610
+ if (isRepoLocalPackageExec(request)) {
611
+ return {
612
+ outcome: 'allow',
613
+ reason: 'read_only',
614
+ signals: [...request.evidence.signals, 'repo_local_exec'],
615
+ matchedRule: 'builtin.repo_local_exec',
616
+ };
617
+ }
300
618
  if (request.action === 'secret.read' || request.action === 'git.ref.write') {
301
619
  return {
302
620
  outcome: 'require_approval',
@@ -456,14 +774,69 @@ export function getDefaultPolicyEngine() {
456
774
  export function policyDecisionRequiresAsk(decision) {
457
775
  return decision.outcome === 'require_approval' || decision.outcome === 'deny';
458
776
  }
777
+ function policyOutcomeRank(outcome) {
778
+ switch (outcome) {
779
+ case 'deny':
780
+ return 3;
781
+ case 'require_approval':
782
+ return 2;
783
+ case 'allow':
784
+ return 1;
785
+ default:
786
+ return 0;
787
+ }
788
+ }
789
+ export function combinePolicyDecisions(decisions) {
790
+ if (!decisions.length) {
791
+ return {
792
+ outcome: 'allow',
793
+ reason: 'read_only',
794
+ signals: [],
795
+ matchedRule: 'plan.conjunction',
796
+ };
797
+ }
798
+ const sorted = [...decisions].sort((left, right) => policyOutcomeRank(right.outcome) - policyOutcomeRank(left.outcome));
799
+ const worst = sorted[0];
800
+ if (!worst) {
801
+ return {
802
+ outcome: 'allow',
803
+ reason: 'read_only',
804
+ signals: [],
805
+ matchedRule: 'plan.conjunction',
806
+ };
807
+ }
808
+ return worst;
809
+ }
810
+ export function evaluateCapabilityRequestsPolicy(requests, config, auth, trustedWorkspaceRoots) {
811
+ let workingAuth = auth;
812
+ const decisions = [];
813
+ for (const request of requests) {
814
+ const enriched = enrichAuthWithMaterializedGrants(request, config, workingAuth);
815
+ const decision = getDefaultPolicyEngine().evaluate(request, buildAuthorizationContext(config, trustedWorkspaceRoots, enriched));
816
+ decisions.push(decision);
817
+ const previousCount = workingAuth?.grants?.length ?? 0;
818
+ const nextCount = enriched?.grants?.length ?? 0;
819
+ if (nextCount > previousCount) {
820
+ workingAuth = enriched;
821
+ }
822
+ }
823
+ return {
824
+ decisions,
825
+ decision: combinePolicyDecisions(decisions),
826
+ };
827
+ }
459
828
  export function evaluateShellPolicy(analysis, config, auth) {
460
- const request = buildShellCapabilityRequest(analysis);
461
- const enriched = enrichAuthWithMaterializedGrants(request, config, {
829
+ const requests = buildShellCapabilityRequests(analysis);
830
+ const enrichedAuth = {
462
831
  ...auth,
463
832
  sensitivePaths: auth?.sensitivePaths ?? analysis.sensitivePaths,
464
- });
465
- const decision = getDefaultPolicyEngine().evaluate(request, buildAuthorizationContext(config, analysis.trustedWorkspaceRoots, enriched));
466
- return { request, decision };
833
+ };
834
+ const { decision } = evaluateCapabilityRequestsPolicy(requests, config, enrichedAuth, analysis.trustedWorkspaceRoots);
835
+ const [request] = requests;
836
+ if (!request) {
837
+ throw new Error('shell analysis produced no capability request');
838
+ }
839
+ return { request, requests, decision };
467
840
  }
468
841
  export function evaluateFileMutationPolicy(analysis, config, auth) {
469
842
  const request = buildFileMutationCapabilityRequest(analysis);
@@ -2,6 +2,10 @@ export declare const CAPABILITY_REQUEST_VERSION: 1;
2
2
  export type CapabilityAction = 'fs.read' | 'fs.write' | 'process.exec' | 'network.connect' | 'secret.read' | 'git.ref.write' | 'control_plane.write' | 'indeterminate';
3
3
  export type CapabilityEvidenceLevel = 'certain' | 'possible' | 'indeterminate';
4
4
  export type CapabilityHookKind = 'shell' | 'tool' | 'subagent';
5
+ export type NetworkMode = 'read' | 'mutate' | 'ambiguous';
6
+ export type NetworkPayload = 'none' | 'present' | 'secret';
7
+ export type ProcessOperation = 'inspect' | 'spawn' | 'signal';
8
+ export type GitRefScope = 'local' | 'remote';
5
9
  export interface CapabilityPrincipal {
6
10
  adapter?: string;
7
11
  repoRoot: string;
@@ -15,12 +19,20 @@ export type CapabilityResource = {
15
19
  host: string;
16
20
  port?: number;
17
21
  protocol?: string;
22
+ mode?: NetworkMode;
23
+ payload?: NetworkPayload;
18
24
  } | {
19
25
  kind: 'executable';
20
26
  command: string;
27
+ operation?: ProcessOperation;
28
+ } | {
29
+ kind: 'package-cache';
30
+ manager: 'npm' | 'pnpm';
21
31
  } | {
22
32
  kind: 'git-ref';
23
33
  ref: string;
34
+ scope?: GitRefScope;
35
+ repoPath?: string;
24
36
  } | {
25
37
  kind: 'unknown';
26
38
  };
@@ -1,4 +1,3 @@
1
- export { evaluateSegmentShellPolicy, type SegmentShellPolicyInput, } from '../verdict/shell-policy.js';
2
1
  export { BOUNDARY_PROFILE_L3_POLICY, checkGatedActionLimits } from './limits.js';
3
2
  export { type CapabilityAuthorizationMetadata, policyReasonToLegacyReason, singleCapabilityMetadata, } from './policy-bridge.js';
4
3
  export { buildFileMutationCapabilityRequest, buildShellCapabilityRequest, buildSubagentCapabilityRequest, evaluateFileMutationPolicy, evaluateShellPolicy, evaluateSubagentPolicy, type FileMutationCapabilityAnalysis, policyDecisionRequiresAsk, type ShellCapabilityAnalysis, type SubagentCapabilityAnalysis, } from './policy-engine.js';
@@ -1,4 +1,3 @@
1
- export { evaluateSegmentShellPolicy, } from '../verdict/shell-policy.js';
2
1
  export { BOUNDARY_PROFILE_L3_POLICY, checkGatedActionLimits } from './limits.js';
3
2
  export { policyReasonToLegacyReason, singleCapabilityMetadata, } from './policy-bridge.js';
4
3
  export { buildFileMutationCapabilityRequest, buildShellCapabilityRequest, buildSubagentCapabilityRequest, evaluateFileMutationPolicy, evaluateShellPolicy, evaluateSubagentPolicy, policyDecisionRequiresAsk, } from './policy-engine.js';
@@ -46,6 +46,15 @@ export interface BelayModelAssistConfig {
46
46
  model?: string;
47
47
  timeoutMs?: number;
48
48
  }
49
+ export interface BelayFileCheckpointConfig {
50
+ enabled: boolean;
51
+ allowNonGit: boolean;
52
+ maxFiles: number;
53
+ maxSourceBytes: number;
54
+ maxWorkspaceBytes: number;
55
+ prepareTimeoutMs: number;
56
+ copyConcurrency: number;
57
+ }
49
58
  export interface BelayTransactionalConfig {
50
59
  enabled: boolean;
51
60
  minConfidence: number;
@@ -55,6 +64,14 @@ export interface BelayTransactionalConfig {
55
64
  gates: {
56
65
  shell: boolean;
57
66
  };
67
+ fileCheckpoint: BelayFileCheckpointConfig;
68
+ checkpoint?: {
69
+ enabled: boolean;
70
+ appliedRetentionHours: number;
71
+ restoredRetentionHours: number;
72
+ maxCheckpoints: number;
73
+ maxBytes: number;
74
+ };
58
75
  }
59
76
  export interface BelayPolicyConfig {
60
77
  unknownLocalEffect: UnknownLocalEffectPolicy;
@@ -196,6 +213,9 @@ export type BelayConfig = BelayConfigV4;
196
213
  export declare const DEFAULT_FENCE_WARN_THRESHOLD = 0.5;
197
214
  export declare const DEFAULT_CONFIDENCE_THRESHOLDS: BelayConfidenceThresholds;
198
215
  export declare const DEFAULT_MODEL_ASSIST: BelayModelAssistConfig;
216
+ export declare const DEFAULT_FILE_CHECKPOINT: BelayFileCheckpointConfig;
217
+ export declare function normalizeFileCheckpointConfig(raw: Partial<BelayFileCheckpointConfig> | undefined): BelayFileCheckpointConfig;
218
+ export declare const DEFAULT_RECOVERY_CHECKPOINT: NonNullable<BelayTransactionalConfig['checkpoint']>;
199
219
  export declare const DEFAULT_TRANSACTIONAL_V3: BelayTransactionalConfig;
200
220
  export declare const LEGACY_POLICY_V3: BelayPolicyConfig;
201
221
  /** Fresh install defaults: recoverable-first with opaque/unparseable fail-closed. */
@@ -51,6 +51,46 @@ export const DEFAULT_MODEL_ASSIST = {
51
51
  enabled: false,
52
52
  timeoutMs: 3000,
53
53
  };
54
+ export const DEFAULT_FILE_CHECKPOINT = {
55
+ enabled: false,
56
+ allowNonGit: false,
57
+ maxFiles: 100_000,
58
+ maxSourceBytes: 2_147_483_648,
59
+ maxWorkspaceBytes: 4_294_967_296,
60
+ prepareTimeoutMs: 30_000,
61
+ copyConcurrency: 8,
62
+ };
63
+ function clampCopyConcurrency(value) {
64
+ return Math.min(32, Math.max(1, Math.floor(value)));
65
+ }
66
+ export function normalizeFileCheckpointConfig(raw) {
67
+ return {
68
+ enabled: raw?.enabled === true,
69
+ allowNonGit: raw?.allowNonGit === true,
70
+ maxFiles: typeof raw?.maxFiles === 'number' && raw.maxFiles > 0
71
+ ? Math.floor(raw.maxFiles)
72
+ : DEFAULT_FILE_CHECKPOINT.maxFiles,
73
+ maxSourceBytes: typeof raw?.maxSourceBytes === 'number' && raw.maxSourceBytes > 0
74
+ ? Math.floor(raw.maxSourceBytes)
75
+ : DEFAULT_FILE_CHECKPOINT.maxSourceBytes,
76
+ maxWorkspaceBytes: typeof raw?.maxWorkspaceBytes === 'number' && raw.maxWorkspaceBytes > 0
77
+ ? Math.floor(raw.maxWorkspaceBytes)
78
+ : DEFAULT_FILE_CHECKPOINT.maxWorkspaceBytes,
79
+ prepareTimeoutMs: typeof raw?.prepareTimeoutMs === 'number' && raw.prepareTimeoutMs > 0
80
+ ? Math.floor(raw.prepareTimeoutMs)
81
+ : DEFAULT_FILE_CHECKPOINT.prepareTimeoutMs,
82
+ copyConcurrency: typeof raw?.copyConcurrency === 'number' && raw.copyConcurrency > 0
83
+ ? clampCopyConcurrency(raw.copyConcurrency)
84
+ : DEFAULT_FILE_CHECKPOINT.copyConcurrency,
85
+ };
86
+ }
87
+ export const DEFAULT_RECOVERY_CHECKPOINT = {
88
+ enabled: false,
89
+ appliedRetentionHours: 7 * 24,
90
+ restoredRetentionHours: 24,
91
+ maxCheckpoints: 20,
92
+ maxBytes: 1024 * 1024 * 1024,
93
+ };
54
94
  export const DEFAULT_TRANSACTIONAL_V3 = {
55
95
  enabled: false,
56
96
  minConfidence: DEFAULT_CONFIDENCE_THRESHOLDS.flag,
@@ -60,6 +100,8 @@ export const DEFAULT_TRANSACTIONAL_V3 = {
60
100
  gates: {
61
101
  shell: true,
62
102
  },
103
+ fileCheckpoint: { ...DEFAULT_FILE_CHECKPOINT },
104
+ checkpoint: { ...DEFAULT_RECOVERY_CHECKPOINT },
63
105
  };
64
106
  export const LEGACY_POLICY_V3 = {
65
107
  unknownLocalEffect: 'allow_flagged',
@@ -696,6 +738,26 @@ export function normalizeConfig(config) {
696
738
  gates: {
697
739
  shell: v4.policy?.transactional?.gates?.shell !== false,
698
740
  },
741
+ fileCheckpoint: normalizeFileCheckpointConfig(v4.policy?.transactional?.fileCheckpoint),
742
+ checkpoint: {
743
+ enabled: v4.policy?.transactional?.checkpoint?.enabled === true,
744
+ appliedRetentionHours: typeof v4.policy?.transactional?.checkpoint?.appliedRetentionHours === 'number' &&
745
+ v4.policy.transactional.checkpoint.appliedRetentionHours > 0
746
+ ? v4.policy.transactional.checkpoint.appliedRetentionHours
747
+ : DEFAULT_RECOVERY_CHECKPOINT.appliedRetentionHours,
748
+ restoredRetentionHours: typeof v4.policy?.transactional?.checkpoint?.restoredRetentionHours === 'number' &&
749
+ v4.policy.transactional.checkpoint.restoredRetentionHours > 0
750
+ ? v4.policy.transactional.checkpoint.restoredRetentionHours
751
+ : DEFAULT_RECOVERY_CHECKPOINT.restoredRetentionHours,
752
+ maxCheckpoints: typeof v4.policy?.transactional?.checkpoint?.maxCheckpoints === 'number' &&
753
+ v4.policy.transactional.checkpoint.maxCheckpoints > 0
754
+ ? Math.floor(v4.policy.transactional.checkpoint.maxCheckpoints)
755
+ : DEFAULT_RECOVERY_CHECKPOINT.maxCheckpoints,
756
+ maxBytes: typeof v4.policy?.transactional?.checkpoint?.maxBytes === 'number' &&
757
+ v4.policy.transactional.checkpoint.maxBytes > 0
758
+ ? Math.floor(v4.policy.transactional.checkpoint.maxBytes)
759
+ : DEFAULT_RECOVERY_CHECKPOINT.maxBytes,
760
+ },
699
761
  };
700
762
  })(),
701
763
  },
@@ -875,8 +937,6 @@ export function scrubOptionsFromConfig(config) {
875
937
  export function classifierOptionsFromConfig(config) {
876
938
  return {
877
939
  strictChains: config.classifier.strictChains,
878
- customExternalCommands: config.overrides.external,
879
- customAllowCommands: config.overrides.allow,
880
940
  sensitivePaths: config.classifier.sensitivePaths,
881
941
  unknownLocalEffect: config.policy.unknownLocalEffect,
882
942
  unparseableShell: config.policy.unparseableShell,
@@ -0,0 +1,9 @@
1
+ import type { PolicyDecision } from '../capability/policy-types.js';
2
+ import type { CapabilityRequestV1 } from '../capability/request.js';
3
+ import type { EffectPlan } from './types.js';
4
+ export declare function hashEffectPlan(plan: EffectPlan): string;
5
+ export declare function effectPlanAuditFields(plan: EffectPlan | undefined, policy?: {
6
+ capabilityRequests?: readonly CapabilityRequestV1[];
7
+ decisions?: readonly PolicyDecision[];
8
+ authorizationDecision?: PolicyDecision;
9
+ }): Record<string, unknown>;