@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
@@ -1,8 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { compactApprovals, createApprovalRecord } from './approval.js';
3
- import { issueApprovalToken } from './approval-token.js';
4
3
  import { mutateApprovalStateWithRetry } from './capability/approval-state-mutation.js';
5
- import { configuredControlPlaneDir } from './config.js';
6
4
  import { addDomainToAllowlist, mutateEgressAllowlist } from './egress/allowlist.js';
7
5
  import { parseHostFromSummary } from './egress/fingerprint.js';
8
6
  import { notifyDeny } from './notify.js';
@@ -65,28 +63,12 @@ export async function notifyEgressDeny(params) {
65
63
  if (!params.config.notifications.webhookUrl && !params.config.notifications.commandHook) {
66
64
  return;
67
65
  }
68
- let approvalToken;
69
- if (params.config.approvalSigning.required) {
70
- try {
71
- approvalToken = await issueApprovalToken({
72
- approvalId: params.approval.approvalId,
73
- fingerprint: params.approval.fingerprint,
74
- repoRoot: params.approval.repoRoot,
75
- issuedAt: params.approval.createdAt,
76
- expiresAt: params.approval.expiresAt,
77
- }, configuredControlPlaneDir(params.config));
78
- }
79
- catch {
80
- approvalToken = undefined;
81
- }
82
- }
83
66
  await notifyDeny(params.config.notifications, {
84
67
  approvalId: params.approval.approvalId,
85
68
  reason: params.policyResult.reason,
86
69
  summary: params.policyResult.summary,
87
70
  repoRoot: params.repoRoot,
88
71
  fingerprint: params.policyResult.fingerprint,
89
- approvalToken,
90
72
  });
91
73
  }
