Skip to content

flows: path-scoped auth from workspace:/tools: — SURFACE §2 rule 3 #308

Description

@kjgbot

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>/<path>: <mode>
  • <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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions