Skip to content

ci: add a claims suite tying security.md promises to tests - #76

Draft
pmoust wants to merge 4 commits into
mainfrom
ci/22-claims-suite
Draft

pmoust wants to merge 4 commits into
mainfrom
ci/22-claims-suite

Conversation

@pmoust

@pmoust pmoust commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

docs/security.md makes specific, testable promises, and nothing in CI proved
any of them, so each held until a refactor quietly ended one. Several came
close recently. This ties every testable sentence on that page to the test that
defends it, and fails CI when a row names a test that no longer exists, so a
claim cannot be left silently undefended after a rename.

Related issues

Closes #22

Changes

  • docs/claims.md: fourteen rows, each the exact sentence, where it appears,
    and a typed token naming its defence: go:TestName, smoke:<check text>,
    vm, or pending.
  • script/check-claims.sh: resolves every token, go: against
    go test -list ./... and smoke: against the ok lines in script/*.sh,
    and fails on any that resolves to nothing. --self-test first runs it against
    a row that names a missing test and confirms it fails, so the guard is proven
    live before it is trusted.
  • CI step claims table: self-test, then the check. make claims runs the
    same thing locally.
  • Four new smoke checks against the stub runtime, which already logs the
    environment it receives: only the declared credential names reach the guest,
    a denied billing key does not, a scheme:// value is refused, and no
    credential is written into the workspace.
  • script/claims-vm.sh and make claims-vm for the two rows only a real VM can
    prove (the guest sees one directory; the keychain, SSH agent and other host
    directories are not reachable). It boots a sandbox where a runtime is present
    and skips cleanly where none is.
  • Two rows marked pending, for the fail-open switch fix in fix(wrap): require an explicit value for security switches #63 and the digest
    pin in feat(verify): verify the digest that boots, not the tag #67; each flips to a real token when its PR merges.
  • One link added near the top of docs/security.md; nothing else there.

Checklist

  • make all passes (vet, test, build)
  • script/smoke.sh passes
  • I have added or updated tests covering the change
  • I have run go test ./... -race, if the change touches concurrency, subprocesses or the daemon
  • I have exercised the change against a real runtime (brig run <agent>), if it touches the run, exec or credential path
  • I have updated the affected docs (README, docs/security.md, docs/profiles.md, docs/brigd.md)

claims-vm.sh was seen to skip cleanly on a box with no runtime. Its boot half,
the two VM rows, has not been run against a real sandbox yet, and it should be
once before this merges, since those two rows are the ones the page leads with.

Verified here beyond the gates: the checker refuses a row naming a missing Go
test and a row naming a missing smoke check, and its self-test passes.

Credentials and the sandbox boundary

No behaviour changes. This is the mechanism that keeps the claims about both
promises honest after the fact: every sentence the page makes is now either
defended by a named test, marked as needing a VM, or marked pending a specific
PR, and CI refuses a fourth state.

pmoust added a commit that referenced this pull request Aug 26, 2026
The legend said a smoke token names a check in script/smoke.sh, but the
checker grepped script/*.sh, so a smoke token could resolve against
script/claims-vm.sh, which CI never runs. Grep smoke.sh alone, the file
the legend names.

Both pending rows listed security.md as where the claim appears, but
neither sentence is in that file yet. Say the sentence lands with its PR
instead of pointing at a place it is not.

Refs: #76
Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
@pmoust
pmoust marked this pull request as draft August 26, 2026 18:06
pmoust added a commit that referenced this pull request Aug 27, 2026
The legend said a smoke token names a check in script/smoke.sh, but the
checker grepped script/*.sh, so a smoke token could resolve against
script/claims-vm.sh, which CI never runs. Grep smoke.sh alone, the file
the legend names.

Both pending rows listed security.md as where the claim appears, but
neither sentence is in that file yet. Say the sentence lands with its PR
instead of pointing at a place it is not.

Refs: #76
Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
@pmoust
pmoust force-pushed the ci/22-claims-suite branch from 66a73a9 to 886f598 Compare August 27, 2026 10:32
pmoust added 4 commits August 27, 2026 18:35
docs/security.md makes specific, testable promises: the guest sees one
directory, a symlinked workspace is refused, an image under our registry
with a bad signature stops the boot, credentials are forwarded by name
and not ambient, a denied billing key does not reach the guest. Nothing
in CI proved any of them, so a promise could quietly stop being true in a
refactor and no test would notice. Several of these have regressed or
nearly regressed, which is why this matters now.

Add docs/claims.md, a table with one row per public claim: the exact
sentence, where it appears, and the test that defends it. Each test is
named with a typed token so the mapping is machine-checkable rather than
prose a reader has to trust.

Add script/check-claims.sh, run in CI, which fails when a row names a Go
test or a smoke check that does not exist. A row without a live test is
the drift this catches, so a rename that orphans a claim fails the build
rather than the next refactor. Its --self-test mode proves the guard is
live by running it against a row that names a missing test.

Most claims already had tests, named here rather than duplicated. Two new
checks go into script/smoke.sh on the run path: a denied billing key
never reaches the guest env line, and only the declared credential names
do. Both assert on the run's argv, because brig hands the runtime child
its whole environment, so a value being visible to the stub proves
nothing about the guest.

The isolation boundary needs a real VM to prove, since a stub runtime
cannot show that a host file is unreadable from the guest. Those rows are
marked as needing a VM and defended by the make claims-vm target, which
boots a real sandbox where a runtime exists and skips cleanly where none
does. Two further claims, the BRIG_ALLOW_DENIED fail-open switch and
digest pinning, live on branches not yet merged; their rows are present
and marked pending so the claims are tracked until the tests land.

Fixes: #22
Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
check-claims.sh resolves both go: and smoke: tokens, but its --self-test
fixture held only a go: row. The two tokens resolve through independent
paths (go test -list versus a grep over script/*.sh), so the smoke branch
could break -- for instance a mutation that auto-passes every smoke row --
while the self-test and the real check both stayed green. smoke is 10 of
the table's 24 tokens, so half the guard was unproven.

Add a smoke: row naming a check that cannot exist alongside the go: row,
and assert each is reported missing by name rather than only that the run
failed. Checking the aggregate fail flag was the loophole: with either
branch broken the other still trips the flag, so the aggregate stays 1
and the old assertion passed. Breaking either resolver now fails the
self-test.

Also clarify the pending BRIG_ALLOW_DENIED row. On this branch the
behaviour is not merely untested, it is false: BRIG_ALLOW_DENIED=false
still forwards the denied variable today, and #63 is the fix. Note that
in the row and document that pending means "fixed on an unmerged branch",
not "unknown", so a reader does not read pending as a passing claim.

Refs: #22
Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
The legend said a smoke token names a check in script/smoke.sh, but the
checker grepped script/*.sh, so a smoke token could resolve against
script/claims-vm.sh, which CI never runs. Grep smoke.sh alone, the file
the legend names.

Both pending rows listed security.md as where the claim appears, but
neither sentence is in that file yet. Say the sentence lands with its PR
instead of pointing at a place it is not.

Refs: #76
Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
Both pending rows named work that has since merged. BRIG_ALLOW_DENIED's
strict reading landed with #63 and the digest pin with #67, so the rows
now name the tests that defend them and the table has no pending rows at
all.

The sentences themselves were not in security.md, only in the table's
Where-it-appears column, which is the same fault this PR fixed for the
pending rows earlier: a column pointing at a claim the doc does not make.
Both are true and tested now, so security.md states them: every off
spelling of BRIG_ALLOW_DENIED leaves the denied variable behind, and the
digest that booted is the digest that was verified.

Refs: #22
Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
@pmoust
pmoust force-pushed the ci/22-claims-suite branch from 886f598 to 059be4d Compare August 27, 2026 15:39

@ananos ananos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this a lot -- a claim with no test being a visible row rather than a silent gap is the right mechanism, and the vm / pending states keep it honest about what CI can actually prove.

One gap to close before it comes out of draft, inline.

Comment thread docs/claims.md
| "A `scheme://` value read from the environment is refused as an unresolved secret-manager reference." | security.md, Credentials (the three rules) | `smoke:a secret-manager reference is not forwarded` + `go:TestUnresolvedReferencesAreRejectedButOrdinaryURLsAreNot` | existing |
| "Nothing is written into the workspace." (no credential lands in the workspace) | security.md, Credentials | `smoke:no credential is written into the workspace` | existing |
| "`BRIG_ALLOW_DENIED=false` does not forward the denied variable" (the fail-open switch) | security.md, Credentials | `go:TestOffSpellingsDoNotForwardADeniedCredential` | existing |
| "the digest that booted is the digest that was verified" | security.md, Guest images | `go:TestVerifyResolvesVerifiesAndPinsAMatchingDigest` | existing |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The table has no rows for the network claims, and #130 added two to security.md yesterday:

  • a policy bound to a sandbox is enforced at the gateway, and a run that cannot enforce it is refused rather than booted unconstrained;
  • --network isolated gives the sandbox a network of its own, and is refused where brig owns no network.

Both have tests already, so they would come in as existing rather than pending: the refusals are covered in script/smoke.sh and in internal/runtime (TestNerdctlRefusesAPolicyItCannotEnforce, TestIsolatedIsRefusedWhereBrigOwnsNoNetwork), and the enforcement itself is measured in docs/manual-tests/egress-policy.md, which is vm territory.

Worth rebasing on main and adding them -- a claims table that misses the newest claims is the exact failure it exists to prevent. Happy to write the rows if that helps.

@ananos ananos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I still like this a lot. A claim with no test being a visible row rather than a silent gap is the right mechanism, and retiring the pending rows in 059be4d was the right call -- pending was the one status that could quietly mean "this claim is not defended" while looking tracked.

The typed-token scheme has aged well too: go: checked against go test -list, smoke: matched on its ok line, vm for what only a real boot can show. That a stub cannot prove isolation, and that nothing here pretends otherwise, is the honest part.

One gap, unchanged since I first read this, and now the more serious for it. Plus a note on where the branch sits.

Comment thread docs/claims.md
| Claim (exact sentence from the docs) | Where it appears | Defended by | Status |
| --- | --- | --- | --- |
| "the agent sees one directory and the credentials you chose to give it, and nothing else on the host." | security.md, intro | `vm` | needs a VM |
| "It does not have your keychain, your SSH agent, your secret manager, or any other directory on the host." | security.md, The boundary | `vm` | needs a VM |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The table still has no rows for the network and policy claims, and #130 merged three days ago, so docs/security.md on main now carries two promises this suite does not track:

  • a policy bound to a sandbox is enforced at the gateway, and a run that cannot enforce it is refused rather than booted unconstrained;
  • --network isolated gives the sandbox a network of its own, and is refused where brig owns no network.

Both already have tests, so they come in as existing rather than needing anything written. The refusals are covered by go:TestNerdctlRefusesAPolicyItCannotEnforce and go:TestIsolatedIsRefusedWhereBrigOwnsNoNetwork -- and note the first of those got stronger this morning: #149 moved the GUI refusal into nerdctl.CanRun so the join path is covered too, which is the same shape. The enforcement itself is measured in docs/manual-tests/egress-policy.md, which is vm territory by this table's own rule.

I am flagging it again rather than letting it go because it is precisely the failure this page exists to prevent: the newest security claims are the ones with no row, and a reader would take the table's silence for "nothing more is promised". Happy to write the two rows if that is quicker than you doing it.

@ananos

ananos commented Sep 7, 2026

Copy link
Copy Markdown
Member

Separately from the table itself: this branch is 85 commits behind main and has been open since 25 August. The suite reads docs/security.md and matches ok lines in script/smoke.sh, and both have moved a long way since -- smoke.sh in particular gained cases from #130, #137, #146 and #158. So the checker may well fail on the rebase for reasons that are not defects, simply because a token no longer resolves.

Worth rebasing before spending effort on the rows, so the run you are debugging is the real one.

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.

Test each public security claim in CI

2 participants