@guilz-dev/belay 0.9.3 → 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 (83) hide show
  1. package/README.md +35 -1
  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 +7 -0
  7. package/dist/adapters/cursor/hook-dispatch-entry.js +126 -0
  8. package/dist/adapters/cursor/hook-router.d.ts +23 -0
  9. package/dist/adapters/cursor/hook-router.js +266 -0
  10. package/dist/adapters/cursor/hooks.d.ts +4 -0
  11. package/dist/adapters/cursor/hooks.js +99 -8
  12. package/dist/adapters/cursor/routing-layout.d.ts +5 -0
  13. package/dist/adapters/cursor/routing-layout.js +29 -0
  14. package/dist/adapters/cursor/runtime-entry.d.ts +17 -0
  15. package/dist/adapters/cursor/runtime-entry.js +39 -39
  16. package/dist/adapters/layouts/scope.d.ts +5 -0
  17. package/dist/adapters/layouts/scope.js +71 -0
  18. package/dist/adapters/shared/gate-runtime.js +1 -0
  19. package/dist/bundle/claude-runtime.mjs +1932 -1740
  20. package/dist/bundle/codex-runtime.mjs +1934 -1742
  21. package/dist/bundle/cursor-dispatcher.mjs +443 -0
  22. package/dist/bundle/cursor-runtime.mjs +2017 -1821
  23. package/dist/commands/doctor.js +191 -15
  24. package/dist/commands/health-snapshot.js +9 -4
  25. package/dist/commands/recovery-checkpoints.d.ts +3 -3
  26. package/dist/config-io.js +11 -2
  27. package/dist/core/audit-legacy-archive.js +43 -25
  28. package/dist/core/classify-tool.js +16 -8
  29. package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
  30. package/dist/core/effect-ir/argv-delegate.js +42 -0
  31. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
  32. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
  33. package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
  34. package/dist/core/effect-ir/shell-lower/augment.js +97 -0
  35. package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
  36. package/dist/core/effect-ir/shell-lower/context.js +1 -0
  37. package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
  38. package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
  39. package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
  40. package/dist/core/effect-ir/shell-lower/decoders/belay.js +29 -0
  41. package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
  42. package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
  43. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
  44. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
  45. package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
  46. package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
  47. package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
  48. package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
  49. package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
  50. package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
  51. package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
  52. package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
  53. package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
  54. package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
  55. package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
  56. package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
  57. package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
  58. package/dist/core/effect-ir/shell-lower/segment.js +82 -0
  59. package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
  60. package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
  61. package/dist/core/effect-ir/shell-lower.d.ts +2 -7
  62. package/dist/core/effect-ir/shell-lower.js +60 -1502
  63. package/dist/core/glob.js +32 -16
  64. package/dist/core/integrity.d.ts +2 -2
  65. package/dist/core/integrity.js +52 -12
  66. package/dist/core/replay-scrub.d.ts +2 -0
  67. package/dist/core/replay-scrub.js +8 -0
  68. package/dist/core/shell-substitution.js +2 -2
  69. package/dist/core/shell-unparseable.js +3 -1
  70. package/dist/defaults.d.ts +7 -3
  71. package/dist/defaults.js +29 -22
  72. package/dist/installer/bootstrap.d.ts +1 -0
  73. package/dist/installer/bootstrap.js +2 -2
  74. package/dist/installer/runtime-artifacts.js +43 -13
  75. package/dist/installer.js +44 -9
  76. package/dist/node-resolution.d.ts +1 -0
  77. package/dist/node-resolution.js +61 -0
  78. package/dist/templates.d.ts +7 -4
  79. package/dist/templates.js +42 -4
  80. package/dist/types.d.ts +1 -0
  81. package/dist/version.d.ts +1 -1
  82. package/dist/version.js +1 -1
  83. package/package.json +1 -1
@@ -1,9 +1,12 @@
1
- import { existsSync } from 'node:fs';
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, realpathSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
5
+ import { promisify } from 'node:util';
4
6
  import { getClaudeManagedHookEntries } from '../adapters/claude/hooks.js';
