@guilz-dev/belay 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +67 -11
  2. package/dist/adapters/claude/runtime-entry.js +2 -0
  3. package/dist/adapters/codex/runtime-entry.js +2 -0
  4. package/dist/adapters/cursor/hooks.d.ts +5 -1
  5. package/dist/adapters/cursor/hooks.js +37 -2
  6. package/dist/adapters/cursor/runtime-entry.js +5 -0
  7. package/dist/adapters/shared/gate-runtime.d.ts +6 -0
  8. package/dist/adapters/shared/gate-runtime.js +260 -59
  9. package/dist/bundle/claude-runtime.mjs +7068 -3966
  10. package/dist/bundle/codex-runtime.mjs +7001 -3899
  11. package/dist/bundle/cursor-runtime.mjs +7004 -3899
  12. package/dist/commands/classify-for-report.js +5 -0
  13. package/dist/commands/doctor.js +88 -9
  14. package/dist/commands/explain.js +3 -2
  15. package/dist/commands/metrics.js +42 -0
  16. package/dist/commands/recovery-checkpoints.d.ts +8 -1
  17. package/dist/commands/recovery-checkpoints.js +44 -11
  18. package/dist/commands/session.d.ts +5 -0
  19. package/dist/commands/session.js +8 -1
  20. package/dist/config-io.js +2 -2
  21. package/dist/conformance/contained-execution-guarantee.d.ts +121 -0
  22. package/dist/conformance/contained-execution-guarantee.js +121 -0
  23. package/dist/conformance/guarantee-table.js +7 -0
  24. package/dist/core/audit-analysis.js +7 -4
  25. package/dist/core/audit-io.d.ts +1 -0
  26. package/dist/core/audit-io.js +16 -6
  27. package/dist/core/audit-legacy-archive.d.ts +6 -0
  28. package/dist/core/audit-legacy-archive.js +51 -0
  29. package/dist/core/audit-metrics.d.ts +15 -0
  30. package/dist/core/audit-metrics.js +16 -2
  31. package/dist/core/audit-query.d.ts +2 -0
  32. package/dist/core/audit-query.js +33 -9
  33. package/dist/core/audit-recovery-metrics.d.ts +33 -0
  34. package/dist/core/audit-recovery-metrics.js +146 -0
  35. package/dist/core/audit-serialize.d.ts +9 -0
  36. package/dist/core/audit-serialize.js +179 -0
  37. package/dist/core/audit-types.d.ts +8 -1
  38. package/dist/core/audit-types.js +1 -1
  39. package/dist/core/bounded-output.d.ts +11 -0
  40. package/dist/core/bounded-output.js +26 -0
  41. package/dist/core/capability/attestation.d.ts +56 -0
  42. package/dist/core/capability/attestation.js +110 -3
  43. package/dist/core/capability/boundary-session.d.ts +5 -0
  44. package/dist/core/capability/boundary-session.js +38 -1
  45. package/dist/core/capability/index.d.ts +1 -1
  46. package/dist/core/capability/index.js +1 -1
  47. package/dist/core/capability/policy-engine.js +32 -13
  48. package/dist/core/config.d.ts +18 -0
  49. package/dist/core/config.js +89 -10
  50. package/dist/core/contained-execution/docker-policy.d.ts +18 -0
  51. package/dist/core/contained-execution/docker-policy.js +97 -0
  52. package/dist/core/contained-execution/docker.d.ts +223 -0
  53. package/dist/core/contained-execution/docker.js +846 -0
  54. package/dist/core/contained-execution/eligibility.d.ts +9 -0
  55. package/dist/core/contained-execution/eligibility.js +103 -0
  56. package/dist/core/contained-execution/failure.d.ts +18 -0
  57. package/dist/core/contained-execution/failure.js +105 -0
  58. package/dist/core/contained-execution/mirror.d.ts +54 -0
  59. package/dist/core/contained-execution/mirror.js +587 -0
  60. package/dist/core/contained-execution/policy.d.ts +10 -0
  61. package/dist/core/contained-execution/policy.js +20 -0
  62. package/dist/core/decision-config-fingerprint.d.ts +3 -0
  63. package/dist/core/decision-config-fingerprint.js +6 -0
  64. package/dist/core/effect-ir/index.d.ts +1 -1
  65. package/dist/core/effect-ir/index.js +1 -1
  66. package/dist/core/effect-ir/normalize.d.ts +5 -0
  67. package/dist/core/effect-ir/normalize.js +17 -7
  68. package/dist/core/effect-ir/shell-build.js +2 -14
  69. package/dist/core/effect-ir/shell-lower.js +103 -25
  70. package/dist/core/gate-contract.d.ts +4 -1
  71. package/dist/core/gate-contract.js +2 -0
  72. package/dist/core/process-runner.d.ts +10 -3
  73. package/dist/core/process-runner.js +66 -10
  74. package/dist/core/recovery/checkpoint.d.ts +3 -0
  75. package/dist/core/recovery/checkpoint.js +33 -4
  76. package/dist/core/recovery/types.d.ts +1 -0
  77. package/dist/core/scrub.d.ts +17 -0
  78. package/dist/core/scrub.js +499 -5
  79. package/dist/core/standing-allow.d.ts +0 -20
  80. package/dist/core/standing-allow.js +0 -71
  81. package/dist/core/transactional/apply-observed-changes.d.ts +1 -0
  82. package/dist/core/transactional/apply-observed-changes.js +11 -0
  83. package/dist/core/transactional/backend-selector.d.ts +2 -1
  84. package/dist/core/transactional/backend-selector.js +32 -29
  85. package/dist/core/transactional/backend.d.ts +3 -0
  86. package/dist/core/transactional/file-checkpoint-backend.js +242 -54
  87. package/dist/core/transactional/file-checkpoint-git.d.ts +3 -0
  88. package/dist/core/transactional/file-checkpoint-git.js +53 -0
  89. package/dist/core/transactional/file-checkpoint-isolation.d.ts +3 -0
  90. package/dist/core/transactional/file-checkpoint-isolation.js +16 -0
  91. package/dist/core/transactional/git-worktree-backend.js +3 -0
  92. package/dist/core/transactional/git-worktree.d.ts +1 -0
  93. package/dist/core/transactional/git-worktree.js +1 -0
  94. package/dist/core/transactional/runner.js +59 -13
  95. package/dist/core/types.d.ts +13 -0
  96. package/dist/core/verdict/egress-classify.js +1 -0
  97. package/dist/core/verdict/parser.d.ts +10 -0
  98. package/dist/core/verdict/parser.js +271 -40
  99. package/dist/core/verdict/shell-semantics.js +21 -0
  100. package/dist/corpus/adversarial-probe.d.ts +1 -1
  101. package/dist/corpus/adversarial-probe.js +3 -3
  102. package/dist/corpus/benign-probe-cores.d.ts +6 -0
  103. package/dist/corpus/{must-allow-commands.js → benign-probe-cores.js} +8 -2
  104. package/dist/corpus/coverage-compare.d.ts +36 -0
  105. package/dist/corpus/coverage-compare.js +197 -0
  106. package/dist/corpus/coverage-contexts.d.ts +17 -0
  107. package/dist/corpus/coverage-contexts.js +88 -0
  108. package/dist/corpus/coverage-matrix.d.ts +37 -0
  109. package/dist/corpus/coverage-matrix.js +159 -0
  110. package/dist/corpus/coverage-probe.d.ts +91 -0
  111. package/dist/corpus/coverage-probe.js +341 -0
  112. package/dist/corpus/evaluate.d.ts +1 -1
  113. package/dist/corpus/evaluate.js +1 -1
  114. package/dist/corpus/structural-fixture-root.d.ts +2 -0
  115. package/dist/corpus/structural-fixture-root.js +5 -0
  116. package/dist/defaults.js +0 -8
  117. package/dist/egress-daemon.js +20 -4
  118. package/dist/installer.js +3 -0
  119. package/dist/runtime-provenance.d.ts +10 -0
  120. package/dist/runtime-provenance.js +63 -0
  121. package/dist/templates.js +12 -7
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/package.json +4 -1
  125. package/skills/belay/SKILL.md +19 -0
  126. package/skills/belay/belay-status.md +5 -0
  127. package/dist/corpus/must-allow-commands.d.ts +0 -6
