Skip to content

Commit 7f82217

Browse files
Ship a planning registry that reads, and let Slack name it
plan_incident proves the governance with stub bodies, so "summarise the docs in this workspace" got either an honest negative or a hollow success depending on how vague the goal was. plan_docs closes that gap for one bounded job: survey/read/summarise kinds whose bodies really read documentation — read-only, confined to the working directory, symlinks resolved and checked before a byte is read. The bodies are deterministic operator code; the model plans, it does not read. Behaviour keys on NodeBuild.kind, not the instance name the planner invented — the loop test proposes docs_survey-of-kind-survey to pin that. The scripted planner replies now come off the registry module when it ships them (incident replies against the docs catalog were five rounds of unregistered_node), so the free no-model path exercises the same registry the paid one does. From Slack, plan --registry accepts exactly the two registries the package ships and nothing else: the flag's general form imports arbitrary code, which stays refused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9ea15bd commit 7f82217

6 files changed

Lines changed: 374 additions & 8 deletions

File tree

docs/cookbook/07-slack.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ an afterthought. The defaults:
2525
| `demo`, `run`, `plan`, `models`, `replay`, `diff`, `trace`, `metrics`, `viz` | `serve` |
2626
| Paths that resolve inside the bot's working directory | Any path that escapes it (`trace ../../.env` is refused before a process spawns) |
2727
| The budget, policy and trace flags each command already has | `--registry` (imports an arbitrary module), `--config`, `--json`, `--no-color` |
28+
| `plan --registry`, for exactly the two registries the package ships | any other `--registry` value |
2829
| `agent`, only behind the double opt-in below | `--model` / `--reviewer-model`, unless the operator opts in |
2930

3031
With `--model` off, every reachable command runs the scripted, spend-free
@@ -114,6 +115,24 @@ upward-directory search that the model gateway performs is deliberately not
114115
used here: a bot that a whole workspace can drive must not discover
115116
credentials in a file the operator did not point it at.
116117

118+
## A `plan` that reads
119+
120+
The default planning registry is the incident-response demo: its node bodies
121+
are stubs, so a goal like "summarise the docs here" gets an honest negative
122+
(or, if phrased vaguely enough, a hollow success). The shipped alternative
123+
has bodies that really read — `survey` / `read` / `summarise`, read-only,
124+
confined to the bot's working directory:
125+
126+
```
127+
@grapharc plan "summarise the docs in this workspace" --registry grapharc.examples.plan_docs:build_registry --trace docs.jsonl --run-id docs-1
128+
```
129+
130+
Works with the scripted planner (free) and with `--model`; either way the
131+
notes in the final state carry actual file names, titles and excerpts,
132+
because the reading is operator code, not model output. `--registry` from
133+
Slack accepts exactly these two shipped modules and nothing else — the flag's
134+
general form imports arbitrary code, which stays refused.
135+
117136
## The `agent` opt-in
118137

119138
`agent` is the command with file tools, which is exactly why it is off by

