@guilz-dev/belay 0.9.2 → 0.9.4

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 (118) hide show
  1. package/README.md +36 -2
  2. package/dist/adapters/codex/hooks.d.ts +1 -0
  3. package/dist/adapters/codex/hooks.js +3 -0
  4. package/dist/adapters/codex/runtime-entry.d.ts +3 -0
  5. package/dist/adapters/codex/runtime-entry.js +27 -4
  6. package/dist/adapters/cursor/cwd-resolution.d.ts +10 -0
  7. package/dist/adapters/cursor/cwd-resolution.js +58 -0
  8. package/dist/adapters/cursor/dispatcher-generation.d.ts +3 -0
  9. package/dist/adapters/cursor/dispatcher-generation.js +5 -0
  10. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +7 -0
  11. package/dist/adapters/cursor/hook-dispatch-entry.js +126 -0
  12. package/dist/adapters/cursor/hook-router.d.ts +23 -0
  13. package/dist/adapters/cursor/hook-router.js +266 -0
  14. package/dist/adapters/cursor/hooks.d.ts +5 -0
  15. package/dist/adapters/cursor/hooks.js +115 -3
  16. package/dist/adapters/cursor/routing-layout.d.ts +5 -0
  17. package/dist/adapters/cursor/routing-layout.js +29 -0
  18. package/dist/adapters/cursor/runtime-entry.d.ts +18 -0
  19. package/dist/adapters/cursor/runtime-entry.js +135 -34
  20. package/dist/adapters/layouts/scope.d.ts +5 -0
  21. package/dist/adapters/layouts/scope.js +71 -0
  22. package/dist/adapters/shared/gate-runtime.js +27 -3
  23. package/dist/adapters/shared/repo-root.js +20 -1
  24. package/dist/bundle/claude-runtime.mjs +2660 -1951
  25. package/dist/bundle/codex-runtime.mjs +2680 -1953
  26. package/dist/bundle/cursor-dispatcher.mjs +443 -0
  27. package/dist/bundle/cursor-runtime.mjs +5638 -4697
  28. package/dist/cli.js +33 -3
  29. package/dist/commands/doctor.js +215 -16
  30. package/dist/commands/health-snapshot.d.ts +3 -0
  31. package/dist/commands/health-snapshot.js +61 -0
  32. package/dist/commands/recovery-checkpoints.d.ts +3 -3
  33. package/dist/commands/report.js +14 -0
  34. package/dist/commands/status.js +15 -0
  35. package/dist/commands/where.d.ts +4 -0
  36. package/dist/commands/where.js +52 -0
  37. package/dist/config-io.js +11 -2
  38. package/dist/core/approval-repo-lookup.d.ts +16 -0
  39. package/dist/core/approval-repo-lookup.js +48 -0
  40. package/dist/core/audit-io.d.ts +1 -1
  41. package/dist/core/audit-io.js +1 -1
  42. package/dist/core/audit-legacy-archive.js +43 -25
  43. package/dist/core/audit-query.d.ts +1 -0
  44. package/dist/core/audit-query.js +7 -0
  45. package/dist/core/audit-serialize.d.ts +3 -0
  46. package/dist/core/audit-serialize.js +39 -3
  47. package/dist/core/audit-summary.d.ts +9 -0
  48. package/dist/core/audit-summary.js +58 -1
  49. package/dist/core/audit-types.d.ts +4 -0
  50. package/dist/core/classify-tool.js +16 -8
  51. package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
  52. package/dist/core/effect-ir/argv-delegate.js +42 -0
  53. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
  54. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
  55. package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
  56. package/dist/core/effect-ir/shell-lower/augment.js +97 -0
  57. package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
  58. package/dist/core/effect-ir/shell-lower/context.js +1 -0
  59. package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
  60. package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
  61. package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
  62. package/dist/core/effect-ir/shell-lower/decoders/belay.js +29 -0
  63. package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
  64. package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
  65. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
  66. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
  67. package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
  68. package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
  69. package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
  70. package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
  71. package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
  72. package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
  73. package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
  74. package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
  75. package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
  76. package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
  77. package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
  78. package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
  79. package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
  80. package/dist/core/effect-ir/shell-lower/segment.js +82 -0
  81. package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
  82. package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
  83. package/dist/core/effect-ir/shell-lower.d.ts +2 -7
  84. package/dist/core/effect-ir/shell-lower.js +122 -1511
  85. package/dist/core/glob.js +32 -16
  86. package/dist/core/integrity.d.ts +2 -2
  87. package/dist/core/integrity.js +52 -12
  88. package/dist/core/replay-scrub.d.ts +3 -0
  89. package/dist/core/replay-scrub.js +30 -3
  90. package/dist/core/shell-substitution.js +2 -2
  91. package/dist/core/shell-tokenizer.d.ts +28 -0
  92. package/dist/core/shell-tokenizer.js +111 -29
  93. package/dist/core/shell-unparseable.js +3 -1
  94. package/dist/core/verdict/docker-compose-run.d.ts +18 -0
  95. package/dist/core/verdict/docker-compose-run.js +136 -0
  96. package/dist/core/verdict/launcher-resolve.js +28 -28
  97. package/dist/core/verdict/parser.d.ts +3 -0
  98. package/dist/core/verdict/parser.js +23 -40
  99. package/dist/core/verdict/recursive-invocation.d.ts +20 -0
  100. package/dist/core/verdict/recursive-invocation.js +224 -0
  101. package/dist/defaults.d.ts +7 -3
  102. package/dist/defaults.js +35 -21
  103. package/dist/installer/bootstrap.d.ts +1 -0
  104. package/dist/installer/bootstrap.js +2 -2
  105. package/dist/installer/runtime-artifacts.js +43 -13
  106. package/dist/installer/scope-config.d.ts +2 -2
  107. package/dist/installer.d.ts +10 -1
  108. package/dist/installer.js +83 -7
  109. package/dist/node-resolution.d.ts +1 -0
  110. package/dist/node-resolution.js +61 -0
  111. package/dist/templates.d.ts +7 -4
  112. package/dist/templates.js +42 -4
  113. package/dist/types.d.ts +35 -1
  114. package/dist/version.d.ts +1 -1
  115. package/dist/version.js +1 -1
  116. package/package.json +5 -2
  117. package/skills/belay/SKILL.md +5 -0
  118. package/skills/belay/belay-report.md +4 -1
