conduct-cli 0.13.0__tar.gz → 0.14.1__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.13.0 → conduct_cli-0.14.1}/PKG-INFO +1 -1
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/pyproject.toml +1 -1
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/guard.py +137 -42
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/hooks/base.py +42 -3
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/hooks/posttooluse.py +2 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/hooks/pretooluse.py +2 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/main.py +0 -2
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- conduct_cli-0.14.1/tests/test_hook_journal.py +64 -0
- conduct_cli-0.14.1/tests/test_install_all.py +24 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_proxy_coverage.py +1 -1
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_proxy_env.py +100 -10
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/LICENSE +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/README.md +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/setup.cfg +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/setup.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_api_client.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_bash_scan_target.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_cli_matrix.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_guard_approvals.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_hook_receipt.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_log_util.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_pretooluse_approval_e2e.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_pretooluse_check_policy.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_report_tool_coverage.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/tests/test_switch.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.1}/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.1
|
|
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.1"
|
|
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.
|
|
@@ -1716,6 +1781,24 @@ def _post_local_findings(cfg: dict, base_url: str, findings: list[dict]) -> None
|
|
|
1716
1781
|
print(f" {YELLOW}Audit upload skipped: {e}{RESET}")
|
|
1717
1782
|
|
|
1718
1783
|
|
|
1784
|
+
_LEGACY_CLAUDE_BYPASSES = {
|
|
1785
|
+
"alias claude='env -u ANTHROPIC_BASE_URL claude'",
|
|
1786
|
+
(
|
|
1787
|
+
"function claude { "
|
|
1788
|
+
"$prev=$env:ANTHROPIC_BASE_URL; $env:ANTHROPIC_BASE_URL=$null; "
|
|
1789
|
+
"try { & claude @args } finally { $env:ANTHROPIC_BASE_URL=$prev } }"
|
|
1790
|
+
),
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def _remove_legacy_claude_bypass(content: str) -> str:
|
|
1795
|
+
"""Remove only proxy-bypass aliases previously generated by Conduct."""
|
|
1796
|
+
return "".join(
|
|
1797
|
+
line for line in content.splitlines(keepends=True)
|
|
1798
|
+
if line.strip() not in _LEGACY_CLAUDE_BYPASSES
|
|
1799
|
+
)
|
|
1800
|
+
|
|
1801
|
+
|
|
1719
1802
|
def _write_proxy_env_windows(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
|
|
1720
1803
|
"""Windows equivalent of _write_proxy_env — PowerShell profile + env.ps1."""
|
|
1721
1804
|
CONDUCT_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -1727,10 +1810,10 @@ def _write_proxy_env_windows(agent_token: str, proxy_url: str) -> tuple[Path, bo
|
|
|
1727
1810
|
SHELL_RC_MARKER,
|
|
1728
1811
|
"# Edit ~/.conduct/env-override.ps1 to add your own vars — sourced after this file.",
|
|
1729
1812
|
"",
|
|
1730
|
-
f'$env:ANTHROPIC_BASE_URL = "{proxy}"',
|
|
1813
|
+
f'$env:ANTHROPIC_BASE_URL = "{proxy}/anthropic"',
|
|
1731
1814
|
f'$env:ANTHROPIC_API_KEY = "{token}"',
|
|
1732
1815
|
"",
|
|
1733
|
-
f'$env:OPENAI_BASE_URL = "{proxy}/openai"',
|
|
1816
|
+
f'$env:OPENAI_BASE_URL = "{proxy}/openai/v1"',
|
|
1734
1817
|
f'$env:OPENAI_API_KEY = "{token}"',
|
|
1735
1818
|
"",
|
|
1736
1819
|
f'$env:PERPLEXITY_BASE_URL = "{proxy}/perplexity"',
|
|
@@ -1749,16 +1832,14 @@ def _write_proxy_env_windows(agent_token: str, proxy_url: str) -> tuple[Path, bo
|
|
|
1749
1832
|
rc = wps5_profile
|
|
1750
1833
|
|
|
1751
1834
|
existing = rc.read_text() if rc.exists() else ""
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
"try { & claude @args } finally { $env:ANTHROPIC_BASE_URL=$prev } }"
|
|
1756
|
-
)
|
|
1835
|
+
cleaned = _remove_legacy_claude_bypass(existing)
|
|
1836
|
+
bypass_removed = cleaned != existing
|
|
1837
|
+
existing = cleaned
|
|
1757
1838
|
PS_SOURCE_LINE = 'if (Test-Path "$HOME/.conduct/env.ps1") { . "$HOME/.conduct/env.ps1" }'
|
|
1758
1839
|
|
|
1759
1840
|
if SHELL_RC_MARKER in existing:
|
|
1760
|
-
if
|
|
1761
|
-
rc.write_text(existing
|
|
1841
|
+
if bypass_removed:
|
|
1842
|
+
rc.write_text(existing)
|
|
1762
1843
|
return rc, True
|
|
1763
1844
|
return rc, False
|
|
1764
1845
|
|
|
@@ -1766,7 +1847,6 @@ def _write_proxy_env_windows(agent_token: str, proxy_url: str) -> tuple[Path, bo
|
|
|
1766
1847
|
addition = (
|
|
1767
1848
|
f"\n\n{SHELL_RC_MARKER}\n"
|
|
1768
1849
|
f"{PS_SOURCE_LINE}\n"
|
|
1769
|
-
f"{CLAUDE_ALIAS}\n"
|
|
1770
1850
|
)
|
|
1771
1851
|
rc.write_text(existing.rstrip() + addition if existing else addition.lstrip())
|
|
1772
1852
|
return rc, True
|
|
@@ -1796,10 +1876,10 @@ def _write_proxy_env(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
|
|
|
1796
1876
|
SHELL_RC_MARKER,
|
|
1797
1877
|
"# Edit ~/.conduct/env-override to add your own vars — sourced after this file.",
|
|
1798
1878
|
"",
|
|
1799
|
-
f'export ANTHROPIC_BASE_URL="{proxy}"',
|
|
1879
|
+
f'export ANTHROPIC_BASE_URL="{proxy}/anthropic"',
|
|
1800
1880
|
f'export ANTHROPIC_API_KEY="{token}"',
|
|
1801
1881
|
"",
|
|
1802
|
-
f'export OPENAI_BASE_URL="{proxy}/openai"',
|
|
1882
|
+
f'export OPENAI_BASE_URL="{proxy}/openai/v1"',
|
|
1803
1883
|
f'export OPENAI_API_KEY="{token}"',
|
|
1804
1884
|
"",
|
|
1805
1885
|
f'export PERPLEXITY_BASE_URL="{proxy}/perplexity"',
|
|
@@ -1824,22 +1904,20 @@ def _write_proxy_env(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
|
|
|
1824
1904
|
return Path(), False
|
|
1825
1905
|
|
|
1826
1906
|
existing = rc.read_text() if rc.exists() else ""
|
|
1827
|
-
|
|
1907
|
+
cleaned = _remove_legacy_claude_bypass(existing)
|
|
1908
|
+
bypass_removed = cleaned != existing
|
|
1909
|
+
existing = cleaned
|
|
1828
1910
|
|
|
1829
1911
|
if SHELL_RC_MARKER in existing:
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
rc.write_text(existing.rstrip() + f"\n{CLAUDE_ALIAS}\n")
|
|
1912
|
+
if bypass_removed:
|
|
1913
|
+
rc.write_text(existing)
|
|
1833
1914
|
return rc, True
|
|
1834
1915
|
return rc, False
|
|
1835
1916
|
|
|
1836
1917
|
rc.parent.mkdir(parents=True, exist_ok=True)
|
|
1837
|
-
# ponytail: alias strips ANTHROPIC_BASE_URL so Claude Code auth isn't routed
|
|
1838
|
-
# through the Guard proxy (Claude Code governs agents; it isn't one)
|
|
1839
1918
|
addition = (
|
|
1840
1919
|
f"\n\n{SHELL_RC_MARKER}\n"
|
|
1841
1920
|
f"{SHELL_SOURCE_LINE}\n"
|
|
1842
|
-
f"{CLAUDE_ALIAS}\n"
|
|
1843
1921
|
)
|
|
1844
1922
|
rc.write_text(existing.rstrip() + addition if existing else addition.lstrip())
|
|
1845
1923
|
return rc, True
|
|
@@ -2152,14 +2230,27 @@ def cmd_guard_status(args):
|
|
|
2152
2230
|
elif d_status == "stale":
|
|
2153
2231
|
daemon_line = f"{YELLOW}stale{RESET} (pid {d_pid}, not flushing — will restart on next tool call)"
|
|
2154
2232
|
else:
|
|
2155
|
-
daemon_line = f"{RED}not running{RESET} — will start on next tool call
|
|
2233
|
+
daemon_line = f"{RED}not running{RESET} — will start on next tool call"
|
|
2234
|
+
|
|
2235
|
+
journal_dir = Path.home() / ".conduct" / "journal"
|
|
2236
|
+
queued_events = len(list(journal_dir.glob("*.json"))) if journal_dir.exists() else 0
|
|
2237
|
+
dead_events = len(list((journal_dir / "dead-letter").glob("*.json"))) if (journal_dir / "dead-letter").exists() else 0
|
|
2238
|
+
heartbeat_path = Path.home() / ".conduct" / "hook-heartbeat.json"
|
|
2239
|
+
heartbeat_line = "never observed"
|
|
2240
|
+
try:
|
|
2241
|
+
heartbeat = json.loads(heartbeat_path.read_text())
|
|
2242
|
+
age = max(0, int(time.time() - float(heartbeat["ts"])))
|
|
2243
|
+
heartbeat_line = f"{heartbeat.get('event', 'hook')} · {age}s ago"
|
|
2244
|
+
except Exception:
|
|
2245
|
+
pass
|
|
2156
2246
|
|
|
2157
2247
|
# Proxy coverage — check active env vars in this shell
|
|
2158
|
-
proxy_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/") + "/proxy"
|
|
2159
2248
|
_env = os.environ
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2249
|
+
def _conduct_proxy_url(value: str) -> bool:
|
|
2250
|
+
return "api.conductai.ai" in value and ("/gateway/v1" in value or "/proxy" in value)
|
|
2251
|
+
anthropic_ok = _conduct_proxy_url(_env.get("ANTHROPIC_BASE_URL", ""))
|
|
2252
|
+
openai_ok = _conduct_proxy_url(_env.get("OPENAI_BASE_URL", ""))
|
|
2253
|
+
perplexity_ok = _conduct_proxy_url(_env.get("PERPLEXITY_BASE_URL", ""))
|
|
2163
2254
|
env_file_exists = (Path.home() / ".conduct" / "env").exists()
|
|
2164
2255
|
|
|
2165
2256
|
def _cov(ok: bool, name: str) -> str:
|
|
@@ -2185,6 +2276,8 @@ def cmd_guard_status(args):
|
|
|
2185
2276
|
print()
|
|
2186
2277
|
print(f"Proxy coverage: {proxy_line}")
|
|
2187
2278
|
print(f"Drain daemon: {daemon_line}")
|
|
2279
|
+
print(f"Event journal: {queued_events} queued · {dead_events} dead-letter")
|
|
2280
|
+
print(f"Hook heartbeat: {heartbeat_line}")
|
|
2188
2281
|
print()
|
|
2189
2282
|
print(f"Today:")
|
|
2190
2283
|
print(f" Sessions: {session_str}")
|
|
@@ -2357,7 +2450,9 @@ def register_guard_parser(sub):
|
|
|
2357
2450
|
sync_p.add_argument("--reset-instructions", action="store_true", dest="reset_instructions",
|
|
2358
2451
|
help="Remove ConductGuard blocks from all instruction files")
|
|
2359
2452
|
sync_p.add_argument("--proxy-url", default=None,
|
|
2360
|
-
help="Override the Guard
|
|
2453
|
+
help="Override the Guard gateway URL (default: https://api.conductai.ai/gateway/v1)")
|
|
2454
|
+
sync_p.add_argument("--no-codex-proxy", action="store_true",
|
|
2455
|
+
help="Leave Codex model traffic on its current provider")
|
|
2361
2456
|
sync_p.add_argument("--no-local-audit", action="store_true",
|
|
2362
2457
|
help="Skip the local pre-existing API key scan")
|
|
2363
2458
|
|
|
@@ -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)
|
|
@@ -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 (
|
|
@@ -259,6 +260,7 @@ def main() -> None:
|
|
|
259
260
|
except Exception:
|
|
260
261
|
sys.exit(0)
|
|
261
262
|
|
|
263
|
+
record_hook_heartbeat("post_tool_use")
|
|
262
264
|
_this_file = Path(__file__).resolve()
|
|
263
265
|
tool_name = (data.get("tool_name") or "").lower()
|
|
264
266
|
tool_use_id = data.get("tool_use_id")
|
|
@@ -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"):
|
|
@@ -1409,7 +1409,6 @@ _FRIENDLY_NAMES = {
|
|
|
1409
1409
|
|
|
1410
1410
|
def cmd_install_all(args):
|
|
1411
1411
|
server, workspace_id, token = _require_auth(args)
|
|
1412
|
-
hdrs = api.headers(workspace_id, token, "application/json")
|
|
1413
1412
|
|
|
1414
1413
|
slugs = _ALL_SLUGS
|
|
1415
1414
|
|
|
@@ -1435,7 +1434,6 @@ def cmd_install_all(args):
|
|
|
1435
1434
|
# Patch server/workspace/auth into the namespace so _require_auth works
|
|
1436
1435
|
a.server = server
|
|
1437
1436
|
a.workspace = workspace_id
|
|
1438
|
-
a.api_key = api_key
|
|
1439
1437
|
a.token = token
|
|
1440
1438
|
|
|
1441
1439
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conduct-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.1
|
|
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
|
|
@@ -39,7 +39,9 @@ tests/test_guard_approvals.py
|
|
|
39
39
|
tests/test_guard_policy.py
|
|
40
40
|
tests/test_guard_savings.py
|
|
41
41
|
tests/test_hook_dispatch.py
|
|
42
|
+
tests/test_hook_journal.py
|
|
42
43
|
tests/test_hook_receipt.py
|
|
44
|
+
tests/test_install_all.py
|
|
43
45
|
tests/test_instruction_files.py
|
|
44
46
|
tests/test_journal_drain.py
|
|
45
47
|
tests/test_log_util.py
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from urllib.error import HTTPError
|
|
6
|
+
|
|
7
|
+
from conduct_cli.hooks import base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _redirect_journal(tmp_path, monkeypatch):
|
|
11
|
+
journal = tmp_path / "journal"
|
|
12
|
+
monkeypatch.setattr(base, "JOURNAL_DIR", journal)
|
|
13
|
+
monkeypatch.setattr(base, "JOURNAL_DEAD_DIR", journal / "dead-letter")
|
|
14
|
+
monkeypatch.setattr(base, "JOURNAL_PID_PATH", journal / "drain.pid")
|
|
15
|
+
monkeypatch.setattr(base.time, "sleep", lambda _seconds: None)
|
|
16
|
+
return journal
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_permanent_delivery_failure_moves_event_to_dead_letter(tmp_path, monkeypatch):
|
|
20
|
+
journal = _redirect_journal(tmp_path, monkeypatch)
|
|
21
|
+
base.journal_append(json.dumps({"workspace_id": "workspace"}), "https://api.test")
|
|
22
|
+
|
|
23
|
+
def _fail(*args, **kwargs):
|
|
24
|
+
raise HTTPError("https://api.test", 400, "bad request", {}, None)
|
|
25
|
+
|
|
26
|
+
monkeypatch.setattr(base.urllib.request, "urlopen", _fail)
|
|
27
|
+
base.run_drain_daemon()
|
|
28
|
+
|
|
29
|
+
assert not list(journal.glob("*.json"))
|
|
30
|
+
dead = list((journal / "dead-letter").glob("*.json"))
|
|
31
|
+
assert len(dead) == 1
|
|
32
|
+
if os.name != "nt":
|
|
33
|
+
assert dead[0].stat().st_mode & 0o077 == 0
|
|
34
|
+
entry = json.loads(dead[0].read_text())
|
|
35
|
+
assert entry["attempts"] == 1
|
|
36
|
+
assert entry["last_error"] == "HTTP 400"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_successful_delivery_removes_event(tmp_path, monkeypatch):
|
|
40
|
+
journal = _redirect_journal(tmp_path, monkeypatch)
|
|
41
|
+
base.journal_append(json.dumps({"workspace_id": "workspace"}), "https://api.test")
|
|
42
|
+
|
|
43
|
+
class _Ok:
|
|
44
|
+
def __enter__(self):
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, *args):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
monkeypatch.setattr(base.urllib.request, "urlopen", lambda *args, **kwargs: _Ok())
|
|
51
|
+
base.run_drain_daemon()
|
|
52
|
+
assert not list(journal.glob("*.json"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_hook_heartbeat_contains_no_request_data(tmp_path, monkeypatch):
|
|
56
|
+
heartbeat = tmp_path / "heartbeat.json"
|
|
57
|
+
monkeypatch.setattr(base, "HOOK_HEARTBEAT_PATH", heartbeat)
|
|
58
|
+
monkeypatch.setattr(base, "GUARD_DIR", tmp_path)
|
|
59
|
+
base.record_hook_heartbeat("post_tool_use")
|
|
60
|
+
data = json.loads(heartbeat.read_text())
|
|
61
|
+
assert data["event"] == "post_tool_use"
|
|
62
|
+
assert set(data) == {"event", "ts"}
|
|
63
|
+
if os.name != "nt":
|
|
64
|
+
assert heartbeat.stat().st_mode & 0o077 == 0
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
from conduct_cli import main as cli
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_install_all_forwards_authenticated_token_to_every_install(monkeypatch):
|
|
7
|
+
installed = []
|
|
8
|
+
token = "cond_agt_test"
|
|
9
|
+
|
|
10
|
+
monkeypatch.setattr(
|
|
11
|
+
cli,
|
|
12
|
+
"_require_auth",
|
|
13
|
+
lambda _args: ("https://api.example.test", "workspace-id", token),
|
|
14
|
+
)
|
|
15
|
+
monkeypatch.setattr(cli, "cmd_install", installed.append)
|
|
16
|
+
|
|
17
|
+
cli.cmd_install_all(
|
|
18
|
+
SimpleNamespace(project="Smoke Test", repo="owner/repo", input=[])
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
assert [args.slug for args in installed] == cli._ALL_SLUGS
|
|
22
|
+
assert all(args.server == "https://api.example.test" for args in installed)
|
|
23
|
+
assert all(args.workspace == "workspace-id" for args in installed)
|
|
24
|
+
assert all(args.token == token for args in installed)
|
|
@@ -41,7 +41,7 @@ _PROXY = "https://api.conductai.ai/proxy"
|
|
|
41
41
|
def test_all_three_active():
|
|
42
42
|
env = {
|
|
43
43
|
"ANTHROPIC_BASE_URL": _PROXY,
|
|
44
|
-
"OPENAI_BASE_URL": f"{_PROXY}/openai",
|
|
44
|
+
"OPENAI_BASE_URL": f"{_PROXY}/openai/v1",
|
|
45
45
|
"PERPLEXITY_BASE_URL": f"{_PROXY}/perplexity",
|
|
46
46
|
}
|
|
47
47
|
line = _run_coverage_block(env, _CFG)
|
|
@@ -8,6 +8,7 @@ Validates:
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
|
+
import json
|
|
11
12
|
import sys
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from unittest import mock
|
|
@@ -38,9 +39,9 @@ def test_env_file_written_with_all_three_pairs(tmp_path, monkeypatch):
|
|
|
38
39
|
rc, sourced = guard._write_proxy_env("abc123", "https://api.conductai.ai/proxy")
|
|
39
40
|
|
|
40
41
|
env = (tmp_path / ".conduct" / "env").read_text()
|
|
41
|
-
assert 'export ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy"' in env
|
|
42
|
+
assert 'export ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy/anthropic"' in env
|
|
42
43
|
assert 'export ANTHROPIC_API_KEY="abc123"' in env
|
|
43
|
-
assert 'export OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai"' in env
|
|
44
|
+
assert 'export OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai/v1"' in env
|
|
44
45
|
assert 'export OPENAI_API_KEY="abc123"' in env
|
|
45
46
|
assert 'export PERPLEXITY_BASE_URL="https://api.conductai.ai/proxy/perplexity"' in env
|
|
46
47
|
assert 'export PERPLEXITY_API_KEY="abc123"' in env
|
|
@@ -60,6 +61,26 @@ def test_shell_rc_source_line_added_once_across_multiple_syncs(tmp_path, monkeyp
|
|
|
60
61
|
rc_text = (tmp_path / ".zshrc").read_text()
|
|
61
62
|
assert rc_text.count(guard.SHELL_RC_MARKER) == 1
|
|
62
63
|
assert rc_text.count(guard.SHELL_SOURCE_LINE) == 1
|
|
64
|
+
assert "env -u ANTHROPIC_BASE_URL" not in rc_text
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytestmark_posix
|
|
68
|
+
def test_sync_removes_generated_claude_proxy_bypass(tmp_path, monkeypatch):
|
|
69
|
+
_redirect_home(tmp_path, monkeypatch)
|
|
70
|
+
monkeypatch.setenv("SHELL", "/bin/zsh")
|
|
71
|
+
zshrc = tmp_path / ".zshrc"
|
|
72
|
+
custom_alias = "alias claude-work='claude --worktree'"
|
|
73
|
+
zshrc.write_text(
|
|
74
|
+
f"{custom_alias}\n{guard.SHELL_RC_MARKER}\n{guard.SHELL_SOURCE_LINE}\n"
|
|
75
|
+
"alias claude='env -u ANTHROPIC_BASE_URL claude'\n"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_, changed = guard._write_proxy_env("abc", "https://api.conductai.ai/gateway/v1")
|
|
79
|
+
|
|
80
|
+
content = zshrc.read_text()
|
|
81
|
+
assert changed is True
|
|
82
|
+
assert "env -u ANTHROPIC_BASE_URL" not in content
|
|
83
|
+
assert custom_alias in content
|
|
63
84
|
|
|
64
85
|
|
|
65
86
|
@pytestmark_posix
|
|
@@ -90,8 +111,8 @@ def test_override_url_picked_up(tmp_path, monkeypatch):
|
|
|
90
111
|
|
|
91
112
|
guard._write_proxy_env("abc", "https://my-self-hosted.example.com/proxy")
|
|
92
113
|
env = (tmp_path / ".conduct" / "env").read_text()
|
|
93
|
-
assert 'ANTHROPIC_BASE_URL="https://my-self-hosted.example.com/proxy"' in env
|
|
94
|
-
assert 'OPENAI_BASE_URL="https://my-self-hosted.example.com/proxy/openai"' in env
|
|
114
|
+
assert 'ANTHROPIC_BASE_URL="https://my-self-hosted.example.com/proxy/anthropic"' in env
|
|
115
|
+
assert 'OPENAI_BASE_URL="https://my-self-hosted.example.com/proxy/openai/v1"' in env
|
|
95
116
|
|
|
96
117
|
|
|
97
118
|
@pytestmark_posix
|
|
@@ -101,8 +122,63 @@ def test_env_file_strips_trailing_slash(tmp_path, monkeypatch):
|
|
|
101
122
|
|
|
102
123
|
guard._write_proxy_env("abc", "https://api.conductai.ai/proxy/")
|
|
103
124
|
env = (tmp_path / ".conduct" / "env").read_text()
|
|
104
|
-
assert 'ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy"' in env
|
|
105
|
-
assert 'OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai"' in env
|
|
125
|
+
assert 'ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy/anthropic"' in env
|
|
126
|
+
assert 'OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai/v1"' in env
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_fetched_legacy_proxy_url_maps_to_gateway_v1():
|
|
130
|
+
assert guard._gateway_v1_url("https://api.conductai.ai/proxy") == "https://api.conductai.ai/gateway/v1"
|
|
131
|
+
assert guard._gateway_v1_url("http://localhost:8000/gateway/v1") == "http://localhost:8000/gateway/v1"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_configure_codex_proxy_is_secret_free(tmp_path, monkeypatch):
|
|
135
|
+
monkeypatch.setattr(guard.Path, "home", lambda: tmp_path)
|
|
136
|
+
codex = tmp_path / ".codex"
|
|
137
|
+
codex.mkdir()
|
|
138
|
+
config = codex / "config.toml"
|
|
139
|
+
config.write_text('model = "gpt-test"\n')
|
|
140
|
+
|
|
141
|
+
assert guard._configure_codex_proxy("https://api.conductai.ai/gateway/v1")
|
|
142
|
+
written = config.read_text()
|
|
143
|
+
assert 'model_provider = "conduct"' in written
|
|
144
|
+
assert 'base_url = "https://api.conductai.ai/gateway/v1/openai/v1"' in written
|
|
145
|
+
assert 'wire_api = "responses"' in written
|
|
146
|
+
assert "cond_agt_" not in written
|
|
147
|
+
assert config.with_suffix(".toml.pre-conduct-proxy").exists()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_codex_hook_install_collapses_duplicate_conduct_entries(tmp_path, monkeypatch):
|
|
151
|
+
monkeypatch.setattr(guard.Path, "home", lambda: tmp_path)
|
|
152
|
+
monkeypatch.setattr(guard, "_best_python", lambda: "/usr/bin/python3")
|
|
153
|
+
codex = tmp_path / ".codex"
|
|
154
|
+
codex.mkdir()
|
|
155
|
+
hook_path = tmp_path / ".conduct" / "hook.py"
|
|
156
|
+
stale_hook_path = tmp_path / ".conductguard" / "hook.py"
|
|
157
|
+
hooks_path = codex / "hooks.json"
|
|
158
|
+
duplicate = {"matcher": ".*", "hooks": [{"type": "command", "command": f"python3 {hook_path}"}]}
|
|
159
|
+
stale = {"matcher": ".*", "hooks": [{"type": "command", "command": f"python3 {stale_hook_path}"}]}
|
|
160
|
+
unrelated = {"matcher": "Read", "hooks": [{"type": "command", "command": "other-hook"}]}
|
|
161
|
+
hooks_path.write_text(json.dumps({"hooks": {
|
|
162
|
+
"PreToolUse": [duplicate, duplicate, stale, unrelated],
|
|
163
|
+
"PostToolUse": [duplicate, duplicate, stale],
|
|
164
|
+
}}))
|
|
165
|
+
|
|
166
|
+
guard._install_codex_hook(hook_path)
|
|
167
|
+
installed = json.loads(hooks_path.read_text())["hooks"]
|
|
168
|
+
for event in ("PreToolUse", "PostToolUse"):
|
|
169
|
+
commands = [
|
|
170
|
+
item["command"]
|
|
171
|
+
for registration in installed[event]
|
|
172
|
+
for item in registration["hooks"]
|
|
173
|
+
]
|
|
174
|
+
conduct_commands = [
|
|
175
|
+
command
|
|
176
|
+
for command in commands
|
|
177
|
+
if str(hook_path) in command
|
|
178
|
+
]
|
|
179
|
+
assert len(conduct_commands) == 1
|
|
180
|
+
assert all(str(stale_hook_path) not in command for command in commands)
|
|
181
|
+
assert installed["PreToolUse"][0] == unrelated
|
|
106
182
|
|
|
107
183
|
|
|
108
184
|
@pytestmark_posix
|
|
@@ -136,9 +212,9 @@ def test_windows_writes_ps1_env_file(tmp_path, monkeypatch):
|
|
|
136
212
|
ps1 = tmp_path / ".conduct" / "env.ps1"
|
|
137
213
|
assert ps1.exists()
|
|
138
214
|
text = ps1.read_text()
|
|
139
|
-
assert '$env:ANTHROPIC_BASE_URL = "https://api.conductai.ai/proxy"' in text
|
|
215
|
+
assert '$env:ANTHROPIC_BASE_URL = "https://api.conductai.ai/proxy/anthropic"' in text
|
|
140
216
|
assert '$env:ANTHROPIC_API_KEY = "abc123"' in text
|
|
141
|
-
assert '$env:OPENAI_BASE_URL = "https://api.conductai.ai/proxy/openai"' in text
|
|
217
|
+
assert '$env:OPENAI_BASE_URL = "https://api.conductai.ai/proxy/openai/v1"' in text
|
|
142
218
|
assert '$env:OPENAI_API_KEY = "abc123"' in text
|
|
143
219
|
assert '$env:PERPLEXITY_BASE_URL = "https://api.conductai.ai/proxy/perplexity"' in text
|
|
144
220
|
assert '$env:PERPLEXITY_API_KEY = "abc123"' in text
|
|
@@ -156,6 +232,20 @@ def test_windows_appends_source_line_to_powershell_profile_once(tmp_path, monkey
|
|
|
156
232
|
text = profile.read_text()
|
|
157
233
|
assert text.count(guard.SHELL_RC_MARKER) == 1
|
|
158
234
|
assert text.count('. "$HOME/.conduct/env.ps1"') == 1
|
|
235
|
+
assert "$env:ANTHROPIC_BASE_URL=$null" not in text
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_windows_sync_removes_generated_claude_proxy_bypass(tmp_path, monkeypatch):
|
|
239
|
+
_redirect_home_windows(tmp_path, monkeypatch)
|
|
240
|
+
profile = tmp_path / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1"
|
|
241
|
+
profile.parent.mkdir(parents=True)
|
|
242
|
+
bypass = next(value for value in guard._LEGACY_CLAUDE_BYPASSES if value.startswith("function claude"))
|
|
243
|
+
profile.write_text(f"{guard.SHELL_RC_MARKER}\n{bypass}\n")
|
|
244
|
+
|
|
245
|
+
_, changed = guard._write_proxy_env("abc", "https://api.conductai.ai/gateway/v1")
|
|
246
|
+
|
|
247
|
+
assert changed is True
|
|
248
|
+
assert "$env:ANTHROPIC_BASE_URL=$null" not in profile.read_text()
|
|
159
249
|
|
|
160
250
|
|
|
161
251
|
def test_windows_prefers_ps7_profile_when_no_wps5_exists(tmp_path, monkeypatch):
|
|
@@ -186,7 +276,7 @@ def test_is_anthropic_proxied_falls_back_to_env_file(tmp_path, monkeypatch):
|
|
|
186
276
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
187
277
|
(tmp_path / ".conduct").mkdir(exist_ok=True)
|
|
188
278
|
(tmp_path / ".conduct" / "env").write_text(
|
|
189
|
-
'export ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy"\n'
|
|
279
|
+
'export ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy/anthropic"\n'
|
|
190
280
|
'export ANTHROPIC_API_KEY="cond_agt_test"\n'
|
|
191
281
|
)
|
|
192
282
|
assert guard._is_anthropic_proxied() is True
|
|
@@ -198,7 +288,7 @@ def test_is_openai_proxied_falls_back_to_env_file(tmp_path, monkeypatch):
|
|
|
198
288
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
199
289
|
(tmp_path / ".conduct").mkdir(exist_ok=True)
|
|
200
290
|
(tmp_path / ".conduct" / "env").write_text(
|
|
201
|
-
'export OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai"\n'
|
|
291
|
+
'export OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai/v1"\n'
|
|
202
292
|
)
|
|
203
293
|
assert guard._is_openai_proxied() is True
|
|
204
294
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|