@guilz-dev/belay 0.2.0 → 0.3.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 (76) hide show
  1. package/README.md +39 -12
  2. package/dist/adapters/shared/gate-runtime.js +13 -5
  3. package/dist/bundle/claude-runtime.mjs +1659 -371
  4. package/dist/bundle/codex-runtime.mjs +1658 -371
  5. package/dist/bundle/cursor-runtime.mjs +1659 -371
  6. package/dist/cli.js +198 -8
  7. package/dist/commands/approve.js +11 -0
  8. package/dist/commands/config.d.ts +70 -0
  9. package/dist/commands/config.js +494 -0
  10. package/dist/commands/doctor.js +2 -1
  11. package/dist/commands/health-snapshot.d.ts +6 -0
  12. package/dist/commands/health-snapshot.js +34 -0
  13. package/dist/commands/judge.d.ts +90 -0
  14. package/dist/commands/judge.js +285 -0
  15. package/dist/commands/status.js +2 -2
  16. package/dist/commands/stdin-key.d.ts +1 -0
  17. package/dist/commands/stdin-key.js +8 -0
  18. package/dist/conformance/guarantee-table.js +12 -0
  19. package/dist/conformance/types.d.ts +2 -0
  20. package/dist/core/audit-io.d.ts +2 -0
  21. package/dist/core/audit-io.js +14 -0
  22. package/dist/core/capability/index.d.ts +2 -2
  23. package/dist/core/capability/index.js +2 -2
  24. package/dist/core/capability/paths.d.ts +1 -0
  25. package/dist/core/capability/paths.js +74 -6
  26. package/dist/core/capability/reasons.d.ts +3 -0
  27. package/dist/core/capability/reasons.js +8 -0
  28. package/dist/core/classify-tool.js +97 -27
  29. package/dist/core/config-layers.js +2 -1
  30. package/dist/core/config.d.ts +22 -2
  31. package/dist/core/config.js +110 -11
  32. package/dist/core/credential-store.d.ts +11 -0
  33. package/dist/core/credential-store.js +60 -0
  34. package/dist/core/gate-engine.js +104 -13
  35. package/dist/core/integrity.d.ts +2 -0
  36. package/dist/core/integrity.js +13 -0
  37. package/dist/core/judge-api-key.d.ts +19 -0
  38. package/dist/core/judge-api-key.js +74 -0
  39. package/dist/core/judge-cloud-consent.d.ts +13 -0
  40. package/dist/core/judge-cloud-consent.js +38 -0
  41. package/dist/core/judge-config.d.ts +41 -4
  42. package/dist/core/judge-config.js +263 -57
  43. package/dist/core/judge-doctor.d.ts +6 -1
  44. package/dist/core/judge-doctor.js +147 -96
  45. package/dist/core/judge-model-discovery.d.ts +24 -0
  46. package/dist/core/judge-model-discovery.js +168 -0
  47. package/dist/core/judge-model-policy.d.ts +5 -0
  48. package/dist/core/judge-model-policy.js +21 -0
  49. package/dist/core/judge-runtime-detection.d.ts +9 -0
  50. package/dist/core/judge-runtime-detection.js +68 -0
  51. package/dist/core/transactional/diff-evaluator.js +1 -19
  52. package/dist/core/types.d.ts +2 -0
  53. package/dist/core/verdict/adapter.d.ts +1 -0
  54. package/dist/core/verdict/adapter.js +7 -1
  55. package/dist/core/verdict/containment.d.ts +5 -0
  56. package/dist/core/verdict/containment.js +32 -2
  57. package/dist/core/verdict/judge-catalog.d.ts +40 -0
  58. package/dist/core/verdict/judge-catalog.js +148 -0
  59. package/dist/core/verdict/judge-cli.d.ts +23 -0
  60. package/dist/core/verdict/judge-cli.js +280 -0
  61. package/dist/core/verdict/judge-factory.d.ts +15 -4
  62. package/dist/core/verdict/judge-factory.js +117 -14
  63. package/dist/core/verdict/judge.d.ts +20 -1
  64. package/dist/core/verdict/judge.js +83 -14
  65. package/dist/core/verdict/persistent-paths.d.ts +8 -0
  66. package/dist/core/verdict/persistent-paths.js +52 -0
  67. package/dist/core/verdict/types.d.ts +6 -2
  68. package/dist/core/verdict/verdict.js +161 -47
  69. package/dist/installer.js +66 -15
  70. package/dist/types.d.ts +7 -0
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/package.json +1 -1
  74. package/skills/belay/SKILL.md +8 -7
  75. package/dist/commands/init-wizard.d.ts +0 -21
  76. package/dist/commands/init-wizard.js +0 -63