@@ -159,6 +159,7 @@ export async function applyWorktreeChanges(worktreePath, repoRoot, changes, opti
159
159
  sourceRoot: worktreePath,
160
160
  targetRoot: repoRoot,
161
161
  changes: observed,
162
+ beforeMutation: options?.beforeMutation,
162
163
  afterApply: options?.afterApply,
163
164
  });
164
165
  }
@@ -1,18 +1,33 @@
1
1
  import { boundaryMountReadOnlyFromPrediction, runWithBoundaryRunnable, } from '../capability/boundary-run.js';
2
2
  import { hostIntegrationBoundaryContext } from '../capability/boundary-session.js';
3
3
  import { resolveGuestWorkdir } from '../capability/boundary-workspace-mount.js';
4
- import { discardPreparedRecoveryCheckpoint, listRecoveryCheckpoints, markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, prepareRecoveryCheckpoint, reconcileRecoveryCheckpoint, } from '../recovery/checkpoint.js';
4
+ import { canonicalPath, pathWithinRoot } from '../path-utils.js';
5
+ import { discardPreparedRecoveryCheckpoint, listRecoveryCheckpoints, markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, markRecoveryCheckpointNeedsManualRepair, prepareRecoveryCheckpoint, reconcileRecoveryCheckpoint, } from '../recovery/checkpoint.js';
5
6
  import { buildObservedChangesFromTransactional } from './apply-observed-changes.js';
6
7
  import { selectTransactionalBackend } from './backend-selector.js';
7
8
  import { evaluateTransactionalDiff } from './diff-evaluator.js';
8
9
  import { FileCheckpointDiagnosticError } from './file-tree.js';
9
10
  import { applyWorktreeChanges, resolveWorktreeCwd, TRANSACTIONAL_APPLY_ROLLBACK_FAILED, TRANSACTIONAL_APPLY_TOCTOU, } from './git-worktree.js';
