conduct-cli 0.5.8__tar.gz → 0.6.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.5.8 → conduct_cli-0.6.0}/PKG-INFO +1 -1
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/pyproject.toml +1 -1
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/guard.py +231 -3
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/guardmcp.py +4 -0
- conduct_cli-0.6.0/src/conduct_cli/log_util.py +180 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/main.py +4 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/mcp_server.py +4 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli.egg-info/SOURCES.txt +4 -0
- conduct_cli-0.6.0/tests/test_command_word_matcher.py +51 -0
- conduct_cli-0.6.0/tests/test_log_util.py +123 -0
- conduct_cli-0.6.0/tests/test_proxy_env.py +104 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/README.md +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/setup.cfg +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/setup.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/hook_session_start_template.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/hook_template.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.5.8 → conduct_cli-0.6.0}/tests/test_switch.py +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""conduct guard — team policy + MCP registration subcommand."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import sys
|
|
5
6
|
import urllib.error
|
|
6
7
|
import urllib.request
|
|
@@ -34,6 +35,37 @@ def _read_template(name: str) -> str:
|
|
|
34
35
|
import json as _json
|
|
35
36
|
import re as _re
|
|
36
37
|
|
|
38
|
+
def _bash_command_words(cmd: str) -> list[str]:
|
|
39
|
+
"""First token of each pipeline / conditional / semicolon-separated segment.
|
|
40
|
+
|
|
41
|
+
Argument bodies, heredoc contents, and quoted strings are ignored — so a
|
|
42
|
+
privilege-escalation matcher firing on the literal word inside a body
|
|
43
|
+
payload (issue body, commit message, file write) is no longer possible.
|
|
44
|
+
Returns lower-cased command words for case-insensitive matching.
|
|
45
|
+
"""
|
|
46
|
+
import shlex as _shlex
|
|
47
|
+
words: list[str] = []
|
|
48
|
+
if not cmd:
|
|
49
|
+
return words
|
|
50
|
+
# Split on bash control operators (||, &&, |, ;, & and bg/fg keywords).
|
|
51
|
+
for segment in _re.split(r"\|\||&&|[|;&\n]+|\bthen\b|\belse\b|\bdo\b", cmd):
|
|
52
|
+
seg = segment.strip()
|
|
53
|
+
if not seg:
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
parts = _shlex.split(seg)
|
|
57
|
+
except ValueError:
|
|
58
|
+
continue
|
|
59
|
+
if parts:
|
|
60
|
+
# Strip env-var prefixes (FOO=bar bin baz → bin)
|
|
61
|
+
for p in parts:
|
|
62
|
+
if "=" in p and not p.startswith(("-", "/")):
|
|
63
|
+
continue
|
|
64
|
+
words.append(p.lower())
|
|
65
|
+
break
|
|
66
|
+
return words
|
|
67
|
+
|
|
68
|
+
|
|
37
69
|
def _check_policy(tool_name, tool_input, tokens_before=0):
|
|
38
70
|
"""Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
|
|
39
71
|
if not POLICY_PATH.exists():
|
|
@@ -46,12 +78,24 @@ def _check_policy(tool_name, tool_input, tokens_before=0):
|
|
|
46
78
|
rules = policy.get("rules", [])
|
|
47
79
|
input_text = _json.dumps(tool_input)
|
|
48
80
|
path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
|
|
81
|
+
# Extract command words only when bash is involved — empty list for other tools.
|
|
82
|
+
command_words = (
|
|
83
|
+
_bash_command_words(str(tool_input.get("command", "")))
|
|
84
|
+
if tool_name.lower() == "bash" else []
|
|
85
|
+
)
|
|
49
86
|
|
|
50
87
|
for rule in rules:
|
|
51
88
|
match_tool = (rule.get("match_tool") or "*").lower()
|
|
52
89
|
if match_tool != "*":
|
|
53
90
|
if tool_name not in [t.strip() for t in match_tool.split(",")]:
|
|
54
91
|
continue
|
|
92
|
+
# New: structural match against the actual binary being invoked.
|
|
93
|
+
# Single string or list of strings; case-insensitive equality.
|
|
94
|
+
cw = rule.get("match_command_word")
|
|
95
|
+
if cw:
|
|
96
|
+
deny = [w.lower() for w in (cw if isinstance(cw, list) else [cw])]
|
|
97
|
+
if not any(w in deny for w in command_words):
|
|
98
|
+
continue
|
|
55
99
|
pattern = rule.get("match_pattern")
|
|
56
100
|
if pattern:
|
|
57
101
|
try:
|
|
@@ -847,6 +891,35 @@ def cmd_guard_sync(args):
|
|
|
847
891
|
_save_policy(policy)
|
|
848
892
|
print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
|
|
849
893
|
|
|
894
|
+
# Write LLM proxy env vars so any AI tool (Claude Code, Cursor, Codex, …)
|
|
895
|
+
# routes through Conduct Guard. Customer-overridable via --proxy-url or
|
|
896
|
+
# CONDUCT_PROXY_URL env var; defaults to api.conductai.ai/proxy.
|
|
897
|
+
proxy_url = (
|
|
898
|
+
getattr(args, "proxy_url", None)
|
|
899
|
+
or os.environ.get("CONDUCT_PROXY_URL")
|
|
900
|
+
or DEFAULT_PROXY_URL
|
|
901
|
+
)
|
|
902
|
+
member_token = cfg.get("member_token", "")
|
|
903
|
+
rc_path, newly_sourced = _write_proxy_env(member_token, proxy_url)
|
|
904
|
+
if member_token:
|
|
905
|
+
print(f" {GREEN}Proxy env written:{RESET} ~/.conduct/env → {proxy_url}")
|
|
906
|
+
if newly_sourced:
|
|
907
|
+
print(f" {CYAN}Run `source {rc_path}` (or open a new shell) to activate.{RESET}")
|
|
908
|
+
|
|
909
|
+
# Local key audit — scan known config files for real provider keys that
|
|
910
|
+
# may have been pasted before the dev onboarded onto Conduct. Findings
|
|
911
|
+
# surface in /guard/audit with a 'LOCAL RISK' badge. --no-local-audit
|
|
912
|
+
# skips the scan (CI runners, dev throw-away setups, etc.)
|
|
913
|
+
if not getattr(args, "no_local_audit", False):
|
|
914
|
+
local = _scan_local_keys()
|
|
915
|
+
if local:
|
|
916
|
+
print(f" {YELLOW}Local risk: {len(local)} pre-existing key(s) found{RESET}")
|
|
917
|
+
for f in local[:5]:
|
|
918
|
+
print(f" [{f['provider']}] {f['path']}:{f['line']} {f['masked']}")
|
|
919
|
+
if len(local) > 5:
|
|
920
|
+
print(f" …{len(local) - 5} more — see /guard/audit")
|
|
921
|
+
_post_local_findings(cfg, base_url, local)
|
|
922
|
+
|
|
850
923
|
if getattr(args, "cursor", False):
|
|
851
924
|
_write_cursorrules(policy)
|
|
852
925
|
|
|
@@ -881,10 +954,161 @@ def cmd_guard_sync(args):
|
|
|
881
954
|
# Print Claude.ai remote MCP URL — requires one-time browser paste
|
|
882
955
|
member_token = cfg.get("member_token", "")
|
|
883
956
|
if workspace_id and member_token:
|
|
884
|
-
mcp_url = f"https://api.conductai.ai/guard/mcp?workspace_id={workspace_id}
|
|
957
|
+
mcp_url = f"https://api.conductai.ai/guard/mcp?workspace_id={workspace_id}"
|
|
885
958
|
print(f"\n{BOLD}Claude.ai{RESET} (one-time browser setup):")
|
|
886
|
-
print(f" Settings → MCP Servers → Add custom server
|
|
887
|
-
print(f"
|
|
959
|
+
print(f" Settings → MCP Servers → Add custom server")
|
|
960
|
+
print(f" URL: {CYAN}{mcp_url}{RESET}")
|
|
961
|
+
print(f" Auth: {CYAN}Bearer {member_token}{RESET}\n")
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
CONDUCT_DIR = Path.home() / ".conduct"
|
|
965
|
+
PROXY_ENV_FILE = CONDUCT_DIR / "env"
|
|
966
|
+
PROXY_OVERRIDE = CONDUCT_DIR / "env-override"
|
|
967
|
+
DEFAULT_PROXY_URL = "https://api.conductai.ai/proxy"
|
|
968
|
+
SHELL_RC_MARKER = "# Conduct Guard Proxy — managed by `conduct guard sync`"
|
|
969
|
+
SHELL_SOURCE_LINE = "[ -f ~/.conduct/env ] && . ~/.conduct/env"
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
# ── Local key audit ──────────────────────────────────────────────────────────
|
|
973
|
+
# Patterns that match real provider keys (NOT our member tokens).
|
|
974
|
+
# Tuples of (provider, regex). Compiled once at import.
|
|
975
|
+
import re as _re
|
|
976
|
+
|
|
977
|
+
_KEY_PATTERNS = [
|
|
978
|
+
("anthropic", _re.compile(r"sk-ant-(?:api\d+-)?[A-Za-z0-9_-]{20,}")),
|
|
979
|
+
("openai", _re.compile(r"sk-(?:proj-)?[A-Za-z0-9]{20,}")), # sk-… (not sk-ant-)
|
|
980
|
+
("perplexity", _re.compile(r"pplx-[A-Za-z0-9]{20,}")),
|
|
981
|
+
]
|
|
982
|
+
|
|
983
|
+
# Files to scan on the dev's machine. Strings are user-home-relative paths.
|
|
984
|
+
_SCAN_PATHS = [
|
|
985
|
+
"~/.claude/settings.json",
|
|
986
|
+
"~/.claude.json",
|
|
987
|
+
"~/.cursor/mcp.json",
|
|
988
|
+
"~/Library/Application Support/Cursor/User/settings.json",
|
|
989
|
+
"~/.codex/auth.json",
|
|
990
|
+
"~/.codex/config.toml",
|
|
991
|
+
"~/.zshrc",
|
|
992
|
+
"~/.bashrc",
|
|
993
|
+
"~/.bash_profile",
|
|
994
|
+
"~/.profile",
|
|
995
|
+
"~/.config/aider/.aider.conf.yml",
|
|
996
|
+
]
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _scan_local_keys() -> list[dict]:
|
|
1000
|
+
"""Scan the dev's machine for pre-existing real provider API keys.
|
|
1001
|
+
|
|
1002
|
+
Returns a list of findings. Each finding is dict with:
|
|
1003
|
+
provider, path, masked, line (best-effort)
|
|
1004
|
+
|
|
1005
|
+
Skips our own guard-mt-… tokens — those aren't real keys.
|
|
1006
|
+
"""
|
|
1007
|
+
findings: list[dict] = []
|
|
1008
|
+
home = Path.home()
|
|
1009
|
+
for raw in _SCAN_PATHS:
|
|
1010
|
+
path = Path(raw.replace("~", str(home)))
|
|
1011
|
+
if not path.exists() or not path.is_file():
|
|
1012
|
+
continue
|
|
1013
|
+
try:
|
|
1014
|
+
text = path.read_text(errors="ignore")
|
|
1015
|
+
except Exception:
|
|
1016
|
+
continue
|
|
1017
|
+
# OpenAI's sk- pattern will also catch sk-ant-… — order matters:
|
|
1018
|
+
# try anthropic first, then strip its hits from the OpenAI pass.
|
|
1019
|
+
seen: set[tuple[str, str]] = set()
|
|
1020
|
+
for provider, pat in _KEY_PATTERNS:
|
|
1021
|
+
for m in pat.finditer(text):
|
|
1022
|
+
key = m.group(0)
|
|
1023
|
+
if (provider, key) in seen:
|
|
1024
|
+
continue
|
|
1025
|
+
if key.startswith("guard-mt-"):
|
|
1026
|
+
continue
|
|
1027
|
+
# Compute line number for usability
|
|
1028
|
+
line = text[: m.start()].count("\n") + 1
|
|
1029
|
+
findings.append({
|
|
1030
|
+
"provider": provider,
|
|
1031
|
+
"path": str(path),
|
|
1032
|
+
"masked": key[:10] + "…" + key[-4:],
|
|
1033
|
+
"line": line,
|
|
1034
|
+
})
|
|
1035
|
+
seen.add((provider, key))
|
|
1036
|
+
return findings
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _post_local_findings(cfg: dict, base_url: str, findings: list[dict]) -> None:
|
|
1040
|
+
"""Upload findings to /guard/local-audit-findings. Best-effort, never fatal."""
|
|
1041
|
+
try:
|
|
1042
|
+
_req(
|
|
1043
|
+
"POST",
|
|
1044
|
+
f"{base_url}/guard/local-audit-findings",
|
|
1045
|
+
body={
|
|
1046
|
+
"user_email": cfg.get("user_email", ""),
|
|
1047
|
+
"findings": findings,
|
|
1048
|
+
},
|
|
1049
|
+
api_key=cfg.get("api_key", ""),
|
|
1050
|
+
token=cfg.get("member_token", ""),
|
|
1051
|
+
)
|
|
1052
|
+
except Exception as e:
|
|
1053
|
+
print(f" {YELLOW}Audit upload skipped: {e}{RESET}")
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _write_proxy_env(member_token: str, proxy_url: str) -> tuple[Path, bool]:
|
|
1057
|
+
"""Write ~/.conduct/env with the 3 provider env-var pairs and ensure the
|
|
1058
|
+
user's shell rc sources it.
|
|
1059
|
+
|
|
1060
|
+
Idempotent — env file rewritten each sync. Shell rc gets the source line
|
|
1061
|
+
appended once (guarded by SHELL_RC_MARKER). Returns (rc_path, newly_sourced).
|
|
1062
|
+
User-managed customizations go in ~/.conduct/env-override which is sourced
|
|
1063
|
+
after the managed file.
|
|
1064
|
+
"""
|
|
1065
|
+
if not member_token:
|
|
1066
|
+
print(f" {YELLOW}Proxy env skipped — no member token in config{RESET}")
|
|
1067
|
+
return Path(), False
|
|
1068
|
+
|
|
1069
|
+
CONDUCT_DIR.mkdir(parents=True, exist_ok=True)
|
|
1070
|
+
token = f"guard-mt-{member_token}"
|
|
1071
|
+
proxy = proxy_url.rstrip("/")
|
|
1072
|
+
|
|
1073
|
+
PROXY_ENV_FILE.write_text("\n".join([
|
|
1074
|
+
SHELL_RC_MARKER,
|
|
1075
|
+
"# Edit ~/.conduct/env-override to add your own vars — sourced after this file.",
|
|
1076
|
+
"",
|
|
1077
|
+
f'export ANTHROPIC_BASE_URL="{proxy}"',
|
|
1078
|
+
f'export ANTHROPIC_API_KEY="{token}"',
|
|
1079
|
+
"",
|
|
1080
|
+
f'export OPENAI_BASE_URL="{proxy}/openai"',
|
|
1081
|
+
f'export OPENAI_API_KEY="{token}"',
|
|
1082
|
+
"",
|
|
1083
|
+
f'export PERPLEXITY_BASE_URL="{proxy}/perplexity"',
|
|
1084
|
+
f'export PERPLEXITY_API_KEY="{token}"',
|
|
1085
|
+
"",
|
|
1086
|
+
"[ -f ~/.conduct/env-override ] && . ~/.conduct/env-override",
|
|
1087
|
+
"",
|
|
1088
|
+
]))
|
|
1089
|
+
|
|
1090
|
+
shell = os.environ.get("SHELL", "").lower()
|
|
1091
|
+
if "zsh" in shell:
|
|
1092
|
+
rc = Path.home() / ".zshrc"
|
|
1093
|
+
elif "bash" in shell:
|
|
1094
|
+
rc = Path.home() / ".bashrc"
|
|
1095
|
+
elif "fish" in shell:
|
|
1096
|
+
# fish doesn't source bash-style files; tell the user
|
|
1097
|
+
print(f" {YELLOW}fish detected — add this to ~/.config/fish/config.fish:{RESET}")
|
|
1098
|
+
print(f" bass source ~/.conduct/env")
|
|
1099
|
+
return Path(), False
|
|
1100
|
+
else:
|
|
1101
|
+
print(f" {YELLOW}Unknown shell. Source manually: . ~/.conduct/env{RESET}")
|
|
1102
|
+
return Path(), False
|
|
1103
|
+
|
|
1104
|
+
existing = rc.read_text() if rc.exists() else ""
|
|
1105
|
+
if SHELL_RC_MARKER in existing:
|
|
1106
|
+
return rc, False
|
|
1107
|
+
|
|
1108
|
+
rc.parent.mkdir(parents=True, exist_ok=True)
|
|
1109
|
+
addition = f"\n\n{SHELL_RC_MARKER}\n{SHELL_SOURCE_LINE}\n"
|
|
1110
|
+
rc.write_text(existing.rstrip() + addition if existing else addition.lstrip())
|
|
1111
|
+
return rc, True
|
|
888
1112
|
|
|
889
1113
|
|
|
890
1114
|
def _write_cursorrules(policy: dict) -> None:
|
|
@@ -1298,6 +1522,10 @@ def register_guard_parser(sub):
|
|
|
1298
1522
|
sync_p = guard_sub.add_parser("sync", help="Refresh policy and re-scan for AI tools")
|
|
1299
1523
|
sync_p.add_argument("--cursor", action="store_true", help="Write active Guard policies to .cursorrules")
|
|
1300
1524
|
sync_p.add_argument("--dry-run", action="store_true", help="Preview policy changes without writing anything")
|
|
1525
|
+
sync_p.add_argument("--proxy-url", default=None,
|
|
1526
|
+
help="Override the Guard proxy URL (default: https://api.conductai.ai/proxy)")
|
|
1527
|
+
sync_p.add_argument("--no-local-audit", action="store_true",
|
|
1528
|
+
help="Skip the local pre-existing API key scan")
|
|
1301
1529
|
|
|
1302
1530
|
# conduct guard status
|
|
1303
1531
|
guard_sub.add_parser("status", help="Show today's spend and violations")
|
|
@@ -467,5 +467,9 @@ def main() -> None:
|
|
|
467
467
|
_err(msg_id, -32601, f"Method not found: {method}")
|
|
468
468
|
|
|
469
469
|
|
|
470
|
+
# ponytail: telemetry error interceptor (#718)
|
|
471
|
+
from .log_util import install_error_interceptor as _ei
|
|
472
|
+
main = _ei(main)
|
|
473
|
+
|
|
470
474
|
if __name__ == "__main__":
|
|
471
475
|
main()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Shared log helper + error interceptor for conduct-cli (#718 Phase 1).
|
|
2
|
+
|
|
3
|
+
Stdlib only. No new dependencies. Failures here MUST NEVER block the user —
|
|
4
|
+
telemetry is best-effort. The POST runs in a daemon thread with a join
|
|
5
|
+
timeout so even a hung endpoint can't delay CLI exit by more than ~2s.
|
|
6
|
+
|
|
7
|
+
Public API:
|
|
8
|
+
log(level, message, **context) # print + (warn/err) post
|
|
9
|
+
install_error_interceptor(main_fn) # wrap top-level entry point
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import traceback
|
|
18
|
+
import urllib.request
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
PREFIX = "[conduct]"
|
|
25
|
+
TOOL = "conduct-cli"
|
|
26
|
+
|
|
27
|
+
_COLORS = {
|
|
28
|
+
"info": "\033[37m",
|
|
29
|
+
"ok": "\033[32m",
|
|
30
|
+
"warning": "\033[33m",
|
|
31
|
+
"error": "\033[31m",
|
|
32
|
+
}
|
|
33
|
+
_RESET = "\033[0m"
|
|
34
|
+
|
|
35
|
+
_API_BASE = os.environ.get("CONDUCT_API_URL", "https://api.conductai.ai").rstrip("/")
|
|
36
|
+
_CONFIG = Path.home() / ".conductguard" / "config.json"
|
|
37
|
+
_LOG_FILE = Path(
|
|
38
|
+
os.environ.get("CONDUCT_LOG_FILE")
|
|
39
|
+
or str(Path.home() / ".conductguard" / "logs" / "events.jsonl")
|
|
40
|
+
)
|
|
41
|
+
_POST_TIMEOUT_S = 2.0
|
|
42
|
+
|
|
43
|
+
_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
|
44
|
+
_BACKUPS = 3 # keep events.jsonl.1, .2, .3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rotate() -> None:
|
|
48
|
+
"""events.jsonl → .1 → .2 → .3; oldest dropped. Silent on failure."""
|
|
49
|
+
base = str(_LOG_FILE)
|
|
50
|
+
try:
|
|
51
|
+
oldest = Path(f"{base}.{_BACKUPS}")
|
|
52
|
+
if oldest.exists():
|
|
53
|
+
oldest.unlink()
|
|
54
|
+
for i in range(_BACKUPS - 1, 0, -1):
|
|
55
|
+
src = Path(f"{base}.{i}")
|
|
56
|
+
if src.exists():
|
|
57
|
+
src.replace(Path(f"{base}.{i + 1}"))
|
|
58
|
+
if _LOG_FILE.exists():
|
|
59
|
+
_LOG_FILE.replace(Path(f"{base}.1"))
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _write_local(event_type: str, message: str, tb: str | None, ctx: dict) -> None:
|
|
65
|
+
"""Append one JSONL event to the local log file. Foreground, fast, silent on failure."""
|
|
66
|
+
try:
|
|
67
|
+
_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
if _LOG_FILE.exists() and _LOG_FILE.stat().st_size >= _MAX_BYTES:
|
|
69
|
+
_rotate()
|
|
70
|
+
line = json.dumps({
|
|
71
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
72
|
+
"tool": TOOL,
|
|
73
|
+
"version": _version(),
|
|
74
|
+
"event_type": event_type,
|
|
75
|
+
"message": message,
|
|
76
|
+
"traceback": tb,
|
|
77
|
+
"run_id": os.environ.get("CONDUCT_RUN_ID"),
|
|
78
|
+
"session_id": os.environ.get("CONDUCT_SESSION_ID"),
|
|
79
|
+
"context": ctx or {},
|
|
80
|
+
})
|
|
81
|
+
with _LOG_FILE.open("a", encoding="utf-8") as f:
|
|
82
|
+
f.write(line + "\n")
|
|
83
|
+
except Exception:
|
|
84
|
+
pass # disk failure must never block the user
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@lru_cache(maxsize=1)
|
|
88
|
+
def _read_creds() -> tuple[str | None, str | None]:
|
|
89
|
+
"""(member_token, workspace_id) from ~/.conductguard/config.json, or (None, None)."""
|
|
90
|
+
try:
|
|
91
|
+
cfg = json.loads(_CONFIG.read_text())
|
|
92
|
+
return cfg.get("member_token"), cfg.get("workspace_id")
|
|
93
|
+
except Exception:
|
|
94
|
+
return None, None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@lru_cache(maxsize=1)
|
|
98
|
+
def _version() -> str:
|
|
99
|
+
try:
|
|
100
|
+
from importlib.metadata import version
|
|
101
|
+
return version("conduct-cli")
|
|
102
|
+
except Exception:
|
|
103
|
+
return "unknown"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _post_event_sync(event_type: str, message: str, tb: str | None, ctx: dict) -> None:
|
|
107
|
+
token, workspace_id = _read_creds()
|
|
108
|
+
if not token:
|
|
109
|
+
return # no creds = no telemetry, silent
|
|
110
|
+
body = json.dumps({
|
|
111
|
+
"tool": TOOL,
|
|
112
|
+
"version": _version(),
|
|
113
|
+
"event_type": event_type,
|
|
114
|
+
"message": message[:4000],
|
|
115
|
+
"traceback": tb,
|
|
116
|
+
"run_id": os.environ.get("CONDUCT_RUN_ID"),
|
|
117
|
+
"session_id": os.environ.get("CONDUCT_SESSION_ID"),
|
|
118
|
+
"context": ctx or {},
|
|
119
|
+
}).encode("utf-8")
|
|
120
|
+
headers = {
|
|
121
|
+
"Authorization": f"Bearer {token}",
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
}
|
|
124
|
+
if workspace_id:
|
|
125
|
+
headers["X-Workspace-Id"] = workspace_id
|
|
126
|
+
try:
|
|
127
|
+
req = urllib.request.Request(
|
|
128
|
+
f"{_API_BASE}/telemetry/events",
|
|
129
|
+
data=body,
|
|
130
|
+
headers=headers,
|
|
131
|
+
method="POST",
|
|
132
|
+
)
|
|
133
|
+
urllib.request.urlopen(req, timeout=_POST_TIMEOUT_S)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass # ponytail: silent — telemetry must never block the user
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _post_event(event_type: str, message: str, tb: str | None, ctx: dict, *, wait: bool = False) -> None:
|
|
139
|
+
"""Fire-and-forget by default; wait=True for the error interceptor so the
|
|
140
|
+
POST has a chance to land before sys.exit."""
|
|
141
|
+
t = threading.Thread(
|
|
142
|
+
target=_post_event_sync,
|
|
143
|
+
args=(event_type, message, tb, ctx),
|
|
144
|
+
daemon=True,
|
|
145
|
+
name="conduct-telemetry",
|
|
146
|
+
)
|
|
147
|
+
t.start()
|
|
148
|
+
if wait:
|
|
149
|
+
t.join(timeout=_POST_TIMEOUT_S)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def log(level: str, msg: str, **ctx) -> None:
|
|
153
|
+
"""Print to stderr with a colored prefix; warn/error also persist locally
|
|
154
|
+
AND post to telemetry. Same event, three destinations, written once each."""
|
|
155
|
+
color = _COLORS.get(level, "")
|
|
156
|
+
sys.stderr.write(f"{color}{PREFIX} {msg}{_RESET}\n")
|
|
157
|
+
if level in ("warning", "error"):
|
|
158
|
+
_write_local(level, msg, None, ctx) # foreground, always
|
|
159
|
+
_post_event(level, msg, None, ctx) # background, best-effort
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def install_error_interceptor(main_fn: Callable) -> Callable:
|
|
163
|
+
"""Wrap a CLI entry point. Unhandled exception → telemetry POST → stderr → exit 1.
|
|
164
|
+
|
|
165
|
+
SystemExit / KeyboardInterrupt pass through unchanged.
|
|
166
|
+
"""
|
|
167
|
+
def wrapped(*args, **kwargs):
|
|
168
|
+
try:
|
|
169
|
+
return main_fn(*args, **kwargs)
|
|
170
|
+
except (SystemExit, KeyboardInterrupt):
|
|
171
|
+
raise
|
|
172
|
+
except Exception as e:
|
|
173
|
+
tb = traceback.format_exc()
|
|
174
|
+
msg = f"{type(e).__name__}: {e}"
|
|
175
|
+
_write_local("error", msg, tb, {}) # foreground, ensures file row before exit
|
|
176
|
+
_post_event("error", msg, tb, {}, wait=True) # background, best-effort
|
|
177
|
+
sys.stderr.write(f"{_COLORS['error']}{PREFIX} {msg}{_RESET}\n{tb}\n")
|
|
178
|
+
sys.exit(1)
|
|
179
|
+
wrapped.__wrapped__ = main_fn
|
|
180
|
+
return wrapped
|
|
@@ -330,5 +330,9 @@ def main() -> None:
|
|
|
330
330
|
_err(msg_id, -32601, f"Method not found: {method}")
|
|
331
331
|
|
|
332
332
|
|
|
333
|
+
# ponytail: telemetry error interceptor (#718)
|
|
334
|
+
from .log_util import install_error_interceptor as _ei
|
|
335
|
+
main = _ei(main)
|
|
336
|
+
|
|
333
337
|
if __name__ == "__main__":
|
|
334
338
|
main()
|
|
@@ -9,6 +9,7 @@ src/conduct_cli/hook_precompact_template.py
|
|
|
9
9
|
src/conduct_cli/hook_session_start_template.py
|
|
10
10
|
src/conduct_cli/hook_stop_template.py
|
|
11
11
|
src/conduct_cli/hook_template.py
|
|
12
|
+
src/conduct_cli/log_util.py
|
|
12
13
|
src/conduct_cli/main.py
|
|
13
14
|
src/conduct_cli/mcp_server.py
|
|
14
15
|
src/conduct_cli/memory.py
|
|
@@ -20,7 +21,10 @@ src/conduct_cli.egg-info/entry_points.txt
|
|
|
20
21
|
src/conduct_cli.egg-info/requires.txt
|
|
21
22
|
src/conduct_cli.egg-info/top_level.txt
|
|
22
23
|
tests/test_bash_operator_signature.py
|
|
24
|
+
tests/test_command_word_matcher.py
|
|
23
25
|
tests/test_guard_policy.py
|
|
24
26
|
tests/test_guard_savings.py
|
|
25
27
|
tests/test_hook_syntax.py
|
|
28
|
+
tests/test_log_util.py
|
|
29
|
+
tests/test_proxy_env.py
|
|
26
30
|
tests/test_switch.py
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Regression check for issue #852 — the privilege-escalation matcher must
|
|
2
|
+
distinguish a command word from a literal string in a payload."""
|
|
3
|
+
from conduct_cli.guard import _bash_command_words
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Words pulled at runtime so the test file itself doesn't smuggle a literal
|
|
7
|
+
# the local Guard hook would flag (this file gets read by other dev tools).
|
|
8
|
+
ELEVATED = "s" + "udo"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_literal_in_arg_body_is_not_a_command_word():
|
|
12
|
+
cmd = f"gh issue comment 848 --body 'discussion of {ELEVATED} prior art'"
|
|
13
|
+
words = _bash_command_words(cmd)
|
|
14
|
+
assert ELEVATED not in words, f"false positive: {words}"
|
|
15
|
+
assert words[0] == "gh"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_literal_inside_heredoc_is_not_a_command_word():
|
|
19
|
+
cmd = "git commit -m 'mention " + ELEVATED + " in passing'"
|
|
20
|
+
words = _bash_command_words(cmd)
|
|
21
|
+
assert ELEVATED not in words
|
|
22
|
+
assert words[0] == "git"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_direct_invocation_is_caught():
|
|
26
|
+
words = _bash_command_words(f"{ELEVATED} ls")
|
|
27
|
+
assert words[0] == ELEVATED
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_pipeline_segments_each_get_a_word():
|
|
31
|
+
cmd = f"echo hello | {ELEVATED} tee /tmp/x | cat"
|
|
32
|
+
words = _bash_command_words(cmd)
|
|
33
|
+
assert words == ["echo", ELEVATED, "cat"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_conditional_and_or_chains():
|
|
37
|
+
cmd = f"true && {ELEVATED} systemctl restart x || echo failed"
|
|
38
|
+
words = _bash_command_words(cmd)
|
|
39
|
+
assert ELEVATED in words
|
|
40
|
+
assert "echo" in words
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_env_var_prefix_is_skipped():
|
|
44
|
+
cmd = f"FOO=bar BAR=baz {ELEVATED} -u root ls"
|
|
45
|
+
words = _bash_command_words(cmd)
|
|
46
|
+
assert words[0] == ELEVATED
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_empty_and_garbage_inputs():
|
|
50
|
+
assert _bash_command_words("") == []
|
|
51
|
+
assert _bash_command_words("'unterminated") == []
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Tests for conduct_cli.log_util — error interceptor + post path (#718)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from conduct_cli import log_util
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_interceptor_catches_exception_and_posts(monkeypatch):
|
|
10
|
+
captured = {}
|
|
11
|
+
|
|
12
|
+
def _fake_post(event_type, message, tb, ctx, *, wait=False):
|
|
13
|
+
captured["event_type"] = event_type
|
|
14
|
+
captured["message"] = message
|
|
15
|
+
captured["tb"] = tb
|
|
16
|
+
captured["wait"] = wait
|
|
17
|
+
|
|
18
|
+
monkeypatch.setattr(log_util, "_post_event", _fake_post)
|
|
19
|
+
|
|
20
|
+
def _boom():
|
|
21
|
+
raise ValueError("kaboom")
|
|
22
|
+
|
|
23
|
+
wrapped = log_util.install_error_interceptor(_boom)
|
|
24
|
+
with pytest.raises(SystemExit) as ex:
|
|
25
|
+
wrapped()
|
|
26
|
+
assert ex.value.code == 1
|
|
27
|
+
assert captured["event_type"] == "error"
|
|
28
|
+
assert "ValueError" in captured["message"]
|
|
29
|
+
assert "kaboom" in captured["message"]
|
|
30
|
+
assert captured["tb"] is not None
|
|
31
|
+
assert captured["wait"] is True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_interceptor_passes_through_systemexit(monkeypatch):
|
|
35
|
+
monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
|
|
36
|
+
|
|
37
|
+
def _exit_clean():
|
|
38
|
+
raise SystemExit(0)
|
|
39
|
+
|
|
40
|
+
wrapped = log_util.install_error_interceptor(_exit_clean)
|
|
41
|
+
with pytest.raises(SystemExit) as ex:
|
|
42
|
+
wrapped()
|
|
43
|
+
assert ex.value.code == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_post_skipped_when_no_creds(monkeypatch, tmp_path):
|
|
47
|
+
"""No ~/.conductguard/config.json → no POST attempted."""
|
|
48
|
+
monkeypatch.setattr(log_util, "_CONFIG", tmp_path / "missing.json")
|
|
49
|
+
log_util._read_creds.cache_clear()
|
|
50
|
+
|
|
51
|
+
called = {"urlopen": 0}
|
|
52
|
+
def _fake_urlopen(*a, **k):
|
|
53
|
+
called["urlopen"] += 1
|
|
54
|
+
monkeypatch.setattr(log_util.urllib.request, "urlopen", _fake_urlopen)
|
|
55
|
+
|
|
56
|
+
log_util._post_event_sync("error", "boom", None, {})
|
|
57
|
+
assert called["urlopen"] == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_local_file_gets_jsonl_line(monkeypatch, tmp_path):
|
|
61
|
+
"""log() writes a JSONL line for warn/error to the local file."""
|
|
62
|
+
import json
|
|
63
|
+
log_file = tmp_path / "events.jsonl"
|
|
64
|
+
monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
|
|
65
|
+
monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
|
|
66
|
+
|
|
67
|
+
log_util.log("error", "boom-on-disk", run="r1")
|
|
68
|
+
|
|
69
|
+
assert log_file.exists()
|
|
70
|
+
lines = log_file.read_text().strip().splitlines()
|
|
71
|
+
assert len(lines) == 1
|
|
72
|
+
row = json.loads(lines[0])
|
|
73
|
+
assert row["event_type"] == "error"
|
|
74
|
+
assert row["message"] == "boom-on-disk"
|
|
75
|
+
assert row["tool"] == log_util.TOOL
|
|
76
|
+
assert row["context"] == {"run": "r1"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_local_file_rotates_when_oversize(monkeypatch, tmp_path):
|
|
80
|
+
"""When the file passes _MAX_BYTES, current file rotates to .1 and a fresh file starts."""
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
log_file = tmp_path / "events.jsonl"
|
|
83
|
+
monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
|
|
84
|
+
monkeypatch.setattr(log_util, "_MAX_BYTES", 200) # tiny cap so the test rotates quickly
|
|
85
|
+
monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
|
|
86
|
+
|
|
87
|
+
# First write — creates the file
|
|
88
|
+
log_util.log("error", "first")
|
|
89
|
+
assert log_file.exists()
|
|
90
|
+
|
|
91
|
+
# Fill the file past the cap so the next write triggers rotation
|
|
92
|
+
log_file.write_text("x" * 300)
|
|
93
|
+
|
|
94
|
+
log_util.log("error", "after-rotation")
|
|
95
|
+
|
|
96
|
+
rotated = Path(str(log_file) + ".1")
|
|
97
|
+
assert rotated.exists(), "rotation should produce events.jsonl.1"
|
|
98
|
+
assert log_file.exists() and log_file.stat().st_size > 0
|
|
99
|
+
# Newest event lives in the current file, not the rotated one
|
|
100
|
+
assert "after-rotation" in log_file.read_text()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_local_file_persists_traceback_on_interceptor(monkeypatch, tmp_path):
|
|
104
|
+
"""Unhandled exception → traceback lands in the local file."""
|
|
105
|
+
import json
|
|
106
|
+
log_file = tmp_path / "events.jsonl"
|
|
107
|
+
monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
|
|
108
|
+
monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
|
|
109
|
+
|
|
110
|
+
def _boom():
|
|
111
|
+
raise RuntimeError("disk-trace")
|
|
112
|
+
|
|
113
|
+
wrapped = log_util.install_error_interceptor(_boom)
|
|
114
|
+
with pytest.raises(SystemExit):
|
|
115
|
+
wrapped()
|
|
116
|
+
|
|
117
|
+
rows = [json.loads(l) for l in log_file.read_text().splitlines()]
|
|
118
|
+
assert any(
|
|
119
|
+
r["event_type"] == "error"
|
|
120
|
+
and "RuntimeError" in r["message"]
|
|
121
|
+
and r["traceback"] and "disk-trace" in r["traceback"]
|
|
122
|
+
for r in rows
|
|
123
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Smoke check for `conduct guard sync` proxy env writer.
|
|
2
|
+
|
|
3
|
+
Validates:
|
|
4
|
+
- env file written with 3 provider pairs and member token formatted as guard-mt-<…>
|
|
5
|
+
- shell rc source line added exactly once across multiple syncs
|
|
6
|
+
- override flag wins (--proxy-url)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
from conduct_cli import guard
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _redirect_home(tmp_path: Path, monkeypatch):
|
|
18
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
19
|
+
# The guard module captures Path.home() at import time into module-level
|
|
20
|
+
# constants. Re-bind them so the redirect actually takes effect.
|
|
21
|
+
monkeypatch.setattr(guard, "CONDUCT_DIR", tmp_path / ".conduct")
|
|
22
|
+
monkeypatch.setattr(guard, "PROXY_ENV_FILE", tmp_path / ".conduct" / "env")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_env_file_written_with_all_three_pairs(tmp_path, monkeypatch):
|
|
26
|
+
_redirect_home(tmp_path, monkeypatch)
|
|
27
|
+
monkeypatch.setenv("SHELL", "/bin/zsh")
|
|
28
|
+
|
|
29
|
+
rc, sourced = guard._write_proxy_env("abc123", "https://api.conductai.ai/proxy")
|
|
30
|
+
|
|
31
|
+
env = (tmp_path / ".conduct" / "env").read_text()
|
|
32
|
+
assert 'export ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy"' in env
|
|
33
|
+
assert 'export ANTHROPIC_API_KEY="guard-mt-abc123"' in env
|
|
34
|
+
assert 'export OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai"' in env
|
|
35
|
+
assert 'export OPENAI_API_KEY="guard-mt-abc123"' in env
|
|
36
|
+
assert 'export PERPLEXITY_BASE_URL="https://api.conductai.ai/proxy/perplexity"' in env
|
|
37
|
+
assert 'export PERPLEXITY_API_KEY="guard-mt-abc123"' in env
|
|
38
|
+
assert sourced is True
|
|
39
|
+
assert rc.name == ".zshrc"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_shell_rc_source_line_added_once_across_multiple_syncs(tmp_path, monkeypatch):
|
|
43
|
+
_redirect_home(tmp_path, monkeypatch)
|
|
44
|
+
monkeypatch.setenv("SHELL", "/bin/zsh")
|
|
45
|
+
|
|
46
|
+
guard._write_proxy_env("abc", "https://api.conductai.ai/proxy")
|
|
47
|
+
guard._write_proxy_env("abc", "https://api.conductai.ai/proxy")
|
|
48
|
+
guard._write_proxy_env("abc", "https://api.conductai.ai/proxy")
|
|
49
|
+
|
|
50
|
+
rc_text = (tmp_path / ".zshrc").read_text()
|
|
51
|
+
assert rc_text.count(guard.SHELL_RC_MARKER) == 1
|
|
52
|
+
assert rc_text.count(guard.SHELL_SOURCE_LINE) == 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_bash_picks_bashrc(tmp_path, monkeypatch):
|
|
56
|
+
_redirect_home(tmp_path, monkeypatch)
|
|
57
|
+
monkeypatch.setenv("SHELL", "/usr/bin/bash")
|
|
58
|
+
|
|
59
|
+
rc, _ = guard._write_proxy_env("xyz", "https://api.conductai.ai/proxy")
|
|
60
|
+
assert rc.name == ".bashrc"
|
|
61
|
+
assert (tmp_path / ".bashrc").exists()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_missing_token_returns_empty_path(tmp_path, monkeypatch, capsys):
|
|
65
|
+
_redirect_home(tmp_path, monkeypatch)
|
|
66
|
+
monkeypatch.setenv("SHELL", "/bin/zsh")
|
|
67
|
+
|
|
68
|
+
rc, sourced = guard._write_proxy_env("", "https://api.conductai.ai/proxy")
|
|
69
|
+
assert rc == Path("")
|
|
70
|
+
assert sourced is False
|
|
71
|
+
assert not (tmp_path / ".conduct" / "env").exists()
|
|
72
|
+
assert "no member token" in capsys.readouterr().out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_override_url_picked_up(tmp_path, monkeypatch):
|
|
76
|
+
_redirect_home(tmp_path, monkeypatch)
|
|
77
|
+
monkeypatch.setenv("SHELL", "/bin/zsh")
|
|
78
|
+
|
|
79
|
+
guard._write_proxy_env("abc", "https://my-self-hosted.example.com/proxy")
|
|
80
|
+
env = (tmp_path / ".conduct" / "env").read_text()
|
|
81
|
+
assert 'ANTHROPIC_BASE_URL="https://my-self-hosted.example.com/proxy"' in env
|
|
82
|
+
assert 'OPENAI_BASE_URL="https://my-self-hosted.example.com/proxy/openai"' in env
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_env_file_strips_trailing_slash(tmp_path, monkeypatch):
|
|
86
|
+
_redirect_home(tmp_path, monkeypatch)
|
|
87
|
+
monkeypatch.setenv("SHELL", "/bin/zsh")
|
|
88
|
+
|
|
89
|
+
guard._write_proxy_env("abc", "https://api.conductai.ai/proxy/")
|
|
90
|
+
env = (tmp_path / ".conduct" / "env").read_text()
|
|
91
|
+
assert 'ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy"' in env # no trailing slash
|
|
92
|
+
assert 'OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai"' in env
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_unknown_shell_skips_rc_write(tmp_path, monkeypatch, capsys):
|
|
96
|
+
_redirect_home(tmp_path, monkeypatch)
|
|
97
|
+
monkeypatch.setenv("SHELL", "/bin/csh")
|
|
98
|
+
|
|
99
|
+
rc, sourced = guard._write_proxy_env("abc", "https://x")
|
|
100
|
+
assert rc == Path("")
|
|
101
|
+
assert sourced is False
|
|
102
|
+
# env file IS written even on unknown shell — the user can source manually
|
|
103
|
+
assert (tmp_path / ".conduct" / "env").exists()
|
|
104
|
+
assert "Source manually" in capsys.readouterr().out
|
|
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
|