@@ -0,0 +1,285 @@
1
+ import path from 'node:path';
2
+ import { stdin as input, stdout as output } from 'node:process';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { configPathFor, loadApprovalState, loadConfigFile, repoLocalStateDirFor, resolveAdapterName, writeConfigFile, } from '../config-io.js';
5
+ import { appendCliAuditEvent } from '../core/audit-io.js';
6
+ import { JUDGE_CLOUD_CONSENT_REASON } from '../core/capability/reasons.js';
7
+ import { belayStateDir, normalizeJudgeConfig } from '../core/config.js';
8
+ import { writeJudgeCredentialStore } from '../core/credential-store.js';
9
+ import { refreshIntegrityIfPinned } from '../core/integrity.js';
10
+ import { resolveJudgeCredential } from '../core/judge-api-key.js';
11
+ import { ensurePendingJudgeCloudConsentApproval } from '../core/judge-cloud-consent.js';
12
+ import { hasValidCloudConsent, isCloudJudgeConfig, resolveJudgeUsePatch, } from '../core/judge-config.js';
13
+ import { diagnoseJudge } from '../core/judge-doctor.js';
14
+ import { rejectDeprecatedJudgeModelAuto } from '../core/judge-model-policy.js';
15
+ import { resolveJudgeTransport } from '../core/judge-runtime-detection.js';
16
+ import { getJudgeProviderSpec, isJudgeProviderId, JUDGE_CATALOG, JUDGE_PROVIDER_IDS, normalizeLegacyProviderId, } from '../core/verdict/judge-catalog.js';
17
+ import { resolveJudgeModel } from '../core/verdict/judge-factory.js';
18
+ import { readKeyFromStdin } from './stdin-key.js';
19
+ export function isInteractiveTTY() {
20
+ return Boolean(input.isTTY && output.isTTY);
21
+ }
22
+ async function confirmCloudConsent(endpoint) {
23
+ if (!isInteractiveTTY()) {
24
+ return false;
25
+ }
26
+ const rl = createInterface({ input, output });
27
+ try {
28
+ const answer = await rl.question(`Cloud judge sends redacted commands to ${endpoint}. Continue? [y/N] `);
29
+ return ['y', 'yes'].includes(answer.trim().toLowerCase());
30
+ }
31
+ finally {
32
+ rl.close();
33
+ }
34
+ }
35
+ async function hasApprovedCloudConsent(repoRoot, config, approvalId) {
36
+ const approved = await loadApprovalState(repoRoot, 'approved-approvals.json', config);
37
+ return approved.approvals.some((entry) => entry.approvalId === approvalId && entry.reason === 'judge_cloud_consent');
38
+ }
39
+ function formatJudgeDiff(before, after) {
40
+ const lines = ['Judge config changes:'];
41
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
42
+ for (const key of keys) {
43
+ const left = JSON.stringify(before[key]);
44
+ const right = JSON.stringify(after[key]);
45
+ if (left !== right) {
46
+ lines.push(` ${String(key)}: ${left} -> ${right}`);
47
+ }
48
+ }
49
+ return lines.join('\n');
50
+ }
51
+ export async function judgeStatus(options = {}) {
52
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
53
+ const config = await loadConfigFile(repoRoot);
54
+ const judge = config.judge;
55
+ const spec = judge.providerId ? getJudgeProviderSpec(judge.providerId) : null;
56
+ const repoLocalDir = repoLocalStateDirFor(repoRoot, config);
57
+ const credential = await resolveJudgeCredential({
58
+ judge,
59
+ catalogSpec: spec ?? undefined,
60
+ repoRoot,
61
+ repoLocalStateDir: repoLocalDir,
62
+ config,
63
+ });
64
+ const { resolved } = resolveJudgeModel(judge);
65
+ const transport = resolveJudgeTransport(judge);
66
+ const lines = [
67
+ `Judge providerId : ${judge.providerId ?? '(inferred)'}`,
68
+ `Judge driver : ${judge.provider}`,
69
+ `Transport : ${transport}`,
70
+ `Endpoint : ${judge.endpoint ?? '(none)'}`,
71
+ `Model : ${resolved} (requested: ${judge.model})`,
72
+ `Credential : ${credential.mode} (${credential.sourceKind})${credential.source ? ` → ${credential.source}` : ''} ${credential.key ? '✓ set' : '✗ missing'}`,
73
+ `Cloud consent : ${judge.cloudConsent?.accepted
74
+ ? `accepted ${judge.cloudConsent.at} by ${judge.cloudConsent.by}`
75
+ : 'not recorded'}`,
76
+ `Tier1 fallback : ask (fail-closed) — judge unreachable would block, not allow`,
77
+ ];
78
+ if (options.json) {
79
+ return {
80
+ providerId: judge.providerId,
81
+ provider: judge.provider,
82
+ endpoint: judge.endpoint,
83
+ model: judge.model,
84
+ modelResolved: resolved,
85
+ transport,
86
+ credential,
87
+ cloudConsent: judge.cloudConsent ?? null,
88
+ cloudJudgeActive: isCloudJudgeConfig(judge) && hasValidCloudConsent(judge),
89
+ };
90
+ }
91
+ return lines.join('\n');
92
+ }
93
+ export function judgeList(options = {}) {
94
+ const entries = JUDGE_PROVIDER_IDS.map((id) => {
95
+ const spec = JUDGE_CATALOG[id];
96
+ return {
97
+ id,
98
+ driver: spec.driver,
99
+ cloud: spec.isCloud,
100
+ defaultEndpoint: spec.defaultEndpoint,
101
+ defaultModel: spec.defaultModel,
102
+ apiKeyEnvVars: spec.apiKeyEnvVars,
103
+ };
104
+ });
105
+ if (options.json) {
106
+ return entries;
107
+ }
108
+ return entries
109
+ .map((entry) => `${entry.id} (${entry.cloud ? 'cloud' : 'local'}) driver=${entry.driver} model=${entry.defaultModel} endpoint=${entry.defaultEndpoint ?? 'optional'}`)
110
+ .join('\n');
111
+ }
112
+ export async function judgeUse(options) {
113
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
114
+ const config = await loadConfigFile(repoRoot);
115
+ const adapter = resolveAdapterName(config);
116
+ const providerId = options.providerId?.trim();
117
+ if (!providerId || !isJudgeProviderId(providerId)) {
118
+ throw new Error(`judge use requires provider-id: ${JUDGE_PROVIDER_IDS.join(', ')}`);
119
+ }
120
+ const before = { ...config.judge };
121
+ if (options.model !== undefined) {
122
+ rejectDeprecatedJudgeModelAuto(options.model);
123
+ }
124
+ if (options.keyStdin &&
125
+ options.credentialMode === 'apiKey' &&
126
+ options.acceptCloud &&
127
+ isInteractiveTTY()) {
128
+ throw new Error('Cannot combine --accept-cloud with --key-stdin in interactive mode. Use env for the API key, or record consent separately.');
129
+ }
130
+ let keyFromStdin;
131
+ if (options.keyStdin && options.credentialMode === 'apiKey') {
132
+ keyFromStdin = await readKeyFromStdin();
133
+ if (!keyFromStdin) {
134
+ throw new Error('--key-stdin requires a non-empty API key on stdin.');
135
+ }
136
+ }
137
+ let interactiveConsentApproved = false;
138
+ if (options.acceptCloud && isInteractiveTTY()) {
139
+ const spec = getJudgeProviderSpec(providerId);
140
+ const endpoint = options.endpoint?.trim() || spec?.defaultEndpoint || before.endpoint?.trim() || '';
141
+ if (endpoint) {
142
+ interactiveConsentApproved = await confirmCloudConsent(endpoint);
143
+ }
144
+ }
145
+ const cloudConsentApprovalId = options.cloudConsentApprovalId;
146
+ if (options.cloudConsentApprovalId) {
147
+ const ok = await hasApprovedCloudConsent(repoRoot, config, options.cloudConsentApprovalId);
148
+ if (!ok) {
149
+ throw new Error(`Approval ${options.cloudConsentApprovalId} is not an approved judge_cloud_consent.`);
150
+ }
151
+ }
152
+ else if (options.acceptCloud &&
153
+ !isInteractiveTTY() &&
154
+ getJudgeProviderSpec(providerId)?.isCloud) {
155
+ throw new Error('--accept-cloud has no effect in non-interactive mode. Use TTY confirmation or --cloud-consent-approval-id.');
156
+ }
157
+ const patch = resolveJudgeUsePatch(before, {
158
+ providerId,
159
+ model: options.model,
160
+ endpoint: options.endpoint,
161
+ timeoutMs: options.timeoutMs,
162
+ acceptCloud: options.acceptCloud,
163
+ cloudConsentApprovalId,
164
+ credentialMode: options.credentialMode,
165
+ keyEnv: options.keyEnv,
166
+ interactiveTTY: isInteractiveTTY(),
167
+ interactiveConsentApproved,
168
+ });
169
+ if (patch.errors.length > 0) {
170
+ throw new Error(patch.errors.join(' '));
171
+ }
172
+ if (keyFromStdin !== undefined) {
173
+ const stateDir = belayStateDir(config, repoLocalStateDirFor(repoRoot, config));
174
+ await writeJudgeCredentialStore(stateDir, keyFromStdin);
175
+ }
176
+ const after = normalizeJudgeConfig(patch.judge);
177
+ const updated = { ...config, judge: after };
178
+ await writeConfigFile(repoRoot, updated, adapter);
179
+ await refreshIntegrityIfPinned(repoRoot, updated);
180
+ await appendCliAuditEvent(repoRoot, updated, {
181
+ event: 'judge_provider_changed',
182
+ from: { providerId: before.providerId, provider: before.provider },
183
+ to: { providerId: after.providerId, provider: after.provider },
184
+ by: 'belay judge use',
185
+ });
186
+ if (after.cloudConsent?.accepted && !before.cloudConsent?.accepted) {
187
+ await appendCliAuditEvent(repoRoot, updated, {
188
+ event: 'judge_cloud_consent_recorded',
189
+ providerId: after.cloudConsent.providerId,
190
+ endpoint: after.cloudConsent.endpoint,
191
+ by: after.cloudConsent.by,
192
+ });
193
+ }
194
+ const spec = getJudgeProviderSpec(providerId);
195
+ const credential = await resolveJudgeCredential({
196
+ judge: after,
197
+ catalogSpec: spec ?? undefined,
198
+ repoRoot,
199
+ repoLocalStateDir: repoLocalStateDirFor(repoRoot, updated),
200
+ config: updated,
201
+ });
202
+ if (spec?.isCloud && !credential.key) {
203
+ patch.warnings.push(`API key not found for ${providerId}. Tier1 cloud judge will fail closed to ask.`);
204
+ }
205
+ const result = {
206
+ ok: true,
207
+ configPath: configPathFor(repoRoot, adapter),
208
+ diff: formatJudgeDiff(before, after),
209
+ warnings: patch.warnings,
210
+ judge: after,
211
+ };
212
+ if (options.json) {
213
+ return result;
214
+ }
215
+ const parts = [
216
+ result.diff,
217
+ ...result.warnings.map((w) => `Warning: ${w}`),
218
+ 'Judge config updated.',
219
+ ];
220
+ return parts.join('\n');
221
+ }
222
+ export async function judgeRequestCloudConsent(options = {}) {
223
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
224
+ const config = await loadConfigFile(repoRoot);
225
+ const providerId = options.providerId?.trim();
226
+ if (!providerId || !isJudgeProviderId(providerId)) {
227
+ throw new Error(`judge consent requires --provider-id: ${JUDGE_PROVIDER_IDS.join(', ')}`);
228
+ }
229
+ const canonicalProviderId = normalizeLegacyProviderId(providerId);
230
+ const spec = getJudgeProviderSpec(canonicalProviderId);
231
+ if (!spec?.isCloud) {
232
+ throw new Error(`judge consent applies only to cloud providers (${JUDGE_PROVIDER_IDS.filter((id) => JUDGE_CATALOG[id].isCloud).join(', ')}).`);
233
+ }
234
+ const endpoint = options.endpoint?.trim() || config.judge.endpoint?.trim() || spec.defaultEndpoint || '';
235
+ if (!endpoint) {
236
+ throw new Error(`${canonicalProviderId} requires --endpoint for cloud consent request.`);
237
+ }
238
+ const { approvalId, created } = await ensurePendingJudgeCloudConsentApproval({
239
+ repoRoot,
240
+ config,
241
+ providerId: canonicalProviderId,
242
+ endpoint,
243
+ });
244
+ const lines = [
245
+ `Cloud consent approval ${created ? 'created' : 'reused'}: ${approvalId}`,
246
+ `Approve: belay approve ${approvalId}`,
247
+ `Then: belay judge use ${canonicalProviderId} --endpoint ${endpoint} --cloud-consent-approval-id ${approvalId}`,
248
+ ];
249
+ if (options.json) {
250
+ return { approvalId, created, providerId, endpoint, reason: JUDGE_CLOUD_CONSENT_REASON };
251
+ }
252
+ return lines.join('\n');
253
+ }
254
+ export async function judgeTest(options = {}) {
255
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
256
+ const config = await loadConfigFile(repoRoot);
257
+ const diagnosis = await diagnoseJudge(config, repoRoot);
258
+ const modelCheck = diagnosis.modelCheck;
259
+ if (options.json) {
260
+ return { ...diagnosis, modelCheck };
261
+ }
262
+ return [
263
+ `Model check: ${modelCheck?.status ?? 'unverified'}`,
264
+ `Model source: ${modelCheck?.source ?? 'unknown'}`,
265
+ ...diagnosis.notes.map((n) => `Note: ${n}`),
266
+ ...diagnosis.warnings.map((w) => `Warning: ${w}`),
267
+ ...diagnosis.issues.map((i) => `Issue: ${i}`),
268
+ ].join('\n');
269
+ }
270
+ export async function runJudgeCommand(options) {
271
+ switch (options.subcommand) {
272
+ case 'status':
273
+ return judgeStatus(options);
274
+ case 'list':
275
+ return judgeList(options);
276
+ case 'use':
277
+ return judgeUse(options);
278
+ case 'test':
279
+ return judgeTest(options);
280
+ case 'consent':
281
+ return judgeRequestCloudConsent(options);
282
+ default:
283
+ throw new Error('judge requires subcommand: status, list, use, test, or consent');
284
+ }
285
+ }
@@ -39,9 +39,9 @@ export function formatStatusReport(report) {
39
39
  ...(health.additionalRiskSignals.length > 0
40
40
  ? [`Additional risk signals: ${health.additionalRiskSignals.join('; ')}`]
41
41
  : []),
42
- ...(health.skillOnly
42
+ ...(!health.floorInstalled
43
43
  ? [
44
- 'Skill-only mode: yes — hooks are missing or incomplete. Run `npx @guilz-dev/belay init` to install the enforcement floor.',
44
+ 'Enforcement floor missing or incomplete. Run `belay config` for interactive setup, or `npx @guilz-dev/belay init` for non-interactive install.',
45
45
  ]
46
46
  : []),
47
47
  `Approval state: ${report.approvalStateDir}`,
@@ -0,0 +1 @@
1
+ export declare function readKeyFromStdin(): Promise<string>;
@@ -0,0 +1,8 @@
1
+ import { stdin as input } from 'node:process';
2
+ export async function readKeyFromStdin() {
3
+ const chunks = [];
4
+ for await (const chunk of input) {
5
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
6
+ }
7
+ return Buffer.concat(chunks).toString('utf8').trim();
8
+ }
@@ -86,10 +86,22 @@ export const GUARANTEE_SCENARIOS = {
86
86
  reason: 'external_effect',
87
87
  },
88
88
  {
89
+ // L1-full only: shell + tool paths use classifyGatedAction + brokerFsScope.
89
90
  id: 'l1f-deny-outside-repo',
90
91
  command: 'echo hi > ../../outside.txt',
91
92
  permission: 'deny',
92
93
  reason: 'outside_repo_redirect',
93
94
  },
95
+ {
96
+ id: 'l1f-deny-outside-repo-write',
97
+ kind: 'tool',
98
+ command: '',
99
+ toolPayload: {
100
+ tool_name: 'Write',
101
+ tool_input: { path: '../../outside.txt', contents: 'hi' },
102
+ },
103
+ permission: 'deny',
104
+ reason: 'outside_repo_mutation',
105
+ },
94
106
  ],
95
107
  };
