Skip to content

ADR 0019 - Privilege is an ACL on a resource tree, not a bag of scopes

  • Status: Accepted; immediate adoption, verified adversarially post-deploy
  • Date: 2026-08-20 (accepted 2026-08-20)
  • Decision owner: MAC fleet owner
  • Related: ADR 0013 (one authoritative hub allocator), ADR 0014 (visibility is a communication boundary, not a dispatch gate), ADR 0016 (agents decide what a task needs)

Context

A sandboxed executor cannot file a child of the task it is running. Hub credentials are stripped from the sandbox (_HOST_ONLY_HUB_CREDENTIALS) with a sound rationale — a fleet worker bearer would let the sandbox heartbeat, claim work, mutate the ledger and sign evidence as the host identity. But the enforcement is all-or-nothing, so a narrow, obviously-safe action is denied along with the dangerous ones.

The obvious fix is a narrower token. Attempting to specify one exposed that there is no coherent model to be narrow within.

The audit

Nine scopes, one flat namespace. KNOWN_SCOPES = {admin, agent, deploy, dispatch, read, roles, secret, workflow, write}. They are not ordered, not nested, and not derived from anything. read is a verb, secret is a resource, agent is a principal kind, workflow is a subsystem, dispatch is an operation. Four different taxonomies in one set.

The gate is a 204-line ordered if/elif chain. _required_scope(method, path) maps path prefixes to a single scope across 33 branches, and its own docstring says "THIS is the authorization gate". Being ordered, an early broad startswith shadows a later narrow one, and nothing detects that. The distribution is lopsided: agent 8 routes, admin 8, read 5, secret 3, deploy 2, and write, workflow, roles, dispatch one route each.

Implicit grants that are invisible at the call site. In has_scope: admin returns true for everything, and write silently also grants roles and workflow. So write — which guards exactly one route — is really three domains. Any least-privilege reasoning that starts from write is wrong before it begins.

Bindings are four independent scalars, checked ad hoc. TokenPrincipal carries tenant_id, agent_id, human_id and client_id, each enforced by a different in-handler call (assert_tenant, assert_actor, refuse_tenant_bound, require_admin). They do not compose, and there is no statement anywhere of which combinations are meaningful.

There is no resource-level binding at all. _assert_task_actor(principal, task_id, claimed_agent_id) accepts a task_id and never reads it:

def _assert_task_actor(principal, task_id, claimed_agent_id) -> None:
    principal.assert_actor(claimed_agent_id)

Authority is per-agent, never per-task. A function whose name promises resource scoping and silently does not enforce it is worse than its absence, because callers reasonably assume it works.

The vocabulary is duplicated and already divergent. DEFAULT_SCOPES is defined twice — ("dispatch", "read", "write") in client_principals.py and ("read", "write", "dispatch") in client_login.py — plus WORKER_SCOPES, ELEVATED_SCOPES, LIFECYCLE_SCOPES and DREAM_SCOPES as separate, unrelated notions of "scope". More than twenty distinct MAC_*_TOKEN / *_KEY environment names carry credentials.

None of this is one mistake. It is the shape of a model that was extended one route at a time.

Decision

Replace scopes with access control entries on a resource tree, with inheritance, modelled on filesystem ACLs.

1. Resources form a hierarchy with paths

/fleet
/fleet/project/<name>
/fleet/project/<name>/task/<id>
/fleet/project/<name>/task/<id>/evidence
/fleet/agent/<id>
/fleet/machine/<id>
/fleet/secret/<name>
/fleet/workflow/<id>

Every authorizable thing has exactly one canonical path. A route's requirement becomes (resource path, permission) — data, not a branch in a chain.

2. Permissions are a small, ordered set

read      observe the resource
append    add to it without altering what is there (evidence, annotations,
          comments) — the operation an executor needs
create    create children beneath it
update    modify the resource's own fields
write     replace or DELETE the resource itself
stop      abort in-flight work and park the resource
start     return a stopped resource to the queue
control   lifecycle: claim, heartbeat, lease, transition
grant     change the ACL

Ordered by strength but NOT implicitly implied: a grant of write does not confer control. Implicit implication is exactly what made write mean three things. Where a role needs several, it is granted several, visibly.

update is split out of write, and stop/start out of control, because the common operator case — correcting a task's scope (ADR 0020) — should carry neither the destructive nor the lifecycle ones. Someone fixing a bad task description should not thereby be able to delete the task, nor to claim work and impersonate a worker's lifecycle. control is what an EXECUTOR needs; stop/start are what an OPERATOR needs.

3. Principals are users; roles are groups

A principal (worker, client, human, sandbox) holds ACEs directly and through role membership, exactly as a filesystem user accumulates rights through groups. admin stops being magic: it is grant at /fleet, and it appears in listings like any other entry.

4. Inheritance, with the filesystem's rules

