@guilz-dev/belay 0.9.3 → 0.10.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 (110) hide show
  1. package/README.md +38 -3
  2. package/dist/adapters/codex/hooks.d.ts +1 -0
  3. package/dist/adapters/codex/hooks.js +3 -0
  4. package/dist/adapters/cursor/dispatcher-generation.d.ts +3 -0
  5. package/dist/adapters/cursor/dispatcher-generation.js +5 -0
  6. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +10 -0
  7. package/dist/adapters/cursor/hook-dispatch-entry.js +137 -0
  8. package/dist/adapters/cursor/hook-router.d.ts +23 -0
  9. package/dist/adapters/cursor/hook-router.js +273 -0
  10. package/dist/adapters/cursor/hooks.d.ts +5 -1
  11. package/dist/adapters/cursor/hooks.js +112 -10
  12. package/dist/adapters/cursor/routing-config-trust.d.ts +1 -0
  13. package/dist/adapters/cursor/routing-config-trust.js +67 -0
  14. package/dist/adapters/cursor/routing-layout.d.ts +5 -0
  15. package/dist/adapters/cursor/routing-layout.js +29 -0
  16. package/dist/adapters/cursor/runtime-entry.d.ts +17 -0
  17. package/dist/adapters/cursor/runtime-entry.js +74 -49
  18. package/dist/adapters/layouts/scope.d.ts +5 -0
  19. package/dist/adapters/layouts/scope.js +71 -0
  20. package/dist/adapters/shared/gate-runtime.js +29 -17
  21. package/dist/bundle/claude-runtime.mjs +2428 -2072
  22. package/dist/bundle/codex-runtime.mjs +2440 -2084
  23. package/dist/bundle/cursor-dispatcher.mjs +517 -0
  24. package/dist/bundle/cursor-runtime.mjs +2521 -2133
  25. package/dist/cli.js +53 -4
  26. package/dist/commands/approval-token.d.ts +10 -0
  27. package/dist/commands/approval-token.js +26 -0
  28. package/dist/commands/audit.d.ts +2 -1
  29. package/dist/commands/audit.js +2 -2
  30. package/dist/commands/config.d.ts +1 -1
  31. package/dist/commands/config.js +28 -2
  32. package/dist/commands/doctor.js +149 -17
  33. package/dist/commands/dogfood-check.d.ts +3 -0
  34. package/dist/commands/dogfood-check.js +116 -0
  35. package/dist/commands/dogfood.d.ts +1 -0
  36. package/dist/commands/dogfood.js +4 -3
  37. package/dist/commands/health-snapshot.js +9 -4
  38. package/dist/commands/judge.js +2 -2
  39. package/dist/commands/recovery-checkpoints.d.ts +3 -3
  40. package/dist/commands/recovery-checkpoints.js +2 -11
  41. package/dist/config-io.d.ts +1 -0
  42. package/dist/config-io.js +19 -3
  43. package/dist/core/audit-legacy-archive.js +43 -25
  44. package/dist/core/classify-tool.js +16 -8
  45. package/dist/core/dogfood-environment.d.ts +8 -0
  46. package/dist/core/dogfood-environment.js +55 -0
  47. package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
  48. package/dist/core/effect-ir/argv-delegate.js +42 -0
  49. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
  50. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
  51. package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
  52. package/dist/core/effect-ir/shell-lower/augment.js +97 -0
  53. package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
  54. package/dist/core/effect-ir/shell-lower/context.js +1 -0
  55. package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
  56. package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
  57. package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
  58. package/dist/core/effect-ir/shell-lower/decoders/belay.js +41 -0
  59. package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
  60. package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
  61. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
  62. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
  63. package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
  64. package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
  65. package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
  66. package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
  67. package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
  68. package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
  69. package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
  70. package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
  71. package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
  72. package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
  73. package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
  74. package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
  75. package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
  76. package/dist/core/effect-ir/shell-lower/segment.js +82 -0
  77. package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
  78. package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
  79. package/dist/core/effect-ir/shell-lower.d.ts +2 -7
  80. package/dist/core/effect-ir/shell-lower.js +60 -1502
  81. package/dist/core/egress-approval.js +0 -18
  82. package/dist/core/glob.js +32 -16
  83. package/dist/core/integrity.d.ts +2 -2
  84. package/dist/core/integrity.js +52 -12
  85. package/dist/core/notify.d.ts +8 -2
  86. package/dist/core/notify.js +63 -7
  87. package/dist/core/recovery/operator-guidance.js +4 -4
  88. package/dist/core/replay-scrub.d.ts +2 -0
  89. package/dist/core/replay-scrub.js +8 -0
  90. package/dist/core/repo-config-trust.d.ts +31 -0
  91. package/dist/core/repo-config-trust.js +152 -0
  92. package/dist/core/shell-substitution.js +2 -2
  93. package/dist/core/shell-unparseable.js +3 -1
  94. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  95. package/dist/corpus/benign-probe-cores.js +0 -1
  96. package/dist/defaults.d.ts +7 -3
  97. package/dist/defaults.js +29 -47
  98. package/dist/installer/bootstrap.d.ts +1 -0
  99. package/dist/installer/bootstrap.js +2 -2
  100. package/dist/installer/runtime-artifacts.js +43 -13
  101. package/dist/installer/scope-config.js +2 -2
  102. package/dist/installer.js +48 -13
  103. package/dist/node-resolution.d.ts +1 -0
  104. package/dist/node-resolution.js +61 -0
  105. package/dist/templates.d.ts +7 -4
  106. package/dist/templates.js +42 -4
  107. package/dist/types.d.ts +20 -0
  108. package/dist/version.d.ts +1 -1
  109. package/dist/version.js +1 -1
  110. package/package.json +5 -1
