@guilz-dev/belay 0.9.1 → 0.9.3

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 (61) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/codex/runtime-entry.d.ts +3 -0
  3. package/dist/adapters/codex/runtime-entry.js +27 -4
  4. package/dist/adapters/cursor/cwd-resolution.d.ts +10 -0
  5. package/dist/adapters/cursor/cwd-resolution.js +58 -0
  6. package/dist/adapters/cursor/hooks.d.ts +5 -3
  7. package/dist/adapters/cursor/hooks.js +29 -20
  8. package/dist/adapters/cursor/runtime-entry.d.ts +1 -0
  9. package/dist/adapters/cursor/runtime-entry.js +107 -6
  10. package/dist/adapters/shared/gate-runtime.js +26 -3
  11. package/dist/adapters/shared/repo-root.js +20 -1
  12. package/dist/bundle/claude-runtime.mjs +1225 -287
  13. package/dist/bundle/codex-runtime.mjs +1247 -291
  14. package/dist/bundle/cursor-runtime.mjs +4320 -3154
  15. package/dist/cli.js +33 -3
  16. package/dist/commands/doctor.js +38 -9
  17. package/dist/commands/health-snapshot.d.ts +3 -0
  18. package/dist/commands/health-snapshot.js +56 -0
  19. package/dist/commands/report.js +14 -0
  20. package/dist/commands/status.js +15 -0
  21. package/dist/commands/where.d.ts +4 -0
  22. package/dist/commands/where.js +52 -0
  23. package/dist/core/approval-repo-lookup.d.ts +16 -0
  24. package/dist/core/approval-repo-lookup.js +48 -0
  25. package/dist/core/audit-io.d.ts +1 -1
  26. package/dist/core/audit-io.js +1 -1
  27. package/dist/core/audit-legacy-archive.d.ts +1 -0
  28. package/dist/core/audit-legacy-archive.js +5 -0
  29. package/dist/core/audit-query.d.ts +1 -0
  30. package/dist/core/audit-query.js +7 -0
  31. package/dist/core/audit-serialize.d.ts +3 -0
  32. package/dist/core/audit-serialize.js +39 -3
  33. package/dist/core/audit-summary.d.ts +9 -0
  34. package/dist/core/audit-summary.js +58 -1
  35. package/dist/core/audit-types.d.ts +4 -0
  36. package/dist/core/effect-ir/shell-lower.js +283 -27
  37. package/dist/core/replay-scrub.d.ts +1 -0
  38. package/dist/core/replay-scrub.js +22 -3
  39. package/dist/core/shell-tokenizer.d.ts +28 -0
  40. package/dist/core/shell-tokenizer.js +111 -29
  41. package/dist/core/verdict/docker-compose-run.d.ts +18 -0
  42. package/dist/core/verdict/docker-compose-run.js +136 -0
  43. package/dist/core/verdict/launcher-resolve.js +66 -25
  44. package/dist/core/verdict/makefile-expand.d.ts +4 -0
  45. package/dist/core/verdict/makefile-expand.js +151 -0
  46. package/dist/core/verdict/parser.d.ts +4 -0
  47. package/dist/core/verdict/parser.js +25 -30
  48. package/dist/core/verdict/recursive-invocation.d.ts +20 -0
  49. package/dist/core/verdict/recursive-invocation.js +224 -0
  50. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  51. package/dist/corpus/benign-probe-cores.js +2 -0
  52. package/dist/defaults.js +16 -0
  53. package/dist/installer/scope-config.d.ts +2 -2
  54. package/dist/installer.d.ts +10 -1
  55. package/dist/installer.js +43 -2
  56. package/dist/types.d.ts +34 -1
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +5 -2
  60. package/skills/belay/SKILL.md +5 -0
  61. package/skills/belay/belay-report.md +4 -1
@@ -1,4 +1,4 @@
1
- import { filterAuditRecords, inferWouldBlock, isApprovalRecorded, isGateRecord, parseTimestamp, recordStringField, } from './audit-query.js';
1
+ import { auditToolInvocationCorrelationId, filterAuditRecords, inferWouldBlock, isApprovalRecorded, isGateRecord, parseTimestamp, recordStringField, } from './audit-query.js';
2
2
  export const DEFAULT_SILENT_PASS_THRESHOLD = 0.5;
