@guilz-dev/belay 0.8.1 → 0.9.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 (104) hide show
  1. package/README.md +60 -10
  2. package/dist/adapters/shared/gate-runtime.d.ts +5 -0
  3. package/dist/adapters/shared/gate-runtime.js +237 -33
  4. package/dist/bundle/claude-runtime.mjs +4082 -1230
  5. package/dist/bundle/codex-runtime.mjs +4057 -1205
  6. package/dist/bundle/cursor-runtime.mjs +4057 -1205
  7. package/dist/commands/classify-for-report.js +5 -0
  8. package/dist/commands/doctor.js +73 -6
  9. package/dist/commands/explain.js +3 -2
  10. package/dist/commands/metrics.js +42 -0
  11. package/dist/commands/recovery-checkpoints.d.ts +8 -1
  12. package/dist/commands/recovery-checkpoints.js +44 -11
  13. package/dist/commands/session.d.ts +5 -0
  14. package/dist/commands/session.js +8 -1
  15. package/dist/config-io.js +2 -2
  16. package/dist/conformance/contained-execution-guarantee.d.ts +121 -0
  17. package/dist/conformance/contained-execution-guarantee.js +121 -0
  18. package/dist/conformance/guarantee-table.js +7 -0
  19. package/dist/core/audit-io.js +8 -0
  20. package/dist/core/audit-metrics.d.ts +11 -0
  21. package/dist/core/audit-metrics.js +14 -0
  22. package/dist/core/audit-recovery-metrics.d.ts +33 -0
  23. package/dist/core/audit-recovery-metrics.js +146 -0
  24. package/dist/core/audit-types.d.ts +7 -1
  25. package/dist/core/audit-types.js +1 -1
  26. package/dist/core/bounded-output.d.ts +11 -0
  27. package/dist/core/bounded-output.js +26 -0
  28. package/dist/core/capability/attestation.d.ts +56 -0
  29. package/dist/core/capability/attestation.js +110 -3
  30. package/dist/core/capability/boundary-session.d.ts +5 -0
  31. package/dist/core/capability/boundary-session.js +38 -1
  32. package/dist/core/capability/index.d.ts +1 -1
  33. package/dist/core/capability/index.js +1 -1
  34. package/dist/core/config.d.ts +18 -0
  35. package/dist/core/config.js +89 -10
  36. package/dist/core/contained-execution/docker-policy.d.ts +18 -0
  37. package/dist/core/contained-execution/docker-policy.js +97 -0
  38. package/dist/core/contained-execution/docker.d.ts +223 -0
  39. package/dist/core/contained-execution/docker.js +846 -0
  40. package/dist/core/contained-execution/eligibility.d.ts +9 -0
  41. package/dist/core/contained-execution/eligibility.js +103 -0
  42. package/dist/core/contained-execution/failure.d.ts +18 -0
  43. package/dist/core/contained-execution/failure.js +105 -0
  44. package/dist/core/contained-execution/mirror.d.ts +54 -0
  45. package/dist/core/contained-execution/mirror.js +587 -0
  46. package/dist/core/contained-execution/policy.d.ts +10 -0
  47. package/dist/core/contained-execution/policy.js +20 -0
  48. package/dist/core/effect-ir/index.d.ts +1 -1
  49. package/dist/core/effect-ir/index.js +1 -1
  50. package/dist/core/effect-ir/normalize.d.ts +5 -0
  51. package/dist/core/effect-ir/normalize.js +17 -7
  52. package/dist/core/effect-ir/shell-build.js +2 -14
  53. package/dist/core/effect-ir/shell-lower.js +100 -25
  54. package/dist/core/gate-contract.d.ts +4 -1
  55. package/dist/core/gate-contract.js +2 -0
  56. package/dist/core/process-runner.d.ts +10 -3
  57. package/dist/core/process-runner.js +66 -10
  58. package/dist/core/recovery/checkpoint.d.ts +3 -0
  59. package/dist/core/recovery/checkpoint.js +33 -4
  60. package/dist/core/recovery/types.d.ts +1 -0
  61. package/dist/core/scrub.d.ts +17 -0
  62. package/dist/core/scrub.js +499 -5
  63. package/dist/core/standing-allow.d.ts +0 -20
  64. package/dist/core/standing-allow.js +0 -71
  65. package/dist/core/transactional/apply-observed-changes.d.ts +1 -0
  66. package/dist/core/transactional/apply-observed-changes.js +11 -0
  67. package/dist/core/transactional/backend-selector.d.ts +2 -1
  68. package/dist/core/transactional/backend-selector.js +32 -29
  69. package/dist/core/transactional/backend.d.ts +3 -0
  70. package/dist/core/transactional/file-checkpoint-backend.js +242 -54
  71. package/dist/core/transactional/file-checkpoint-git.d.ts +3 -0
  72. package/dist/core/transactional/file-checkpoint-git.js +53 -0
  73. package/dist/core/transactional/file-checkpoint-isolation.d.ts +3 -0
  74. package/dist/core/transactional/file-checkpoint-isolation.js +16 -0
  75. package/dist/core/transactional/git-worktree-backend.js +3 -0
  76. package/dist/core/transactional/git-worktree.d.ts +1 -0
  77. package/dist/core/transactional/git-worktree.js +1 -0
  78. package/dist/core/transactional/runner.js +59 -13
  79. package/dist/core/types.d.ts +13 -0
  80. package/dist/core/verdict/parser.d.ts +10 -0
  81. package/dist/core/verdict/parser.js +271 -40
  82. package/dist/core/verdict/shell-semantics.js +21 -0
  83. package/dist/corpus/adversarial-probe.d.ts +1 -1
  84. package/dist/corpus/adversarial-probe.js +3 -3
  85. package/dist/corpus/benign-probe-cores.d.ts +6 -0
  86. package/dist/corpus/{must-allow-commands.js → benign-probe-cores.js} +8 -2
  87. package/dist/corpus/coverage-compare.d.ts +36 -0
  88. package/dist/corpus/coverage-compare.js +197 -0
  89. package/dist/corpus/coverage-contexts.d.ts +17 -0
  90. package/dist/corpus/coverage-contexts.js +88 -0
  91. package/dist/corpus/coverage-matrix.d.ts +37 -0
  92. package/dist/corpus/coverage-matrix.js +159 -0
  93. package/dist/corpus/coverage-probe.d.ts +91 -0
  94. package/dist/corpus/coverage-probe.js +341 -0
  95. package/dist/corpus/evaluate.d.ts +1 -1
  96. package/dist/corpus/evaluate.js +1 -1
  97. package/dist/corpus/structural-fixture-root.d.ts +2 -0
  98. package/dist/corpus/structural-fixture-root.js +5 -0
  99. package/dist/version.d.ts +1 -1
  100. package/dist/version.js +1 -1
  101. package/package.json +3 -1
  102. package/skills/belay/SKILL.md +19 -0
  103. package/skills/belay/belay-status.md +5 -0
  104. package/dist/corpus/must-allow-commands.d.ts +0 -6