5
- import { getCodexManagedHookEntries } from '../adapters/codex/hooks.js';
6
- import { hasDuplicateCursorShellGates } from '../adapters/cursor/hooks.js';
7
+ import { codexHooksTomlIncludesCommand, getCodexManagedHookEntries, } from '../adapters/codex/hooks.js';
8
+ import { hasCurrentCursorDispatcherGeneration } from '../adapters/cursor/dispatcher-generation.js';
9
+ import { hasLegacyCursorDoubleShellGates, hasManagedCursorHookEntries, } from '../adapters/cursor/hooks.js';
7
10
  import { getAdapterLayout } from '../adapters/layouts/index.js';
8
11
  import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
9
12
  import { resolveScopedPaths } from '../adapters/layouts/scope.js';
@@ -15,7 +18,7 @@ import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summar
15
18
  import { inspectBoundaryAttestationFile } from '../core/capability/boundary-attestation-sign.js';
16
19
  import { boundaryAttestationPath, boundarySessionStatus, } from '../core/capability/boundary-session.js';
17
20
  import { configuredControlPlaneDir, defaultControlPlaneDir, hasForbiddenShellOverrideLists, stripForbiddenShellOverrideLists, } from '../core/config.js';
18
- import { verifyIntegrityManifest } from '../core/integrity.js';
21
+ import { runtimeIntegrityFiles, verifyIntegrityManifest } from '../core/integrity.js';
19
22
  import { diagnoseJudge, stopJudgeSessionBrokers } from '../core/judge-doctor.js';
20
23
  import { resolveJudgeTransport } from '../core/judge-runtime-detection.js';
21
24
  import { listRecoveryCheckpoints } from '../core/recovery/checkpoint.js';
@@ -33,6 +36,7 @@ import { PACKAGE_VERSION } from '../version.js';
33
36
  import { loadAuditRecords } from './audit.js';
34
37
  import { collectHealthSnapshot } from './health-snapshot.js';
35
38
  import { metricsProject } from './metrics.js';
