From 923189242625e43fe02a6e7f8fbe4b1964b737f6 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 12 Sep 2026 11:15:05 +0200 Subject: [PATCH 1/2] feat(sdk): wire workspace:/tools.fs: scope-compiler into preflight (#308) Consumes the scope-compiler and mount-registry from #329. Flow-header declarations compile against the nearest relayfile.mounts.json; preflight refuses with scope_syntax_invalid / mount_unknown / scope_ungrantable before any token is minted. Walker parity extended for the three new kinds. Co-Authored-By: Claude Opus 4.7 (1M context) Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/src/compile.ts | 2 + packages/sdk/src/failure-kinds.ts | 3 + packages/sdk/src/preflight.ts | 30 ++++++++ packages/sdk/src/spec.ts | 4 + packages/sdk/src/step-fields.ts | 2 + packages/sdk/src/validate.ts | 26 +++++++ packages/sdk/tests/preflight.test.ts | 23 ++++++ packages/sdk/tests/scope-preflight.test.ts | 90 ++++++++++++++++++++++ packages/sdk/tsconfig.tests.json | 3 +- 9 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/tests/scope-preflight.test.ts diff --git a/packages/sdk/src/compile.ts b/packages/sdk/src/compile.ts index 8a861a765..9c311ae19 100644 --- a/packages/sdk/src/compile.ts +++ b/packages/sdk/src/compile.ts @@ -166,6 +166,8 @@ export function compileSpec(spec: unknown): CompiledFlowSpec { ...(input.triggers?.length ? { triggers: input.triggers } : {}), steps, ...(input.budget !== undefined ? { budget: input.budget } : {}), + ...(input.workspace !== undefined ? { workspace: input.workspace } : {}), + ...(input.tools !== undefined ? { tools: input.tools } : {}), }; return flow; } diff --git a/packages/sdk/src/failure-kinds.ts b/packages/sdk/src/failure-kinds.ts index fd46d5f1c..e6c73fd92 100644 --- a/packages/sdk/src/failure-kinds.ts +++ b/packages/sdk/src/failure-kinds.ts @@ -25,6 +25,9 @@ const PREFLIGHT_ENVIRONMENT_FAILURE_KINDS = [ 'memory_unreachable', 'no_executor', 'probe_failed', + 'scope_syntax_invalid', + 'mount_unknown', + 'scope_ungrantable', ] as const; /** Closed refusal taxonomy for public preflight (RFC covenant 2). */ diff --git a/packages/sdk/src/preflight.ts b/packages/sdk/src/preflight.ts index 9561e5a37..7f46ef7d4 100644 --- a/packages/sdk/src/preflight.ts +++ b/packages/sdk/src/preflight.ts @@ -9,6 +9,8 @@ import { acceptsAnyOutput, inspectStepGate, type StepGateInspection } from './ga import { compileSpec, CompileError } from './compile.js'; import { helperCall } from './yaml-helpers.js'; import { isNamedGate, NAMED_GATE_FAILURE_KINDS, type NamedGateFailureKind } from './named-gates.js'; +import { compileScopes, type ScopeInput, type MountRegistry } from './scope-compiler.js'; +import { readMountRegistry } from './mount-registry.js'; import type { PreflightFailureKind, PreflightWarningKind, @@ -211,6 +213,7 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul const cliProbeResults = new Map(); diagnostics.push(...unknownModelDiagnostics(compiled, options)); + diagnostics.push(...scopeDiagnostics(compiled, options)); for (const server of new Set(options.mcpServers ?? [])) { if (options.mcp !== undefined && Object.hasOwn(options.mcp, server)) continue; diagnostics.push({ severity: 'refusal', kind: 'mcp_undeclared_server', server, @@ -277,6 +280,33 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul }; } +/** + * Compile author-declared `workspace:` / `tools.fs:` grants against the nearest + * relayfile mount manifest. The compiler is pure; only mount discovery reads a + * fact about the filesystem. Missing manifest means no known mounts — a grant + * still parses but refuses as `mount_unknown` in that case. + */ +function scopeDiagnostics(flow: FlowSpec, options: PreflightOptions): PreflightRefusal[] { + const workspace = flow.workspace; + const toolsFs = flow.tools?.fs; + if (workspace === undefined && toolsFs === undefined) return []; + const input: ScopeInput = { + ...(workspace === undefined ? {} : { workspace }), + ...(toolsFs === undefined ? {} : { tools: { fs: toolsFs } }), + }; + let mounts: MountRegistry | undefined; + if (options.projectSearchStart !== undefined) { + try { mounts = readMountRegistry(options.projectSearchStart); } + catch { mounts = {}; /* fail closed — unreadable manifest treats every mount as unknown */ } + } + return compileScopes(input, mounts).diagnostics.map(refusal => ({ + severity: 'refusal', + kind: refusal.kind, + message: refusal.message, + ...(refusal.stepId === undefined ? {} : { stepId: refusal.stepId }), + })); +} + /** Pure authoring validation: no executable, command, trigger, or daemon probe. */ function unknownModelDiagnostics( flow: FlowSpec, diff --git a/packages/sdk/src/spec.ts b/packages/sdk/src/spec.ts index f9658df0c..e75bc1410 100644 --- a/packages/sdk/src/spec.ts +++ b/packages/sdk/src/spec.ts @@ -351,6 +351,10 @@ export interface FlowSpec { triggers?: TriggerSpec[]; steps: StepSpec[]; budget?: BudgetSpec | import('./budget.js').HeaderBudget; + /** Path-scoped workspace grants — "mount/path: readonly|readwrite|append". Compiled by preflight. */ + workspace?: string | readonly string[]; + /** Path-scoped tool grants; `fs` mirrors workspace for shell/deterministic scope. */ + tools?: { fs?: string | readonly string[] }; } /** Current spec schema version emitted by this SDK. */ diff --git a/packages/sdk/src/step-fields.ts b/packages/sdk/src/step-fields.ts index becc010e1..00bcf5ce6 100644 --- a/packages/sdk/src/step-fields.ts +++ b/packages/sdk/src/step-fields.ts @@ -10,6 +10,8 @@ export const FLOW_FIELDS = [ 'triggers', 'steps', 'budget', + 'workspace', + 'tools', ] as const; /** Closed named-agent declaration schema. */ diff --git a/packages/sdk/src/validate.ts b/packages/sdk/src/validate.ts index 9d12ccdfe..4b7d44ef9 100644 --- a/packages/sdk/src/validate.ts +++ b/packages/sdk/src/validate.ts @@ -166,6 +166,22 @@ class Validator { if (s['budget'] !== undefined) this.validateBudget(s['budget']); + if (s['workspace'] !== undefined) this.validateScopeGrants(s['workspace'], 'spec.workspace'); + + if (s['tools'] !== undefined) { + if (!isObject(s['tools'])) { + this.fail('spec.tools: expected an object'); + } else { + const tools = s['tools'] as Record; + for (const key of Object.keys(tools)) { + if (key !== 'fs' && key !== 'mcp') { + this.fail(`spec.tools: unknown key "${key}" (expected fs or mcp)`); + } + } + if (tools['fs'] !== undefined) this.validateScopeGrants(tools['fs'], 'spec.tools.fs'); + } + } + if (!Array.isArray(s['steps']) || s['steps'].length === 0) { this.fail('spec.steps: expected a non-empty array'); return this.result(); @@ -207,6 +223,16 @@ class Validator { } } + private validateScopeGrants(value: unknown, at: string): void { + const grants = Array.isArray(value) ? value : [value]; + for (const grant of grants) { + if (typeof grant !== 'string' || grant.length === 0) { + this.fail(`${at}: expected a scope-grant string like "mount/path: readonly" (or an array of them)`); + return; + } + } + } + private validateBudget(b: unknown, at = 'spec.budget'): void { if (!isObject(b)) { this.fail(`${at}: expected an object`); diff --git a/packages/sdk/tests/preflight.test.ts b/packages/sdk/tests/preflight.test.ts index a263b1487..27902f599 100644 --- a/packages/sdk/tests/preflight.test.ts +++ b/packages/sdk/tests/preflight.test.ts @@ -410,6 +410,9 @@ describe('preflight: CLI resolution and refusal predicates', () => { preflight(flow({ id: 'a', type: 'llm', prompt: 'p', cli: 'x', verification: { type: 'subprocess_gate', command: '/nonexistent-gate-binary-xxx' } }), { probes: probes({ command: (c) => c !== '/nonexistent-gate-binary-xxx' }) }), + // `scope_syntax_invalid`: grant string doesn't match "mount/path: mode". + preflight({ ...flow({ id: 'a', type: 'deterministic', command: 'x' }), + workspace: 'not-a-grant' } as FlowSpec, { probes: probes() }), ]; const refusalKinds = scenarios.flatMap((result) => result.diagnostics) .filter((diagnostic) => diagnostic.severity === 'refusal') @@ -463,6 +466,26 @@ describe('preflight: CLI resolution and refusal predicates', () => { refusalKinds.push(...result.diagnostics.filter(d => d.severity === 'refusal').map(d => d.kind as PreflightFailureKind)); } finally { rmSync(root, { recursive: true, force: true }); } } + // Path-scoped auth: `mount_unknown` and `scope_ungrantable` fire against the + // nearest relayfile.mounts.json — a real manifest that declares `acme` with + // only the `api` prefix in `readonly` mode. Any grant naming another mount + // is unknown; any grant asking for a different path or mode is ungrantable. + { + const root = mkdtempSync(join(tmpdir(), 'scope-mounts-')); + try { + writeFileSync(join(root, 'relayfile.mounts.json'), JSON.stringify({ + version: 1, mounts: { acme: [{ path: 'api', modes: ['readonly'] }] }, + })); + const unknownFlow = { ...flow({ id: 'a', type: 'deterministic', command: 'x' }), + workspace: 'other/api: readonly' } as FlowSpec; + const unknownResult = preflight(unknownFlow, { probes: probes(), projectSearchStart: root }) as import('../src/preflight.js').PreflightResult; + refusalKinds.push(...unknownResult.diagnostics.filter(d => d.severity === 'refusal').map(d => d.kind as PreflightFailureKind)); + const ungrantableFlow = { ...flow({ id: 'a', type: 'deterministic', command: 'x' }), + workspace: 'acme/other: readwrite' } as FlowSpec; + const ungrantableResult = preflight(ungrantableFlow, { probes: probes(), projectSearchStart: root }) as import('../src/preflight.js').PreflightResult; + refusalKinds.push(...ungrantableResult.diagnostics.filter(d => d.severity === 'refusal').map(d => d.kind as PreflightFailureKind)); + } finally { rmSync(root, { recursive: true, force: true }); } + } expect(new Set(refusalKinds)).toEqual(new Set(PREFLIGHT_FAILURE_KINDS)); expect(JSON.stringify(scenarios)).not.toContain('raw secret'); }); diff --git a/packages/sdk/tests/scope-preflight.test.ts b/packages/sdk/tests/scope-preflight.test.ts new file mode 100644 index 000000000..4a7e0a55b --- /dev/null +++ b/packages/sdk/tests/scope-preflight.test.ts @@ -0,0 +1,90 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import { preflight } from '../src/index.js'; +import type { PreflightResult, PreflightProbes, CliProbeResult } from '../src/preflight.js'; +import type { FlowSpec } from '../src/spec.js'; + +const probes: PreflightProbes = { + cli: (): CliProbeResult => ({ exists: true, authenticated: true, modelAvailable: true }), + executor: () => true, + command: () => true, + helper: () => true, +}; + +const baseFlow: FlowSpec = { + version: '0.1.0', + name: 'scope-test', + steps: [{ id: 'run', type: 'deterministic', command: 'true', maxIterations: 1 }], +}; + +describe('preflight — path-scoped auth (#308)', () => { + it('accepts a grant the mount manifest can satisfy', () => { + const root = mkdtempSync(join(tmpdir(), 'scope-ok-')); + try { + writeFileSync(join(root, 'relayfile.mounts.json'), JSON.stringify({ + version: 1, mounts: { acme: [{ path: 'api', modes: ['readonly', 'readwrite'] }] }, + })); + const flow: FlowSpec = { ...baseFlow, workspace: 'acme/api: readonly' }; + const result = preflight(flow, { probes, projectSearchStart: root }) as PreflightResult; + expect(result.diagnostics.filter(d => d.severity === 'refusal')).toEqual([]); + expect(result.ok).toBe(true); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + + it('refuses a malformed grant with scope_syntax_invalid', () => { + const flow: FlowSpec = { ...baseFlow, workspace: 'not-a-grant' }; + const result = preflight(flow, { probes }) as PreflightResult; + const refusal = result.diagnostics.find(d => d.severity === 'refusal' && d.kind === 'scope_syntax_invalid'); + expect(refusal).toBeDefined(); + expect(result.ok).toBe(false); + }); + + it('refuses an unknown mount with mount_unknown when a manifest is present', () => { + const root = mkdtempSync(join(tmpdir(), 'scope-unknown-')); + try { + writeFileSync(join(root, 'relayfile.mounts.json'), JSON.stringify({ + version: 1, mounts: { acme: [{ path: 'api', modes: ['readonly'] }] }, + })); + const flow: FlowSpec = { ...baseFlow, workspace: 'other/api: readonly' }; + const result = preflight(flow, { probes, projectSearchStart: root }) as PreflightResult; + const refusal = result.diagnostics.find(d => d.severity === 'refusal' && d.kind === 'mount_unknown'); + expect(refusal).toBeDefined(); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + + it('refuses an out-of-scope path or mode with scope_ungrantable', () => { + const root = mkdtempSync(join(tmpdir(), 'scope-ungrantable-')); + try { + writeFileSync(join(root, 'relayfile.mounts.json'), JSON.stringify({ + version: 1, mounts: { acme: [{ path: 'api', modes: ['readonly'] }] }, + })); + const flowPath: FlowSpec = { ...baseFlow, workspace: 'acme/other: readonly' }; + const pathResult = preflight(flowPath, { probes, projectSearchStart: root }) as PreflightResult; + expect(pathResult.diagnostics.find(d => d.severity === 'refusal' && d.kind === 'scope_ungrantable')).toBeDefined(); + + const flowMode: FlowSpec = { ...baseFlow, workspace: 'acme/api: readwrite' }; + const modeResult = preflight(flowMode, { probes, projectSearchStart: root }) as PreflightResult; + expect(modeResult.diagnostics.find(d => d.severity === 'refusal' && d.kind === 'scope_ungrantable')).toBeDefined(); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + + it('honors tools.fs the same way as workspace', () => { + const root = mkdtempSync(join(tmpdir(), 'scope-toolsfs-')); + try { + writeFileSync(join(root, 'relayfile.mounts.json'), JSON.stringify({ + version: 1, mounts: { acme: [{ path: 'api', modes: ['readwrite'] }] }, + })); + const flow: FlowSpec = { ...baseFlow, tools: { fs: 'acme/api: readwrite' } }; + const result = preflight(flow, { probes, projectSearchStart: root }) as PreflightResult; + expect(result.diagnostics.filter(d => d.severity === 'refusal')).toEqual([]); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + + it('accepts absence of workspace and tools without probing anything', () => { + const result = preflight(baseFlow, { probes }) as PreflightResult; + expect(result.diagnostics.filter(d => d.severity === 'refusal')).toEqual([]); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 7e8adf11e..00a96d540 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -32,7 +32,8 @@ "tests/direct-input.test.ts", "tests/fixtures/needs-human.flow.ts", "tests/named-gates.test.ts", - "tests/build-gate.test.ts" + "tests/build-gate.test.ts", + "tests/scope-preflight.test.ts" ], "exclude": ["node_modules", "dist"] } From 5c99a5cc3cc9495c4b9a757d80b69c750f7eed85 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 12 Sep 2026 11:48:46 +0200 Subject: [PATCH 2/2] test(sdk): extend verb-field-lint FLOW_FIELDS pin for workspace, tools The scope-compiler wiring in this PR adds workspace and tools to FLOW_FIELDS; update the pinned expectation. Co-Authored-By: Claude Opus 4.7 (1M context) Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/tests/verb-field-lint.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/tests/verb-field-lint.test.ts b/packages/sdk/tests/verb-field-lint.test.ts index 37eab6d28..c3544c369 100644 --- a/packages/sdk/tests/verb-field-lint.test.ts +++ b/packages/sdk/tests/verb-field-lint.test.ts @@ -170,6 +170,7 @@ describe('closed per-verb step fields', () => { it('pins the per-verb descriptor and generates every foreign-field pair from it', () => { expect(FLOW_FIELDS).toEqual([ 'version', 'name', 'description', 'cli', 'agents', 'triggers', 'steps', 'budget', + 'workspace', 'tools', ]); expect(AGENT_DECLARATION_FIELDS).toEqual(['cli', 'model']); // `timeoutMs` is deliberately absent: it is a deterministic-only authoring