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
30 changes: 29 additions & 1 deletion docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export default flow("chief", {

const plan = await f.agent("planner", {
task: `Research and plan: ${intent}`,
workspace: "acme/api: readonly", // compiles to relayauth path scopes
workspace: "acme/api",
permissions: { accessPreset: "readonly" }, // validated declaration; currently unenforced
});

const ok = await f.human(`Ship this?\n${plan.summary}`, { to: "khaliq" });
Expand Down Expand Up @@ -290,6 +291,33 @@ The authoring surface deliberately narrows `steps: []`: `flows check` refuses
it as `invalid_spec`, while the kernel accepts it. This is a chosen
authoring-time narrowing, not a kernel guarantee.

### Per-agent permissions in TypeScript

Supported `f.agent` calls accept an optional `permissions` declaration:

```ts
const draft = await f.agent("writer", {
task: "Write drafts/post.md.",
permissions: { fileGlobs: ["drafts/**"], accessPreset: "readwrite" },
});
const review = await f.agent("reviewer", {
task: "Review drafts/post.md and flag issues; do not edit it.",
permissions: { fileGlobs: ["drafts/**"], accessPreset: "readonly" },
});
```

The exported `PermissionsSpec` has three optional camelCase fields:
`fileGlobs?: string[]`, `networkAllowlist?: string[]`, and
`accessPreset?: "readonly" | "readwrite"`. Array elements must be nonempty
strings. Empty or partial declarations are accepted without inferred defaults;
no workspace is required. Workspace names must not carry permission suffixes.

These per-step permissions are validated and recorded in the compiled step spec
but are **not currently enforced** (gate 8 / #442). They are separate from
flow-wide `FlowHeader.workspace` / `tools.fs` scopes. The chief harness above
remains an aspirational example; this option does not make that entire harness
executable today.

### Supported TypeScript LLM calls

The local authored executor supports these signatures:
Expand Down
17 changes: 9 additions & 8 deletions packages/sdk/src/authored-worker-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { classifyOutcome, type RunLifecycleOptions } from './cli/run.js';
import type { PreflightDiagnostic } from './preflight.js';
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
import type { JournalClient } from './journal-client.js';
import { SPEC_SCHEMA_VERSION, type FlowSpec, type StepSpec } from './spec.js';
import { SPEC_SCHEMA_VERSION, type FlowSpec, type PermissionsSpec, type StepSpec } from './spec.js';
import { isSurfaceCompletionReason, readCompletedStepOutput, readSuccessfulOutput } from './authored-step-output.js';
import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
import { snapshotJsonValue } from './json-value.js';
Expand Down Expand Up @@ -97,13 +97,10 @@ export function authoredWorkerRunner(
if (options.workspace !== undefined && WORKSPACE_PERMISSION_ANNOTATION.test(options.workspace)) {
throw new AuthoredFlowExecutionError(
'unsupported_workspace_permission',
`flow "${definition.name}" step "${id}": workspace "${options.workspace}" declares a `
+ 'permission annotation ("...: readonly" / "...: readwrite"), but nothing enforces it — '
+ 'no parser anywhere in this package turns that annotation into a real restriction '
+ '(kernel/DAEMON-LIFECYCLE.md\'s permission model is untouched by f.agent). '
+ 'Silently accepting and ignoring it would let a flow believe a restriction is in effect '
+ "when it is not. Declare a bare surface name (no trailing \": readonly\"/\": readwrite\") "
+ 'if you do not need enforcement, or use the declarative spec\'s `permissions` field, which is real.',
`flow "${definition.name}" step "${id}": workspace "${options.workspace}": `
+ "Workspace permission suffixes are unsupported. Use a bare workspace name and f.agent's "
+ "permissions option, for example permissions: { fileGlobs: ['src/**'], accessPreset: 'readonly' }. "
+ 'This declaration is validated and recorded with the step spec; it is not currently enforced (gate 8 / #442).',
);
}
if (options.cli !== undefined && typeof options.cli !== 'string') {
Expand All @@ -130,6 +127,9 @@ export function authoredWorkerRunner(
`f.agent options.transport must be 'direct' or 'relay' (got ${JSON.stringify(options.transport)}).`,
);
}
const permissions = options.permissions;
const permissionsSnapshot = permissions === undefined ? undefined
: snapshotJsonValue(permissions, 'f.agent options.permissions') as unknown as PermissionsSpec;
// Artifact detection only tells the truth for the local-agent DIRECT
// path: that is the only case that runs in this same process, on this
// same filesystem, so `options.cwd` (or `process.cwd()`) is provably
Expand All @@ -146,6 +146,7 @@ export function authoredWorkerRunner(
const before = artifactRoot === undefined ? undefined : await snapshotWorkspaceFiles(artifactRoot);
const output = await run({
id, type: 'agent', instruction: options.task,
...(permissionsSnapshot === undefined ? {} : { permissions: permissionsSnapshot }),
...(localAgentStream === undefined ? {} : { surfaces: { streams: [{ stream: localAgentStream }] } }),
...(options.workspace === undefined ? {} : { surfaces: { workspace: [{ surface: options.workspace }] } }),
...(options.cli === undefined ? {} : { cli: options.cli }),
Expand Down
142 changes: 142 additions & 0 deletions packages/sdk/tests/authored-agent-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import type { Server } from 'node:net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { flow, type AgentOptions } from '@relayflows/surface';
import { executeAuthoredFlow } from '../src/authored-flow-executor.js';
import { JournalClient } from '../src/journal-client.js';
import { sendOk, sendResult, sockPath, startLoopback } from './journal-client-loopback.js';

describe('authored agent permissions', () => {
let root: string;
let server: Server | undefined;
let socket: string | undefined;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'authored-permissions-'));
const wrapper = join(root, 'adapter.mjs');
writeFileSync(wrapper, `#!/usr/bin/env node
import { receiveWrapperRequest } from ${JSON.stringify(resolve('../../testdata/preflight/wrapper-session.mjs'))};
if (process.argv[2] === 'auth') process.exit(0);
await receiveWrapperRequest();
process.stdout.write('unused');
`);
chmodSync(wrapper, 0o755);
writeFileSync(join(root, 'flows.json'), JSON.stringify({ cli: wrapper, models: ['test-model'] }));
writeFileSync(join(root, 'package.json'), '{"type":"module"}');
});

afterEach(async () => {
if (server) await new Promise<void>(resolve => server!.close(() => resolve()));
if (socket) rmSync(socket, { force: true });
rmSync(root, { recursive: true, force: true });
server = undefined;
socket = undefined;
});

async function capture(options: AgentOptions, local = false): Promise<Record<string, unknown>> {
socket = sockPath();
const submitted: Record<string, unknown>[] = [];
const steps = new Map<string, Record<string, unknown>>();
server = startLoopback(socket, {
hello: ctx => sendOk(ctx),
'run.start': (ctx, params) => {
const spec = params.spec as { steps: Record<string, unknown>[] };
const step = spec.steps[0]!;
const runId = `permissions-${steps.size}`;
steps.set(runId, step);
if (step.type === 'agent') submitted.push(step);
sendResult(ctx, { run_id: runId, status: 'completed', completion_reason: 'success', completed_steps: 1 });
},
'journal.read': (ctx, params) => sendResult(ctx, {
entries: [{ entry_type: 'step.completed', step_id: steps.get(params.run_id as string)!.id,
payload: { completionReason: 'success', disposition: 'step_done',
output: { exit_code: 0, stdout_tail: 'ok', stderr_tail: '' } } }],
}),
});
const client = new JournalClient(socket, { requestTimeoutMs: 2000 });
await client.connect();
await client.hello('permissions-test');
try {
await executeAuthoredFlow(flow('permissions', async f => {
await f.agent('writer', options);
f.done('success');
}), client, undefined, {
flowPath: join(root, 'permissions.flow.ts'),
...(local ? { localAgentStream: 'test-stream' } : {}),
});
expect(submitted).toHaveLength(1);
return submitted[0]!;
} finally { client.close(); }
}

it.each(['readonly', 'readwrite'] as const)('lowers the full %s declaration', async accessPreset => {
const step = await capture({ task: 'x', workspace: 'repo', permissions: {
fileGlobs: ['drafts/**'], networkAllowlist: ['example.com'], accessPreset,
} });
expect(step.permissions).toEqual({
file_globs: ['drafts/**'], network_allowlist: ['example.com'], access_preset: accessPreset,
});
});

it.each([
[{ fileGlobs: ['drafts/**'] }, { file_globs: ['drafts/**'] }],
[{ networkAllowlist: [] }, { network_allowlist: [] }],
[{ accessPreset: 'readonly' }, { access_preset: 'readonly' }],
[{}, {}],
[{ fileGlobs: undefined, accessPreset: 'readonly' }, { access_preset: 'readonly' }],
])('preserves partial declarations without defaults: %j', async (permissions, expected) => {
const step = await capture({ task: 'x', permissions } as AgentOptions);
expect(step.permissions).toEqual(expected);
});

it.each([{}, { permissions: undefined }])('preserves absence: %j', async options => {
const step = await capture({ task: 'x', ...options } as AgentOptions);
expect(step).not.toHaveProperty('permissions');
});

it('accepts permissions without workspace on the local stream path', async () => {
const step = await capture({ task: 'x', cwd: root, permissions: { accessPreset: 'readonly' } }, true);
expect(step.permissions).toEqual({ access_preset: 'readonly' });
});

it('reads the outer permissions property once', async () => {
const getter = vi.fn(() => ({ fileGlobs: ['drafts/**'] }));
const step = await capture({ task: 'x', get permissions() { return getter(); } });
expect(getter).toHaveBeenCalledTimes(1);
expect(step.permissions).toEqual({ file_globs: ['drafts/**'] });
});

async function refuse(permissions: unknown, message: string, code?: string): Promise<void> {
const client = new JournalClient('/journal-must-not-be-contacted');
const submit = vi.spyOn(client, 'runStart');
await expect(executeAuthoredFlow(flow('invalid-permissions', async f => {
await f.agent('writer', { task: 'x', permissions } as AgentOptions);
f.done('success');
}), client, undefined, { flowPath: join(root, 'permissions.flow.ts') })).rejects.toMatchObject({
message: expect.stringContaining(message), ...(code ? { code } : {}),
});
expect(submit).not.toHaveBeenCalled();
}

it.each([null, [], 'readonly', 42, true])('rejects non-object %j', async value => {
await refuse(value, 'permissions: expected an object', 'agent_cli_unresolved');
});

it.each([
[{ unknown: true }, 'permissions: unknown key "unknown"'],
[{ file_globs: ['src/**'] }, 'unknown key "file_globs" — did you mean "fileGlobs"?'],
[{ accessPreset: 'admin' }, 'accessPreset: expected readonly | readwrite'],
...['fileGlobs', 'networkAllowlist'].flatMap(field =>
['src/**', [1], ['']].map(value => [{ [field]: value }, `${field}: expected an array of strings`])),
] as [unknown, string][])('rejects invalid declaration %j', async (value, message) => {
await refuse(value, message, 'agent_cli_unresolved');
});

it('rejects nested accessors without invoking them', async () => {
const getter = vi.fn(() => ['drafts/**']);
await refuse({ get fileGlobs() { return getter(); } }, 'accessors are not allowed');
expect(getter).not.toHaveBeenCalled();
});
});
3 changes: 2 additions & 1 deletion packages/sdk/tests/authored-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,13 @@ describe('authored flow journal executor', () => {
it('refuses a workspace permission annotation f.agent cannot enforce, before contacting the journal', async () => {
const disconnectedJournal = new JournalClient('/journal-must-not-be-contacted');

for (const workspace of ['src/**: readonly', 'src/**: readwrite', 'src/**:readonly']) {
for (const workspace of ['src/**: readonly', 'src/**: readwrite', 'src/**:readonly', 'src/**: READONLY ', 'src/**:\treadwrite\t']) {
await expect(executeAuthoredFlow(flow('workspace-permission-not-enforced', async (f) => {
await f.agent('worker', { task: 'must not dispatch', workspace });
f.done('success');
}), disconnectedJournal)).rejects.toMatchObject({
code: 'unsupported_workspace_permission',
message: expect.stringMatching(/f\.agent's permissions option.*not currently enforced/),
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/sdk/tsconfig.tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"tests/authored-flow-lifecycle-executor.test.ts",
"tests/authored-flow-operation.test.ts",
"tests/authored-flow.test.ts",
"tests/authored-agent-permissions.test.ts",
"tests/flow-executor-chain.test.ts",
"tests/input-binding.test.ts",
"tests/journal-client-loopback.ts",
Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/type-tests/agent-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { AgentOptions, PermissionsSpec as SurfacePermissionsSpec } from '@relayflows/surface';
import type { AgentStepSpec, PermissionsSpec } from '../src/spec.js';

type Assert<T extends true> = T;
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type _OptionalFieldParity = Assert<Equal<AgentOptions['permissions'], AgentStepSpec['permissions']>>;
type _ExportParity = Assert<Equal<SurfacePermissionsSpec, PermissionsSpec>>;
11 changes: 11 additions & 0 deletions packages/surface/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,20 @@ export interface AgentResult {
artifacts: string[];
}

/** Per-step declarations: validated and recorded, not currently enforced (gate 8 / #442).
* Separate from flow-wide FlowHeader.workspace / tools.fs scopes.
*/
export interface PermissionsSpec {
fileGlobs?: string[];
networkAllowlist?: string[];
accessPreset?: 'readonly' | 'readwrite';
}

export interface AgentOptions {
task: string;
workspace?: string;
/** Validated declaration only; not currently enforced (gate 8 / #442). */
permissions?: PermissionsSpec;
cli?: string;
model?: string;
/** Working directory for the CLI subprocess; defaults to the flow-runner's cwd. */
Expand Down
2 changes: 1 addition & 1 deletion packages/surface/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type {
WorkerSummary,
CloudHelper,
} from "./cloud.js";
export type { AgentOptions, AgentResult, LlmOptions, Ctx } from "./context.js";
export type { AgentOptions, AgentResult, PermissionsSpec, LlmOptions, Ctx } from "./context.js";
export {
COMPLETION_REASONS,
RUN_COMPLETION_REASONS,
Expand Down
31 changes: 31 additions & 0 deletions packages/surface/tests/agent-permissions.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Ctx, PermissionsSpec } from '../src/index.js';

export async function agentPermissions(f: Ctx): Promise<void> {
await f.agent('writer', {
task: 'Write a draft.',
permissions: { fileGlobs: ['drafts/**'], accessPreset: 'readwrite' },
});
await f.agent('reviewer', {
task: 'Review drafts/post.md; do not edit it.',
permissions: { fileGlobs: ['drafts/**'], accessPreset: 'readonly' },
});
const full: PermissionsSpec = {
fileGlobs: ['src/**'], networkAllowlist: ['example.com'], accessPreset: 'readonly',
};
f.agent('full', { task: 'x', permissions: full });
f.agent('partial', { task: 'x', permissions: { networkAllowlist: [] } });
f.agent('empty', { task: 'x', permissions: {} });
f.agent('omitted', { task: 'x' });
// @ts-expect-error Only readonly and readwrite are supported.
f.agent('bad', { task: 'x', permissions: { accessPreset: 'admin' } });
// @ts-expect-error Authoring keys are camelCase.
f.agent('bad', { task: 'x', permissions: { file_globs: [] } });
// @ts-expect-error fileGlobs must be an array.
f.agent('bad', { task: 'x', permissions: { fileGlobs: 'src/**' } });
// @ts-expect-error networkAllowlist must be an array.
f.agent('bad', { task: 'x', permissions: { networkAllowlist: 'example.com' } });
// @ts-expect-error fileGlobs elements must be strings.
f.agent('bad', { task: 'x', permissions: { fileGlobs: [1] } });
// @ts-expect-error networkAllowlist elements must be strings.
f.agent('bad', { task: 'x', permissions: { networkAllowlist: [1] } });
}
Loading
Loading