You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A policy's resource = "node" rules were compiled by nobody, so a denied kind ran (#77)
`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>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -24,3 +24,4 @@ Entries are newest-last within a release, matching the order they were written.
24
24
- **fan-out handed every worker the same payload object**, and never held it to the schema the worker declared. `_enter` deep-copied only a `BaseModel`, so two `Send`s built from one dict gave both parallel workers the *same live dict* — each reading the other's mutations, through a channel no node declared a write to and no trace event records, in the one place the isolation matters most. `_check_goto_target` validated `Send.node` against exactly this class of silent failure and left `Send.arg` alone, so `input_schema` — documented as typing a worker's payload — enforced nothing: a dict where a model was declared reached the worker and surfaced as a bare `AttributeError` frames away from the dispatcher that produced it, and a wrong model class sharing a field name never surfaced at all. Every payload is deep-copied now whatever its type, and one contradicting a declared `input_schema` is refused at dispatch with `StateTypeError` naming the node, the schema and what arrived. Declaring no `input_schema` stays legal — no claim, nothing to check — but the copy is unconditional.
25
25
-**the front door was the one door the state contract did not hold.**`update_state` refuses an unknown field and `GraphARCState` forbids extras, but `invoke`/`stream`/`ainvoke`/`astream` handed `input` straight to LangGraph, which filters a dict down to known channels *before* the state model is ever constructed — so `extra="forbid"` never saw the typo. `invoke({"quesiton": …})` ran the whole graph on default values and returned a complete, plausible answer to an empty question, with nothing said to the caller: the quietest failure in the runtime, on the door every user goes through first. All four entry points, and `astream_events`, now refuse an unknown input key in the same words `update_state` uses. A wrongly *typed* input value was already loud and still raises Pydantic's `ValidationError`.
26
26
- a node stopped by **Ctrl-C left no ending in the trace**. The sync wrapper caught `Exception` while its async twin catches `BaseException` for the reason its own comment gives — "a stop with no trace line is a stop nobody can audit afterwards" — so a `KeyboardInterrupt` or `SystemExit` inside a sync node escaped with no terminal `error` event, and `metrics.summarize` then reported `errors: 0` for a run an audit reads as having simply stopped between nodes. Ctrl-C is not an exotic ending; it is the commonest way a human stops a long run. The sync wrapper catches `BaseException` now and re-raises it untouched: only the record is new.
27
+
- a policy document's `resource = "node"` rules were **silently discarded**. `edge_policy()` compiled the edge half and nothing compiled the other one, `AdmissionChecker` gated node kinds on registry membership alone, and `check_node` — correct, documented, advertised in the engine's own docstring — had no runtime caller anywhere. So a document denying the kind `deploy` admitted it and ran it, and the only hint that half the file had been dropped was an oblique `1 edge rule(s)` in a line that reads as a summary. The shipped `example.toml` led with exactly that shape: an operator who copied `no-shell-nodes` got a policy that denied nothing. `PolicyEngine.node_policy()` now compiles the node half as `edge_policy()` does the edge half, `AdmissionChecker(node_policy=...)` consults it for every proposed node, and a refusal comes back as `policy/node_denied` quoting the rule's own `reason` — a code the planner replans against, exactly like `edge_denied`. A document that declares *no* node rules still leaves kinds to the registry: saying nothing about nodes is not the same statement as denying all of them, and the banner now counts both halves so a reader can tell which was said.
Copy file name to clipboardExpand all lines: README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -381,7 +381,7 @@ Three things that phrase over-promises if left alone. **An interrupt does not st
381
381
382
382
**The HTTP API is FastAPI plus SSE** — create a session, list, get, post an event, stream the trace, fetch it as NDJSON, healthz. A request may name a registered graph and supply input and a budget; it may not *describe* a graph, because topology comes from a registry the operator fills in Python. But note the seam: **it does not use the session layer above.** It ships its own in-process runtime whose sessions die with the process, never evict, and record `message` and `approval` events without delivering them into a running graph. Two session layers that have not been joined ([ROADMAP.md](ROADMAP.md) §12.3).
383
383
384
-
**Policy is a TOML document** over nodes, edges, tools and spend, with tiered evaluation — every `deny` before every `ask` before every `allow`, so a broad deny beats a narrow allow including one scoped to a single tenant. Every decision lands in an audit record naming the rule id, the reason, the policy version and a digest of the document, so a decision can be tied to the exact text that made it. And the seam, now narrowed to exactly half: **the edge half is wired and the tool half is not.** `PolicyEngine.edge_policy()` compiles the document into the `EdgePolicy` the admission checker consults, and `grapharc plan --policy` is a real caller — so what may connect to what *is* governed by a document you can read. But `permission_policy()`, `check_tool()` and `approval_router()` have no caller outside `grapharc/policy/`, so `grapharc agent` still assembles its tool gating from `--allow` / `--deny` / `--ask` globs. The most dangerous surface in the package is the one the document cannot reach yet; [issue #6](https://github.com/CodeGraphContext/GraphARC/issues/6) is that work, and the precedence question it has to settle is what happens when a flag `allow` meets a document `deny`.
384
+
**Policy is a TOML document** over nodes, edges, tools and spend, with tiered evaluation — every `deny` before every `ask` before every `allow`, so a broad deny beats a narrow allow including one scoped to a single tenant. Every decision lands in an audit record naming the rule id, the reason, the policy version and a digest of the document, so a decision can be tied to the exact text that made it. And the seam, now narrowed to the tool plane: **the planner half is wired and the tool half is not.** `PolicyEngine.edge_policy()` and `PolicyEngine.node_policy()` compile the document into the `EdgePolicy` and `NodePolicy` the admission checker consults, and `grapharc plan --policy` is a real caller — so what may run, and what may connect to what, *is* governed by a document you can read. (A `resource = "node"` rule used to be dropped by the compiler and enforced by nothing; [issue #66](https://github.com/CodeGraphContext/GraphARC/issues/66).) But `permission_policy()`, `check_tool()` and `approval_router()` have no caller outside `grapharc/policy/`, so `grapharc agent` still assembles its tool gating from `--allow` / `--deny` / `--ask` globs. The most dangerous surface in the package is the one the document cannot reach yet; [issue #6](https://github.com/CodeGraphContext/GraphARC/issues/6) is that work, and the precedence question it has to settle is what happens when a flag `allow` meets a document `deny`.
print("and about the other kind:", engine.check_node("summarise").effect.value)
1767
+
```
1768
+
1769
+
```
1770
+
status: rejected
1771
+
[policy/node_denied] helper: the node policy denies this kind: kind 'shell_exec' (proposed as 'helper'): a shell node is an unbounded tool the decision is made on the registry kind, not the name you chose: renaming the node will not change it — propose a permitted kind
1772
+
engine agrees: deny
1773
+
and about the other kind: allow
1774
+
```
1775
+
1776
+
**Why it works this way.** The registry and the node policy are two different
1777
+
questions and a kind has to pass both: the registry says a kind exists and what
1778
+
it costs — operator code, fixed at start-up — while the document says whether it
1779
+
may run here, and can be edited without touching that code. `node_policy=` is
1780
+
`None` by default, and that is not a wildcard: with no document the registry is
1781
+
the only node gate, and it is an allowlist with no wildcard either.
1782
+
1783
+
**The sharp edge.**`node_policy()` is faithful to `check_node`, so a document
1784
+
with *no* node rules and `default = "deny"` compiles to a policy that denies
1785
+
every kind. That is the same answer `check_node` gives, and it is why
1786
+
`grapharc plan --policy` compiles the node half only when the document declares
1787
+
at least one `node` rule — saying nothing about nodes is not the same statement
1788
+
as denying all of them. Compiling by hand, you decide which you meant.
1789
+
1790
+
---
1791
+
1720
1792
## What this section does not give you
1721
1793
1722
1794
Stated plainly, because a governance layer that overstates itself is worse than
@@ -1727,8 +1799,9 @@ none:
1727
1799
unchecked, and that is your gate to build.
1728
1800
2.**`parent_depth` is on your honour.** The checker cannot observe how deep the
1729
1801
run really is.
1730
-
3.**Edge approvals are not routed.**`NEEDS_APPROVAL` tells you an edge needs a
1731
-
human; nothing carries it to one. The `ApprovalRouter` handles tools.
1802
+
3.**Admission approvals are not routed.**`NEEDS_APPROVAL` tells you an edge or
1803
+
a node kind needs a human; nothing carries it to one. The `ApprovalRouter`
1804
+
handles tools.
1732
1805
4.**Cycles across the boundary are invisible.** The acyclicity check sees only
1733
1806
the topology inside the proposal.
1734
1807
5.**`known_nodes` and `Materializer` do not compose.** A proposal wired to a
@@ -1744,14 +1817,17 @@ none:
1744
1817
reassignment, but `args` is an ordinary dict whose contents can be mutated in
1745
1818
place. `fingerprint()` is what detects that, by hashing content rather than
1746
1819
trusting the reference — and `Materializer` checks it for you.
1747
-
9.**No shipped edge-policy compiler.** The TOML document's `edge` rules do not
1748
-
reach `AdmissionChecker` on their own; the bridge above is fifteen lines you
1749
-
write and this section's tests pin.
1820
+
9.**A document reaches admission only when something hands it over.** The
1821
+
compilers are shipped (`edge_policy()`, `node_policy()`) and `grapharc plan
1822
+
--policy` calls both, but an `AdmissionChecker` you build yourself is subject
1823
+
to a document only if you pass the compiled objects to it. Its `tool` and
1824
+
`spend` rules reach neither gate: those are the harness's plane.
1750
1825
10.**The spend ledger is in-process.** It does not survive a restart and is not
1751
1826
shared between processes.
1752
1827
1753
1828
The parts that *are* enforced, and that every snippet above demonstrates: a
1754
-
proposal cannot execute itself, an unregistered kind cannot run, a denied
1829
+
proposal cannot execute itself, an unregistered kind cannot run, a kind the
1830
+
document denies cannot run either, a denied
1755
1831
transition cannot be renamed into an allowed one, an over-budget plan is refused
1756
1832
before its first node exists, and every decision — yes and no alike — is a
0 commit comments