@@ -3,4 +3,6 @@ export interface LayerConformanceScenario {
3
3
  command: string;
4
4
  permission: 'allow' | 'deny';
5
5
  reason?: string;
6
+ kind?: 'shell' | 'tool';
7
+ toolPayload?: Record<string, unknown>;
6
8
  }
@@ -0,0 +1,2 @@
1
+ import type { BelayConfigV4 } from './config.js';
2
+ export declare function appendCliAuditEvent(repoRoot: string, config: BelayConfigV4, event: Record<string, unknown>): Promise<void>;
@@ -0,0 +1,14 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function appendCliAuditEvent(repoRoot, config, event) {
4
+ const auditPath = path.isAbsolute(config.audit.logPath)
5
+ ? config.audit.logPath
6
+ : path.join(repoRoot, config.audit.logPath);
7
+ await mkdir(path.dirname(auditPath), { recursive: true });
8
+ const line = JSON.stringify({
9
+ ts: new Date().toISOString(),
10
+ source: 'belay-cli',
11
+ ...event,
12
+ });
13
+ await appendFile(auditPath, `${line}\n`, 'utf8');
14
+ }
@@ -1,5 +1,5 @@
1
1
  export { addPathToAllowlist, allPathsAllowlisted, fsScopeAllowlistPath, isPathAllowlisted, loadFsScopeAllowlist, loadFsScopeAllowlistSync, saveFsScopeAllowlist, } from './allowlist.js';