@@ -0,0 +1,16 @@
1
+ import type { AdapterName } from '../adapters/layouts/types.js';
2
+ export type ApprovalRepoLookupResult = {
3
+ status: 'found';
4
+ repoRoot: string;
5
+ } | {
6
+ status: 'ambiguous';
7
+ repoRoots: string[];
8
+ } | {
9
+ status: 'not_found';
10
+ };
11
+ export declare function findApprovalRepoRoots(params: {
12
+ approvalId: string;
13
+ candidateRepoRoots: string[];
14
+ adapter: AdapterName;
15
+ }): Promise<ApprovalRepoLookupResult>;
16
+ export declare function formatAmbiguousApprovalRepoMessage(repoRoots: string[]): string;
@@ -0,0 +1,48 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { approvedApprovalsPath, loadConfigFile, pendingApprovalsPath } from '../config-io.js';
4
+ async function approvalStateContainsId(filePath, approvalId) {
5
+ try {
6
+ const raw = await readFile(filePath, 'utf8');
7
+ const parsed = JSON.parse(raw);
8
+ return parsed.approvals?.some((entry) => entry.approvalId === approvalId) ?? false;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export async function findApprovalRepoRoots(params) {
15
+ const matches = [];
16
+ const seen = new Set();
17
+ for (const candidate of params.candidateRepoRoots) {
18
+ const repoRoot = path.resolve(candidate);
19
+ if (seen.has(repoRoot)) {
20
+ continue;
21
+ }
22
+ seen.add(repoRoot);
23
+ try {
24
+ const config = await loadConfigFile(repoRoot, params.adapter);
25
+ const pendingPath = pendingApprovalsPath(repoRoot, config);
26
+ const approvedPath = approvedApprovalsPath(repoRoot, config);
27
+ const hasPending = await approvalStateContainsId(pendingPath, params.approvalId);
28
+ const hasApproved = await approvalStateContainsId(approvedPath, params.approvalId);
29
+ if (hasPending || hasApproved) {
30
+ matches.push(repoRoot);
31
+ }
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ }
37
+ if (matches.length === 1) {
38
+ return { status: 'found', repoRoot: matches[0] };
39
+ }
40
+ if (matches.length > 1) {
41
+ return { status: 'ambiguous', repoRoots: matches };
42
+ }
43
+ return { status: 'not_found' };
44
+ }
45
+ export function formatAmbiguousApprovalRepoMessage(repoRoots) {
46
+ return ('Belay found the same approval ID in multiple repositories. ' +
47
+ `Open one workspace and retry: ${repoRoots.join(', ')}`);
48
+ }
@@ -1,3 +1,3 @@
1
1
  import type { BelayConfigV4 } from './config.js';
2
- export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, } from './audit-serialize.js';
2
+ export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, canonicalToolUseIdForCorrelation, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, toolInvocationCorrelationId, } from './audit-serialize.js';
3
3
  export declare function appendCliAuditEvent(repoRoot: string, config: BelayConfigV4, event: Record<string, unknown>): Promise<void>;
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { resolveActiveAuditCohort } from '../runtime-provenance.js';
3
3
  import { appendAuditRecord } from './audit-serialize.js';
4
4
  import { scrubOptionsFromConfig } from './config.js';
5
- export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, } from './audit-serialize.js';
5
+ export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, canonicalToolUseIdForCorrelation, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, toolInvocationCorrelationId, } from './audit-serialize.js';
6
6
  export async function appendCliAuditEvent(repoRoot, config, event) {
7
7
  const auditPath = path.isAbsolute(config.audit.logPath)
8
8
  ? config.audit.logPath
@@ -1,43 +1,61 @@
1
- import { existsSync } from 'node:fs';
2
- import { open, rename } from 'node:fs/promises';
1
+ import { createReadStream, existsSync } from 'node:fs';
2
+ import { rename } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { createInterface } from 'node:readline';
4
5
  const LEGACY_PLACEHOLDER_PATTERNS = [
5
6
  /"(?:timestamp|ts)"\s*:\s*"<timestamp>"/,
6
7
  /"(?:fingerprint|commandFingerprint|effectIRHash|payloadHash|configFingerprint|runtimeArtifactHash|decisionConfigFingerprint|receiptHash)"\s*:\s*"<high-entropy>"/,
7
8
  /"approvalId"\s*:\s*"<approval-id>"/,
8
9
  ];
9
- const AUDIT_SCAN_CHUNK_BYTES = 64 * 1024;
10
- const AUDIT_SCAN_OVERLAP_CHARS = 256;
10
+ const LEGACY_HIGH_ENTROPY_FIELDS = [
11
+ 'fingerprint',
12
+ 'commandFingerprint',
13
+ 'effectIRHash',
14
+ 'payloadHash',
15
+ 'configFingerprint',
16
+ 'runtimeArtifactHash',
17
+ 'decisionConfigFingerprint',
18
+ 'receiptHash',
19
+ ];
11
20
  export function auditLogHasLegacyScrubPlaceholders(sample) {
12
- return LEGACY_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(sample));
21
+ for (const line of sample.split(/\r?\n/)) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed) {
24
+ continue;
25
+ }
26
+ try {
27
+ const parsed = JSON.parse(trimmed);
28
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
29
+ if (auditRecordHasLegacyCorrelationPlaceholders(parsed)) {
30
+ return true;
31
+ }
32
+ continue;
33
+ }
34
+ }
35
+ catch {
36
+ // Malformed legacy lines still need archival before a new runtime appends to the log.
37
+ }
38
+ if (LEGACY_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(trimmed))) {
39
+ return true;
40
+ }
41
+ }
42
+ return false;
13
43
  }
