Skip to content

Commit cb41cee

Browse files
authored
fix(run): enforce read-only seat capability (#383)
1 parent 94ab1a9 commit cb41cee

10 files changed

Lines changed: 245 additions & 11 deletions

File tree

docs/seat-catalog.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,29 @@ role = "Open-weight implementation worker on the flat sub; overflow relief for t
4545

4646
Known gap: Composer models return empty text in read-only plan mode (issue #206). Pin a non-composer model for read-only seats.
4747

48+
Mark direct Cursor Composer and Grok seats as incapable so the chef cannot route
49+
`--read-only` work to them:
50+
51+
```toml
52+
[agents.composer]
53+
cli = "cursor"
54+
model = "composer-2.5"
55+
read_only_capable = false
56+
role = "Fast implementation worker for writable runs."
57+
58+
[agents.grok]
59+
cli = "cursor"
60+
model = "grok-4.5-xhigh"
61+
read_only_capable = false
62+
role = "Alternate implementation worker for writable runs."
63+
```
64+
65+
Keep those direct seats set to `false`. To use either model for read-only findings,
66+
create a separate ACP seat and set `transport = "acpx"`,
67+
`transport_version = "0.12.0"`, and `read_only_capable = true` on that seat. ACP
68+
model IDs differ from direct Cursor aliases, so copy the reviewed model ID from
69+
the [technical guide](technical-guide.md#run-a-brigade).
70+
4871
### Claude subscription
4972

5073
Two seats, two costs. The heavier model reviews. A lighter sibling takes routine passes at a fraction of the quota burn:
@@ -111,10 +134,14 @@ Several open-weight providers expose Anthropic-compatible endpoints, which means
111134
[agents.k3]
112135
cli = "claude"
113136
model = "kimi-k3"
137+
read_only_capable = false
114138
role = "Open-weight worker on a coding-plan quota; relief for orchestrator-tier work."
115139
env = { ANTHROPIC_BASE_URL = "https://api.moonshot.ai/anthropic", ANTHROPIC_AUTH_TOKEN_REF = "KIMI_API_KEY", CLAUDE_CONFIG_DIR = "/home/operator/.claude-lanes" }
116140
```
117141

142+
This proxy example opts out of read-only dispatch. Set the value to `true` only after
143+
the validation smoke below returns a usable worker result in read-only mode.
144+
118145
Overrides apply to the spawned CLI process only, `run.json` records the override names and endpoint host (never values), and a missing referenced variable fails the worker before dispatch. If the CLI echoes any resolved override value, Brigade replaces the exact value with its target name in brackets before worker text, detail, stdout, or stderr can be stored. Direct CLI seats only: acpx and codex-cloud seats manage their own environment.
119146

120147
The isolated `CLAUDE_CONFIG_DIR` is load-bearing: with the default config directory, the `claude` CLI prefers its subscription OAuth over env auth, the upstream returns 401, and the CLI retries silently, which presents as an indefinite hang.

docs/technical-guide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ a large one). Pull the model yourself (`ollama pull llama3.2:3b`) or name one
279279
`ollama list` already shows; `brigade roster doctor` warns about missing ones.
280280
`limits.timeout_seconds` is the default per-agent timeout.
281281
`agents.<name>.timeout_seconds` overrides it for one agent.
282+
`agents.<name>.read_only_capable` is an optional boolean that defaults to `true`.
283+
Set it to `false` when a seat cannot return usable output under `--read-only`.
284+
The chef sees the value in its planning prompt, plan validation rejects an incapable
285+
assignment during read-only runs, and `--worker` rejects the seat before run artifacts
286+
are created. The field does not restrict writable runs.
282287
`limits.sandbox` is optional. When set to `read-only`, `workspace-write`, or
283288
`danger-full-access`, `brigade run` uses it as the native Codex sandbox mode
284289
unless the run also passes `--sandbox`.

src/brigade/aboyeur.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
write_worker_logs as _write_worker_logs,
3838
)
3939
from .run_transport import Assignment, WorkerResult
40-
from .roster import Agent, Roster, is_cli_allowed, timeout_for, workers
40+
from .roster import Agent, Roster, is_cli_allowed, read_only_capability_error, timeout_for, workers
4141
from .route_catalog import RouteBrief, route_brief, uncovered_stages, unknown_covers
4242

4343
CODE_GRAPH_HEADING = "## Code graph context (GraphTrail, read-only)"
@@ -368,12 +368,18 @@ def build_plan_prompt(
368368
evidence: EvidenceBrief | None = None,
369369
route: RouteBrief | None = None,
370370
) -> str:
371-
worker_lines = "\n".join(f"- {agent.name}: cli={agent.cli}; role={agent.role}" for agent in workers(roster))
371+
worker_lines = "\n".join(
372+
f"- {agent.name}: cli={agent.cli}; "
373+
+ (f"read_only_capable={str(agent.read_only_capable).lower()}; " if read_only else "")
374+
+ f"role={agent.role}"
375+
for agent in workers(roster)
376+
)
372377
if not worker_lines:
373378
worker_lines = "- no workers configured"
374379

375380
note = f"\nCorrection needed: {corrective_note}\n" if corrective_note else ""
376381
policy = f"\n\n{_read_only_rules()}\n" if read_only else ""
382+
capability_rule = "- Assign only workers with read_only_capable=true.\n" if read_only else ""
377383
route_section = ""
378384
route_rule = ""
379385
if route is not None and route.attached and route.text:
@@ -395,6 +401,7 @@ def build_plan_prompt(
395401
"- Assignments in the same stage run in parallel; later stages receive earlier-stage worker results.\n"
396402
"- Omit stage only for backwards-compatible stage 1 assignments.\n"
397403
"- Assign only listed workers.\n"
404+
f"{capability_rule}"
398405
"- Use zero assignments only if no worker is useful."
399406
f"{route_rule}"
400407
f"{policy}"
@@ -571,7 +578,7 @@ def _read_only_rules() -> str:
571578
)
572579

573580

574-
def parse_plan(text: str, roster: Roster) -> list[Assignment]:
581+
def parse_plan(text: str, roster: Roster, *, read_only: bool = False) -> list[Assignment]:
575582
try:
576583
payload = _extract_json(text)
577584
except json.JSONDecodeError as exc:
@@ -601,6 +608,10 @@ def parse_plan(text: str, roster: Roster) -> list[Assignment]:
601608
raise ValueError(f"assignment references unknown worker: {worker!r}")
602609
if worker == roster.orchestrator:
603610
raise ValueError("assignment cannot target the orchestrator")
611+
if read_only:
612+
capability_error = read_only_capability_error(roster.agents[worker])
613+
if capability_error is not None:
614+
raise ValueError(capability_error)
604615
if not isinstance(subtask, str) or not subtask.strip():
605616
raise ValueError("assignment.task must be a non-empty string")
606617
raw_covers = item.get("covers", [])
@@ -824,7 +835,7 @@ def plan(
824835
_record_plan_attempt(attempts, stage="initial", result=first)
825836
raise RuntimeError(f"orchestrator failed during plan: {first.detail}")
826837
try:
827-
assignments = parse_plan(first.text, roster)
838+
assignments = parse_plan(first.text, roster, read_only=read_only)
828839
_record_plan_attempt(
829840
attempts,
830841
stage="initial",
@@ -859,7 +870,7 @@ def plan(
859870
_record_plan_attempt(attempts, stage="correction", result=second)
860871
raise RuntimeError(f"orchestrator failed during plan correction: {second.detail}") from exc
861872
try:
862-
assignments = parse_plan(second.text, roster)
873+
assignments = parse_plan(second.text, roster, read_only=read_only)
863874
_record_plan_attempt(
864875
attempts,
865876
stage="correction",
@@ -911,7 +922,7 @@ def plan(
911922
_record_plan_attempt(attempts, stage="coverage-correction", result=revised_result)
912923
return assignments
913924
try:
914-
revised = parse_plan(revised_result.text, roster)
925+
revised = parse_plan(revised_result.text, roster, read_only=read_only)
915926
except ValueError as exc:
916927
_record_plan_attempt(attempts, stage="coverage-correction", result=revised_result, parse_error=str(exc))
917928
return assignments
@@ -1838,6 +1849,7 @@ def _roster_payload(roster: Roster) -> dict[str, object]:
18381849
"role": agent.role,
18391850
"timeout_seconds": agent.timeout_seconds,
18401851
"invalid_final_fallback": agent.invalid_final_fallback,
1852+
"read_only_capable": agent.read_only_capable,
18411853
# env tables hold names and references only, never secret
18421854
# values (enforced at roster load), so persisting them for
18431855
# resume is safe.
@@ -1960,12 +1972,16 @@ def _run_payload(
19601972
return payload
19611973

19621974

1963-
def _direct_worker_error(worker: str, roster: Roster) -> str | None:
1975+
def _direct_worker_error(worker: str, roster: Roster, *, read_only: bool = False) -> str | None:
19641976
agent = roster.agents.get(worker)
19651977
if agent is None:
19661978
return f"unknown worker: {worker}"
19671979
if worker == roster.orchestrator:
19681980
return f"--worker cannot target orchestrator seat: {worker}"
1981+
if read_only:
1982+
capability_error = read_only_capability_error(agent)
1983+
if capability_error is not None:
1984+
return capability_error
19691985
if agent.cli is None:
19701986
return f"worker has no CLI adapter: {worker}"
19711987
if not is_cli_allowed(agent.cli, roster):
@@ -2181,7 +2197,7 @@ def _payload(**kwargs: Any) -> dict[str, object]:
21812197
return _run_payload(lock_workspace=lock_workspace, **kwargs)
21822198

21832199
if worker is not None:
2184-
worker_error = _direct_worker_error(worker, roster)
2200+
worker_error = _direct_worker_error(worker, roster, read_only=read_only)
21852201
if worker_error is not None:
21862202
print(f"error: {worker_error}", file=sys.stderr)
21872203
return 2

src/brigade/cli/run.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,12 @@ def dispatch(args) -> int:
306306
file=sys.stderr,
307307
)
308308
if args.worker is not None:
309-
worker_error = _direct_worker_error(args.worker, loaded_roster, roster_mod)
309+
worker_error = _direct_worker_error(
310+
args.worker,
311+
loaded_roster,
312+
roster_mod,
313+
read_only=args.read_only,
314+
)
310315
if worker_error is not None:
311316
print(f"error: {worker_error}", file=sys.stderr)
312317
return 2
@@ -766,12 +771,16 @@ def _detached_child_argv(args, *, run_cwd: Path, roster_resolution, output_dir:
766771
return argv
767772

768773

769-
def _direct_worker_error(worker: str, loaded_roster, roster_mod) -> str | None:
774+
def _direct_worker_error(worker: str, loaded_roster, roster_mod, *, read_only: bool = False) -> str | None:
770775
agent = loaded_roster.agents.get(worker)
771776
if agent is None:
772777
return f"unknown worker: {worker}"
773778
if worker == loaded_roster.orchestrator:
774779
return f"--worker cannot target orchestrator seat: {worker}"
780+
if read_only:
781+
capability_error = roster_mod.read_only_capability_error(agent)
782+
if capability_error is not None:
783+
return capability_error
775784
if agent.cli is None:
776785
return f"worker has no CLI adapter: {worker}"
777786
if not roster_mod.is_cli_allowed(agent.cli, loaded_roster):

src/brigade/roster.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class Agent:
3939
transport_version: str | None = None
4040
env: dict[str, str] | None = None
4141
invalid_final_fallback: str | None = None
42+
read_only_capable: bool = True
4243

4344

4445
@dataclass(frozen=True)
@@ -68,6 +69,12 @@ def _as_positive_number(value: object, field: str) -> float:
6869
return float(value)
6970

7071

72+
def _as_bool(value: object, field: str) -> bool:
73+
if not isinstance(value, bool):
74+
raise ValueError(f"{field} must be a boolean")
75+
return value
76+
77+
7178
def _as_sandbox(value: object) -> str | None:
7279
if value is None:
7380
return None
@@ -246,6 +253,10 @@ def load_roster(path: Path, *, resolution: RosterResolution | None = None) -> Ro
246253
invalid_final_fallback = (
247254
_as_str(fallback_raw, f"agents.{agent_name}.invalid_final_fallback") if fallback_raw is not None else None
248255
)
256+
read_only_capable = _as_bool(
257+
raw_agent.get("read_only_capable", True),
258+
f"agents.{agent_name}.read_only_capable",
259+
)
249260

250261
cli_raw = raw_agent.get("cli")
251262
has_endpoint = endpoint is not None and model is not None
@@ -301,6 +312,7 @@ def load_roster(path: Path, *, resolution: RosterResolution | None = None) -> Ro
301312
transport_version=transport_version,
302313
env=env,
303314
invalid_final_fallback=invalid_final_fallback,
315+
read_only_capable=read_only_capable,
304316
)
305317

306318
if orchestrator not in parsed_agents:
@@ -341,3 +353,9 @@ def load_roster(path: Path, *, resolution: RosterResolution | None = None) -> Ro
341353

342354
def workers(roster: Roster) -> list[Agent]:
343355
return [agent for name, agent in roster.agents.items() if name != roster.orchestrator]
356+
357+
358+
def read_only_capability_error(agent: Agent) -> str | None:
359+
if agent.read_only_capable:
360+
return None
361+
return f"worker {agent.name!r} cannot run in read-only mode: agents.{agent.name}.read_only_capable is false"

src/brigade/run_resume.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pathlib import Path
1414

1515
from . import aboyeur, agents, codex_appserver, runguard
16-
from .roster import Agent, Roster, _as_env
16+
from .roster import Agent, Roster, _as_bool, _as_env
1717

1818
_RESUMABLE_STATUSES = ("interrupted", "failed")
1919
_NONTERMINAL_RUN_STATUSES = frozenset(
@@ -55,6 +55,10 @@ def _roster_from_snapshot(snapshot: dict) -> Roster:
5555
transport_version=raw.get("transport_version"),
5656
env=_as_env(raw.get("env"), name),
5757
invalid_final_fallback=raw.get("invalid_final_fallback"),
58+
read_only_capable=_as_bool(
59+
raw.get("read_only_capable", True),
60+
f"agents.{name}.read_only_capable",
61+
),
5862
)
5963
return Roster(
6064
orchestrator=snapshot["orchestrator"],

tests/test_aboyeur.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ def _roster():
3131
)
3232

3333

34+
def _roster_with_incapable_worker():
35+
return Roster(
36+
orchestrator="chef",
37+
agents={
38+
"chef": Agent("chef", "codex", "plan and synthesize"),
39+
"coder": Agent(
40+
"coder",
41+
"cursor",
42+
"write code",
43+
model="composer-2.5",
44+
read_only_capable=False,
45+
),
46+
},
47+
max_workers=1,
48+
)
49+
50+
3451
def _timeout_roster():
3552
return Roster(
3653
orchestrator="chef",
@@ -151,6 +168,20 @@ def test_parse_plan_accepts_plain_json():
151168
assert plan == [aboyeur.Assignment(worker="coder", task="implement it")]
152169

153170

171+
def test_parse_plan_rejects_incapable_worker_only_in_read_only_mode():
172+
text = '{"assignments":[{"worker":"coder","task":"inspect it"}]}'
173+
174+
with pytest.raises(
175+
ValueError,
176+
match=r"coder.*read-only mode.*agents\.coder\.read_only_capable is false",
177+
):
178+
aboyeur.parse_plan(text, _roster_with_incapable_worker(), read_only=True)
179+
180+
assert aboyeur.parse_plan(text, _roster_with_incapable_worker()) == [
181+
aboyeur.Assignment(worker="coder", task="inspect it")
182+
]
183+
184+
154185
def test_parse_plan_accepts_staged_json_and_defaults_missing_stage():
155186
plan = aboyeur.parse_plan(
156187
json.dumps(
@@ -255,6 +286,19 @@ def test_build_plan_prompt_describes_stage_contract():
255286
assert "later stages receive earlier-stage worker results" in prompt
256287

257288

289+
def test_build_plan_prompt_exposes_read_only_capability():
290+
prompt = aboyeur.build_plan_prompt("inspect feature", _roster_with_incapable_worker(), read_only=True)
291+
292+
assert "coder: cli=cursor; read_only_capable=false; role=write code" in prompt
293+
assert "Assign only workers with read_only_capable=true" in prompt
294+
295+
296+
def test_build_plan_prompt_hides_read_only_capability_for_writable_runs():
297+
prompt = aboyeur.build_plan_prompt("build feature", _roster_with_incapable_worker())
298+
299+
assert "read_only_capable" not in prompt
300+
301+
258302
def test_worker_prompt_without_prior_context_keeps_original_contract():
259303
assignment = aboyeur.Assignment(worker="coder", task="implement it")
260304
prompt = aboyeur._worker_prompt(_roster().agents["coder"], assignment)
@@ -2290,6 +2334,37 @@ def test_roster_payload_includes_invalid_final_fallback():
22902334
assert payload["agents"]["grok_cli"]["invalid_final_fallback"] == "cursor_grok"
22912335

22922336

2337+
def test_roster_payload_includes_read_only_capability():
2338+
payload = aboyeur._roster_payload(_roster_with_incapable_worker())
2339+
2340+
assert payload["agents"]["chef"]["read_only_capable"] is True
2341+
assert payload["agents"]["coder"]["read_only_capable"] is False
2342+
2343+
2344+
def test_direct_worker_rejects_incapable_read_only_seat_before_artifacts(monkeypatch, tmp_path, capsys):
2345+
output_dir = tmp_path / "run"
2346+
monkeypatch.setattr(
2347+
aboyeur,
2348+
"code_graph_brief",
2349+
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("validation happened too late")),
2350+
)
2351+
2352+
rc = aboyeur.run(
2353+
"inspect feature",
2354+
_roster_with_incapable_worker(),
2355+
worker="coder",
2356+
read_only=True,
2357+
cwd=tmp_path,
2358+
output_dir=output_dir,
2359+
)
2360+
2361+
assert rc == 2
2362+
assert not output_dir.exists()
2363+
err = capsys.readouterr().err
2364+
assert "coder" in err
2365+
assert "agents.coder.read_only_capable is false" in err
2366+
2367+
22932368
def test_roster_payload_includes_sandbox():
22942369
roster = Roster(
22952370
orchestrator="chef",

0 commit comments

Comments
 (0)