2
2
  export { evaluateL1FullStatus, hasSandboxRuntime, isCapabilityBrokerDemotionActive, isSandboxBrokerEnabled, } from './broker.js';
3
- export { collectOutsideRepoPaths } from './paths.js';
4
- export { FS_SCOPE_REASONS, shouldSkipBrokerApprovedOnce } from './reasons.js';
3
+ export { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload } from './paths.js';
4
+ export { FS_SCOPE_REASONS, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, } from './reasons.js';
5
5
  export type { CapabilityApprovalScope, FsScopeAllowlistEntry, FsScopeAllowlistFile, } from './types.js';
@@ -1,4 +1,4 @@
1
1
  export { addPathToAllowlist, allPathsAllowlisted, fsScopeAllowlistPath, isPathAllowlisted, loadFsScopeAllowlist, loadFsScopeAllowlistSync, saveFsScopeAllowlist, } from './allowlist.js';
2
2
  export { evaluateL1FullStatus, hasSandboxRuntime, isCapabilityBrokerDemotionActive, isSandboxBrokerEnabled, } from './broker.js';
3
- export { collectOutsideRepoPaths } from './paths.js';
4
- export { FS_SCOPE_REASONS, shouldSkipBrokerApprovedOnce } from './reasons.js';
3
+ export { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload } from './paths.js';
4
+ export { FS_SCOPE_REASONS, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, } from './reasons.js';
@@ -1 +1,2 @@
1
1
  export declare function collectOutsideRepoPaths(command: string, cwd: string, repoRoot: string): string[];
