@guilz-dev/belay 0.9.4 → 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 (49) hide show
  1. package/README.md +3 -2
  2. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +3 -0
  3. package/dist/adapters/cursor/hook-dispatch-entry.js +11 -0
  4. package/dist/adapters/cursor/hook-router.js +14 -7
  5. package/dist/adapters/cursor/hooks.d.ts +1 -1
  6. package/dist/adapters/cursor/hooks.js +16 -5
  7. package/dist/adapters/cursor/routing-config-trust.d.ts +1 -0
  8. package/dist/adapters/cursor/routing-config-trust.js +67 -0
  9. package/dist/adapters/cursor/runtime-entry.d.ts +2 -2
  10. package/dist/adapters/cursor/runtime-entry.js +35 -10
  11. package/dist/adapters/shared/gate-runtime.js +28 -17
  12. package/dist/bundle/claude-runtime.mjs +602 -438
  13. package/dist/bundle/codex-runtime.mjs +612 -448
  14. package/dist/bundle/cursor-dispatcher.mjs +100 -26
  15. package/dist/bundle/cursor-runtime.mjs +662 -470
  16. package/dist/cli.js +53 -4
  17. package/dist/commands/approval-token.d.ts +10 -0
  18. package/dist/commands/approval-token.js +26 -0
  19. package/dist/commands/audit.d.ts +2 -1
  20. package/dist/commands/audit.js +2 -2
  21. package/dist/commands/config.d.ts +1 -1
  22. package/dist/commands/config.js +28 -2
  23. package/dist/commands/doctor.js +10 -54
  24. package/dist/commands/dogfood-check.d.ts +3 -0
  25. package/dist/commands/dogfood-check.js +116 -0
  26. package/dist/commands/dogfood.d.ts +1 -0
  27. package/dist/commands/dogfood.js +4 -3
  28. package/dist/commands/judge.js +2 -2
  29. package/dist/commands/recovery-checkpoints.js +2 -11
  30. package/dist/config-io.d.ts +1 -0
  31. package/dist/config-io.js +8 -1
  32. package/dist/core/dogfood-environment.d.ts +8 -0
  33. package/dist/core/dogfood-environment.js +55 -0
  34. package/dist/core/effect-ir/shell-lower/decoders/belay.js +12 -0
  35. package/dist/core/egress-approval.js +0 -18
  36. package/dist/core/notify.d.ts +8 -2
  37. package/dist/core/notify.js +63 -7
  38. package/dist/core/recovery/operator-guidance.js +4 -4
  39. package/dist/core/repo-config-trust.d.ts +31 -0
  40. package/dist/core/repo-config-trust.js +152 -0
  41. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  42. package/dist/corpus/benign-probe-cores.js +0 -1
  43. package/dist/defaults.js +0 -25
  44. package/dist/installer/scope-config.js +2 -2
  45. package/dist/installer.js +4 -4
  46. package/dist/types.d.ts +19 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +5 -1
