conduct-cli 0.12.0__tar.gz → 0.14.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 (57) hide show
  1. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/PKG-INFO +1 -1
  2. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/pyproject.toml +1 -1
  3. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/guard.py +109 -27
  4. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/base.py +42 -3
  5. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/posttooluse.py +3 -135
  6. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/pretooluse.py +2 -0
  7. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/main.py +2 -404
  8. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  9. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  10. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_cli_matrix.py +0 -1
  11. conduct_cli-0.14.0/tests/test_hook_journal.py +64 -0
  12. conduct_cli-0.14.0/tests/test_install_all.py +24 -0
  13. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_proxy_coverage.py +1 -1
  14. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_proxy_env.py +66 -10
  15. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/LICENSE +0 -0
  16. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/README.md +0 -0
  17. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/setup.cfg +0 -0
  18. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/setup.py +0 -0
  19. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/__init__.py +0 -0
  20. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/api.py +0 -0
  21. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/guard_policy.md +0 -0
  22. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/__init__.py +0 -0
  23. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/precompact.py +0 -0
  24. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/session_parser.py +0 -0
  25. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/session_report_push.py +0 -0
  26. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/session_start.py +0 -0
  27. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/stop.py +0 -0
  28. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/log_util.py +0 -0
  29. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/memory.py +0 -0
  30. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/paxel.py +0 -0
  31. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/tool_groups.py +0 -0
  32. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  33. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  34. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/requires.txt +0 -0
  35. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/top_level.txt +0 -0
  36. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_advisory_and_hook.py +0 -0
  37. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_api_client.py +0 -0
  38. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_bash_operator_signature.py +0 -0
  39. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_bash_scan_target.py +0 -0
  40. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_blast_radius.py +0 -0
  41. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_cmd_run.py +0 -0
  42. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_command_word_matcher.py +0 -0
  43. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_drain_daemon_health.py +0 -0
  44. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_guard_approvals.py +0 -0
  45. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_guard_policy.py +0 -0
  46. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_guard_savings.py +0 -0
  47. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_hook_dispatch.py +0 -0
  48. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_hook_receipt.py +0 -0
  49. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_instruction_files.py +0 -0
  50. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_journal_drain.py +0 -0
  51. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_log_util.py +0 -0
  52. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_pretooluse_approval_e2e.py +0 -0
  53. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_pretooluse_check_policy.py +0 -0
  54. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_report_tool_coverage.py +0 -0
  55. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_signed_policy.py +0 -0
  56. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_switch.py +0 -0
  57. {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_token_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.12.0
3
+ Version: 0.14.0
4
4
  Summary: Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.12.0"
7
+ version = "0.14.0"
8
8
  description = "Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -471,7 +471,7 @@ def _patch_claude_desktop_proxy(api_url: str, agent_token: str) -> None:
471
471
  shell env vars, so ANTHROPIC_BASE_URL has no effect. We write the proxy
472
472
  URL directly so PII blocking, spend limits, and audit apply to Desktop too.
473
473
  """
474
- proxy_url = f"{api_url}/proxy/anthropic"
474
+ proxy_url = f"{api_url.rstrip('/')}/gateway/v1/anthropic"
475
475
  candidates = [
476
476
  Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
477
477
  Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
@@ -695,9 +695,35 @@ def _install_codex_hook(hook_path: Path) -> None:
695
695
 
696
696
  hook_section = hooks.setdefault("hooks", {})
697
697
 
698
+ hook_path_str = str(hook_path)
699
+ # Match the retired location only to delete stale registrations. The
700
+ # desired entry below always points at the current ~/.conduct/hook.py.
701
+ stale_hook_path = str(Path.home() / ".conductguard" / "hook.py")
702
+ conduct_hook_paths = {
703
+ hook_path_str,
704
+ str(Path.home() / ".conduct" / "hook.py"),
705
+ stale_hook_path,
706
+ }
707
+
708
+ def _is_conduct_hook(entry: dict) -> bool:
709
+ command = entry.get("command", "")
710
+ return any(path in command for path in conduct_hook_paths)
711
+
712
+ def _replace_conduct_entries(entries: list, command: str) -> tuple[list, bool]:
713
+ kept = []
714
+ removed = False
715
+ for registration in entries:
716
+ commands = registration.get("hooks", [])
717
+ filtered = [entry for entry in commands if not _is_conduct_hook(entry)]
718
+ if len(filtered) != len(commands):
719
+ removed = True
720
+ if filtered:
721
+ kept.append({**registration, "hooks": filtered})
722
+ desired = {"matcher": ".*", "hooks": [{"type": "command", "command": command}]}
723
+ return [*kept, desired], removed or entries != [*kept, desired]
724
+
698
725
  # PreToolUse
699
726
  pre_cmd = f"{_best_python()} {hook_path}"
700
- hook_path_str = str(hook_path)
701
727
  pre = hook_section.setdefault("PreToolUse", [])
702
728
  # Match by hook path so old python3/python3.11 entries are treated as already registered
703
729
  pre_already = any(
@@ -706,16 +732,9 @@ def _install_codex_hook(hook_path: Path) -> None:
706
732
  for e in h.get("hooks", [])
707
733
  )
708
734
  changed = False
709
- if not pre_already:
710
- pre.append({"matcher": ".*", "hooks": [{"type": "command", "command": pre_cmd}]})
711
- changed = True
712
- else:
713
- # Update existing entry to use current sys.executable
714
- for h in pre:
715
- for e in h.get("hooks", []):
716
- if hook_path_str in e.get("command", "") and e["command"] != pre_cmd:
717
- e["command"] = pre_cmd
718
- changed = True
735
+ normalized_pre, pre_changed = _replace_conduct_entries(pre, pre_cmd)
736
+ hook_section["PreToolUse"] = normalized_pre
737
+ changed = pre_changed
719
738
 
720
739
  # PostToolUse
721
740
  post_cmd = f"{_best_python()} {hook_path} post"
@@ -734,9 +753,9 @@ def _install_codex_hook(hook_path: Path) -> None:
734
753
  for h in post
735
754
  for e in h.get("hooks", [])
736
755
  )
737
- if not post_already:
738
- post.append({"matcher": ".*", "hooks": [{"type": "command", "command": post_cmd}]})
739
- changed = True
756
+ normalized_post, post_changed = _replace_conduct_entries(post, post_cmd)
757
+ hook_section["PostToolUse"] = normalized_post
758
+ changed = changed or post_changed
740
759
  if cleaned:
741
760
  changed = True
742
761
 
@@ -751,6 +770,36 @@ def _install_codex_hook(hook_path: Path) -> None:
751
770
  print(f" {GRAY}Codex hooks already registered{RESET}")
752
771
 
753
772
 
773
+ def _configure_codex_proxy(proxy_url: str) -> bool:
774
+ """Opt in Codex to Conduct's Responses endpoint without storing a secret."""
775
+ config_path = Path.home() / ".codex" / "config.toml"
776
+ if not config_path.parent.exists():
777
+ return False
778
+ content = config_path.read_text() if config_path.exists() else ""
779
+ backup = config_path.with_suffix(".toml.pre-conduct-proxy")
780
+ if config_path.exists() and not backup.exists():
781
+ backup.write_text(content)
782
+ backup.chmod(0o600)
783
+
784
+ import re as _re
785
+ sections = _re.split(r"(?m)(?=^\[)", content)
786
+ root = sections[0]
787
+ root = _re.sub(r'(?m)^model_provider\s*=.*\n?', '', root)
788
+ root = root.rstrip() + '\nmodel_provider = "conduct"\n\n'
789
+ remaining = [s for s in sections[1:] if not s.startswith("[model_providers.conduct]")]
790
+ base = proxy_url.rstrip("/") + "/openai/v1"
791
+ snippet = (
792
+ "[model_providers.conduct]\n"
793
+ 'name = "Conduct Gateway"\n'
794
+ f"base_url = {json.dumps(base)}\n"
795
+ 'env_key = "OPENAI_API_KEY"\n'
796
+ 'wire_api = "responses"\n'
797
+ 'requires_openai_auth = false\n\n'
798
+ )
799
+ config_path.write_text(root + snippet + "".join(remaining).lstrip())
800
+ return True
801
+
802
+
754
803
  # ── HTTP helpers (no third-party deps — mirrors api.py style) ─────────────────
755
804
 
756
805
  def _default_agent_token() -> str | None:
@@ -1520,7 +1569,8 @@ def cmd_guard_sync(args):
1520
1569
  # Write LLM proxy env vars so any AI tool (Claude Code, Cursor, Codex, …)
1521
1570
  # routes through Conduct Guard. Customer-overridable via --proxy-url or
1522
1571
  # CONDUCT_PROXY_URL env var; otherwise fetched from server (workspace_config).
1523
- proxy_url = getattr(args, "proxy_url", None) or os.environ.get("CONDUCT_PROXY_URL")
1572
+ explicit_proxy_url = getattr(args, "proxy_url", None) or os.environ.get("CONDUCT_PROXY_URL")
1573
+ proxy_url = explicit_proxy_url
1524
1574
  if not proxy_url:
1525
1575
  try:
1526
1576
  import urllib.request as _ur, urllib.error as _ue
@@ -1533,6 +1583,8 @@ def cmd_guard_sync(args):
1533
1583
  proxy_url = json.loads(_resp.read()).get("conduct_proxy_url") or DEFAULT_PROXY_URL
1534
1584
  except Exception:
1535
1585
  proxy_url = DEFAULT_PROXY_URL
1586
+ if not explicit_proxy_url:
1587
+ proxy_url = _gateway_v1_url(proxy_url)
1536
1588
  agent_token = cfg.get("agent_token", "")
1537
1589
  rc_path, newly_sourced = _write_proxy_env(agent_token, proxy_url)
1538
1590
  if agent_token:
@@ -1541,6 +1593,11 @@ def cmd_guard_sync(args):
1541
1593
  print(f" {GREEN}Proxy env written:{RESET} ~/.conduct/{env_name} → {proxy_url}")
1542
1594
  if newly_sourced:
1543
1595
  print(f" {CYAN}Run `{activate_cmd}` (or open a new shell) to activate.{RESET}")
1596
+ if not getattr(args, "no_codex_proxy", False):
1597
+ if _configure_codex_proxy(proxy_url):
1598
+ print(f" {GREEN}Codex proxy enabled:{RESET} Responses API via Conduct (restart Codex)")
1599
+ else:
1600
+ print(f" {YELLOW}Codex proxy skipped:{RESET} ~/.codex is not installed")
1544
1601
 
1545
1602
  _tools = _detect_ai_tools()
1546
1603
  if _tools:
@@ -1628,11 +1685,19 @@ def cmd_guard_sync(args):
1628
1685
  CONDUCT_DIR = Path.home() / ".conduct"
1629
1686
  PROXY_ENV_FILE = CONDUCT_DIR / "env"
1630
1687
  PROXY_OVERRIDE = CONDUCT_DIR / "env-override"
1631
- DEFAULT_PROXY_URL = "https://api.conductai.ai/proxy"
1688
+ DEFAULT_PROXY_URL = "https://api.conductai.ai/gateway/v1"
1632
1689
  SHELL_RC_MARKER = "# Conduct Guard Proxy — managed by `conduct guard sync`"
1633
1690
  SHELL_SOURCE_LINE = "[ -f ~/.conduct/env ] && . ~/.conduct/env"
1634
1691
 
1635
1692
 
1693
+ def _gateway_v1_url(proxy_url: str) -> str:
1694
+ """Convert a legacy server proxy URL to the parallel gateway surface."""
1695
+ base = proxy_url.rstrip("/")
1696
+ if base.endswith("/proxy"):
1697
+ return base[:-len("/proxy")] + "/gateway/v1"
1698
+ return base
1699
+
1700
+
1636
1701
  # ── Local key audit ──────────────────────────────────────────────────────────
1637
1702
  # Patterns that match real provider keys (NOT our member tokens).
1638
1703
  # Tuples of (provider, regex). Compiled once at import.
@@ -1727,10 +1792,10 @@ def _write_proxy_env_windows(agent_token: str, proxy_url: str) -> tuple[Path, bo
1727
1792
  SHELL_RC_MARKER,
1728
1793
  "# Edit ~/.conduct/env-override.ps1 to add your own vars — sourced after this file.",
1729
1794
  "",
1730
- f'$env:ANTHROPIC_BASE_URL = "{proxy}"',
1795
+ f'$env:ANTHROPIC_BASE_URL = "{proxy}/anthropic"',
1731
1796
  f'$env:ANTHROPIC_API_KEY = "{token}"',
1732
1797
  "",
1733
- f'$env:OPENAI_BASE_URL = "{proxy}/openai"',
1798
+ f'$env:OPENAI_BASE_URL = "{proxy}/openai/v1"',
1734
1799
  f'$env:OPENAI_API_KEY = "{token}"',
1735
1800
  "",
1736
1801
  f'$env:PERPLEXITY_BASE_URL = "{proxy}/perplexity"',
@@ -1796,10 +1861,10 @@ def _write_proxy_env(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
1796
1861
  SHELL_RC_MARKER,
1797
1862
  "# Edit ~/.conduct/env-override to add your own vars — sourced after this file.",
1798
1863
  "",
1799
- f'export ANTHROPIC_BASE_URL="{proxy}"',
1864
+ f'export ANTHROPIC_BASE_URL="{proxy}/anthropic"',
1800
1865
  f'export ANTHROPIC_API_KEY="{token}"',
1801
1866
  "",
1802
- f'export OPENAI_BASE_URL="{proxy}/openai"',
1867
+ f'export OPENAI_BASE_URL="{proxy}/openai/v1"',
1803
1868
  f'export OPENAI_API_KEY="{token}"',
1804
1869
  "",
1805
1870
  f'export PERPLEXITY_BASE_URL="{proxy}/perplexity"',
@@ -2152,14 +2217,27 @@ def cmd_guard_status(args):
2152
2217
  elif d_status == "stale":
2153
2218
  daemon_line = f"{YELLOW}stale{RESET} (pid {d_pid}, not flushing — will restart on next tool call)"
2154
2219
  else:
2155
- daemon_line = f"{RED}not running{RESET} — will start on next tool call, journal events queued"
2220
+ daemon_line = f"{RED}not running{RESET} — will start on next tool call"
2221
+
2222
+ journal_dir = Path.home() / ".conduct" / "journal"
2223
+ queued_events = len(list(journal_dir.glob("*.json"))) if journal_dir.exists() else 0
2224
+ dead_events = len(list((journal_dir / "dead-letter").glob("*.json"))) if (journal_dir / "dead-letter").exists() else 0
2225
+ heartbeat_path = Path.home() / ".conduct" / "hook-heartbeat.json"
2226
+ heartbeat_line = "never observed"
2227
+ try:
2228
+ heartbeat = json.loads(heartbeat_path.read_text())
2229
+ age = max(0, int(time.time() - float(heartbeat["ts"])))
2230
+ heartbeat_line = f"{heartbeat.get('event', 'hook')} · {age}s ago"
2231
+ except Exception:
2232
+ pass
2156
2233
 
2157
2234
  # Proxy coverage — check active env vars in this shell
2158
- proxy_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/") + "/proxy"
2159
2235
  _env = os.environ
2160
- anthropic_ok = proxy_url in _env.get("ANTHROPIC_BASE_URL", "")
2161
- openai_ok = proxy_url in _env.get("OPENAI_BASE_URL", "")
2162
- perplexity_ok = proxy_url in _env.get("PERPLEXITY_BASE_URL","")
2236
+ def _conduct_proxy_url(value: str) -> bool:
2237
+ return "api.conductai.ai" in value and ("/gateway/v1" in value or "/proxy" in value)
2238
+ anthropic_ok = _conduct_proxy_url(_env.get("ANTHROPIC_BASE_URL", ""))
2239
+ openai_ok = _conduct_proxy_url(_env.get("OPENAI_BASE_URL", ""))
2240
+ perplexity_ok = _conduct_proxy_url(_env.get("PERPLEXITY_BASE_URL", ""))
2163
2241
  env_file_exists = (Path.home() / ".conduct" / "env").exists()
2164
2242
 
2165
2243
  def _cov(ok: bool, name: str) -> str:
@@ -2185,6 +2263,8 @@ def cmd_guard_status(args):
2185
2263
  print()
2186
2264
  print(f"Proxy coverage: {proxy_line}")
2187
2265
  print(f"Drain daemon: {daemon_line}")
2266
+ print(f"Event journal: {queued_events} queued · {dead_events} dead-letter")
2267
+ print(f"Hook heartbeat: {heartbeat_line}")
2188
2268
  print()
2189
2269
  print(f"Today:")
2190
2270
  print(f" Sessions: {session_str}")
@@ -2357,7 +2437,9 @@ def register_guard_parser(sub):
2357
2437
  sync_p.add_argument("--reset-instructions", action="store_true", dest="reset_instructions",
2358
2438
  help="Remove ConductGuard blocks from all instruction files")
2359
2439
  sync_p.add_argument("--proxy-url", default=None,
2360
- help="Override the Guard proxy URL (default: https://api.conductai.ai/proxy/anthropic)")
2440
+ help="Override the Guard gateway URL (default: https://api.conductai.ai/gateway/v1)")
2441
+ sync_p.add_argument("--no-codex-proxy", action="store_true",
2442
+ help="Leave Codex model traffic on its current provider")
2361
2443
  sync_p.add_argument("--no-local-audit", action="store_true",
2362
2444
  help="Skip the local pre-existing API key scan")
2363
2445
 
@@ -28,7 +28,9 @@ VERSION_CACHE_TTL = 60 # seconds
28
28
  WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
29
29
  SIGNING_KEY_PATH = GUARD_DIR / "signing.key"
30
30
  JOURNAL_DIR = GUARD_DIR / "journal"
31
+ JOURNAL_DEAD_DIR = JOURNAL_DIR / "dead-letter"
31
32
  JOURNAL_PID_PATH = JOURNAL_DIR / "drain.pid"
33
+ HOOK_HEARTBEAT_PATH = GUARD_DIR / "hook-heartbeat.json"
32
34
 
33
35
  SNAPSHOT_PATH = GUARD_DIR / "session_snapshot.json"
34
36
  CONDUCT_ENV_PATH = Path.home() / ".conduct" / "env"
@@ -42,6 +44,18 @@ class HookResult:
42
44
  metadata: dict = field(default_factory=dict)
43
45
 
44
46
 
47
+ def record_hook_heartbeat(event_name: str) -> None:
48
+ """Record local hook liveness without storing request or user data."""
49
+ try:
50
+ GUARD_DIR.mkdir(parents=True, exist_ok=True)
51
+ tmp = HOOK_HEARTBEAT_PATH.with_suffix(".tmp")
52
+ tmp.write_text(json.dumps({"event": event_name, "ts": time.time()}))
53
+ tmp.chmod(0o600)
54
+ tmp.replace(HOOK_HEARTBEAT_PATH)
55
+ except Exception:
56
+ pass
57
+
58
+
45
59
  # ── Config loading ────────────────────────────────────────────────────────────
46
60
 
47
61
  def load_config() -> dict:
@@ -155,11 +169,13 @@ def journal_append(payload_str: str, api_url: str) -> None:
155
169
  """Atomically write one event to the journal for the drain daemon to pick up."""
156
170
  try:
157
171
  JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
172
+ JOURNAL_DIR.chmod(0o700)
158
173
  import random
159
174
  name = f"{time.time_ns()}_{random.randint(0, 9999):04d}.json"
160
- entry = json.dumps({"api_url": api_url, "payload": payload_str})
175
+ entry = json.dumps({"api_url": api_url, "payload": payload_str, "attempts": 0})
161
176
  tmp = JOURNAL_DIR / (name + ".tmp")
162
177
  tmp.write_text(entry)
178
+ tmp.chmod(0o600)
163
179
  tmp.rename(JOURNAL_DIR / name)
164
180
  except Exception:
165
181
  pass
@@ -256,6 +272,7 @@ def run_drain_daemon() -> None:
256
272
  try:
257
273
  JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
258
274
  JOURNAL_PID_PATH.write_text(str(_os.getpid()))
275
+ JOURNAL_PID_PATH.chmod(0o600)
259
276
  except Exception:
260
277
  return
261
278
  empty_scans = 0
@@ -268,6 +285,7 @@ def run_drain_daemon() -> None:
268
285
  continue
269
286
  posted_any = False
270
287
  for f in files:
288
+ entry: dict = {}
271
289
  try:
272
290
  entry = json.loads(f.read_text())
273
291
  api_url = entry["api_url"]
@@ -293,8 +311,29 @@ def run_drain_daemon() -> None:
293
311
  urllib.request.urlopen(req, timeout=8)
294
312
  f.unlink(missing_ok=True)
295
313
  posted_any = True
296
- except Exception:
297
- pass
314
+ except Exception as exc:
315
+ attempts = int(entry.get("attempts", 0)) + 1
316
+ status = getattr(exc, "code", None)
317
+ permanent = isinstance(status, int) and 400 <= status < 500 and status not in (408, 429)
318
+ try:
319
+ if permanent or attempts >= 5:
320
+ JOURNAL_DEAD_DIR.mkdir(parents=True, exist_ok=True)
321
+ JOURNAL_DEAD_DIR.chmod(0o700)
322
+ entry["attempts"] = attempts
323
+ entry["last_error"] = f"HTTP {status}" if status else type(exc).__name__
324
+ destination = JOURNAL_DEAD_DIR / f.name
325
+ destination.write_text(json.dumps(entry))
326
+ destination.chmod(0o600)
327
+ f.unlink(missing_ok=True)
328
+ else:
329
+ entry["attempts"] = attempts
330
+ entry["last_error"] = f"HTTP {status}" if status else type(exc).__name__
331
+ tmp = f.with_suffix(".tmp")
332
+ tmp.write_text(json.dumps(entry))
333
+ tmp.chmod(0o600)
334
+ tmp.replace(f)
335
+ except Exception:
336
+ pass
298
337
  if not posted_any:
299
338
  empty_scans += 1
300
339
  time.sleep(2)
@@ -1,4 +1,4 @@
1
- """ConductGuard PostToolUse hook — token tracking + security classifier."""
1
+ """ConductGuard PostToolUse hook — token tracking."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
@@ -12,6 +12,7 @@ from conduct_cli.hooks.base import (
12
12
  detect_ai_tool,
13
13
  load_config,
14
14
  post_event,
15
+ record_hook_heartbeat,
15
16
  run_drain_daemon,
16
17
  )
17
18
  from conduct_cli.hooks.pretooluse import (
@@ -20,139 +21,6 @@ from conduct_cli.hooks.pretooluse import (
20
21
  check_policy,
21
22
  )
22
23
 
23
- # ── Security classifier ───────────────────────────────────────────────────────
24
-
25
- SECRET_PATTERNS = [
26
- (r"sk-[A-Za-z0-9]{20,}", "secret-leak", "high", "Potential OpenAI/Anthropic API key"),
27
- (r"ghp_[A-Za-z0-9]{36}", "secret-leak", "high", "GitHub Personal Access Token"),
28
- (r"AKIA[0-9A-Z]{16}", "secret-leak", "critical", "AWS Access Key ID"),
29
- (r"Bearer\s+[A-Za-z0-9+/=]{20,}", "secret-leak", "high", "Bearer token in output"),
30
- (r"""password\s*=\s*['"][^'"]{4,}""", "secret-leak", "high", "Hardcoded password"),
31
- (r"""api[_-]?key\s*=\s*['"][^'"]{4,}""", "secret-leak", "high", "Hardcoded API key"),
32
- (r"\.\./\.\./\.\./", "path-traversal", "medium", "Path traversal sequence"),
33
- (r"file://", "path-traversal", "medium", "File URI scheme in output"),
34
- (r"eval\s*\(", "injection", "high", "eval() in output"),
35
- (r"exec\s*\(", "injection", "high", "exec() in output"),
36
- (r"__import__\s*\(", "injection", "high", "__import__() in output"),
37
- (r"ssl\.CERT_NONE", "crypto", "high", "SSL verification disabled"),
38
- (r"verify\s*=\s*False", "crypto", "medium", "TLS verification bypassed"),
39
- ]
40
- OWASP_KEYWORDS = [
41
- ("sql injection", "injection", "high", "SQL injection mentioned in AI output"),
42
- ("cross-site scripting", "injection", "high", "XSS mentioned in AI output"),
43
- (" xss ", "injection", "high", "XSS mentioned in AI output"),
44
- ("idor", "injection", "medium", "IDOR mentioned in AI output"),
45
- ("ssrf", "injection", "high", "SSRF mentioned in AI output"),
46
- ("command injection", "injection", "high", "Command injection mentioned in AI output"),
47
- ("auth bypass", "auth-bypass", "high", "Auth bypass mentioned in AI output"),
48
- ]
49
-
50
-
51
- def _classify_text(text: str):
52
- import re as _re
53
- for pattern, ftype, sev, desc in SECRET_PATTERNS:
54
- if _re.search(pattern, text, _re.IGNORECASE):
55
- return ftype, sev, desc, pattern
56
- lower = text.lower()
57
- for kw, ftype, sev, desc in OWASP_KEYWORDS:
58
- if kw in lower:
59
- return ftype, sev, desc, kw
60
- return None, None, None, None
61
-
62
-
63
- def _line_number_from_text(text: str, matched_pattern: str):
64
- import re as _re
65
- if not matched_pattern:
66
- return None
67
- try:
68
- for raw_line in text.splitlines():
69
- m = _re.match(r"^\s*(\d+)\t(.*)$", raw_line)
70
- if m:
71
- lineno, content = int(m.group(1)), m.group(2)
72
- try:
73
- if _re.search(matched_pattern, content, _re.IGNORECASE):
74
- return lineno
75
- except Exception:
76
- if matched_pattern.lower() in content.lower():
77
- return lineno
78
- m = _re.search(matched_pattern, text, _re.IGNORECASE)
79
- if m:
80
- return text[:m.start()].count(chr(10)) + 1
81
- except Exception:
82
- pass
83
- return None
84
-
85
-
86
- def _maybe_emit_security_finding(tool_response, session_id, tool_name, tool_input=None) -> None:
87
- """Classify tool output + input for security findings; POST if flag ON. Never raises."""
88
- try:
89
- cfg = load_config()
90
- except Exception:
91
- return
92
- if not cfg.get("security_emit_enabled", False):
93
- return
94
- workspace_id = cfg.get("workspace_id")
95
- agent_token = cfg.get("agent_token", "")
96
- api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
97
- if not workspace_id:
98
- return
99
-
100
- ti = tool_input or {}
101
- candidates = [("response", str(tool_response))]
102
- if tool_name in ("edit", "multiedit"):
103
- candidates.append(("input", ti.get("new_string", "")))
104
- elif tool_name == "write":
105
- candidates.append(("input", ti.get("content", "")))
106
- elif tool_name in ("bash", "terminal"):
107
- candidates.append(("input", ti.get("command", "")))
108
-
109
- finding_type = severity = description = matched_pattern = scan_text = None
110
- for _src, text in candidates:
111
- ft, sv, desc, pat = _classify_text(text)
112
- if ft:
113
- finding_type, severity, description, matched_pattern, scan_text = ft, sv, desc, pat, text
114
- break
115
-
116
- if not finding_type:
117
- return
118
-
119
- file_path = (
120
- ti.get("file_path") or ti.get("path") or
121
- (ti.get("command", "")[:120] if tool_name in ("bash", "terminal") else None)
122
- ) or None
123
-
124
- line_no = _line_number_from_text(scan_text, matched_pattern) if scan_text else None
125
-
126
- payload = json.dumps({
127
- "tool": detect_ai_tool(),
128
- "severity": severity,
129
- "type": finding_type,
130
- "description": description,
131
- "source_run_id": session_id,
132
- "reporter_email": cfg.get("user_email") or "",
133
- "file": file_path,
134
- "line": line_no,
135
- })
136
- script = (
137
- "import urllib.request\n"
138
- "try:\n"
139
- f" req = urllib.request.Request(\"{api_url}/security-findings?workspace_id={workspace_id}\","
140
- f" data={repr(payload.encode())}, headers={{\"Content-Type\": \"application/json\","
141
- f" \"Authorization\": \"Bearer {agent_token}\"}}, method=\"POST\")\n"
142
- " urllib.request.urlopen(req, timeout=5)\n"
143
- "except: pass\n"
144
- )
145
- try:
146
- import subprocess as _sp
147
- _sp.Popen(
148
- [sys.executable, "-c", script],
149
- stdout=_sp.DEVNULL,
150
- stderr=_sp.DEVNULL,
151
- start_new_session=True,
152
- )
153
- except Exception:
154
- pass
155
-
156
24
 
157
25
  # ── Token reading ─────────────────────────────────────────────────────────────
158
26
 
@@ -392,6 +260,7 @@ def main() -> None:
392
260
  except Exception:
393
261
  sys.exit(0)
394
262
 
263
+ record_hook_heartbeat("post_tool_use")
395
264
  _this_file = Path(__file__).resolve()
396
265
  tool_name = (data.get("tool_name") or "").lower()
397
266
  tool_use_id = data.get("tool_use_id")
@@ -442,7 +311,6 @@ def main() -> None:
442
311
  if action == "warn" and session_id and rule_id:
443
312
  _record_session_warn(session_id, rule_id)
444
313
  post_event(tool_name, {}, decision, rule_id, message, session_id, drain_via=_this_file, blast_radius=blast_radius)
445
- _maybe_emit_security_finding(str(tool_response), session_id, tool_name, tool_input)
446
314
 
447
315
  sys.exit(0)
448
316
 
@@ -30,6 +30,7 @@ from conduct_cli.hooks.base import (
30
30
  journal_append,
31
31
  load_config,
32
32
  post_event,
33
+ record_hook_heartbeat,
33
34
  run_drain_daemon,
34
35
  )
35
36
 
@@ -768,6 +769,7 @@ def main() -> None:
768
769
  data = json.load(sys.stdin)
769
770
  except Exception:
770
771
  sys.exit(0)
772
+ record_hook_heartbeat("pre_tool_use")
771
773
 
772
774
  # Stop hook — session ended, capture for team memory
773
775
  if data.get("hook_event_name") == "Stop" or data.get("stop_hook_active"):