@@ -0,0 +1,116 @@
1
+ import path from 'node:path';
2
+ import { getAdapterLayout } from '../adapters/layouts/index.js';
3
+ import { detectAdapterName, loadConfigFile } from '../config-io.js';
4
+ import { isGateRecord, parseTimestamp } from '../core/audit-query.js';
5
+ import { summarizeAuditVisibility } from '../core/audit-summary.js';
6
+ import { detectUndogfoodedLinkedWorktrees } from '../core/dogfood-environment.js';
7
+ import { matchesAuditCohort, resolveActiveAuditCohort } from '../runtime-provenance.js';
8
+ import { loadAuditRecords } from './audit.js';
9
+ function isDogfoodMode(config) {
10
+ return config.mode === 'audit' && config.policy?.unknownLocalEffect === 'deny';
11
+ }
12
+ function baseResult(repoRoot, since) {
13
+ return {
14
+ ok: false,
15
+ repoRoot,
16
+ since,
17
+ gateEvents: 0,
18
+ auditModeDenyCount: 0,
19
+ hostDeniedAfterAllowCount: 0,
20
+ shellPreToolUseCount: 0,
21
+ mismatchedCohortCount: 0,
22
+ environmentSkewCount: 0,
23
+ failures: [],
24
+ };
25
+ }
26
+ export async function checkDogfoodProject(options) {
27
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
28
+ const since = options.since;
29
+ const result = baseResult(repoRoot, since);
30
+ const sinceMs = parseTimestamp(since);
31
+ if (sinceMs === null) {
32
+ result.failures.push('invalid_since');
33
+ return result;
34
+ }
35
+ const adapter = options.adapter ?? detectAdapterName(repoRoot);
36
+ const config = await loadConfigFile(repoRoot, adapter);
37
+ const dogfoodActive = isDogfoodMode(config);
38
+ if (!dogfoodActive) {
39
+ result.failures.push('dogfood_inactive');
40
+ }
41
+ const records = await loadAuditRecords(repoRoot, adapter);
42
+ const invalidTimestampRecord = records.some((record) => parseTimestamp(record.timestamp) === null);
43
+ if (invalidTimestampRecord) {
44
+ result.failures.push('invalid_timestamp_record');
45
+ }
46
+ const inWindowRecords = records.filter((record) => {
47
+ const recordMs = parseTimestamp(record.timestamp);
48
+ return recordMs !== null && recordMs >= sinceMs;
49
+ });
50
+ const activeCohort = await resolveActiveAuditCohort(repoRoot, config);
51
+ const inWindowGateRecords = inWindowRecords.filter((record) => isGateRecord(record));
52
+ let cohortGateRecords = inWindowGateRecords;
53
+ let cohortScopedRecords = inWindowRecords;
54
+ if (activeCohort) {
55
+ cohortGateRecords = inWindowGateRecords.filter((record) => matchesAuditCohort(record, activeCohort));
56
+ const cohortEventRecords = new Set(cohortGateRecords);
57
+ cohortScopedRecords = inWindowRecords.filter((record) => !isGateRecord(record) || cohortEventRecords.has(record));
58
+ result.mismatchedCohortCount = inWindowGateRecords.length - cohortGateRecords.length;
59
+ }
60
+ else {
61
+ result.mismatchedCohortCount = inWindowGateRecords.length;
62
+ if (inWindowGateRecords.length > 0) {
63
+ result.failures.push('active_cohort_unavailable');
64
+ }
65
+ }
66
+ result.gateEvents = cohortGateRecords.length;
67
+ result.auditModeDenyCount = cohortGateRecords.filter((record) => record.mode === 'audit' && record.permission === 'deny').length;
68
+ result.shellPreToolUseCount = cohortGateRecords.filter((record) => record.kind === 'shell' && record.event === 'preToolUse').length;
69
+ result.hostDeniedAfterAllowCount =
70
+ summarizeAuditVisibility(cohortScopedRecords).hostDeniedAfterAllowCount;
71
+ if (result.gateEvents === 0) {
72
+ result.failures.push('no_gate_events_since_cutoff');
73
+ }
74
+ if (result.auditModeDenyCount > 0) {
75
+ result.failures.push('audit_mode_permission_deny');
76
+ }
77
+ if (result.hostDeniedAfterAllowCount > 0) {
78
+ result.failures.push('host_denied_after_allow');
79
+ }
80
+ if (result.shellPreToolUseCount > 0) {
81
+ result.failures.push('shell_event_recorded_as_preToolUse');
82
+ }
83
+ if (result.mismatchedCohortCount > 0) {
84
+ result.failures.push('mismatched_active_cohort');
85
+ }
86
+ if (dogfoodActive) {
87
+ const environmentWarnings = await detectUndogfoodedLinkedWorktrees({
88
+ repoRoot,
89
+ adapterName: adapter,
90
+ layout: getAdapterLayout(adapter),
91
+ });
92
+ result.environmentSkewCount = environmentWarnings.length;
93
+ if (result.environmentSkewCount > 0) {
94
+ result.failures.push('environment_skew');
95
+ }
96
+ }
97
+ result.ok = result.failures.length === 0;
98
+ return result;
99
+ }
100
+ export function formatDogfoodCheckResult(result) {
101
+ const lines = [
102
+ `dogfood check for ${result.repoRoot}`,
103
+ `since: ${result.since}`,
104
+ `gate events: ${result.gateEvents}`,
105
+ `audit-mode deny count: ${result.auditModeDenyCount}`,
106
+ `host denied-after-allow count: ${result.hostDeniedAfterAllowCount}`,
107
+ `shell preToolUse count: ${result.shellPreToolUseCount}`,
108
+ `mismatched active cohort count: ${result.mismatchedCohortCount}`,
109
+ `environment skew count: ${result.environmentSkewCount}`,
110
+ `status: ${result.ok ? 'ok' : 'fail'}`,
111
+ ];
112
+ if (!result.ok) {
113
+ lines.push(`failures: ${result.failures.join(', ')}`);
114
+ }
115
+ return `${lines.join('\n')}\n`;
116
+ }
@@ -2,4 +2,5 @@ import { isDogfoodConfig, loadOperationalInsights } from '../operational-insight
2
2
  import type { DogfoodOptions, DogfoodResult } from '../types.js';
