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.
Files changed (41) hide show
  1. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/PKG-INFO +1 -1
  2. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/pyproject.toml +1 -1
  3. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/guard.py +178 -13
  4. conduct_cli-0.6.16/src/conduct_cli/hooks/__init__.py +1 -0
  5. conduct_cli-0.6.16/src/conduct_cli/hooks/base.py +220 -0
  6. conduct_cli-0.6.16/src/conduct_cli/hooks/posttooluse.py +364 -0
  7. conduct_cli-0.6.16/src/conduct_cli/hooks/precompact.py +77 -0
  8. conduct_cli-0.6.16/src/conduct_cli/hooks/pretooluse.py +473 -0
  9. conduct_cli-0.6.16/src/conduct_cli/hooks/session_start.py +143 -0
  10. conduct_cli-0.6.16/src/conduct_cli/hooks/stop.py +30 -0
  11. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  12. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/SOURCES.txt +7 -0
  13. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/README.md +0 -0
  14. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/setup.cfg +0 -0
  15. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/setup.py +0 -0
  16. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/__init__.py +0 -0
  17. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/api.py +0 -0
  18. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/guardmcp.py +0 -0
  19. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/hook_precompact_template.py +0 -0
  20. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/hook_session_start_template.py +0 -0
  21. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/hook_stop_template.py +0 -0
  22. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/hook_template.py +0 -0
  23. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/log_util.py +0 -0
  24. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/main.py +0 -0
  25. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/mcp_server.py +0 -0
  26. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/memory.py +0 -0
  27. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli/paxel.py +0 -0
  28. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  29. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  30. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/requires.txt +0 -0
  31. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/top_level.txt +0 -0
  32. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_bash_operator_signature.py +0 -0
  33. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_command_word_matcher.py +0 -0
  34. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_guard_policy.py +0 -0
  35. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_guard_savings.py +0 -0
  36. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_hook_syntax.py +0 -0
  37. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_journal_drain.py +0 -0
  38. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_log_util.py +0 -0
  39. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_proxy_env.py +0 -0
  40. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_signed_policy.py +0 -0
  41. {conduct_cli-0.6.14 → conduct_cli-0.6.16}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.14
3
+ Version: 0.6.16
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.6.14"
7
+ version = "0.6.16"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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 hook_template.py to path, then py_compile-validate it.
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
- import py_compile, tempfile, os
146
- # Stash existing hook so we can restore on failure
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
- path.write_text(_read_template("hook_template.py"))
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 = GUARD_DIR / "guard-precompact.py"
234
+ precompact_path = GUARD_DIR / "guard-precompact.py"
170
235
  session_start_path = GUARD_DIR / "guard-session-start.py"
171
- stop_path = GUARD_DIR / "guard-stop.py"
236
+ stop_path = GUARD_DIR / "guard-stop.py"
172
237
 
173
- precompact_path.write_text(_read_template("hook_precompact_template.py"))
174
- precompact_path.chmod(0o755)
175
- session_start_path.write_text(_read_template("hook_session_start_template.py"))
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)