fix(sandbox): writable toolchain caches in the seat jail; single-seat refresh and write-scope docs

- seat-sandbox.sh: point XDG_CACHE_HOME and GOCACHE at the per-run scratch.
  The jail binds / read-only, so go build/test died on
  ~/.cache/go-build (live finding from the fleet-proofs runbook, Refs #91,
  Refs #12). Red-proof test reproduces the live error on the old wrapper.
- Document the single-seat refresh and re-home routes that OpenRig 0.7.5
  already ships (rig seat launch --fresh --stop, rig seat set-cwd), which
  the seat-heal/repair waves used live (Refs #11, Refs #78).
- New generated docs/SEAT-WRITE-SCOPES.md (scripts/seat-scopes.py) with a
  drift test against fleet/fleet.yaml (Refs #78).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VjcGdhFQxjHWWV9xxvurqs
This commit is contained in:
Claude committed 2026-10-30 21:47:04 +00:00
1 parent 0fb1741dc5
commit cc24517a5b
8 files changed
+288

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ Use `yaml.safe_dump` NEVER on `fleet/fleet.yaml` — it strips comments. Surgica
| Seat render dry-run | `HERETEK_SECRETS=~/.config/heretek-rigs/secrets python3 scripts/render-seat.py --role <id> --rig <id> --pod <id> --dry-run` |
| Seed then launch | `python3 scripts/seed-rig.py rigs/<rig>/rig.yaml [--allow-missing-secrets]` → `(cd rigs/<rig> && rig up rig.yaml --cwd <workspace> --yes)` |
| Permission-only change on running seats | `python3 scripts/seed-rig.py rigs/<rig>/rig.yaml --refresh-policy-only` (pps hot-reloads config.json and updates sandbox.env path_write roots; no relaunch, #96). Charter/model/MCP changes still need a relaunch. |
| Relaunch or re-home ONE seat | Re-seed, then `rig seat launch <seat> --fresh --stop --reason "<why>"` (blank conversation; queue/identity kept). Move a seat to a mission workspace first with `rig seat set-cwd <seat> --cwd <abs> --reason "<why>"`. `rig launch` never relaunches a running node (#11). Write-scope lookup: `docs/SEAT-WRITE-SCOPES.md` (`scripts/seat-scopes.py --write` after fleet.yaml changes). |
| Talk to a seat | `rig send '<pod>-<member>@<rig>' "<message>"` / `rig capture` / `rig transcript --tail N` |
| Queue work | `rig queue create --json --verify --destination <session> --body-file <file>` / `rig queue list --json [--destination S] [-a]` (copy qitem IDs only from `--json` output — never retype; bare queue output mixes JSON with a human trailer/advisory and MUST NOT be parsed; bare create output is advisory, not proof of persistence; bare `rig queue list`, bare `rig queue create` (no `--json --verify`), and bare `rig send` deny in-seat — always pass `--json` (`--json --verify` for create)) |
| Tracker proposals | `make reconcile TRACKER=… [OUT=…]` / `make generate-tracker TRACKER=… OUT=…` — propose-only: NEVER auto-edit trackers or write queue state; `--out` MUST be a fresh non-tracker path (refuse `*dispatch-tracker.md`, `sprints/` locations, existing files; `O_EXCL` create) — violations fail closed (exit 2). |
+6
View File
@@ -409,3 +409,9 @@
- Revert verified: `git status --short` shows only `M scripts/verify-rigs.sh`; re-ran verify → 30/0.
- `make test` (untouched, proven): `Ran 257 tests ... OK`, exit 0; Makefile `test:` runs 11 suites.
- `scripts/generate-rigs.py` NOT run (no catalog edits in this stage — nothing to re-bake).
## 2026-10-30 — #91/#12 go exec residual + #11/#78 single-seat routes (Claude follow-up)
- Live finding (fleet-proofs runbook S3): `go build`/`go test` were policy-allowed but died on `failed to initialize build cache at ~/.cache/go-build: read-only file system` because the jail binds `/` read-only. `scripts/seat-sandbox.sh` now sets `XDG_CACHE_HOME` and `GOCACHE` under the per-run scratch. Nothing persists between commands or crosses seats; each command rebuilds what it needs. Red-proof: `tests/test_seat_sandbox.py::test_go_test_runs_in_seat_scope` fails on the old wrapper with the exact live error and passes on the new one.
- Remaining go limit (by design, not fixed): `go build` of a single main package writes its binary into the cwd, which is read-only in the jail, and `-o` is denied by the go command effect. Use `go test ./...` or `go build ./...` over several packages (outputs discarded) as the compile proof. `go mod init` cannot create `go.mod` from bash (file roots bind existing files only); write it with the `write` tool first.
- #11/#78: OpenRig 0.7.5 (the pinned host toolchain) already ships `rig seat launch --fresh --stop`, `rig seat stop` and `rig seat set-cwd`, and `rig launch` help no longer promises relaunch. Seat-heal/seat-repair used the re-seed + fresh-launch route live on 24 seats. Documented in USER_GUIDE "Refreshing or re-homing one seat", AGENTS.md, and the generated `docs/SEAT-WRITE-SCOPES.md` (`scripts/seat-scopes.py`, drift-tested by `tests/test_seat_scopes.py`). `set-cwd` on a pi seat is not yet proven live.
+80
View File
@@ -0,0 +1,80 @@
# Seat write scopes
<!-- Generated by scripts/seat-scopes.py from fleet/fleet.yaml. Do not edit by hand. -->
Every seat's `path_write` globs are relative to the workspace it was launched
in (`rig up ... --cwd <workspace>`), and `external_directory` is `deny`. A
mission whose files sit outside these globs cannot be written by any seat:
the seat will block honestly, not work around it (#78).
Place mission files so each slice lands under its owning seat's globs. A new
top-level folder that no row below covers is writable by no seat.
To give a mission its own workspace without bouncing the fleet, move only
the seats it needs (OpenRig 0.7.5+; see docs/USER_GUIDE.md, "Refreshing or
re-homing one seat"):
```bash
rig seat set-cwd <seat> --cwd /abs/path/to/mission-workspace --reason "<mission>"
rig seat launch <seat> --fresh --stop --reason "<mission>: adopt workspace"
```
Inside that workspace the same relative globs apply. Fresh launch starts a
blank conversation; queue rows and seat identity are kept.
`*` in a glob also matches `/`, so `src/*` covers `src/a/b.c`. Rows marked
read-only charter write reports only; they do not author code.
| Seat | Rig | Writable (relative to the workspace) |
|---|---|---|
| `data-analytics-metrics-eng` | cloud-sre-and-data-platform | `migrations/clickhouse/*`, `analytics/*` |
| `data-cache-streaming-eng` | cloud-sre-and-data-platform | `schemas/events/*`, `src/cache/*`, `deploy/kafka/*` |
| `platform-cloud-platform-eng` | cloud-sre-and-data-platform | `terraform/*`, `infra/*` |
| `data-data-pipeline-eng` | cloud-sre-and-data-platform | `dbt/*`, `pipelines/*` |
| `data-dba-specialist` | cloud-sre-and-data-platform | `migrations/*`, `docs/db/*` |
| `platform-dependency-patcher` | cloud-sre-and-data-platform | `*package-lock.json`, `*pnpm-lock.yaml`, `package.json`, `Cargo.toml`, `Cargo.lock`, `pyproject.toml`, `go.mod`, `go.sum` |
| `platform-k8s-orchestrator` | cloud-sre-and-data-platform | `deploy/*`, `k8s/*` |
| `ml-mlops-serving-eng` | cloud-sre-and-data-platform | `deploy/mlops/*`, `tools/mlops/*` |
| `sre-observability-eng` | cloud-sre-and-data-platform | `deploy/prometheus/*`, `deploy/grafana/*`, `deploy/otel/*` |
| `sre-sre-incident-eng` | cloud-sre-and-data-platform | `docs/incidents/*`, `docs/runbooks/*` (read-only charter) |
| `ml-vector-rag-spec` | cloud-sre-and-data-platform | `src/rag/*`, `tests/rag/*` |
| `red-ai-red-teamer` | defensive-sec-and-red-team | `docs/security/ai-redteam/*` (read-only charter) |
| `blue-appsec-engineer` | defensive-sec-and-red-team | `docs/security/advisories/*` (read-only charter) |
| `red-binary-reverse-eng` | defensive-sec-and-red-team | `docs/protocols/*`, `docs/forensics/*` (read-only charter) |
| `blue-cloud-sec-auditor` | defensive-sec-and-red-team | `docs/security/cloud/*` (read-only charter) |
| `blue-compliance-auditor` | defensive-sec-and-red-team | `sbom/*`, `docs/compliance/*` (read-only charter) |
| `red-firmware-sec-analyst` | defensive-sec-and-red-team | `docs/security/firmware/*` (read-only charter) |
| `blue-threat-modeler` | defensive-sec-and-red-team | `docs/security/threat-models/*` (read-only charter) |
| `exec-exec-director` | executive-and-architecture | `docs/executive/*` (read-only charter) |
| `delivery-git-drift-coord` | executive-and-architecture | `docs/ops/*` (read-only charter) |
| `exec-product-director` | executive-and-architecture | `docs/product/*`, `docs/brief/*` (read-only charter) |
| `exec-system-architect` | executive-and-architecture | `docs/adr/*`, `docs/architecture/*` (read-only charter) |
| `delivery-tech-docs-lead` | executive-and-architecture | `docs/*`, `docs/*.md` (read-only charter) |
| `delivery-tpm-scrum-lead` | executive-and-architecture | `docs/sprints/*` (read-only charter) |
| `design-a11y-specialist` | fullstack-and-web-studio | `tests/a11y/*`, `docs/a11y/*`, `docs/design-system/*` |
| `design-brand-copy-spec` | fullstack-and-web-studio | `locales/*`, `docs/brand/*` |
| `design-design-system-eng` | fullstack-and-web-studio | `packages/ui/*`, `tokens/*`, `src/components/ui/*`, `docs/design-system/*` |
| `design-fe-perf-eng` | fullstack-and-web-studio | `*.config.js`, `*.config.ts`, `docs/perf/*` |
| `build-go-backend-eng` | fullstack-and-web-studio | `cmd/*`, `internal/*`, `pkg/*`, `go.mod`, `go.sum` |
| `build-java-kotlin-eng` | fullstack-and-web-studio | `src/main/*`, `src/test/*`, `build.gradle*`, `pom.xml` |
| `design-mobile-web-eng` | fullstack-and-web-studio | `src/styles/*`, `src/components/*`, `public/*` |
| `build-python-backend-spec` | fullstack-and-web-studio | `app/*`, `tests/*`, `pyproject.toml` |
| `build-ts-node-fullstack` | fullstack-and-web-studio | `src/*`, `package.json`, `docs/architecture/*` |
| `design-ui-ux-designer` | fullstack-and-web-studio | `src/components/*`, `src/assets/*`, `docs/design-system/*` |
| `design-ux-research-analyst` | fullstack-and-web-studio | `docs/research/*`, `docs/design-system/*` (read-only charter) |
| `engine-engine-systems-eng` | game-dev-and-sim-rig | `src/engine/*`, `tests/engine/*`, `build.sh`, `CMakePresets.json`, `CMakeLists.txt`, `build/*` |
| `gameplay-game-ai-dev` | game-dev-and-sim-rig | `src/game/ai/*`, `tests/game/ai/*` |
| `gameplay-game-systems-dev` | game-dev-and-sim-rig | `src/game/*`, `tests/game/*` |
| `engine-graphics-shader-eng` | game-dev-and-sim-rig | `src/shaders/*`, `src/rendering/*` |
| `engine-physics-sim-eng` | game-dev-and-sim-rig | `src/engine/physics/*`, `tests/engine/physics/*` |
| `gameplay-tech-artist-pipeline` | game-dev-and-sim-rig | `assets/*`, `tools/asset-pipeline/*` |
| `qa-api-contract-tester` | qa-and-verification-gauntlet | `tests/api/*`, `openapi/*` |
| `qa-e2e-browser-qa` | qa-and-verification-gauntlet | `tests/e2e/*`, `playwright.config.*` |
| `qa-load-stress-tester` | qa-and-verification-gauntlet | `tests/load/*`, `docs/perf/*` |
| `qa-release-qa-officer` | qa-and-verification-gauntlet | `docs/releases/*` (read-only charter) |
| `qa-sabotage-qa-tester` | qa-and-verification-gauntlet | `tests/chaos/*`, `docs/qa/*` |
| `qa-static-analysis-lead` | qa-and-verification-gauntlet | `*.eslintrc.json`, `*eslint.config.js`, `rules/*`, `biome.json`, `ruff.toml` |
| `mobile-android-mobile-spec` | systems-and-mobile-foundry | `app/src/*`, `build.gradle*`, `app/build.gradle*`, `docs/evidence/android-artemis/*` |
| `systems-cpp-lowlevel-eng` | systems-and-mobile-foundry | `src/*`, `include/*`, `tests/*`, `CMakeLists.txt` |
| `mobile-ios-mobile-spec` | systems-and-mobile-foundry | `ios/*`, `Sources/*`, `Tests/*`, `Package.swift` |
| `systems-rust-systems-spec` | systems-and-mobile-foundry | `crates/*`, `src/*`, `tests/*`, `Cargo.toml`, `Cargo.lock` |
+34
View File
@@ -244,6 +244,40 @@ Every seat is governed by the **Heretek Fleet Culture** (`CULTURE.md`) and `@got
`[pi-permission-system] Denied by policy: 'path_write' for tool 'write' for path 'src/auth.ts' (rule '*')`
- Instead of working around the restriction, the seat writes its advisory to `docs/security/advisories/` and escalates or hands off the code fix to its designated escalation target (`rig send <reports_to> "<message>"`) as documented in its `<agentDir>/AGENTS.md` `## Seat` block.
### Where Mission Files Must Live
Seats can only write under their own `path_write` globs, relative to the
workspace they were launched in, and every seat denies paths outside that
workspace. Check [`SEAT-WRITE-SCOPES.md`](SEAT-WRITE-SCOPES.md) (generated from
`fleet/fleet.yaml`) before placing a mission, and put each slice under the
globs of the seat that owns it. A scratch directory or a new top-level folder
is writable by no seat (#78).
### Refreshing or Re-homing One Seat
`rig launch <rig> <node>` does not relaunch a running node (#11). Use the
single-seat verbs instead; they touch only the named seat, and its queue rows,
address and identity are kept.
```bash
# Permission-only change: hot reload, no relaunch (#96)
python3 scripts/seed-rig.py rigs/<rig>/rig.yaml --refresh-policy-only
# Charter, model, MCP or skill change: re-seed, then relaunch only the
# affected seat. Check it is idle first (rig ps --nodes --json); a fresh
# launch starts a blank conversation.
python3 scripts/seed-rig.py rigs/<rig>/rig.yaml # or members/<role>.yaml --rig <rig> --pod <pod>
rig seat launch <seat> --fresh --stop --reason "refresh: <change>"
# Give one seat a mission workspace (path_write globs then apply there)
rig seat set-cwd <seat> --cwd /abs/path/to/mission-workspace --reason "<mission>"
rig seat launch <seat> --fresh --stop --reason "<mission>: adopt workspace"
```
Preview any launch with `--plan`. After a whole-fleet restart, resumed seats
can fail their first model call (`encrypted_content was not issued to this
caller`); the same `rig seat launch <seat> --fresh --stop` clears it.
### Snapshotting, Restoring, and Stopping a Rig
```bash
+7
View File
@@ -195,6 +195,11 @@ done
scratch=$(mktemp -d "$jail_tmp/seatjail.$$.XXXXXX")
# Cleaned on exit, so bwrap below must not be exec'd (exec drops the trap).
trap 'jail_rm "$scratch"' EXIT
# #91/#12: $HOME is read-only in the jail, so toolchains that default their
# cache under it (go: ~/.cache/go-build, golangci-lint and others via
# XDG_CACHE_HOME) fail before doing any work. Point them at the per-run
# scratch: nothing persists between commands or crosses seats.
mkdir -p "$scratch/cache/go-build"
# A write root may never resolve outside cwd (symlinked dir/file escape), and
# never touch agent/VCS control paths (.git, .pi, .agents, AGENTS.md).
@@ -304,6 +309,8 @@ bwrap \
--ro-bind / / --dev /dev --proc /proc \
"${broad[@]}" --ro-bind "$cwd" "$cwd" \
--bind "$scratch" "$scratch" --setenv TMPDIR "$scratch" \
--setenv XDG_CACHE_HOME "$scratch/cache" \
--setenv GOCACHE "$scratch/cache/go-build" \
"${binds[@]}" "${mask[@]}" --chdir "$cwd" \
/bin/bash -c "$cmd" <&3 3<&- &
child=$!
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Render the per-seat write-scope table (docs/SEAT-WRITE-SCOPES.md).
#78: every seat runs with `external_directory: deny` and cwd-relative
`path_write` globs, so a mission's files must sit under the globs of the
seats that will touch them. This table is the operator's lookup for that,
generated from fleet/fleet.yaml so it cannot drift silently
(tests/test_seat_scopes.py fails when the committed copy is stale).
Usage:
python3 scripts/seat-scopes.py # print to stdout
python3 scripts/seat-scopes.py --write # rewrite docs/SEAT-WRITE-SCOPES.md
python3 scripts/seat-scopes.py --check # exit 1 if the committed copy is stale
"""
import argparse
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
FLEET = ROOT / "fleet/fleet.yaml"
OUT = ROOT / "docs/SEAT-WRITE-SCOPES.md"
HEADER = """\
# Seat write scopes
<!-- Generated by scripts/seat-scopes.py from fleet/fleet.yaml. Do not edit by hand. -->
Every seat's `path_write` globs are relative to the workspace it was launched
in (`rig up ... --cwd <workspace>`), and `external_directory` is `deny`. A
mission whose files sit outside these globs cannot be written by any seat:
the seat will block honestly, not work around it (#78).
Place mission files so each slice lands under its owning seat's globs. A new
top-level folder that no row below covers is writable by no seat.
To give a mission its own workspace without bouncing the fleet, move only
the seats it needs (OpenRig 0.7.5+; see docs/USER_GUIDE.md, "Refreshing or
re-homing one seat"):
```bash
rig seat set-cwd <seat> --cwd /abs/path/to/mission-workspace --reason "<mission>"
rig seat launch <seat> --fresh --stop --reason "<mission>: adopt workspace"
```
Inside that workspace the same relative globs apply. Fresh launch starts a
blank conversation; queue rows and seat identity are kept.
`*` in a glob also matches `/`, so `src/*` covers `src/a/b.c`. Rows marked
read-only charter write reports only; they do not author code.
| Seat | Rig | Writable (relative to the workspace) |
|---|---|---|
"""
def render() -> str:
fleet = yaml.safe_load(FLEET.read_text())
roles = fleet["roles"]
rows = []
for rid, role in sorted(roles.items(), key=lambda kv: (kv[1]["rig"], kv[0])):
seat = role["session"].split("@", 1)[0]
globs = role["permissions"]["path_write"]
scope = ", ".join(f"`{g}`" for g in globs) or "_none_"
if role.get("readonly_charter"):
scope += " (read-only charter)"
rows.append(f"| `{seat}` | {role['rig']} | {scope} |\n")
return HEADER + "".join(rows)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
mode = ap.add_mutually_exclusive_group()
mode.add_argument("--write", action="store_true")
mode.add_argument("--check", action="store_true")
args = ap.parse_args()
text = render()
if args.write:
OUT.write_text(text)
elif args.check:
if not OUT.exists() or OUT.read_text() != text:
print(f"{OUT.relative_to(ROOT)} is stale: run scripts/seat-scopes.py --write", file=sys.stderr)
return 1
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+33
View File
@@ -105,6 +105,39 @@ class SeatSandboxEscapes(unittest.TestCase):
proc = self.run_jailed('echo t > "$TMPDIR/x" && echo t > /tmp/y')
self.assertEqual(proc.returncode, 0, proc.stderr)
def test_toolchain_caches_point_into_scratch(self):
"""#91/#12: go (and anything on XDG_CACHE_HOME) needs a writable
cache; it lives under the per-run scratch while $HOME stays
read-only."""
proc = self.run_jailed(
'case "$GOCACHE" in "$TMPDIR"/*) ;; *) exit 3 ;; esac; '
'case "$XDG_CACHE_HOME" in "$TMPDIR"/*) ;; *) exit 4 ;; esac; '
'test -d "$GOCACHE" && echo t > "$XDG_CACHE_HOME/x"'
)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.run_jailed('mkdir -p "$HOME/.cache/go-build"')
self.assertFalse((self.home / ".cache").exists())
def test_cache_does_not_survive_the_run(self):
d = self._jail_scratch()
proc = self.run_jailed(
'echo t > "$GOCACHE/x"', HERETEK_JAIL_SCRATCH=str(d)
)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(list(d.iterdir()), [], "seatjail cache leaked")
@unittest.skipUnless(shutil.which("go"), "go toolchain not installed")
def test_go_test_runs_in_seat_scope(self):
"""#91 live finding: `go test ./...` failed on a read-only GOCACHE."""
(self.ws / "src/go.mod").write_text("module example.com/w\n\ngo 1.21\n")
(self.ws / "src/w_test.go").write_text(
'package w\nimport "testing"\nfunc TestW(t *testing.T) {}\n'
)
# The test HOME sits under /tmp, which the jail replaces with a
# writable tmpfs; a real seat's HOME is on the read-only host root.
proc = self.run_jailed("cd src && go test ./...", HOME=str(REPO_ROOT))
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
# --- the per-command scratch dir is cleaned up ----------------------
def _jail_scratch(self):
d = Path(self.tmp.name) / "jailscratch"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""#78: docs/SEAT-WRITE-SCOPES.md must match fleet/fleet.yaml path_write."""
import subprocess
import sys
import unittest
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "scripts/seat-scopes.py"
class SeatScopesDoc(unittest.TestCase):
def test_committed_table_is_current(self):
proc = subprocess.run(
[sys.executable, str(SCRIPT), "--check"], capture_output=True, text=True
)
self.assertEqual(proc.returncode, 0, proc.stderr)
def test_every_seat_and_glob_listed(self):
doc = (REPO_ROOT / "docs/SEAT-WRITE-SCOPES.md").read_text()
roles = yaml.safe_load((REPO_ROOT / "fleet/fleet.yaml").read_text())["roles"]
self.assertEqual(len(roles), 51)
for role in roles.values():
seat = role["session"].split("@", 1)[0]
line = next((ln for ln in doc.splitlines() if ln.startswith(f"| `{seat}` |")), None)
self.assertIsNotNone(line, seat)
for glob in role["permissions"]["path_write"]:
self.assertIn(f"`{glob}`", line, seat)
if __name__ == "__main__":
unittest.main()