@guilz-dev/belay 0.9.4 → 0.10.1

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 (62) hide show
  1. package/README.md +3 -2
  2. package/dist/adapters/claude/runtime-entry.js +5 -18
  3. package/dist/adapters/codex/runtime-entry.js +10 -11
  4. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +3 -0
  5. package/dist/adapters/cursor/hook-dispatch-entry.js +11 -0
  6. package/dist/adapters/cursor/hook-router.d.ts +1 -0
  7. package/dist/adapters/cursor/hook-router.js +39 -9
  8. package/dist/adapters/cursor/hook-routing-health.d.ts +1 -0
  9. package/dist/adapters/cursor/hook-routing-health.js +53 -0
  10. package/dist/adapters/cursor/hooks.d.ts +1 -1
  11. package/dist/adapters/cursor/hooks.js +16 -5
  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/runtime-entry.d.ts +2 -2
  15. package/dist/adapters/cursor/runtime-entry.js +35 -10
  16. package/dist/adapters/shared/gate-runtime.d.ts +0 -2
  17. package/dist/adapters/shared/gate-runtime.js +12 -52
  18. package/dist/bundle/claude-runtime.mjs +1083 -669
  19. package/dist/bundle/codex-runtime.mjs +1092 -698
  20. package/dist/bundle/cursor-dispatcher.mjs +119 -28
  21. package/dist/bundle/cursor-runtime.mjs +1132 -677
  22. package/dist/cli.js +53 -4
  23. package/dist/commands/approval-token.d.ts +10 -0
  24. package/dist/commands/approval-token.js +26 -0
  25. package/dist/commands/audit.d.ts +2 -1
  26. package/dist/commands/audit.js +2 -2
  27. package/dist/commands/config.d.ts +1 -1
  28. package/dist/commands/config.js +28 -2
  29. package/dist/commands/doctor.js +14 -54
  30. package/dist/commands/dogfood-check.d.ts +3 -0
  31. package/dist/commands/dogfood-check.js +116 -0
  32. package/dist/commands/dogfood.d.ts +1 -0
  33. package/dist/commands/dogfood.js +4 -3
  34. package/dist/commands/judge.js +2 -2
  35. package/dist/commands/recovery-checkpoints.js +2 -11
  36. package/dist/config-io.d.ts +1 -0
  37. package/dist/config-io.js +8 -1
  38. package/dist/core/capability/index.d.ts +1 -1
  39. package/dist/core/capability/index.js +1 -1
  40. package/dist/core/capability/policy-engine.d.ts +19 -0
  41. package/dist/core/capability/policy-engine.js +31 -0
  42. package/dist/core/classify-tool.js +372 -148
  43. package/dist/core/config.js +2 -2
  44. package/dist/core/dogfood-environment.d.ts +8 -0
  45. package/dist/core/dogfood-environment.js +55 -0
  46. package/dist/core/effect-ir/shell-lower/decoders/belay.js +12 -0
  47. package/dist/core/egress-approval.js +0 -18
  48. package/dist/core/gate-engine.js +2 -9
  49. package/dist/core/notify.d.ts +8 -2
  50. package/dist/core/notify.js +63 -7
  51. package/dist/core/recovery/operator-guidance.js +4 -4
  52. package/dist/core/repo-config-trust.d.ts +31 -0
  53. package/dist/core/repo-config-trust.js +152 -0
  54. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  55. package/dist/corpus/benign-probe-cores.js +0 -1
  56. package/dist/defaults.js +0 -25
  57. package/dist/installer/scope-config.js +2 -2
  58. package/dist/installer.js +4 -4
  59. package/dist/types.d.ts +19 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +5 -1
@@ -4,8 +4,8 @@
4
4
  import process2 from "node:process";
5
5
 
6
6
  // src/adapters/cursor/hook-router.ts
7
- import { accessSync, constants, existsSync as existsSync2, readFileSync, realpathSync, statSync } from "node:fs";
8
- import path3 from "node:path";
7
+ import { accessSync, constants, existsSync as existsSync2, readFileSync as readFileSync2, realpathSync as realpathSync2, statSync } from "node:fs";
8
+ import path4 from "node:path";
9
9
 
10
10
  // src/adapters/cursor/cwd-resolution.ts
11
11
  import path from "node:path";
@@ -45,29 +45,89 @@ function resolveCursorActionCwdDetails(payload, fallback = process.cwd()) {
45
45
  };
46
46
  }
47
47
 
