Repository navigation
Authority hardening: process:exec allow-lists + spawn_agent grant attenuation - #163
Conversation
howlcipher
left a comment
There was a problem hiding this comment.
Assurance review of b8f303d (QA+Security).
Passed: the exec allow-list matches the exact argv, with no shell and no PATH lookup. Relative, unclean and symlinked program paths are refused. A bare process grant cannot exec. spawn_agent children get no grants by default, can't request one the parent lacks, and grandchildren can't escalate (tests at authority_cli_test.go:256 and :267). Denials raise a typed CAPABILITY_DENIED, and the VM records them in the receipt. No prod flip, no secrets, and the docs honestly say "not a sandbox". The CI build is green. Locally, examples failed once in the full suite and passed when re-run alone; it looks like the known parallel flake.
Mutation testing: I disabled seven checks one at a time (VM exec, interpreter exec, child attenuation, the parent-covers check, exact-length argv, the denied receipt entry, and bare-process deny). Each one made tests fail. All were reverted.
Follow-ups (not blockers):
- internal/vm/vm.go:2544: plain
spawnchildren, and the HTTP handler child at :2621, still get the parent's full grants. Document this or attenuate them. - internal/vm/vm.go:796-799: interpreter exec denials aren't written to the receipt, and the interpreter has no spawn_agent attenuation.
- internal/capability/process.go:100-105 vs vm.go:800: a link swapped between the symlink check and the exec would not be caught. Note it in process_authority.md.
Recommendation: merge-ready once Motoko's matching and schema items are settled. File 1–3 as follow-ups.
Assurance (bot-assurance-0001)
howlcipher
left a comment
There was a problem hiding this comment.
Verdict: merge-ready (COMMENT). No blockers, nothing fails open. Two doc should-fixes and a few nits below.
Reviewed head b8f303d (base 54009fb) in a fresh clone, with adversarial probes on -run, -run-bc and generated Go.
Gates
- CI
build: success. gofmt -l .: empty.go vet ./...: exit 0.go test ./...: exit 1. The only failure wasexamples/TestHTTPServerServeHFBC("unexpected listener on port 8080"), caused by another job on the same box. Run on its own (-count=1) it passes, exit 0.- Freeze, pin,
TestHFIRBenchmarkSolutionParity, arity, drift and fuzz tests, plusTestProdFlipCriteriaLock: exit 0.prod_flip_criteria_test.go,benchmarks/andexperiments/have no diff vs base, so the pins are byte-identical. - Hard nos hold. No prod
-compile-bc/HFIR default flip (the flip-criteria doc only gains a line; #90 stays Partial), no new tip-lock, no live-LLM or H1 claim.
What held (repros under /tmp/p163)
- Bare
processdenies exec:exec argv is not in the allow-liston both-runand-run-bc. - Paths:
printfand./printfare refused as not absolute./usr/bin/../bin/printfand/usr//bin/printfare refused as not clean, and the grant itself is rejected at-allow-caps./USR/BIN/printfgivescannot be resolved, so matching is case-sensitive.- A symlinked binary and a binary under a symlinked parent dir are both refused. The real path runs.
- Patterns:
*matches exactly one argument and does not span arguments. With/usr/bin/printf %s| * *, the call%s|abis denied.- A count mismatch is denied.
** *(a**before the end) is rejected at grant parse.- An argv of 20,000 arguments under
**is fine. - NUL in an argument gives
IO_ERROR invalid argumentfrom fork/exec. That is after authorization, so it is harmless. - Shell metacharacters (
;|$(id)$HOME) are passed verbatim, with no shell.
- Denial text: an argument containing
SECRET-TOKEN-123appears in neither the error nor the receipt. The receipt records{op:EXEC, capability:process, target_summary:<basename>, decision:denied}. - spawn_agent:
- The default form gives the child no grants: a child exec is denied, and the receipt shows the denied EXEC.
filesystem:read=/tmp/p163/in/..under a parent holdingfilesystem:read=/tmp/p163/innormalizes to/tmp/p163and is denied.- A grandchild asking for an exec grant its parent (the child) lacks is denied, even though the top-level parent holds it. Nested attenuation works.
- A child cannot add exec patterns, because exec grants must be held verbatim.
- The child VM's
AllowedCapsis the attenuated set for every op, so fetch, exec and files can't re-widen it.
- Schema gap: grants ride in
SPAWN_AGENT.string_operand_2. A missing operand means no grants, which is fail-closed. A crafted operand is still attenuated against the parent at runtime. HFIR refusesspawn_agentat lowering (not in the Phase-1 executable subset), and the interpreter refuses it too. Fail-closed on all paths. - Go/JS: the exec helpers re-implement the allow-list (absolute path, Lstat on every component, no
./../empty segments, same*/**semantics) beforeexec.Command/spawnSync. No generated path runs exec unchecked.(grants ...)isUNSUPPORTED_ON_TARGET.
Should-fix (docs)
- Flag injection into
*or**is real, and the docs don't warn about it. Repro: in a git repo, run-allow-caps 'process:exec=/usr/bin/git log *' -runon(exec "/usr/bin/git" "log" "--output=/tmp/p163/pwned"). It exits 0 and writes/tmp/p163/pwned.-c core.pager=shworks the same way.process_authority.mdshould say that a wildcard slot authorizes any flag the program accepts, and recommend literal flags plus--before the wildcard. - An allowed exec inherits the full host environment, without an
environmentgrant. Repro:FOO=leakme, then-allow-caps 'process:exec=/usr/bin/env'on(exec "/usr/bin/env"), and the output contains FOO. This was already the case before this PR, but it is a secret channel the doc doesn't mention. Document it now. A follow-up could pass an empty or allow-listed env.
Nits
- Not docs-only: #162 conflicts with this PR in
tools/difftest/main.go, the-allow-capslines in the HFIR leg (git merge-treeCONFLICT). It's a trivial resolve, but #162's rewrite of that block has to keepprocess:exec=/usr/bin/rm **. - Spawn denials echo the requested grant string, for example
child requested "process:exec=/usr/bin/printf **". That is grant text, not runtime argv, but a literal in a pattern could be sensitive. Consider echoing only the capability class. - Audit detail: the receipt has no pattern or grant ID for allowed or denied execs, only the basename. Adding the index of the matching grant would help audits.
- Escaping: there is no way to match a literal
*or**argument, and a backslash is taken literally (process:exec=/usr/bin/printf\ *parses with the program/usr/bin/printf\). Document that there is no escaping. - Empty arguments:
*matches the empty string"". Worth one line in the docs. - Go/JS mid-
**: the Go/JS helpers don't reject a**that isn't last. They treat it as a literal, which is fail-closed but diverges from the core's parse-time rejection. - Go/JS default form: for the default
spawn_agentform, Go/JS still print a banner and never run the task body. That is not an authority leak (nothing runs), but the five-target conformance claim shouldn't imply the child body runs there. - TOCTOU:
EvalSymlinksruns, thenexec.Command(path), so a writable parent dir can still swap the binary between them. This is documented as a caveat. Fine for 'capability-governed runtime, not a sandbox', and that wording in SECURITY.md is accurate.
Compatibility breaks (intended)
These are updated in the PR:
examples/repo_analyst(find→/usr/bin/find)- the swarm dogfoods (
(grants "process")) tests/parity/08_io_cli.howl(rm→/usr/bin/rm)test_primitivesandtest_v2_features- conformance 06, 19 and 21, and
lowered_hfir_abi_v1.json - difftest's allow-caps
Any out-of-tree script using bare program names or -allow-caps process for exec will now be denied, as designed.
I'm also asking Okabe to get Lain's review against her requirements.
… S9 grant attenuation, typed denials in receipts
…lags in * slots, empty exec env with process:env grants, receipt grant, non-echoing denials
b8f303d to
d7fd5be
Compare
…ares database once
howlcipher
left a comment
There was a problem hiding this comment.
Re-review @ 1641b8d (base 3ab40ff): merge-ready (COMMENT)
No blockers. Both should-fixes from my last review are fixed. There's one design follow-up on route network (recommendation below) and two small nits.
Gates
- CI
build: success. gofmt -l .: empty.go vet ./...: exit 0.go test ./...: exit 0, with 75 packages ok and no flake. Port 8080 was free, so nothing needed killing.- Freeze, pin,
TestHFIRBenchmarkSolutionParity, #142 arity/drift/fuzz tests andTestProdFlipCriteriaLock: exit 0. benchmarks/,evidence/,experiments/andprod_flip_criteria_test.gohave no diff vs 3ab40ff, so the pins are byte-identical.- Hard nos hold: no default flip (#90 Partial), no tip-lock, no live LLM or H1 claim.
- Rebase:
tools/difftest/main.gomerged cleanly with #162's fence logic and keepsprocess:exec=/usr/bin/rm **on both bytecode legs.
1. Flag injection: fixed
The grant is process:exec=/usr/bin/git log *, run in a git repo with -run.
- Denied:
--output=/tmp/p163/pwned(no file written),-c,--and"". - Allowed, but treated as revisions (git exit 128, no file written):
=-x," -x",–output=(en dash) and−x(minus sign). getopt only treats an ASCII-as a flag, so these are inert. - Escaping:
printf \*matches only a literal*;xis denied. A bare*still matches*. **still accepts flags. That's documented as an explicit 'anything'.
2. Exec env: fixed on all four paths
Tested on -run, -run-bc, generated Go and node 20.
- With
FOO=leakmeand/usr/bin/envgranted, the child sees an empty env. process:env=FOOpasses only FOO.process:env=*,process:env=F*,process:env=FOO=xand an emptyprocess:env=are all rejected at-allow-caps.process:env=PATH,HOME,LD_PRELOADpasses exactly those three.git --versionruns fine with no PATH on Go and JS.- Nit:
process:env=LD_PRELOAD(andLD_*/DYLD_*generally) is grantable and lets the host inject code into every exec child. Consider refusing these names or warning about them inprocess_authority.md.
3. Routes (bytecode, probe server on :8163)
The server-level default was (grants "database"), and the runner had network,database,process,filesystem:read=in.
| route | result |
|---|---|
| default route | db works |
route with (grants) (empty) |
db CAPABILITY_DENIED |
/admin with (grants "filesystem:read=in") |
read works |
route with no grants doing read_file |
denied |
route with (grants "process") spawning a child that asks for filesystem:read or database |
denied, attenuation is nested |
Narrowing works.
What a route keeps: the parent's coarse network, which is unscoped and covers outbound fetch as well as responding. A route with no fs grant fetched http://127.0.0.1:8163/admin and got SECRETFILE back.
That isn't an escalation past what any external client already has, since /admin is public. But the route can also reach any internal or metadata service the host can, purely because it answers HTTP.
Recommendation (follow-up, not a blocker; routes had full parent authority before this PR): separate 'respond' from outbound fetch. Let res, res_json and res_header work without network, and require outbound fetch in a route to be listed explicitly, ideally scoped by host. Until then, process_authority.md should state plainly that every route can make outbound requests to any host.
Nit: handler errors return the full runtime-error JSON (opcode, capability) to the HTTP client. This predates the PR.
4. counter_api: no behavior change
I ran the old source on a base build and the new source on the head build against the same sequence:
- GET /count
- 2× POST /inc
- GET /count
- GET, DELETE /inc (405)
- PUT /reset (405)
- POST /reset
- POST /count
- GET /nope (404)
Status codes, bodies and content types were identical. Dropping nz is safe because n is seeded at start and /reset writes "0".
Under concurrency (100 parallel POST /inc, 3 trials each), both versions lose updates: old 96/96/100, new 100/100/95. That race predates the PR and isn't a change.
Tokens with tiktoken 0.8.0 (cl100k_base): new 223, old 229, Python 233. The claim is confirmed.
5. Earlier nits: addressed
- Spawn denials now say
child requested a grant the parent does not hold, without the grant text. - The receipt effect carries
grant: "process:exec=/usr/bin/printf **", and the argv secret isn't in the receipt. - Go and JS both refuse a non-final
**(invalid process:exec grant (** must be last), exit 1). - The docs now say Go/JS
spawn_agentdoesn't run the body, and that interpreter exec receipts are missing. - Nit: because the receipt's
grantfield now records the matched entry verbatim, any literal in a pattern ends up in the receipt. That's fine, but worth one line in the docs.
Seat vote option 2 (Lain's requirements). HowlFrame is described as a capability-governed runtime, not a sandbox.
Changes
process:exec=<absolute path>[ <pattern>...](literal,*= one arg, trailing**= rest). A bareprocessgrant never authorizesexec(fails closed). No shell, no PATH lookup: bare names, relative or non-clean paths, and symlinked program paths (calling through a link, or granting the link) are refused. This is enforced on the interpreter,-run-bc(AST and HFIR artifacts) and generated Go/JS (HOWLFRAME_ALLOW_CAPS), so ABI parity holds.(spawn_agent name (grants "cap" ...) task). Children get no grants by default and only an explicit subset covered by the spawning agent's grants. Coarse names and exec entries must match verbatim; filesystem roots must be contained in a parent root. Spawning still requires coarseprocess, so nested escalation is impossible. The grant list is stored inSPAWN_AGENTstring_operand_2, so no opcode was added.grantsis a new opaque construct (arity 0+) and is in the HFIR promote-blocker fence likespawn_agent.CAPABILITY_DENIEDwith a reason that never echoes the program or its args.--receiptrecords deniedEXEC/SPAWN_AGENTprocess effects plus the exit error.authority_cli_test.go, run on interpreter,-run-bcand HFIR): bare process, wrong program/args, argv injection (shell metachar, extra, short, args packed into the program string), bare/relative/dotdot/symlink paths, default-no-grants child, child over-request (coarse, filesystem widening, wider exec pattern), nested escalation, and exact exec delegation. Grants must be literals. ABI conformance addsexec_bare_process_deniedandexec_argv_mismatch_deniedacross all 5 targets. Unit tests are ininternal/capability/process_test.go.Breaking changes (documented in change_log.md, docs/reference/process_authority.md, docs/cli.md, README, SECURITY)
-allow-caps process+(exec "find" ...)must become-allow-caps 'process:exec=/usr/bin/find **'+(exec "/usr/bin/find" ...). On usr-merged hosts/bin/shand/bin/echoare symlinks, so use canonical paths.(grants "process").Not changed
Limitations
spawn_agentremains bytecode-only. The interpreter and HFIR refuse it, which fails closed, and the tests assert that no child body runs.Checks: gofmt clean, go build/vet pass. go test ./... passes except
TestHTTPServerServeHFBC, which fails locally because a strayhf160process holds :8080; it fails on main too.