39
+ const execFileAsync = promisify(execFile);
36
40
  function resolveDoctorAdapter(options, configAdapter) {
37
41
  if (options.adapter) {
38
42
  return options.adapter;
@@ -52,6 +56,110 @@ function hasCursorGlobalWorkspaceResolver(runtimeSource) {
52
56
  runtimeSource.includes('working_directory') &&
53
57
  runtimeSource.includes('tool_input'));
54
58
  }
59
+ const CURSOR_HOOK_SHIMS = [
60
+ 'belay-before-submit.mjs',
61
+ 'belay-shell-gate.mjs',
62
+ 'belay-tool-gate.mjs',
63
+ 'belay-audit.mjs',
64
+ ];
65
+ function runnerFileName(adapterName, platform) {
66
+ if (platform !== 'win32') {
67
+ return 'belay-runner';
68
+ }
69
+ return adapterName === 'cursor' ? 'belay-runner.ps1' : 'belay-runner.cmd';
70
+ }
71
+ async function cursorOriginIssues(hooksDir, installScope, repoRoot) {
72
+ const canonicalRepoRoot = realpathSync(repoRoot);
73
+ const issues = [];
74
+ for (const fileName of CURSOR_HOOK_SHIMS) {
75
+ const shimPath = path.join(hooksDir, fileName);
76
+ if (!existsSync(shimPath)) {
77
+ continue;
78
+ }
79
+ let source;
80
+ try {
81
+ source = await readFile(shimPath, 'utf8');
82
+ }
83
+ catch (error) {
84
+ const detail = error instanceof Error ? error.message : String(error);
85
+ issues.push(`Unable to inspect Cursor hook shim for intended ${installScope} owner: ${shimPath}: ${detail}. Run belay upgrade --scope ${installScope}.`);
86
+ continue;
87
+ }
88
+ if (!source.includes("from '../belay/runtime/dispatcher.mjs'")) {
89
+ issues.push(`Cursor router generation mismatch for intended ${installScope} owner: ${shimPath}. Run belay upgrade --scope ${installScope}.`);
90
+ continue;
91
+ }
92
+ const originMatch = source.match(/origin:\s*(\{[^\n]+\})/);
93
+ let originMatches = false;
94
+ if (originMatch?.[1]) {
95
+ try {
96
+ const origin = JSON.parse(originMatch[1]);
97
+ originMatches =
98
+ installScope === 'global'
99
+ ? origin.scope === 'global'
100
+ : origin.scope === 'project' &&
101
+ typeof origin.repoRoot === 'string' &&
102
+ realpathSync(origin.repoRoot) === canonicalRepoRoot;
103
+ }
104
+ catch {
105
+ originMatches = false;
106
+ }
107
+ }
108
+ if (!originMatches) {
109
+ issues.push(`Cursor hook origin mismatch for intended ${installScope} owner: ${shimPath}. Run belay upgrade --scope ${installScope}.`);
110
+ }
111
+ }
112
+ return issues;
113
+ }
114
+ async function listLinkedWorktreePaths(repoRoot) {
115
+ try {
116
+ const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
117
+ cwd: repoRoot,
118
+ encoding: 'utf8',
119
+ });
120
+ return stdout
121
+ .split(/\r?\n/)
122
+ .filter((line) => line.startsWith('worktree '))
123
+ .map((line) => line.slice('worktree '.length).trim())
124
+ .filter((entry) => entry.length > 0);
125
+ }
126
+ catch {
127
+ return [];
128
+ }
129
+ }
130
+ async function detectUndogfoodedLinkedWorktrees(params) {
131
+ const repoRootCanonical = realpathSync(params.repoRoot);
132
+ const worktrees = await listLinkedWorktreePaths(params.repoRoot);
133
+ const warnings = [];
134
+ for (const worktreePath of worktrees) {
135
+ let canonicalPath = worktreePath;
136
+ try {
137
+ canonicalPath = realpathSync(worktreePath);
138
+ }
139
+ catch {
140
+ // Keep the original path for warning output when the entry is stale.
141
+ }
142
+ if (canonicalPath === repoRootCanonical) {
143
+ continue;
144
+ }
145
+ const configPath = params.layout.configPath(worktreePath);
146
+ if (!existsSync(configPath)) {
147
+ 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.`);
148
+ continue;
149
+ }
150
+ try {
151
+ const candidate = await loadLayeredConfig(worktreePath, params.adapterName);
152
+ const dogfoodEnabled = candidate.config.mode === 'audit' && candidate.config.policy.unknownLocalEffect === 'deny';
153
+ if (!dogfoodEnabled) {
154
+ 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.`);
155
+ }
156
+ }
157
+ catch {
158
+ 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.`);
159
+ }
160
+ }
161
+ return warnings;
162
+ }
55
163
  export async function doctorProject(options = {}) {
56
164
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
57
165
  const issues = [];
@@ -145,9 +253,9 @@ export async function doctorProject(options = {}) {
145
253
  }
146
254
  if (loadedConfig.controlPlane.integrity === 'hash-pinned') {
147
255
  notes.push('Integrity: hash-pinned (verify with belay upgrade after runtime changes).');
148
- const integrity = await verifyIntegrityManifest(repoRoot, activeLayout);
256
+ const integrity = await verifyIntegrityManifest(repoRoot, activeLayout, runtimeIntegrityFiles(activeLayout, scopedPaths));
149
257
  if (!integrity.ok) {
150
- issues.push(`Integrity verification failed: ${integrity.mismatches.slice(0, 3).join(', ')}`);
258
+ issues.push(`Integrity verification failed: ${integrity.mismatches.join(', ')}`);
151
259
  }
152
260
  }
153
261
  }
@@ -163,14 +271,16 @@ export async function doctorProject(options = {}) {
163
271
  const repoLocalDir = loadedConfig
164
272
  ? repoLocalStateDirFor(repoRoot, loadedConfig)
165
273
  : activeLayout.repoLocalStateDir(repoRoot);
166
- const requiredPaths = [
167
- path.join(hooksDir, 'belay-runner'),
168
- path.join(hooksDir, 'belay-runner.cmd'),
274
+ const routingOwnerPaths = [
275
+ path.join(hooksDir, runnerFileName(adapterName, process.platform)),
169
276
  path.join(hooksDir, 'belay-before-submit.mjs'),
170
277
  path.join(hooksDir, 'belay-shell-gate.mjs'),
171
278
  path.join(hooksDir, 'belay-tool-gate.mjs'),
172
279
  path.join(hooksDir, 'belay-audit.mjs'),
280
+ ...(adapterName === 'cursor' ? [path.join(scopedPaths.runtimeDir, 'dispatcher.mjs')] : []),
173
281
  corePath,
282
+ ];
283
+ const operationalPaths = [
174
284
  loadedConfig
175
285
  ? pendingApprovalsPath(repoRoot, loadedConfig)
176
286
  : path.join(repoLocalDir, 'pending-approvals.json'),
@@ -179,9 +289,67 @@ export async function doctorProject(options = {}) {
179
289
  : path.join(repoLocalDir, 'approved-approvals.json'),
180
290
  path.join(repoRoot, loadedConfig?.audit.logPath ?? activeLayout.defaultAuditLogPath(repoRoot)),
181
291
  ];
292
+ const requiredPaths = [...routingOwnerPaths, ...operationalPaths];
182
293
  for (const requiredPath of requiredPaths) {
183
294
  if (!existsSync(requiredPath)) {
184
295
  issues.push(`Missing generated file: ${requiredPath}`);
296
+ if (adapterName === 'cursor' && routingOwnerPaths.includes(requiredPath)) {
297
+ issues.push(`Cursor intended ${installScope} owner is incomplete: missing ${requiredPath}. Run belay upgrade --scope ${installScope}.`);
298
+ }
299
+ }
300
+ }
301
+ if (adapterName === 'cursor') {
302
+ issues.push(...(await cursorOriginIssues(hooksDir, installScope, repoRoot)));
303
+ const dispatcherPath = path.join(scopedPaths.runtimeDir, 'dispatcher.mjs');
304
+ if (existsSync(dispatcherPath) &&
305
+ !hasCurrentCursorDispatcherGeneration(await readFile(dispatcherPath, 'utf8'))) {
306
+ issues.push(`Cursor router generation mismatch for intended ${installScope} owner dispatcher: ${dispatcherPath}. Run belay upgrade --scope ${installScope}.`);
307
+ }
308
+ if (installScope === 'project') {
309
+ const globalPaths = resolveScopedPaths(activeLayout, 'global', repoRoot);
310
+ if (existsSync(globalPaths.hooksSettingsPath)) {
311
+ try {
312
+ const { loadHooksFile } = await import('../installer.js');
313
+ const globalHooks = await loadHooksFile(globalPaths.hooksSettingsPath);
314
+ if (hasManagedCursorHookEntries(globalHooks, process.platform, globalPaths.hooksDir, repoRoot)) {
315
+ const globalOwnerFiles = [
316
+ path.join(globalPaths.hooksDir, runnerFileName('cursor', process.platform)),
317
+ path.join(globalPaths.runtimeDir, 'core.mjs'),
318
+ path.join(globalPaths.runtimeDir, 'dispatcher.mjs'),
319
+ ...CURSOR_HOOK_SHIMS.map((fileName) => path.join(globalPaths.hooksDir, fileName)),
320
+ ];
321
+ const globalOriginProblems = await cursorOriginIssues(globalPaths.hooksDir, 'global', repoRoot);
322
+ issues.push(...globalOriginProblems);
323
+ const globalProblems = [
324
+ ...globalOwnerFiles.filter((filePath) => !existsSync(filePath)),
325
+ ...globalOriginProblems,
326
+ ];
327
+ const globalDispatcherPath = path.join(globalPaths.runtimeDir, 'dispatcher.mjs');
328
+ if (existsSync(globalDispatcherPath)) {
329
+ try {
330
+ if (!hasCurrentCursorDispatcherGeneration(await readFile(globalDispatcherPath, 'utf8'))) {
331
+ globalProblems.push(globalDispatcherPath);
332
+ }
333
+ }
334
+ catch (error) {
335
+ const detail = error instanceof Error ? error.message : String(error);
336
+ issues.push(`Unable to inspect shadowed global Cursor dispatcher at ${globalDispatcherPath}: ${detail}.`);
337
+ globalProblems.push(globalDispatcherPath);
338
+ }
339
+ }
340
+ if (globalProblems.length > 0) {
341
+ issues.push('Global Cursor installation cannot yield to the project owner. Run belay upgrade from this project to refresh the global router.');
342
+ }
343
+ else {
344
+ notes.push('Healthy global Cursor install is shadowed by this project owner and will return neutral for this repository.');
345
+ }
346
+ }
347
+ }
348
+ catch (error) {
349
+ const detail = error instanceof Error ? error.message : String(error);
350
+ issues.push(`Unable to inspect shadowed global Cursor hooks at ${globalPaths.hooksSettingsPath}: ${detail}.`);
351
+ }
352
+ }
185
353
  }
186
354
  }
187
355
  let hooksOk = true;
@@ -196,15 +364,17 @@ export async function doctorProject(options = {}) {
196
364
  const hooksFile = await loadHooksFile(hooksPath);
197
365
  for (const { event, definition } of managedEntries) {
198
366
  const entries = hooksFile.hooks[event] ?? [];
199
- const present = entries.some((entry) => entry.command === definition.command && entry.matcher === definition.matcher);
200
- if (!present) {
367
+ const matchingEntry = entries.find((entry) => entry.command === definition.command && entry.matcher === definition.matcher);
368
+ if (!matchingEntry || matchingEntry.failClosed !== true) {
201
369
  hooksOk = false;
202
370
  const matcherSuffix = definition.matcher ? ` (matcher: ${definition.matcher})` : '';
203
- issues.push(`Missing managed hook for ${event}: ${definition.command}${matcherSuffix}`);
371
+ issues.push(matchingEntry
372
+ ? `Managed Cursor hook for ${event} must set failClosed: true: ${definition.command}${matcherSuffix}`
373
+ : `Missing managed hook for ${event}: ${definition.command}${matcherSuffix}`);
204
374
  }
205
375
  }
206
- if (hasDuplicateCursorShellGates(hooksFile, process.platform, hooksDir, repoRoot)) {
207
- warnings.push('Duplicate Cursor Shell preToolUse gates detected. Run belay upgrade to dedupe managed hooks.');
376
+ if (hasLegacyCursorDoubleShellGates(hooksFile, process.platform, hooksDir, repoRoot)) {
377
+ warnings.push('Legacy Cursor Shell preToolUse gate detected alongside beforeShellExecution. Run belay upgrade to remove the managed Shell matcher.');
208
378
  }
209
379
  }
210
380
  else if (adapterName === 'codex') {
@@ -212,7 +382,7 @@ export async function doctorProject(options = {}) {
212
382
  // are present in the rendered TOML block.
213
383
  const toml = await readFile(hooksPath, 'utf8');
214
384
  for (const { event, definition } of managedEntries) {
215
- if (!toml.includes(definition.command)) {
385
+ if (!codexHooksTomlIncludesCommand(toml, definition.command)) {
216
386
  hooksOk = false;
217
387
  issues.push(`Missing Codex managed hook for ${event}: ${definition.command}`);
218
388
  }
@@ -346,6 +516,12 @@ export async function doctorProject(options = {}) {
346
516
  };
347
517
  if (dogfood.active) {
348
518
  notes.push(`Dogfood active cohort: ${dogfood.gateEvents} gate events, ${dogfood.wouldBlockCount} would-block (${(dogfood.wouldBlockRate * 100).toFixed(1)}%); ${dogfood.excludedGateEvents} historical/mismatched event(s) excluded.`);
519
+ notes.push(`Host denied after Belay allow (cohort): ${auditVisibility.hostDeniedAfterAllowCount}.`);
520
+ warnings.push(...(await detectUndogfoodedLinkedWorktrees({
521
+ repoRoot,
522
+ adapterName,
523
+ layout: activeLayout,
524
+ })));
349
525
  if (dogfood.readyForEnforce) {
350
526
  notes.push('Dogfood metrics suggest enforce mode is ready (belay dogfood --enforce).');
351
527
  }
@@ -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) {
@@ -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;
package/dist/config-io.js CHANGED
@@ -209,8 +209,17 @@ export async function loadConfigFile(repoRoot, adapter = detectAdapterName(repoR
209
209
  }
210
210
  export async function writeConfigFile(repoRoot, config, adapter = resolveAdapterName(config)) {
211
211
  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');
212
+ const directory = path.dirname(configPath);
213
+ const temporaryPath = path.join(directory, `.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`);
214
+ const content = `${JSON.stringify(stripForbiddenShellOverrideLists(config), null, 2)}\n`;
215
+ await mkdir(directory, { recursive: true });
216
+ try {
217
+ await writeFile(temporaryPath, content, { encoding: 'utf8', flag: 'wx' });
218
+ await rename(temporaryPath, configPath);
219
+ }
220
+ finally {
221
+ await unlink(temporaryPath).catch(() => undefined);
222
+ }
214
223
  }
215
224
  export async function mergeAndWriteConfig(repoRoot, adapter = 'cursor') {
216
225
  const layout = getAdapterLayout(adapter);
@@ -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,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[];