Policy: one vocabulary, composable modules, a test verb, and enforcement below the layer that can bypass it
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 9
- Forks
- 0
- Avg merge
- 3h 3m
- Merged PRs (30d)
- 509
Description
Sandboxing should be a first-class pillar of this system rather than three unrelated files that happen to say "deny". An audit of what is on main today says the parts are individually good and collectively not a system, and it names four specific things to fix before the surface grows further.
What is on main, verified
Three policy surfaces exist, each with its own file, its own flag, and its own CEL environment:
| Surface | Flag | CEL environment | Names the subject |
|---|---|---|---|
| Egress | --egress-policy |
netpolicy/rules.go:132 (request env) and :153 (connection env) |
identity |
| Task shape | --task-policy |
taskpolicy.go:208 |
identity |
| Secret access | --auth-policy |
auth/secretpolicy.go:258 |
workload |
| Role assumption | --auth-policy |
auth/assume.go:175 |
workload |
Each is fail-closed, each denies on evaluation error, each compiles at load rather than at request time. Individually they are right. What they are not is one thing.
Finding 1: the same subject has three spellings and three types
The workload identity a policy gates on is identity in egress and task policy and workload in secret and assume policy — and behind those names sit different registered object types: identityTypeName in netpolicy, taskPolicyIdentityTypeName in taskpolicy, a third in auth. This is CLAUDE.md's own defect class, "a value with one meaning, written down twice", except it is written down three times and it is user-facing: an operator writing all three policies must remember which surface calls it what, and an expression that is correct in one file is a compile error in the next.
The repository already contains the shape of the fix. eval_task_library.go:353 builds the http task's environment as base.Extend(cel.Variable(ResponseRoot, ...)) — one base vocabulary, extended per surface with what that surface genuinely adds. Policy should be built the same way: one policy.BaseEnv() naming the subject once, extended with url/host/port for egress, task for task shape, secret for secret access.
Finding 2: there is no way to decompose a policy, so it grows until it is unreadable
examples/egress-policy.yaml is 53 lines and already carries a tenant-specific rule (identity.namespace == "team-a" && host == "partner-a.example.com") inline beside a global one. That file is fine. The same file with forty tenants is not, and there is no mechanism to split it: no import, no named rule set, no per-tenant layer, no way for a platform team to publish a base policy that a team extends without being able to weaken it.
The layering rule already exists in miniature and just needs generalizing — a file replaces the default policy entirely, deny beats allow, and bounds "cannot be removed from a file, only raised". That is exactly the monotonicity property a module system needs: a layer may narrow and may not widen.
Finding 3: policies are the only thing in this repository with no test verb
flow test runs workflows. flow validate checks them. Nothing runs a policy against cases. So the rule CLAUDE.md is most insistent about — test that A cannot reach B, not that A can reach A — is currently untestable for the surfaces where it matters most, and the tenancy bug that lesson came from was precisely a policy-shaped bug. A platform team writing forty tenant rules has no way to assert that team-b cannot reach team-a's partner API other than by reading carefully.
The friction complaint has the same root. When a request is denied there is no way to ask which rule denied it, so debugging a policy means bisecting it by hand.
Finding 4: of the three pillars, only networking has a policy at all
Networking has netpolicy, and it is genuinely good — categorical denial of loopback, private ranges, link-local and metadata endpoints, re-checked on redirect in the dialer, with the byte cap deliberately placed on the http.RoundTripper rather than configured through the RPC library.
Compute and storage have nothing. grep finds no seccomp, no Landlock, no cgroup, no rlimit anywhere in the tree. A task today gets the worker's whole ambient authority over CPU, memory, processes and the filesystem, and the only reason that has not bitten is that the two built-in tasks are log and http. A plugin task is a separate process with the worker's full privileges, and the moment a task runs a command or writes a file — which is what an agent spinning up a container or a VM wants — that gap becomes the whole story.
The through-line: decide anywhere, enforce at the layer that cannot be bypassed
This repository has already learned this lesson once, in a different vocabulary. The HTTP response cap was moved off connect.WithReadMaxBytes and onto the http.RoundTripper because a library option only bounds the paths that library remembers to check, and connect-go's non-200 unmarshaler did not carry the limit over. The generalization is the design principle for sandboxing: a check the task performs is advice, because the task is the thing being constrained. Enforcement belongs below it — at the socket for network, at the syscall filter or namespace for compute, at the filesystem boundary for storage — where no path can miss it.
That also settles the portability question honestly. The decision is portable: CEL over a stated vocabulary, running identically on a laptop and in a cluster. The enforcement is not: Landlock is Linux 5.13+, seccomp is Linux, a microVM boundary needs KVM, and macOS has none of them. So enforcement is an interface with several implementations and a stated fallback, and the fallback is a refusal rather than a silent downgrade — a policy that says "this task may not touch the filesystem" on a platform that cannot enforce it must fail to start, not run unconfined while claiming otherwise.
Sketches
Illustrative, not the landed shape.
One vocabulary, extended rather than redefined:
// Package policy holds the single vocabulary every policy surface is written
// in. A subject is spelled `identity` everywhere, with one registered type, so
// an expression that type-checks in an egress rule type-checks in a storage
// rule and means the same thing.
func BaseEnv() *cel.Env // identity, env, now
// Each surface extends the base with what it genuinely adds, the way the http
// task extends the workflow environment with `response`.
func EgressEnv() *cel.Env // + url, scheme, host, port, method, path, ip
func ComputeEnv() *cel.Env // + command, args, cpu, memory
func StorageEnv() *cel.Env // + path, mode, bytes
modules, with the monotonicity the current file-level rule already implies:
# A platform team publishes the floor. A tenant layer may narrow it and cannot
# widen it: an `allow` in a later layer intersects, a `deny` unions, and a
# bound may only be lowered.
policy:
extends:
- ./base/platform.yaml
- ./tenants/team-a.yaml
compute:
deny:
- command.startsWith("/usr/bin/sudo")
max_memory: 512MiB
max_processes: 32
storage:
allow:
- path.startsWith("/work/") && mode == "read"
deny:
- path.startsWith("/work/.ssh/")
the enforcement seam, so an embedder who wants none of this can supply none of it and an embedder who wants more can supply more:
// Enforcer applies a decision at a boundary the workload cannot reach around.
// Deciding is portable; enforcing is not, which is why this is an interface
// with a Linux implementation, a container implementation, and a refusal.
type Enforcer interface {
// Available reports whether this enforcer can hold on this machine. A
// policy that requires an enforcer no one can provide fails at startup:
// running unconfined while reporting confinement is the one outcome that
// is worse than refusing.
Available() error
Confine(context.Context, Decision) error
}
and the two verbs that turn all of it from a hope into a property:
$ flow policy test ./policy.yaml ./policy_test.yaml
ok team-a reaches partner-a.example.com
ok team-b is refused partner-a.example.com (negative)
ok an unattested run is refused everything (negative)
FAIL team-b is refused team-a's secret
expected: deny, got: allow
matched: auth.yaml:12 identity.namespace.startsWith("team")
$ flow policy explain ./policy.yaml --egress \
--identity team-b --url https://partner-a.example.com/v1
denied
no allow rule matched
nearest: egress.yaml:31 identity.namespace == "team-a" && host == "partner-a.example.com"
identity.namespace was "team-b"
How the layers relate:
flowchart TB
subgraph decide["decision — portable, one vocabulary"]
B[policy.BaseEnv<br/>identity] --> E[egress]
B --> C[compute]
B --> S[storage]
end
subgraph enforce["enforcement — platform-specific, below the workload"]
E --> ED[dialer + RoundTripper<br/>ships today]
C -.-> CD[seccomp / cgroup / rlimit<br/>Linux only]
C -.-> CV[container or microVM<br/>where available]
S -.-> SD[Landlock / mount namespace<br/>Linux only]
end
CD -.->|unavailable| R[refuse to start]
SD -.->|unavailable| R
Constraints
- Fail closed stays absolute, including the new failure mode this introduces: an unavailable enforcer is a startup refusal, never a downgrade.
- Compile at load, not at request. Every rule in every layer, after composition, type-checks when configuration loads. A module system that defers a type error to the first matching request has moved a startup failure into a production one.
- Bound the composition, per the house rule: total rules after expansion, not import depth — a diamond of imports multiplies breadth exactly the way the YAML alias case does.
- Diagnostics are a property of the file. A policy module's errors get position, name the rule, and say what to write instead.
flow policy explainis the same commitment pointed at runtime. - Say nothing about the deployment in a validator. The existing rule that keeps egress-policy answers out of authoring diagnostics applies unchanged; a policy is deployment configuration and the author's editor must not pretend to know it.
- Optional for embedders. A system embedding flowstate that does not care about sandboxing supplies no enforcer and pays nothing. What it must not be able to do is supply no enforcer and a policy that requires one.
Questions
- Vocabulary unification first? It is the smallest change, it is a prerequisite for modules and for
flow policy test, and it is the one with a breaking edge:workloadin existing auth policies would need an alias or an edition-style migration. Recommended first, withidentityas the surviving spelling since two of four surfaces already use it and it is the word the docs use. flow policy testbefore or after modules? Recommended before — a test verb makes the module semantics falsifiable as they land, and the reverse order means writing the composition rules with nothing able to check them.- Compute and storage: policy surface first, or enforcement first? Recommended policy-surface-first with exactly one enforcer (Linux,
Available()returning an error everywhere else), so the vocabulary is settled before three implementations calcify around it. - How far does flowstate go on isolation itself? Confining the worker's own tasks is clearly ours. An agent asking flowstate to provision an ephemeral VM or container is a task that speaks to a platform, and is probably a plugin rather than engine code — worth stating explicitly so the boundary does not blur.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading netpolicy/rules.go, taskpolicy.go, auth/secretpolicy.go, auth/assume.go, and eval_task_library.go to compare the existing policy environments and enforcement boundaries. Review examples/egress-policy.yaml and the stated constraints before choosing a scoped design. Done requires an agreed sequence and design for vocabulary unification, composable modules, policy testing and explanation, plus compute and storage enforcement without fail-open behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100