14
44
  export function auditRecordHasLegacyCorrelationPlaceholders(record) {
15
45
  return (record.timestamp === '<timestamp>' ||
16
- record.fingerprint === '<high-entropy>' ||
46
+ record.ts === '<timestamp>' ||
47
+ LEGACY_HIGH_ENTROPY_FIELDS.some((field) => record[field] === '<high-entropy>') ||
17
48
  record.approvalId === '<approval-id>');
18
49
  }
19
50
  async function auditFileHasLegacyScrubPlaceholders(auditPath) {
20
- const handle = await open(auditPath, 'r');
21
- const buffer = Buffer.allocUnsafe(AUDIT_SCAN_CHUNK_BYTES);
22
- let carry = '';
23
- let position = 0;
24
- try {
25
- while (true) {
26
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
27
- if (bytesRead === 0) {
28
- return false;
29
- }
30
- const sample = carry + buffer.toString('utf8', 0, bytesRead);
31
- if (auditLogHasLegacyScrubPlaceholders(sample)) {
32
- return true;
33
- }
34
- carry = sample.slice(-AUDIT_SCAN_OVERLAP_CHARS);
35
- position += bytesRead;
51
+ const input = createReadStream(auditPath, { encoding: 'utf8' });
52
+ const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
53
+ for await (const line of lines) {
54
+ if (auditLogHasLegacyScrubPlaceholders(line)) {
55
+ return true;
36
56
  }
37
57
  }
38
- finally {
39
- await handle.close();
40
- }
58
+ return false;
41
59
  }
42
60
  export async function archiveLegacyAuditLogIfNeeded(repoRoot, config) {
43
61
  const auditPath = path.isAbsolute(config.audit.logPath)
@@ -3,6 +3,7 @@ export declare function toAuditRecord(value: Record<string, unknown>): AuditReco
3
3
  export declare function parseTimestamp(value?: string): number | null;
4
4
  export declare function auditFingerprint(record: AuditRecord): string | undefined;
5
5
  export declare function auditApprovalCorrelationId(record: AuditRecord): string | undefined;
6
+ export declare function auditToolInvocationCorrelationId(record: AuditRecord): string | undefined;
6
7
  export declare function isGateRecord(record: AuditRecord): boolean;
7
8
  export declare function isShellGateRecord(record: AuditRecord): boolean;
8
9
  export declare function isApprovalRecorded(record: AuditRecord): boolean;
@@ -27,6 +27,13 @@ export function auditApprovalCorrelationId(record) {
27
27
  }
28
28
  return undefined;
29
29
  }
30
+ export function auditToolInvocationCorrelationId(record) {
31
+ if (typeof record.toolInvocationCorrelationId === 'string' &&
32
+ isValidApprovalCorrelationId(record.toolInvocationCorrelationId)) {
33
+ return record.toolInvocationCorrelationId;
34
+ }
35
+ return undefined;
36
+ }
30
37
  export function isGateRecord(record) {
31
38
  return typeof record.event === 'string' && GATE_EVENTS.has(record.event);
32
39
  }
@@ -1,6 +1,9 @@
1
1
  import type { ScrubOptions } from './types.js';
2
2
  export declare const AUDIT_SCHEMA_VERSION = 3;
3
3
  export declare function approvalCorrelationId(approvalId: string): string;
4
+ /** Normalize Cursor host tool_use_id values before correlation hashing. */
5
+ export declare function canonicalToolUseIdForCorrelation(toolUseId: string): string;
6
+ export declare function toolInvocationCorrelationId(toolUseId: string): string;
4
7
  export declare function isValidApprovalCorrelationId(value: string): boolean;
5
8
  export declare function isValidAuditTimestamp(value: string): boolean;
6
9
  export declare function isValidAuditFingerprint(value: string): boolean;
@@ -19,6 +19,7 @@ const PRESERVED_HASH_FIELDS = new Set([
19
19
  const PRESERVED_LITERAL_FIELDS = new Set([
20
20
  'timestamp',
21
21
  'approvalCorrelationId',
22
+ 'toolInvocationCorrelationId',
22
23
  'runtimeVersion',
23
24
  'runtimeBuildStamp',
24
25
  'boundaryProfile',
@@ -42,6 +43,27 @@ const SCRUBBED_CONTAINER_FIELDS = new Set([
42
43
  export function approvalCorrelationId(approvalId) {
43
44
  return createHash('sha256').update(approvalId).digest('hex').slice(0, 16);
44
45
  }
46
+ const TOOL_USE_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
47
+ /** Normalize Cursor host tool_use_id values before correlation hashing. */
48
+ export function canonicalToolUseIdForCorrelation(toolUseId) {
49
+ const trimmed = toolUseId.trim();
50
+ if (trimmed.startsWith('tool_')) {
51
+ const remainder = trimmed.slice('tool_'.length);
52
+ if (TOOL_USE_UUID_PATTERN.test(remainder)) {
53
+ return remainder.toLowerCase();
54
+ }
55
+ }
56
+ if (TOOL_USE_UUID_PATTERN.test(trimmed)) {
57
+ return trimmed.toLowerCase();
58
+ }
59
+ return trimmed;
60
+ }
61
+ export function toolInvocationCorrelationId(toolUseId) {
62
+ return createHash('sha256')
63
+ .update(canonicalToolUseIdForCorrelation(toolUseId))
64
+ .digest('hex')
65
+ .slice(0, 16);
66
+ }
45
67
  export function isValidApprovalCorrelationId(value) {
46
68
  return /^[a-f0-9]{16}$/.test(value);
47
69
  }
@@ -64,7 +86,17 @@ function isValidPreservedHashField(field, value) {
64
86
  return isValidAuditFingerprint(value);
65
87
  }
66
88
  function scrubAuditContainer(value, options) {
67
- return scrubValue(value, {
89
+ const withoutRawToolIds = (input) => {
90
+ if (Array.isArray(input))
91
+ return input.map(withoutRawToolIds);
92
+ if (input && typeof input === 'object') {
93
+ return Object.fromEntries(Object.entries(input)
94
+ .filter(([key]) => key !== 'tool_use_id')
95
+ .map(([key, child]) => [key, withoutRawToolIds(child)]));
96
+ }
97
+ return input;
98
+ };
99
+ return scrubValue(withoutRawToolIds(value), {
68
100
  ...options,
69
101
  maskHighEntropyStrings: true,
70
102
  });
@@ -83,7 +115,7 @@ function serializeAuditField(key, value, options) {
83
115
  if (key === 'timestamp' && typeof value === 'string' && isValidAuditTimestamp(value)) {
84
116
  return value;
85
117
  }
86
- if (key === 'approvalCorrelationId' &&
118
+ if ((key === 'approvalCorrelationId' || key === 'toolInvocationCorrelationId') &&
87
119
  typeof value === 'string' &&
88
120
  isValidApprovalCorrelationId(value)) {
89
121
  return value;
@@ -145,7 +177,11 @@ export function serializeAuditRecordV3(record, options) {
145
177
  serialized.approvalCorrelationId = record.approvalCorrelationId;
146
178
  }
147
179
  for (const [key, value] of Object.entries(record)) {
148
- if (key === 'timestamp' || key === 'ts' || key === 'approvalId' || key === 'schemaVersion') {
180
+ if (key === 'timestamp' ||
181
+ key === 'ts' ||
182
+ key === 'approvalId' ||
183
+ key === 'tool_use_id' ||
184
+ key === 'schemaVersion') {
149
185
  continue;
150
186
  }
151
187
  const next = serializeAuditField(key, value, options);
@@ -6,6 +6,12 @@ export interface RecentAskEntry {
6
6
  reason: string;
7
7
  tier: AuditTier;
8
8
  }
9
+ export interface RecentHostDenialEntry {
10
+ gateTimestamp?: string;
11
+ failureTimestamp?: string;
12
+ summary: string;
13
+ errorMessage: string;
14
+ }
9
15
  export interface AuditVisibilitySummary {
10
16
  gateEvents: number;
11
17
  askCount: number;
@@ -16,6 +22,9 @@ export interface AuditVisibilitySummary {
16
22
  allowCount: number;
17
23
  silentPassRate: number;
18
24
  recentAsks: RecentAskEntry[];
25
+ hostDeniedAfterAllowCount: number;
26
+ recentHostDenials: RecentHostDenialEntry[];
27
+ unrecognizedHostFailureCount: number;
19
28
  }
20
29
  export declare const DEFAULT_SILENT_PASS_THRESHOLD = 0.5;
21
30
  export declare const MIN_GATE_EVENTS_FOR_FENCE_DRIFT = 20;
@@ -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;
@@ -5,6 +5,7 @@ import { evaluateFileMutationPolicy, policyDecisionRequiresAsk, } from './capabi
5
5
  import { canonicalStringify, toolFingerprint } from './fingerprint.js';
6
6
  import { matchesSensitivePath } from './glob.js';
7
7
  import { pathWithinRoot, resolveWorkspaceRootMatch } from './path-utils.js';
8
+ import { fingerprintToolInputSource } from './replay-scrub.js';
8
9
  import { scrubValue } from './scrub.js';
9
10
  import { classifyShell, resolveClassifierTrustedCwd } from './verdict/adapter.js';
10
11
  import { isGitPath } from './verdict/containment.js';
@@ -37,6 +38,13 @@ function policyAuth(options) {
37
38
  function scrubPayload(value, options) {
38
39
  return scrubValue(value, options.scrubOptions);
39
40
  }
41
+ function fingerprintPayload(payload, options) {
42
+ const toolInput = fingerprintToolInputSource(payload);
43
+ if (Object.keys(toolInput).length > 0) {
44
+ return toolInput;
45
+ }
46
+ return scrubPayload(payload.tool_input ?? {}, options);
47
+ }
40
48
  function extractFilePath(payload) {
41
49
  const toolInput = payload.tool_input;
42
50
  if (!toolInput || typeof toolInput !== 'object') {
@@ -209,7 +217,7 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
209
217
  verdict: 'deny_pending_approval',
210
218
  reason: 'tool_shell_missing_command',
211
219
  summary: canonicalStringify(scrubPayload(payload.tool_input ?? {}, options)),
212
- fingerprint: toolFingerprint(toolName, scrubPayload(payload.tool_input ?? {}, options), repoRoot),
220
+ fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
213
221
  assessment: {
214
222
  reversibility: 'irreversible',
215
223
  external: false,
@@ -223,7 +231,7 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
223
231
  verdict: 'allow_flagged',
224
232
  reason: 'tool_shell_missing_command',
225
233
  summary: canonicalStringify(scrubPayload(payload.tool_input ?? {}, options)),
226
- fingerprint: toolFingerprint(toolName, scrubPayload(payload.tool_input ?? {}, options), repoRoot),
234
+ fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
227
235
  assessment: {
228
236
  reversibility: 'recoverable_with_cost',
229
237
  external: false,
@@ -249,7 +257,7 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
249
257
  verdict: 'deny_pending_approval',
250
258
  reason: 'file_mutation_missing_path',
251
259
  summary: canonicalStringify(scrubPayload(payload.tool_input ?? {}, options)),
252
- fingerprint: toolFingerprint(toolName, scrubPayload(payload.tool_input ?? {}, options), repoRoot),
260
+ fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
253
261
  assessment: {
254
262
  reversibility: 'irreversible',
255
263
  external: false,
@@ -263,7 +271,7 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
263
271
  verdict: 'allow_flagged',
264
272
  reason: 'file_mutation_missing_path',
265
273
  summary: canonicalStringify(scrubPayload(payload.tool_input ?? {}, options)),
266
- fingerprint: toolFingerprint(toolName, scrubPayload(payload.tool_input ?? {}, options), repoRoot),
274
+ fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
267
275
  assessment: {
268
276
  reversibility: 'recoverable_with_cost',
269
277
  external: false,
@@ -409,7 +417,7 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
409
417
  verdict: 'deny_pending_approval',
410
418
  reason: 'apply_patch_missing_path',
411
419
  summary: canonicalStringify(scrubPayload(payload.tool_input ?? {}, options)),
412
- fingerprint: toolFingerprint(toolName, scrubPayload(payload.tool_input ?? {}, options), repoRoot),
420
+ fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
413
421
  assessment: {
414
422
  reversibility: 'irreversible',
415
423
  external: false,
@@ -423,7 +431,7 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
423
431
  verdict: 'allow_flagged',
424
432
  reason: 'apply_patch_missing_path',
425
433
  summary: canonicalStringify(scrubPayload(payload.tool_input ?? {}, options)),
426
- fingerprint: toolFingerprint(toolName, scrubPayload(payload.tool_input ?? {}, options), repoRoot),
434
+ fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
427
435
  assessment: {
428
436
  reversibility: 'recoverable_with_cost',
429
437
  external: false,
@@ -448,7 +456,7 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
448
456
  verdict: 'allow_flagged',
449
457
  reason: sawDelete ? 'file_delete' : 'file_mutation',
450
458
  summary: targets.map((target) => target.path).join(', '),
451
- fingerprint: toolFingerprint(toolName, scrubPayload(payload.tool_input ?? {}, options), repoRoot),
459
+ fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
452
460
  assessment: {
453
461
  reversibility: 'recoverable_with_cost',
454
462
  external: false,
@@ -462,7 +470,7 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
462
470
  verdict: 'allow',
463
471
  reason: 'unclassified_tool',
464
472
  summary: canonicalStringify(scrubPayload(payload.tool_input ?? {}, options)),
465
- fingerprint: toolFingerprint(toolName, scrubPayload(payload.tool_input ?? {}, options), repoRoot),
473
+ fingerprint: toolFingerprint(toolName, fingerprintPayload(payload, options), repoRoot),
466
474
  assessment: {
467
475
  reversibility: 'reversible',
468
476
  external: false,
@@ -0,0 +1,9 @@
1
+ export interface ArgvDelegatePeelResult {
2
+ launcher: string;
3
+ innerTokens: string[];
4
+ opaque: boolean;
5
+ reason: string;
6
+ signals: string[];
7
+ }
8
+ export declare function innerRecipeFromArgvDelegate(peel: ArgvDelegatePeelResult): string | null;
9
+ export declare function peelArgvDelegateArgv(tokens: string[]): ArgvDelegatePeelResult | null;
@@ -0,0 +1,42 @@
1
+ export function innerRecipeFromArgvDelegate(peel) {
2
+ if (peel.opaque || peel.innerTokens.length === 0) {
3
+ return null;
4
+ }
5
+ return peel.innerTokens.join(' ');
6
+ }
7
+ export function peelArgvDelegateArgv(tokens) {
8
+ const launcher = tokens[0] ?? '';
9
+ if (!launcher || tokens.length < 2) {
10
+ return null;
11
+ }
12
+ const signals = ['process.argv_delegate'];
13
+ let index = 1;
14
+ while (index < tokens.length) {
15
+ const token = tokens[index] ?? '';
16
+ if (token === '--') {
17
+ index += 1;
18
+ break;
19
+ }
20
+ if (token.startsWith('-')) {
21
+ return {
22
+ launcher,
23
+ innerTokens: [],
24
+ opaque: true,
25
+ reason: 'argv_delegate_wrapper_options',
26
+ signals: [...signals, 'argv_delegate_wrapper_options'],
27
+ };
28
+ }
29
+ break;
30
+ }
31
+ const innerTokens = tokens.slice(index);
32
+ if (innerTokens.length === 0) {
33
+ return null;
34
+ }
35
+ return {
36
+ launcher,
37
+ innerTokens,
38
+ opaque: false,
39
+ reason: 'argv_delegate_peel',
40
+ signals,
41
+ };
42
+ }
@@ -0,0 +1,3 @@
1
+ import type { ShellEffectRequirement } from '../shell-build.js';
2
+ export declare function shouldApplyArgvDelegate(head: string, innerTokens: string[], depth: number): boolean;
3
+ export declare function isGrammarUnknownOnly(requirements: ShellEffectRequirement[], head: string): boolean;
@@ -0,0 +1,40 @@
1
+ import path from 'node:path';
2
+ import { isEgressToolHead } from '../../verdict/egress-classify.js';
3
+ const ARGV_DELEGATE_INNER_BLOCKLIST = new Set([
4
+ 'sudo',
5
+ 'env',
6
+ 'command',
7
+ 'builtin',
8
+ 'exec',
9
+ 'time',
10
+ 'nice',
11
+ 'nohup',
12
+ 'stdbuf',
13
+ 'setsid',
14
+ '(',
15
+ ]);
16
+ export function shouldApplyArgvDelegate(head, innerTokens, depth) {
17
+ if (depth > 0 || isEgressToolHead(head) || head === 'bundle') {
18
+ return false;
19
+ }
20
+ if (ARGV_DELEGATE_INNER_BLOCKLIST.has(head)) {
21
+ return false;
22
+ }
23
+ const innerHead = path.basename(innerTokens[0] ?? '');
24
+ if (ARGV_DELEGATE_INNER_BLOCKLIST.has(innerHead)) {
25
+ return false;
26
+ }
27
+ return innerTokens.length >= 2;
28
+ }
29
+ export function isGrammarUnknownOnly(requirements, head) {
30
+ if (requirements.length !== 2) {
31
+ return false;
32
+ }
33
+ const [spawn, indeterminate] = requirements;
34
+ return (spawn.action === 'process.exec' &&
35
+ spawn.resource.kind === 'executable' &&
36
+ spawn.resource.command === head &&
37
+ spawn.resource.operation === 'spawn' &&
38
+ indeterminate.action === 'indeterminate' &&
39
+ indeterminate.evidence.signals.includes('process.grammar_unknown'));
40
+ }
@@ -0,0 +1,4 @@
1
+ import type { ShellEffectRequirement } from '../shell-build.js';
2
+ import type { LowerContext } from './context.js';
3
+ export declare function addRedirectEffects(requirements: ShellEffectRequirement[], tokens: string[], env: Readonly<Record<string, string | undefined>>, context: LowerContext, segment: string): void;
4
+ export declare function effectChangingEnvironmentSignals(head: string, env: Readonly<Record<string, string | undefined>>, changedNames: ReadonlySet<string>): string[];