grapharc/cli/plan.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,29 @@ def resolve_edge_policy(policy_path: Path | None, *, tenant: str) -> tuple[Any,
146146
return policy, f"{policy_path} (tenant {tenant!r}, {len(policy.rules)} edge rule(s))"
147147

148148

149-
def _model_for(spec: str | None) -> tuple[Any, str]:
150-
"""The scripted planner by default; a real backend when asked for one."""
149+
def _model_for(spec: str | None, registry_target: str = DEFAULT_REGISTRY) -> tuple[Any, str]:
150+
"""The scripted planner by default; a real backend when asked for one.
151+
152+
The scripted replies come from the registry module when it supplies
153+
`scripted_planner_replies`, because a script that proposes one registry's
154+
kinds against another registry's catalog is rejected every round — the
155+
incident replies against the docs registry produced five rounds of
156+
`unregistered_node` and a `planning_failed`. The incident module's replies
157+
stay the fallback for modules that ship none.
158+
"""
151159
if spec is None:
152-
from grapharc.examples.plan_incident import scripted_planner_replies
153160
from grapharc.testing import ScriptedChatModel
154161

155-
return ScriptedChatModel(responses=scripted_planner_replies()), "scripted"
162+
module_name = registry_target.split(":", 1)[0]
163+
try:
164+
module = importlib.import_module(module_name)
165+
except ImportError as exc:
166+
raise PlanSetupError(f"--registry {registry_target!r}: {exc}") from exc
167+
replies = getattr(module, "scripted_planner_replies", None)
168+
if replies is None:
169+
from grapharc.examples.plan_incident import scripted_planner_replies as replies
170+
171+
return ScriptedChatModel(responses=replies()), "scripted"
156172
from grapharc.gateway import get_model
157173

158174
return get_model(spec), spec
@@ -191,7 +207,7 @@ def plan(
191207
tenant = settings.resolve("tenant", tenant, "default")
192208
max_rounds = settings.resolve("max_rounds", max_rounds, 8)
193209
max_tokens = settings.resolve("max_tokens", max_tokens, 100_000)
194-
model, model_description = _model_for(model_spec)
210+
model, model_description = _model_for(model_spec, registry_target)
195211
bundle = resolve_registry(registry_target, model)
196212
registry, state_schema, writes = bundle.registry, bundle.state_schema, bundle.writes
197213
edge_policy, policy_description, policy_source = resolve_or_generate_policy(

grapharc/examples/plan_docs.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""A planning registry whose kinds can actually read — ROADMAP §12.1's sequel.
2+
3+
`plan_incident` proves the governance; its node bodies are stubs, and a goal
4+
like "summarise the docs in this workspace" gets either an honest negative or
5+
a hollow success, depending on how vague the goal is. This registry closes
6+
that gap for one bounded job: **reading documentation under the current
7+
working directory and reporting what is there.** Three kinds:
8+
9+
survey list the documentation files under cwd
10+
read read them and record an excerpt of each
11+
summarise one note distilling title + first paragraph per file
12+
13+
Everything is deterministic operator code. The model still does the
14+
*planning* — which kinds, in what order, replanning on refusal — but no node
15+
body contains a model call, so a run with a scripted planner produces real
16+
file content and a run with `--model` spends tokens only on rounds.
17+
18+
The confinement matters more than the capability: bodies read, never write,
19+
and only under `Path.cwd()` — which, launched from the Slack bot, is the
20+
bot's working directory. A proposal cannot steer them elsewhere because a
21+
proposal names kinds and carries no arguments; there is no path field to
22+
inject (the gap issue #10 tracks does not open here because these bodies
23+
take no arguments at all).
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from pathlib import Path
29+
from typing import Any
30+
31+
from pydantic import BaseModel
32+
33+
from grapharc.harness.permissions import Decision
34+
from grapharc.planner import CostEstimate, EdgePolicy, EdgeRule, NodeRegistry, NodeSpec
35+
36+
#: Read at most this many files, this much of each. Documentation, not a dump.
37+
MAX_FILES = 20
38+
MAX_CHARS = 40_000
39+
_SUFFIXES = (".md", ".txt", ".rst")
40+
_SKIP_DIRS = {".git", ".grapharc", ".venv", "__pycache__", "node_modules"}
41+
42+
43+
class DocsState(BaseModel):
44+
"""`notes` is deliberately the whole record: the loop's goal check reads it."""
45+
46+
goal: str = ""
47+
notes: list[str] = []
48+
49+
50+
def _docs_files(root: Path) -> list[Path]:
51+
"""Every documentation file under `root`, and nothing outside it."""
52+
found = []
53+
for path in sorted(root.rglob("*")):
54+
if len(found) >= MAX_FILES:
55+
break
56+
if not path.is_file() or path.suffix.lower() not in _SUFFIXES:
57+
continue
58+
if any(part in _SKIP_DIRS for part in path.parts):
59+
continue
60+
# Belt and braces: rglob cannot leave root, but a symlink can point
61+
# anywhere. Resolve and check before a single byte is read.
62+
if not path.resolve().is_relative_to(root):
63+
continue
64+
found.append(path)
65+
return found
66+
67+
68+
def _excerpt(path: Path, root: Path) -> str:
69+
text = path.read_text(encoding="utf-8", errors="replace")[:MAX_CHARS]
70+
lines = [line.strip() for line in text.splitlines()]
71+
title = next((line.lstrip("# ") for line in lines if line), path.name)
72+
body = next((line for line in lines if line and not line.startswith("#")), "")
73+
return f"{path.relative_to(root)}: {title}" + (f" — {body[:160]}" if body else "")
74+
75+
76+
def _survey_body(state: DocsState) -> dict:
77+
root = Path.cwd().resolve()
78+
files = _docs_files(root)
79+
listing = ", ".join(str(f.relative_to(root)) for f in files) or "none found"
80+
return {"notes": [*state.notes, f"survey: {len(files)} documentation file(s): {listing}"]}
81+
82+
83+
def _read_body(state: DocsState) -> dict:
84+
root = Path.cwd().resolve()
85+
notes = [f"read {f.relative_to(root)}: {_excerpt(f, root)}" for f in _docs_files(root)]
86+
return {"notes": [*state.notes, *(notes or ["read: nothing to read"])]}
87+
88+
89+
def _summarise_body(state: DocsState) -> dict:
90+
root = Path.cwd().resolve()
91+
files = _docs_files(root)
92+
if not files:
93+
summary = "summary: no documentation files under the working directory"
94+
else:
95+
parts = "; ".join(_excerpt(f, root) for f in files[:10])
96+
summary = f"summary of {len(files)} file(s): {parts}"
97+
return {"notes": [*state.notes, summary]}
98+
99+
100+
_BODIES = {"survey": _survey_body, "read": _read_body, "summarise": _summarise_body}
101+
102+
103+
def _factory(build: Any) -> Any:
104+
# `build` is the materialiser's NodeBuild: `name` is the instance the
105+
# planner chose ("readme_survey"), `kind` is what the registry licensed.
106+
# Behaviour keys on the kind; the name is the planner's business.
107+
body = _BODIES[build.kind]
108+
body.writes = {"notes"}
109+
return body
110+
111+
112+
WRITES: dict[str, set[str]] = {kind: {"notes"} for kind in _BODIES}
113+
114+
115+
def build_registry() -> NodeRegistry:
116+
"""Three read-only kinds. Absence is refusal; there is no write kind to deny."""
117+
return NodeRegistry(
118+
[
119+
NodeSpec(
120+
name="survey",
121+
description="list the documentation files under the working directory",
122+
factory=_factory,
123+
worst_case=CostEstimate(iterations=1, tokens=300),
124+
),
125+
NodeSpec(
126+
name="read",
127+
description="read each documentation file and record an excerpt",
128+
factory=_factory,
129+
worst_case=CostEstimate(iterations=1, tokens=1500),
130+
),
131+
NodeSpec(
132+
name="summarise",
133+
description="distil what was read into one summary note",
134+
factory=_factory,
135+
worst_case=CostEstimate(iterations=1, tokens=800),
136+
),
137+
]
138+
)
139+
140+
141+
def default_edge_policy() -> EdgePolicy:
142+
"""Allow every transition: nothing here mutates, so nothing needs denying."""
143+
return EdgePolicy(rules=(EdgeRule(action=Decision.ALLOW),))
144+
145+
146+
def scripted_planner_replies() -> list[str]:
147+
"""One reply: survey → read → summarise. Read by `grapharc plan` when no
148+
`--model` is given, so the free path exercises the same registry the paid
149+
one does. In an empty directory the chain still yields three notes, so the
150+
loop's goal check is satisfied either way."""
151+
import json
152+
153+
from grapharc.runtime.graph import END, START
154+
155+
chain = ["survey", "read", "summarise"]
156+
endpoints = [START, *chain, END]
157+
return [
158+
json.dumps(
159+
{
160+
"nodes": [{"name": kind} for kind in chain],
161+
"edges": [
162+
{"source": a, "target": b}
163+
for a, b in zip(endpoints, endpoints[1:], strict=False)
164+
],
165+
}
166+
)
167+
]
168+
169+
170+
STATE_SCHEMA = DocsState
171+
172+
#: Nothing in this registry writes outside run state, so the policy generator
173+
#: has nothing to deny — and saying so explicitly beats being defaulted.
174+
MUTATING_KINDS: tuple[str, ...] = ()
175+
176+
__all__ = [
177+
"MUTATING_KINDS",
178+
"STATE_SCHEMA",
179+
"WRITES",
180+
"DocsState",
181+
"build_registry",
182+
"default_edge_policy",
183+
"scripted_planner_replies",
184+
]

grapharc/slack/command.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
- **Flags are allowlisted per subcommand.** `--registry MODULE:ATTR` imports
1414
an arbitrary module on the host, `--config PATH` swaps the governing file,
1515
and `--json`/`--no-color` fight the bot's own output handling — none are
16-
reachable from Slack.
16+
reachable from Slack, with one carve-out: `plan --registry` accepts exactly
17+
the registry modules this package ships (`PLAN_REGISTRIES`), because code
18+
the wheel itself carries is the operator's, not the requester's.
1719
- **`--model` is refused unless the operator opted in**, because it reaches a
1820
paid backend. Without it every allowed command runs the scripted, spend-free
1921
path; the default answer to "can Slack cost me money?" is no.
@@ -47,11 +49,25 @@ class CommandSpec:
4749
path_positionals: frozenset[int] = frozenset()
4850
# value flags that reach a paid backend; admitted only with allow_model
4951
model_flags: frozenset[str] = frozenset()
52+
# flag -> the exact values it may take. How `--registry` stays shut against
53+
# arbitrary imports while the registries this package ships stay reachable.
54+
choice_flags: dict[str, frozenset[str]] = field(default_factory=dict)
5055

5156

5257
_BUDGET = {"--max-tokens": False, "--max-iterations": False, "--max-seconds": False}
5358
_NAMED_RUN = {"--trace": True, "--run-id": False}
5459

60+
#: The only `--registry` values `plan` accepts from Slack: the two registries
61+
#: this package ships. The flag stays refused everywhere else — its value is
62+
#: an arbitrary `module:attr` import, which is exactly what the gate exists to
63+
#: prevent — but a registry the wheel itself carries is the operator's code.
64+
PLAN_REGISTRIES = frozenset(
65+
{
66+
"grapharc.examples.plan_incident:build_registry",
67+
"grapharc.examples.plan_docs:build_registry",
68+
}
69+
)
70+
5571
ALLOWED_COMMANDS: dict[str, CommandSpec] = {
5672
"demo": CommandSpec(
5773
value_flags={"--trace": True, "--memory": True, "--memory-backend": False},
@@ -77,6 +93,7 @@ class CommandSpec:
7793
"--max-tokens": False,
7894
},
7995
model_flags=frozenset({"--model"}),
96+
choice_flags={"--registry": PLAN_REGISTRIES},
8097
),
8198
"models": CommandSpec(bool_flags=frozenset({"--check"})),
8299
"agent": CommandSpec(
@@ -188,8 +205,8 @@ def parse_command(
188205
"the operator enables it with GRAPHARC_SLACK_ALLOW_MODEL=1"
189206
)
190207
is_path = False
191-
elif flag in spec.value_flags:
192-
is_path = spec.value_flags[flag]
208+
elif flag in spec.choice_flags or flag in spec.value_flags:
209+
is_path = spec.value_flags.get(flag, False)
193210
else:
194211
raise SlackCommandError(f"`{flag}` is not allowed on `{name}` from Slack")
195212
if eq:
@@ -200,6 +217,11 @@ def parse_command(
200217
raise SlackCommandError(f"`{flag}` needs a value")
201218
value = rest[index + 1]
202219
index += 2
220+
if flag in spec.choice_flags and value not in spec.choice_flags[flag]:
221+
allowed = ", ".join(f"`{v}`" for v in sorted(spec.choice_flags[flag]))
222+
raise SlackCommandError(
223+
f"`{flag}` accepts only the shipped registries from Slack: {allowed}"
224+
)
203225
if is_path:
204226
_confined(value, workdir)
205227
argv.extend([flag, value])

0 commit comments

Comments
 (0)