Repository navigation
runtime: a deny-everything policy now denies the surfaces it never saw (#473) - #554
Merged
Merged
Conversation
#473) CapabilityPolicy was consulted only for the four sandbox capability groups. Every effect surface that was not filesystem/subprocess/network/env -- tool_call, syscall, agent_call, and the whole memory store -- was invisible to it. The vocabulary was closed at five names, so there was not even a name to express the intent: DenyList("tool.invoke") raised "unknown capability". The chokepoint was never the problem. VM.call_builtin consults BUILTIN_CAPABILITIES faithfully; the map never grew past the flags. #405 built the enforcement point and it works exactly as designed. Five capabilities added -- tool.invoke, syscall, agent.call, memory.read, memory.write -- and the builtins behind them reach the policy with their arguments, so a policy decides on which tool rather than merely whether. The memory pair is spelled to match what SyscallSpec has always declared for the memory syscalls, so the two surfaces name the same authority rather than two spellings of it (#478 follows). action tool "x" and action agent "a" are governed too. They lower to __action_tool / __action_agent without passing through tool_call, and a host can shadow neither -- register_function refusing to override a builtin is a deliberate boundary (#441-#444). Gating one spelling would have left the DSL form uninterposable, which is the concrete consequence the issue reported. FS_READ was declared in ALL_CAPABILITIES from 5.0.0 and attached to nothing, so reads were invisible for the same reason. read_file, list_dir, path_exists and the hash_*_file family now carry it -- exactly the builtins reaching _ensure_path_allowed without writing. That is half of #467; the declarative read-only/writable split for allowed_paths is untouched and stays open. The part that outlives the fix: NO_AUTHORITY_BUILTINS names the 227 builtins that carry no authority, grouped by why, and a test requires BUILTIN_CAPABILITIES | NO_AUTHORITY_BUILTIN_NAMES == BUILTIN_NAMES so a new builtin fails the suite until somebody decides which side it is on. "Is this governed?" stops depending on whether anyone remembered -- which is how four authority-bearing surfaces stayed ungoverned from 5.0.0 through 5.2.0. Confirmed the guard fails, naming the culprit, against an injected builtin. Additive: a runtime with no policy behaves as before. Cost is ~0.8us per governed call (CapabilityRequest build plus the floor check, measured) against ~25us for a builtin call; ungoverned builtins pay nothing, since the dict lookup already happened. No short-circuit was added for the no-policy case: it would put "which capabilities the floor rules on" in a second place that can drift from the floor itself, which is this cluster's defect class. Docs: the embedder runbook said the allow_* switches "default to permissive". They have denied by default since 5.0.0 -- backwards for three releases, in the document an embedder reads to configure confinement. Full suite 2,578 passed. Each behaviour assertion confirmed red against the 5.2.0 map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8nTAPwZc9zV7PKDvxQkYE
The closed-issues gate reads issue numbers from CHANGELOG entry lines and requires a matching `# closes: #N` regression test. #467 is only half fixed here -- FS_READ is wired, the declarative read/write path split is not -- so it stays open, and a marker claiming otherwise would make the gate report a pass for work that has not happened. Per the convention in CLAUDE.md, an issue number inside an entry's prose is a free cross-reference and demands no test. The claim moves there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8nTAPwZc9zV7PKDvxQkYE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #473. Closes half of #467 (see below).
Reproduced first
The policy was never called once, and there was no name under which to express
the intent.
The chokepoint was never the problem
VM.call_builtinconsultsBUILTIN_CAPABILITIESfaithfully. #405 built theenforcement point and it works exactly as designed. The map never grew past
the four sandbox flags. That distinction matters, because it says where the
durable fix goes: not another check, but a classification that cannot be
incomplete.
What changed
Five capabilities —
tool.invoke,syscall,agent.call,memory.read,memory.write— and the builtins behind them now reach the policy with theirarguments, so a policy can decide on which tool, not merely whether:
memory.read/memory.writeare spelled to match whatSyscallSpechasalways declared for the memory syscalls, so the two surfaces name the same
authority rather than two spellings of it. #478 follows and enforces the
per-spec field.
action toolandaction agentare governed too:They lower to
__action_tool/__action_agentwithout passing throughtool_call, and a host can shadow neither —register_functionrefusing tooverride a builtin is a deliberate boundary (#441–#444). Gating one spelling
would have left the DSL form uninterposable, which is the concrete consequence
the issue reported.
#467, in part.
FS_READsat inALL_CAPABILITIESfrom 5.0.0 attached tonothing, so reads were invisible for the same reason.
read_file,list_dir,path_existsand thehash_*_filefamily now carry it — exactly the builtinsreaching
_ensure_path_allowedwithout writing.path_joinand its neighboursstay ungoverned; they touch no filesystem. The rest of #467 — a declarative
read-only/writable split for
allowed_paths— is untouched, and I've left theissue open for it.
The part that outlives the fix
NO_AUTHORITY_BUILTINSnames the 227 builtins that carry no authority, groupedby why — "pure computation", "path strings, touching no filesystem",
"discovery, not invocation", "orchestration of work that is itself governed", and
so on. 287 = 60 governed + 227 not, no overlaps, no phantom names.
A behaviour test passes the moment one name is added. This fails for the next
builtin nobody classified:
(Verified by injecting exactly that.) Same shape as
TASK_STATUSESandFLOW_DECLARATIONS— name the set once, let a test drive off it. "Is thisgoverned?" stops depending on whether anyone remembered, which is how four
authority-bearing surfaces stayed ungoverned across three releases while the
chokepoint that would have caught them worked perfectly.
Judgement calls
tool_register/tool_unregisterare classified as no-authority. Ichased a hijack — guest unregisters a host tool, registers its own — and it
does not reach: tools registered through
NodusRuntimelive in a separatePython-side registry that
tool_unregistercannot touch. A guest-registeredtool runs guest code, so registering one confers nothing. Recorded as the
group's stated reason rather than left implicit.
tool_list,syscall_list,agent_describe. Adenied
tool_callis still denied aftertool_listnames the tool; hiding thecatalogue while leaving the call ungoverned would have been the wrong half.
run_workflowgrants nothing a direct callwould not — every builtin its steps reach passes this same chokepoint. Gating
it would refuse the shape of a program rather than its authority.
CapabilityRequestand consults the floor even with no policy: ~0.8 µs percall (measured) against ~25 µs for a builtin call, and 0 for the 227
ungoverned ones since the dict lookup already happened. Skipping it would need
"which capabilities the floor rules on" stated in a second place that can drift
from the floor itself — this cluster's exact defect class. Left as is; happy to
revisit if the number bothers you.
allow_tools=switch. Making a surface visible to a policy does notimply a registration flag, and adding five would be a much larger decision
about defaults. These five are policy-only.
Found on the way
OPERATOR_OR_EMBEDDER_RUNBOOK.mdsaid theallow_*switches "default topermissive". They have denied by default since 5.0.0 — backwards for three
releases, in the document an embedder reads to configure confinement. Verified
against the current tree before touching it, and fixed with a note about which
advice written against the old default is now inverted.
Verification
20 new tests. Every behaviour assertion confirmed red against the 5.2.0 map
(11 of 14 fail; the 3 that pass are the "unchanged behaviour" guards, correctly).
ruff check src/ tests/— cleanmypy src/nodus/— cleannodus_gate --static --versions --contracts— PASS🤖 Generated with Claude Code