Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/sdk/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/failure-kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
30 changes: 30 additions & 0 deletions packages/sdk/src/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -211,6 +213,7 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
const cliProbeResults = new Map<string, CliProbeOutcome>();

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,
Expand Down Expand Up @@ -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 */ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manifest errors reported as unknown mounts

Medium Severity

scopeDiagnostics catches every readMountRegistry failure and replaces it with an empty registry, so corrupt JSON, an invalid manifest, or a permission error all surface as mount_unknown. The registry already throws a path-specific reason, but that message is discarded and the grant is described as missing from the manifest.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5c99a5c. Configure here.

}
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,
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/step-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const FLOW_FIELDS = [
'triggers',
'steps',
'budget',
'workspace',
'tools',
Comment thread
cursor[bot] marked this conversation as resolved.
] as const;

/** Closed named-agent declaration schema. */
Expand Down
26 changes: 26 additions & 0 deletions packages/sdk/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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');
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

if (!Array.isArray(s['steps']) || s['steps'].length === 0) {
this.fail('spec.steps: expected a non-empty array');
return this.result();
Expand Down Expand Up @@ -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`);
Expand Down
23 changes: 23 additions & 0 deletions packages/sdk/tests/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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');
});
Expand Down
90 changes: 90 additions & 0 deletions packages/sdk/tests/scope-preflight.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions packages/sdk/tests/verb-field-lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/tsconfig.tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Loading