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.
Files changed (50) hide show
  1. {regent_cli-0.5.0/src/regent_cli.egg-info → regent_cli-0.6.0}/PKG-INFO +7 -2
  2. {regent_cli-0.5.0 → regent_cli-0.6.0}/README.md +6 -1
  3. {regent_cli-0.5.0 → regent_cli-0.6.0}/pyproject.toml +1 -1
  4. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/__init__.py +1 -1
  5. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/activity_cli.py +44 -0
  6. regent_cli-0.6.0/src/regent/conduction/confine.py +79 -0
  7. regent_cli-0.6.0/src/regent/conduction/hookscript.py +181 -0
  8. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/process.py +3 -2
  9. regent_cli-0.6.0/src/regent/conduction/turn.py +389 -0
  10. regent_cli-0.6.0/src/regent/conduction/turnlog.py +216 -0
  11. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/initcmd.py +5 -2
  12. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/templates/MANIFEST.json +2 -1
  13. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/templates/skills/regent/SKILL.md +9 -1
  14. {regent_cli-0.5.0 → regent_cli-0.6.0/src/regent_cli.egg-info}/PKG-INFO +7 -2
  15. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/SOURCES.txt +8 -1
  16. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_activity_cli.py +2 -2
  17. regent_cli-0.6.0/tests/test_confine.py +229 -0
  18. regent_cli-0.6.0/tests/test_hookscript.py +222 -0
  19. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_skills_v1.py +2 -1
  20. regent_cli-0.6.0/tests/test_turn.py +308 -0
  21. {regent_cli-0.5.0 → regent_cli-0.6.0}/LICENSE +0 -0
  22. {regent_cli-0.5.0 → regent_cli-0.6.0}/setup.cfg +0 -0
  23. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/activity.py +0 -0
  24. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/cli.py +0 -0
  25. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/__init__.py +0 -0
  26. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/consult.py +0 -0
  27. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/evidence.py +0 -0
  28. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/conduction/gate.py +0 -0
  29. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/doctor.py +0 -0
  30. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/__init__.py +0 -0
  31. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/audit.py +0 -0
  32. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/control.py +0 -0
  33. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/lock.py +0 -0
  34. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/protocol/stop.py +0 -0
  35. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
  36. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
  37. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
  38. {regent_cli-0.5.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/top_level.txt +0 -0
  39. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_activity.py +0 -0
  40. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_choreography.py +0 -0
  41. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_consult.py +0 -0
  42. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_control.py +0 -0
  43. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_doctor.py +0 -0
  44. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_gate.py +0 -0
  45. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_init_upgrade.py +0 -0
  46. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_initcmd.py +0 -0
  47. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_lock.py +0 -0
  48. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_protocol_facade.py +0 -0
  49. {regent_cli-0.5.0 → regent_cli-0.6.0}/tests/test_step06_fixes.py +0 -0
  50. {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.5.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.5.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"
@@ -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.6.0"
@@ -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) -> RunResult:
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)