Skip to content

Commit 842a405

Browse files
Merge pull request #133 from CodeGraphContext/fix/agent-policy-config
feat(policy): govern agent tools from configured documents
2 parents 9a408e2 + 99c6892 commit 842a405

13 files changed

Lines changed: 1661 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ The edges are documented, not denied — the full list with mechanisms is in the
220220
- The in-process sandbox is defense in depth; `ContainerExecutor` is the real boundary. `run_command` children are unconfined.
221221
- The HTTP API does not yet use the durable session layer.
222222
- On the Claude CLI backend an agent node is *delegated*, not governed: by default it runs under an allowlist mapped from the node's own tools, but enforcement there is Claude Code's, and the `bypass` tier — explicit opt-in — has no checks at all.
223-
- Policy documents govern planning; the tool plane still reads CLI flags.
223+
- Agent tool policies are enforced on tool-calling backends. A policy document refuses delegated Claude CLI runs; allowed tool calls are not written to the document audit.
224224
- The MCP gate binds the MCP surface, not the host: an agent with its own file tools in the run directory could forge the approval decision. The trust boundary is the working directory, as it is for the Slack workspace.
225225

226226
Version `0.1.8` · [changelog](CHANGELOG.md) · [roadmap](ROADMAP.md) · [website](https://codegraphcontext.github.io/GraphARC/) · MIT

‎ROADMAP.md‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,11 @@ The component with no prior art to copy. It exists, and the cycle runs.
324324
runner from claiming a session, and nothing reclaims one whose runner died
325325
holding it. That is a claim, not a lease.
326326

327-
## 7. Policy engine — `[~] ~75% built, 0% wired`
327+
## 7. Policy engine — `[~] node, edge and tool paths wired`
328328

329-
Everything here works and nothing calls it.
329+
The planner's admission gate and the agent CLI compile policy documents into
330+
their existing permission boundaries. Compiled rules do not audit ordinary
331+
allowances; the agent records document-driven denials and approval decisions.
330332

331333
- [x] **7.1 — Declarative policy config** over nodes, edges, tools and spend.
332334
TOML in, `PolicyEngine` out; a commented example ships at
@@ -339,7 +341,7 @@ Everything here works and nothing calls it.
339341
`engine.approval_router(handlers, tenant=…)` produces the callback a
340342
`Harness` already obeys, and `engine.permission_policy(tenant=…)` produces
341343
a real `PermissionPolicy`.
342-
- [x] **7.3 — Policy versioning and decision audit.** Every decision lands in a
344+
- [x] **7.3 — Policy versioning and decision audit.** Every direct engine check lands in a
343345
JSONL record naming the resource, subject, tenant, effect, the rule id and
344346
reason that produced it, the policy version, and a digest of the document
345347
— so a decision can be tied to the exact policy text that made it.
@@ -355,9 +357,13 @@ Everything here works and nothing calls it.
355357
longer imported by nothing. What the compiled object still cannot carry is
356358
what `permission_policy()` cannot either: the approver role and the audit
357359
record, because `EdgePolicy.decide` returns a bare `Decision`. Admission
358-
treats `ask` as not-yet-permitted. Still open: no call from `AgentNode` or
359-
`grapharc agent` to `permission_policy()`, so the tool plane is still
360-
governed by Python objects rather than by the document.
360+
treats `ask` as not-yet-permitted. `grapharc agent --policy` now compiles
361+
tool rules through `permission_policy()`, pairs document `ask` with
362+
`approval_router()`, and records document denials through `check_tool()`.
363+
Flags can narrow the document but cannot widen it. Policy and tenant
364+
follow the shared flag/environment/config precedence, and a configured
365+
policy refuses delegated execution just as an explicit one does. Ordinary
366+
allowed tool calls still have no document-audit record.
361367

362368
## 8. Memory & artifacts — `[~] ~85%`
363369

‎docs/cookbook/03-agents-and-tools.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,3 +1601,40 @@ grapharc agent --workspace ./scratch --deny 'run_command' --ask 'write_file' \
16011601

16021602
A trace lands at `<workspace>/trace.jsonl` either way; `grapharc trace`,
16031603
`grapharc metrics` and `grapharc viz` read it.
1604+
1605+
### How does a policy document govern the CLI agent?
1606+
1607+
`grapharc agent --policy policy.toml --tenant acme` applies the document's
1608+
`resource = "tool"` rules through the same harness that filters tool schemas
1609+
before the model sees them. Flags can narrow that authority: any denial wins,
1610+
then any approval requirement, and a tool runs freely only when both sides
1611+
allow it. `--allow '*'` cannot override a document denial.
1612+
1613+
Policy and tenant can also come from configuration:
1614+
1615+
```toml
1616+
# grapharc.toml
1617+
[grapharc]
1618+
policy = "policy.toml"
1619+
tenant = "acme"
1620+
```
1621+
1622+
The document must declare `acme` when it lists tenants. Resolution is
1623+
`flag > GRAPHARC_POLICY / GRAPHARC_TENANT > grapharc.toml > default`.
1624+
`--config PATH` selects another file; a relative policy path in that file is
1625+
anchored to its directory. Parent directories are not searched. This agent
1626+
config path resolves policy and tenant only; model, budgets and the other agent
1627+
options retain their CLI/Python-argument defaults.
1628+
1629+
A governed JSON result includes `config_file`, `sources`, `policy_source`, and
1630+
`policy_document` with the document's path, source, version, digest and tenant.
1631+
The human view includes the document's source beside its path. The
1632+
`policy_audit` path points to `policy-audit.jsonl` beside the trace, recording
1633+
document-driven denials and approval decisions. Ordinary allowed calls and
1634+
flag-only denials are not document-audited; attempted calls and their outcomes
1635+
remain on the run trace.
1636+
1637+
Document approval requests fail closed under `--json` or redirected stdin.
1638+
A policy selected from flags, environment or config also refuses
1639+
`--executor claude-cli` and Claude CLI models: that delegated loop cannot
1640+
enforce the document. Bad config is refused before model setup or delegation.

‎docs/deep-dive.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ Three things that phrase over-promises if left alone. **An interrupt does not st
139139

140140
**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).
141141