3
3
  export declare function dogfoodProject(options?: DogfoodOptions): Promise<DogfoodResult>;
4
4
  export declare function formatDogfoodResult(result: DogfoodResult): string;
5
+ export { checkDogfoodProject, formatDogfoodCheckResult } from './dogfood-check.js';
5
6
  export { isDogfoodConfig, loadOperationalInsights };
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { configPathFor, loadConfigFile, writeConfigFile } from '../config-io.js';
2
+ import { configPathFor, loadConfigFile, writeTrustedConfigFile } from '../config-io.js';
3
3
  import { mergeConfig } from '../core/config.js';
4
4
  import { isDogfoodConfig, loadOperationalInsights } from '../operational-insights.js';
5
5
  import { metricsProject } from './metrics.js';
@@ -19,7 +19,7 @@ export async function dogfoodProject(options = {}) {
19
19
  unknownLocalEffect: 'deny',
20
20
  },
21
21
  });
22
- await writeConfigFile(repoRoot, updated, adapter);
22
+ await writeTrustedConfigFile(repoRoot, updated, adapter);
23
23
  return {
24
24
  ok: true,
25
25
  repoRoot,
@@ -53,7 +53,7 @@ async function promoteDogfoodToEnforce(repoRoot, configPath, force, adapter = 'c
53
53
  ...existing,
54
54
  mode: 'enforce',
55
55
  });