92
74
  export async function recordEgressApproval(params) {
package/dist/core/glob.js CHANGED
@@ -1,26 +1,42 @@
1
+ import { escapeRegex } from './approval.js';
2
+ function globPatternToRegexSource(pattern) {
3
+ let result = '';
4
+ let index = 0;
5
+ while (index < pattern.length) {
6
+ const char = pattern[index];
7
+ if (char === '*' && pattern[index + 1] === '*') {
8
+ index += 2;
9
+ if (pattern[index] === '/') {
10
+ result += '(?:.*/)?';
11
+ index += 1;
12
+ }
13
+ else {
14
+ result += '.*';
15
+ }
16
+ continue;
17
+ }
18
+ if (char === '*') {
19
+ result += '[^/]*';
20
+ index += 1;
21
+ continue;
22
+ }
23
+ result += escapeRegex(char);
24
+ index += 1;
25
+ }
26
+ return result;
27
+ }
28
+ function globPatternMatches(pattern, normalized, baseName) {
29
+ const regex = new RegExp(`^${globPatternToRegexSource(pattern)}$`);
30
+ return regex.test(normalized) || regex.test(baseName);
31
+ }
1
32
  export function matchesSensitivePath(filePath, patterns) {
2
33
  const normalized = filePath.replaceAll('\\', '/');
3
34
  const segments = normalized.split('/');
4
35
  const baseName = segments.at(-1) ?? normalized;
5
36
  for (const pattern of patterns) {
6
37
  const normalizedPattern = pattern.replaceAll('\\', '/');
7
- if (normalizedPattern.includes('**')) {
8
- const parts = normalizedPattern.split('**').map((part) => part.replace(/^\/+|\/+$/g, ''));
9
- const prefix = parts[0]?.replace(/\/+$/, '') ?? '';
10
- const suffix = parts[1]?.replace(/^\/+/, '') ?? '';
11
- if (prefix && !normalized.startsWith(prefix)) {
12
- continue;
13
- }
14
- if (suffix && !normalized.includes(suffix)) {
15
- continue;
16
- }
17
- if (prefix || suffix) {
18
- return true;
19
- }
20
- }
21
38
  if (normalizedPattern.includes('*')) {
22
- const regex = new RegExp(`^${normalizedPattern.replaceAll('.', '\\.').replaceAll('*', '.*')}$`);
23
- if (regex.test(normalized) || regex.test(baseName)) {
39
+ if (globPatternMatches(normalizedPattern, normalized, baseName)) {
24
40
  return true;
25
41
  }
26
42
  continue;
@@ -8,9 +8,9 @@ export interface IntegrityManifest {
8
8
  }
9
9
  export declare function sha256File(filePath: string): Promise<string>;
10
10
  export declare function integrityManifestPath(layout: AdapterLayout, repoRoot: string): string;
11
- export declare function runtimeIntegrityFiles(_layout: AdapterLayout, paths: ScopedPaths): string[];
11
+ export declare function runtimeIntegrityFiles(layout: AdapterLayout, paths: ScopedPaths): string[];
12
12
  export declare function writeIntegrityManifest(repoRoot: string, layout: AdapterLayout, filePaths: string[]): Promise<void>;
13
- export declare function verifyIntegrityManifest(repoRoot: string, layout: AdapterLayout): Promise<{
13
+ export declare function verifyIntegrityManifest(repoRoot: string, layout: AdapterLayout, requiredFilePaths?: string[]): Promise<{
14
14
  ok: boolean;
15
15
  mismatches: string[];
16
16
  }>;
@@ -3,8 +3,24 @@ import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { getAdapterLayout } from '../adapters/layouts/index.js';
6
- import { resolveScopedPaths } from '../adapters/layouts/scope.js';
6
+ import { isPathInside, resolveScopedPaths } from '../adapters/layouts/scope.js';
7
7
  import { resolveAdapterName } from '../config-io.js';
8
+ const GLOBAL_INTEGRITY_PREFIX = '@global/';
9
+ function portableRelativePath(from, to) {
10
+ return path.relative(from, to).split(path.sep).join('/');
11
+ }
12
+ function integrityManifestFileKey(repoRoot, layout, filePath) {
13
+ const absolutePath = path.resolve(filePath);
14
+ if (isPathInside(absolutePath, repoRoot)) {
15
+ return portableRelativePath(repoRoot, absolutePath);
16
+ }
17
+ const globalPaths = resolveScopedPaths(layout, 'global', repoRoot);
18
+ const globalAgentDir = path.dirname(globalPaths.hooksSettingsPath);
19
+ if (isPathInside(absolutePath, globalAgentDir)) {
20
+ return `${GLOBAL_INTEGRITY_PREFIX}${portableRelativePath(globalAgentDir, absolutePath)}`;
21
+ }
22
+ throw new Error(`Integrity file is outside the repository and global adapter root: ${filePath}`);
23
+ }
8
24
  export async function sha256File(filePath) {
9
25
  const content = await readFile(filePath);
10
26
  return createHash('sha256').update(content).digest('hex');
@@ -12,11 +28,8 @@ export async function sha256File(filePath) {
12
28
  export function integrityManifestPath(layout, repoRoot) {
13
29
  return path.join(layout.repoLocalStateDir(repoRoot), 'integrity-manifest.json');
14
30
  }
15
- export function runtimeIntegrityFiles(_layout, paths) {
31
+ export function runtimeIntegrityFiles(layout, paths) {
16
32
  const files = [paths.configPath];
17
- if (paths.scope !== 'project') {
18
- return files;
19
- }
20
33
  const hooksDir = paths.hooksDir;
21
34
  const runtimeDir = paths.runtimeDir;
22
35
  return [
@@ -28,7 +41,9 @@ export function runtimeIntegrityFiles(_layout, paths) {
28
41
  path.join(hooksDir, 'belay-audit.mjs'),
29
42
  path.join(hooksDir, 'belay-runner'),
30
43
  path.join(hooksDir, 'belay-runner.cmd'),
44
+ ...(layout.name === 'cursor' ? [path.join(hooksDir, 'belay-runner.ps1')] : []),
31
45
  path.join(runtimeDir, 'core.mjs'),
46
+ ...(layout.name === 'cursor' ? [path.join(runtimeDir, 'dispatcher.mjs')] : []),
32
47
  ];
33
48
  }
34
49
  export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
@@ -37,8 +52,8 @@ export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
37
52
  if (!existsSync(filePath)) {
38
53
  continue;
39
54
  }
40
- const relativePath = path.relative(repoRoot, filePath);
41
- files[relativePath] = await sha256File(filePath);
55
+ const manifestKey = integrityManifestFileKey(repoRoot, layout, filePath);
56
+ files[manifestKey] = await sha256File(filePath);
42
57
  }
43
58
  const manifest = {
44
59
  version: 1,
@@ -49,22 +64,47 @@ export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
49
64
  await mkdir(path.dirname(manifestPath), { recursive: true });
50
65
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
51
66
  }
52
- export async function verifyIntegrityManifest(repoRoot, layout) {
67
+ export async function verifyIntegrityManifest(repoRoot, layout, requiredFilePaths = []) {
53
68
  const manifestPath = integrityManifestPath(layout, repoRoot);
54
69
  if (!existsSync(manifestPath)) {
55
70
  return { ok: false, mismatches: ['missing integrity-manifest.json'] };
56
71
  }
57
72
  const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
58
73
  const mismatches = [];
59
- for (const [relativePath, expectedHash] of Object.entries(manifest.files ?? {})) {
60
- const absolutePath = path.join(repoRoot, relativePath);
74
+ const requiredPathsByKey = new Map(requiredFilePaths.map((requiredPath) => [
75
+ integrityManifestFileKey(repoRoot, layout, requiredPath),
76
+ path.resolve(requiredPath),
77
+ ]));
78
+ for (const requiredPath of requiredFilePaths) {
79
+ const requiredKey = integrityManifestFileKey(repoRoot, layout, requiredPath);
80
+ if (!Object.hasOwn(manifest.files ?? {}, requiredKey)) {
81
+ mismatches.push(`missing integrity pin ${requiredKey}`);
82
+ }
83
+ }
84
+ for (const [manifestKey, expectedHash] of Object.entries(manifest.files ?? {})) {
85
+ let absolutePath;
86
+ if (manifestKey.startsWith(GLOBAL_INTEGRITY_PREFIX)) {
87
+ const requiredPath = requiredPathsByKey.get(manifestKey);
88
+ if (!requiredPath) {
89
+ mismatches.push(`unrecognized global integrity pin ${manifestKey}`);
90
+ continue;
91
+ }
92
+ absolutePath = requiredPath;
93
+ }
94
+ else {
95
+ absolutePath = path.resolve(repoRoot, manifestKey);
96
+ if (!isPathInside(absolutePath, repoRoot)) {
97
+ mismatches.push(`integrity pin escapes repository ${manifestKey}`);
98
+ continue;
99
+ }
100
+ }
61
101
  if (!existsSync(absolutePath)) {
62
- mismatches.push(`missing ${relativePath}`);
102
+ mismatches.push(`missing ${manifestKey}`);
63
103
  continue;
64
104
  }
65
105
  const actualHash = await sha256File(absolutePath);
66
106
  if (actualHash !== expectedHash) {
67
- mismatches.push(`hash mismatch ${relativePath}`);
107
+ mismatches.push(`hash mismatch ${manifestKey}`);
68
108
  }
69
109
  }
70
110
  return { ok: mismatches.length === 0, mismatches };
@@ -8,6 +8,12 @@ export interface DenyNotificationEvent {
8
8
  summary: string;
9
9
  repoRoot: string;
10
10
  fingerprint: string;
11
- approvalToken?: string;
12
11
  }
13
- export declare function notifyDeny(config: DenyNotificationConfig, event: DenyNotificationEvent): Promise<void>;
12
+ export interface NotifyDependencies {
13
+ fetch: typeof globalThis.fetch;
14
+ execFile: (file: string, args: readonly string[], options: {
15
+ env: NodeJS.ProcessEnv;
16
+ }) => Promise<unknown>;
17
+ }
18
+ export declare function notificationConfigIssues(config: DenyNotificationConfig, repoRoot: string): string[];
19
+ export declare function notifyDeny(config: DenyNotificationConfig, event: DenyNotificationEvent, deps?: NotifyDependencies): Promise<void>;
@@ -1,14 +1,69 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
2
3
  import { promisify } from 'node:util';
4
+ import { canonicalPath, pathWithinRoot } from './path-utils.js';
3
5
  const execFileAsync = promisify(execFile);
4
- export async function notifyDeny(config, event) {
5
- const payload = JSON.stringify(event);
6
+ const LOOPBACK_WEBHOOK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
7
+ function webhookConfigIssue(url) {
8
+ let parsed;
9
+ try {
10
+ parsed = new URL(url);
11
+ }
12
+ catch {
13
+ return `notifications.webhookUrl is invalid: ${url}`;
14
+ }
15
+ if (parsed.protocol === 'https:') {
16
+ return null;
17
+ }
18
+ const normalizedHostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
19
+ if (parsed.protocol === 'http:' && LOOPBACK_WEBHOOK_HOSTS.has(normalizedHostname)) {
20
+ return null;
21
+ }
22
+ return `notifications.webhookUrl must use https (http is allowed only for localhost, 127.0.0.1, or ::1): ${url}`;
23
+ }
24
+ function commandHookConfigIssue(commandHook, repoRoot) {
25
+ if (!path.isAbsolute(commandHook)) {
26
+ return `notifications.commandHook must be an absolute path: ${commandHook}`;
27
+ }
28
+ if (pathWithinRoot(canonicalPath(repoRoot), canonicalPath(commandHook))) {
29
+ return `notifications.commandHook must not be inside the repository: ${commandHook}`;
30
+ }
31
+ return null;
32
+ }
33
+ export function notificationConfigIssues(config, repoRoot) {
34
+ const issues = [];
6
35
  if (config.webhookUrl) {
36
+ const issue = webhookConfigIssue(config.webhookUrl);
37
+ if (issue) {
38
+ issues.push(issue);
39
+ }
40
+ }
41
+ if (config.commandHook) {
42
+ const issue = commandHookConfigIssue(config.commandHook, repoRoot);
43
+ if (issue) {
44
+ issues.push(issue);
45
+ }
46
+ }
47
+ return issues;
48
+ }
49
+ export async function notifyDeny(config, event, deps = {
50
+ fetch: globalThis.fetch.bind(globalThis),
51
+ execFile: (file, args, options) => execFileAsync(file, [...args], options),
52
+ }) {
53
+ const payload = JSON.stringify({
54
+ approvalId: event.approvalId,
55
+ reason: event.reason,
56
+ summary: event.summary,
57
+ repoRoot: event.repoRoot,
58
+ fingerprint: event.fingerprint,
59
+ });
60
+ const webhookIssue = config.webhookUrl ? webhookConfigIssue(config.webhookUrl) : null;
61
+ if (config.webhookUrl && !webhookIssue) {
7
62
  try {
8
63
  const controller = new AbortController();
9
64
  const timeout = setTimeout(() => controller.abort(), 5000);
10
65
  try {
11
- await fetch(config.webhookUrl, {
66
+ await deps.fetch(config.webhookUrl, {
12
67
  method: 'POST',
13
68
  headers: { 'content-type': 'application/json' },
14
69
  body: payload,
@@ -23,17 +78,18 @@ export async function notifyDeny(config, event) {
23
78
  // best-effort notification
24
79
  }
25
80
  }
26
- if (config.commandHook) {
81
+ const commandHookIssue = config.commandHook
82
+ ? commandHookConfigIssue(config.commandHook, event.repoRoot)
83
+ : null;
84
+ if (config.commandHook && !commandHookIssue) {
27
85
  try {
28
- await execFileAsync(config.commandHook, [], {
86
+ await deps.execFile(config.commandHook, [], {
29
87
  env: {
30
- ...process.env,
31
88
  BELAY_APPROVAL_ID: event.approvalId,
32
89
  BELAY_REASON: event.reason,
33
90
  BELAY_SUMMARY: event.summary,
34
91
  BELAY_REPO_ROOT: event.repoRoot,
35
92
  BELAY_FINGERPRINT: event.fingerprint,
36
- BELAY_APPROVAL_TOKEN: event.approvalToken ?? '',
37
93
  },
38
94
  });
39
95
  }
@@ -3,13 +3,13 @@ export function recoveryNotificationConfigured(config) {
3
3
  }
4
4
  export function recoveryApprovalSetupNotes() {
5
5
  return [
6
- 'Recovery restore flow: run `belay recover apply <checkpoint-id>`, approve with `belay approve <approval-id> --token <signed-token>`, then run the same apply command again.',
7
- 'Recovery restore always requires a signed out-of-band token, even when approvalSigning.required is false for general approvals.',
6
+ 'Recovery restore flow: run `belay recover apply <checkpoint-id>`, retrieve the token locally with `belay approval-token <approval-id>`, approve with `belay approve <approval-id> --token <signed-token>`, then run the same apply command again.',
7
+ 'Recovery restore always requires a signed local-control-plane token, even when approvalSigning.required is false for general approvals.',
8
8
  ];
9
9
  }
10
10
  export function recoveryNotificationSetupWarning() {
11
- return ('Recovery checkpoint restore requires a signed out-of-band approval token, but no notification channel is configured. ' +
12
- 'Set notifications.webhookUrl or notifications.commandHook (e.g. via `belay config`) so `belay recover apply` can deliver the token.');
11
+ return ('No notification channel is configured, so recovery approval alerts are shown only by the local CLI. ' +
12
+ 'Optionally set notifications.webhookUrl or notifications.commandHook (e.g. via `belay config`) to receive approval IDs out of band.');
13
13
  }
14
14
  export function formatRecoveryStateDiagnostic(state, detail) {
15
15
  switch (state) {
@@ -3,6 +3,8 @@ import type { ScrubOptions } from './types.js';
3
3
  export declare function redactToolInvocationId(value: unknown, rawToolUseId?: string): unknown;
4
4
  /** Subagent fingerprint input — must match classify-subagent `fingerprintSource`. */
5
5
  export declare function subagentFingerprintSource(payload: Record<string, unknown>, scrubOptions: ScrubOptions): unknown;
6
+ /** Stable tool-input source for fingerprints — redacts volatile ids only, not secret values. */
7
+ export declare function fingerprintToolInputSource(payload: Record<string, unknown>): Record<string, unknown>;
6
8
  /**
7
9
  * Scrubbed payload used for replay envelope hashing — aligned with classifier fingerprints.
8
10
  * Tool: scrubbed `tool_input`. Subagent: description/prompt subset. Other: full payload.
@@ -40,6 +40,14 @@ export function subagentFingerprintSource(payload, scrubOptions) {
40
40
  }
41
41
  return scrubValue(payload, scrubOptions);
42
42
  }
43
+ /** Stable tool-input source for fingerprints — redacts volatile ids only, not secret values. */
44
+ export function fingerprintToolInputSource(payload) {
45
+ const toolInput = payload.tool_input;
46
+ if (!toolInput || typeof toolInput !== 'object') {
47
+ return {};
48
+ }
49
+ return redactToolInvocationId(toolInput, typeof payload.tool_use_id === 'string' ? payload.tool_use_id : undefined);
50
+ }
43
51
  /**
44
52
  * Scrubbed payload used for replay envelope hashing — aligned with classifier fingerprints.
45
53
  * Tool: scrubbed `tool_input`. Subagent: description/prompt subset. Other: full payload.
@@ -0,0 +1,31 @@
1
+ import type { AdapterName } from '../types.js';
2
+ export interface RepoConfigTrustRecordV1 {
3
+ schemaVersion: 1;
4
+ repoRoot: string;
5
+ adapter: 'cursor' | 'claude' | 'codex';
6
+ repoConfigFingerprint: string;
7
+ trustedAt: string;
8
+ }
9
+ export type RepoConfigTrustStatus = {
10
+ trusted: true;
11
+ recordPath: string;
12
+ fingerprint: string;
13
+ } | {
14
+ trusted: false;
15
+ recordPath: string;
16
+ reason: 'missing' | 'malformed' | 'identity_mismatch' | 'fingerprint_mismatch';
17
+ };
18
+ export declare function repoConfigFingerprint(rawConfig: unknown): string;
19
+ export declare function repoConfigTrustPath(repoRoot: string, adapter: AdapterName): string;
20
+ export declare function inspectRepoConfigTrust(repoRoot: string, adapter: AdapterName, rawConfig: unknown): Promise<RepoConfigTrustStatus>;
21
+ export declare function trustRepoConfig(repoRoot: string, adapter: AdapterName, rawConfig: unknown): Promise<RepoConfigTrustRecordV1>;
22
+ export declare class RepoConfigTrustError extends Error {
23
+ readonly status: Exclude<RepoConfigTrustStatus, {
24
+ trusted: true;
25
+ }>;
26
+ constructor(status: Exclude<RepoConfigTrustStatus, {
27
+ trusted: true;
28
+ }>);
29
+ }
30
+ export declare function isRepoConfigTrustError(error: unknown): error is RepoConfigTrustError;
31
+ export declare function assertRepoConfigTrusted(repoRoot: string, adapter: AdapterName, rawConfig: unknown): Promise<void>;
@@ -0,0 +1,152 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { chmod, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { defaultControlPlaneDir } from './config.js';
6
+ import { canonicalStringify, hashValue } from './fingerprint.js';
7
+ import { canonicalPath } from './path-utils.js';
8
+ const TRUST_MESSAGE = 'Repository config is not trusted. Review it, then run `belay config trust`.';
9
+ function isObjectRecord(value) {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+ function hasOnlyExpectedKeys(value) {
13
+ const expected = new Set([
14
+ 'schemaVersion',
15
+ 'repoRoot',
16
+ 'adapter',
17
+ 'repoConfigFingerprint',
18
+ 'trustedAt',
19
+ ]);
20
+ const keys = Object.keys(value);
21
+ if (keys.length !== expected.size) {
22
+ return false;
23
+ }
24
+ return keys.every((key) => expected.has(key));
25
+ }
26
+ function isAdapterName(value) {
27
+ return value === 'cursor' || value === 'claude' || value === 'codex';
28
+ }
29
+ function isIsoTimestamp(value) {
30
+ return Number.isFinite(Date.parse(value));
31
+ }
32
+ function parseStrictTrustRecord(value) {
33
+ if (!isObjectRecord(value) || !hasOnlyExpectedKeys(value)) {
34
+ return null;
35
+ }
36
+ if (value.schemaVersion !== 1) {
37
+ return null;
38
+ }
39
+ if (typeof value.repoRoot !== 'string' || value.repoRoot.trim().length === 0) {
40
+ return null;
41
+ }
42
+ if (!isAdapterName(value.adapter)) {
43
+ return null;
44
+ }
45
+ if (typeof value.repoConfigFingerprint !== 'string' ||
46
+ !/^[a-f0-9]{64}$/.test(value.repoConfigFingerprint)) {
47
+ return null;
48
+ }
49
+ if (typeof value.trustedAt !== 'string' || !isIsoTimestamp(value.trustedAt)) {
50
+ return null;
51
+ }
52
+ return {
53
+ schemaVersion: 1,
54
+ repoRoot: value.repoRoot,
55
+ adapter: value.adapter,
56
+ repoConfigFingerprint: value.repoConfigFingerprint,
57
+ trustedAt: value.trustedAt,
58
+ };
59
+ }
60
+ export function repoConfigFingerprint(rawConfig) {
61
+ return hashValue(canonicalStringify(rawConfig));
62
+ }
63
+ export function repoConfigTrustPath(repoRoot, adapter) {
64
+ const canonicalRepoRoot = canonicalPath(repoRoot);
65
+ const identity = hashValue(`${canonicalRepoRoot}\u0000${adapter}`);
66
+ return path.join(defaultControlPlaneDir(), 'config-trust', `${identity}.json`);
67
+ }
68
+ async function writeTrustRecordAtomically(recordPath, record) {
69
+ const directory = path.dirname(recordPath);
70
+ await mkdir(directory, { recursive: true, mode: 0o700 });
71
+ await chmod(directory, 0o700).catch(() => undefined);
72
+ const tempPath = path.join(directory, `.${path.basename(recordPath)}.${process.pid}.${randomUUID()}.tmp`);
73
+ let handle = null;
74
+ try {
75
+ handle = await open(tempPath, 'wx', 0o600);
76
+ await handle.writeFile(`${JSON.stringify(record, null, 2)}\n`, 'utf8');
77
+ await handle.sync();
78
+ await handle.close();
79
+ handle = null;
80
+ await rename(tempPath, recordPath);
81
+ await chmod(recordPath, 0o600);
82
+ const directoryHandle = await open(directory, 'r');
83
+ try {
84
+ await directoryHandle.sync().catch(() => undefined);
85
+ }
86
+ finally {
87
+ await directoryHandle.close();
88
+ }
89
+ }
90
+ finally {
91
+ if (handle) {
92
+ await handle.close().catch(() => undefined);
93
+ }
94
+ await unlink(tempPath).catch(() => undefined);
95
+ }
96
+ }
97
+ export async function inspectRepoConfigTrust(repoRoot, adapter, rawConfig) {
98
+ const canonicalRepoRoot = canonicalPath(repoRoot);
99
+ const recordPath = repoConfigTrustPath(repoRoot, adapter);
100
+ if (!existsSync(recordPath)) {
101
+ return { trusted: false, recordPath, reason: 'missing' };
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(await readFile(recordPath, 'utf8'));
106
+ }
107
+ catch {
108
+ return { trusted: false, recordPath, reason: 'malformed' };
109
+ }
110
+ const record = parseStrictTrustRecord(parsed);
111
+ if (!record) {
112
+ return { trusted: false, recordPath, reason: 'malformed' };
113
+ }
114
+ if (record.adapter !== adapter || canonicalPath(record.repoRoot) !== canonicalRepoRoot) {
115
+ return { trusted: false, recordPath, reason: 'identity_mismatch' };
116
+ }
117
+ const fingerprint = repoConfigFingerprint(rawConfig);
118
+ if (record.repoConfigFingerprint !== fingerprint) {
119
+ return { trusted: false, recordPath, reason: 'fingerprint_mismatch' };
120
+ }
121
+ return { trusted: true, recordPath, fingerprint };
122
+ }
123
+ export async function trustRepoConfig(repoRoot, adapter, rawConfig) {
124
+ const recordPath = repoConfigTrustPath(repoRoot, adapter);
125
+ const record = {
126
+ schemaVersion: 1,
127
+ repoRoot: canonicalPath(repoRoot),
128
+ adapter,
129
+ repoConfigFingerprint: repoConfigFingerprint(rawConfig),
130
+ trustedAt: new Date().toISOString(),
131
+ };
132
+ await writeTrustRecordAtomically(recordPath, record);
133
+ return record;
134
+ }
135
+ export class RepoConfigTrustError extends Error {
136
+ status;
137
+ constructor(status) {
138
+ super(TRUST_MESSAGE);
139
+ this.name = 'RepoConfigTrustError';
140
+ this.status = status;
141
+ }
142
+ }
143
+ export function isRepoConfigTrustError(error) {
144
+ return error instanceof RepoConfigTrustError;
145
+ }
146
+ export async function assertRepoConfigTrusted(repoRoot, adapter, rawConfig) {
147
+ const status = await inspectRepoConfigTrust(repoRoot, adapter, rawConfig);
148
+ if (status.trusted) {
149
+ return;
150
+ }
151
+ throw new RepoConfigTrustError(status);
152
+ }
@@ -16,7 +16,7 @@ export function findCommandSubstitutions(command) {
16
16
  index += 1;
17
17
  continue;
18
18
  }
19
- if (char === '\\' && (inSingle || inDouble)) {
19
+ if (char === '\\' && inDouble) {
20
20
  escaping = true;
21
21
  index += 1;
22
22
  continue;
@@ -156,7 +156,7 @@ function extractBalancedParenContent(command, start) {
156
156
  index += 1;
157
157
  continue;
158
158
  }
159
- if (char === '\\' && (inSingle || inDouble)) {
159
+ if (char === '\\' && inDouble) {
160
160
  escaping = true;
161
161
  index += 1;
162
162
  continue;
@@ -42,7 +42,9 @@ function hasUnclosedQuote(command) {
42
42
  continue;
43
43
  }
44
44
  if (char === '\\') {
45
- escaping = true;
45
+ if (quote !== "'") {
46
+ escaping = true;
47
+ }
46
48
  continue;
47
49
  }
48
50
  if (quote) {
@@ -3,4 +3,4 @@
3
3
  * These commands guard classifier availability in tests and never grant runtime authority.
4
4
  * @see src/__tests__/verdict/structural-suite.test.ts
5
5
  */
6
- export declare const BENIGN_PROBE_CORES: readonly ["npm test", "npm run build", "pnpm test", "pnpm build", "pnpm vitest run src/example.test.ts", "bash -lc 'git status'", "belay approve belay_deadbeef1234", "bundle -v", "ruby -v", "yarn --version", "make -n test", "bin/rails routes", "bundle exec rubocop --version", "bundle exec rubocop test/upgrade_script_contract_test.rb", "ruby -Itest test/upgrade_script_contract_test.rb"];
6
+ export declare const BENIGN_PROBE_CORES: readonly ["npm test", "npm run build", "pnpm test", "pnpm build", "pnpm vitest run src/example.test.ts", "bash -lc 'git status'", "bundle -v", "ruby -v", "yarn --version", "make -n test", "bin/rails routes", "bundle exec rubocop --version", "bundle exec rubocop test/upgrade_script_contract_test.rb", "ruby -Itest test/upgrade_script_contract_test.rb"];
@@ -10,7 +10,6 @@ export const BENIGN_PROBE_CORES = [
10
10
  'pnpm build',
11
11
  'pnpm vitest run src/example.test.ts',
12
12
  "bash -lc 'git status'",
13
- 'belay approve belay_deadbeef1234',
14
13
  'bundle -v',
15
14
  'ruby -v',
16
15
  'yarn --version',
@@ -5,13 +5,17 @@ export type ManagedHookDefinition = {
5
5
  command: string;
6
6
  placement: 'prepend' | 'append';
7
7
  matcher?: string;
8
+ failClosed?: boolean;
8
9
  };
9
- export declare function getManagedHookEntries(platform?: NodeJS.Platform, hooksDir?: string, repoRoot?: string): Array<{
10
+ export type CursorManagedHookDefinition = ManagedHookDefinition & {
11
+ failClosed: true;
12
+ };
13
+ export declare function getManagedHookEntries(platform?: NodeJS.Platform, hooksDir?: string, repoRoot?: string, runnerPathMode?: 'absolute' | 'legacy-relative' | 'legacy-absolute' | 'legacy-quoted-absolute' | 'legacy-bare-powershell-absolute'): Array<{
10
14
  event: string;
11
- definition: ManagedHookDefinition;
15
+ definition: CursorManagedHookDefinition;
12
16
  }>;
13
17
  /** @deprecated Use getManagedHookEntries instead. */
14
- export declare function getManagedHookEvents(platform?: NodeJS.Platform): Record<string, ManagedHookDefinition>;
18
+ export declare function getManagedHookEvents(platform?: NodeJS.Platform): Record<string, CursorManagedHookDefinition>;
15
19
  export declare const EMPTY_APPROVALS: {
16
20
  readonly version: 1;
17
21
  readonly approvals: readonly [];