142-
**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`.
142+
**Policy is a TOML document** over nodes, edges, tools and spend, with tiered evaluation: every `deny` before every `ask` before every `allow`. The planner compiles node and edge rules into its admission gate. `grapharc agent --policy` compiles tool rules into the harness, combines them with CLI restrictions using `DENY > ASK > ALLOW`, and routes document approval requests through the policy's named role. An `--allow` flag cannot widen a document denial or bypass its approval requirement. JSON mode and redirected stdin refuse approval requests, and a selected policy document refuses delegated Claude CLI execution.
143+
144+
Agent policy and tenant settings follow `flag > GRAPHARC_* environment > grapharc.toml > default`, including `--config` and paths anchored to the config file. Governed results report the source, document version and digest, tenant, and policy audit path. Document-driven denials and approval decisions are audited; ordinary allowed tool calls and flag-only denials are not written to the document audit. The run trace still records attempted tool calls and their outcomes. These limits matter when assessing the remaining work in [issue #6](https://github.com/CodeGraphContext/GraphARC/issues/6).
143145

144146

145147
## Independent verification
@@ -254,7 +256,7 @@ A stable system is not one that claims to have no edges — it is one whose edge
254256
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
255257
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.
256258

257-
**Verified this pass:** `pytest` → green, 2,267 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
259+
**Verified this pass:** `pytest` → green, 2,305 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
258260

259261
[ROADMAP.md](../ROADMAP.md) tracks what is built and what is not, item by item.
260262

@@ -265,4 +267,3 @@ Defects that have been **closed** — each with what broke, how it was found and
265267

266268
Architecturally *inspired by* systems studied from public documentation: OpenClaw (policy-before-schema tool gating, file-first state, and its security post-mortems), Hermes Agent (budgeted tiered memory, ephemeral subagents), Claude Code (advisory-vs-enforced split, subagent context isolation, verification-centered loops), and OpenRouter (routing semantics, budget-scoped accounting).
267269

268-

0 commit comments

Comments
 (0)