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.
- {regent_cli-0.5.0/src/regent_cli.egg-info → regent_cli-0.7.0}/PKG-INFO +7 -2
- {regent_cli-0.5.0 → regent_cli-0.7.0}/README.md +6 -1
- {regent_cli-0.5.0 → regent_cli-0.7.0}/pyproject.toml +1 -1
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/__init__.py +1 -1
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/activity_cli.py +98 -0
- regent_cli-0.7.0/src/regent/conduction/abort.py +130 -0
- regent_cli-0.7.0/src/regent/conduction/confine.py +79 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/conduction/gate.py +2 -2
- regent_cli-0.7.0/src/regent/conduction/hookscript.py +181 -0
- regent_cli-0.7.0/src/regent/conduction/loop.py +265 -0
- regent_cli-0.7.0/src/regent/conduction/process.py +70 -0
- regent_cli-0.7.0/src/regent/conduction/turn.py +505 -0
- regent_cli-0.7.0/src/regent/conduction/turnlog.py +216 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/initcmd.py +5 -2
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/templates/MANIFEST.json +3 -1
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/templates/skills/regent/SKILL.md +13 -1
- {regent_cli-0.5.0 → regent_cli-0.7.0/src/regent_cli.egg-info}/PKG-INFO +7 -2
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/SOURCES.txt +13 -1
- regent_cli-0.7.0/tests/test_abort.py +358 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_activity_cli.py +2 -2
- regent_cli-0.7.0/tests/test_confine.py +229 -0
- regent_cli-0.7.0/tests/test_hookscript.py +222 -0
- regent_cli-0.7.0/tests/test_loop.py +175 -0
- regent_cli-0.7.0/tests/test_loop_cli.py +86 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_skills_v1.py +2 -1
- regent_cli-0.7.0/tests/test_turn.py +308 -0
- regent_cli-0.5.0/src/regent/conduction/process.py +0 -44
- {regent_cli-0.5.0 → regent_cli-0.7.0}/LICENSE +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/setup.cfg +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/activity.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/cli.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/conduction/__init__.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/conduction/consult.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/conduction/evidence.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/doctor.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/__init__.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/audit.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/control.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/lock.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/protocol/stop.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/top_level.txt +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_activity.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_choreography.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_consult.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_control.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_doctor.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_gate.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_init_upgrade.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_initcmd.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_lock.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_protocol_facade.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.7.0}/tests/test_step06_fixes.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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()))
|