3
3
  export const MIN_GATE_EVENTS_FOR_FENCE_DRIFT = 20;
4
4
  function isTier0Reason(reason) {
@@ -38,6 +38,7 @@ function isGateEventRecord(record) {
38
38
  export function summarizeAuditVisibility(records, filter = {}, options = {}) {
39
39
  const filtered = filterAuditRecords(records, filter);
40
40
  const gateRecords = filtered.filter(isGateEventRecord);
41
+ const allGateRecords = records.filter(isGateEventRecord);
41
42
  const recentAskLimit = options.recentAskLimit ?? 10;
42
43
  let askCount = 0;
43
44
  let enforceAskCount = 0;
@@ -46,6 +47,23 @@ export function summarizeAuditVisibility(records, filter = {}, options = {}) {
46
47
  let flagCount = 0;
47
48
  let allowCount = 0;
48
49
  const recentAsks = [];
50
+ const allowedByInvocation = new Map();
51
+ const recentHostDenials = [];
52
+ let unrecognizedHostFailureCount = 0;
53
+ const matchedHostDenials = new Set();
54
+ for (const record of allGateRecords) {
55
+ const invocationId = auditToolInvocationCorrelationId(record);
56
+ if (invocationId && !inferWouldBlock(record) && record.permission === 'allow') {
57
+ const existing = allowedByInvocation.get(invocationId);
58
+ const recordMs = parseTimestamp(record.timestamp) ?? Number.NEGATIVE_INFINITY;
59
+ const existingMs = existing
60
+ ? (parseTimestamp(existing.timestamp) ?? Number.NEGATIVE_INFINITY)
61
+ : Number.NEGATIVE_INFINITY;
62
+ if (!existing || recordMs >= existingMs) {
63
+ allowedByInvocation.set(invocationId, record);
64
+ }
65
+ }
66
+ }
49
67
  for (const record of gateRecords) {
50
68
  if (inferWouldBlock(record)) {
51
69
  askCount += 1;
@@ -73,11 +91,47 @@ export function summarizeAuditVisibility(records, filter = {}, options = {}) {
73
91
  allowCount += 1;
74
92
  }
75
93
  }
94
+ for (const record of filtered) {
95
+ if (record.event !== 'postToolUseFailure') {
96
+ continue;
97
+ }
98
+ if (record.failureType !== 'permission_denied') {
99
+ unrecognizedHostFailureCount += 1;
100
+ continue;
101
+ }
102
+ const invocationId = auditToolInvocationCorrelationId(record);
103
+ const gate = invocationId ? allowedByInvocation.get(invocationId) : undefined;
104
+ if (!gate) {
105
+ continue;
106
+ }
107
+ const gateMs = parseTimestamp(gate.timestamp);
108
+ const failureMs = parseTimestamp(record.timestamp);
109
+ if (gateMs !== null && failureMs !== null && failureMs < gateMs) {
110
+ continue;
111
+ }
112
+ if (invocationId && matchedHostDenials.has(invocationId)) {
113
+ continue;
114
+ }
115
+ if (invocationId) {
116
+ matchedHostDenials.add(invocationId);
117
+ }
118
+ recentHostDenials.push({
119
+ gateTimestamp: gate.timestamp,
120
+ failureTimestamp: record.timestamp,
121
+ summary: typeof gate.summary === 'string' ? gate.summary : '',
122
+ errorMessage: typeof record.errorMessage === 'string' ? record.errorMessage : '',
123
+ });
124
+ }
76
125
  recentAsks.sort((left, right) => {
77
126
  const leftMs = parseTimestamp(left.timestamp) ?? 0;
78
127
  const rightMs = parseTimestamp(right.timestamp) ?? 0;
79
128
  return rightMs - leftMs;
80
129
  });
130
+ recentHostDenials.sort((left, right) => {
131
+ const leftMs = parseTimestamp(left.failureTimestamp) ?? 0;
132
+ const rightMs = parseTimestamp(right.failureTimestamp) ?? 0;
133
+ return rightMs - leftMs;
134
+ });
81
135
  const gateEvents = gateRecords.length;
82
136
  const silentPassRate = gateEvents > 0 ? (allowCount + flagCount) / gateEvents : 0;
83
137
  return {
@@ -90,6 +144,9 @@ export function summarizeAuditVisibility(records, filter = {}, options = {}) {
90
144
  allowCount,
91
145
  silentPassRate,
92
146
  recentAsks: recentAsks.slice(0, recentAskLimit),
147
+ hostDeniedAfterAllowCount: recentHostDenials.length,
148
+ recentHostDenials: recentHostDenials.slice(0, recentAskLimit),
149
+ unrecognizedHostFailureCount,
93
150
  };
94
151
  }
95
152
  export function detectFenceDrift(summary, options = {}) {
@@ -12,6 +12,10 @@ export interface AuditRecord {
12
12
  fingerprint?: string;
13
13
  summary?: string;
14
14
  approvalId?: string;
15
+ toolInvocationCorrelationId?: string;
16
+ toolName?: string;
17
+ failureType?: string;
18
+ errorMessage?: string;
15
19
  wouldBlock?: boolean;
16
20
  judgeFallbackReason?: string;
17
21
  permission?: string;
@@ -1,11 +1,13 @@
1
1
  import { lstatSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { inspectGitResourceIdentity } from '../git-resource-identity.js';
4
- import { isFdDuplication, isRedirectOperator, tokenizeShell } from '../shell-tokenizer.js';
4
+ import { canonicalPath, pathWithinRoot } from '../path-utils.js';
5
+ import { isFdDuplication, isRedirectOperator, lexShell, tokenizeShell, } from '../shell-tokenizer.js';
6
+ import { decodeDockerComposeRun as decodeStructuredDockerComposeRun } from '../verdict/docker-compose-run.js';
5
7
  import { decodeEgressEffects } from '../verdict/egress-classify.js';
6
8
  import { decodeGitEffects } from '../verdict/git-classifier.js';
7
9
  import { resolveLauncherRecipe } from '../verdict/launcher-resolve.js';
8
- import { extractRecursiveScript, isCommandInspection, isDynamicRecursiveEvaluation, parseSegment, redactCommand, segmentOpacity, splitStructuralShellSegments, structuralSubstitutionInners, } from '../verdict/parser.js';
10
+ import { decodeRecursiveInvocationTokens, isCommandInspection, parseSegment, redactCommand, segmentOpacity, splitStructuralShellSegments, structuralSubstitutionInners, } from '../verdict/parser.js';
9
11
  import { joinEffectOpacity } from './normalize.js';
10
12
  import { classifyPackageAcquisitionSpec, innerRecipeFromPeel, peelPackageExecArgv, resolveLocalBin, } from './package-exec.js';
11
13
  import { buildShellEffectPlan, } from './shell-build.js';
@@ -126,15 +128,29 @@ function joinNestedOpacity(outer, nested) {
126
128
  }
127
129
  function lowerSegment(command, context) {
128
130
  const commandRedacted = redactCommand(command);
129
- const rawTokens = tokenizeShell(command);
131
+ const lexed = lexShell(command);
132
+ const rawTokens = lexed.tokens.map((token) => token.value);
130
133
  const environment = extractEnvironment(rawTokens, context.env);
131
134
  const env = environment.env;
132
135
  const parsed = parseSegment(command);
133
- const tokens = stripRedirects(environment.commandTokens ?? parsed.tokens).map((token) => expandKnownVariables(token, env));
136
+ const parsedTokens = environment.commandTokens ?? parsed.tokens;
137
+ const tokens = stripRedirects(parsedTokens.length === 0 &&
138
+ rawTokens.length > 0 &&
139
+ rawTokens.every((token) => ENV_PREFIX_PATTERN.test(token))
140
+ ? rawTokens
141
+ : parsedTokens).map((token) => expandKnownVariables(token, env));
142
+ const decoderTokens = alignStructuredTokens(stripStructuredRedirects(lexed.tokens), stripRedirects(parsedTokens));
134
143
  const head = path.basename(tokens[0] ?? parsed.head);
135
144
  let opacity = segmentOpacity(command);
136
145
  const signals = new Set();
137
146
  const requirements = [];
147
+ if (!lexed.complete) {
148
+ requirements.push(requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
149
+ 'shell.grammar_incomplete',
150
+ ]));
151
+ signals.add('shell.grammar_incomplete');
152
+ opacity = joinEffectOpacity(opacity, 'unparseable');
153
+ }
138
154
  addRedirectEffects(requirements, rawTokens, env, context, commandRedacted);
139
155
  addSubstitutionEffects(requirements, command, context, commandRedacted, signals);
140
156
  if (environment.malformed) {
@@ -150,7 +166,7 @@ function lowerSegment(command, context) {
150
166
  signals.add('shell.xargs_stdin_dynamic');
151
167
  opacity = joinEffectOpacity(opacity, 'opaque');
152
168
  }
153
- if (context.depth >= MAX_LOWER_DEPTH) {
169
+ if (context.depth > MAX_LOWER_DEPTH) {
154
170
  requirements.push(requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
155
171
  'shell.lower_depth_exceeded',
156
172
  ]));
@@ -185,31 +201,69 @@ function lowerSegment(command, context) {
185
201
  }
186
202
  return shellSegment(commandRedacted, head, requirements, opacity, signals);
187
203
  }
188
- const recursiveScript = extractRecursiveScript(tokens);
189
- if (recursiveScript && opacity !== 'opaque' && opacity !== 'unparseable') {
190
- const dynamicEvaluation = isDynamicRecursiveEvaluation(tokens);
191
- requirements.push(processRequirement(head || 'sh', 'spawn', commandRedacted, [
204
+ const recursive = decodeRecursiveInvocationTokens(decoderTokens);
205
+ if (recursive.kind === 'static' && opacity !== 'opaque' && opacity !== 'unparseable') {
206
+ requirements.push(processRequirement(recursive.interpreter, 'spawn', commandRedacted, [
192
207
  'shell.recursive_wrapper',
193
- ...(dynamicEvaluation ? ['dynamic_shell_evaluation'] : []),
208
+ 'dynamic_shell_evaluation',
194
209
  ]));
195
- const nested = lowerTopLevelSegments(recursiveScript, {
196
- ...context,
197
- command: recursiveScript,
198
- env,
199
- depth: context.depth + 1,
200
- });
201
- for (const nestedSegment of nested) {
202
- requirements.push(...nestedSegment.requirements.map((entry) => withInnerProvenance(entry, recursiveScript, head, commandRedacted)));
203
- for (const signal of nestedSegment.signals) {
204
- signals.add(signal);
210
+ if (recursive.script !== '') {
211
+ const nested = lowerTopLevelSegments(recursive.script, {
212
+ ...context,
213
+ command: recursive.script,
214
+ env,
215
+ depth: context.depth + 1,
216
+ });
217
+ for (const nestedSegment of nested) {
218
+ requirements.push(...nestedSegment.requirements.map((entry) => withInnerProvenance(entry, recursive.script, head, commandRedacted)));
219
+ for (const signal of nestedSegment.signals)
220
+ signals.add(signal);
205
221
  }
206
222
  }
207
223
  signals.add('shell.recursive_wrapper');
208
- if (dynamicEvaluation) {
209
- signals.add('dynamic_shell_evaluation');
224
+ signals.add('dynamic_shell_evaluation');
225
+ return shellSegment(commandRedacted, head, requirements, 'recursive', signals);
226
+ }
227
+ if (recursive.kind === 'dynamic' || recursive.kind === 'indeterminate') {
228
+ const recursiveSignals = [
229
+ recursive.signal,
230
+ ...(recursive.kind === 'dynamic' ? ['dynamic_shell_evaluation'] : []),
231
+ ];
232
+ requirements.push(processRequirement(recursive.interpreter, 'spawn', commandRedacted, recursiveSignals), requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
233
+ ...recursiveSignals,
234
+ ]));
235
+ for (const signal of recursiveSignals)
236
+ signals.add(signal);
237
+ return shellSegment(commandRedacted, head, requirements, joinEffectOpacity(opacity, 'opaque'), signals);
238
+ }
239
+ const compose = decodeStructuredDockerComposeRun(decoderTokens);
240
+ if (compose.kind === 'recursive' && opacity !== 'opaque' && opacity !== 'unparseable') {
241
+ requirements.push(processRequirement(head, 'spawn', commandRedacted, ['process.docker_compose_run']));
242
+ if (compose.script !== '') {
243
+ const nested = lowerTopLevelSegments(compose.script, {
244
+ ...context,
245
+ command: compose.script,
246
+ env,
247
+ depth: context.depth + 1,
248
+ });
249
+ for (const nestedSegment of nested) {
250
+ requirements.push(...nestedSegment.requirements.map((entry) => withInnerProvenance(entry, compose.script, head, commandRedacted)));
251
+ for (const signal of nestedSegment.signals)
252
+ signals.add(signal);
253
+ opacity = joinNestedOpacity(opacity, nestedSegment);
254
+ }
210
255
  }
256
+ signals.add('process.docker_compose_run');
211
257
  return shellSegment(commandRedacted, head, requirements, 'recursive', signals);
212
258
  }
259
+ if (compose.kind === 'dynamic' || compose.kind === 'indeterminate') {
260
+ requirements.push(processRequirement(head, 'spawn', commandRedacted, ['process.docker_compose_run']), requirement('indeterminate', 'indeterminate', { kind: 'unknown' }, commandRedacted, [
261
+ compose.signal,
262
+ ]));
263
+ signals.add('process.docker_compose_run');
264
+ signals.add(compose.signal);
265
+ return shellSegment(commandRedacted, head, requirements, joinEffectOpacity(opacity, 'opaque'), signals);
266
+ }
213
267
  const launcher = resolveLauncherRecipe({
214
268
  tokens,
215
269
  cwd: context.cwd,
@@ -381,6 +435,101 @@ function railsReadOnlySubcommand(args) {
381
435
  }
382
436
  return RAILS_READ_ONLY_SUBCOMMANDS.has(subcommand);
383
437
  }
438
+ function isRubyTestScript(scriptPath) {
439
+ const base = path.basename(scriptPath);
440
+ return base.endsWith('_test.rb') || base.endsWith('_spec.rb');
441
+ }
442
+ function parseRubyTestInvocation(args) {
443
+ const includePaths = [];
444
+ for (let index = 0; index < args.length; index += 1) {
445
+ const arg = args[index] ?? '';
446
+ if (arg === '-e' || arg === '-r') {
447
+ return null;
448
+ }
449
+ if (arg === '-I') {
450
+ const includePath = args[index + 1];
451
+ if (!includePath) {
452
+ return null;
453
+ }
454
+ includePaths.push(includePath);
455
+ index += 1;
456
+ continue;
457
+ }
458
+ if (arg.startsWith('-I') && arg.length > 2) {
459
+ includePaths.push(arg.slice(2));
460
+ continue;
461
+ }
462
+ if (arg.startsWith('-')) {
463
+ if (arg === '-n') {
464
+ if (!args[index + 1]) {
465
+ return null;
466
+ }
467
+ index += 1;
468
+ continue;
469
+ }
470
+ if (arg.startsWith('-n')) {
471
+ continue;
472
+ }
473
+ return null;
474
+ }
475
+ if (isRubyTestScript(arg)) {
476
+ return { includePaths, scriptPath: arg };
477
+ }
478
+ return null;
479
+ }
480
+ return null;
481
+ }
482
+ function isRubocopMutating(args) {
483
+ return args.some((arg) => arg === '-A' ||
484
+ arg === '-a' ||
485
+ arg === '--auto-correct' ||
486
+ arg === '--autocorrect' ||
487
+ arg.startsWith('--auto-correct-all') ||
488
+ arg.startsWith('--autocorrect-all'));
489
+ }
490
+ function decodeBundleExecInner(innerHead, innerArgs, segment) {
491
+ const innerBase = executableBaseName(innerHead);
492
+ if (innerBase === 'rubocop') {
493
+ const mutating = isRubocopMutating(innerArgs);
494
+ return [
495
+ processRequirement(innerHead, mutating ? 'spawn' : 'inspect', segment, mutating ? ['process.linter.mutating'] : ['process.inspect.linter']),
496
+ ];
497
+ }
498
+ if (innerBase === 'rspec') {
499
+ const targetArgs = innerArgs.filter((arg) => !arg.startsWith('-'));
500
+ if (targetArgs.length === 0) {
501
+ return null;
502
+ }
503
+ return [processRequirement(innerHead, 'spawn', segment, ['process.test_runner.rspec'])];
504
+ }
505
+ return null;
506
+ }
507
+ function decodeRuby(args, cwd, repoRoot, segment) {
508
+ const parsed = parseRubyTestInvocation(args);
509
+ if (!parsed) {
510
+ return unsupportedProcess('ruby', segment, 'process.ruby_grammar_incomplete');
511
+ }
512
+ const scriptPath = resolvePathOperand(parsed.scriptPath, cwd);
513
+ if (!pathWithinRoot(canonicalPath(repoRoot), canonicalPath(scriptPath))) {
514
+ return unsupportedProcess('ruby', segment, 'process.ruby_outside_repo');
515
+ }
516
+ for (const includePath of parsed.includePaths) {
517
+ const resolvedInclude = resolvePathOperand(includePath, cwd);
518
+ if (!pathWithinRoot(canonicalPath(repoRoot), canonicalPath(resolvedInclude))) {
519
+ return unsupportedProcess('ruby', segment, 'process.ruby_outside_repo');
520
+ }
521
+ }
522
+ const lowered = [
523
+ processRequirement('ruby', 'spawn', segment, ['process.test_runner.minitest']),
524
+ requirement('fs.read', 'fs.read', { kind: 'path', path: scriptPath }, segment, [
525
+ 'ruby.minitest_script_read',
526
+ ]),
527
+ ];
528
+ for (const includePath of parsed.includePaths) {
529
+ lowered.push(requirement('fs.read', 'fs.read', { kind: 'path', path: resolvePathOperand(includePath, cwd) }, segment, ['ruby.minitest_load_path_read']));
530
+ }
531
+ return lowered;
532
+ }
384
533
  function decodeRuntimeMetadataProcess(head, args, segment) {
385
534
  if (head === 'bundle') {
386
535
  if (args.length === 1 && isMetadataOnlyArgv(args)) {
@@ -402,6 +551,10 @@ function decodeRuntimeMetadataProcess(head, args, segment) {
402
551
  ]),
403
552
  ];
404
553
  }
554
+ const bundleExecInner = decodeBundleExecInner(innerHead, innerArgs, segment);
555
+ if (bundleExecInner) {
556
+ return bundleExecInner;
557
+ }
405
558
  }
406
559
  return null;
407
560
  }
@@ -417,9 +570,75 @@ function decodeRuntimeMetadataProcess(head, args, segment) {
417
570
  }
418
571
  return null;
419
572
  }
573
+ function decodeSetBuiltin(args) {
574
+ let index = 0;
575
+ while (index < args.length) {
576
+ const arg = args[index] ?? '';
577
+ if (arg === '--') {
578
+ index += 1;
579
+ continue;
580
+ }
581
+ if (arg === '-o' || arg === '+o') {
582
+ if (!args[index + 1]) {
583
+ return false;
584
+ }
585
+ index += 2;
586
+ continue;
587
+ }
588
+ if (/^[-+][A-Za-z0-9]+$/.test(arg)) {
589
+ index += 1;
590
+ continue;
591
+ }
592
+ return false;
593
+ }
594
+ return true;
595
+ }
596
+ function decodeShellControlBuiltin(head, args) {
597
+ if (head === 'set') {
598
+ return decodeSetBuiltin(args) ? [] : null;
599
+ }
600
+ if (head === 'wait') {
601
+ if (args.length === 0 || args.every((arg) => /^\d+$/.test(arg))) {
602
+ return [];
603
+ }
604
+ return null;
605
+ }
606
+ if (head === 'exit') {
607
+ if (args.length === 0 || (args.length === 1 && /^-?\d+$/.test(args[0] ?? ''))) {
608
+ return [];
609
+ }
610
+ return null;
611
+ }
612
+ return null;
613
+ }
614
+ function decodeDockerComposeRun(head, args, segment) {
615
+ let composeArgs = null;
616
+ let command = head;
617
+ if (head === 'docker-compose') {
618
+ composeArgs = args;
619
+ }
620
+ else if (head === 'docker' && args[0] === 'compose') {
621
+ composeArgs = args.slice(1);
622
+ command = 'docker';
623
+ }
624
+ if (!composeArgs) {
625
+ return null;
626
+ }
627
+ if (composeArgs.includes('run')) {
628
+ return [processRequirement(command, 'spawn', segment, ['process.docker_compose_run'])];
629
+ }
630
+ return unsupportedProcess(command, segment, 'process.docker_compose_grammar_incomplete');
631
+ }
420
632
  function decodeProcessOrFilesystem(params) {
421
633
  const { tokens, head, env, cwd, repoRoot, segment } = params;
422
634
  const args = tokens.slice(1);
635
+ if (tokens.length > 0 && tokens.every((token) => ENV_PREFIX_PATTERN.test(token))) {
636
+ return [];
637
+ }
638
+ const shellControl = decodeShellControlBuiltin(head, args);
639
+ if (shellControl) {
640
+ return shellControl;
641
+ }
423
642
  if (isCommandInspection(tokens)) {
424
643
  return [processRequirement(head, 'inspect', segment, ['process.inspect.command_lookup'])];
425
644
  }
@@ -500,6 +719,19 @@ function decodeProcessOrFilesystem(params) {
500
719
  if (runtimeMetadata) {
501
720
  return runtimeMetadata;
502
721
  }
722
+ if (head === 'ruby') {
723
+ return decodeRuby(args, cwd, repoRoot, segment);
724
+ }
725
+ if (head === 'rubocop' || head === 'rspec') {
726
+ const decoded = decodeBundleExecInner(head, args, segment);
727
+ if (decoded) {
728
+ return decoded;
729
+ }
730
+ }
731
+ const dockerCompose = decodeDockerComposeRun(head, args, segment);
732
+ if (dockerCompose) {
733
+ return dockerCompose;
734
+ }
503
735
  if ((head === 'npm' || head === 'pnpm') && args.length === 1 && isMetadataOnlyArgv(args)) {
504
736
  return [processRequirement(head, 'inspect', segment, ['process.inspect.package_manager'])];
505
737
  }
@@ -1134,15 +1366,39 @@ function stripRedirects(tokens) {
1134
1366
  stripped.push(token);
1135
1367
  continue;
1136
1368
  }
1137
- if (token.includes('>') || token.includes('<')) {
1138
- const inline = token.replace(/^\d*(?:>>?|<<?|<>|>\|)/, '');
1139
- if (!inline) {
1140
- index += 1;
1141
- }
1369
+ index += 1;
1370
+ }
1371
+ return stripped;
1372
+ }
1373
+ function stripStructuredRedirects(tokens) {
1374
+ const stripped = [];
1375
+ for (let index = 0; index < tokens.length; index += 1) {
1376
+ const token = tokens[index];
1377
+ if (!token)
1378
+ continue;
1379
+ if (isFdDuplication(token.value)) {
1380
+ continue;
1142
1381
  }
1382
+ if (!isRedirectOperator(token.value)) {
1383
+ stripped.push(token);
1384
+ continue;
1385
+ }
1386
+ index += 1;
1143
1387
  }
1144
1388
  return stripped;
1145
1389
  }
1390
+ function alignStructuredTokens(tokens, values) {
1391
+ if (values.length === 0)
1392
+ return [];
1393
+ for (let start = tokens.length - values.length; start >= 0; start -= 1) {
1394
+ const candidate = tokens.slice(start);
1395
+ if (candidate.length === values.length &&
1396
+ candidate.every((token, index) => token.value === values[index])) {
1397
+ return candidate;
1398
+ }
1399
+ }
1400
+ return [];
1401
+ }
1146
1402
  function shellSegment(commandRedacted, segmentHead, requirements, opacity, signals) {
1147
1403
  const normalizedRequirements = requirements.flatMap((entry) => {
1148
1404
  const dynamicSignal = dynamicResourceSignal(entry.resource);
@@ -1,5 +1,6 @@
1
1
  import type { GatedActionKind } from './gate-contract.js';
2
2
  import type { ScrubOptions } from './types.js';
3
+ export declare function redactToolInvocationId(value: unknown, rawToolUseId?: string): unknown;
3
4
  /** Subagent fingerprint input — must match classify-subagent `fingerprintSource`. */
4
5
  export declare function subagentFingerprintSource(payload: Record<string, unknown>, scrubOptions: ScrubOptions): unknown;
5
6
  /**
@@ -1,4 +1,22 @@
1
1
  import { scrubValue } from './scrub.js';
2
+ export function redactToolInvocationId(value, rawToolUseId) {
3
+ if (typeof value === 'string') {
4
+ return rawToolUseId ? value.replaceAll(rawToolUseId, '<tool-use-id>') : value;
5
+ }
6
+ if (Array.isArray(value)) {
7
+ return value.map((item) => redactToolInvocationId(item, rawToolUseId));
8
+ }
9
+ if (value && typeof value === 'object') {
10
+ const result = {};
11
+ for (const [key, child] of Object.entries(value)) {
12
+ if (key !== 'tool_use_id') {
13
+ result[key] = redactToolInvocationId(child, rawToolUseId);
14
+ }
15
+ }
16
+ return result;
17
+ }
18
+ return value;
19
+ }
2
20
  /** Subagent fingerprint input — must match classify-subagent `fingerprintSource`. */
3
21
  export function subagentFingerprintSource(payload, scrubOptions) {
4
22
  const toolInput = payload.tool_input;
@@ -30,14 +48,15 @@ export function fingerprintReplayPayload(kind, payload, scrubOptions) {
30
48
  if (!payload) {
31
49
  return undefined;
32
50
  }
51
+ const replayPayload = redactToolInvocationId(payload, typeof payload.tool_use_id === 'string' ? payload.tool_use_id : undefined);
33
52
  if (kind === 'tool') {
34
- const toolInput = payload.tool_input;
53
+ const toolInput = replayPayload.tool_input;
35
54
  if (toolInput && typeof toolInput === 'object') {
36
55
  return scrubValue(toolInput, scrubOptions);
37
56
  }
38
57
  }
39
58
  if (kind === 'subagent') {
40
- return subagentFingerprintSource(payload, scrubOptions);
59
+ return subagentFingerprintSource(replayPayload, scrubOptions);
41
60
  }
42
- return scrubValue(payload, scrubOptions);
61
+ return scrubValue(replayPayload, scrubOptions);
43
62
  }
@@ -1,5 +1,33 @@
1
1
  export declare function isRedirectOperator(token: string): boolean;
2
2
  export declare function isFdDuplication(token: string): boolean;
3
+ export type ShellQuoteMode = 'unquoted' | 'single' | 'double';
4
+ export interface ShellWordPart {
5
+ value: string;
6
+ raw: string;
7
+ start: number;
8
+ end: number;
9
+ quote: ShellQuoteMode;
10
+ hasExpansion: boolean;
11
+ }
12
+ export type ShellToken = {
13
+ kind: 'word';
14
+ value: string;
15
+ raw: string;
16
+ start: number;
17
+ end: number;
18
+ parts: ShellWordPart[];
19
+ } | {
20
+ kind: 'operator';
21
+ value: string;
22
+ raw: string;
23
+ start: number;
24
+ end: number;
25
+ };
26
+ export interface ShellLexResult {
27
+ tokens: ShellToken[];
28
+ complete: boolean;
29
+ }
30
+ export declare function lexShell(input: string): ShellLexResult;
3
31
  export declare function tokenizeShell(input: string): string[];
4
32
  export declare function normalizeShellCommand(command: string, repoRoot: string, normalizeToken: (t: string, r: string) => string): string;
5
33
  export declare function splitTopLevelSegments(tokens: string[]): string[][];