Skip to content

Commit 03d3018

Browse files
authored
Make Boatstack independently developed and verified (#171)
1 parent 6ac168e commit 03d3018

31 files changed

Lines changed: 659 additions & 363 deletions

.github/scripts/ci-test-sharding.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,10 @@ estimated cost, place each on the currently-lightest shard.
5555
duplicated test). This invariant is unit-tested in `test_ci_shard.py`, which
5656
the `ci-policy` workflow runs — a broken partition can't merge.
5757

58-
## Keep the two copies in sync
58+
## Canonical location
5959

60-
`.github/` is control-plane and is **not** projected by labkit, so the upstream
61-
`operatorstack/intelligence-flow` monorepo carries its own copy of `ci_shard.py`
62-
and its own sharded `runtime-windows` job in
63-
`.github/workflows/boatstack-lab.yml`. When you change the controller here,
64-
mirror it there (and vice versa).
60+
This repository owns `ci_shard.py` and the sharded runtime workflow. There is no
61+
upstream mirror or external CI authority.
6562

6663
## Operational note
6764

.github/scripts/release_notes.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env python3
2+
"""Validate Boatstack's append-only release-note contract."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import re
8+
import subprocess
9+
from pathlib import Path
10+
11+
12+
NAME_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$")
13+
HEADING_PATTERN = re.compile(r"^### [^\s].+$")
14+
RELEASE_NOTES = Path("release-notes")
15+
16+
17+
def validate_release_note(path: Path) -> None:
18+
if not NAME_PATTERN.fullmatch(path.name):
19+
raise ValueError(f"{path}: name must match YYYY-MM-DD-<slug>.md")
20+
try:
21+
content = path.read_text(encoding="utf-8")
22+
except UnicodeDecodeError as error:
23+
raise ValueError(f"{path}: release note must be UTF-8") from error
24+
if not content.endswith("\n"):
25+
raise ValueError(f"{path}: release note must end with a newline")
26+
lines = content.splitlines()
27+
if not lines or not HEADING_PATTERN.fullmatch(lines[0]):
28+
raise ValueError(f"{path}: first line must be a level-three Markdown heading")
29+
if not any(line.strip() for line in lines[1:]):
30+
raise ValueError(f"{path}: release note must describe user impact")
31+
32+
33+
def validate_directory(repo: Path) -> None:
34+
root = repo / RELEASE_NOTES
35+
if not root.is_dir():
36+
raise ValueError(f"{root}: release-note directory is missing")
37+
unexpected = sorted(
38+
path for path in root.iterdir()
39+
if not path.is_file() or path.is_symlink() or path.suffix != ".md"
40+
)
41+
if unexpected:
42+
raise ValueError(f"{unexpected[0]}: only direct Markdown files are allowed")
43+
notes = sorted(root.glob("*.md"))
44+
if not notes:
45+
raise ValueError(f"{root}: at least one release note is required")
46+
for note in notes:
47+
validate_release_note(note)
48+
49+
50+
def git(repo: Path, *args: str) -> str:
51+
result = subprocess.run(
52+
["git", *args], cwd=repo, text=True, capture_output=True, check=False
53+
)
54+
if result.returncode != 0:
55+
raise ValueError(result.stderr.strip() or f"git {' '.join(args)} failed")
56+
return result.stdout.strip()
57+
58+
59+
def check_policy(repo: Path, base: str, head: str) -> None:
60+
output = git(repo, "diff", "--name-status", "--no-renames", base, head)
61+
changes: list[tuple[str, Path]] = []
62+
for line in output.splitlines():
63+
if line:
64+
status, value = line.split("\t", 1)
65+
changes.append((status, Path(value)))
66+
if not changes:
67+
return
68+
note_changes = [
69+
(status, path)
70+
for status, path in changes
71+
if path.is_relative_to(RELEASE_NOTES)
72+
]
73+
rewritten = [f"{status}\t{path}" for status, path in note_changes if status != "A"]
74+
if rewritten:
75+
raise ValueError(
76+
"release notes are append-only; add a correction fragment instead:\n "
77+
+ "\n ".join(rewritten)
78+
)
79+
added = [repo / path for status, path in note_changes if status == "A"]
80+
if not added:
81+
raise ValueError("Boatstack changes require a new file under release-notes/")
82+
for note in sorted(added):
83+
validate_release_note(note)
84+
85+
86+
def preflight(repo: Path, remote: str, base_branch: str, head: str) -> None:
87+
dirty = git(repo, "status", "--porcelain", "--untracked-files=all")
88+
if dirty:
89+
raise ValueError("commit or remove uncommitted changes before preflight")
90+
git(repo, "fetch", "--quiet", remote, base_branch)
91+
check_policy(repo, f"refs/remotes/{remote}/{base_branch}", head)
92+
93+
94+
def main() -> int:
95+
parser = argparse.ArgumentParser(description=__doc__)
96+
subparsers = parser.add_subparsers(dest="command", required=True)
97+
validate = subparsers.add_parser("validate")
98+
validate.add_argument("--repo", type=Path, default=Path("."))
99+
check = subparsers.add_parser("check-policy")
100+
check.add_argument("--repo", type=Path, required=True)
101+
check.add_argument("--base", required=True)
102+
check.add_argument("--head", required=True)
103+
before = subparsers.add_parser("preflight")
104+
before.add_argument("--repo", type=Path, required=True)
105+
before.add_argument("--remote", default="origin")
106+
before.add_argument("--base-branch", default="main")
107+
before.add_argument("--head", default="HEAD")
108+
args = parser.parse_args()
109+
try:
110+
repo = args.repo.resolve()
111+
validate_directory(repo)
112+
if args.command == "check-policy":
113+
check_policy(repo, args.base, args.head)
114+
elif args.command == "preflight":
115+
preflight(repo, args.remote, args.base_branch, args.head)
116+
except ValueError as error:
117+
print(f"BLOCKED: {error}")
118+
return 1
119+
print("PASS: Boatstack release-note contract is satisfied")
120+
return 0
121+
122+
123+
if __name__ == "__main__":
124+
raise SystemExit(main())
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""End-to-end evaluation of Detached Supervision.
2+
3+
Unlike the Go unit conformance tests, this harness builds the real
4+
``boatstack-helper`` binary once and drives it against actual scratch git
5+
repositories — attach, activate, guard, and detach — asserting at every step that
6+
the plant/controller boundary holds: no Boatstack-owned file ever lands in the
7+
target repository or its ``.git``, and the developer's own host config is never
8+
clobbered. It is the "actually set up repos and evaluate the system works" check.
9+
10+
Run (from repo root):
11+
python -m unittest discover -s labs/12-product-engineering-loop/tests -p 'test_*.py'
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
import os
18+
import subprocess
19+
import tempfile
20+
import unittest
21+
from pathlib import Path
22+
23+
24+
REPO = Path(__file__).resolve().parents[2]
25+
SKILL = REPO / "boatstack"
26+
27+
FORBIDDEN_IN_REPO = [
28+
".product-loop",
29+
".boatstack-project.json",
30+
".claude",
31+
".cursor",
32+
".codex",
33+
".gemini",
34+
".agents",
35+
".github/PULL_REQUEST_TEMPLATE/boatstack.md",
36+
]
37+
38+
DESTRUCTIVE_EVENT = json.dumps(
39+
{
40+
"hook_event_name": "PreToolUse",
41+
"tool_name": "Bash",
42+
"tool_input": {"command": "git reset --hard HEAD~1"},
43+
}
44+
)
45+
46+
47+
class DetachedSupervisionEndToEnd(unittest.TestCase):
48+
@classmethod
49+
def setUpClass(cls) -> None:
50+
cls.build_temp = tempfile.TemporaryDirectory()
51+
cls.binary = Path(cls.build_temp.name) / (
52+
"boatstack-helper.exe" if os.name == "nt" else "boatstack-helper"
53+
)
54+
env = dict(os.environ)
55+
env["GOCACHE"] = str(Path(cls.build_temp.name) / "go-cache")
56+
env["GOMODCACHE"] = str(Path(cls.build_temp.name) / "go-mod")
57+
result = subprocess.run(
58+
["go", "build", "-o", str(cls.binary), "./cmd/boatstack-helper"],
59+
cwd=SKILL,
60+
env=env,
61+
text=True,
62+
capture_output=True,
63+
)
64+
if result.returncode != 0:
65+
raise RuntimeError(result.stdout + result.stderr)
66+
67+
@classmethod
68+
def tearDownClass(cls) -> None:
69+
cls.build_temp.cleanup()
70+
71+
def setUp(self) -> None:
72+
self.work = tempfile.TemporaryDirectory()
73+
self.addCleanup(self.work.cleanup)
74+
base = Path(self.work.name)
75+
self.state_root = base / "state"
76+
self.user_root = base / "user"
77+
self.repo = base / "app"
78+
for path in (self.state_root, self.user_root, self.repo):
79+
path.mkdir()
80+
self._git("init", "-b", "main")
81+
self._git("config", "user.name", "Boatstack Test")
82+
self._git("config", "user.email", "boatstack@example.invalid")
83+
self._git("remote", "add", "origin", "https://github.com/acme/app.git")
84+
(self.repo / "README.md").write_text("# app\n")
85+
(self.repo / "go.mod").write_text("module app\n\ngo 1.22\n")
86+
self._git("add", ".")
87+
self._git("commit", "-m", "init")
88+
89+
# --- helpers -------------------------------------------------------------
90+
91+
def _git(self, *args: str) -> subprocess.CompletedProcess[str]:
92+
result = subprocess.run(
93+
["git", "-C", str(self.repo), *args], text=True, capture_output=True
94+
)
95+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
96+
return result
97+
98+
def _env(self) -> dict:
99+
env = dict(os.environ)
100+
env["BOATSTACK_STATE_ROOT"] = str(self.state_root)
101+
env["BOATSTACK_USER_CONFIG_ROOT"] = str(self.user_root)
102+
return env
103+
104+
def run_helper(self, *args: object, expected: int = 0, stdin: str | None = None):
105+
result = subprocess.run(
106+
[str(self.__class__.binary), *map(str, args)],
107+
cwd=str(self.repo),
108+
text=True,
109+
capture_output=True,
110+
input=stdin,
111+
env=self._env(),
112+
)
113+
self.assertEqual(
114+
result.returncode, expected, f"{args}\nSTDOUT:{result.stdout}\nSTDERR:{result.stderr}"
115+
)
116+
return result
117+
118+
def helper_json(self, *args: object) -> dict:
119+
return json.loads(self.run_helper(*args).stdout)
120+
121+
def porcelain(self) -> str:
122+
return subprocess.run(
123+
["git", "-C", str(self.repo), "status", "--porcelain=v1", "--untracked-files=all"],
124+
text=True,
125+
capture_output=True,
126+
).stdout.strip()
127+
128+
def assert_repo_uncontaminated(self) -> None:
129+
for forbidden in FORBIDDEN_IN_REPO:
130+
self.assertFalse(
131+
(self.repo / forbidden).exists(),
132+
f"Boatstack file leaked into the repo: {forbidden}",
133+
)
134+
135+
# --- tests ---------------------------------------------------------------
136+
137+
def test_attach_leaves_repository_pristine_and_state_external(self) -> None:
138+
before = self.porcelain()
139+
result = self.helper_json("attach", "--repo", ".", "--mode", "detached")
140+
self.assertEqual(result["verification_status"], "VERIFIED")
141+
142+
self.assertEqual(self.porcelain(), before, "attach changed the working tree")
143+
self.assert_repo_uncontaminated()
144+
145+
control_root = Path(result["control_root"])
146+
self.assertTrue((control_root / ".product-loop" / "project.json").exists())
147+
self.assertTrue((control_root / "binding.json").exists())
148+
self.assertTrue((self.state_root / "boatstack" / "registry.json").exists())
149+
# The external shared runtime slot was populated so the guard has a helper.
150+
runtimes = self.state_root / "boatstack" / "runtimes"
151+
self.assertTrue(runtimes.exists() and any(runtimes.rglob("boatstack-helper*")))
152+
153+
status = self.helper_json("detached-status", "--repo", ".")
154+
self.assertTrue(status["attached"] and status["verified"])
155+
156+
def test_activate_installs_guard_preserving_user_hooks(self) -> None:
157+
self.run_helper("attach", "--repo", ".", "--mode", "detached")
158+
159+
claude_config = self.user_root / ".claude" / "settings.json"
160+
claude_config.parent.mkdir(parents=True, exist_ok=True)
161+
claude_config.write_text(
162+
json.dumps(
163+
{
164+
"theme": "dark",
165+
"hooks": {
166+
"PreToolUse": [
167+
{"matcher": "Bash", "hooks": [{"type": "command", "command": "my-own.sh"}]}
168+
]
169+
},
170+
}
171+
)
172+
)
173+
174+
installed = self.helper_json("activate", "--repo", ".")
175+
self.assertEqual(installed["verification_status"], "VERIFIED")
176+
177+
text = claude_config.read_text()
178+
self.assertIn("my-own.sh", text)
179+
self.assertIn("ambient-safety-hook", text)
180+
self.assertIn("theme", text)
181+
182+
# Idempotent: re-activating changes nothing.
183+
again = self.helper_json("activate", "--repo", ".", "--host", "claude")
184+
self.assertTrue(all(host["action"] == "unchanged" for host in again["hosts"]))
185+
186+
# Deactivate removes only the ambient guard.
187+
self.run_helper("deactivate", "--repo", ".", "--host", "claude")
188+
after = claude_config.read_text()
189+
self.assertNotIn("ambient-safety-hook", after)
190+
self.assertIn("my-own.sh", after)
191+
192+
def test_ambient_guard_enforces_managed_and_noops_unmanaged(self) -> None:
193+
# Unattached: the developer-level guard must not control this repository.
194+
unmanaged = self.run_helper("ambient-safety-hook", "--host", "claude", "--repo", ".", stdin=DESTRUCTIVE_EVENT)
195+
self.assertNotIn('"permissionDecision":"deny"', unmanaged.stdout)
196+
197+
# Attached: the same destructive command is denied by the same engine.
198+
self.run_helper("attach", "--repo", ".", "--mode", "detached")
199+
managed = self.run_helper("ambient-safety-hook", "--host", "claude", "--repo", ".", stdin=DESTRUCTIVE_EVENT)
200+
self.assertIn('"permissionDecision":"deny"', managed.stdout)
201+
202+
def test_detached_work_keeps_repo_product_only(self) -> None:
203+
self.run_helper("attach", "--repo", ".", "--mode", "detached")
204+
205+
context = self.helper_json("context", "--repo", ".", "--operation", "build", "--host", "claude")
206+
self.assertEqual(context["mode"], "detached")
207+
self.assertTrue(context["attached"])
208+
self.assertNotEqual(context.get("next_operation", ""), "")
209+
210+
# Boatstack operations are read-only against the plant: the repo is pristine.
211+
self.assertEqual(self.porcelain(), "")
212+
self.assert_repo_uncontaminated()
213+
214+
# The only change that ever appears in the repo is product work.
215+
(self.repo / "feature.txt").write_text("product work\n")
216+
self.assertEqual(self.porcelain(), "?? feature.txt")
217+
218+
def test_detach_removes_external_state_and_restores_embedded(self) -> None:
219+
attached = self.helper_json("attach", "--repo", ".", "--mode", "detached")
220+
control_root = Path(attached["control_root"])
221+
self.assertTrue(control_root.exists())
222+
223+
removed = self.helper_json("detach", "--repo", ".")
224+
self.assertEqual(removed["verification_status"], "VERIFIED")
225+
self.assertTrue(removed["state_removed"])
226+
self.assertFalse(control_root.exists())
227+
228+
status = self.helper_json("detached-status", "--repo", ".")
229+
self.assertFalse(status["attached"])
230+
self.assert_repo_uncontaminated()
231+
232+
233+
if __name__ == "__main__":
234+
unittest.main()

0 commit comments

Comments
 (0)