conduct-cli 0.7.14__tar.gz → 0.7.17__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.7.14 → conduct_cli-0.7.17}/PKG-INFO +1 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/pyproject.toml +1 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/guard.py +3 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/guardmcp.py +21 -7
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/base.py +19 -5
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/pretooluse.py +8 -1
- conduct_cli-0.7.17/src/conduct_cli/tool_groups.py +58 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_advisory_and_hook.py +62 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_guard_policy.py +8 -4
- conduct_cli-0.7.17/tests/test_proxy_coverage.py +79 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/README.md +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/setup.cfg +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/setup.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_switch.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.17"
|
|
8
8
|
description = "CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -9,6 +9,8 @@ import urllib.request
|
|
|
9
9
|
from datetime import datetime, timedelta, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from conduct_cli.tool_groups import expand_match_tool
|
|
13
|
+
|
|
12
14
|
RESET = "\033[0m"
|
|
13
15
|
BOLD = "\033[1m"
|
|
14
16
|
GREEN = "\033[32m"
|
|
@@ -132,7 +134,7 @@ def _check_policy(tool_name, tool_input, tokens_before=0):
|
|
|
132
134
|
for rule in rules:
|
|
133
135
|
match_tool = (rule.get("match_tool") or "*").lower()
|
|
134
136
|
if match_tool != "*":
|
|
135
|
-
if tool_name not in
|
|
137
|
+
if tool_name not in expand_match_tool(match_tool):
|
|
136
138
|
continue
|
|
137
139
|
# New: structural match against the actual binary being invoked.
|
|
138
140
|
# Single string or list of strings; case-insensitive equality.
|
|
@@ -12,6 +12,7 @@ Exposes three tools:
|
|
|
12
12
|
guard_sync — pull latest policy from the ConductGuard API
|
|
13
13
|
"""
|
|
14
14
|
from __future__ import annotations
|
|
15
|
+
from conduct_cli.tool_groups import expand_match_tool
|
|
15
16
|
import argparse
|
|
16
17
|
import json
|
|
17
18
|
import re
|
|
@@ -183,14 +184,23 @@ def _maybe_sync() -> None:
|
|
|
183
184
|
def _detect_surface(client_info: dict) -> str:
|
|
184
185
|
"""Map MCP clientInfo.name → ai_tool label sent to Guard API."""
|
|
185
186
|
name = (client_info.get("name") or "").lower()
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
# ── Anthropic ─────────────────────────────────────────────────────────────
|
|
188
|
+
if "code" in name and "claude" in name:
|
|
189
|
+
return "claude-code"
|
|
190
|
+
if "desktop" in name and "claude" in name:
|
|
191
|
+
return "claude-desktop"
|
|
188
192
|
if "work" in name or "teams" in name or "enterprise" in name:
|
|
189
|
-
return "
|
|
193
|
+
return "claude-desktop" # Claude for Work / Teams
|
|
190
194
|
if "claude" in name:
|
|
191
|
-
return "
|
|
195
|
+
return "claude-ai" # claude.ai web
|
|
196
|
+
# ── OpenAI ────────────────────────────────────────────────────────────────
|
|
197
|
+
if "chatgpt" in name or "openai" in name:
|
|
198
|
+
return "openai-chatgpt"
|
|
199
|
+
if "codex" in name and "desktop" in name:
|
|
200
|
+
return "codex-desktop"
|
|
192
201
|
if "codex" in name:
|
|
193
|
-
return "codex"
|
|
202
|
+
return "codex-cli"
|
|
203
|
+
# ── Other IDEs ────────────────────────────────────────────────────────────
|
|
194
204
|
if "cursor" in name:
|
|
195
205
|
return "cursor"
|
|
196
206
|
if "windsurf" in name:
|
|
@@ -259,9 +269,13 @@ def _match_policy(tool_name: str, tool_input: dict) -> dict | None:
|
|
|
259
269
|
for rule in rules:
|
|
260
270
|
match_tool = (rule.get("match_tool") or "*").lower()
|
|
261
271
|
if match_tool != "*":
|
|
262
|
-
|
|
263
|
-
if tool_name.lower() not in allowed:
|
|
272
|
+
if tool_name.lower() not in expand_match_tool(match_tool):
|
|
264
273
|
continue
|
|
274
|
+
match_ai = rule.get("match_ai_tool")
|
|
275
|
+
if match_ai:
|
|
276
|
+
surfaces = [s.strip().lower() for s in match_ai.split(",")]
|
|
277
|
+
if not any(s in ai_tool.lower() for s in surfaces):
|
|
278
|
+
continue # rule targets a different surface
|
|
265
279
|
|
|
266
280
|
pattern = rule.get("match_pattern")
|
|
267
281
|
if pattern:
|
|
@@ -74,24 +74,38 @@ def detect_repo() -> Optional[str]:
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def detect_ai_tool() -> str:
|
|
77
|
-
"""Return a string identifying the active AI coding tool."""
|
|
77
|
+
"""Return a string identifying the active AI coding tool / surface."""
|
|
78
78
|
import os
|
|
79
|
+
# ── Anthropic ─────────────────────────────────────────────────────────────
|
|
79
80
|
if os.environ.get("CLAUDE_CODE_ENTRYPOINT") or os.environ.get("CLAUDECODE"):
|
|
80
81
|
return "claude-code"
|
|
81
|
-
if os.environ.get("
|
|
82
|
-
return "
|
|
82
|
+
if os.environ.get("CLAUDE_DESKTOP_ENTRYPOINT") or os.environ.get("CLAUDE_DESKTOP"):
|
|
83
|
+
return "claude-desktop"
|
|
84
|
+
# ── OpenAI / Codex ────────────────────────────────────────────────────────
|
|
85
|
+
if os.environ.get("CODEX_CLI_VERSION") or os.environ.get("CODEX_SESSION_ID"):
|
|
86
|
+
return "codex-cli"
|
|
87
|
+
if os.environ.get("CODEX_DESKTOP") or os.environ.get("OPENAI_CODEX_DESKTOP"):
|
|
88
|
+
return "codex-desktop"
|
|
89
|
+
if os.environ.get("OPENAI_CHATGPT_MCP") or os.environ.get("CHATGPT_SESSION_ID"):
|
|
90
|
+
return "openai-chatgpt"
|
|
91
|
+
# ── TERM_PROGRAM ──────────────────────────────────────────────────────────
|
|
83
92
|
term = os.environ.get("TERM_PROGRAM", "").lower()
|
|
84
93
|
if term == "cursor":
|
|
85
94
|
return "cursor"
|
|
86
95
|
if term == "windsurf":
|
|
87
96
|
return "windsurf"
|
|
97
|
+
# ── PATH heuristics ───────────────────────────────────────────────────────
|
|
88
98
|
path = os.environ.get("PATH", "")
|
|
89
|
-
if "Codex.app" in path
|
|
90
|
-
return "codex"
|
|
99
|
+
if "Codex.app" in path:
|
|
100
|
+
return "codex-desktop"
|
|
101
|
+
if "codex" in path.lower():
|
|
102
|
+
return "codex-cli"
|
|
91
103
|
if "cursor" in path.lower():
|
|
92
104
|
return "cursor"
|
|
93
105
|
if "windsurf" in path.lower():
|
|
94
106
|
return "windsurf"
|
|
107
|
+
if "claude" in path.lower():
|
|
108
|
+
return "claude-desktop"
|
|
95
109
|
return "claude-code"
|
|
96
110
|
|
|
97
111
|
|
|
@@ -10,6 +10,7 @@ import time
|
|
|
10
10
|
import urllib.request
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
+
from conduct_cli.tool_groups import expand_match_tool
|
|
13
14
|
from conduct_cli.hooks.base import (
|
|
14
15
|
BUDGET_CACHE_PATH,
|
|
15
16
|
BUDGET_CACHE_TTL,
|
|
@@ -325,10 +326,16 @@ def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
|
|
|
325
326
|
input_text = json.dumps(tool_input)
|
|
326
327
|
path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
|
|
327
328
|
|
|
329
|
+
current_ai_tool = detect_ai_tool()
|
|
328
330
|
for rule in rules:
|
|
329
331
|
match_tool = (rule.get("match_tool") or "*").lower()
|
|
330
332
|
if match_tool != "*":
|
|
331
|
-
if tool_name not in
|
|
333
|
+
if tool_name not in expand_match_tool(match_tool):
|
|
334
|
+
continue
|
|
335
|
+
match_ai = rule.get("match_ai_tool")
|
|
336
|
+
if match_ai:
|
|
337
|
+
surfaces = [s.strip().lower() for s in match_ai.split(",")]
|
|
338
|
+
if not any(s in current_ai_tool.lower() for s in surfaces):
|
|
332
339
|
continue
|
|
333
340
|
pattern = rule.get("match_pattern")
|
|
334
341
|
if pattern:
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canonical tool group mapping — single source of truth for semantic match_tool names.
|
|
3
|
+
Shared across CLI hook, MCP, and API (mirrored at apps/api/app/modules/guard/tool_groups.py).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
TOOL_GROUPS: dict[str, set[str]] = {
|
|
7
|
+
"shell": {"bash", "run_command", "execute", "terminal", "shell"},
|
|
8
|
+
"filesystem-write": {"write", "edit", "write_file", "edit_file", "str_replace_editor"},
|
|
9
|
+
"filesystem-read": {"read", "read_file", "glob", "grep", "list_directory"},
|
|
10
|
+
"network": {"web_fetch", "web_search", "http_request", "curl", "fetch"},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
# Inverted: raw tool name → semantic group
|
|
14
|
+
_RAW_TO_GROUP: dict[str, str] = {
|
|
15
|
+
raw: group
|
|
16
|
+
for group, raws in TOOL_GROUPS.items()
|
|
17
|
+
for raw in raws
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_tool(tool_name: str) -> str:
|
|
22
|
+
"""Map a raw tool name to its semantic group, or return as-is if unknown."""
|
|
23
|
+
return _RAW_TO_GROUP.get(tool_name.lower(), tool_name.lower())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def expand_match_tool(match_tool: str) -> set[str]:
|
|
27
|
+
"""
|
|
28
|
+
Expand a match_tool value (semantic group or raw name, comma-separated)
|
|
29
|
+
into the full set of raw tool names it covers.
|
|
30
|
+
"""
|
|
31
|
+
result: set[str] = set()
|
|
32
|
+
for token in match_tool.split(","):
|
|
33
|
+
token = token.strip().lower()
|
|
34
|
+
if token == "*":
|
|
35
|
+
return {"*"}
|
|
36
|
+
if token in TOOL_GROUPS:
|
|
37
|
+
result |= TOOL_GROUPS[token]
|
|
38
|
+
else:
|
|
39
|
+
result.add(token)
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def collapse_to_group(match_tool: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Collapse a comma-separated match_tool value to semantic group names.
|
|
46
|
+
e.g. "write,edit" → "filesystem-write"
|
|
47
|
+
"bash,run_command" → "shell"
|
|
48
|
+
"bash,write" → "shell,filesystem-write"
|
|
49
|
+
"""
|
|
50
|
+
if not match_tool or match_tool.strip() == "*":
|
|
51
|
+
return "*"
|
|
52
|
+
tokens = [t.strip().lower() for t in match_tool.split(",") if t.strip()]
|
|
53
|
+
groups_seen: list[str] = []
|
|
54
|
+
for token in tokens:
|
|
55
|
+
group = _RAW_TO_GROUP.get(token, token)
|
|
56
|
+
if group not in groups_seen:
|
|
57
|
+
groups_seen.append(group)
|
|
58
|
+
return ",".join(groups_seen)
|
|
@@ -10,6 +10,7 @@ src/conduct_cli/main.py
|
|
|
10
10
|
src/conduct_cli/mcp_server.py
|
|
11
11
|
src/conduct_cli/memory.py
|
|
12
12
|
src/conduct_cli/paxel.py
|
|
13
|
+
src/conduct_cli/tool_groups.py
|
|
13
14
|
src/conduct_cli.egg-info/PKG-INFO
|
|
14
15
|
src/conduct_cli.egg-info/SOURCES.txt
|
|
15
16
|
src/conduct_cli.egg-info/dependency_links.txt
|
|
@@ -35,6 +36,7 @@ tests/test_guard_savings.py
|
|
|
35
36
|
tests/test_hook_dispatch.py
|
|
36
37
|
tests/test_journal_drain.py
|
|
37
38
|
tests/test_log_util.py
|
|
39
|
+
tests/test_proxy_coverage.py
|
|
38
40
|
tests/test_proxy_env.py
|
|
39
41
|
tests/test_signed_policy.py
|
|
40
42
|
tests/test_switch.py
|
|
@@ -542,3 +542,65 @@ class TestInvalidStdin:
|
|
|
542
542
|
with pytest.raises(SystemExit) as exc:
|
|
543
543
|
pt.main()
|
|
544
544
|
assert exc.value.code == 0
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ── Fail-closed: API unreachable (budget check path) ──────────────────────────
|
|
548
|
+
|
|
549
|
+
class TestFailClosedApiUnreachable:
|
|
550
|
+
"""fail_closed + policy exists + budget API unreachable → blocks with clear message."""
|
|
551
|
+
|
|
552
|
+
def test_api_unreachable_blocks_with_clear_message(self, monkeypatch, tmp_path, capsys):
|
|
553
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
554
|
+
from io import StringIO
|
|
555
|
+
|
|
556
|
+
policy_file = tmp_path / "policy.json"
|
|
557
|
+
policy_file.write_text('{"rules": [], "fail_mode": "fail_closed"}')
|
|
558
|
+
|
|
559
|
+
monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
|
|
560
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_closed")
|
|
561
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
|
|
562
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
563
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
564
|
+
# Budget cache miss, then API unreachable → returns (False, None)
|
|
565
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
|
|
566
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
567
|
+
|
|
568
|
+
posted: list[dict] = []
|
|
569
|
+
def _fake_post(tn, ti, decision, rule_id, msg, sid, **kw):
|
|
570
|
+
posted.append({"decision": decision, "rule_id": rule_id, "msg": msg})
|
|
571
|
+
monkeypatch.setattr(pt, "post_event", _fake_post)
|
|
572
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
573
|
+
|
|
574
|
+
with pytest.raises(SystemExit) as exc:
|
|
575
|
+
pt.main()
|
|
576
|
+
|
|
577
|
+
assert exc.value.code == 2
|
|
578
|
+
assert posted[0]["decision"] == "blocked"
|
|
579
|
+
assert posted[0]["rule_id"] == "guard-unavailable"
|
|
580
|
+
# Message must say "unreachable", not "budget"
|
|
581
|
+
assert "unreachable" in posted[0]["msg"].lower()
|
|
582
|
+
assert "budget" not in posted[0]["msg"].lower()
|
|
583
|
+
|
|
584
|
+
def test_api_unreachable_fail_open_passes(self, monkeypatch, tmp_path):
|
|
585
|
+
"""Same scenario but fail_open → should NOT block."""
|
|
586
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
587
|
+
from io import StringIO
|
|
588
|
+
|
|
589
|
+
policy_file = tmp_path / "policy.json"
|
|
590
|
+
policy_file.write_text('{"rules": []}')
|
|
591
|
+
|
|
592
|
+
monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
|
|
593
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
594
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
|
|
595
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
596
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
597
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
|
|
598
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
599
|
+
monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
|
|
600
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
601
|
+
|
|
602
|
+
# Should not raise SystemExit(2) — exits 0 (allow)
|
|
603
|
+
try:
|
|
604
|
+
pt.main()
|
|
605
|
+
except SystemExit as e:
|
|
606
|
+
assert e.code == 0
|
|
@@ -97,22 +97,26 @@ class TestMatchTool:
|
|
|
97
97
|
assert action == "warn"
|
|
98
98
|
|
|
99
99
|
def test_exact_tool_match(self, policy_path):
|
|
100
|
-
|
|
100
|
+
# semantic group "shell" matches raw tool "bash"
|
|
101
|
+
policy_path([_rule(match_tool="shell", action="warn")])
|
|
101
102
|
_, action, _, _ = _check_policy("bash", {})
|
|
102
103
|
assert action == "warn"
|
|
103
104
|
|
|
104
105
|
def test_exact_tool_no_match(self, policy_path):
|
|
105
|
-
|
|
106
|
+
# "filesystem-write" does not match "bash"
|
|
107
|
+
policy_path([_rule(match_tool="filesystem-write", action="block")])
|
|
106
108
|
_, action, _, _ = _check_policy("bash", {})
|
|
107
109
|
assert action == "allow"
|
|
108
110
|
|
|
109
111
|
def test_comma_separated_includes_tool(self, policy_path):
|
|
110
|
-
|
|
112
|
+
# "shell,filesystem-write" matches "edit" (filesystem-write group)
|
|
113
|
+
policy_path([_rule(match_tool="shell,filesystem-write", action="warn")])
|
|
111
114
|
_, action, _, _ = _check_policy("edit", {})
|
|
112
115
|
assert action == "warn"
|
|
113
116
|
|
|
114
117
|
def test_comma_separated_excludes_tool(self, policy_path):
|
|
115
|
-
|
|
118
|
+
# "shell,filesystem-write" does not match "read" (filesystem-read group)
|
|
119
|
+
policy_path([_rule(match_tool="shell,filesystem-write", action="warn")])
|
|
116
120
|
_, action, _, _ = _check_policy("read", {})
|
|
117
121
|
assert action == "allow"
|
|
118
122
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for proxy coverage detection in guard status."""
|
|
2
|
+
import io
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _run_coverage_block(env: dict, cfg: dict, env_file_exists: bool = True) -> str:
|
|
9
|
+
"""Run only the proxy-coverage printing block from cmd_guard_status."""
|
|
10
|
+
import conduct_cli.guard as g
|
|
11
|
+
|
|
12
|
+
output = io.StringIO()
|
|
13
|
+
with patch.dict("os.environ", env, clear=True), \
|
|
14
|
+
patch.object(Path, "exists", return_value=env_file_exists):
|
|
15
|
+
proxy_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/") + "/proxy"
|
|
16
|
+
_env = env
|
|
17
|
+
anthropic_ok = proxy_url in _env.get("ANTHROPIC_BASE_URL", "")
|
|
18
|
+
openai_ok = proxy_url in _env.get("OPENAI_BASE_URL", "")
|
|
19
|
+
perplexity_ok = proxy_url in _env.get("PERPLEXITY_BASE_URL", "")
|
|
20
|
+
|
|
21
|
+
covered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if ok]
|
|
22
|
+
uncovered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if not ok]
|
|
23
|
+
|
|
24
|
+
GREEN, YELLOW, RED, RESET, BOLD = "", "", "", "", ""
|
|
25
|
+
|
|
26
|
+
if covered and not uncovered:
|
|
27
|
+
line = f"active — {', '.join(covered)} routed through Guard proxy"
|
|
28
|
+
elif covered:
|
|
29
|
+
line = f"partial — {', '.join(covered)} covered · {', '.join(uncovered)} NOT intercepted"
|
|
30
|
+
elif env_file_exists:
|
|
31
|
+
line = "inactive — ~/.conduct/env exists but not sourced in this shell."
|
|
32
|
+
else:
|
|
33
|
+
line = "not configured — run: conduct guard sync"
|
|
34
|
+
return line
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_CFG = {"api_url": "https://api.conductai.ai"}
|
|
38
|
+
_PROXY = "https://api.conductai.ai/proxy"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_all_three_active():
|
|
42
|
+
env = {
|
|
43
|
+
"ANTHROPIC_BASE_URL": _PROXY,
|
|
44
|
+
"OPENAI_BASE_URL": f"{_PROXY}/openai",
|
|
45
|
+
"PERPLEXITY_BASE_URL": f"{_PROXY}/perplexity",
|
|
46
|
+
}
|
|
47
|
+
line = _run_coverage_block(env, _CFG)
|
|
48
|
+
assert line.startswith("active")
|
|
49
|
+
assert "Anthropic" in line
|
|
50
|
+
assert "OpenAI" in line
|
|
51
|
+
assert "Perplexity" in line
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_partial_coverage():
|
|
55
|
+
env = {"ANTHROPIC_BASE_URL": _PROXY}
|
|
56
|
+
line = _run_coverage_block(env, _CFG)
|
|
57
|
+
assert line.startswith("partial")
|
|
58
|
+
assert "Anthropic" in line
|
|
59
|
+
assert "NOT intercepted" in line
|
|
60
|
+
assert "OpenAI" in line
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_inactive_env_file_exists():
|
|
64
|
+
line = _run_coverage_block({}, _CFG, env_file_exists=True)
|
|
65
|
+
assert line.startswith("inactive")
|
|
66
|
+
assert "~/.conduct/env" in line
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_not_configured_no_env_file():
|
|
70
|
+
line = _run_coverage_block({}, _CFG, env_file_exists=False)
|
|
71
|
+
assert line.startswith("not configured")
|
|
72
|
+
assert "conduct guard sync" in line
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_wrong_proxy_url_not_counted():
|
|
76
|
+
"""Env var pointing to a different proxy should not count as covered."""
|
|
77
|
+
env = {"ANTHROPIC_BASE_URL": "https://some-other-proxy.io"}
|
|
78
|
+
line = _run_coverage_block(env, _CFG, env_file_exists=True)
|
|
79
|
+
assert line.startswith("inactive")
|
|
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
|