regent-cli 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {regent_cli-0.5.0/src/regent_cli.egg-info → regent_cli-0.7.0}/PKG-INFO +7 -2
  2. {regent_cli-0.5.0 → regent_cli-0.7.0}/README.md +6 -1
  3. {regent_cli-0.5.0 → regent_cli-0.7.0}/pyproject.toml +1 -1
  4. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/__init__.py +1 -1
  5. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/activity_cli.py +98 -0
  6. regent_cli-0.7.0/src/regent/conduction/abort.py +130 -0
  7. regent_cli-0.7.0/src/regent/conduction/confine.py +79 -0
  8. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/conduction/gate.py +2 -2
  9. regent_cli-0.7.0/src/regent/conduction/hookscript.py +181 -0
  10. regent_cli-0.7.0/src/regent/conduction/loop.py +265 -0
  11. regent_cli-0.7.0/src/regent/conduction/process.py +70 -0
  12. regent_cli-0.7.0/src/regent/conduction/turn.py +505 -0
  13. regent_cli-0.7.0/src/regent/conduction/turnlog.py +216 -0
  14. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/initcmd.py +5 -2
  15. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/templates/MANIFEST.json +3 -1
  16. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/templates/skills/regent/SKILL.md +13 -1
  17. {regent_cli-0.5.0 → regent_cli-0.7.0/src/regent_cli.egg-info}/PKG-INFO +7 -2
  18. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/SOURCES.txt +13 -1
  19. regent_cli-0.7.0/tests/test_abort.py +358 -0
  20. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_activity_cli.py +2 -2
  21. regent_cli-0.7.0/tests/test_confine.py +229 -0
  22. regent_cli-0.7.0/tests/test_hookscript.py +222 -0
  23. regent_cli-0.7.0/tests/test_loop.py +175 -0
  24. regent_cli-0.7.0/tests/test_loop_cli.py +86 -0
  25. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_skills_v1.py +2 -1
  26. regent_cli-0.7.0/tests/test_turn.py +308 -0
  27. regent_cli-0.5.0/src/regent/conduction/process.py +0 -44
  28. {regent_cli-0.5.0 → regent_cli-0.7.0}/LICENSE +0 -0
  29. {regent_cli-0.5.0 → regent_cli-0.7.0}/setup.cfg +0 -0
  30. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/activity.py +0 -0
  31. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/cli.py +0 -0
  32. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/conduction/__init__.py +0 -0
  33. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/conduction/consult.py +0 -0
  34. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/conduction/evidence.py +0 -0
  35. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/doctor.py +0 -0
  36. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/__init__.py +0 -0
  37. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/audit.py +0 -0
  38. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/control.py +0 -0
  39. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/lock.py +0 -0
  40. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/stop.py +0 -0
  41. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
  42. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
  43. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
  44. {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/top_level.txt +0 -0
  45. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_activity.py +0 -0
  46. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_choreography.py +0 -0
  47. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_consult.py +0 -0
  48. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_control.py +0 -0
  49. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_doctor.py +0 -0
  50. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_gate.py +0 -0
  51. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_init_upgrade.py +0 -0
  52. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_initcmd.py +0 -0
  53. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_lock.py +0 -0
  54. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_protocol_facade.py +0 -0
  55. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_step06_fixes.py +0 -0
  56. {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_stop.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regent-cli
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Autonomous conduction and mediated adversarial deliberation between AI agents, pluggable into any project.
5
5
  Author-email: Flavio Alvim <flavioalvim@gmail.com>
6
6
  License: MIT
@@ -53,7 +53,12 @@ phase 1 mechanizes the two most error-prone sub-steps: `regent advisor consult
53
53
  --prompt-file … --artifact … --linkage …` (read-only sandboxed consultation with the
54
54
  REQ-003 §5 evidence pair generated by the command, fail-closed verdict expectation) and
55
55
  `regent gate run --command … --declared-in <plan> --artifact … --linkage …` (verbatim
56
- provenance check, process-group-killing timeout, integral output preserved).
56
+ provenance check, process-group-killing timeout, integral output preserved). Conduction
57
+ phase 2 adds `regent turn run`: one supervised, CONFINED `claude -p` turn — the agent may
58
+ only write inside a declared envelope (a PreToolUse hook denies the rest), the supervisor
59
+ proves what changed via git (blob shas vs authenticated post events) and commits through a
60
+ private index with a HEAD compare-and-swap. The agent never commits; a violation, a
61
+ tampered log or a red gate never yields a product commit.
57
62
 
58
63
  Development: `PYTHONPATH=src python3 -m unittest discover -s tests`; packaging gate:
59
64
  `bash scripts/gate-package.sh`. Canonical skill content lives in `src/regent/templates/`
@@ -37,7 +37,12 @@ phase 1 mechanizes the two most error-prone sub-steps: `regent advisor consult
37
37
  --prompt-file … --artifact … --linkage …` (read-only sandboxed consultation with the
38
38
  REQ-003 §5 evidence pair generated by the command, fail-closed verdict expectation) and
39
39
  `regent gate run --command … --declared-in <plan> --artifact … --linkage …` (verbatim
40
- provenance check, process-group-killing timeout, integral output preserved).
40
+ provenance check, process-group-killing timeout, integral output preserved). Conduction
41
+ phase 2 adds `regent turn run`: one supervised, CONFINED `claude -p` turn — the agent may
42
+ only write inside a declared envelope (a PreToolUse hook denies the rest), the supervisor
43
+ proves what changed via git (blob shas vs authenticated post events) and commits through a
44
+ private index with a HEAD compare-and-swap. The agent never commits; a violation, a
45
+ tampered log or a red gate never yields a product commit.
41
46
 