56
- await writeConfigFile(repoRoot, updated, adapter);
56
+ await writeTrustedConfigFile(repoRoot, updated, adapter);
57
57
  return {
58
58
  ok: true,
59
59
  repoRoot,
@@ -68,4 +68,5 @@ async function promoteDogfoodToEnforce(repoRoot, configPath, force, adapter = 'c
68
68
  export function formatDogfoodResult(result) {
69
69
  return `${result.message}\n`;
70
70
  }
71
+ export { checkDogfoodProject, formatDogfoodCheckResult } from './dogfood-check.js';
71
72
  export { isDogfoodConfig, loadOperationalInsights };
@@ -51,16 +51,21 @@ async function managedHooksPresent(adapter, hooksPath, hooksDir, repoRoot) {
51
51
  /** Cursor modes confirmed not to apply host-level denial after Belay allows. */
52
52
  const CURSOR_APPROVAL_MODES_WITHOUT_HOST_DENIAL = new Set(['unrestricted']);
53
53
  export function cursorCliConfigPaths(homeDir, env) {
54
+ const candidates = [];
55
+ const addCandidate = (candidate) => {
56
+ if (!candidates.includes(candidate)) {
57
+ candidates.push(candidate);
58
+ }
59
+ };
54
60
  const cursorConfigDir = env.CURSOR_CONFIG_DIR?.trim();
55
61
  if (cursorConfigDir) {
56
- return [path.join(cursorConfigDir, 'cli-config.json')];
62
+ addCandidate(path.join(cursorConfigDir, 'cli-config.json'));
57
63
  }
58
- const candidates = [];
59
64
  const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
60
65
  if (xdgConfigHome) {
61
- candidates.push(path.join(xdgConfigHome, 'cursor', 'cli-config.json'));
66
+ addCandidate(path.join(xdgConfigHome, 'cursor', 'cli-config.json'));
62
67
  }
63
- candidates.push(path.join(homeDir, '.cursor', 'cli-config.json'));
68
+ addCandidate(path.join(homeDir, '.cursor', 'cli-config.json'));
64
69
  return candidates;
65
70
  }
66
71
  export function cursorApprovalModeRequiresHostDenialWarning(approvalMode) {
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { stdin as input, stdout as output } from 'node:process';
3
3
  import { createInterface } from 'node:readline/promises';
4
- import { configPathFor, loadApprovalState, loadConfigFile, repoLocalStateDirFor, resolveAdapterName, writeConfigFile, } from '../config-io.js';
4
+ import { configPathFor, loadApprovalState, loadConfigFile, repoLocalStateDirFor, resolveAdapterName, writeTrustedConfigFile, } from '../config-io.js';
5
5
  import { appendCliAuditEvent } from '../core/audit-io.js';
6
6
  import { JUDGE_CLOUD_CONSENT_REASON } from '../core/capability/reasons.js';
7
7
  import { belayStateDir, normalizeJudgeConfig } from '../core/config.js';
@@ -176,7 +176,7 @@ export async function judgeUse(options) {
176
176
  }
177
177
  const after = normalizeJudgeConfig(patch.judge);
178
178
  const updated = { ...config, judge: after };
179
- await writeConfigFile(repoRoot, updated, adapter);
179
+ await writeTrustedConfigFile(repoRoot, updated, adapter);
180
180
  await refreshIntegrityIfPinned(repoRoot, updated);
181
181
  await appendCliAuditEvent(repoRoot, updated, {
182
182
  event: 'judge_provider_changed',
@@ -86,7 +86,7 @@ export declare function recoveryCheckpointCommand(options: {
86
86
  state: import("../core/recovery/types.js").RecoveryCheckpointStateV1;
87
87
  manifestHash: string;
88
88
  receipt: import("../core/index.js").RecoveryReceiptV1 | undefined;
89
- repoRoot?: undefined;
89
+ readonly repoRoot?: undefined;
90
90
  backend?: undefined;
91
91
  availableBackends?: undefined;
92
92
  backendCounts?: undefined;
@@ -118,7 +118,7 @@ export declare function recoveryCheckpointCommand(options: {
118
118
  paths: string[];
119
119
  auditRecorded: boolean;
120
120
  message: string;
121
- repoRoot?: undefined;
121
+ readonly repoRoot?: undefined;
122
122
  backend?: undefined;
123
123
  availableBackends?: undefined;
124
124
  backendCounts?: undefined;
@@ -146,7 +146,7 @@ export declare function recoveryCheckpointCommand(options: {
146
146
  subcommand: string;
147
147
  verdict: string;
148
148
  checkpointId: string;
149
- repoRoot?: undefined;
149
+ readonly repoRoot?: undefined;
150
150
  backend?: undefined;
151
151
  availableBackends?: undefined;
152
152
  backendCounts?: undefined;
@@ -5,7 +5,6 @@ import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
5
5
  import { ensureBelayStateDir, loadConfigFile } from '../config-io.js';
6
6
  import { compactApprovals, createApprovalRecordWithEnvelope } from '../core/approval.js';
7
7
  import { createGateApprovalStore } from '../core/approval-service.js';
8
- import { issueApprovalToken } from '../core/approval-token.js';
9
8
  import { appendCliAuditEvent } from '../core/audit-io.js';
10
9
  import { mutateApprovalStateWithRetry } from '../core/capability/approval-state-mutation.js';
11
10
  import { boundarySessionStatus } from '../core/capability/boundary-session.js';
@@ -253,20 +252,12 @@ export async function recoveryCheckpointCommand(options) {
253
252
  }
254
253
  const notificationConfigured = recoveryNotificationConfigured(config);
255
254
  if (notificationConfigured) {
256
- const approvalToken = await issueApprovalToken({
257
- approvalId: request.approvalId,
258
- fingerprint: request.fingerprint,
259
- repoRoot: request.repoRoot,
260
- issuedAt: request.createdAt,
261
- expiresAt: request.expiresAt,
262
- }, configuredControlPlaneDir(config));
263
255
  await notifyDeny(config.notifications, {
264
256
  approvalId: request.approvalId,
265
257
  reason: request.reason,
266
258
  summary: request.summary,
267
259
  repoRoot: request.repoRoot,
268
260
  fingerprint: request.fingerprint,
269
- approvalToken,
270
261
  });
271
262
  }
272
263
  const auditRecorded = await appendRecoveryAudit(repoRoot, config, {
@@ -287,8 +278,8 @@ export async function recoveryCheckpointCommand(options) {
287
278
  paths: binding.paths,
288
279
  auditRecorded,
289
280
  message: notificationConfigured
290
- ? `Signed out-of-band approval required for ${request.approvalId}. Use the token delivered through the configured notification channel, then run \`belay approve ${request.approvalId} --token <signed-token>\` and repeat this command.`
291
- : `${recoveryNotificationSetupWarning()} Pending approval id: ${request.approvalId}.`,
281
+ ? `Signed approval required for ${request.approvalId}. Run \`belay approval-token ${request.approvalId}\` locally, then \`belay approve ${request.approvalId} --token <signed-token>\`, and repeat this command.`
282
+ : `${recoveryNotificationSetupWarning()} Pending approval id: ${request.approvalId}. Run \`belay approval-token ${request.approvalId}\` locally to retrieve the signed token.`,
292
283
  };
293
284
  }
294
285
  try {
@@ -17,6 +17,7 @@ export declare function migrateControlPlaneApprovalsToRepoLocal(repoRoot: string
17
17
  export declare function loadLayeredConfig(repoRoot: string, adapter?: AdapterName): Promise<LayeredConfigResult>;
18
18
  export declare function loadConfigFile(repoRoot: string, adapter?: AdapterName): Promise<BelayConfigV3>;
19
19
  export declare function writeConfigFile(repoRoot: string, config: BelayConfigV3, adapter?: AdapterName): Promise<void>;
20
+ export declare function writeTrustedConfigFile(repoRoot: string, config: BelayConfigV3, adapter?: AdapterName): Promise<void>;
20
21
  export declare function mergeAndWriteConfig(repoRoot: string, adapter?: AdapterName): Promise<BelayConfigV3>;
21
22
  export declare function loadApprovalState(repoRoot: string, fileName: 'pending-approvals.json' | 'approved-approvals.json', config: BelayConfigV3): Promise<ApprovalStateFile>;
22
23
  export declare function saveApprovalState(repoRoot: string, fileName: 'pending-approvals.json' | 'approved-approvals.json', state: ApprovalStateFile, config: BelayConfigV3): Promise<void>;
package/dist/config-io.js CHANGED
@@ -7,6 +7,7 @@ import { compactApprovals, isExpired, mergeApprovalStates } from './core/approva
7
7
  import { mutateApprovalStateWithRetry } from './core/capability/approval-state-mutation.js';
8
8
  import { approvedApprovalsFile, belayStateDir, configuredControlPlaneDir, mergeConfig, pendingApprovalsFile, stripForbiddenShellOverrideLists, } from './core/config.js';
9
9
  import { resolveLayeredConfig, teamConfigPath, } from './core/config-layers.js';
10
+ import { trustRepoConfig } from './core/repo-config-trust.js';
10
11
  export function resolveAdapterName(config) {
11
12
  if (config.adapter === 'claude') {
12
13
  return 'claude';
@@ -209,8 +210,23 @@ export async function loadConfigFile(repoRoot, adapter = detectAdapterName(repoR
209
210
  }
210
211
  export async function writeConfigFile(repoRoot, config, adapter = resolveAdapterName(config)) {
211
212
  const configPath = configPathFor(repoRoot, adapter);
212
- await mkdir(path.dirname(configPath), { recursive: true });
213
- await writeFile(configPath, `${JSON.stringify(stripForbiddenShellOverrideLists(config), null, 2)}\n`, 'utf8');
213
+ const directory = path.dirname(configPath);
214
+ const temporaryPath = path.join(directory, `.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`);
215
+ const content = `${JSON.stringify(stripForbiddenShellOverrideLists(config), null, 2)}\n`;
216
+ await mkdir(directory, { recursive: true });
217
+ try {
218
+ await writeFile(temporaryPath, content, { encoding: 'utf8', flag: 'wx' });
219
+ await rename(temporaryPath, configPath);
220
+ }
221
+ finally {
222
+ await unlink(temporaryPath).catch(() => undefined);
223
+ }
224
+ }
225
+ export async function writeTrustedConfigFile(repoRoot, config, adapter = resolveAdapterName(config)) {
226
+ await writeConfigFile(repoRoot, config, adapter);
227
+ const configPath = configPathFor(repoRoot, adapter);
228
+ const parsed = JSON.parse(await readFile(configPath, 'utf8'));
229
+ await trustRepoConfig(repoRoot, adapter, parsed);
214
230
  }
215
231
  export async function mergeAndWriteConfig(repoRoot, adapter = 'cursor') {
216
232
  const layout = getAdapterLayout(adapter);
@@ -220,7 +236,7 @@ export async function mergeAndWriteConfig(repoRoot, adapter = 'cursor') {
220
236
  existing = JSON.parse(await readFile(configPath, 'utf8'));
221
237
  }
222
238
  const merged = mergeConfig(existing, layout.defaultConfig(repoRoot));
223
- await writeConfigFile(repoRoot, merged, adapter);
239
+ await writeTrustedConfigFile(repoRoot, merged, adapter);
224
240
  await ensureBelayStateDir(merged, repoRoot);
225
241
  if (merged.controlPlane.enabled) {
226
242
  await migrateRepoLocalApprovalsToControlPlane(repoRoot, merged);
@@ -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)
@@ -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,8 @@
1
+ import type { getAdapterLayout } from '../adapters/layouts/index.js';
2
+ import type { AdapterName } from '../types.js';
3
+ export declare function listLinkedWorktreePaths(repoRoot: string): Promise<string[]>;
4
+ export declare function detectUndogfoodedLinkedWorktrees(params: {
5
+ repoRoot: string;
6
+ adapterName: AdapterName;
7
+ layout: ReturnType<typeof getAdapterLayout>;
8
+ }): Promise<string[]>;
@@ -0,0 +1,55 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { loadLayeredConfig } from '../config-io.js';
6
+ const execFileAsync = promisify(execFile);
7
+ export async function listLinkedWorktreePaths(repoRoot) {
8
+ try {
9
+ const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
10
+ cwd: repoRoot,
11
+ encoding: 'utf8',
12
+ });
13
+ return stdout
14
+ .split(/\r?\n/)
15
+ .filter((line) => line.startsWith('worktree '))
16
+ .map((line) => line.slice('worktree '.length).trim())
17
+ .filter((entry) => entry.length > 0);
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
23
+ export async function detectUndogfoodedLinkedWorktrees(params) {
24
+ const repoRootCanonical = realpathSync(params.repoRoot);
25
+ const worktrees = await listLinkedWorktreePaths(params.repoRoot);
26
+ const warnings = [];
27
+ for (const worktreePath of worktrees) {
28
+ let canonicalPath = worktreePath;
29
+ try {
30
+ canonicalPath = realpathSync(worktreePath);
31
+ }
32
+ catch {
33
+ // Keep the original path for warning output when the entry is stale.
34
+ }
35
+ if (canonicalPath === repoRootCanonical) {
36
+ continue;
37
+ }
38
+ const configPath = params.layout.configPath(worktreePath);
39
+ if (!existsSync(configPath)) {
40
+ warnings.push(`Dogfood is active here but ${path.basename(worktreePath)} has no belay.config.json (defaults to enforce). Run belay dogfood in each worktree you use with Cursor.`);
41
+ continue;
42
+ }
43
+ try {
44
+ const candidate = await loadLayeredConfig(worktreePath, params.adapterName);
45
+ const dogfoodEnabled = candidate.config.mode === 'audit' && candidate.config.policy.unknownLocalEffect === 'deny';
46
+ if (!dogfoodEnabled) {
47
+ warnings.push(`Dogfood is active here but ${path.basename(worktreePath)} is not in dogfood mode (mode=${candidate.config.mode}, unknownLocalEffect=${candidate.config.policy.unknownLocalEffect}). Run belay dogfood in each worktree you use with Cursor.`);
48
+ }
49
+ }
50
+ catch {
51
+ warnings.push(`Dogfood is active here but ${path.basename(worktreePath)} has an unreadable belay.config.json. Run belay doctor and belay dogfood in that worktree.`);
52
+ }
53
+ }
54
+ return warnings;
55
+ }
@@ -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;