A policy's resource = "node" rules were compiled by nobody, so a denied kind ran - #77
Merged
Conversation
…nied kind ran
`PolicyEngine.edge_policy()` compiled the edge half of a document and nothing
compiled the other one. `AdmissionChecker` gated node kinds on `NodeRegistry`
membership alone, and `check_node` — correct, tested, and advertised in the
engine's own module docstring as the answer to "may this node run?" — had no
runtime caller anywhere in `grapharc/`. A written, valid, non-refused `deny`
rule over a node kind therefore meant nothing at all:
$ grapharc plan "fix the outage" --policy nodepolicy.toml
policy : nodepolicy.toml (tenant 'default', 1 edge rule(s))
round 1: admitted nodes=2 executed=True
state : notes=['triage ran', 'deploy ran', ...]
Fail-open, silent, and on the documented path: `grapharc/policy/example.toml`
ships `no-shell-nodes` as the canonical example of governing what may run, so an
operator who copied the shipped example got a policy that denied nothing. The
only hint that half the file had been discarded was `1 edge rule(s)` in a line
that reads as a summary rather than a warning.
**Enforced, not merely refused at load.** `NodePolicy`/`NodeRule` sit beside
`EdgePolicy`/`EdgeRule` with the same tiered semantics — every deny before every
ask before every allow, first match within a tier, unmatched takes the default —
and `PolicyEngine.node_policy(tenant=…)` compiles the node half exactly as
`edge_policy()` compiles the edge half. A test pins the compiled object to
`check_node` across a kind x tenant matrix, as the edge one already was.
`AdmissionChecker(node_policy=…)` consults it for every proposed node, in every
scope, keyed on the registry `kind` like every other node decision — so renaming
a denied instance launders nothing and naming an instance after a permitted kind
borrows nothing. A refusal is `policy/node_denied` (or `node_needs_approval`,
which reports `NEEDS_APPROVAL` exactly as the edge half does), carrying the
rule's own `reason`, under the POLICY check the planner already replans against.
**What a document that says nothing about nodes means.** `node_policy()` is
faithful to `check_node`, which means a document with no node rules and
`default = "deny"` compiles to a policy that denies every kind. That is the
right answer for the API and the wrong reading of an operator's intent, so
`grapharc plan --policy` compiles the node half only when the document declares
at least one `node` rule: saying nothing about nodes is not the same statement
as denying all of them, and the registry — an allowlist with no wildcard — is
still the gate in that case. `node_policy=None` on the checker means exactly
that, and is what every existing caller keeps.
Both halves now travel together as `GatePolicy` (`resolve_edge_policy` becomes
`resolve_policy`; `compile_policy` is the single place a document becomes
admission's objects, so `--policy`, a cached generated policy and a freshly
generated one cannot be read three different ways). The banner counts both:
`(tenant 'default', 1 edge rule(s), 2 node rule(s))`.
The repro above now refuses `deploy` with the operator's reason and replans
around it; `example.toml`'s node rules are enforced, and a test drives the
shipped document through the gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 3, 2026
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.
Fixes #66.
I took the first option in the issue — enforce the rules — rather than
refusing to compile the document. The enforcement path already existed on the
edge side and the node side needed the same three pieces (a policy object, a
compiler, a check), so the change is symmetric with code that is already there
rather than a new mechanism, and it leaves the operator with the rule they
wrote instead of an error telling them to delete it.
What was wrong
PolicyEngine.edge_policy()compiled the edge half of a document and nothingcompiled the other one.
AdmissionCheckergated node kinds onNodeRegistrymembership alone, and
check_node— correct, tested, advertised in the engine'smodule docstring as the answer to "may this node run?" — had no runtime caller
anywhere in
grapharc/. So a valid, non-refuseddenyrule over a node kindmeant nothing, on the documented path:
grapharc/policy/example.tomlshipsno-shell-nodesas the example of governing what may run.What changed
NodePolicy/NodeRule(grapharc/planner/admission.py) besideEdgePolicy/EdgeRule: same tiered semantics (every deny before every askbefore every allow, first match within a tier, unmatched takes the default),
matchis an fnmatch over a registry kind, andreasonis carried fromthe document so a refusal can quote it.
PolicyEngine.node_policy(tenant=…)compiles the node half exactly asedge_policy()compiles the edge half, including the undeclared-tenantdenial. A test pins it to
check_nodeacross a kind × tenant matrix, as theedge one already was.
AdmissionChecker(node_policy=…)checks every proposed node in everyscope, keyed on
kind. Refusals arepolicy/node_deniedandpolicy/node_needs_approval— under the existing POLICY check, so a plannerreplans against them the same way it does
edge_denied, and an ask-onlyresult still reports
NEEDS_APPROVAL.GatePolicy(grapharc/cli/plan.py) carries both halves of a documenttogether;
resolve_edge_policybecomesresolve_policy, andcompile_policyis the single place a document becomes admission's objects — so
--policy, acached generated policy and a freshly generated one cannot be read three
different ways.
grapharc planandgrapharc runpass both halves through.(tenant 'default', 1 edge rule(s), 2 node rule(s)).The one judgement call
node_policy()is faithful tocheck_node, so a document with no noderules and
default = "deny"compiles to a policy that denies every kind. Thatis the right answer for the API and the wrong reading of an operator's intent,
so the CLI compiles the node half only when the document declares at least one
noderule. Saying nothing about nodes is not the same statement as denyingall of them, and the registry — an allowlist with no wildcard — is still the
gate in that case.
node_policy=Nonemeans exactly that, and is what everyexisting caller (and every edge-only policy document) keeps.
Before / after — the issue's exact repro
Before, with the issue's
nodepolicy.tomlverbatim:After, same file:
deploy ranis gone, and thedeployrejection quotes the rule:Every kind is refused there because that document says
default = "deny"andnames only
deploy, sotriagematches no node rule and takes the default —which is exactly what
engine.check_node("triage")has always answered for it.Add the catch-all its author's default needs and the intended behaviour is what
you get:
example.tomlIts node rules are enforced rather than removed, verified by driving the shipped
document through the gate (
test_the_shipped_examples_node_rules_reach_the_admission_gate):a
shell_execkind is refused with "a shell node is an unbounded tool" andsummariseis admitted. The section gained a comment saying the rules match aregistry kind and reach the checker.
Docs
docs/cookbook/05-governance.md: new executed recipe "How do I stop a nodekind from running, from the document?"; the bridge section no longer claims
there is no shipped compiler; limitation 9 rewritten; limitation 3 now covers
node approvals. The cookbook's
policy.tomlgained areasononno-shell-nodesso the recipe shows a quoted refusal — the two printed policydigests were re-run and updated with it.
grapharc.policyandgrapharc.planner.admissiondocstrings, ROADMAP §7.5, CHANGELOG.
Verification
python -m pytest: 1802 passed, 12 deselected — twice (default randomordering, and
-p no:randomly). No failures at all, including the SIGALRMtiming tests.
ruff check .: clean.directions, nested scopes, reason quoting,
node_policy=Noneleaving theregistry as the only gate, engine/compiled equivalence across tenants, the
shipped example through the gate, the CLI repro end to end (including that
deploy ranis absent and the trace carriespolicy/node_denied), and anoperator's node rules added to a cached generated policy being honoured.
Compatibility
grapharc.cli.plan.resolve_edge_policyis nowresolve_policyand returns aGatePolicy;resolve_or_generate_policy's first element is that object ratherthan a bare
EdgePolicy(.edgeis the old value). A registry module supplyingits own
build_loopnow receivesnode_policy=— both shipped ones accept it,and a third-party one would need the keyword added.
🤖 Generated with Claude Code