Repository navigation
Signed-grant caveats are verified but never enforced: netd checks hosts against env config, not against the grant it just validated #24
Description
Sub-issue of #21.
The gap
A SignedGrant carries caveats, and they are covered by the signature — guest-room/mod.ts:280-288:
export function grantSigningBytes(grant: DoorGrant, binding: GrantBinding): string {
return JSON.stringify({
name: grant.name,
guest: grant.guest,
caveats: [...(grant.caveats ?? [])].sort(),
binding,
});
}netd validates that signature on the tcp/vsock path. verifyConnectGrant (netd/netd.ts:93-110) checks:
- the bearer parses as a
SignedGrant grant.name === "net"(right door)- audience matches
ROOM_ID, andexphas not passed (guest-room/mod.ts:326) - the signature verifies against the concierge's published keys
It then returns { ok: true } and discards the grant. grant.caveats is never read.
The host decision that follows (netd/netd.ts:205 → allowed() at :168-174) consults ALLOW and CAVEATS, both derived from environment variables at process start (netd/netd.ts:159-165).
Consequence
There are two unrelated things called "caveats" in this system and they never meet:
| source | scope | enforced? | |
|---|---|---|---|
NETD_CAVEATS |
env, at daemon start | whole daemon, all callers | yes — allowed() |
grant.caveats |
signed, per-caller | that caller's authority | no |
A grant attenuated to host=github.com and one with no caveats at all produce identical egress through netd. The narrowing is signed, transmitted, and verified — and then has no effect on what the holder can reach.
This is a direct contradiction of the attenuation model the org's engine implements and proves. guest-room goes to real lengths here: attenuate is append-only, attenuatesDoors proves narrowing is monotone, and interpose.ts exists specifically because "a delegated child still holds the same upstream reference… the caveats were a string it was trusted to honor, not a boundary" (guest-room/interpose.ts:8-17). At netd, the caveats are exactly that string again — except here nobody is even trusted to honor them, because nothing reads them.
guest-room/mod.ts:305-307 says it outright: "Pair with checkCaveats for full enforcement (signature THEN caveats)." netd does the signature half.
Proposed fix
-
Thread the verified grant out of
verifyConnectGrantinstead of discarding it — return the validatedSignedGrantalongside the verdict, soonHeadhas it in scope at the point of the host decision. -
Apply the grant's caveats to the host check, intersected with (never widening) the daemon's
ALLOW. The engine already has the primitive:checkCaveats(grant, ctx, verifiers)with ahostverifier, wherectxis derived from the CONNECT target. This is the same shapeinterpose.ts:94-118uses — check caveats, and only then forward. -
Decide the relationship between
NETD_CAVEATSandgrant.caveatsexplicitly and document it. The safe rule is intersection: a host must satisfyALLOW∧NETD_CAVEATS∧grant.caveats, so neither channel can widen the other. -
Tests to add:
- a grant with
host=XreachingX→ allowed - the same grant attempting
Y, whereYis in the daemon'sALLOWbut not in the grant's caveats → 403 - a grant whose caveats name a host outside
ALLOW→ still 403 (caveats cannot widen)
That middle case is the one that currently fails, and it is the whole point of the issue.
- a grant with
Note on the unix path
On unix the mounted socket is the authority and no grant is presented (netd/netd.ts:41-46), so caveat enforcement there needs a different carrier — the per-launch NETD_CAVEATS, or a scoped netd instance per room, both of which the launcher already does. This issue is specifically about the tcp/vsock path, where a signed grant is present and is being partially ignored.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity