regent-cli 0.5.0__tar.gz → 0.6.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.6.0}/PKG-INFO +7 -2
- {regent_cli-0.5.0 → regent_cli-0.6.0}/README.md +6 -1
- {regent_cli-0.5.0 → regent_cli-0.6.0}/pyproject.toml +1 -1
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/__init__.py +1 -1
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/activity_cli.py +44 -0
- regent_cli-0.6.0/src/regent/conduction/confine.py +79 -0
- regent_cli-0.6.0/src/regent/conduction/hookscript.py +181 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/process.py +3 -2
- regent_cli-0.6.0/src/regent/conduction/turn.py +389 -0
- regent_cli-0.6.0/src/regent/conduction/turnlog.py +216 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/initcmd.py +5 -2
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/templates/MANIFEST.json +2 -1
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/templates/skills/regent/SKILL.md +9 -1
- {regent_cli-0.5.0 → regent_cli-0.6.0/src/regent_cli.egg-info}/PKG-INFO +7 -2
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/SOURCES.txt +8 -1
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_activity_cli.py +2 -2
- regent_cli-0.6.0/tests/test_confine.py +229 -0
- regent_cli-0.6.0/tests/test_hookscript.py +222 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_skills_v1.py +2 -1
- regent_cli-0.6.0/tests/test_turn.py +308 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/LICENSE +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/setup.cfg +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/activity.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/cli.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/__init__.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/consult.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/evidence.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/gate.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/doctor.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/__init__.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/audit.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/control.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/lock.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/stop.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/top_level.txt +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_activity.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_choreography.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_consult.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_control.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_doctor.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_gate.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_init_upgrade.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_initcmd.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_lock.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_protocol_facade.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_step06_fixes.py +0 -0
- {regent_cli-0.5.0 → regent_cli-0.6.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.6.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.6.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,9 @@ _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,
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
|
|
@@ -102,6 +105,22 @@ def build_parser(sub) -> None:
|
|
|
102
105
|
p.add_argument("--timeout", type=float, default=600.0)
|
|
103
106
|
p.add_argument("--expect-verdict", default=None, dest="expect_verdict")
|
|
104
107
|
|
|
108
|
+
p_turn = sub.add_parser("turn", help="supervised confined production turn")
|
|
109
|
+
p_turn.add_argument("--project", default=None)
|
|
110
|
+
turn_sub = p_turn.add_subparsers(dest="turn_command", required=True)
|
|
111
|
+
p = turn_sub.add_parser("run")
|
|
112
|
+
p.add_argument("--prompt-file", required=True, dest="prompt_file")
|
|
113
|
+
p.add_argument("--envelope", action="append", required=True)
|
|
114
|
+
p.add_argument("--gate-envelope", action="append", default=[],
|
|
115
|
+
dest="gate_envelope")
|
|
116
|
+
p.add_argument("--gate-command", required=True, dest="gate_command")
|
|
117
|
+
p.add_argument("--declared-in", required=True, dest="declared_in")
|
|
118
|
+
p.add_argument("--step", required=True)
|
|
119
|
+
p.add_argument("--artifact-dir", required=True, dest="artifact_dir")
|
|
120
|
+
p.add_argument("--linkage", required=True)
|
|
121
|
+
p.add_argument("--timeout", type=float, default=900.0)
|
|
122
|
+
p.add_argument("--claude-bin", default="claude", dest="claude_bin")
|
|
123
|
+
|
|
105
124
|
p_gate = sub.add_parser("gate", help="mechanized gate execution")
|
|
106
125
|
p_gate.add_argument("--project", default=None)
|
|
107
126
|
gate_sub = p_gate.add_subparsers(dest="gate_command", required=True)
|
|
@@ -160,6 +179,31 @@ def run(args, out=None) -> int:
|
|
|
160
179
|
"verdict": result["verdict"],
|
|
161
180
|
"artifact": result["artifact"]}, out)
|
|
162
181
|
return _emit(result, 0, out)
|
|
182
|
+
if args.command == "turn":
|
|
183
|
+
from .conduction.turn import TurnError, run_turn
|
|
184
|
+
from .conduction.gate import ProvenanceError
|
|
185
|
+
from .conduction.evidence import EvidenceConflict
|
|
186
|
+
try:
|
|
187
|
+
result = run_turn(
|
|
188
|
+
root, prompt_file=Path(args.prompt_file),
|
|
189
|
+
envelope=args.envelope, gate_envelope=args.gate_envelope,
|
|
190
|
+
gate_command=args.gate_command,
|
|
191
|
+
declared_in=Path(args.declared_in), step=args.step,
|
|
192
|
+
artifact_dir=Path(args.artifact_dir), linkage=args.linkage,
|
|
193
|
+
timeout=args.timeout, claude_bin=args.claude_bin)
|
|
194
|
+
except TurnError as exc:
|
|
195
|
+
return _fail(exc.code, exc.detail, out)
|
|
196
|
+
except ProvenanceError as exc:
|
|
197
|
+
return _fail("PROVENANCE", {"reason": str(exc)}, out)
|
|
198
|
+
except EvidenceConflict as exc:
|
|
199
|
+
return _fail("CONFLICT", {"paths": exc.paths}, out)
|
|
200
|
+
if not result["ok"]:
|
|
201
|
+
code = ({"TURN_VIOLATION": "TURN_VIOLATION",
|
|
202
|
+
"TURN_TAMPERED": "TURN_TAMPERED",
|
|
203
|
+
"GATE_RED": "GATE_RED"}.get(result["outcome"], "TURN_FAILED"))
|
|
204
|
+
return _fail(code, {"outcome": result["outcome"],
|
|
205
|
+
"artifact": result["artifact"]}, out)
|
|
206
|
+
return _emit(result, 0, out)
|
|
163
207
|
if args.command == "gate":
|
|
164
208
|
from .conduction.gate import ProvenanceError, run_gate
|
|
165
209
|
from .conduction.evidence import EvidenceConflict
|
|
@@ -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"
|
|
@@ -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()))
|
|
@@ -25,10 +25,11 @@ class RunResult:
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class SubprocessRunner:
|
|
28
|
-
def run(self, argv: list[str], *, cwd: str, timeout: float
|
|
28
|
+
def run(self, argv: list[str], *, cwd: str, timeout: float,
|
|
29
|
+
env: dict | None = None) -> RunResult:
|
|
29
30
|
proc = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.DEVNULL,
|
|
30
31
|
stdout=subprocess.PIPE,
|
|
31
|
-
stderr=subprocess.STDOUT,
|
|
32
|
+
stderr=subprocess.STDOUT, env=env,
|
|
32
33
|
start_new_session=True) # bytes mode + headless: NEVER
|
|
33
34
|
# inherit the parent's stdin — a child waiting for EOF on an
|
|
34
35
|
# inherited pipe hangs forever (caught live by the dogfooded review)
|