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.
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/PKG-INFO +1 -1
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/pyproject.toml +1 -1
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/guard.py +109 -27
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/base.py +42 -3
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/posttooluse.py +3 -135
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/pretooluse.py +2 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/main.py +2 -404
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_cli_matrix.py +0 -1
- conduct_cli-0.14.0/tests/test_hook_journal.py +64 -0
- conduct_cli-0.14.0/tests/test_install_all.py +24 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_proxy_coverage.py +1 -1
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_proxy_env.py +66 -10
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/LICENSE +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/README.md +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/setup.cfg +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/setup.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_api_client.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_bash_scan_target.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_guard_approvals.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_hook_receipt.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_log_util.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_pretooluse_approval_e2e.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_pretooluse_check_policy.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_report_tool_coverage.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.12.0 → conduct_cli-0.14.0}/tests/test_switch.py +0 -0
- {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.
|
|
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.
|
|
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}/
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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"):
|