42
47
  Development: `PYTHONPATH=src python3 -m unittest discover -s tests`; packaging gate:
43
48
  `bash scripts/gate-package.sh`. Canonical skill content lives in `src/regent/templates/`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "regent-cli"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "Autonomous conduction and mediated adversarial deliberation between AI agents, pluggable into any project."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """regent — autonomous conduction and mediated adversarial deliberation between AI agents."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
@@ -25,6 +25,12 @@ _EXIT_BY_CODE = {
25
25
  "TOKEN_MISMATCH": 3, "LOCK_HELD": 3, "LOCK_SUSPECT": 3, "BUSY": 3,
26
26
  "CONFLICT": 3, "UNATTRIBUTABLE": 3, "CORRUPT_CONTROL": 4, "IO": 5,
27
27
  "ADVISOR_FAILED": 3, "ADVISOR_UNAVAILABLE": 2, "GATE_RED": 3, "PROVENANCE": 3,
28
+ "TURN_VIOLATION": 3, "TURN_TAMPERED": 3, "TURN_FAILED": 3, "NOT_ACTIVE": 2,
29
+ "STEP_MISMATCH": 2, "ARTIFACT_OUTSIDE_REGENT": 2, "STEP_ALREADY_DONE": 2,
30
+ "WORKTREE_DIRTY": 3,
31
+ "LOOP_HALTED": 3, "LOOP_ABORTED": 3, "LOOP_MAX_TURNS": 3, "LOOP_STOPPED": 2,
32
+ "LOOP_BUSY": 3, "PLAN_NOT_EXECUTABLE": 2, "LOOP_CONFLICT": 3, "LOOP_DIRTY": 3,
33
+ "LOOP_MISCONFIGURED": 2, "ABORT_PENDING": 3,
28
34
  }
29
35
 
30
36
 
@@ -102,6 +108,38 @@ def build_parser(sub) -> None:
102
108
  p.add_argument("--timeout", type=float, default=600.0)
103
109
  p.add_argument("--expect-verdict", default=None, dest="expect_verdict")
104
110
 
111
+ p_loop = sub.add_parser("loop", help="chain supervised turns over a plan")
112
+ p_loop.add_argument("--project", default=None)
113
+ loop_sub = p_loop.add_subparsers(dest="loop_command", required=True)
114
+ p = loop_sub.add_parser("run")
115
+ p.add_argument("--plan", required=True)
116
+ p.add_argument("--prompt-template", required=True, dest="prompt_template")
117
+ p.add_argument("--envelope", action="append", required=True)
118
+ p.add_argument("--gate-envelope", action="append", default=[], dest="gate_envelope")
119
+ p.add_argument("--declared-in", required=True, dest="declared_in")
120
+ p.add_argument("--artifact-dir", required=True, dest="artifact_dir")
121
+ p.add_argument("--max-turns", type=int, default=20, dest="max_turns")
122
+ p.add_argument("--timeout", type=float, default=900.0)
123
+ p.add_argument("--claude-bin", default="claude", dest="claude_bin")
124
+ p = loop_sub.add_parser("abort")
125
+ p.add_argument("--reason", required=True)
126
+
127
+ p_turn = sub.add_parser("turn", help="supervised confined production turn")
128
+ p_turn.add_argument("--project", default=None)
129
+ turn_sub = p_turn.add_subparsers(dest="turn_command", required=True)
130
+ p = turn_sub.add_parser("run")
131
+ p.add_argument("--prompt-file", required=True, dest="prompt_file")
132
+ p.add_argument("--envelope", action="append", required=True)
133
+ p.add_argument("--gate-envelope", action="append", default=[],
134
+ dest="gate_envelope")
135
+ p.add_argument("--gate-command", required=True, dest="gate_command")
136
+ p.add_argument("--declared-in", required=True, dest="declared_in")
137
+ p.add_argument("--step", required=True)
138
+ p.add_argument("--artifact-dir", required=True, dest="artifact_dir")
139
+ p.add_argument("--linkage", required=True)
140
+ p.add_argument("--timeout", type=float, default=900.0)
141
+ p.add_argument("--claude-bin", default="claude", dest="claude_bin")
142
+
105
143
  p_gate = sub.add_parser("gate", help="mechanized gate execution")
106
144
  p_gate.add_argument("--project", default=None)
107
145
  gate_sub = p_gate.add_subparsers(dest="gate_command", required=True)
@@ -160,6 +198,66 @@ def run(args, out=None) -> int:
160
198
  "verdict": result["verdict"],
161
199
  "artifact": result["artifact"]}, out)
162
200
  return _emit(result, 0, out)
201
+ if args.command == "loop":
202
+ if args.loop_command == "abort":
203
+ from .conduction.abort import AbortPending, write_abort_request
204
+ control = service.store.load()
205
+ activity = control.get("activity")
206
+ if activity is None:
207
+ return _fail("NO_ACTIVITY", {"state": "idle"}, out)
208
+ token = (activity.get("turn") or {}).get("token")
209
+ try:
210
+ req = write_abort_request(
211
+ service.state_dir, activity_id=activity["id"],
212
+ activity_epoch=activity["epoch"], turn_token=token,
213
+ reason=args.reason)
214
+ except AbortPending:
215
+ return _fail("ABORT_PENDING", {"reason": "an abort is already "
216
+ "pending"}, out)
217
+ return _emit({"ok": True, "abort_id": req["id"]}, 0, out)
218
+ from .conduction.loop import LoopError, run_loop
219
+ try:
220
+ result = run_loop(
221
+ root, plan_id=args.plan, prompt_template=Path(args.prompt_template),
222
+ envelope=args.envelope, gate_envelope=args.gate_envelope,
223
+ declared_in=Path(args.declared_in),
224
+ artifact_dir=Path(args.artifact_dir), max_turns=args.max_turns,
225
+ timeout=args.timeout, claude_bin=args.claude_bin)
226
+ except LoopError as exc:
227
+ return _fail(exc.code, exc.detail, out)
228
+ if not result["ok"]:
229
+ cond = result["stop_condition"]
230
+ code = {"HALTED": "LOOP_HALTED", "ABORTED": "LOOP_ABORTED",
231
+ "MAX_TURNS": "LOOP_MAX_TURNS", "STOPPED": "LOOP_STOPPED",
232
+ }.get(cond, cond if cond in _EXIT_BY_CODE else "LOOP_HALTED")
233
+ return _fail(code, {"stop_condition": cond, "turns": result["turns"],
234
+ "count": result["count"]}, out)
235
+ return _emit(result, 0, out)
236
+ if args.command == "turn":
237
+ from .conduction.turn import TurnError, run_turn
238
+ from .conduction.gate import ProvenanceError
239
+ from .conduction.evidence import EvidenceConflict
240
+ try:
241
+ result = run_turn(
242
+ root, prompt_file=Path(args.prompt_file),
243
+ envelope=args.envelope, gate_envelope=args.gate_envelope,
244
+ gate_command=args.gate_command,
245
+ declared_in=Path(args.declared_in), step=args.step,
246
+ artifact_dir=Path(args.artifact_dir), linkage=args.linkage,
247
+ timeout=args.timeout, claude_bin=args.claude_bin)
248
+ except TurnError as exc:
249
+ return _fail(exc.code, exc.detail, out)
250
+ except ProvenanceError as exc:
251
+ return _fail("PROVENANCE", {"reason": str(exc)}, out)
252
+ except EvidenceConflict as exc:
253
+ return _fail("CONFLICT", {"paths": exc.paths}, out)
254
+ if not result["ok"]:
255
+ code = ({"TURN_VIOLATION": "TURN_VIOLATION",
256
+ "TURN_TAMPERED": "TURN_TAMPERED",
257
+ "GATE_RED": "GATE_RED"}.get(result["outcome"], "TURN_FAILED"))
258
+ return _fail(code, {"outcome": result["outcome"],
259
+ "artifact": result["artifact"]}, out)
260
+ return _emit(result, 0, out)
163
261
  if args.command == "gate":
164
262
  from .conduction.gate import ProvenanceError, run_gate
165
263
  from .conduction.evidence import EvidenceConflict
@@ -0,0 +1,130 @@
1
+ """Durable abort-request signalling (PLAN-005 STEP-01).
2
+
3
+ A `regent loop abort` writes an abort-request into the DISPOSABLE XDG state
4
+ (outside the repo), bound to {activity_id, activity_epoch, turn_token} plus the
5
+ in-flight turn nonce. The in-flight turn's keep-alive reads it (~1s), validates
6
+ the binding, and — only if a matching turn is in flight — signals cancellation.
7
+ Honored exactly once (rename to `.claimed`); a request whose binding no longer
8
+ matches is discarded with an audit record. Creation is O_EXCL: one pending
9
+ abort at a time.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import uuid
17
+ from pathlib import Path
18
+
19
+ from ..protocol.audit import AuditLog, utcnow
20
+
21
+
22
+ class AbortPending(Exception):
23
+ """An abort-request is already pending (O_EXCL)."""
24
+
25
+
26
+ def request_path(state_dir: Path) -> Path:
27
+ return Path(state_dir) / "abort.request"
28
+
29
+
30
+ def write_abort_request(state_dir: Path, *, activity_id: str, activity_epoch: int,
31
+ turn_token: str | None, reason: str) -> dict:
32
+ state_dir = Path(state_dir)
33
+ state_dir.mkdir(parents=True, exist_ok=True)
34
+ payload = {"id": uuid.uuid4().hex, "activity_id": activity_id,
35
+ "activity_epoch": activity_epoch, "turn_token": turn_token,
36
+ "turn_nonce": _turn_in_flight(state_dir), # bind to the CURRENT turn
37
+ "reason": reason, "requested_at": utcnow()}
38
+ path = request_path(state_dir)
39
+ try:
40
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
41
+ except FileExistsError:
42
+ raise AbortPending(str(path)) from None
43
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
44
+ json.dump(payload, handle)
45
+ return payload
46
+
47
+
48
+ def write_turn_nonce(state_dir: Path, nonce: str) -> None:
49
+ """Marks a turn in flight (atomic tmp+replace)."""
50
+ target = Path(state_dir) / "turn.nonce"
51
+ tmp = target.with_name(".turn.nonce.tmp")
52
+ tmp.write_text(nonce, encoding="utf-8")
53
+ os.replace(tmp, target)
54
+
55
+
56
+ def clear_turn_nonce(state_dir: Path) -> None:
57
+ try:
58
+ (Path(state_dir) / "turn.nonce").unlink()
59
+ except OSError:
60
+ pass
61
+
62
+
63
+ def _turn_in_flight(state_dir: Path) -> str | None:
64
+ try:
65
+ return (Path(state_dir) / "turn.nonce").read_text(encoding="utf-8").strip()
66
+ except OSError:
67
+ return None
68
+
69
+
70
+ def claim_matching_abort(state_dir: Path, audit: AuditLog, *, activity_id: str,
71
+ activity_epoch: int, turn_token: str | None) -> dict | None:
72
+ """Returns and CLAIMS (rename → .claimed) a valid abort-request bound to the
73
+ current activity while a turn is in flight; discards a stale one (audited);
74
+ returns None if there is none/not honorable."""
75
+ path = request_path(state_dir)
76
+ try:
77
+ payload = json.loads(path.read_text(encoding="utf-8"))
78
+ except (OSError, ValueError):
79
+ return None
80
+ in_flight = _turn_in_flight(state_dir)
81
+ bound = (payload.get("activity_id") == activity_id
82
+ and payload.get("activity_epoch") == activity_epoch
83
+ and payload.get("turn_token") == turn_token
84
+ and payload.get("turn_nonce") is not None
85
+ and payload.get("turn_nonce") == in_flight) # SAME turn, not just any
86
+ if not bound:
87
+ _discard(path, audit, payload, "binding mismatch or wrong/absent turn nonce")
88
+ return None
89
+ claimed = path.with_name(f"abort.claimed-{payload['id']}") # unique, never clobbered
90
+ try:
91
+ os.replace(path, claimed) # single claim
92
+ except OSError:
93
+ return None
94
+ return payload
95
+
96
+
97
+ def pending_claimed(state_dir: Path) -> list[Path]:
98
+ """Unfinished aborts (claimed but not yet reconciled) — for recovery."""
99
+ return sorted(Path(state_dir).glob("abort.claimed-*"))
100
+
101
+
102
+ def clear_claimed(state_dir: Path, *, activity_id: str | None = None,
103
+ activity_epoch: int | None = None,
104
+ turn_token: str | None = None) -> None:
105
+ """Removes claimed markers. If a binding is given, ONLY markers matching it
106
+ (never unrelated aborts)."""
107
+ import json as _json
108
+ for p in pending_claimed(state_dir):
109
+ if activity_id is not None:
110
+ try:
111
+ m = _json.loads(p.read_text(encoding="utf-8"))
112
+ except (OSError, ValueError):
113
+ continue
114
+ if not (m.get("activity_id") == activity_id
115
+ and m.get("activity_epoch") == activity_epoch
116
+ and m.get("turn_token") == turn_token):
117
+ continue # a different abort — leave it
118
+ try:
119
+ p.unlink()
120
+ except OSError:
121
+ pass
122
+
123
+
124
+ def _discard(path: Path, audit: AuditLog, payload: dict, reason: str) -> None:
125
+ audit.append({"event": "abort_request_discarded", "request_id": payload.get("id"),
126
+ "reason": reason})
127
+ try:
128
+ path.unlink()
129
+ except OSError:
130
+ pass
@@ -0,0 +1,79 @@
1
+ """Confined-turn composition (PLAN-004 STEP-02).
2
+
3
+ Builds an immutable per-turn PRIVATE dir (outside the repo) with a generated
4
+ settings.json that wires the Pre/PostToolUse hooks to a private copy of
5
+ hookscript.py, and returns the `claude -p` argv + minimal environment. The
6
+ launch inherits NO config (`--setting-sources ""`), restricts tools, and forces
7
+ acceptEdits (the confinement IS the hook's deny; dontAsk would deny even
8
+ in-envelope writes — empirical IMP-003 lesson).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import secrets
16
+ import shutil
17
+ import tempfile
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ ALLOWED_TOOLS = "Read,Write,Edit,MultiEdit"
22
+ ENV_ALLOWLIST = ("PATH", "HOME", "LANG", "LC_ALL")
23
+
24
+
25
+ @dataclass
26
+ class ConfinedTurn:
27
+ private_dir: Path
28
+ settings_path: Path
29
+ event_log: Path
30
+ secret: str
31
+ envelope: list[str]
32
+
33
+ def cleanup(self) -> None:
34
+ shutil.rmtree(self.private_dir, ignore_errors=True)
35
+
36
+
37
+ def compose(*, envelope: list[str], claude_bin: str = "claude") -> ConfinedTurn:
38
+ private_dir = Path(tempfile.mkdtemp(prefix="regent-turn-"))
39
+ hook_copy = private_dir / "hookscript.py"
40
+ shutil.copy(Path(__file__).with_name("hookscript.py"), hook_copy)
41
+ event_log = private_dir / "events.log"
42
+ secret = secrets.token_hex(32)
43
+ envelope = [str(Path(p).resolve()) for p in envelope]
44
+
45
+ hook_cmd = f"{_python()} {hook_copy}"
46
+ settings = {
47
+ "hooks": {
48
+ "PreToolUse": [{"hooks": [{"type": "command", "command": hook_cmd}]}],
49
+ "PostToolUse": [{"hooks": [{"type": "command", "command": hook_cmd}]}],
50
+ }
51
+ }
52
+ settings_path = private_dir / "settings.json"
53
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
54
+ # Freeze the composition (read-only) so nothing mutates it mid-turn.
55
+ for path in (hook_copy, settings_path):
56
+ path.chmod(0o400)
57
+ return ConfinedTurn(private_dir, settings_path, event_log, secret, envelope)
58
+
59
+
60
+ def launch_argv(turn: ConfinedTurn, *, prompt: str,
61
+ claude_bin: str = "claude") -> list[str]:
62
+ return [claude_bin, "-p", prompt,
63
+ "--setting-sources", "", # inherit NOTHING
64
+ "--settings", str(turn.settings_path),
65
+ "--tools", ALLOWED_TOOLS,
66
+ "--permission-mode", "acceptEdits"]
67
+
68
+
69
+ def launch_env(turn: ConfinedTurn) -> dict[str, str]:
70
+ env = {k: os.environ[k] for k in ENV_ALLOWLIST if k in os.environ}
71
+ env["REGENT_TURN_SECRET"] = turn.secret
72
+ env["REGENT_ENVELOPE"] = json.dumps(turn.envelope)
73
+ env["REGENT_EVENT_LOG"] = str(turn.event_log)
74
+ return env
75
+
76
+
77
+ def _python() -> str:
78
+ import sys
79
+ return sys.executable or "python3"
@@ -22,7 +22,7 @@ class ProvenanceError(Exception):
22
22
 
23
23
 
24
24
  def run_gate(root: Path, *, command: str, declared_in: Path, artifact: Path,
25
- linkage: str, timeout: float = 1800.0, runner=None) -> dict:
25
+ linkage: str, timeout: float = 1800.0, runner=None, cancel=None) -> dict:
26
26
  root = Path(root)
27
27
  if not command.strip():
28
28
  raise ProvenanceError("empty gate command is never declared")
@@ -41,7 +41,7 @@ def run_gate(root: Path, *, command: str, declared_in: Path, artifact: Path,
41
41
  # ONE cleanup guard around execution + both publishes (see consult.py).
42
42
  try:
43
43
  result = runner.run(["bash", "-c", command], cwd=str(root),
44
- timeout=timeout)
44
+ timeout=timeout, cancel=cancel)
45
45
  raw = result.output_bytes
46
46
  output_bytes = len(raw)
47
47
  truncated = output_bytes > TAIL_LIMIT
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """Standalone Claude Code hook for confined turns (PLAN-004 STEP-01).
3
+
4
+ Copied into a per-turn PRIVATE dir and referenced by the generated
5
+ settings.json. Reads REGENT_ENVELOPE (JSON list of allowed write roots),
6
+ REGENT_EVENT_LOG (private append log) and REGENT_TURN_SECRET (HMAC key for the
7
+ audit chain — NOT an anti-forgery proof against the agent; see PLAN-004 trust
8
+ model) from its own environment.
9
+
10
+ Semantics (official Claude Code hooks):
11
+ - PreToolUse decides allow/deny. Write/Edit/MultiEdit are allowed ONLY if the
12
+ canonical real-path of the target is inside the envelope; every other
13
+ write/exec tool (Bash included, defense in depth) is denied; read-only tools
14
+ are allowed. A `pre` event is appended.
15
+ - PostToolUse (only fires after a successful tool) appends a `post` event with
16
+ the resulting file's content sha256, correlated by tool_use_id.
17
+ Fails CLOSED: any internal error → deny + `hook_error` event.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import fcntl
23
+ import hashlib
24
+ import hmac
25
+ import json
26
+ import os
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ WRITE_TOOLS = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
31
+ READ_ONLY_TOOLS = {"Read", "Glob", "Grep", "NotebookRead", "TodoWrite",
32
+ "WebFetch", "WebSearch", "Task"}
33
+
34
+
35
+ def _canonical(line: dict) -> str:
36
+ return json.dumps(line, sort_keys=True, separators=(",", ":"))
37
+
38
+
39
+ def _append_event(event: dict) -> None:
40
+ log_path = os.environ["REGENT_EVENT_LOG"]
41
+ secret = os.environ["REGENT_TURN_SECRET"].encode()
42
+ fd = os.open(log_path, os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600)
43
+ try:
44
+ fcntl.flock(fd, fcntl.LOCK_EX) # serialize concurrent hooks
45
+ existing = _read_last_line(log_path)
46
+ prev_hmac = existing.get("hmac", "") if existing else ""
47
+ seq = (existing.get("seq", -1) + 1) if existing else 0
48
+ event["seq"] = seq
49
+ payload = _canonical(event)
50
+ event["hmac"] = hmac.new(secret, (payload + prev_hmac).encode(),
51
+ hashlib.sha256).hexdigest()
52
+ os.write(fd, (json.dumps(event, sort_keys=True) + "\n").encode())
53
+ os.fsync(fd)
54
+ finally:
55
+ os.close(fd)
56
+
57
+
58
+ def _read_last_line(path: str) -> dict | None:
59
+ try:
60
+ lines = [l for l in Path(path).read_text(encoding="utf-8").splitlines()
61
+ if l.strip()]
62
+ except OSError:
63
+ return None
64
+ return json.loads(lines[-1]) if lines else None
65
+
66
+
67
+ def _target_paths(tool_name: str, tool_input: dict) -> list[str]:
68
+ if tool_name in ("Write", "Edit", "NotebookEdit"):
69
+ p = tool_input.get("file_path") or tool_input.get("notebook_path")
70
+ return [p] if p else []
71
+ if tool_name == "MultiEdit":
72
+ p = tool_input.get("file_path")
73
+ return [p] if p else []
74
+ return []
75
+
76
+
77
+ def _inside_envelope(path: str, envelope: list[str]) -> bool:
78
+ try:
79
+ real = Path(path).resolve()
80
+ except (OSError, RuntimeError):
81
+ return False
82
+ for root in envelope:
83
+ try:
84
+ root_real = Path(root).resolve()
85
+ except (OSError, RuntimeError):
86
+ continue
87
+ if real == root_real or root_real in real.parents:
88
+ return True
89
+ return False
90
+
91
+
92
+ def _decide(payload: dict) -> dict:
93
+ tool_name = payload.get("tool_name", "")
94
+ tool_input = payload.get("tool_input")
95
+ tool_use_id = payload.get("tool_use_id", "")
96
+ envelope = json.loads(os.environ.get("REGENT_ENVELOPE", "[]"))
97
+
98
+ # Malformed payload → deny (fail closed), even for an allowlisted tool.
99
+ if not isinstance(tool_input, dict):
100
+ _append_event({"kind": "pre", "tool": tool_name or "<unknown>",
101
+ "tool_use_id": tool_use_id, "paths": [],
102
+ "decision": "deny", "reason": "malformed tool_input"})
103
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
104
+ "permissionDecision": "deny",
105
+ "permissionDecisionReason":
106
+ "malformed tool_input (fail closed)"}}
107
+
108
+ if tool_name in WRITE_TOOLS:
109
+ targets = _target_paths(tool_name, tool_input)
110
+ allow = bool(targets) and all(_inside_envelope(p, envelope)
111
+ for p in targets)
112
+ decision = "allow" if allow else "deny"
113
+ _append_event({"kind": "pre", "tool": tool_name, "tool_use_id": tool_use_id,
114
+ "paths": targets, "decision": decision})
115
+ if allow:
116
+ return {} # allow (no explicit permission decision needed)
117
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
118
+ "permissionDecision": "deny",
119
+ "permissionDecisionReason":
120
+ "outside the turn envelope"}}
121
+ # Default-deny: only an ALLOWLIST of known read-only tools passes silently.
122
+ # Anything else — Bash/exec, an unknown/future tool, a malformed payload —
123
+ # is denied (a write/exec we do not recognize must never slip through).
124
+ if tool_name in READ_ONLY_TOOLS:
125
+ return {}
126
+ _append_event({"kind": "pre", "tool": tool_name or "<unknown>",
127
+ "tool_use_id": tool_use_id, "paths": [], "decision": "deny"})
128
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
129
+ "permissionDecision": "deny",
130
+ "permissionDecisionReason":
131
+ "tool not in the confined-turn allowlist"}}
132
+
133
+
134
+ def _post(payload: dict) -> dict:
135
+ tool_name = payload.get("tool_name", "")
136
+ tool_input = payload.get("tool_input")
137
+ if not isinstance(tool_input, dict):
138
+ return {}
139
+ tool_use_id = payload.get("tool_use_id", "")
140
+ for path in _target_paths(tool_name, tool_input):
141
+ try:
142
+ if os.path.islink(path):
143
+ digest, mode = None, None # symlink target: not attributable
144
+ else:
145
+ digest = hashlib.sha256(Path(path).read_bytes()).hexdigest()
146
+ mode = oct(os.lstat(path).st_mode & 0o777)
147
+ except OSError:
148
+ digest, mode = None, None
149
+ _append_event({"kind": "post", "tool": tool_name,
150
+ "tool_use_id": tool_use_id, "path": path,
151
+ "content_sha256": digest, "mode": mode})
152
+ return {}
153
+
154
+
155
+ def main(argv: list[str], stdin_text: str) -> int:
156
+ try:
157
+ payload = json.loads(stdin_text or "{}")
158
+ event_name = payload.get("hook_event_name") \
159
+ or (argv[1] if len(argv) > 1 else "")
160
+ if event_name == "PreToolUse":
161
+ output = _decide(payload)
162
+ elif event_name == "PostToolUse":
163
+ output = _post(payload)
164
+ else:
165
+ output = {}
166
+ if output:
167
+ sys.stdout.write(json.dumps(output))
168
+ return 0
169
+ except Exception as exc: # noqa: BLE001 — fail CLOSED
170
+ try:
171
+ _append_event({"kind": "hook_error", "error": repr(exc)})
172
+ except Exception: # noqa: BLE001
173
+ pass
174
+ sys.stdout.write(json.dumps({"hookSpecificOutput": {
175
+ "hookEventName": "PreToolUse", "permissionDecision": "deny",
176
+ "permissionDecisionReason": "hook internal error (fail closed)"}}))
177
+ return 0
178
+
179
+
180
+ if __name__ == "__main__":
181
+ raise SystemExit(main(sys.argv, sys.stdin.read()))