48
+ // src/adapters/cursor/routing-config-trust.ts
49
+ import { createHash } from "node:crypto";
50
+ import { readFileSync, realpathSync } from "node:fs";
51
+ import path2 from "node:path";
52
+ function canonicalStringify(value) {
53
+ if (value === null || typeof value !== "object") {
54
+ return JSON.stringify(value);
55
+ }
56
+ if (Array.isArray(value)) {
57
+ return `[${value.map((item) => canonicalStringify(item)).join(",")}]`;
58
+ }
59
+ const entries = Object.entries(value).sort(
60
+ ([left], [right]) => left.localeCompare(right)
61
+ );
62
+ return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalStringify(child)}`).join(",")}}`;
63
+ }
64
+ function hashValue(value) {
65
+ return createHash("sha256").update(value).digest("hex");
66
+ }
67
+ function defaultControlPlaneDir() {
68
+ if (process.platform === "win32" && process.env.APPDATA?.trim()) {
69
+ return path2.join(process.env.APPDATA.trim(), "agent-belay");
70
+ }
71
+ const base = process.env.XDG_CONFIG_HOME?.trim() || path2.join(process.env.HOME ?? "", ".config");
72
+ return path2.join(base, "agent-belay");
73
+ }
74
+ function canonicalRepoRoot(repoRoot) {
75
+ try {
76
+ return realpathSync(repoRoot);
77
+ } catch {
78
+ return path2.resolve(repoRoot);
79
+ }
80
+ }
81
+ function isTrustedCursorRoutingConfig(repoRoot, rawConfig) {
82
+ const canonicalRoot = canonicalRepoRoot(repoRoot);
83
+ const identity = hashValue(`${canonicalRoot}\0cursor`);
84
+ const recordPath = path2.join(defaultControlPlaneDir(), "config-trust", `${identity}.json`);
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(readFileSync(recordPath, "utf8"));
88
+ } catch {
89
+ return false;
90
+ }
91
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
92
+ return false;
93
+ }
94
+ const record = parsed;
95
+ const expectedKeys = /* @__PURE__ */ new Set([
96
+ "schemaVersion",
97
+ "repoRoot",
98
+ "adapter",
99
+ "repoConfigFingerprint",
100
+ "trustedAt"
101
+ ]);
102
+ if (Object.keys(record).length !== expectedKeys.size || !Object.keys(record).every((key) => expectedKeys.has(key)) || record.schemaVersion !== 1 || record.adapter !== "cursor" || typeof record.repoRoot !== "string" || canonicalRepoRoot(record.repoRoot) !== canonicalRoot || typeof record.repoConfigFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(record.repoConfigFingerprint) || typeof record.trustedAt !== "string" || !Number.isFinite(Date.parse(record.trustedAt))) {
103
+ return false;
104
+ }
105
+ return record.repoConfigFingerprint === hashValue(canonicalStringify(rawConfig));
106
+ }
107
+
48
108
  // src/adapters/cursor/routing-layout.ts
49
109
  import { existsSync } from "node:fs";
50
- import path2 from "node:path";
110
+ import path3 from "node:path";
51
111
  function cursorRoutingConfigPath(repoRoot) {
52
- return path2.join(repoRoot, ".cursor", "belay.config.json");
112
+ return path3.join(repoRoot, ".cursor", "belay.config.json");
53
113
  }
54
114
  function cursorRoutingHooksDir(repoRoot) {
55
- return path2.join(repoRoot, ".cursor", "hooks");
115
+ return path3.join(repoRoot, ".cursor", "hooks");
56
116
  }
57
117
  function cursorRoutingHooksSettingsPath(repoRoot) {
58
- return path2.join(repoRoot, ".cursor", "hooks.json");
118
+ return path3.join(repoRoot, ".cursor", "hooks.json");
59
119
  }
60
120
  function cursorRoutingRuntimeDir(repoRoot) {
61
- return path2.join(repoRoot, ".cursor", "belay", "runtime");
121
+ return path3.join(repoRoot, ".cursor", "belay", "runtime");
62
122
  }