10
11
  import { TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPLY_FAILED, TRANSACTIONAL_OBSERVED_RISK, } from './reasons.js';
12
+ const TRANSACTIONAL_RESOURCE_IDENTITY_CHANGED = 'transactional_resource_identity_changed';
13
+ function errorChainIncludes(error, message) {
14
+ const seen = new Set();
15
+ let current = error;
16
+ while (current instanceof Error && !seen.has(current)) {
17
+ if (current.message === message)
18
+ return true;
19
+ seen.add(current);
20
+ current = current.cause;
21
+ }
22
+ return false;
23
+ }
11
24
  export async function runTransactionalExecution(params) {
12
25
  const { predicted, repoRoot, stateDir, command, cwd, timeoutMs, diffContext } = params;
26
+ const recoveryStateDir = canonicalPath(stateDir);
27
+ const recoveryStateOutsideResource = !pathWithinRoot(repoRoot, recoveryStateDir);
13
28
  const backendContext = {
14
29
  repoRoot,
15
- stateDir,
30
+ stateDir: recoveryStateDir,
16
31
  cwd,
17
32
  dirtyIgnoreRoots: params.dirtyIgnoreRoots,
18
33
  fileCheckpoint: params.fileCheckpoint,
@@ -32,11 +47,13 @@ export async function runTransactionalExecution(params) {
32
47
  skipReason: skipReason ?? 'transactional_execution_failed',
33
48
  predicted,
34
49
  result: predicted,
50
+ transactionalBackend: selection.probe.backend,
51
+ resourceKind: selection.probe.resourceKind,
35
52
  };
36
53
  }
37
54
  if (params.checkpoint?.enabled) {
38
55
  try {
39
- await listRecoveryCheckpoints(stateDir, repoRoot);
56
+ await listRecoveryCheckpoints(recoveryStateDir, repoRoot);
40
57
  }
41
58
  catch (error) {
42
59
  return {
@@ -45,6 +62,8 @@ export async function runTransactionalExecution(params) {
45
62
  skipReason: error instanceof Error ? error.message : 'recovery_checkpoint_reconcile_failed',
46
63
  predicted,
47
64
  result: predicted,
65
+ transactionalBackend: selection.probe.backend,
66
+ resourceKind: selection.probe.resourceKind,
48
67
  };
49
68
  }
50
69
  }
@@ -118,7 +137,7 @@ export async function runTransactionalExecution(params) {
118
137
  const observedChanges = await buildObservedChangesFromTransactional(snapshot.baselineRoot ?? repoRoot, snapshot.executionRoot, changes);
119
138
  const checkpoint = params.checkpoint?.enabled && changes.length > 0
120
139
  ? await prepareRecoveryCheckpoint({
121
- stateDir,
140
+ stateDir: recoveryStateDir,
122
141
  repoRoot,
123
142
  baselinePath: snapshot.baselineRoot ?? repoRoot,
124
143
  worktreePath: snapshot.executionRoot,
@@ -127,18 +146,30 @@ export async function runTransactionalExecution(params) {
127
146
  protectedRoots: diffContext.protectedRoots,
128
147
  config: params.checkpoint,
129
148
  backend: snapshot.backend,
149
+ expectedResourceIdentity: snapshot.resourceIdentity,
130
150
  })
131
151
  : null;
132
152
  try {
133
153
  if (checkpoint) {
134
- await markRecoveryCheckpointApplying(stateDir, checkpoint);
154
+ await markRecoveryCheckpointApplying(recoveryStateDir, checkpoint);
135
155
  }
136
156
  let receipt;
157
+ const validateResourceIdentity = snapshot.validateResourceIdentity;
137
158
  await applyWorktreeChanges(snapshot.executionRoot, repoRoot, changes, {
138
159
  observedChanges,
160
+ beforeMutation: validateResourceIdentity
161
+ ? async () => {
162
+ try {
163
+ await validateResourceIdentity();
164
+ }
165
+ catch (error) {
166
+ throw new Error(TRANSACTIONAL_RESOURCE_IDENTITY_CHANGED, { cause: error });
167
+ }
168
+ }
169
+ : undefined,
139
170
  afterApply: checkpoint
140
171
  ? async () => {
141
- receipt = await markRecoveryCheckpointApplied(stateDir, checkpoint);
172
+ receipt = await markRecoveryCheckpointApplied(recoveryStateDir, checkpoint);
142
173
  }
143
174
  : undefined,
144
175
  });
@@ -169,14 +200,25 @@ export async function runTransactionalExecution(params) {
169
200
  };
170
201
  }
171
202
  catch (error) {
203
+ const toctou = error instanceof Error && error.message === TRANSACTIONAL_APPLY_TOCTOU;
204
+ const rollbackFailed = error instanceof Error && error.message === TRANSACTIONAL_APPLY_ROLLBACK_FAILED;
205
+ const resourceIdentityChanged = errorChainIncludes(error, TRANSACTIONAL_RESOURCE_IDENTITY_CHANGED);
206
+ const recoveryStateUnavailable = !recoveryStateOutsideResource && (rollbackFailed || resourceIdentityChanged);
172
207
  if (checkpoint) {
173
- const recoveryState = await reconcileRecoveryCheckpoint(stateDir, checkpoint.checkpointId);
174
- if (recoveryState === 'prepared') {
175
- await discardPreparedRecoveryCheckpoint(stateDir, checkpoint.checkpointId);
208
+ if (recoveryStateUnavailable) {
209
+ // The resource path may now resolve to attacker-controlled replacement bytes.
210
+ // Leave the original artifact in applying state instead of touching that path.
211
+ }
212
+ else if (rollbackFailed) {
213
+ await markRecoveryCheckpointNeedsManualRepair(recoveryStateDir, checkpoint.checkpointId, TRANSACTIONAL_APPLY_ROLLBACK_FAILED);
214
+ }
215
+ else {
216
+ const recoveryState = await reconcileRecoveryCheckpoint(recoveryStateDir, checkpoint.checkpointId);
217
+ if (recoveryState === 'prepared') {
218
+ await discardPreparedRecoveryCheckpoint(recoveryStateDir, checkpoint.checkpointId);
219
+ }
176
220
  }
177
221
  }
178
- const toctou = error instanceof Error && error.message === TRANSACTIONAL_APPLY_TOCTOU;
179
- const rollbackFailed = error instanceof Error && error.message === TRANSACTIONAL_APPLY_ROLLBACK_FAILED;
180
222
  const result = {
181
223
  ...predicted,
182
224
  verdict: 'deny_pending_approval',
@@ -189,9 +231,10 @@ export async function runTransactionalExecution(params) {
189
231
  ...observed.assessment.signals,
190
232
  rollbackFailed
191
233
  ? 'transactional_apply_rollback_failed'
192
- : toctou
234
+ : toctou || resourceIdentityChanged
193
235
  ? 'transactional_apply_toctou'
194
236
  : 'transactional_apply_failed',
237
+ ...(recoveryStateUnavailable ? ['transactional_recovery_state_unavailable'] : []),
195
238
  ],
196
239
  },
197
240
  };
@@ -245,7 +288,10 @@ export async function runTransactionalExecution(params) {
245
288
  snapshotCopyStrategy: snapshot.copyStrategy,
246
289
  snapshotPrepareMs: snapshot.snapshotPrepareMs,
247
290
  }
248
- : {}),
291
+ : {
292
+ transactionalBackend: selection.probe.backend,
293
+ resourceKind: selection.probe.resourceKind,
294
+ }),
249
295
  };
250
296
  }
251
297
  finally {
@@ -4,6 +4,17 @@ import type { PolicyDecision } from './capability/policy-types.js';
4
4
  import type { CapabilityRequestV1 } from './capability/request.js';
5
5
  import type { FsScopeAllowlistFile } from './capability/types.js';
6
6
  import type { EffectPlan, EffectPlanPolicyProjection } from './effect-ir/types.js';
7
+ export interface MediatedExecutionResult {
8
+ exitCode: number | null;
9
+ signal: string | null;
10
+ timedOut: boolean;
11
+ stdout: string;
12
+ stderr: string;
13
+ stdoutTruncated: boolean;
14
+ stderrTruncated: boolean;
15
+ receiptHash: string;
16
+ workspaceChangesDiscarded: true;
17
+ }
7
18
  export type BelayMode = 'enforce' | 'audit';
8
19
  export type HookVerdict = 'allow' | 'allow_flagged' | 'deny_pending_approval';
9
20
  export type Reversibility = 'reversible' | 'recoverable_with_cost' | 'irreversible';
@@ -45,6 +56,8 @@ export interface ClassifyResult {
45
56
  effectPlanPolicyDecisions?: PolicyDecision[];
46
57
  effectPlanProjection?: EffectPlanPolicyProjection;
47
58
  boundaryProfile?: string;
59
+ wouldMediate?: boolean;
60
+ mediatedExecution?: MediatedExecutionResult;
48
61
  }
49
62
  export type UnknownLocalEffectPolicy = 'allow_flagged' | 'deny';
50
63
  export type UnparseableShellPolicy = 'allow_flagged' | 'deny';
@@ -47,6 +47,7 @@ const GH_READ_COMMANDS = new Set([
47
47
  'repo view',
48
48
  'run list',
49
49
  'run view',
50
+ 'search code',
50
51
  'workflow list',
51
52
  'workflow view',
52
53
  ]);
@@ -4,15 +4,25 @@ export interface ParsedSegment {
4
4
  head: string;
5
5
  key: string;
6
6
  normalized: string;
7
+ encounteredXargs: boolean;
7
8
  }
8
9
  export declare function normalizeHead(token: string): string;
9
10
  export declare function peelTransparentWrappers(tokens: string[]): {
10
11
  tokens: string[];
11
12
  xargsStdinOpaque: boolean;
13
+ encounteredXargs: boolean;
14
+ opaque: boolean;
12
15
  };
13
16
  export declare function isVariableIndirectHead(head: string): boolean;
14
17
  export declare function extractEvalBody(tokens: string[]): string | null;
15
18
  export declare function extractRecursiveScript(tokens: string[]): string | null;
19
+ /**
20
+ * True when a recursive script is evaluated from a command argument rather
21
+ * than expanded from a static launcher recipe. Callers use this semantic fact
22
+ * to preserve fail-closed policy for dynamic evaluation.
23
+ */
24
+ export declare function isDynamicRecursiveEvaluation(tokens: string[]): boolean;
25
+ export declare function isCommandInspection(tokens: string[]): boolean;
16
26
  export declare function isBareInterpreter(tokens: string[]): boolean;
17
27
  export declare function splitTopLevelSegments(command: string): string[];
18
28
  export declare function splitStructuralShellSegments(command: string): string[];
@@ -3,17 +3,7 @@ import { findCommandSubstitutions, findStructuralCommandSubstitutions, } from '.
3
3
  import { commandKey, tokenizeShell } from '../shell-tokenizer.js';
4
4
  import { detectUnparseableShell } from '../shell-unparseable.js';
5
5
  const ENV_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S+)$/;
6
- const TRANSPARENT_WRAPPERS = new Set([
7
- 'sudo',
8
- 'env',
9
- 'nohup',
10
- 'time',
11
- 'nice',
12
- 'ionice',
13
- 'stdbuf',
14
- 'setsid',
15
- 'xargs',
16
- ]);
6
+ const MAX_WRAPPER_PEEL_DEPTH = 32;
17
7
  const SHELL_INTERPRETERS = new Set(['bash', 'sh', 'zsh', 'dash', 'fish']);
18
8
  const CODE_INTERPRETERS = new Set(['python', 'python3', 'node', 'ruby', 'perl', 'osascript']);
19
9
  const SCRIPT_FLAGS = new Set(['-c', '-lc', '-e', '--eval']);
@@ -37,7 +27,13 @@ export function normalizeHead(token) {
37
27
  export function peelTransparentWrappers(tokens) {
38
28
  let current = [...tokens];
39
29
  let xargsStdinOpaque = false;
30
+ let encounteredXargs = false;
31
+ let peelDepth = 0;
40
32
  while (current.length > 0) {
33
+ if (peelDepth >= MAX_WRAPPER_PEEL_DEPTH) {
34
+ return { tokens: current, xargsStdinOpaque: false, encounteredXargs, opaque: true };
35
+ }
36
+ peelDepth += 1;
41
37
  while (current.length > 0 && ENV_PREFIX_PATTERN.test(current[0] ?? '')) {
42
38
  current.shift();
43
39
  }
@@ -45,38 +41,247 @@ export function peelTransparentWrappers(tokens) {
45
41
  break;
46
42
  }
47
43
  const head = normalizeHead(current[0] ?? '');
48
- if (!TRANSPARENT_WRAPPERS.has(head)) {
44
+ if (head === 'xargs') {
45
+ encounteredXargs = true;
46
+ const wrapper = peelXargsWrapper(current);
47
+ if (wrapper.kind === 'opaque') {
48
+ xargsStdinOpaque = current.length === 1;
49
+ return {
50
+ tokens: xargsStdinOpaque ? [] : current,
51
+ xargsStdinOpaque,
52
+ encounteredXargs,
53
+ opaque: true,
54
+ };
55
+ }
56
+ current = wrapper.tokens;
57
+ continue;
58
+ }
59
+ const wrapper = peelTransparentWrapper(head, current);
60
+ if (!wrapper) {
49
61
  break;
50
62
  }
51
- if (head === 'xargs') {
52
- let index = 1;
53
- while (index < current.length && current[index]?.startsWith('-')) {
54
- index += 1;
63
+ if (wrapper.kind === 'opaque') {
64
+ return { tokens: current, xargsStdinOpaque: false, encounteredXargs, opaque: true };
65
+ }
66
+ if (wrapper.kind === 'preserve') {
67
+ return { tokens: current, xargsStdinOpaque: false, encounteredXargs, opaque: false };
68
+ }
69
+ current = wrapper.tokens;
70
+ }
71
+ return { tokens: current, xargsStdinOpaque, encounteredXargs, opaque: false };
72
+ }
73
+ function peelTransparentWrapper(head, tokens) {
74
+ switch (head) {
75
+ case 'sudo':
76
+ return peelSudoWrapper(tokens);
77
+ case 'command':
78
+ case 'builtin':
79
+ case 'exec':
80
+ return peelShellInvocationWrapper(head, tokens);
81
+ case 'env':
82
+ return peelEnvWrapper(tokens);
83
+ case 'time':
84
+ return peelTimeWrapper(tokens);
85
+ case 'nice':
86
+ return peelNiceWrapper(tokens);
87
+ case 'ionice':
88
+ case 'stdbuf':
89
+ case 'setsid':
90
+ case 'nohup':
91
+ return peelNoOptionWrapper(tokens);
92
+ default:
93
+ return null;
94
+ }
95
+ }
96
+ function peelXargsWrapper(tokens) {
97
+ const option = tokens[1] ?? '';
98
+ if (!option) {
99
+ return { kind: 'opaque' };
100
+ }
101
+ if (option === '-I') {
102
+ return isWrapperOperand(tokens[2]) ? targetFrom(tokens, 3) : { kind: 'opaque' };
103
+ }
104
+ if (option.startsWith('-I') && option.length > 2) {
105
+ return targetFrom(tokens, 2);
106
+ }
107
+ if (option.startsWith('-')) {
108
+ return { kind: 'opaque' };
109
+ }
110
+ return { kind: 'peeled', tokens: tokens.slice(1) };
111
+ }
112
+ function peelEnvWrapper(tokens) {
113
+ let index = 1;
114
+ while (index < tokens.length) {
115
+ const token = tokens[index] ?? '';
116
+ if (token === '--') {
117
+ index += 1;
118
+ break;
119
+ }
120
+ if (ENV_PREFIX_PATTERN.test(token)) {
121
+ index += 1;
122
+ continue;
123
+ }
124
+ if (token === '-i' || token === '--ignore-environment') {
125
+ index += 1;
126
+ continue;
127
+ }
128
+ if (token === '-u' || token === '--unset') {
129
+ if (!isWrapperOperand(tokens[index + 1])) {
130
+ return { kind: 'opaque' };
55
131
  }
56
- const rest = current.slice(index);
57
- if (rest.length === 0) {
58
- xargsStdinOpaque = true;
59
- return { tokens: [], xargsStdinOpaque: true };
132
+ index += 2;
133
+ continue;
134
+ }
135
+ if (token.startsWith('--unset=') && token.length > '--unset='.length) {
136
+ index += 1;
137
+ continue;
138
+ }
139
+ if (token.startsWith('-')) {
140
+ return { kind: 'opaque' };
141
+ }
142
+ break;
143
+ }
144
+ return targetFrom(tokens, index);
145
+ }
146
+ function peelTimeWrapper(tokens) {
147
+ if (tokens[1] === '-p') {
148
+ return targetFrom(tokens, 2);
149
+ }
150
+ return targetFrom(tokens, 1);
151
+ }
152
+ function peelNiceWrapper(tokens) {
153
+ if (tokens[1] === '-n') {
154
+ return isNiceAdjustment(tokens[2]) ? targetFrom(tokens, 3) : { kind: 'opaque' };
155
+ }
156
+ return targetFrom(tokens, 1);
157
+ }
158
+ function peelNoOptionWrapper(tokens) {
159
+ return targetFrom(tokens, 1);
160
+ }
161
+ function targetFrom(tokens, index) {
162
+ return isWrapperOperand(tokens[index])
163
+ ? { kind: 'peeled', tokens: tokens.slice(index) }
164
+ : { kind: 'opaque' };
165
+ }
166
+ function isWrapperOperand(token) {
167
+ return Boolean(token && token !== '--' && !token.startsWith('-'));
168
+ }
169
+ function isNiceAdjustment(token) {
170
+ return Boolean(token && /^[+-]?\d+$/.test(token));
171
+ }
172
+ function peelSudoWrapper(tokens) {
173
+ let index = 1;
174
+ while (index < tokens.length) {
175
+ const token = tokens[index] ?? '';
176
+ if (token === '--') {
177
+ index += 1;
178
+ break;
179
+ }
180
+ if (!token.startsWith('-') || token === '-') {
181
+ break;
182
+ }
183
+ if (token === '-n' || token === '--non-interactive') {
184
+ index += 1;
185
+ continue;
186
+ }
187
+ if (token === '-u' || token === '-g' || token === '--user' || token === '--group') {
188
+ if (!tokens[index + 1] || tokens[index + 1] === '--') {
189
+ return { kind: 'opaque' };
60
190
  }
61
- current = rest;
191
+ index += 2;
192
+ continue;
193
+ }
194
+ if ((token.startsWith('-u') || token.startsWith('-g')) && token.length > 2) {
195
+ index += 1;
196
+ continue;
197
+ }
198
+ if ((token.startsWith('--user=') || token.startsWith('--group=')) &&
199
+ token.slice(token.indexOf('=') + 1).length > 0) {
200
+ index += 1;
62
201
  continue;
63
202
  }
64
- if (head === 'env') {
65
- let index = 1;
66
- while (index < current.length) {
67
- const token = current[index] ?? '';
68
- if (ENV_PREFIX_PATTERN.test(token) || token.startsWith('-')) {
69
- index += 1;
70
- continue;
71
- }
72
- break;
203
+ return { kind: 'opaque' };
204
+ }
205
+ return index < tokens.length
206
+ ? { kind: 'peeled', tokens: tokens.slice(index) }
207
+ : { kind: 'opaque' };
208
+ }
209
+ function peelShellInvocationWrapper(head, tokens) {
210
+ switch (head) {
211
+ case 'command':
212
+ return peelCommandWrapper(tokens);
213
+ case 'builtin':
214
+ return peelBuiltinWrapper(tokens);
215
+ case 'exec':
216
+ return peelExecWrapper(tokens);
217
+ }
218
+ }
219
+ function peelCommandWrapper(tokens) {
220
+ let index = 1;
221
+ let inspection = false;
222
+ while (index < tokens.length) {
223
+ const token = tokens[index] ?? '';
224
+ if (token === '--') {
225
+ index += 1;
226
+ break;
227
+ }
228
+ if (!token.startsWith('-') || token === '-') {
229
+ break;
230
+ }
231
+ const flags = token.slice(1);
232
+ if (!flags || ![...flags].every((flag) => flag === 'p' || flag === 'v' || flag === 'V')) {
233
+ return { kind: 'opaque' };
234
+ }
235
+ inspection ||= flags.includes('v') || flags.includes('V');
236
+ index += 1;
237
+ }
238
+ if (inspection) {
239
+ return { kind: 'preserve' };
240
+ }
241
+ return index < tokens.length
242
+ ? { kind: 'peeled', tokens: tokens.slice(index) }
243
+ : { kind: 'opaque' };
244
+ }
245
+ function peelBuiltinWrapper(tokens) {
246
+ const first = tokens[1];
247
+ if (first === '--') {
248
+ return tokens.length > 2 ? { kind: 'peeled', tokens: tokens.slice(2) } : { kind: 'opaque' };
249
+ }
250
+ if (!first || first.startsWith('-')) {
251
+ return { kind: 'opaque' };
252
+ }
253
+ return { kind: 'peeled', tokens: tokens.slice(1) };
254
+ }
255
+ function peelExecWrapper(tokens) {
256
+ let index = 1;
257
+ while (index < tokens.length) {
258
+ const token = tokens[index] ?? '';
259
+ if (token === '--') {
260
+ index += 1;
261
+ break;
262
+ }
263
+ if (!token.startsWith('-') || token === '-') {
264
+ break;
265
+ }
266
+ if (token === '-a') {
267
+ if (!tokens[index + 1]) {
268
+ return { kind: 'opaque' };
73
269
  }
74
- current = current.slice(index);
270
+ index += 2;
271
+ continue;
272
+ }
273
+ if (token.startsWith('-a') && token.length > 2) {
274
+ index += 1;
75
275
  continue;
76
276
  }
77
- current = current.slice(1);
277
+ if (![...token.slice(1)].every((flag) => flag === 'c' || flag === 'l')) {
278
+ return { kind: 'opaque' };
279
+ }
280
+ index += 1;
78
281
  }
79
- return { tokens: current, xargsStdinOpaque };
282
+ return index < tokens.length
283
+ ? { kind: 'peeled', tokens: tokens.slice(index) }
284
+ : { kind: 'opaque' };
80
285
  }
81
286
  export function isVariableIndirectHead(head) {
82
287
  return head.startsWith('$');
@@ -90,11 +295,15 @@ export function extractEvalBody(tokens) {
90
295
  return body || null;
91
296
  }
92
297
  export function extractRecursiveScript(tokens) {
93
- const filtered = tokens.filter((token) => token !== 'sudo');
298
+ const { tokens: filtered, opaque } = peelTransparentWrappers(tokens);
299
+ if (opaque) {
300
+ return null;
301
+ }
94
302
  const head = normalizeHead(filtered[0] ?? '');
95
303
  const second = filtered[1] ?? '';
96
304
  if (head === 'eval') {
97
- return extractEvalBody(tokens);
305
+ const body = filtered.slice(1).join(' ').trim();
306
+ return body || null;
98
307
  }
99
308
  if (SHELL_INTERPRETERS.has(head) || CODE_INTERPRETERS.has(head)) {
100
309
  const flagIndex = filtered.findIndex((token) => SCRIPT_FLAGS.has(token));
@@ -117,9 +326,29 @@ export function extractRecursiveScript(tokens) {
117
326
  }
118
327
  return null;
119
328
  }
329
+ /**
330
+ * True when a recursive script is evaluated from a command argument rather
331
+ * than expanded from a static launcher recipe. Callers use this semantic fact
332
+ * to preserve fail-closed policy for dynamic evaluation.
333
+ */
334
+ export function isDynamicRecursiveEvaluation(tokens) {
335
+ const { tokens: filtered, opaque } = peelTransparentWrappers(tokens);
336
+ if (opaque) {
337
+ return false;
338
+ }
339
+ const head = normalizeHead(filtered[0] ?? '');
340
+ if (head === 'eval') {
341
+ return true;
342
+ }
343
+ return ((SHELL_INTERPRETERS.has(head) || CODE_INTERPRETERS.has(head)) &&
344
+ filtered.some((token) => SCRIPT_FLAGS.has(token)));
345
+ }
346
+ export function isCommandInspection(tokens) {
347
+ return (normalizeHead(tokens[0] ?? '') === 'command' && peelCommandWrapper(tokens).kind === 'preserve');
348
+ }
120
349
  export function isBareInterpreter(tokens) {
121
- const { tokens: peeled, xargsStdinOpaque } = peelTransparentWrappers(tokens);
122
- if (xargsStdinOpaque) {
350
+ const { tokens: peeled, xargsStdinOpaque, opaque } = peelTransparentWrappers(tokens);
351
+ if (xargsStdinOpaque || opaque) {
123
352
  return true;
124
353
  }
125
354
  if (peeled.length === 0) {
@@ -267,7 +496,8 @@ export function splitStructuralShellSegments(command) {
267
496
  }
268
497
  export function parseSegment(command) {
269
498
  const tokens = tokenizeShell(command);
270
- const { tokens: peeled } = peelTransparentWrappers(tokens);
499
+ const peeledWrapper = peelTransparentWrappers(tokens);
500
+ const { tokens: peeled } = peeledWrapper;
271
501
  const normalizedTokens = peeled.map((token) => normalizeHead(token));
272
502
  const key = commandKey(peeled.map((token, index) => (index === 0 ? normalizeHead(token) : token)));
273
503
  return {
@@ -275,6 +505,7 @@ export function parseSegment(command) {
275
505
  head: normalizeHead(peeled[0] ?? ''),
276
506
  key,
277
507
  normalized: normalizedTokens.join(' ').trim(),
508
+ encounteredXargs: peeledWrapper.encounteredXargs,
278
509
  };
279
510
  }
280
511
  export function segmentOpacity(command) {
@@ -282,8 +513,8 @@ export function segmentOpacity(command) {
282
513
  return 'unparseable';
283
514
  }
284
515
  const tokens = tokenizeShell(command);
285
- const { xargsStdinOpaque } = peelTransparentWrappers(tokens);
286
- if (xargsStdinOpaque) {
516
+ const { xargsStdinOpaque, opaque } = peelTransparentWrappers(tokens);
517
+ if (xargsStdinOpaque || opaque) {
287
518
  return 'opaque';
288
519
  }
289
520
  if (isBareInterpreter(tokens)) {
@@ -103,6 +103,18 @@ function filterRoutinePathTargets(head, targets) {
103
103
  }
104
104
  return targets.filter((target) => looksLikePathToken(target));
105
105
  }
106
+ function tscDeclaresOutputPaths(args) {
107
+ for (let index = 0; index < args.length; index += 1) {
108
+ const arg = args[index] ?? '';
109
+ if (/^--(?:outDir|outFile)=/.test(arg)) {
110
+ return true;
111
+ }
112
+ if ((arg === '--outDir' || arg === '--outFile') && args[index + 1]) {
113
+ return true;
114
+ }
115
+ }
116
+ return false;
117
+ }
106
118
  function extractFilePathOperands(tokens) {
107
119
  const redirects = extractRedirectTargets(tokens);
108
120
  const args = [...redirects];
@@ -197,6 +209,15 @@ export function analyzeShellCommandSemantics(tokens, segment, cwd) {
197
209
  if (head === 'rsync') {
198
210
  return classifyRsync(tokens);
199
211
  }
212
+ if (head === 'tsc' && tokens.includes('--noEmit') && !tscDeclaresOutputPaths(tokens.slice(1))) {
213
+ return {
214
+ effect: 'read_only',
215
+ pathTargets: [],
216
+ signals: ['tsc.typecheck'],
217
+ normalizedKey: segment.key,
218
+ isReadOnly: true,
219
+ };
220
+ }
200
221
  let effect = 'unknown';
201
222
  let pathTargets = extractFilePathOperands(tokens);
202
223
  pathTargets = filterRoutinePathTargets(head, pathTargets);
@@ -68,7 +68,7 @@ export declare function splitFixHoldout<T>(items: readonly T[], seed: number, ho
68
68
  holdout: T[];
69
69
  };
70
70
  export declare function generateProbeCases(seed: number, maxCases?: number): MutatedCase[];
71
- /** Benign must-allow cores × AUTO_LABEL mutators — FP over-stop probe (not for corpus auto-add). */
71
+ /** Benign probe cores × AUTO_LABEL mutators — FP over-stop probe (not for corpus auto-add). */
72
72
  export declare function generateFpProbeCases(seed: number): MutatedCase[];
73
73
  /** Ratio for overfitting detection; null when holdout unavailable or fix-perfect but holdout not. */
74
74
  export declare function holdoutFixFnRateRatio(fixSetFnRate: number, holdoutFnRate: number | null): number | null;
@@ -1,8 +1,8 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { BENIGN_PROBE_CORES } from './benign-probe-cores.js';
4
5
  import { evaluateCommandBatch, evaluateFpCommandBatch } from './evaluate.js';
5
- import { MUST_ALLOW_SHELL_COMMANDS } from './must-allow-commands.js';
6
6
  import { AUTO_LABEL_MUTATORS, CATASTROPHIC_CORES, generateMutatedCases, } from './mutators.js';
7
7
  const MUST_ASK_VERDICT = 'deny_pending_approval';
8
8
  export function probeCaseKey(entry) {
@@ -47,9 +47,9 @@ export function generateProbeCases(seed, maxCases) {
47
47
  }
48
48
  return shuffled.slice(0, maxCases);
49
49
  }
50
- /** Benign must-allow cores × AUTO_LABEL mutators — FP over-stop probe (not for corpus auto-add). */
50
+ /** Benign probe cores × AUTO_LABEL mutators — FP over-stop probe (not for corpus auto-add). */
51
51
  export function generateFpProbeCases(seed) {
52
- const all = generateMutatedCases([...MUST_ALLOW_SHELL_COMMANDS], AUTO_LABEL_MUTATORS);
52
+ const all = generateMutatedCases([...BENIGN_PROBE_CORES], AUTO_LABEL_MUTATORS);
53
53
  return shuffleWithSeed(all, seed + 1000);
54
54
  }
55
55
  function fnRate(fnCount, total) {