@@ -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
+ }
@@ -12,6 +12,13 @@ export function decodeBelay(args, repoRoot, segment) {
12
12
  const configJudgeMutation = section === 'config' &&
13
13
  ((['set', 'unset'].includes(operation ?? '') && key?.startsWith('judge.')) ||
14
14
  (operation === 'credential' && key === 'mode'));
15
+ const approvalAuthorityCommand = [
16
+ 'approval-token',
17
+ 'approve',
18
+ 'revoke',
19
+ 'standing-allow',
20
+ ].includes(section ?? '');
21
+ const configTrustMutation = section === 'config' && operation === 'trust';
15
22
  if (judgeCommand || configRead || configJudgeMutation) {
16
23
  return [
17
24
  processRequirement('belay', 'inspect', segment, [
@@ -20,6 +27,11 @@ export function decodeBelay(args, repoRoot, segment) {
20
27
  ]),
21
28
  ];
22
29
  }
30
+ if (approvalAuthorityCommand || configTrustMutation) {
31
+ return [
32
+ requirement('control_plane.write', 'control_plane.write', { kind: 'path', path: path.join(repoRoot, '.belay-control-plane') }, segment, [approvalAuthorityCommand ? 'belay.approval_authority' : 'belay.config_trust']),
33
+ ];
34
+ }
23
35
  if (section === 'config' && ['set', 'unset', 'credential'].includes(operation ?? '')) {
24
36
  return [
25
37
  requirement('control_plane.write', 'control_plane.write', { kind: 'path', path: path.join(repoRoot, '.belay-control-plane') }, segment, ['belay.config_non_judge_mutation']),
@@ -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) {
@@ -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) {
@@ -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
+ }
@@ -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',
package/dist/defaults.js CHANGED
@@ -44,31 +44,6 @@ export function getManagedHookEntries(platform = process.platform, hooksDir, rep
44
44
  definition: {
45
45
  command: toolGate,
46
46
  placement: 'prepend',
47
- matcher: 'Task',
48
- },
49
- },
50
- {
51
- event: 'preToolUse',
52
- definition: {
53
- command: toolGate,
54
- placement: 'prepend',
55
- matcher: 'Write',
56
- },
57
- },
58
- {
59
- event: 'preToolUse',
60
- definition: {
61
- command: toolGate,
62
- placement: 'prepend',
63
- matcher: 'StrReplace',
64
- },
65
- },
66
- {
67
- event: 'preToolUse',
68
- definition: {
69
- command: toolGate,
70
- placement: 'prepend',
71
- matcher: 'Delete',
72
47
  },
73
48
  },
74
49
  {
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { getAdapterLayout } from '../adapters/layouts/index.js';
3
3
  import { resolveInstallScope, resolveScopedPaths, } from '../adapters/layouts/scope.js';
4
- import { loadConfigFile, writeConfigFile } from '../config-io.js';
4
+ import { loadConfigFile, writeTrustedConfigFile } from '../config-io.js';
5
5
  export async function resolveOperationScope(repoRoot, adapter, options = {}) {
6
6
  const layout = getAdapterLayout(adapter);
7
7
  let persisted;
@@ -17,7 +17,7 @@ export async function applyInstallScope(repoRoot, adapter, scope, config) {
17
17
  return current;
18
18
  }
19
19
  const updated = { ...current, installScope: scope };
20
- await writeConfigFile(repoRoot, updated, adapter);
20
+ await writeTrustedConfigFile(repoRoot, updated, adapter);
21
21
  return updated;
22
22
  }
23
23
  export function pathsForOperation(adapter, scope, repoRoot) {
package/dist/installer.js CHANGED
@@ -6,7 +6,7 @@ import { cursorLayout } from './adapters/layouts/cursor.js';
6
6
  import { resolveScopedPaths } from './adapters/layouts/scope.js';
7
7
  import { getAdapter } from './adapters/registry.js';
8
8
  import { dogfoodProject } from './commands/dogfood.js';
9
- import { detectAdapterName, loadConfigFile, mergeAndWriteConfig, writeConfigFile, } from './config-io.js';
9
+ import { detectAdapterName, loadConfigFile, mergeAndWriteConfig, writeTrustedConfigFile, } from './config-io.js';
10
10
  import { appendCliAuditEvent } from './core/audit-io.js';
11
11
  import { archiveLegacyAuditLogIfNeeded } from './core/audit-legacy-archive.js';
12
12
  import { isFreshConfigInput, mergeConfig, normalizeConfig, } from './core/config.js';
@@ -230,7 +230,7 @@ async function applyInitJudgeConfig(repoRoot, adapterName, options, isFreshBefor
230
230
  !hasValidCloudConsent(configWithJudge.judge)) {
231
231
  process.stderr.write('Warning: Cloud judge saved without recorded consent. Tier1 cloud judge will fail closed until consent is granted (belay judge consent + belay approve, or TTY --accept-cloud-judge).\n');
232
232
  }
233
- await writeConfigFile(repoRoot, configWithJudge, adapterName);
233
+ await writeTrustedConfigFile(repoRoot, configWithJudge, adapterName);
234
234
  if (migrated) {
235
235
  await auditJudgeMigrationIfNeeded(repoRoot, adapterName, mergedConfig.judge, finalJudge, options.migrateJudgeDefault ? 'belay init --migrate-judge-default' : 'belay init');
236
236
  }
@@ -263,7 +263,7 @@ export async function initProject(options = {}) {
263
263
  const existing = await loadConfigFile(repoRoot, adapterName);
264
264
  const presetConfig = mergeConfig(applyConfigPreset(options.preset));
265
265
  const merged = mergeConfig(presetConfig, existing);
266
- await writeConfigFile(repoRoot, merged, adapterName);
266
+ await writeTrustedConfigFile(repoRoot, merged, adapterName);
267
267
  }
268
268
  if (options.dogfood === true) {
269
269
  await dogfoodProject({ targetDir: repoRoot, adapter: adapterName });
@@ -285,7 +285,7 @@ export async function upgradeProject(options = {}) {
285
285
  const migrated = migrateImplicitLocalJudgeIfNeeded(mergedConfig.judge, adapterName);
286
286
  if (migrated) {
287
287
  const configWithJudge = normalizeConfig({ ...mergedConfig, version: 4, judge: migrated });
288
- await writeConfigFile(repoRoot, configWithJudge, adapterName);
288
+ await writeTrustedConfigFile(repoRoot, configWithJudge, adapterName);
289
289
  await auditJudgeMigrationIfNeeded(repoRoot, adapterName, mergedConfig.judge, migrated, 'belay upgrade --migrate-judge-default');
290
290
  }
291
291
  }
package/dist/types.d.ts CHANGED
@@ -238,6 +238,8 @@ export interface DogfoodOptions {
238
238
  targetDir?: string;
239
239
  enforce?: boolean;
240
240
  force?: boolean;
241
+ check?: boolean;
242
+ since?: string;
241
243
  adapter?: AdapterName;
242
244
  }
243
245
  export interface DogfoodResult {
@@ -248,6 +250,23 @@ export interface DogfoodResult {
248
250
  mode: string;
249
251
  unknownLocalEffect: string;
250
252
  }
253
+ export interface DogfoodCheckOptions {
254
+ targetDir?: string;
255
+ adapter?: AdapterName;
256
+ since: string;
257
+ }
258
+ export interface DogfoodCheckResult {
259
+ ok: boolean;
260
+ repoRoot: string;
261
+ since: string;
262
+ gateEvents: number;
263
+ auditModeDenyCount: number;
264
+ hostDeniedAfterAllowCount: number;
265
+ shellPreToolUseCount: number;
266
+ mismatchedCohortCount: number;
267
+ environmentSkewCount: number;
268
+ failures: string[];
269
+ }
251
270
  export type ExplainKind = 'shell' | 'tool' | 'subagent';
252
271
  export interface ExplainReport {
253
272
  repoRoot: string;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const PACKAGE_VERSION = "0.9.4";
1
+ export declare const PACKAGE_VERSION = "0.10.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/sync-version.mjs — do not edit.
2
- export const PACKAGE_VERSION = '0.9.4';
2
+ export const PACKAGE_VERSION = '0.10.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guilz-dev/belay",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
4
4
  "description": "Belay-style approval and audit gating for agent runtimes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,10 +56,14 @@
56
56
  "lint": "biome check src package.json README.md CHANGELOG.md CONTRIBUTING.md SECURITY.md tsconfig.json tsconfig.build.json vitest.config.ts scripts docs",
57
57
  "typecheck": "tsc --noEmit",
58
58
  "test": "pnpm build && vitest run",
59
+ "test:run": "vitest run",
59
60
  "test:structural": "pnpm build && vitest run src/__tests__/verdict/structural-suite.test.ts",
61
+ "test:structural:run": "vitest run src/__tests__/verdict/structural-suite.test.ts",
60
62
  "test:docker": "pnpm build && vitest run --config vitest.live.config.ts src/__tests__/capability/boundary-container-isolation.test.ts src/__tests__/capability/boundary-container-workspace-mount.test.ts src/__tests__/capability/boundary-driver-container.test.ts src/__tests__/contained-execution-docker.integration.test.ts",
61
63
  "test:llm": "pnpm build && vitest run --config vitest.live.config.ts src/__tests__/verdict/llm/judge-accuracy.test.ts",
62
64
  "test:stable": "pnpm build && vitest run && vitest run && vitest run",
65
+ "test:stable:run": "vitest run && vitest run && vitest run",
66
+ "test:macos": "vitest run src/__tests__/native-seatbelt-boundary-probe.test.ts src/__tests__/git-resource-identity.test.ts src/__tests__/installer-scope.test.ts src/__tests__/cursor-hook-precedence.integration.test.ts src/__tests__/cursor-hooks.test.ts",
63
67
  "corpus": "pnpm build && node scripts/corpus.mjs",
64
68
  "probe:adversarial": "pnpm build && node scripts/adversarial-probe.mjs",
65
69
  "probe:cursor-shell-rewrite": "node scripts/cursor-shell-rewrite-probe.mjs",