63
123
  function findCursorRoutingRepoRoot(startPath) {
64
- const resolvedStart = path2.resolve(startPath);
124
+ const resolvedStart = path3.resolve(startPath);
65
125
  let current = resolvedStart;
66
126
  while (true) {
67
- if (existsSync(path2.join(current, ".git")) || existsSync(path2.join(current, ".cursor")) && existsSync(cursorRoutingConfigPath(current))) {
127
+ if (existsSync(path3.join(current, ".git")) || existsSync(path3.join(current, ".cursor")) && existsSync(cursorRoutingConfigPath(current))) {
68
128
  return current;
69
129
  }
70
- const parent = path2.dirname(current);
130
+ const parent = path3.dirname(current);
71
131
  if (parent === current) {
72
132
  return resolvedStart;
73
133
  }
@@ -76,12 +136,13 @@ function findCursorRoutingRepoRoot(startPath) {
76
136
  }
77
137
 
78
138
  // src/adapters/cursor/hook-router.ts
139
+ var CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE = "belay project hook owner is unavailable; the global sentinel blocked this action.";
79
140
  function canonicalExistingPath(value) {
80
141
  if (!existsSync2(value)) {
81
142
  return void 0;
82
143
  }
83
144
  try {
84
- return realpathSync(value);
145
+ return realpathSync2(value);
85
146
  } catch {
86
147
  return void 0;
87
148
  }
@@ -126,7 +187,7 @@ function eventNameFor(params) {
126
187
  return "postToolUse";
127
188
  }
128
189
  function selectedRunnerPath(hooksDir) {
129
- const runnerPath = path3.join(
190
+ const runnerPath = path4.join(
130
191
  hooksDir,
131
192
  process.platform === "win32" ? "belay-runner.ps1" : "belay-runner"
132
193
  );
@@ -179,21 +240,25 @@ function commandInvokesProjectHook(command, runnerPath, kind, eventName) {
179
240
  ].join(" ");
180
241
  return Buffer.from(encoded, "base64").toString("utf16le") === expected;
181
242
  }
182
- function expectedMatcher(eventName, payload) {
243
+ function expectedMatchers(eventName, payload) {
183
244
  if (eventName === "preToolUse") {
184
- return typeof payload.tool_name === "string" ? payload.tool_name : void 0;
245
+ const values = [void 0];
246
+ if (typeof payload.tool_name === "string") {
247
+ values.push(payload.tool_name);
248
+ }
249
+ return values;
185
250
  }
186
251
  if (eventName === "subagentStart") {
187
- return typeof payload.subagent_type === "string" ? payload.subagent_type : void 0;
252
+ return [typeof payload.subagent_type === "string" ? payload.subagent_type : void 0];
188
253
  }
189
- return void 0;
254
+ return [void 0];
190
255
  }
191
256
  function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
192
257
  const eventName = eventNameFor(params);
193
- const matcher = expectedMatcher(eventName, params.payload);
258
+ const matchers = new Set(expectedMatchers(eventName, params.payload));
194
259
  let parsed;
195
260
  try {
196
- parsed = JSON.parse(readFileSync(cursorRoutingHooksSettingsPath(repoRoot), "utf8"));
261
+ parsed = JSON.parse(readFileSync2(cursorRoutingHooksSettingsPath(repoRoot), "utf8"));
197
262
  } catch {
198
263
  return false;
199
264
  }
@@ -213,23 +278,29 @@ function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
213
278
  return false;
214
279
  }
215
280
  const definition = entry;
216
- return definition.failClosed === true && definition.matcher === matcher && typeof definition.command === "string" && commandInvokesProjectHook(definition.command, runnerPath, params.kind, eventName);
281
+ return definition.failClosed === true && matchers.has(definition.matcher) && typeof definition.command === "string" && commandInvokesProjectHook(definition.command, runnerPath, params.kind, eventName);
217
282
  });
218
283
  }
219
284
  function hasMatchingProjectShim(repoRoot, kind) {
220
285
  let source;
221
286
  try {
222
- source = readFileSync(path3.join(cursorRoutingHooksDir(repoRoot), hookScriptFor(kind)), "utf8");
287
+ source = readFileSync2(path4.join(cursorRoutingHooksDir(repoRoot), hookScriptFor(kind)), "utf8");
223
288
  } catch {
224
289
  return false;
225
290
  }
226
291
  return source.includes("from '../belay/runtime/dispatcher.mjs'") && source.includes(`origin: ${JSON.stringify({ scope: "project", repoRoot })}`);
227
292
  }
293
+ function hasProjectOwnerInstallation(repoRoot) {
294
+ const hooksDir = cursorRoutingHooksDir(repoRoot);
295
+ return Boolean(
296
+ selectedRunnerPath(hooksDir) && isRegularFile(path4.join(cursorRoutingRuntimeDir(repoRoot), "dispatcher.mjs"))
297
+ );
298
+ }
228
299
  function hasCallableProjectOwner(repoRoot, params) {
229
300
  const hooksDir = cursorRoutingHooksDir(repoRoot);
230
301
  const runnerPath = selectedRunnerPath(hooksDir);
231
302
  return Boolean(
232
- runnerPath && isRegularFile(path3.join(cursorRoutingRuntimeDir(repoRoot), "dispatcher.mjs")) && hasMatchingProjectShim(repoRoot, params.kind) && hasManagedProjectHookEntry(repoRoot, runnerPath, params)
303
+ runnerPath && isRegularFile(path4.join(cursorRoutingRuntimeDir(repoRoot), "dispatcher.mjs")) && hasMatchingProjectShim(repoRoot, params.kind) && hasManagedProjectHookEntry(repoRoot, runnerPath, params)
233
304
  );
234
305
  }
235
306
  function payloadForKind(payload, kind) {
@@ -267,18 +338,24 @@ function selectedActionPath(payload) {
267
338
  }
268
339
  return void 0;
269
340
  }
341
+ function hasRoutingRootMarker(dir) {
342
+ return existsSync2(path4.join(dir, ".git")) || existsSync2(path4.join(dir, ".cursor")) && existsSync2(cursorRoutingConfigPath(dir));
343
+ }
270
344
  function readInstallScope(repoRoot) {
271
345
  const configPath = cursorRoutingConfigPath(repoRoot);
272
346
  let source;
273
347
  try {
274
- source = readFileSync(configPath, "utf8");
348
+ source = readFileSync2(configPath, "utf8");
275
349
  } catch (error) {
276
350
  return error.code === "ENOENT" ? "missing" : "project";
277
351
  }
278
352
  try {
279
353
  const parsed = JSON.parse(source);
280
354
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.installScope === "global") {
281
- return "global";
355
+ if (isTrustedCursorRoutingConfig(repoRoot, parsed)) {
356
+ return "global";
357
+ }
358
+ return hasProjectOwnerInstallation(repoRoot) ? "project" : "global";
282
359
  }
283
360
  } catch {
284
361
  }
@@ -287,7 +364,7 @@ function readInstallScope(repoRoot) {
287
364
  function routeCursorHook(params) {
288
365
  const relevantPayload = payloadForKind(params.payload, params.kind);
289
366
  const selectedPath = selectedActionPath(relevantPayload);
290
- if (!selectedPath || !path3.isAbsolute(selectedPath)) {
367
+ if (!selectedPath || !path4.isAbsolute(selectedPath)) {
291
368
  return { decision: "fail_closed", message: "belay could not determine the workspace." };
292
369
  }
293
370
  const resolution = resolveCursorActionCwdDetails(relevantPayload, "/");
@@ -295,10 +372,14 @@ function routeCursorHook(params) {
295
372
  if (!canonicalCwd) {
296
373
  return { decision: "fail_closed", message: "belay could not determine the workspace." };
297
374
  }
298
- const repoRoot = canonicalExistingPath(findCursorRoutingRepoRoot(canonicalCwd));
375
+ const discoveredRepoRoot = findCursorRoutingRepoRoot(canonicalCwd);
376
+ const repoRoot = canonicalExistingPath(discoveredRepoRoot);
299
377
  if (!repoRoot) {
300
378
  return { decision: "neutral" };
301
379
  }
380
+ if (repoRoot === canonicalCwd && !hasRoutingRootMarker(repoRoot)) {
381
+ return { decision: "neutral" };
382
+ }
302
383
  const installScope = readInstallScope(repoRoot);
303
384
  if (installScope === "missing") {
304
385
  return { decision: "neutral" };
@@ -313,10 +394,10 @@ function routeCursorHook(params) {
313
394
  if (params.origin.scope === "global") {
314
395
  return callableProjectOwner ? { decision: "neutral" } : {
315
396
  decision: "fail_closed",
316
- message: "belay project hook owner is unavailable; the global sentinel blocked this action."
397
+ message: `${CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE} Run belay doctor from this repository to diagnose hook routing.`
317
398
  };
318
399
  }
319
- const complete = callableProjectOwner && isRegularFile(path3.join(cursorRoutingRuntimeDir(repoRoot), "core.mjs"));
400
+ const complete = callableProjectOwner && isRegularFile(path4.join(cursorRoutingRuntimeDir(repoRoot), "core.mjs"));
320
401
  return complete ? { decision: "execute", repoRoot } : { decision: "fail_closed", message: "belay project hook installation is incomplete." };
321
402
  }
322
403
 
@@ -408,7 +489,16 @@ async function dispatchCursorHookResponse(params) {
408
489
  if (!input.ok) {
409
490
  return failClosedResponse(params.kind, "belay received malformed Cursor hook input.");
410
491
  }
492
+ if (params.kind === "tool-gate" && (params.eventName === "preToolUse" || params.eventName === "PreToolUse") && input.payload.tool_name === "Shell") {
493
+ return neutralResponse(params.kind);
494
+ }
411
495
  if (!isRecognizedPayload(params, input.payload)) {
496
+ if (params.kind === "tool-gate" && (params.eventName === "preToolUse" || params.eventName === "PreToolUse") && isNonEmptyString(input.payload.tool_name)) {
497
+ return failClosedResponse(
498
+ params.kind,
499
+ "belay received a malformed preToolUse payload. Run belay doctor, then retry."
500
+ );
501
+ }
412
502
  return neutralResponse(params.kind);
413
503
  }
414
504
  try {
@@ -439,5 +529,6 @@ async function dispatchCursorHook(params) {
439
529
  `);
440
530
  }
441
531
  export {
442
- dispatchCursorHook
532
+ dispatchCursorHook,
533
+ dispatchCursorHookResponse
443
534
  };