conduct-cli 0.6.14__tar.gz → 0.6.16__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.14 → conduct_cli-0.6.16}/PKG-INFO +1 -1
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/pyproject.toml +1 -1
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/guard.py +178 -13
- conduct_cli-0.6.16/src/conduct_cli/hooks/__init__.py +1 -0
- conduct_cli-0.6.16/src/conduct_cli/hooks/base.py +220 -0
- conduct_cli-0.6.16/src/conduct_cli/hooks/posttooluse.py +364 -0
- conduct_cli-0.6.16/src/conduct_cli/hooks/precompact.py +77 -0
- conduct_cli-0.6.16/src/conduct_cli/hooks/pretooluse.py +473 -0
- conduct_cli-0.6.16/src/conduct_cli/hooks/session_start.py +143 -0
- conduct_cli-0.6.16/src/conduct_cli/hooks/stop.py +30 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/SOURCES.txt +7 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/README.md +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/setup.cfg +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/setup.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/hook_session_start_template.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/hook_template.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_log_util.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_switch.py +0 -0
|
@@ -28,6 +28,40 @@ 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
|
+
"from conduct_cli.hooks.pretooluse import main; main()\n"
|
|
37
|
+
),
|
|
38
|
+
"posttooluse": (
|
|
39
|
+
"#!/usr/bin/env python3\n"
|
|
40
|
+
"from conduct_cli.hooks.posttooluse import main; main()\n"
|
|
41
|
+
),
|
|
42
|
+
"stop": (
|
|
43
|
+
"#!/usr/bin/env python3\n"
|
|
44
|
+
"from conduct_cli.hooks.stop import main; main()\n"
|
|
45
|
+
),
|
|
46
|
+
"precompact": (
|
|
47
|
+
"#!/usr/bin/env python3\n"
|
|
48
|
+
"from conduct_cli.hooks.precompact import main; main()\n"
|
|
49
|
+
),
|
|
50
|
+
"session-start": (
|
|
51
|
+
"#!/usr/bin/env python3\n"
|
|
52
|
+
"from conduct_cli.hooks.session_start import main; main()\n"
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Detect whether an installed hook is already a thin launcher (contains the import)
|
|
57
|
+
def _is_thin_launcher(path: Path) -> bool:
|
|
58
|
+
try:
|
|
59
|
+
text = path.read_text()
|
|
60
|
+
return "from conduct_cli.hooks." in text
|
|
61
|
+
except Exception:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
31
65
|
|
|
32
66
|
|
|
33
67
|
# ── Policy engine (also embedded in hook_template.py for standalone use) ──────
|
|
@@ -139,16 +173,31 @@ def _best_python() -> str:
|
|
|
139
173
|
# ── Hook write helper ─────────────────────────────────────────────────────────
|
|
140
174
|
|
|
141
175
|
def _write_hook(path: Path) -> None:
|
|
142
|
-
"""Write
|
|
176
|
+
"""Write a thin launcher to path (or legacy template for backward compat), then validate.
|
|
177
|
+
|
|
178
|
+
Thin launcher (new default):
|
|
179
|
+
#!/usr/bin/env python3
|
|
180
|
+
from conduct_cli.hooks.pretooluse import main; main()
|
|
181
|
+
|
|
182
|
+
If conduct_cli.hooks is not importable (e.g. editable install not set up),
|
|
183
|
+
falls back to writing the full template so hooks still work.
|
|
143
184
|
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
|
-
|
|
185
|
+
system is never left without a working hook file.
|
|
186
|
+
"""
|
|
187
|
+
import py_compile
|
|
147
188
|
backup = None
|
|
148
189
|
if path.exists():
|
|
149
190
|
backup = path.read_text()
|
|
150
191
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
-
|
|
192
|
+
|
|
193
|
+
# Prefer thin launcher; fall back to full template if package not importable
|
|
194
|
+
try:
|
|
195
|
+
import conduct_cli.hooks.pretooluse # noqa: F401
|
|
196
|
+
content = _THIN_LAUNCHERS["pretooluse"]
|
|
197
|
+
except ImportError:
|
|
198
|
+
content = _read_template("hook_template.py")
|
|
199
|
+
|
|
200
|
+
path.write_text(content)
|
|
152
201
|
path.chmod(0o755)
|
|
153
202
|
try:
|
|
154
203
|
py_compile.compile(str(path), doraise=True)
|
|
@@ -162,20 +211,33 @@ def _write_hook(path: Path) -> None:
|
|
|
162
211
|
) from exc
|
|
163
212
|
|
|
164
213
|
|
|
214
|
+
def _write_session_hook(path: Path, launcher_key: str, template_name: str) -> None:
|
|
215
|
+
"""Write a thin launcher (or legacy template) for a session hook.
|
|
216
|
+
|
|
217
|
+
If conduct_cli.hooks is importable, writes the thin launcher.
|
|
218
|
+
Falls back to the full template so old installs continue to work.
|
|
219
|
+
Also rewrites old-style (non-thin) hooks to thin launchers on sync.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
import conduct_cli.hooks # noqa: F401
|
|
223
|
+
content = _THIN_LAUNCHERS[launcher_key]
|
|
224
|
+
except ImportError:
|
|
225
|
+
content = _read_template(template_name)
|
|
226
|
+
path.write_text(content)
|
|
227
|
+
path.chmod(0o755)
|
|
228
|
+
|
|
229
|
+
|
|
165
230
|
def _install_session_hooks() -> None:
|
|
166
231
|
"""Write PreCompact + SessionStart + Stop hook scripts and register them in ~/.claude/settings.json."""
|
|
167
232
|
python = _best_python()
|
|
168
233
|
|
|
169
|
-
precompact_path
|
|
234
|
+
precompact_path = GUARD_DIR / "guard-precompact.py"
|
|
170
235
|
session_start_path = GUARD_DIR / "guard-session-start.py"
|
|
171
|
-
stop_path
|
|
236
|
+
stop_path = GUARD_DIR / "guard-stop.py"
|
|
172
237
|
|
|
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)
|
|
238
|
+
_write_session_hook(precompact_path, "precompact", "hook_precompact_template.py")
|
|
239
|
+
_write_session_hook(session_start_path, "session-start", "hook_session_start_template.py")
|
|
240
|
+
_write_session_hook(stop_path, "stop", "hook_stop_template.py")
|
|
179
241
|
|
|
180
242
|
claude_settings = Path.home() / ".claude" / "settings.json"
|
|
181
243
|
settings: dict = {}
|
|
@@ -353,6 +415,9 @@ def _register_mcp(workspace_id: str, member_token: str, api_url: str) -> None:
|
|
|
353
415
|
# so all LLM calls route through the Guard proxy (PII blocking, spend limits, audit).
|
|
354
416
|
_patch_claude_desktop_proxy(api_url, member_token)
|
|
355
417
|
|
|
418
|
+
# Cursor global rules — write Guard policies as user rules so they apply across all projects.
|
|
419
|
+
_patch_cursor_global_rules()
|
|
420
|
+
|
|
356
421
|
|
|
357
422
|
def _patch_claude_desktop_proxy(api_url: str, member_token: str) -> None:
|
|
358
423
|
"""Patch Claude Desktop config to route LLM calls through the Guard proxy.
|
|
@@ -383,6 +448,46 @@ def _patch_claude_desktop_proxy(api_url: str, member_token: str) -> None:
|
|
|
383
448
|
print(f" {YELLOW}Restart Claude Desktop for proxy routing to take effect{RESET}")
|
|
384
449
|
|
|
385
450
|
|
|
451
|
+
def _patch_cursor_global_rules() -> None:
|
|
452
|
+
"""Write ConductGuard policy rules into Cursor's global user rules setting.
|
|
453
|
+
|
|
454
|
+
Cursor IDE stores global rules in settings.json under cursor.rules.user —
|
|
455
|
+
a plain text string injected as system context for every conversation.
|
|
456
|
+
This is soft enforcement (prompt-level), not structural like Claude Code hooks,
|
|
457
|
+
but ensures Guard policies are visible to the model across all Cursor projects.
|
|
458
|
+
"""
|
|
459
|
+
GUARD_RULES = (
|
|
460
|
+
"# ConductGuard — team AI policies (managed by conduct guard sync)\n"
|
|
461
|
+
"- ALWAYS call guard_check (via conductguard MCP) before running shell commands, "
|
|
462
|
+
"reading or writing files, accessing external APIs, or modifying code.\n"
|
|
463
|
+
"- NEVER write credentials, API keys, or secrets to files or output.\n"
|
|
464
|
+
"- NEVER send PII (emails, names, SSNs, payment data) to external endpoints.\n"
|
|
465
|
+
"- If guard_check returns BLOCKED: stop and explain the policy rule to the user.\n"
|
|
466
|
+
"- If guard_check returns WARNING: proceed but surface the warning.\n"
|
|
467
|
+
"- These rules are enforced by your team's ConductGuard policy. Do not bypass them."
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
candidates = [
|
|
471
|
+
Path.home() / "Library" / "Application Support" / "Cursor" / "User" / "settings.json",
|
|
472
|
+
Path.home() / ".config" / "Cursor" / "User" / "settings.json",
|
|
473
|
+
Path.home() / "AppData" / "Roaming" / "Cursor" / "User" / "settings.json",
|
|
474
|
+
]
|
|
475
|
+
for settings_path in candidates:
|
|
476
|
+
if not settings_path.exists():
|
|
477
|
+
continue
|
|
478
|
+
try:
|
|
479
|
+
cfg = json.loads(settings_path.read_text())
|
|
480
|
+
except (json.JSONDecodeError, OSError):
|
|
481
|
+
cfg = {}
|
|
482
|
+
existing = cfg.get("cursor.rules.user", "")
|
|
483
|
+
if "ConductGuard" in existing:
|
|
484
|
+
print(f" {GRAY}Cursor global rules already contain ConductGuard policy{RESET}")
|
|
485
|
+
continue
|
|
486
|
+
cfg["cursor.rules.user"] = (existing + "\n\n" + GUARD_RULES).strip()
|
|
487
|
+
settings_path.write_text(json.dumps(cfg, indent=2))
|
|
488
|
+
print(f" {GREEN}Cursor global rules updated with ConductGuard policy{RESET}")
|
|
489
|
+
|
|
490
|
+
|
|
386
491
|
def _install_codex_hook(hook_path: Path) -> None:
|
|
387
492
|
"""Register PreToolUse and PostToolUse hooks in ~/.codex/hooks.json."""
|
|
388
493
|
codex_hooks = Path.home() / ".codex" / "hooks.json"
|
|
@@ -1750,6 +1855,17 @@ def register_guard_parser(sub):
|
|
|
1750
1855
|
# conduct guard skip-setup
|
|
1751
1856
|
guard_sub.add_parser("skip-setup", help="Suppress the Guard setup reminder (does not disable Guard)")
|
|
1752
1857
|
|
|
1858
|
+
# conduct guard debug-hook <toolname>
|
|
1859
|
+
debug_hook_p = guard_sub.add_parser(
|
|
1860
|
+
"debug-hook",
|
|
1861
|
+
help="Run any hook standalone with JSON from stdin and print what it would do",
|
|
1862
|
+
)
|
|
1863
|
+
debug_hook_p.add_argument(
|
|
1864
|
+
"toolname",
|
|
1865
|
+
choices=["pretooluse", "posttooluse", "stop", "precompact", "session-start"],
|
|
1866
|
+
help="Which hook to test",
|
|
1867
|
+
)
|
|
1868
|
+
|
|
1753
1869
|
return guard_p, guard_sub
|
|
1754
1870
|
|
|
1755
1871
|
|
|
@@ -1846,6 +1962,53 @@ def cmd_guard_booster_status(args):
|
|
|
1846
1962
|
print()
|
|
1847
1963
|
|
|
1848
1964
|
|
|
1965
|
+
def cmd_guard_debug_hook(args):
|
|
1966
|
+
"""Run a hook module standalone with JSON piped from stdin and print the outcome.
|
|
1967
|
+
|
|
1968
|
+
Usage:
|
|
1969
|
+
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | \\
|
|
1970
|
+
conduct guard debug-hook pretooluse
|
|
1971
|
+
"""
|
|
1972
|
+
import subprocess as _sp
|
|
1973
|
+
toolname = args.toolname # pretooluse | posttooluse | stop | precompact | session-start
|
|
1974
|
+
|
|
1975
|
+
module_map = {
|
|
1976
|
+
"pretooluse": "conduct_cli.hooks.pretooluse",
|
|
1977
|
+
"posttooluse": "conduct_cli.hooks.posttooluse",
|
|
1978
|
+
"stop": "conduct_cli.hooks.stop",
|
|
1979
|
+
"precompact": "conduct_cli.hooks.precompact",
|
|
1980
|
+
"session-start":"conduct_cli.hooks.session_start",
|
|
1981
|
+
}
|
|
1982
|
+
module = module_map[toolname]
|
|
1983
|
+
|
|
1984
|
+
print(f"{BOLD}conduct guard debug-hook {toolname}{RESET}")
|
|
1985
|
+
print(f"{GRAY}Reading JSON from stdin (send EOF when done — Ctrl+D)…{RESET}\n")
|
|
1986
|
+
|
|
1987
|
+
stdin_data = sys.stdin.read()
|
|
1988
|
+
if not stdin_data.strip():
|
|
1989
|
+
# Provide a neutral no-op payload so the hook doesn't crash
|
|
1990
|
+
stdin_data = "{}"
|
|
1991
|
+
|
|
1992
|
+
result = _sp.run(
|
|
1993
|
+
[sys.executable, "-c", f"from {module} import main; main()"],
|
|
1994
|
+
input=stdin_data,
|
|
1995
|
+
capture_output=True,
|
|
1996
|
+
text=True,
|
|
1997
|
+
)
|
|
1998
|
+
|
|
1999
|
+
if result.stdout:
|
|
2000
|
+
print(f"{BOLD}stdout:{RESET}")
|
|
2001
|
+
print(result.stdout)
|
|
2002
|
+
if result.stderr:
|
|
2003
|
+
print(f"{BOLD}stderr:{RESET}")
|
|
2004
|
+
print(result.stderr)
|
|
2005
|
+
|
|
2006
|
+
exit_label = {0: f"{GREEN}0 — allowed{RESET}", 2: f"{RED}2 — blocked{RESET}"}.get(
|
|
2007
|
+
result.returncode, f"{YELLOW}{result.returncode}{RESET}"
|
|
2008
|
+
)
|
|
2009
|
+
print(f"\n{BOLD}Exit code:{RESET} {exit_label}")
|
|
2010
|
+
|
|
2011
|
+
|
|
1849
2012
|
def dispatch_guard(args, guard_p):
|
|
1850
2013
|
"""Dispatch to the correct guard handler. Called from main()."""
|
|
1851
2014
|
guard_command = getattr(args, "guard_command", None)
|
|
@@ -1861,6 +2024,8 @@ def dispatch_guard(args, guard_p):
|
|
|
1861
2024
|
cmd_guard_install(args)
|
|
1862
2025
|
elif guard_command == "booster-status":
|
|
1863
2026
|
cmd_guard_booster_status(args)
|
|
2027
|
+
elif guard_command == "debug-hook":
|
|
2028
|
+
cmd_guard_debug_hook(args)
|
|
1864
2029
|
else:
|
|
1865
2030
|
guard_p.print_help()
|
|
1866
2031
|
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)
|