@@ -6,6 +6,22 @@ export function normalizeEffectTags(tags) {
6
6
  }
7
7
  return [...set].sort();
8
8
  }
9
+ /**
10
+ * Joins opacity evidence without allowing a later analysis stage to weaken an
11
+ * earlier uncertainty classification.
12
+ */
13
+ export function joinEffectOpacity(...opacities) {
14
+ if (opacities.includes('unparseable')) {
15
+ return 'unparseable';
16
+ }
17
+ if (opacities.includes('opaque')) {
18
+ return 'opaque';
19
+ }
20
+ if (opacities.includes('recursive')) {
21
+ return 'recursive';
22
+ }
23
+ return 'transparent';
24
+ }
9
25
  export function mergeRequirements(...groups) {
10
26
  const seen = new Map();
11
27
  for (const group of groups) {
@@ -45,13 +61,7 @@ export function mergeEffectPlans(...plans) {
45
61
  return present[0];
46
62
  }
47
63
  const children = present.flatMap((plan) => plan.root.kind === 'merge' ? [...plan.root.children] : [plan.root]);
48
- const opacity = present.some((plan) => plan.opacity === 'unparseable')
49
- ? 'unparseable'
50
- : present.some((plan) => plan.opacity === 'opaque')
51
- ? 'opaque'
52
- : present.some((plan) => plan.opacity === 'recursive')
53
- ? 'recursive'
54
- : 'transparent';
64
+ const opacity = joinEffectOpacity(...present.map((plan) => plan.opacity));
55
65
  const fingerprints = present.map((plan) => plan.inputFingerprint).sort();
56
66
  return {
57
67
  version: 1,
@@ -1,5 +1,5 @@
1
1
  import { collectRequirements } from './build.js';
2
- import { mergeRequirements } from './normalize.js';
2
+ import { joinEffectOpacity, mergeRequirements } from './normalize.js';
3
3
  /**
4
4
  * Build a canonical EffectPlan from shell segments that have already been
5
5
  * semantically analyzed. This function only assembles and normalizes effects;
@@ -16,7 +16,7 @@ export function buildShellEffectPlan(params) {
16
16
  version: 1,
17
17
  root,
18
18
  inputFingerprint: params.inputFingerprint,
19
- opacity: mergeOpacity(params.segments.map((segment) => segment.opacity)),
19
+ opacity: joinEffectOpacity(...params.segments.map((segment) => segment.opacity)),
20
20
  disposition: requirements.length === 0 ? 'effect_free' : 'effects',
21
21
  completeness,
22
22
  signals: [
@@ -63,15 +63,3 @@ function mergeNodes(nodes) {
63
63
  }
64
64
  return { kind: 'merge', children: nodes };
65
65
  }
66
- function mergeOpacity(opacities) {
67
- if (opacities.includes('unparseable')) {
68
- return 'unparseable';
69
- }
70
- if (opacities.includes('opaque')) {
71
- return 'opaque';
72
- }
73
- if (opacities.includes('recursive')) {
74
- return 'recursive';
75
- }
76
- return 'transparent';
77
- }
@@ -5,7 +5,8 @@ import { isFdDuplication, isRedirectOperator, tokenizeShell } from '../shell-tok
5
5
  import { decodeEgressEffects } from '../verdict/egress-classify.js';
6
6
  import { decodeGitEffects } from '../verdict/git-classifier.js';
7
7
  import { resolveLauncherRecipe } from '../verdict/launcher-resolve.js';
8
- import { extractRecursiveScript, parseSegment, redactCommand, segmentOpacity, splitStructuralShellSegments, structuralSubstitutionInners, } from '../verdict/parser.js';
8
+ import { extractRecursiveScript, isCommandInspection, isDynamicRecursiveEvaluation, parseSegment, redactCommand, segmentOpacity, splitStructuralShellSegments, structuralSubstitutionInners, } from '../verdict/parser.js';
9
+ import { joinEffectOpacity } from './normalize.js';
9
10
  import { classifyPackageAcquisitionSpec, innerRecipeFromPeel, peelPackageExecArgv, resolveLocalBin, } from './package-exec.js';
10
11
  import { buildShellEffectPlan, } from './shell-build.js';
11
12
  const MAX_LOWER_DEPTH = 8;
@@ -61,7 +62,7 @@ function lowerTopLevelSegments(command, context) {
61
62
  ...(requiresCwd
62
63
  ? {
63
64
  completeness: 'partial',
64
- opacity: result.opacity === 'unparseable' ? 'unparseable' : 'opaque',
65
+ opacity: joinEffectOpacity(result.opacity, 'opaque'),
65
66
  }
66
67
  : {}),
67
68
  signals: [...new Set([...result.signals, 'shell.cwd_unknown'])],
@@ -117,6 +118,12 @@ function requiresKnownCwd(requirementValue) {
117
118
  requirementValue.resource.kind === 'executable' &&
118
119
  requirementValue.resource.operation === 'spawn');
119
120
  }
121
+ function joinNestedOpacity(outer, nested) {
122
+ const nestedOpacity = nested.completeness === 'partial' && nested.opacity === 'transparent'
123
+ ? 'recursive'
124
+ : nested.opacity;
125
+ return joinEffectOpacity(outer, nestedOpacity);
126
+ }
120
127
  function lowerSegment(command, context) {
121
128
  const commandRedacted = redactCommand(command);
122
129
  const rawTokens = tokenizeShell(command);
@@ -134,20 +141,20 @@ function lowerSegment(command, context) {
134
141
  requirements.push(requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
135
142
  'shell.env_wrapper_incomplete',
136
143
  ]));
137
- opacity = 'opaque';
144
+ opacity = joinEffectOpacity(opacity, 'opaque');
138
145
  }
139
- if (path.basename((environment.commandTokens ?? rawTokens)[0] ?? '') === 'xargs') {
146
+ if (parsed.encounteredXargs) {
140
147
  requirements.push(requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
141
148
  'shell.xargs_stdin_dynamic',
142
149
  ]));
143
150
  signals.add('shell.xargs_stdin_dynamic');
144
- opacity = 'opaque';
151
+ opacity = joinEffectOpacity(opacity, 'opaque');
145
152
  }
146
153
  if (context.depth >= MAX_LOWER_DEPTH) {
147
154
  requirements.push(requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
148
155
  'shell.lower_depth_exceeded',
149
156
  ]));
150
- return shellSegment(commandRedacted, head, requirements, 'opaque', signals);
157
+ return shellSegment(commandRedacted, head, requirements, joinEffectOpacity(opacity, 'opaque'), signals);
151
158
  }
152
159
  const packageExec = peelPackageExecArgv(tokens);
153
160
  if (packageExec) {
@@ -156,7 +163,7 @@ function lowerSegment(command, context) {
156
163
  signals.add(signal);
157
164
  }
158
165
  if (packageExec.opaque) {
159
- opacity = 'opaque';
166
+ opacity = joinEffectOpacity(opacity, 'opaque');
160
167
  }
161
168
  const innerRecipe = packageExecInnerIsMetadata(packageExec)
162
169
  ? null
@@ -173,16 +180,18 @@ function lowerSegment(command, context) {
173
180
  for (const signal of nestedSegment.signals) {
174
181
  signals.add(signal);
175
182
  }
176
- if (nestedSegment.completeness === 'partial') {
177
- opacity = nestedSegment.opacity === 'unparseable' ? 'unparseable' : 'recursive';
178
- }
183
+ opacity = joinNestedOpacity(opacity, nestedSegment);
179
184
  }
180
185
  }
181
186
  return shellSegment(commandRedacted, head, requirements, opacity, signals);
182
187
  }
183
188
  const recursiveScript = extractRecursiveScript(tokens);
184
- if (recursiveScript) {
185
- requirements.push(processRequirement(head || 'sh', 'spawn', commandRedacted, ['shell.recursive_wrapper']));
189
+ if (recursiveScript && opacity !== 'opaque' && opacity !== 'unparseable') {
190
+ const dynamicEvaluation = isDynamicRecursiveEvaluation(tokens);
191
+ requirements.push(processRequirement(head || 'sh', 'spawn', commandRedacted, [
192
+ 'shell.recursive_wrapper',
193
+ ...(dynamicEvaluation ? ['dynamic_shell_evaluation'] : []),
194
+ ]));
186
195
  const nested = lowerTopLevelSegments(recursiveScript, {
187
196
  ...context,
188
197
  command: recursiveScript,
@@ -196,6 +205,9 @@ function lowerSegment(command, context) {
196
205
  }
197
206
  }
198
207
  signals.add('shell.recursive_wrapper');
208
+ if (dynamicEvaluation) {
209
+ signals.add('dynamic_shell_evaluation');
210
+ }
199
211
  return shellSegment(commandRedacted, head, requirements, 'recursive', signals);
200
212
  }
201
213
  const launcher = resolveLauncherRecipe({
@@ -219,16 +231,14 @@ function lowerSegment(command, context) {
219
231
  for (const signal of nestedSegment.signals) {
220
232
  signals.add(signal);
221
233
  }
222
- if (nestedSegment.completeness === 'partial') {
223
- opacity = nestedSegment.opacity === 'unparseable' ? 'unparseable' : 'recursive';
224
- }
234
+ opacity = joinNestedOpacity(opacity, nestedSegment);
225
235
  }
226
236
  }
227
237
  if (launcher.opaque || launcher.recipes.length === 0) {
228
238
  requirements.push(requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
229
239
  `launcher.${launcher.reason}`,
230
240
  ]));
231
- opacity = 'opaque';
241
+ opacity = joinEffectOpacity(opacity, 'opaque');
232
242
  }
233
243
  return shellSegment(commandRedacted, head, requirements, opacity, signals);
234
244
  }
@@ -270,7 +280,7 @@ function lowerSegment(command, context) {
270
280
  const environmentSignals = effectChangingEnvironmentSignals(head, env, environment.changedNames);
271
281
  if (environmentSignals.length > 0) {
272
282
  requirements.push(requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, environmentSignals));
273
- opacity = 'opaque';
283
+ opacity = joinEffectOpacity(opacity, 'opaque');
274
284
  for (const signal of environmentSignals) {
275
285
  signals.add(signal);
276
286
  }
@@ -281,7 +291,7 @@ function lowerSegment(command, context) {
281
291
  ensureRequirement(requirements, requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
282
292
  'pipe_to_shell',
283
293
  ]));
284
- opacity = 'opaque';
294
+ opacity = joinEffectOpacity(opacity, 'opaque');
285
295
  }
286
296
  if (opacity === 'unparseable' || opacity === 'opaque') {
287
297
  ensureRequirement(requirements, requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
@@ -356,9 +366,63 @@ function packageExecInnerIsMetadata(peel) {
356
366
  const args = peel.innerTokens.slice(1);
357
367
  return (args.length > 0 && args.every((arg) => ['--help', '--version', '-V', '-h', '-v'].includes(arg)));
358
368
  }
369
+ const METADATA_ONLY_FLAGS = new Set(['--version', '-v', '-V', '--help', '-h']);
370
+ function isMetadataOnlyArgv(argv) {
371
+ return argv.length > 0 && argv.every((token) => METADATA_ONLY_FLAGS.has(token));
372
+ }
373
+ function executableBaseName(head) {
374
+ return path.basename(head);
375
+ }
376
+ const RAILS_READ_ONLY_SUBCOMMANDS = new Set(['routes', 'middleware', 'stats', 'about', 'version']);
377
+ function railsReadOnlySubcommand(args) {
378
+ const subcommand = args[0];
379
+ if (!subcommand || subcommand.startsWith('-')) {
380
+ return false;
381
+ }
382
+ return RAILS_READ_ONLY_SUBCOMMANDS.has(subcommand);
383
+ }
384
+ function decodeRuntimeMetadataProcess(head, args, segment) {
385
+ if (head === 'bundle') {
386
+ if (args.length === 1 && isMetadataOnlyArgv(args)) {
387
+ return [processRequirement(head, 'inspect', segment, ['process.inspect.runtime_metadata'])];
388
+ }
389
+ if (args[0] === 'exec' && args.length >= 2) {
390
+ const innerHead = args[1] ?? '';
391
+ const innerArgs = args.slice(2);
392
+ const innerBase = executableBaseName(innerHead);
393
+ if ((innerBase === 'rails' || innerBase === 'rake') && railsReadOnlySubcommand(innerArgs)) {
394
+ return [
395
+ processRequirement(innerHead, 'inspect', segment, ['process.inspect.rails_read_only']),
396
+ ];
397
+ }
398
+ if (isMetadataOnlyArgv(innerArgs)) {
399
+ return [
400
+ processRequirement(innerHead, 'inspect', segment, [
401
+ 'process.inspect.bundle_exec_metadata',
402
+ ]),
403
+ ];
404
+ }
405
+ }
406
+ return null;
407
+ }
408
+ if ((head === 'ruby' || head === 'yarn') && args.length >= 1 && isMetadataOnlyArgv(args)) {
409
+ return [processRequirement(head, 'inspect', segment, ['process.inspect.runtime_metadata'])];
410
+ }
411
+ if (head === 'make' && args.includes('-n')) {
412
+ return [processRequirement(head, 'inspect', segment, ['process.inspect.make_dry_run'])];
413
+ }
414
+ const base = executableBaseName(head);
415
+ if ((base === 'rails' || base === 'rake') && railsReadOnlySubcommand(args)) {
416
+ return [processRequirement(head, 'inspect', segment, ['process.inspect.rails_read_only'])];
417
+ }
418
+ return null;
419
+ }
359
420
  function decodeProcessOrFilesystem(params) {
360
421
  const { tokens, head, env, cwd, repoRoot, segment } = params;
361
422
  const args = tokens.slice(1);
423
+ if (isCommandInspection(tokens)) {
424
+ return [processRequirement(head, 'inspect', segment, ['process.inspect.command_lookup'])];
425
+ }
362
426
  if (head === 'lsof') {
363
427
  return validLsof(args)
364
428
  ? [processRequirement(head, 'inspect', segment, ['process.inspect.lsof'])]
@@ -432,7 +496,11 @@ function decodeProcessOrFilesystem(params) {
432
496
  if (head === 'prisma') {
433
497
  return decodePrisma(args, env, repoRoot, segment);
434
498
  }
435
- if ((head === 'npm' || head === 'pnpm') && args.length === 1 && args[0] === '--version') {
499
+ const runtimeMetadata = decodeRuntimeMetadataProcess(head, args, segment);
500
+ if (runtimeMetadata) {
501
+ return runtimeMetadata;
502
+ }
503
+ if ((head === 'npm' || head === 'pnpm') && args.length === 1 && isMetadataOnlyArgv(args)) {
436
504
  return [processRequirement(head, 'inspect', segment, ['process.inspect.package_manager'])];
437
505
  }
438
506
  if (head === 'belay') {
@@ -544,12 +612,9 @@ function decodeBelay(args, repoRoot, segment) {
544
612
  return [processRequirement('belay', 'spawn', segment, ['process.known_local_spawn'])];
545
613
  }
546
614
  function decodeTsc(args, cwd, segment) {
547
- const inspect = args.length > 0 && args.every((arg) => ['--help', '--version', '-h', '-v'].includes(arg));
548
- const requirements = [
549
- processRequirement('tsc', inspect ? 'inspect' : 'spawn', segment, [
550
- inspect ? 'process.inspect.tsc_metadata' : 'process.known_local_spawn',
551
- ]),
552
- ];
615
+ const metadataOnly = args.length > 0 && args.every((arg) => ['--help', '--version', '-h', '-v'].includes(arg));
616
+ const requirements = [];
617
+ let hasOutput = false;
553
618
  for (let index = 0; index < args.length; index += 1) {
554
619
  const arg = args[index] ?? '';
555
620
  const inline = arg.match(/^--(?:outDir|outFile)=(.+)$/)?.[1];
@@ -558,11 +623,21 @@ function decodeTsc(args, cwd, segment) {
558
623
  if (!output) {
559
624
  continue;
560
625
  }
626
+ hasOutput = true;
561
627
  addWriteEffects(requirements, resolvePathOperand(output, cwd), segment, ['tsc.output']);
562
628
  if (separate) {
563
629
  index += 1;
564
630
  }
565
631
  }
632
+ const typecheckOnly = args.includes('--noEmit') && !hasOutput;
633
+ const inspect = metadataOnly || typecheckOnly;
634
+ requirements.unshift(processRequirement('tsc', inspect ? 'inspect' : 'spawn', segment, [
635
+ metadataOnly
636
+ ? 'process.inspect.tsc_metadata'
637
+ : typecheckOnly
638
+ ? 'process.inspect.tsc_typecheck'
639
+ : 'process.known_local_spawn',
640
+ ]));
566
641
  return requirements;
567
642
  }
568
643
  function decodeCopyMove(head, args, cwd, segment) {
@@ -1,4 +1,5 @@
1
- import type { Assessment, ClassifyResult, HookVerdict } from './types.js';
1
+ import type { Assessment, ClassifyResult, HookVerdict, MediatedExecutionResult } from './types.js';
2
+ export type { MediatedExecutionResult } from './types.js';
2
3
  export declare const GATE_CONTRACT_VERSION: 1;
3
4
  export type GatedActionKind = 'shell' | 'subagent' | 'tool';
4
5
  export interface GatedAction {
@@ -34,6 +35,8 @@ export interface GateVerdict extends GatePermissionResponse {
34
35
  effectPlan?: ClassifyResult['effectPlan'];
35
36
  effectPlanPolicyDecisions?: ClassifyResult['effectPlanPolicyDecisions'];
36
37
  boundaryProfile?: string;
38
+ wouldMediate?: boolean;
39
+ mediatedExecution?: MediatedExecutionResult;
37
40
  }
38
41
  export declare function isGatedAction(value: unknown): value is GatedAction;
39
42
  export declare function classifyResultToGateVerdict(params: {
@@ -31,6 +31,8 @@ export function classifyResultToGateVerdict(params) {
31
31
  effectPlan: result.effectPlan,
32
32
  effectPlanPolicyDecisions: result.effectPlanPolicyDecisions,
33
33
  boundaryProfile: result.boundaryProfile,
34
+ wouldMediate: result.wouldMediate,
35
+ mediatedExecution: result.mediatedExecution,
34
36
  };
35
37
  }
36
38
  export function unnormalizedGateVerdict(params) {
@@ -1,10 +1,17 @@
1
1
  import { type SpawnOptionsWithoutStdio } from 'node:child_process';
2
+ import type { ScrubOptions } from './types.js';
2
3
  export interface ShellRunResult {
3
4
  exitCode: number | null;
4
5
  signal: string | null;
5
6
  timedOut: boolean;
6
- stdout?: string;
7
- stderr?: string;
7
+ stdout: string;
8
+ stderr: string;
9
+ stdoutTruncated: boolean;
10
+ stderrTruncated: boolean;
11
+ }
12
+ export interface ProcessOutputPolicy {
13
+ /** Scrub credential context before the irreversible output-tail cap is applied. */
14
+ scrubOptions: ScrubOptions;
8
15
  }
9
16
  export declare function windowsProcessTreeKillArgs(pid: number): string[];
10
- export declare function runProcessWithBoundedOutput(file: string, args: string[], options: SpawnOptionsWithoutStdio, timeoutMs: number): Promise<ShellRunResult>;
17
+ export declare function runProcessWithBoundedOutput(file: string, args: string[], options: SpawnOptionsWithoutStdio, timeoutMs: number, outputPolicy?: ProcessOutputPolicy): Promise<ShellRunResult>;
@@ -1,11 +1,36 @@
1
1
  import { spawn } from 'node:child_process';
2
- const PROCESS_OUTPUT_TAIL_BYTES = 16_384;
2
+ import { appendBoundedOutputTail, decodeBoundedOutputTail, OUTPUT_TAIL_LIMIT_BYTES, } from './bounded-output.js';
3
+ import { createStreamingScrubber } from './scrub.js';
3
4
  const EXIT_OUTPUT_GRACE_MS = 25;
4
5
  const TIMEOUT_KILL_GRACE_MS = 250;
5
6
  const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000;
6
- function appendOutputTail(current, chunk) {
7
- const next = Buffer.concat([current, Buffer.from(chunk)]);
8
- return next.length > PROCESS_OUTPUT_TAIL_BYTES ? next.subarray(-PROCESS_OUTPUT_TAIL_BYTES) : next;
7
+ class ScrubbedOutputTail {
8
+ #scrubber;
9
+ #tail = Buffer.alloc(0);
10
+ #tailTruncated = false;
11
+ #rawBytes = 0;
12
+ constructor(options) {
13
+ this.#scrubber = createStreamingScrubber(options);
14
+ }
15
+ append(chunk) {
16
+ const bytes = Buffer.from(chunk);
17
+ this.#rawBytes += bytes.length;
18
+ this.#appendSafe(this.#scrubber.write(bytes));
19
+ }
20
+ finish() {
21
+ this.#appendSafe(this.#scrubber.end());
22
+ return {
23
+ value: decodeBoundedOutputTail(this.#tail),
24
+ truncated: this.#rawBytes > OUTPUT_TAIL_LIMIT_BYTES || this.#tailTruncated,
25
+ };
26
+ }
27
+ #appendSafe(value) {
28
+ if (!value)
29
+ return;
30
+ const output = appendBoundedOutputTail(this.#tail, value);
31
+ this.#tail = output.tail;
32
+ this.#tailTruncated ||= output.truncated;
33
+ }
9
34
  }
10
35
  export function windowsProcessTreeKillArgs(pid) {
11
36
  return ['/pid', String(pid), '/t', '/f'];
@@ -36,7 +61,7 @@ function forceKillWindowsProcessTree(pid, fallback) {
36
61
  taskkill.on('close', (code) => finish(code === 0));
37
62
  });
38
63
  }
39
- export function runProcessWithBoundedOutput(file, args, options, timeoutMs) {
64
+ export function runProcessWithBoundedOutput(file, args, options, timeoutMs, outputPolicy) {
40
65
  return new Promise((resolve) => {
41
66
  const detached = process.platform !== 'win32';
42
67
  const child = spawn(file, args, {
@@ -46,15 +71,35 @@ export function runProcessWithBoundedOutput(file, args, options, timeoutMs) {
46
71
  });
47
72
  let stdout = Buffer.alloc(0);
48
73
  let stderr = Buffer.alloc(0);
74
+ let stdoutTruncated = false;
75
+ let stderrTruncated = false;
76
+ const scrubbedStdout = outputPolicy
77
+ ? new ScrubbedOutputTail(outputPolicy.scrubOptions)
78
+ : undefined;
79
+ const scrubbedStderr = outputPolicy
80
+ ? new ScrubbedOutputTail(outputPolicy.scrubOptions)
81
+ : undefined;
49
82
  let timedOut = false;
50
83
  let settled = false;
51
84
  let windowsCleanupPending = false;
52
85
  let exitTimer;
53
86
  child.stdout.on('data', (chunk) => {
54
- stdout = appendOutputTail(stdout, chunk);
87
+ if (scrubbedStdout) {
88
+ scrubbedStdout.append(chunk);
89
+ return;
90
+ }
91
+ const output = appendBoundedOutputTail(stdout, chunk);
92
+ stdout = output.tail;
93
+ stdoutTruncated ||= output.truncated;
55
94
  });
56
95
  child.stderr.on('data', (chunk) => {
57
- stderr = appendOutputTail(stderr, chunk);
96
+ if (scrubbedStderr) {
97
+ scrubbedStderr.append(chunk);
98
+ return;
99
+ }
100
+ const output = appendBoundedOutputTail(stderr, chunk);
101
+ stderr = output.tail;
102
+ stderrTruncated ||= output.truncated;
58
103
  });
59
104
  const finish = (exitCode, signal) => {
60
105
  if (settled) {
@@ -67,12 +112,16 @@ export function runProcessWithBoundedOutput(file, args, options, timeoutMs) {
67
112
  }
68
113
  child.stdout.destroy();
69
114
  child.stderr.destroy();
115
+ const safeStdout = scrubbedStdout?.finish();
116
+ const safeStderr = scrubbedStderr?.finish();
70
117
  resolve({
71
118
  exitCode,
72
119
  signal: signal ? String(signal) : null,
73
120
  timedOut,
74
- stdout: stdout.toString('utf8'),
75
- stderr: stderr.toString('utf8'),
121
+ stdout: safeStdout?.value ?? decodeBoundedOutputTail(stdout),
122
+ stderr: safeStderr?.value ?? decodeBoundedOutputTail(stderr),
123
+ stdoutTruncated: safeStdout?.truncated ?? stdoutTruncated,
124
+ stderrTruncated: safeStderr?.truncated ?? stderrTruncated,
76
125
  });
77
126
  };
78
127
  const kill = (signal) => {
@@ -104,7 +153,14 @@ export function runProcessWithBoundedOutput(file, args, options, timeoutMs) {
104
153
  }, TIMEOUT_KILL_GRACE_MS);
105
154
  }, timeoutMs);
106
155
  child.on('error', (error) => {
107
- stderr = appendOutputTail(stderr, error.message);
156
+ if (scrubbedStderr) {
157
+ scrubbedStderr.append(error.message);
158
+ finish(1, null);
159
+ return;
160
+ }
161
+ const output = appendBoundedOutputTail(stderr, error.message);
162
+ stderr = output.tail;
163
+ stderrTruncated ||= output.truncated;
108
164
  finish(1, null);
109
165
  });
110
166
  child.on('exit', (exitCode, signal) => {
@@ -17,8 +17,11 @@ export declare function prepareRecoveryCheckpoint(params: {
17
17
  protectedRoots?: string[];
18
18
  config: CheckpointConfig;
19
19
  backend?: RecoveryBackend;
20
+ /** Snapshot-time resource identity; re-validated before manifest commit. */
21
+ expectedResourceIdentity?: string;
20
22
  }): Promise<PreparedRecoveryCheckpoint>;
21
23
  export declare function discardPreparedRecoveryCheckpoint(stateDir: string, checkpointId: string): Promise<boolean>;
24
+ export declare function markRecoveryCheckpointNeedsManualRepair(stateDir: string, checkpointId: string, detail: string): Promise<void>;
22
25
  export declare function listRecoveryCheckpoints(stateDir: string, repoRoot?: string): Promise<RecoveryCheckpointSummary[]>;
23
26
  export declare function recoveryCheckpointStorageBytes(stateDir: string, repoRoot?: string): Promise<number>;
24
27
  export declare function showRecoveryCheckpoint(stateDir: string, checkpointId: string, expectedRepoRoot?: string): Promise<import("./artifact-store.js").RecoveryArtifact>;
@@ -4,13 +4,31 @@ import { mkdir, readdir, readFile, rename, rm } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { canonicalStringify, hashValue } from '../fingerprint.js';
6
6
  import { canonicalPath } from '../path-utils.js';
7
+ import { isGitWorktreeAvailable } from '../transactional/git-worktree.js';
7
8
  import { artifactRepoRoot, atomicWriteJson, checkpointDir, checkpointIds, checkpointIdsForRepo, checkpointsRoot, cleanupOrphanedStaging, directorySize, fsyncPath, markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, RECOVERY_CHECKPOINT_CORRUPT, readRecoveryArtifact, writeRecoveryState, } from './artifact-store.js';
8
9
  import { matchRecoverySide, reconcileRecoveryCheckpoint } from './reconcile.js';
9
10
  import { currentRecoveryResourceIdentity } from './resource-identity.js';
10
11
  import { RECOVERY_RESTORE_CONFLICT, RECOVERY_RESTORE_REASON, recoveryRestoreBinding, restoreRecoveryCheckpoint, } from './restore.js';
11
12
  import { assertRecoverySafeTarget, captureRecoverySnapshot, withoutRecoveryBlob, } from './snapshot-node.js';
12
- function recoveryProofProbeSignals(backend) {
13
+ async function resolveRecoveryResourceKind(backend, repoRoot) {
14
+ if (backend === 'git_worktree') {
15
+ return 'git_repository';
16
+ }
17
+ if (await isGitWorktreeAvailable(repoRoot)) {
18
+ return 'git_repository';
19
+ }
20
+ return 'directory';
21
+ }
22
+ function recoveryProofProbeSignals(backend, resourceKind) {
13
23
  if (backend === 'file_checkpoint') {
24
+ if (resourceKind === 'directory') {
25
+ return [
26
+ 'non_git_workspace',
27
+ 'non_git_file_checkpoint',
28
+ 'file_checkpoint',
29
+ 'observed_repo_local_diff',
30
+ ];
31
+ }
14
32
  return ['dirty_git_worktree', 'file_checkpoint', 'observed_repo_local_diff'];
15
33
  }
16
34
  return ['clean_git_worktree', 'observed_repo_local_diff'];
@@ -45,6 +63,7 @@ async function garbageCollect(stateDir, config, repoRoot) {
45
63
  }
46
64
  export async function prepareRecoveryCheckpoint(params) {
47
65
  const backend = params.backend ?? 'git_worktree';
66
+ const resourceKind = await resolveRecoveryResourceKind(backend, params.repoRoot);
48
67
  await mkdir(checkpointsRoot(params.stateDir), { recursive: true, mode: 0o700 });
49
68
  await cleanupOrphanedStaging(params.stateDir);
50
69
  await garbageCollect(params.stateDir, params.config, params.repoRoot);
@@ -88,6 +107,10 @@ export async function prepareRecoveryCheckpoint(params) {
88
107
  });
89
108
  }
90
109
  const createdAt = new Date().toISOString();
110
+ const repoIdentity = await currentRecoveryResourceIdentity(params.repoRoot, resourceKind);
111
+ if (params.expectedResourceIdentity && repoIdentity !== params.expectedResourceIdentity) {
112
+ throw new Error('recovery_checkpoint_repo_mismatch');
113
+ }
91
114
  const proof = {
92
115
  version: 1,
93
116
  backend,
@@ -97,15 +120,15 @@ export async function prepareRecoveryCheckpoint(params) {
97
120
  effectClosure: 'repo_local_fs_observed',
98
121
  issuedAt: createdAt,
99
122
  expiresAt: new Date(Date.now() + params.config.appliedRetentionHours * 60 * 60 * 1000).toISOString(),
100
- probeSignals: recoveryProofProbeSignals(backend),
123
+ probeSignals: recoveryProofProbeSignals(backend, resourceKind),
101
124
  };
102
125
  const manifest = {
103
126
  version: 2,
104
127
  checkpointId,
105
128
  backend,
106
129
  repoRoot: canonicalPath(params.repoRoot),
107
- resourceKind: 'git_repository',
108
- repoIdentity: await currentRecoveryResourceIdentity(params.repoRoot, 'git_repository'),
130
+ resourceKind,
131
+ repoIdentity,
109
132
  commandFingerprint: params.commandFingerprint,
110
133
  createdAt,
111
134
  expiresAt: proof.expiresAt,
@@ -144,6 +167,10 @@ export async function discardPreparedRecoveryCheckpoint(stateDir, checkpointId)
144
167
  await rm(loaded.artifactDir, { recursive: true, force: true });
145
168
  return true;
146
169
  }
170
+ export async function markRecoveryCheckpointNeedsManualRepair(stateDir, checkpointId, detail) {
171
+ const loaded = await readRecoveryArtifact(stateDir, checkpointId);
172
+ await writeRecoveryState(loaded.artifactDir, 'needs_manual_repair', loaded.manifestHash, detail);
173
+ }
147
174
  export async function listRecoveryCheckpoints(stateDir, repoRoot) {
148
175
  await cleanupOrphanedStaging(stateDir);
149
176
  const summaries = [];
@@ -176,6 +203,7 @@ export async function listRecoveryCheckpoints(stateDir, repoRoot) {
176
203
  state,
177
204
  ...(loaded.state.detail ? { stateDetail: loaded.state.detail } : {}),
178
205
  backend: loaded.manifest.backend,
206
+ resourceKind: loaded.manifest.version === 2 ? loaded.manifest.resourceKind : 'git_repository',
179
207
  repoRoot: loaded.manifest.repoRoot,
180
208
  commandFingerprint: loaded.manifest.commandFingerprint,
181
209
  createdAt: loaded.manifest.createdAt,
@@ -202,6 +230,7 @@ export async function listRecoveryCheckpoints(stateDir, repoRoot) {
202
230
  checkpointId: id,
203
231
  state: 'corrupt',
204
232
  backend: manifest.backend,
233
+ resourceKind: manifest.version === 2 ? manifest.resourceKind : 'git_repository',
205
234
  repoRoot: manifest.repoRoot,
206
235
  commandFingerprint: manifest.commandFingerprint,
207
236
  createdAt: manifest.createdAt,
@@ -86,6 +86,7 @@ export interface RecoveryCheckpointSummary {
86
86
  /** Operator-facing detail from durable checkpoint state (e.g. mixed-state reason). */
87
87
  stateDetail?: string;
88
88
  backend: RecoveryBackend;
89
+ resourceKind: 'git_repository' | 'directory';
89
90
  repoRoot: string;
90
91
  commandFingerprint: string;
91
92
  createdAt: string;
@@ -1,3 +1,20 @@
1
1
  import type { ScrubOptions } from './types.js';
2
2
  export declare function scrubString(value: string, options?: ScrubOptions): string;
3
+ export interface StreamingScrubber {
4
+ /** Raw, undecided UTF-8 bytes retained between writes. */
5
+ readonly bufferedBytes: number;
6
+ write(chunk: Buffer | string): string;
7
+ end(): string;
8
+ }
9
+ export declare const STREAMING_SCRUBBER_MAX_BUFFER_BYTES = 256;
10
+ /**
11
+ * Creates a deterministic scrubber for unbounded process output.
12
+ *
13
+ * Only bounded, undecided grammar prefixes are retained. Once a bounded sensitive marker is
14
+ * recognized, the scrubber enters a suppression state before consuming any unbounded whitespace,
15
+ * credential value, or URL authority. URL authorities are conservatively hidden in full because a
16
+ * streaming parser cannot know whether an arbitrarily long username will later be followed by a
17
+ * password separator without retaining it.
18
+ */
19
+ export declare function createStreamingScrubber(options?: ScrubOptions): StreamingScrubber;
3
20
  export declare function scrubValue(value: unknown, options?: ScrubOptions): unknown;