Skip to content

Authority hardening: process:exec allow-lists + spawn_agent grant attenuation - #163

Merged
howlcipher merged 3 commits into
mainfrom
authority-hardening
Oct 13, 2026
Merged

howlcipher merged 3 commits into
mainfrom
authority-hardening

Conversation

@howlcipher

Copy link
Copy Markdown
Owner

Seat vote option 2 (Lain's requirements). HowlFrame is described as a capability-governed runtime, not a sandbox.

Changes

  1. Process grant = exact-argv allow-list. process:exec=<absolute path>[ <pattern>...] (literal, * = one arg, trailing ** = rest). A bare process grant never authorizes exec (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.
  2. spawn_agent S9 attenuation. (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 coarse process, so nested escalation is impossible. The grant list is stored in SPAWN_AGENT string_operand_2, so no opcode was added. grants is a new opaque construct (arity 0+) and is in the HFIR promote-blocker fence like spawn_agent.
  3. Typed denials. Denials raise CAPABILITY_DENIED with a reason that never echoes the program or its args. --receipt records denied EXEC/SPAWN_AGENT process effects plus the exit error.
  4. Negative tests (authority_cli_test.go, run on interpreter, -run-bc and 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 adds exec_bare_process_denied and exec_argv_mismatch_denied across all 5 targets. Unit tests are in internal/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/sh and /bin/echo are symlinks, so use canonical paths.
  • spawn_agent children no longer inherit grants. A nested spawner needs (grants "process").
  • Updated consumers: repo_analyst, swarm dogfoods, conformance/parity exec fixtures, difftest default grant, and VM/backend tests.

Not changed

Limitations

  • spawn_agent remains bytecode-only. The interpreter and HFIR refuse it, which fails closed, and the tests assert that no child body runs.
  • Interpreter receipts remain unsupported.
  • Program-file TOCTOU is a caveat, as with filesystem grants.

Checks: gofmt clean, go build/vet pass. go test ./... passes except TestHTTPServerServeHFBC, which fails locally because a stray hf160 process holds :8080; it fails on main too.

@howlcipher howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. internal/vm/vm.go:2544: plain spawn children, and the HTTP handler child at :2621, still get the parent's full grants. Document this or attenuate them.
  2. internal/vm/vm.go:796-799: interpreter exec denials aren't written to the receipt, and the interpreter has no spawn_agent attenuation.
  3. 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 howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 was examples/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, plus TestProdFlipCriteriaLock: exit 0. prod_flip_criteria_test.go, benchmarks/ and experiments/ 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 process denies exec: exec argv is not in the allow-list on both -run and -run-bc.
  • Paths:
    • printf and ./printf are refused as not absolute.
    • /usr/bin/../bin/printf and /usr//bin/printf are refused as not clean, and the grant itself is rejected at -allow-caps.
    • /USR/BIN/printf gives cannot 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| a b is 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 argument from 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-123 appears 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 holding filesystem:read=/tmp/p163/in normalizes to /tmp/p163 and 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 AllowedCaps is 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 refuses spawn_agent at 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) before exec.Command/spawnSync. No generated path runs exec unchecked. (grants ...) is UNSUPPORTED_ON_TARGET.

Should-fix (docs)

  1. 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 *' -run on (exec "/usr/bin/git" "log" "--output=/tmp/p163/pwned"). It exits 0 and writes /tmp/p163/pwned. -c core.pager=sh works the same way. process_authority.md should say that a wildcard slot authorizes any flag the program accepts, and recommend literal flags plus -- before the wildcard.
  2. An allowed exec inherits the full host environment, without an environment grant. 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-caps lines in the HFIR leg (git merge-tree CONFLICT). It's a trivial resolve, but #162's rewrite of that block has to keep process: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_agent form, 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: EvalSymlinks runs, then exec.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_primitives and test_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

@howlcipher howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 and TestProdFlipCriteriaLock: exit 0.
  • benchmarks/, evidence/, experiments/ and prod_flip_criteria_test.go have 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.go merged cleanly with #162's fence logic and keeps process: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 *; x is 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=leakme and /usr/bin/env granted, the child sees an empty env.
  • process:env=FOO passes only FOO.
  • process:env=*, process:env=F*, process:env=FOO=x and an empty process:env= are all rejected at -allow-caps.
  • process:env=PATH,HOME,LD_PRELOAD passes exactly those three.
  • git --version runs fine with no PATH on Go and JS.
  • Nit: process:env=LD_PRELOAD (and LD_*/DYLD_* generally) is grantable and lets the host inject code into every exec child. Consider refusing these names or warning about them in process_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_agent doesn't run the body, and that interpreter exec receipts are missing.
  • Nit: because the receipt's grant field 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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant