conduct-cli 0.6.15__tar.gz → 0.6.17__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.
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/PKG-INFO +1 -1
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/pyproject.toml +1 -1
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/guard.py +140 -13
- conduct_cli-0.6.17/src/conduct_cli/hooks/__init__.py +1 -0
- conduct_cli-0.6.17/src/conduct_cli/hooks/base.py +220 -0
- conduct_cli-0.6.17/src/conduct_cli/hooks/posttooluse.py +364 -0
- conduct_cli-0.6.17/src/conduct_cli/hooks/precompact.py +77 -0
- conduct_cli-0.6.17/src/conduct_cli/hooks/pretooluse.py +473 -0
- conduct_cli-0.6.17/src/conduct_cli/hooks/session_start.py +143 -0
- conduct_cli-0.6.17/src/conduct_cli/hooks/stop.py +30 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli.egg-info/SOURCES.txt +7 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/README.md +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/setup.cfg +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/setup.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/hook_session_start_template.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/hook_template.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_log_util.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.6.15 → conduct_cli-0.6.17}/tests/test_switch.py +0 -0
|
@@ -28,6 +28,45 @@ def _read_template(name: str) -> str:
|
|
|
28
28
|
return (_TEMPLATES_DIR / name).read_text()
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
# ── Thin launcher content ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
_THIN_LAUNCHERS = {
|
|
34
|
+
"pretooluse": (
|
|
35
|
+
"#!/usr/bin/env python3\n"
|
|
36
|
+
"try:\n from conduct_cli.hooks.pretooluse import main; main()\n"
|
|
37
|
+
"except (ImportError, ModuleNotFoundError): import sys; sys.exit(0)\n"
|
|
38
|
+
),
|
|
39
|
+
"posttooluse": (
|
|
40
|
+
"#!/usr/bin/env python3\n"
|
|
41
|
+
"try:\n from conduct_cli.hooks.posttooluse import main; main()\n"
|
|
42
|
+
"except (ImportError, ModuleNotFoundError): import sys; sys.exit(0)\n"
|
|
43
|
+
),
|
|
44
|
+
"stop": (
|
|
45
|
+
"#!/usr/bin/env python3\n"
|
|
46
|
+
"try:\n from conduct_cli.hooks.stop import main; main()\n"
|
|
47
|
+
"except (ImportError, ModuleNotFoundError): import sys; sys.exit(0)\n"
|
|
48
|
+
),
|
|
49
|
+
"precompact": (
|
|
50
|
+
"#!/usr/bin/env python3\n"
|
|
51
|
+
"try:\n from conduct_cli.hooks.precompact import main; main()\n"
|
|
52
|
+
"except (ImportError, ModuleNotFoundError): import sys; sys.exit(0)\n"
|
|
53
|
+
),
|
|
54
|
+
"session-start": (
|
|
55
|
+
"#!/usr/bin/env python3\n"
|
|
56
|
+
"try:\n from conduct_cli.hooks.session_start import main; main()\n"
|
|
57
|
+
"except (ImportError, ModuleNotFoundError): import sys; sys.exit(0)\n"
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Detect whether an installed hook is already a thin launcher (contains the import)
|
|
62
|
+
def _is_thin_launcher(path: Path) -> bool:
|
|
63
|
+
try:
|
|
64
|
+
text = path.read_text()
|
|
65
|
+
return "from conduct_cli.hooks." in text
|
|
66
|
+
except Exception:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
31
70
|
|
|
32
71
|
|
|
33
72
|
# ── Policy engine (also embedded in hook_template.py for standalone use) ──────
|
|
@@ -139,16 +178,31 @@ def _best_python() -> str:
|
|
|
139
178
|
# ── Hook write helper ─────────────────────────────────────────────────────────
|
|
140
179
|
|
|
141
180
|
def _write_hook(path: Path) -> None:
|
|
142
|
-
"""Write
|
|
181
|
+
"""Write a thin launcher to path (or legacy template for backward compat), then validate.
|
|
182
|
+
|
|
183
|
+
Thin launcher (new default):
|
|
184
|
+
#!/usr/bin/env python3
|
|
185
|
+
from conduct_cli.hooks.pretooluse import main; main()
|
|
186
|
+
|
|
187
|
+
If conduct_cli.hooks is not importable (e.g. editable install not set up),
|
|
188
|
+
falls back to writing the full template so hooks still work.
|
|
143
189
|
On syntax failure: restores previous hook (or writes a safe stub) so the
|
|
144
|
-
system is never left without a working hook file.
|
|
145
|
-
|
|
146
|
-
|
|
190
|
+
system is never left without a working hook file.
|
|
191
|
+
"""
|
|
192
|
+
import py_compile
|
|
147
193
|
backup = None
|
|
148
194
|
if path.exists():
|
|
149
195
|
backup = path.read_text()
|
|
150
196
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
-
|
|
197
|
+
|
|
198
|
+
# Prefer thin launcher; fall back to full template if package not importable
|
|
199
|
+
try:
|
|
200
|
+
import conduct_cli.hooks.pretooluse # noqa: F401
|
|
201
|
+
content = _THIN_LAUNCHERS["pretooluse"]
|
|
202
|
+
except ImportError:
|
|
203
|
+
content = _read_template("hook_template.py")
|
|
204
|
+
|
|
205
|
+
path.write_text(content)
|
|
152
206
|
path.chmod(0o755)
|
|
153
207
|
try:
|
|
154
208
|
py_compile.compile(str(path), doraise=True)
|
|
@@ -162,20 +216,33 @@ def _write_hook(path: Path) -> None:
|
|
|
162
216
|
) from exc
|
|
163
217
|
|
|
164
218
|
|
|
219
|
+
def _write_session_hook(path: Path, launcher_key: str, template_name: str) -> None:
|
|
220
|
+
"""Write a thin launcher (or legacy template) for a session hook.
|
|
221
|
+
|
|
222
|
+
If conduct_cli.hooks is importable, writes the thin launcher.
|
|
223
|
+
Falls back to the full template so old installs continue to work.
|
|
224
|
+
Also rewrites old-style (non-thin) hooks to thin launchers on sync.
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
import conduct_cli.hooks # noqa: F401
|
|
228
|
+
content = _THIN_LAUNCHERS[launcher_key]
|
|
229
|
+
except ImportError:
|
|
230
|
+
content = _read_template(template_name)
|
|
231
|
+
path.write_text(content)
|
|
232
|
+
path.chmod(0o755)
|
|
233
|
+
|
|
234
|
+
|
|
165
235
|
def _install_session_hooks() -> None:
|
|
166
236
|
"""Write PreCompact + SessionStart + Stop hook scripts and register them in ~/.claude/settings.json."""
|
|
167
237
|
python = _best_python()
|
|
168
238
|
|
|
169
|
-
precompact_path
|
|
239
|
+
precompact_path = GUARD_DIR / "guard-precompact.py"
|
|
170
240
|
session_start_path = GUARD_DIR / "guard-session-start.py"
|
|
171
|
-
stop_path
|
|
241
|
+
stop_path = GUARD_DIR / "guard-stop.py"
|
|
172
242
|
|
|
173
|
-
precompact_path
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
session_start_path.chmod(0o755)
|
|
177
|
-
stop_path.write_text(_read_template("hook_stop_template.py"))
|
|
178
|
-
stop_path.chmod(0o755)
|
|
243
|
+
_write_session_hook(precompact_path, "precompact", "hook_precompact_template.py")
|
|
244
|
+
_write_session_hook(session_start_path, "session-start", "hook_session_start_template.py")
|
|
245
|
+
_write_session_hook(stop_path, "stop", "hook_stop_template.py")
|
|
179
246
|
|
|
180
247
|
claude_settings = Path.home() / ".claude" / "settings.json"
|
|
181
248
|
settings: dict = {}
|
|
@@ -1793,6 +1860,17 @@ def register_guard_parser(sub):
|
|
|
1793
1860
|
# conduct guard skip-setup
|
|
1794
1861
|
guard_sub.add_parser("skip-setup", help="Suppress the Guard setup reminder (does not disable Guard)")
|
|
1795
1862
|
|
|
1863
|
+
# conduct guard debug-hook <toolname>
|
|
1864
|
+
debug_hook_p = guard_sub.add_parser(
|
|
1865
|
+
"debug-hook",
|
|
1866
|
+
help="Run any hook standalone with JSON from stdin and print what it would do",
|
|
1867
|
+
)
|
|
1868
|
+
debug_hook_p.add_argument(
|
|
1869
|
+
"toolname",
|
|
1870
|
+
choices=["pretooluse", "posttooluse", "stop", "precompact", "session-start"],
|
|
1871
|
+
help="Which hook to test",
|
|
1872
|
+
)
|
|
1873
|
+
|
|
1796
1874
|
return guard_p, guard_sub
|
|
1797
1875
|
|
|
1798
1876
|
|
|
@@ -1889,6 +1967,53 @@ def cmd_guard_booster_status(args):
|
|
|
1889
1967
|
print()
|
|
1890
1968
|
|
|
1891
1969
|
|
|
1970
|
+
def cmd_guard_debug_hook(args):
|
|
1971
|
+
"""Run a hook module standalone with JSON piped from stdin and print the outcome.
|
|
1972
|
+
|
|
1973
|
+
Usage:
|
|
1974
|
+
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | \\
|
|
1975
|
+
conduct guard debug-hook pretooluse
|
|
1976
|
+
"""
|
|
1977
|
+
import subprocess as _sp
|
|
1978
|
+
toolname = args.toolname # pretooluse | posttooluse | stop | precompact | session-start
|
|
1979
|
+
|
|
1980
|
+
module_map = {
|
|
1981
|
+
"pretooluse": "conduct_cli.hooks.pretooluse",
|
|
1982
|
+
"posttooluse": "conduct_cli.hooks.posttooluse",
|
|
1983
|
+
"stop": "conduct_cli.hooks.stop",
|
|
1984
|
+
"precompact": "conduct_cli.hooks.precompact",
|
|
1985
|
+
"session-start":"conduct_cli.hooks.session_start",
|
|
1986
|
+
}
|
|
1987
|
+
module = module_map[toolname]
|
|
1988
|
+
|
|
1989
|
+
print(f"{BOLD}conduct guard debug-hook {toolname}{RESET}")
|
|
1990
|
+
print(f"{GRAY}Reading JSON from stdin (send EOF when done — Ctrl+D)…{RESET}\n")
|
|
1991
|
+
|
|
1992
|
+
stdin_data = sys.stdin.read()
|
|
1993
|
+
if not stdin_data.strip():
|
|
1994
|
+
# Provide a neutral no-op payload so the hook doesn't crash
|
|
1995
|
+
stdin_data = "{}"
|
|
1996
|
+
|
|
1997
|
+
result = _sp.run(
|
|
1998
|
+
[sys.executable, "-c", f"from {module} import main; main()"],
|
|
1999
|
+
input=stdin_data,
|
|
2000
|
+
capture_output=True,
|
|
2001
|
+
text=True,
|
|
2002
|
+
)
|
|
2003
|
+
|
|
2004
|
+
if result.stdout:
|
|
2005
|
+
print(f"{BOLD}stdout:{RESET}")
|
|
2006
|
+
print(result.stdout)
|
|
2007
|
+
if result.stderr:
|
|
2008
|
+
print(f"{BOLD}stderr:{RESET}")
|
|
2009
|
+
print(result.stderr)
|
|
2010
|
+
|
|
2011
|
+
exit_label = {0: f"{GREEN}0 — allowed{RESET}", 2: f"{RED}2 — blocked{RESET}"}.get(
|
|
2012
|
+
result.returncode, f"{YELLOW}{result.returncode}{RESET}"
|
|
2013
|
+
)
|
|
2014
|
+
print(f"\n{BOLD}Exit code:{RESET} {exit_label}")
|
|
2015
|
+
|
|
2016
|
+
|
|
1892
2017
|
def dispatch_guard(args, guard_p):
|
|
1893
2018
|
"""Dispatch to the correct guard handler. Called from main()."""
|
|
1894
2019
|
guard_command = getattr(args, "guard_command", None)
|
|
@@ -1904,6 +2029,8 @@ def dispatch_guard(args, guard_p):
|
|
|
1904
2029
|
cmd_guard_install(args)
|
|
1905
2030
|
elif guard_command == "booster-status":
|
|
1906
2031
|
cmd_guard_booster_status(args)
|
|
2032
|
+
elif guard_command == "debug-hook":
|
|
2033
|
+
cmd_guard_debug_hook(args)
|
|
1907
2034
|
else:
|
|
1908
2035
|
guard_p.print_help()
|
|
1909
2036
|
sys.exit(1)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# ConductGuard hook package — shared utilities + per-hook entrypoints.
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Shared primitives for all ConductGuard hook modules.
|
|
2
|
+
|
|
3
|
+
Everything here is stdlib-only so hooks work from a bare `pip install conduct-cli`
|
|
4
|
+
with no shell rc sourced.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal, Optional
|
|
16
|
+
|
|
17
|
+
# ── Canonical paths ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
GUARD_DIR = Path.home() / ".conductguard"
|
|
20
|
+
CONFIG_PATH = GUARD_DIR / "config.json"
|
|
21
|
+
POLICY_PATH = GUARD_DIR / "policy.json"
|
|
22
|
+
BUDGET_CACHE_PATH = GUARD_DIR / "budget_cache.json"
|
|
23
|
+
BUDGET_CACHE_TTL = 300 # seconds
|
|
24
|
+
VERSION_CACHE_PATH = GUARD_DIR / "version_cache.json"
|
|
25
|
+
VERSION_CACHE_TTL = 60 # seconds
|
|
26
|
+
WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
|
|
27
|
+
SIGNING_KEY_PATH = GUARD_DIR / "signing.key"
|
|
28
|
+
JOURNAL_DIR = GUARD_DIR / "journal"
|
|
29
|
+
JOURNAL_PID_PATH = JOURNAL_DIR / "drain.pid"
|
|
30
|
+
|
|
31
|
+
SNAPSHOT_PATH = GUARD_DIR / "session_snapshot.json"
|
|
32
|
+
CONDUCT_ENV_PATH = Path.home() / ".conduct" / "env"
|
|
33
|
+
|
|
34
|
+
# ── Result type ───────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class HookResult:
|
|
38
|
+
action: Literal["allow", "block", "warn"]
|
|
39
|
+
reason: Optional[str] = None
|
|
40
|
+
metadata: dict = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Config loading ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def load_config() -> dict:
|
|
46
|
+
"""Return ~/.conductguard/config.json as a dict; empty dict on any error."""
|
|
47
|
+
try:
|
|
48
|
+
if CONFIG_PATH.exists():
|
|
49
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Repo / tool detection ─────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def detect_repo() -> Optional[str]:
|
|
58
|
+
"""Return 'owner/repo' from git remote origin, or None."""
|
|
59
|
+
try:
|
|
60
|
+
out = subprocess.check_output(
|
|
61
|
+
["git", "remote", "get-url", "origin"],
|
|
62
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
63
|
+
).strip()
|
|
64
|
+
if "github.com" in out:
|
|
65
|
+
return out.split("github.com")[-1].lstrip("/:").rstrip(".git")
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def detect_ai_tool() -> str:
|
|
72
|
+
"""Return a string identifying the active AI coding tool."""
|
|
73
|
+
import os
|
|
74
|
+
if os.environ.get("CLAUDE_CODE_ENTRYPOINT") or os.environ.get("CLAUDECODE"):
|
|
75
|
+
return "claude-code"
|
|
76
|
+
if os.environ.get("CODEX_SESSION_ID") or os.environ.get("CODEX_CLI_VERSION"):
|
|
77
|
+
return "codex"
|
|
78
|
+
term = os.environ.get("TERM_PROGRAM", "").lower()
|
|
79
|
+
if term == "cursor":
|
|
80
|
+
return "cursor"
|
|
81
|
+
if term == "windsurf":
|
|
82
|
+
return "windsurf"
|
|
83
|
+
path = os.environ.get("PATH", "")
|
|
84
|
+
if "Codex.app" in path or "codex" in path.lower():
|
|
85
|
+
return "codex"
|
|
86
|
+
if "cursor" in path.lower():
|
|
87
|
+
return "cursor"
|
|
88
|
+
if "windsurf" in path.lower():
|
|
89
|
+
return "windsurf"
|
|
90
|
+
return "claude-code"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Journal / drain (fire-and-forget event posting) ───────────────────────────
|
|
94
|
+
|
|
95
|
+
def journal_append(payload_str: str, api_url: str) -> None:
|
|
96
|
+
"""Atomically write one event to the journal for the drain daemon to pick up."""
|
|
97
|
+
try:
|
|
98
|
+
JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
import random
|
|
100
|
+
name = f"{time.time_ns()}_{random.randint(0, 9999):04d}.json"
|
|
101
|
+
entry = json.dumps({"api_url": api_url, "payload": payload_str})
|
|
102
|
+
tmp = JOURNAL_DIR / (name + ".tmp")
|
|
103
|
+
tmp.write_text(entry)
|
|
104
|
+
tmp.rename(JOURNAL_DIR / name)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ensure_drain_daemon(hook_module_path: Optional[Path] = None) -> None:
|
|
110
|
+
"""Start the drain daemon if it is not already running.
|
|
111
|
+
|
|
112
|
+
hook_module_path — path of the calling hook file (used to spawn the daemon
|
|
113
|
+
via `python <path> drain`). When None the drain subprocess is not started
|
|
114
|
+
(used in unit tests / debug mode where the daemon is not needed).
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
if JOURNAL_PID_PATH.exists():
|
|
118
|
+
pid = int(JOURNAL_PID_PATH.read_text().strip())
|
|
119
|
+
import os as _os
|
|
120
|
+
try:
|
|
121
|
+
_os.kill(pid, 0)
|
|
122
|
+
return # alive
|
|
123
|
+
except (ProcessLookupError, PermissionError):
|
|
124
|
+
pass
|
|
125
|
+
if hook_module_path is None:
|
|
126
|
+
return
|
|
127
|
+
subprocess.Popen(
|
|
128
|
+
[sys.executable, str(hook_module_path), "drain"],
|
|
129
|
+
stdout=subprocess.DEVNULL,
|
|
130
|
+
stderr=subprocess.DEVNULL,
|
|
131
|
+
start_new_session=True,
|
|
132
|
+
)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_drain_daemon() -> None:
|
|
138
|
+
"""Drain the journal directory to the API. Call via `python hook.py drain`."""
|
|
139
|
+
import os as _os
|
|
140
|
+
try:
|
|
141
|
+
JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
JOURNAL_PID_PATH.write_text(str(_os.getpid()))
|
|
143
|
+
except Exception:
|
|
144
|
+
return
|
|
145
|
+
empty_scans = 0
|
|
146
|
+
while empty_scans < 3:
|
|
147
|
+
files = sorted(f for f in JOURNAL_DIR.glob("*.json") if f.name != "drain.pid")
|
|
148
|
+
if not files:
|
|
149
|
+
empty_scans += 1
|
|
150
|
+
time.sleep(2)
|
|
151
|
+
continue
|
|
152
|
+
posted_any = False
|
|
153
|
+
for f in files:
|
|
154
|
+
try:
|
|
155
|
+
entry = json.loads(f.read_text())
|
|
156
|
+
api_url = entry["api_url"]
|
|
157
|
+
payload = (
|
|
158
|
+
entry["payload"].encode()
|
|
159
|
+
if isinstance(entry["payload"], str)
|
|
160
|
+
else entry["payload"]
|
|
161
|
+
)
|
|
162
|
+
req = urllib.request.Request(
|
|
163
|
+
f"{api_url}/guard/events",
|
|
164
|
+
data=payload,
|
|
165
|
+
headers={"Content-Type": "application/json"},
|
|
166
|
+
method="POST",
|
|
167
|
+
)
|
|
168
|
+
urllib.request.urlopen(req, timeout=8)
|
|
169
|
+
f.unlink(missing_ok=True)
|
|
170
|
+
posted_any = True
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
if not posted_any:
|
|
174
|
+
empty_scans += 1
|
|
175
|
+
time.sleep(2)
|
|
176
|
+
else:
|
|
177
|
+
empty_scans = 0
|
|
178
|
+
try:
|
|
179
|
+
JOURNAL_PID_PATH.unlink(missing_ok=True)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def post_event(
|
|
185
|
+
tool_name: str,
|
|
186
|
+
tool_input: dict,
|
|
187
|
+
decision: str,
|
|
188
|
+
rule_id: Optional[str] = None,
|
|
189
|
+
message: Optional[str] = None,
|
|
190
|
+
session_id: Optional[str] = None,
|
|
191
|
+
*,
|
|
192
|
+
drain_via: Optional[Path] = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Post one guard event via the journal/drain pattern. Never raises.
|
|
195
|
+
|
|
196
|
+
drain_via — path of the calling hook file passed to ensure_drain_daemon().
|
|
197
|
+
"""
|
|
198
|
+
cfg = load_config()
|
|
199
|
+
workspace_id = cfg.get("workspace_id")
|
|
200
|
+
if not workspace_id:
|
|
201
|
+
return
|
|
202
|
+
import platform as _platform
|
|
203
|
+
_os_info = f"{_platform.system()} {_platform.release()} {_platform.machine()}".strip()
|
|
204
|
+
payload = json.dumps({
|
|
205
|
+
"workspace_id": workspace_id,
|
|
206
|
+
"clerk_user_id": cfg.get("user_email"),
|
|
207
|
+
"user_email": cfg.get("user_email"),
|
|
208
|
+
"ai_tool": detect_ai_tool(),
|
|
209
|
+
"tool_call": tool_name,
|
|
210
|
+
"input_summary": json.dumps(tool_input)[:200],
|
|
211
|
+
"decision": decision,
|
|
212
|
+
"rule_id": rule_id,
|
|
213
|
+
"rule_message": message,
|
|
214
|
+
"hook_session_id": session_id,
|
|
215
|
+
"os_info": _os_info,
|
|
216
|
+
"hostname": _platform.node(),
|
|
217
|
+
})
|
|
218
|
+
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
219
|
+
journal_append(payload, api_url)
|
|
220
|
+
ensure_drain_daemon(drain_via)
|