flows: path-scoped auth from workspace: / tools: — SURFACE §2 rule 3
Spec citation
SURFACE.md §2 rule 3, line 68:
auth — relayauth: never called directly; workspace: / tools: declarations compile to path-scoped tokens ("the filesystem paths are the permissions").
SURFACE.md §1 harness example, line 46:
const plan = await f.agent("planner", {
task: `Research and plan: ${intent}`,
workspace: "acme/api: readonly", // compiles to relayauth path scopes
});
The declaration is the permission. There is no separate ACL system to keep in
sync with the flow — the strings the author writes on workspace: and tools:
are the exact scope the running worker gets. This is what makes covenant 2
(preflight is honest) hold at the auth boundary: an unresolvable scope refuses
the run before any token is minted.
Scope of this slice
Compile author-declared workspace: and tools: strings into scope
descriptors, mint relayauth tokens limited to those scopes, and refuse before
run if any declared mount cannot be granted at the requested mode. tools.mcp
is handled by slice C — this slice covers the filesystem side only.
1. Author-facing shape
Both AgentOptions and FlowHeader accept workspace:
export interface AgentOptions {
task: string;
workspace?: string | string[]; // stricter than the current free-form string
}
export interface FlowHeader {
identity?: string;
memory?: { script?: boolean; agent?: boolean };
budget?: string;
workspace?: string | string[];
tools?: {
mcp?: string[]; // owned by slice C
fs?: string | string[]; // new — shell/deterministic filesystem scope
};
}
An entry is a grant expression:
<mount> — a registered relayfile mount name, resolved through the
same mount registry preflight uses for effect writes.
<path> — a /-separated path prefix relative to the mount root. . and
empty (implicit /) accepted; .. refused as scope_syntax_invalid.
<mode> — one of readonly, readwrite, append. Trailing whitespace is
stripped; case is lowered. Unknown modes fail closed.
Multiple entries are provided as an array. A flow header entry is inherited by
every step of the flow; a step-level workspace on AgentOptions overrides
for that step only (never additive — replacement, so the author sees the total
scope in one place).
2. Compiler output — the scope descriptor
The compiler lowers each entry into an inert JSON descriptor:
{
"mount": "acme/api",
"path": "/",
"mode": "readonly",
"source": {
"kind": "flow" | "step",
"id": "<flow name or step id>",
"raw": "acme/api: readonly"
}
}
The descriptor is what the journal carries and what relayauth mints against —
strings are for authors, JSON is for the system. flows check --json prints
the descriptor list under a new scopes: field so the author can see the
resolved grants before submitting.
3. Preflight — refuse before mint
For every declared descriptor:
mount_unknown — the mount name is not in the relayfile mount registry.
Names are looked up case-sensitively; missing = refuse.
scope_syntax_invalid — .., colons in the path, negative path
components, whitespace-only mode, unknown mode literal.
scope_ungrantable — the mount is registered but the requested mode is
not achievable (e.g., a readonly-registered mount asked for readwrite).
Distinct from mount_unknown so the diagnostic can tell the author whether
to fix the name or the mode.
Preflight collects every failure in a single pass and lists them together,
matching the existing preflight collection style used by model_unknown
(SURFACE §2 rule 6). A single unresolvable descriptor is enough to refuse the
whole run; the CLI exit code stays 2 (before-journal-write refusal).
4. Worker session — token delivery
The worker CLI already has an authenticated bootstrap step (see
packages/sdk/src/worker-cli.ts). Extend it so:
- Worker start receives the resolved scope descriptor list for the step.
- Worker mints (or is handed) a relayauth token limited to those scopes.
- The token is threaded to the agent CLI subprocess through the same
post-identification session request already used for model + wake context.
Environment leakage is forbidden — the scope MUST NOT ride on ambient
env vars, per the same principle that removes ambient RELAYFLOW_MODEL.
- The worker prints the resolved scope on start (one line per descriptor) so
the operator can see what the session is allowed to touch.
Deterministic steps get the same descriptor list; the shell wrapper enforces
by way of the relayfile mount's own permission bit at write time (a mount
registered readonly refuses writes at the mount layer, so the token is not the
only line of defense — but it is the primary one for shell commands that
address the mount by path).
5. Refusal contract summary
| Code |
When |
scope_syntax_invalid |
Grant string does not match <mount>/<path>: <mode> grammar, contains .., unknown mode, or trailing garbage. |
mount_unknown |
Mount name not in the relayfile mount registry. |
scope_ungrantable |
Mount registered but requested mode not achievable at that mount. |
All three are before-journal refusals. Runtime revocation is out of scope.
Acceptance evidence
The PR must include a test that demonstrates each of the following, with
verbatim output captured in the PR description:
- Grant is enforced. A worker running under
workspace: "acme/api: readonly" can read from the acme/api mount and
any write to that mount from the same worker is refused by the mount
layer with a diagnostic that names the scope.
- Unknown mount =
mount_unknown. A flow that declares
workspace: "no-such/thing: readonly" refuses at preflight before any
worker starts and prints the resolved-scopes list showing the missing
mount by name.
- Scope is observable.
flows check --json output includes a
scopes array; running the flow prints a one-line-per-descriptor
summary to stderr on worker start.
- Grammar is validated.
workspace: "acme/api: readwrite/nope"
refuses as scope_syntax_invalid with the offending substring quoted
in the diagnostic.
Files to touch
packages/surface/src/context.ts — tighten AgentOptions.workspace to
string | string[].
packages/surface/src/flow.ts — add workspace and tools.fs to
FlowHeader.
packages/sdk/src/scope-compiler.ts — new. Parser +
descriptor emitter, with unit tests covering every refusal code.
packages/sdk/src/preflight.ts — call into the compiler for every
declared scope and merge failures into the existing collection.
packages/sdk/src/worker-cli.ts — thread the descriptor list through
the worker session boot and log resolved scopes.
packages/sdk/src/authored-flow-executor.ts — carry the scope list
through step submission so the kernel journals it.
docs/SURFACE.md — replace the current handwave line with a link to
the resolved refusal contract above.
Not in scope
- Per-file ACLs. Grants are path-prefix, mode-triples. If the author needs
finer-grained access, they narrow the path prefix.
- Dynamic scope escalation from within a running step.
- Cross-mount composition (a single grant expression referring to two
mounts). Multiple grants stay a list at the header.
- Runtime revocation of a live token — a token that was minted is valid
until the step completes. The next step gets its own mint.
tools.mcp — owned by slice C.
- Provider adapter credential resolution (SLACK_TOKEN etc.) — owned by
slice B and the existing preflight credential probes.
Dependencies
Depends on the relayfile mount registry being reachable at preflight time
(the same registry flows check already consults for effect writes). Does
not depend on slices A–E, F, G, or I; can land in any order relative to
those.
flows: path-scoped auth from
workspace:/tools:— SURFACE §2 rule 3Spec citation
SURFACE.md §2 rule 3, line 68:
SURFACE.md §1 harness example, line 46:
The declaration is the permission. There is no separate ACL system to keep in
sync with the flow — the strings the author writes on
workspace:andtools:are the exact scope the running worker gets. This is what makes covenant 2
(preflight is honest) hold at the auth boundary: an unresolvable scope refuses
the run before any token is minted.
Scope of this slice
Compile author-declared
workspace:andtools:strings into scopedescriptors, mint relayauth tokens limited to those scopes, and refuse before
run if any declared mount cannot be granted at the requested mode.
tools.mcpis handled by slice C — this slice covers the filesystem side only.
1. Author-facing shape
Both
AgentOptionsandFlowHeaderacceptworkspace:An entry is a grant expression:
<mount>— a registered relayfile mount name, resolved through thesame mount registry preflight uses for effect writes.
<path>— a/-separated path prefix relative to the mount root..andempty (implicit
/) accepted;..refused asscope_syntax_invalid.<mode>— one ofreadonly,readwrite,append. Trailing whitespace isstripped; case is lowered. Unknown modes fail closed.
Multiple entries are provided as an array. A flow header entry is inherited by
every step of the flow; a step-level
workspaceonAgentOptionsoverridesfor that step only (never additive — replacement, so the author sees the total
scope in one place).
2. Compiler output — the scope descriptor
The compiler lowers each entry into an inert JSON descriptor:
{ "mount": "acme/api", "path": "/", "mode": "readonly", "source": { "kind": "flow" | "step", "id": "<flow name or step id>", "raw": "acme/api: readonly" } }The descriptor is what the journal carries and what relayauth mints against —
strings are for authors, JSON is for the system.
flows check --jsonprintsthe descriptor list under a new
scopes:field so the author can see theresolved grants before submitting.
3. Preflight — refuse before mint
For every declared descriptor:
mount_unknown— the mount name is not in the relayfile mount registry.Names are looked up case-sensitively; missing = refuse.
scope_syntax_invalid—.., colons in the path, negative pathcomponents, whitespace-only mode, unknown mode literal.
scope_ungrantable— the mount is registered but the requested mode isnot achievable (e.g., a readonly-registered mount asked for
readwrite).Distinct from
mount_unknownso the diagnostic can tell the author whetherto fix the name or the mode.
Preflight collects every failure in a single pass and lists them together,
matching the existing preflight collection style used by
model_unknown(SURFACE §2 rule 6). A single unresolvable descriptor is enough to refuse the
whole run; the CLI exit code stays
2(before-journal-write refusal).4. Worker session — token delivery
The worker CLI already has an authenticated bootstrap step (see
packages/sdk/src/worker-cli.ts). Extend it so:post-identification session request already used for model + wake context.
Environment leakage is forbidden — the scope MUST NOT ride on ambient
env vars, per the same principle that removes ambient
RELAYFLOW_MODEL.the operator can see what the session is allowed to touch.
Deterministic steps get the same descriptor list; the shell wrapper enforces
by way of the relayfile mount's own permission bit at write time (a mount
registered readonly refuses writes at the mount layer, so the token is not the
only line of defense — but it is the primary one for shell commands that
address the mount by path).
5. Refusal contract summary
scope_syntax_invalid<mount>/<path>: <mode>grammar, contains.., unknown mode, or trailing garbage.mount_unknownscope_ungrantableAll three are before-journal refusals. Runtime revocation is out of scope.
Acceptance evidence
The PR must include a test that demonstrates each of the following, with
verbatim output captured in the PR description:
workspace: "acme/api: readonly"can read from theacme/apimount andany write to that mount from the same worker is refused by the mount
layer with a diagnostic that names the scope.
mount_unknown. A flow that declaresworkspace: "no-such/thing: readonly"refuses at preflight before anyworker starts and prints the resolved-scopes list showing the missing
mount by name.
flows check --jsonoutput includes ascopesarray; running the flow prints a one-line-per-descriptorsummary to stderr on worker start.
workspace: "acme/api: readwrite/nope"refuses as
scope_syntax_invalidwith the offending substring quotedin the diagnostic.
Files to touch
packages/surface/src/context.ts— tightenAgentOptions.workspacetostring | string[].packages/surface/src/flow.ts— addworkspaceandtools.fstoFlowHeader.packages/sdk/src/scope-compiler.ts— new. Parser +descriptor emitter, with unit tests covering every refusal code.
packages/sdk/src/preflight.ts— call into the compiler for everydeclared scope and merge failures into the existing collection.
packages/sdk/src/worker-cli.ts— thread the descriptor list throughthe worker session boot and log resolved scopes.
packages/sdk/src/authored-flow-executor.ts— carry the scope listthrough step submission so the kernel journals it.
docs/SURFACE.md— replace the current handwave line with a link tothe resolved refusal contract above.
Not in scope
finer-grained access, they narrow the path prefix.
mounts). Multiple grants stay a list at the header.
until the step completes. The next step gets its own mint.
tools.mcp— owned by slice C.slice B and the existing preflight credential probes.
Dependencies
Depends on the relayfile mount registry being reachable at preflight time
(the same registry
flows checkalready consults for effect writes). Doesnot depend on slices A–E, F, G, or I; can land in any order relative to
those.