2
+ export declare function collectOutsideRepoPathsFromToolPayload(payload: Record<string, unknown>, cwd: string, repoRoot: string): string[];
@@ -1,20 +1,88 @@
1
+ import path from 'node:path';
1
2
  import { relativeWithinRepo, resolveMutationTarget } from '../path-utils.js';
2
3
  import { extractRedirectTargets, tokenizeShell } from '../shell-tokenizer.js';
4
+ function applyPatchTargets(patch) {
5
+ const targets = [];
6
+ for (const line of patch.split('\n')) {
7
+ const match = line.match(/^\*\*\* (?:Add|Delete|Update) File: (.+)$/);
8
+ if (match?.[1]) {
9
+ targets.push(match[1]);
10
+ continue;
11
+ }
12
+ const moveMatch = line.match(/^\*\*\* Move to: (.+)$/);
13
+ if (moveMatch?.[1]) {
14
+ targets.push(moveMatch[1]);
15
+ }
16
+ }
17
+ return targets;
18
+ }
19
+ function extractToolFilePath(payload) {
20
+ const toolInput = payload.tool_input;
21
+ if (!toolInput || typeof toolInput !== 'object') {
22
+ return null;
23
+ }
24
+ const input = toolInput;
25
+ for (const key of ['path', 'file_path', 'target_file', 'filePath']) {
26
+ if (typeof input[key] === 'string') {
27
+ return input[key];
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+ function extractToolPatch(payload) {
33
+ const toolInput = payload.tool_input;
34
+ if (!toolInput || typeof toolInput !== 'object') {
35
+ return null;
36
+ }
37
+ const input = toolInput;
38
+ for (const key of ['patch', 'input', 'text']) {
39
+ if (typeof input[key] === 'string' && input[key].trim()) {
40
+ return input[key];
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ function addOutsideRepoPath(paths, token, cwd, repoRoot) {
46
+ const resolved = resolveMutationTarget(token, cwd);
47
+ if (resolved && relativeWithinRepo(repoRoot, resolved) === null) {
48
+ paths.add(resolved);
49
+ }
50
+ }
3
51
  export function collectOutsideRepoPaths(command, cwd, repoRoot) {
4
52
  const tokens = tokenizeShell(command);
5
53
  const redirects = extractRedirectTargets(tokens);
6
54
  const paths = new Set();
7
55
  for (const token of tokens.slice(1)) {
8
- const resolved = resolveMutationTarget(token, cwd);
9
- if (resolved && relativeWithinRepo(repoRoot, resolved) === null) {
10
- paths.add(resolved);
11
- }
56
+ addOutsideRepoPath(paths, token, cwd, repoRoot);
12
57
  }
13
58
  for (const redirect of redirects) {
14
- const resolved = resolveMutationTarget(redirect, cwd);
15
- if (resolved && relativeWithinRepo(repoRoot, resolved) === null) {
59
+ addOutsideRepoPath(paths, redirect, cwd, repoRoot);
60
+ }
61
+ return [...paths];
62
+ }
63
+ export function collectOutsideRepoPathsFromToolPayload(payload, cwd, repoRoot) {
64
+ const toolKind = String(payload.tool_name ?? '')
65
+ .trim()
66
+ .toLowerCase();
67
+ const paths = new Set();
68
+ const filePath = extractToolFilePath(payload);
69
+ if (filePath) {
70
+ const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
71
+ if (relativeWithinRepo(repoRoot, resolved) === null) {
16
72
  paths.add(resolved);
17
73
  }
74
+ return [...paths];
75
+ }
76
+ if (toolKind === 'apply_patch' || toolKind === 'applypatch') {
77
+ const patch = extractToolPatch(payload);
78
+ if (patch) {
79
+ for (const target of applyPatchTargets(patch)) {
80
+ const resolved = path.isAbsolute(target) ? target : path.resolve(cwd, target);
81
+ if (relativeWithinRepo(repoRoot, resolved) === null) {
82
+ paths.add(resolved);
83
+ }
84
+ }
85
+ }
18
86
  }
19
87
  return [...paths];
20
88
  }
@@ -1,2 +1,5 @@
1
1
  export declare const FS_SCOPE_REASONS: Set<string>;
2
+ export declare const JUDGE_CLOUD_CONSENT_REASON: "judge_cloud_consent";
3
+ export declare const CAPABILITY_APPROVAL_REASONS: Set<string>;
2
4
  export declare function shouldSkipBrokerApprovedOnce(brokerActive: boolean, reason: string): boolean;
5
+ export declare function shouldSkipBrokerApprovedRecord(brokerActive: boolean, approvalReason: string | undefined): boolean;
@@ -1,4 +1,12 @@
1
1
  export const FS_SCOPE_REASONS = new Set(['outside_repo_mutation', 'outside_repo_redirect']);
2
+ export const JUDGE_CLOUD_CONSENT_REASON = 'judge_cloud_consent';
3
+ export const CAPABILITY_APPROVAL_REASONS = new Set([
4
+ ...FS_SCOPE_REASONS,
5
+ JUDGE_CLOUD_CONSENT_REASON,
6
+ ]);
2
7
  export function shouldSkipBrokerApprovedOnce(brokerActive, reason) {
3
8
  return brokerActive && FS_SCOPE_REASONS.has(reason);
4
9
  }
10
+ export function shouldSkipBrokerApprovedRecord(brokerActive, approvalReason) {
11
+ return brokerActive && approvalReason !== undefined && FS_SCOPE_REASONS.has(approvalReason);
12
+ }
@@ -3,7 +3,9 @@ import { canonicalStringify, toolFingerprint } from './fingerprint.js';
3
3
  import { matchesSensitivePath } from './glob.js';
4
4
  import { pathWithinRoot, relativeWithinRepo } from './path-utils.js';
5
5
  import { scrubValue } from './scrub.js';
6
- import { classifyShell } from './verdict/adapter.js';
6
+ import { classifyShell, resolveClassifierTrustedCwd } from './verdict/adapter.js';
7
+ import { mutationPrescanRequiresAsk, tier1RequiresAsk } from './verdict/judge.js';
8
+ import { createJudgeFromConfig } from './verdict/judge-factory.js';
7
9
  const DEFAULT_SENSITIVE_PATHS = ['.env', '.env.*', '**/credentials/**'];
8
10
  const FILE_WRITE_TOOL_NAMES = new Set(['write']);
9
11
  const FILE_EDIT_TOOL_NAMES = new Set([
@@ -74,6 +76,74 @@ function applyPatchTargets(patch) {
74
76
  function normalizedToolName(toolName) {
75
77
  return toolName.trim().toLowerCase();
76
78
  }
79
+ function resolveFileTier1Judge(config, options, repoRoot) {
80
+ return options.tier1Judge ?? createJudgeFromConfig(config, { repoRoot });
81
+ }
82
+ async function classifyFileMutationWithTier1(params) {
83
+ const judge = resolveFileTier1Judge(params.config, params.options, params.repoRoot);
84
+ const trustedCwd = resolveClassifierTrustedCwd(params.cwd, params.options);
85
+ const prescan = mutationPrescanRequiresAsk({
86
+ targets: [params.filePath],
87
+ cwd: params.cwd,
88
+ repoRoot: params.repoRoot,
89
+ trustedCwd,
90
+ sensitivePaths: params.options.sensitivePaths ?? params.config.classifier.sensitivePaths,
91
+ });
92
+ if (prescan) {
93
+ return {
94
+ verdict: 'deny_pending_approval',
95
+ reason: 'tier1_catastrophic',
96
+ summary: params.filePath,
97
+ fingerprint: toolFingerprint(params.toolName, { path: params.filePath }, params.repoRoot),
98
+ assessment: {
99
+ reversibility: 'irreversible',
100
+ external: params.locationLabel === 'outside_repo',
101
+ blastRadius: params.locationLabel === 'outside_repo'
102
+ ? 'outside the repository'
103
+ : 'sensitive repository file',
104
+ confidence: 0.95,
105
+ signals: [...params.signals, 'tier1_catastrophic', prescan.reason],
106
+ },
107
+ };
108
+ }
109
+ const tier1Text = `file_mutation: ${params.toolKind} path=${params.resolvedPath} location=${params.locationLabel}`;
110
+ const tier1 = await judge.evaluate({
111
+ text: tier1Text,
112
+ context: { cwd: params.cwd, repoRoot: params.repoRoot },
113
+ });
114
+ const fingerprint = toolFingerprint(params.toolName, { path: params.filePath }, params.repoRoot);
115
+ if (tier1RequiresAsk(tier1)) {
116
+ return {
117
+ verdict: 'deny_pending_approval',
118
+ reason: 'tier1_catastrophic',
119
+ summary: params.filePath,
120
+ fingerprint,
121
+ assessment: {
122
+ reversibility: 'irreversible',
123
+ external: params.locationLabel === 'outside_repo',
124
+ blastRadius: params.locationLabel === 'outside_repo'
125
+ ? 'outside the repository'
126
+ : 'sensitive repository file',
127
+ confidence: 0.82,
128
+ signals: [...params.signals, 'tier1_catastrophic', tier1.reason],
129
+ },
130
+ };
131
+ }
132
+ const reason = params.isDelete ? 'file_delete' : 'file_mutation';
133
+ return {
134
+ verdict: 'allow_flagged',
135
+ reason,
136
+ summary: params.filePath,
137
+ fingerprint,
138
+ assessment: {
139
+ reversibility: 'recoverable_with_cost',
140
+ external: params.locationLabel === 'outside_repo',
141
+ blastRadius: params.locationLabel === 'outside_repo' ? 'outside the repository' : 'this repository',
142
+ confidence: 0.72,
143
+ signals: [...params.signals, 'tier1_restorable', tier1.reason],
144
+ },
145
+ };
146
+ }
77
147
  export async function classifyToolUse(payload, repoRoot, cwd, config, options = {}) {
78
148
  const toolName = String(payload.tool_name ?? '');
79
149
  const toolKind = normalizedToolName(toolName);
@@ -176,35 +246,35 @@ export async function classifyToolUse(payload, repoRoot, cwd, config, options =
176
246
  const relativePath = relativeWithinRepo(repoRoot, resolvedPath);
177
247
  if (relativePath === null) {
178
248
  signals.push('outside_repo_path');
179
- return {
180
- verdict: 'deny_pending_approval',
181
- reason: 'outside_repo_file_mutation',
182
- summary: filePath,
183
- fingerprint: toolFingerprint(toolName, { path: filePath }, repoRoot),
184
- assessment: {
185
- reversibility: 'irreversible',
186
- external: true,
187
- blastRadius: 'outside the repository',
188
- confidence: 0.9,
189
- signals,
190
- },
191
- };
249
+ return classifyFileMutationWithTier1({
250
+ toolName,
251
+ toolKind,
252
+ filePath,
253
+ resolvedPath,
254
+ repoRoot,
255
+ cwd,
256
+ config,
257
+ options,
258
+ signals,
259
+ isDelete: FILE_DELETE_TOOL_NAMES.has(toolKind),
260
+ locationLabel: 'outside_repo',
261
+ });
192
262
  }
193
263
  if (matchesSensitivePath(relativePath, sensitivePaths)) {
194
264
  signals.push('sensitive_path');
195
- return {
196
- verdict: 'deny_pending_approval',
197
- reason: 'sensitive_file_mutation',
198
- summary: filePath,
199
- fingerprint: toolFingerprint(toolName, { path: filePath }, repoRoot),
200
- assessment: {
201
- reversibility: 'irreversible',
202
- external: false,
203
- blastRadius: 'sensitive repository file',
204
- confidence: 0.88,
205
- signals,
206
- },
207
- };
265
+ return classifyFileMutationWithTier1({
266
+ toolName,
267
+ toolKind,
268
+ filePath,
269
+ resolvedPath,
270
+ repoRoot,
271
+ cwd,
272
+ config,
273
+ options,
274
+ signals,
275
+ isDelete: FILE_DELETE_TOOL_NAMES.has(toolKind),
276
+ locationLabel: 'sensitive_path',
277
+ });
208
278
  }
209
279
  if (FILE_DELETE_TOOL_NAMES.has(toolKind)) {
210
280
  signals.push('file_delete');
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { applyConfigPreset } from '../presets.js';
3
- import { DEFAULT_CONFIG_V3, mergeConfig } from './config.js';
3
+ import { DEFAULT_CONFIG_V3, mergeConfig, rejectTeamLayerJudgeSecrets } from './config.js';
4
4
  export function teamConfigPath(homedir = () => process.env.HOME ?? process.env.USERPROFILE ?? '') {
5
5
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
6
6
  const base = xdg || path.join(homedir(), '.config');
@@ -40,6 +40,7 @@ export function resolveLayeredConfig(params) {
40
40
  const teamRaw = teamFile.preset
41
41
  ? applyConfigPreset(teamFile.preset, teamFile.config ?? {})
42
42
  : (teamFile.config ?? params.teamConfig);
43
+ rejectTeamLayerJudgeSecrets(teamRaw.judge, 'team');
43
44
  config = mergeConfigLayer(config, asV3Layer(teamRaw));
44
45
  provenance.push({
45
46
  path: params.teamConfigPath ?? teamConfigPath(),