An ACE on a node applies to its descendants unless a descendant overrides it. Grant read at /fleet/project/mac and it holds for every task in that project, including tasks created later — the property that makes this usable at 6,849-tasks scale.

Two rules keep it predictable:

  • Longest path wins, deterministically. Not evaluation order. The current ordered chain has no defined behaviour when two prefixes both match; this does.
  • Deny is explicit and wins over any inherited allow at the same or shorter path. Needed to carve one task out of a project-wide grant.

5. Deny by default, and no invisible grants

A path with no matching ACE is denied. has_scope's implicit write → roles/workflow bridge is deleted, not reproduced.

6. The route table becomes data

_required_scope becomes a declarative table from route to (resource template, permission), so the full privilege surface can be enumerated, diffed in review, and tested exhaustively. A route with no entry fails closed. The audit above required reading 204 lines of control flow to learn what is currently enforced; that should be a query.

The case that prompted this

The sandboxed executor gets, for the lifetime of its lease:

allow  /fleet/project/mac/task/<id>            read, append, create

It can annotate its own task and file children of it. It cannot heartbeat (control), cannot claim other work, cannot touch another task, cannot read a secret, and holds nothing at /fleet. That is the least-privilege credential the previous discussion tried to express as a new special case; here it is an ordinary ACE, and it needs no new deny-list exception.

Consequences

  • Least privilege becomes expressible. Today the narrowest useful credential is a fleet worker token, which is why the sandbox gets none.
  • "What can this principal do?" and "who can reach this task?" become queries against ACEs rather than an audit of control flow.
  • This migration can fail open, and the accepted mitigation is adversarial verification rather than dual-path shadowing. See the acceptance record below for the reasoning and the required sequencing.
  • Inheritance means a grant high in the tree is powerful and easy to make carelessly. Listing effective permissions for a principal, and showing where each is inherited from, is required — not optional tooling.
  • Nine scopes on ~25 routes is small enough to be mapped exhaustively, which is what makes the migration tractable now. It will not stay that way.

Acceptance record

Accepted 2026-08-20 by the fleet owner, for immediate adoption at P0.

The dual-path requirement was considered and deliberately not taken

The Proposed version required shadow evaluation: run both gates, enforce the old one, and move only when the disagreement log was empty. That was rejected on a risk assessment specific to this fleet:

  • The fleet is firewalled and not reachable by external actors.
  • Tailscale routing provides further isolation within the firewall.

So the window in which a fail-open defect could be exploited by an outside party is not a window that exists here, and paying dual-path's cost to close it is not warranted. Recorded explicitly because "we skipped the safety rail" is exactly the decision a future reader will want the reasoning for.

What replaces it

Implement the architecture together with a test suite that actively probes the boundaries of the new mechanism — deliberately attempting the operations that must be refused, and confirming that each fails closed rather than merely confirming that permitted operations succeed. A suite that only proves the allowed paths work cannot detect fail-open; that asymmetry is the entire risk here, so the tests must be written from the refusal side.

Sequencing, which is load-bearing

Adversarial verification runs after deployment covers every agent. A partially deployed fleet produces false results in both directions: an agent still on the old gate can pass a probe that the new gate would fail, and can fail a probe that the new gate would pass. Neither outcome is informative, and both are easy to misread as evidence. Deploy fleet-wide first; probe second.

One thing the isolation argument does not cover

The perimeter argument is about external actors, and it holds. But the threat model that produced this ADR is an agent inside the perimeter: a sandboxed executor that is confused, is running a prompt-injected instruction, or is simply buggy. A firewall does nothing about that, and neither does Tailscale.

This does not change the decision — it sharpens what the adversarial suite must cover. The probes that matter are the ones launched from a principal that already holds a valid, narrow credential and tries to exceed it: a task-scoped grant reaching for another task, for control on its own task, for a secret, or for anything at /fleet. Probes from an unauthenticated outsider are the easy case and the one the network already answers.

Alternatives considered

Add one narrow scope for the sandbox case. Rejected: it is the tenth entry in a namespace that is already incoherent, and it does nothing for the missing resource binding — a task_author scope still could not say which task without inventing a parallel mechanism.

Add a task_id binding beside agent_id and tenant_id. Rejected: it makes a fifth independent scalar and a fifth ad-hoc check. The problem is that bindings do not compose; adding one more does not fix that.

Adopt an off-the-shelf policy engine (OPA/Cedar). Rejected for now: the gain is expressiveness the fleet does not yet need, at the cost of a second language and a runtime dependency in the request path. Revisit if ACEs alone prove insufficient — the resource-path model above is compatible with both.

Leave it and document the traps. Rejected. The traps are not incidental: a function that ignores its task_id, and a write scope that silently carries two more domains, are the kind of defect that is discovered by something being permitted that should not have been.