Skip to content

runtime: a deny-everything policy now denies the surfaces it never saw (#473) - #554

Merged
Masterplanner25 merged 2 commits into
mainfrom
capability-vocabulary-473
Aug 25, 2026
Merged

Masterplanner25 merged 2 commits into
mainfrom
capability-vocabulary-473

Conversation

@Masterplanner25

Copy link
Copy Markdown
Owner

Closes #473. Closes half of #467 (see below).

Reproduced first

class DenyEverything(CapabilityPolicy):
    def check(self, request):
        self.seen.append((request.target, request.capability))
        return CapabilityDecision.deny("denied by probe")

rt = NodusRuntime(timeout_ms=None, capability_policy=DenyEverything())
rt.run_source('fn main() { memory_put("k", "leaked-value") ... }')
memory_get -> leaked-value
tool_available -> 4
policy saw: []

ALL_CAPABILITIES: ['env', 'fs.read', 'fs.write', 'network', 'subprocess']
DenyList('tool.invoke') -> ValueError unknown capability ['tool.invoke']

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_builtin consults BUILTIN_CAPABILITIES faithfully. #405 built the
enforcement 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 their
arguments
, so a policy can decide on which tool, not merely whether:

  memory_put     policy saw ('memory_put', 'memory.write')   blocked=True
  memory_get     policy saw ('memory_get', 'memory.read')    blocked=True
  tool_call      policy saw ('tool_call', 'tool.invoke')     blocked=True
  syscall        policy saw ('syscall', 'syscall')           blocked=True
  agent_call     policy saw ('agent_call', 'agent.call')     blocked=True
  read_file      policy saw ('read_file', 'fs.read')         blocked=True
  list_dir       policy saw ('list_dir', 'fs.read')          blocked=True
  path_exists    policy saw ('path_exists', 'fs.read')       blocked=True

memory.read / memory.write are 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 and enforces the
per-spec field.

action tool and action agent are governed too:

action tool     seen=[('__action_tool', 'tool.invoke')]
action agent    seen=[('__action_agent', 'agent.call')]

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.

#467, in part. FS_READ sat in ALL_CAPABILITIES from 5.0.0 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. path_join and its neighbours
stay ungoverned; they touch no filesystem. The rest of #467 — a declarative
read-only/writable split for allowed_paths — is untouched, and I've left the
issue open for it.

The part that outlives the fix

BUILTIN_CAPABILITIES | NO_AUTHORITY_BUILTIN_NAMES == BUILTIN_NAMES

NO_AUTHORITY_BUILTINS names the 227 builtins that carry no authority, grouped
by 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:

these builtins are neither governed nor declared authority-free; add each to
BUILTIN_CAPABILITIES or to a NO_AUTHORITY_BUILTINS group with a reason:
exfiltrate_everything

(Verified by injecting exactly that.) Same shape as TASK_STATUSES and
FLOW_DECLARATIONS — name the set once, let a test drive off it. "Is this
governed?" 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_unregister are classified as no-authority. I
    chased a hijack — guest unregisters a host tool, registers its own — and it
    does not reach: tools registered through NodusRuntime live in a separate
    Python-side registry that tool_unregister cannot touch. A guest-registered
    tool runs guest code, so registering one confers nothing. Recorded as the
    group's stated reason rather than left implicit.
  • Discovery is not gated. tool_list, syscall_list, agent_describe. A
    denied tool_call is still denied after tool_list names the tool; hiding the
    catalogue while leaving the call ungoverned would have been the wrong half.
  • Orchestration is not gated. run_workflow grants nothing a direct call
    would not — every builtin its steps reach passes this same chokepoint. Gating
    it would refuse the shape of a program rather than its authority.
  • No short-circuit for the no-policy case. A governed builtin builds a
    CapabilityRequest and consults the floor even with no policy: ~0.8 µs per
    call
    (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.
  • No allow_tools= switch. Making a surface visible to a policy does not
    imply 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.md 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. 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).

  • Full suite: 2,578 passed, 3 skipped
  • ruff check src/ tests/ — clean
  • mypy src/nodus/ — clean
  • nodus_gate --static --versions --contracts — PASS

🤖 Generated with Claude Code

Masterplanner25 and others added 2 commits August 24, 2026 21:14
#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
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.

CapabilityPolicy is never consulted for tool_call, syscall, agent_call or memory: a deny-everything policy denies nothing

1 participant