conduct-cli 0.13.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.13.0 → conduct_cli-0.14.0}/PKG-INFO +1 -1
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/pyproject.toml +1 -1
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/guard.py +109 -27
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/base.py +42 -3
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/posttooluse.py +2 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/pretooluse.py +2 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/main.py +0 -2
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- 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.13.0 → conduct_cli-0.14.0}/tests/test_proxy_coverage.py +1 -1
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_proxy_env.py +66 -10
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/LICENSE +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/README.md +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/setup.cfg +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/setup.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_api_client.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_bash_scan_target.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_cli_matrix.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_guard_approvals.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_hook_receipt.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_log_util.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_pretooluse_approval_e2e.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_pretooluse_check_policy.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_report_tool_coverage.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.13.0 → conduct_cli-0.14.0}/tests/test_switch.py +0 -0
- {conduct_cli-0.13.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)
|
|
@@ -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.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
|
|
@@ -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
|
|
@@ -90,8 +91,8 @@ def test_override_url_picked_up(tmp_path, monkeypatch):
|
|
|
90
91
|
|
|
91
92
|
guard._write_proxy_env("abc", "https://my-self-hosted.example.com/proxy")
|
|
92
93
|
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
|
|
94
|
+
assert 'ANTHROPIC_BASE_URL="https://my-self-hosted.example.com/proxy/anthropic"' in env
|
|
95
|
+
assert 'OPENAI_BASE_URL="https://my-self-hosted.example.com/proxy/openai/v1"' in env
|
|
95
96
|
|
|
96
97
|
|
|
97
98
|
@pytestmark_posix
|
|
@@ -101,8 +102,63 @@ def test_env_file_strips_trailing_slash(tmp_path, monkeypatch):
|
|
|
101
102
|
|
|
102
103
|
guard._write_proxy_env("abc", "https://api.conductai.ai/proxy/")
|
|
103
104
|
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
|
|
105
|
+
assert 'ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy/anthropic"' in env
|
|
106
|
+
assert 'OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai/v1"' in env
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_fetched_legacy_proxy_url_maps_to_gateway_v1():
|
|
110
|
+
assert guard._gateway_v1_url("https://api.conductai.ai/proxy") == "https://api.conductai.ai/gateway/v1"
|
|
111
|
+
assert guard._gateway_v1_url("http://localhost:8000/gateway/v1") == "http://localhost:8000/gateway/v1"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_configure_codex_proxy_is_secret_free(tmp_path, monkeypatch):
|
|
115
|
+
monkeypatch.setattr(guard.Path, "home", lambda: tmp_path)
|
|
116
|
+
codex = tmp_path / ".codex"
|
|
117
|
+
codex.mkdir()
|
|
118
|
+
config = codex / "config.toml"
|
|
119
|
+
config.write_text('model = "gpt-test"\n')
|
|
120
|
+
|
|
121
|
+
assert guard._configure_codex_proxy("https://api.conductai.ai/gateway/v1")
|
|
122
|
+
written = config.read_text()
|
|
123
|
+
assert 'model_provider = "conduct"' in written
|
|
124
|
+
assert 'base_url = "https://api.conductai.ai/gateway/v1/openai/v1"' in written
|
|
125
|
+
assert 'wire_api = "responses"' in written
|
|
126
|
+
assert "cond_agt_" not in written
|
|
127
|
+
assert config.with_suffix(".toml.pre-conduct-proxy").exists()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_codex_hook_install_collapses_duplicate_conduct_entries(tmp_path, monkeypatch):
|
|
131
|
+
monkeypatch.setattr(guard.Path, "home", lambda: tmp_path)
|
|
132
|
+
monkeypatch.setattr(guard, "_best_python", lambda: "/usr/bin/python3")
|
|
133
|
+
codex = tmp_path / ".codex"
|
|
134
|
+
codex.mkdir()
|
|
135
|
+
hook_path = tmp_path / ".conduct" / "hook.py"
|
|
136
|
+
stale_hook_path = tmp_path / ".conductguard" / "hook.py"
|
|
137
|
+
hooks_path = codex / "hooks.json"
|
|
138
|
+
duplicate = {"matcher": ".*", "hooks": [{"type": "command", "command": f"python3 {hook_path}"}]}
|
|
139
|
+
stale = {"matcher": ".*", "hooks": [{"type": "command", "command": f"python3 {stale_hook_path}"}]}
|
|
140
|
+
unrelated = {"matcher": "Read", "hooks": [{"type": "command", "command": "other-hook"}]}
|
|
141
|
+
hooks_path.write_text(json.dumps({"hooks": {
|
|
142
|
+
"PreToolUse": [duplicate, duplicate, stale, unrelated],
|
|
143
|
+
"PostToolUse": [duplicate, duplicate, stale],
|
|
144
|
+
}}))
|
|
145
|
+
|
|
146
|
+
guard._install_codex_hook(hook_path)
|
|
147
|
+
installed = json.loads(hooks_path.read_text())["hooks"]
|
|
148
|
+
for event in ("PreToolUse", "PostToolUse"):
|
|
149
|
+
commands = [
|
|
150
|
+
item["command"]
|
|
151
|
+
for registration in installed[event]
|
|
152
|
+
for item in registration["hooks"]
|
|
153
|
+
]
|
|
154
|
+
conduct_commands = [
|
|
155
|
+
command
|
|
156
|
+
for command in commands
|
|
157
|
+
if str(hook_path) in command
|
|
158
|
+
]
|
|
159
|
+
assert len(conduct_commands) == 1
|
|
160
|
+
assert all(str(stale_hook_path) not in command for command in commands)
|
|
161
|
+
assert installed["PreToolUse"][0] == unrelated
|
|
106
162
|
|
|
107
163
|
|
|
108
164
|
@pytestmark_posix
|
|
@@ -136,9 +192,9 @@ def test_windows_writes_ps1_env_file(tmp_path, monkeypatch):
|
|
|
136
192
|
ps1 = tmp_path / ".conduct" / "env.ps1"
|
|
137
193
|
assert ps1.exists()
|
|
138
194
|
text = ps1.read_text()
|
|
139
|
-
assert '$env:ANTHROPIC_BASE_URL = "https://api.conductai.ai/proxy"' in text
|
|
195
|
+
assert '$env:ANTHROPIC_BASE_URL = "https://api.conductai.ai/proxy/anthropic"' in text
|
|
140
196
|
assert '$env:ANTHROPIC_API_KEY = "abc123"' in text
|
|
141
|
-
assert '$env:OPENAI_BASE_URL = "https://api.conductai.ai/proxy/openai"' in text
|
|
197
|
+
assert '$env:OPENAI_BASE_URL = "https://api.conductai.ai/proxy/openai/v1"' in text
|
|
142
198
|
assert '$env:OPENAI_API_KEY = "abc123"' in text
|
|
143
199
|
assert '$env:PERPLEXITY_BASE_URL = "https://api.conductai.ai/proxy/perplexity"' in text
|
|
144
200
|
assert '$env:PERPLEXITY_API_KEY = "abc123"' in text
|
|
@@ -186,7 +242,7 @@ def test_is_anthropic_proxied_falls_back_to_env_file(tmp_path, monkeypatch):
|
|
|
186
242
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
187
243
|
(tmp_path / ".conduct").mkdir(exist_ok=True)
|
|
188
244
|
(tmp_path / ".conduct" / "env").write_text(
|
|
189
|
-
'export ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy"\n'
|
|
245
|
+
'export ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy/anthropic"\n'
|
|
190
246
|
'export ANTHROPIC_API_KEY="cond_agt_test"\n'
|
|
191
247
|
)
|
|
192
248
|
assert guard._is_anthropic_proxied() is True
|
|
@@ -198,7 +254,7 @@ def test_is_openai_proxied_falls_back_to_env_file(tmp_path, monkeypatch):
|
|
|
198
254
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
199
255
|
(tmp_path / ".conduct").mkdir(exist_ok=True)
|
|
200
256
|
(tmp_path / ".conduct" / "env").write_text(
|
|
201
|
-
'export OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai"\n'
|
|
257
|
+
'export OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai/v1"\n'
|
|
202
258
|
)
|
|
203
259
|
assert guard._is_openai_proxied() is True
|
|
204
260
|
|
|
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
|