conduct-cli 0.8.3__tar.gz → 0.8.5__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.8.3 → conduct_cli-0.8.5}/PKG-INFO +7 -5
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/README.md +6 -4
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/pyproject.toml +2 -2
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/guard.py +73 -23
- conduct_cli-0.8.5/src/conduct_cli/guard_policy.md +16 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/base.py +48 -12
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/session_start.py +33 -11
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/main.py +10 -11
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/PKG-INFO +7 -5
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_advisory_and_hook.py +0 -32
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_api_client.py +0 -12
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_cmd_run.py +6 -17
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_drain_daemon_health.py +85 -9
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_guard_policy.py +4 -63
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_hook_dispatch.py +1 -1
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_proxy_env.py +72 -4
- conduct_cli-0.8.5/tests/test_report_tool_coverage.py +117 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_switch.py +15 -31
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/setup.cfg +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/setup.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/pretooluse.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_log_util.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.8.3 → conduct_cli-0.8.5}/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.8.
|
|
3
|
+
Version: 0.8.5
|
|
4
4
|
Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
|
|
5
5
|
Author-email: Conduct AI <hello@conductai.ai>
|
|
6
6
|
License: MIT
|
|
@@ -31,6 +31,8 @@ Requires-Dist: agent-booster[watch]>=0.6.6; extra == "booster"
|
|
|
31
31
|
|
|
32
32
|
Official CLI for [Conduct AI](https://conductai.ai) — install AI agents, manage projects, run end-to-end tests, and enforce team AI policies with ConductGuard.
|
|
33
33
|
|
|
34
|
+
Runs on Linux, macOS, and Windows. Python 3.9–3.12.
|
|
35
|
+
|
|
34
36
|

|
|
35
37
|
|
|
36
38
|
## Install
|
|
@@ -45,7 +47,7 @@ pip install conduct-cli
|
|
|
45
47
|
# Authenticate (one-time)
|
|
46
48
|
conduct login \
|
|
47
49
|
--server https://api.conductai.ai \
|
|
48
|
-
--
|
|
50
|
+
--token cond_agt_xxx \
|
|
49
51
|
--workspace <workspace-id>
|
|
50
52
|
|
|
51
53
|
# Browse available agents
|
|
@@ -81,10 +83,10 @@ conduct test --all
|
|
|
81
83
|
|
|
82
84
|
## Authentication
|
|
83
85
|
|
|
84
|
-
Generate an
|
|
86
|
+
Generate an agent token from **Settings → Agents → Issue token** in the Conduct AI dashboard. Tokens start with `cond_agt_` and are stored as SHA-256 hashes — the plaintext is shown only once.
|
|
85
87
|
|
|
86
88
|
```bash
|
|
87
|
-
conduct login --server https://api.conductai.ai --
|
|
89
|
+
conduct login --server https://api.conductai.ai --token cond_agt_xxx --workspace <id>
|
|
88
90
|
```
|
|
89
91
|
|
|
90
92
|
## Install all agents
|
|
@@ -313,7 +315,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
|
|
|
313
315
|
}
|
|
314
316
|
```
|
|
315
317
|
|
|
316
|
-
Run `conduct login` first — `conduct-mcp` reads credentials from `~/.conduct/config.json`. Get your
|
|
318
|
+
Run `conduct login` first — `conduct-mcp` reads credentials from `~/.conduct/config.json`. Get your agent token from **Settings → Agents → Issue token** in the Conduct AI dashboard.
|
|
317
319
|
|
|
318
320
|
---
|
|
319
321
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
Official CLI for [Conduct AI](https://conductai.ai) — install AI agents, manage projects, run end-to-end tests, and enforce team AI policies with ConductGuard.
|
|
5
5
|
|
|
6
|
+
Runs on Linux, macOS, and Windows. Python 3.9–3.12.
|
|
7
|
+
|
|
6
8
|

|
|
7
9
|
|
|
8
10
|
## Install
|
|
@@ -17,7 +19,7 @@ pip install conduct-cli
|
|
|
17
19
|
# Authenticate (one-time)
|
|
18
20
|
conduct login \
|
|
19
21
|
--server https://api.conductai.ai \
|
|
20
|
-
--
|
|
22
|
+
--token cond_agt_xxx \
|
|
21
23
|
--workspace <workspace-id>
|
|
22
24
|
|
|
23
25
|
# Browse available agents
|
|
@@ -53,10 +55,10 @@ conduct test --all
|
|
|
53
55
|
|
|
54
56
|
## Authentication
|
|
55
57
|
|
|
56
|
-
Generate an
|
|
58
|
+
Generate an agent token from **Settings → Agents → Issue token** in the Conduct AI dashboard. Tokens start with `cond_agt_` and are stored as SHA-256 hashes — the plaintext is shown only once.
|
|
57
59
|
|
|
58
60
|
```bash
|
|
59
|
-
conduct login --server https://api.conductai.ai --
|
|
61
|
+
conduct login --server https://api.conductai.ai --token cond_agt_xxx --workspace <id>
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
## Install all agents
|
|
@@ -285,7 +287,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
|
|
|
285
287
|
}
|
|
286
288
|
```
|
|
287
289
|
|
|
288
|
-
Run `conduct login` first — `conduct-mcp` reads credentials from `~/.conduct/config.json`. Get your
|
|
290
|
+
Run `conduct login` first — `conduct-mcp` reads credentials from `~/.conduct/config.json`. Get your agent token from **Settings → Agents → Issue token** in the Conduct AI dashboard.
|
|
289
291
|
|
|
290
292
|
---
|
|
291
293
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.8.
|
|
7
|
+
version = "0.8.5"
|
|
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" }
|
|
@@ -44,4 +44,4 @@ where = ["src"]
|
|
|
44
44
|
include = ["conduct_cli*"]
|
|
45
45
|
|
|
46
46
|
[tool.setuptools.package-data]
|
|
47
|
-
conduct_cli = ["paxel.py"]
|
|
47
|
+
conduct_cli = ["paxel.py", "guard_policy.md"]
|
|
@@ -344,7 +344,8 @@ def _save_guard_config(data: dict, workspace_id: str | None = None):
|
|
|
344
344
|
pass
|
|
345
345
|
existing.update(data)
|
|
346
346
|
CONFIG_PATH.write_text(json.dumps(existing, indent=2))
|
|
347
|
-
|
|
347
|
+
from conduct_cli.hooks.base import restrict_to_owner
|
|
348
|
+
restrict_to_owner(CONFIG_PATH)
|
|
348
349
|
|
|
349
350
|
|
|
350
351
|
def _require_guard_config() -> dict:
|
|
@@ -521,21 +522,7 @@ def _write_mcp_file(
|
|
|
521
522
|
cfg_path.write_text(json.dumps(cfg, indent=2))
|
|
522
523
|
|
|
523
524
|
|
|
524
|
-
_GUARD_RULES_TEXT = (
|
|
525
|
-
"## ConductGuard — AI Governance Policy\n\n"
|
|
526
|
-
"A ConductGuard MCP server is connected to this session (server name: `conduct-guard`).\n"
|
|
527
|
-
"All Guard policies are enforced through it. You MUST follow these rules:\n\n"
|
|
528
|
-
"Before executing any shell command, file write, network request, API call, or code change:\n"
|
|
529
|
-
"1. Call the `guard_check` tool from the `conduct-guard` MCP server with the action and parameters.\n"
|
|
530
|
-
"2. If the response is BLOCKED: stop immediately and explain the policy rule to the user. Do not proceed.\n"
|
|
531
|
-
"3. If the response is WARNING: proceed but surface the warning message to the user.\n"
|
|
532
|
-
"4. If the response is ALLOWED: proceed normally.\n\n"
|
|
533
|
-
"Always-on rules (no guard_check needed, enforce unconditionally):\n"
|
|
534
|
-
"- Never write credentials, API keys, tokens, or secrets to any file or output.\n"
|
|
535
|
-
"- Never send PII (emails, names, payment data) to external endpoints.\n\n"
|
|
536
|
-
"Use `guard_status` (conduct-guard MCP) to show the user their current policy and team info.\n"
|
|
537
|
-
"Use `guard_activity` at the start of every session with a one-line summary of what you are doing."
|
|
538
|
-
)
|
|
525
|
+
_GUARD_RULES_TEXT = (Path(__file__).parent / "guard_policy.md").read_text().rstrip()
|
|
539
526
|
|
|
540
527
|
|
|
541
528
|
def _patch_cursor_global_rules() -> None:
|
|
@@ -608,14 +595,16 @@ def _patch_tool_instruction_files(agent_token: str, api_url: str, dry_run: bool
|
|
|
608
595
|
(repo_root / ".cursorrules" if repo_root else None, Path.home() / ".cursor" / "rules" / "conduct.md", ".cursorrules"),
|
|
609
596
|
(repo_root / ".windsurfrules" if repo_root else None, Path.home() / ".windsurf"/ "rules" / "conduct.md", ".windsurfrules"),
|
|
610
597
|
]
|
|
611
|
-
|
|
598
|
+
# Tolerate legacy marker variants (e.g. ", do not edit this block") so re-sync
|
|
599
|
+
# replaces old blocks in place instead of appending a second one.
|
|
600
|
+
pattern = _re.compile(r"<!-- ConductGuard[^\n]*-->.*?<!-- /ConductGuard -->", _re.DOTALL)
|
|
612
601
|
|
|
613
602
|
for repo_path, global_path, label in targets:
|
|
614
603
|
path = repo_path if (repo_path and repo_path.exists()) else global_path
|
|
615
604
|
if not path.exists() and repo_path:
|
|
616
605
|
path = repo_path
|
|
617
606
|
existing = path.read_text() if path.exists() else ""
|
|
618
|
-
if
|
|
607
|
+
if pattern.search(existing):
|
|
619
608
|
updated = pattern.sub(guard_block, existing)
|
|
620
609
|
if dry_run:
|
|
621
610
|
if updated == existing:
|
|
@@ -1265,7 +1254,7 @@ def _report_tools_to_server() -> None:
|
|
|
1265
1254
|
|
|
1266
1255
|
payload = json.dumps({"email": email, "tools": tools}).encode()
|
|
1267
1256
|
headers = {"Content-Type": "application/json"}
|
|
1268
|
-
if conduct_agent_token
|
|
1257
|
+
if conduct_agent_token:
|
|
1269
1258
|
headers["Authorization"] = f"Bearer {conduct_agent_token}"
|
|
1270
1259
|
elif token:
|
|
1271
1260
|
headers["Authorization"] = f"Bearer {token}"
|
|
@@ -1450,7 +1439,7 @@ def cmd_guard_sync(args):
|
|
|
1450
1439
|
_save_policy(policy)
|
|
1451
1440
|
print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
|
|
1452
1441
|
|
|
1453
|
-
# Refresh
|
|
1442
|
+
# Refresh agent token from server via refresh_token grant.
|
|
1454
1443
|
# Never rely on the locally-cached value — it may be stale or missing.
|
|
1455
1444
|
try:
|
|
1456
1445
|
installed = _req(
|
|
@@ -1490,9 +1479,11 @@ def cmd_guard_sync(args):
|
|
|
1490
1479
|
agent_token = cfg.get("agent_token", "")
|
|
1491
1480
|
rc_path, newly_sourced = _write_proxy_env(agent_token, proxy_url)
|
|
1492
1481
|
if agent_token:
|
|
1493
|
-
|
|
1482
|
+
env_name = "env.ps1" if sys.platform == "win32" else "env"
|
|
1483
|
+
activate_cmd = f". {rc_path}" if sys.platform == "win32" else f"source {rc_path}"
|
|
1484
|
+
print(f" {GREEN}Proxy env written:{RESET} ~/.conduct/{env_name} → {proxy_url}")
|
|
1494
1485
|
if newly_sourced:
|
|
1495
|
-
print(f" {CYAN}Run `
|
|
1486
|
+
print(f" {CYAN}Run `{activate_cmd}` (or open a new shell) to activate.{RESET}")
|
|
1496
1487
|
|
|
1497
1488
|
_tools = _detect_ai_tools()
|
|
1498
1489
|
if _tools:
|
|
@@ -1668,6 +1659,62 @@ def _post_local_findings(cfg: dict, base_url: str, findings: list[dict]) -> None
|
|
|
1668
1659
|
print(f" {YELLOW}Audit upload skipped: {e}{RESET}")
|
|
1669
1660
|
|
|
1670
1661
|
|
|
1662
|
+
def _write_proxy_env_windows(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
|
|
1663
|
+
"""Windows equivalent of _write_proxy_env — PowerShell profile + env.ps1."""
|
|
1664
|
+
CONDUCT_DIR.mkdir(parents=True, exist_ok=True)
|
|
1665
|
+
token = agent_token
|
|
1666
|
+
proxy = proxy_url.rstrip("/")
|
|
1667
|
+
|
|
1668
|
+
ps1_file = CONDUCT_DIR / "env.ps1"
|
|
1669
|
+
ps1_file.write_text("\n".join([
|
|
1670
|
+
SHELL_RC_MARKER,
|
|
1671
|
+
"# Edit ~/.conduct/env-override.ps1 to add your own vars — sourced after this file.",
|
|
1672
|
+
"",
|
|
1673
|
+
f'$env:ANTHROPIC_BASE_URL = "{proxy}"',
|
|
1674
|
+
f'$env:ANTHROPIC_API_KEY = "{token}"',
|
|
1675
|
+
"",
|
|
1676
|
+
f'$env:OPENAI_BASE_URL = "{proxy}/openai"',
|
|
1677
|
+
f'$env:OPENAI_API_KEY = "{token}"',
|
|
1678
|
+
"",
|
|
1679
|
+
f'$env:PERPLEXITY_BASE_URL = "{proxy}/perplexity"',
|
|
1680
|
+
f'$env:PERPLEXITY_API_KEY = "{token}"',
|
|
1681
|
+
"",
|
|
1682
|
+
'if (Test-Path "$HOME/.conduct/env-override.ps1") { . "$HOME/.conduct/env-override.ps1" }',
|
|
1683
|
+
"",
|
|
1684
|
+
]))
|
|
1685
|
+
|
|
1686
|
+
# ponytail: prefer PS7 profile; fall back to WPS5 only if its file already exists
|
|
1687
|
+
ps7_profile = Path.home() / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1"
|
|
1688
|
+
wps5_profile = Path.home() / "Documents" / "WindowsPowerShell" / "Microsoft.PowerShell_profile.ps1"
|
|
1689
|
+
if ps7_profile.parent.exists() or not wps5_profile.exists():
|
|
1690
|
+
rc = ps7_profile
|
|
1691
|
+
else:
|
|
1692
|
+
rc = wps5_profile
|
|
1693
|
+
|
|
1694
|
+
existing = rc.read_text() if rc.exists() else ""
|
|
1695
|
+
CLAUDE_ALIAS = (
|
|
1696
|
+
"function claude { "
|
|
1697
|
+
"$prev=$env:ANTHROPIC_BASE_URL; $env:ANTHROPIC_BASE_URL=$null; "
|
|
1698
|
+
"try { & claude @args } finally { $env:ANTHROPIC_BASE_URL=$prev } }"
|
|
1699
|
+
)
|
|
1700
|
+
PS_SOURCE_LINE = 'if (Test-Path "$HOME/.conduct/env.ps1") { . "$HOME/.conduct/env.ps1" }'
|
|
1701
|
+
|
|
1702
|
+
if SHELL_RC_MARKER in existing:
|
|
1703
|
+
if CLAUDE_ALIAS not in existing:
|
|
1704
|
+
rc.write_text(existing.rstrip() + f"\n{CLAUDE_ALIAS}\n")
|
|
1705
|
+
return rc, True
|
|
1706
|
+
return rc, False
|
|
1707
|
+
|
|
1708
|
+
rc.parent.mkdir(parents=True, exist_ok=True)
|
|
1709
|
+
addition = (
|
|
1710
|
+
f"\n\n{SHELL_RC_MARKER}\n"
|
|
1711
|
+
f"{PS_SOURCE_LINE}\n"
|
|
1712
|
+
f"{CLAUDE_ALIAS}\n"
|
|
1713
|
+
)
|
|
1714
|
+
rc.write_text(existing.rstrip() + addition if existing else addition.lstrip())
|
|
1715
|
+
return rc, True
|
|
1716
|
+
|
|
1717
|
+
|
|
1671
1718
|
def _write_proxy_env(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
|
|
1672
1719
|
"""Write ~/.conduct/env with the 3 provider env-var pairs and ensure the
|
|
1673
1720
|
user's shell rc sources it.
|
|
@@ -1681,8 +1728,11 @@ def _write_proxy_env(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
|
|
|
1681
1728
|
print(f" {YELLOW}Proxy env skipped — no agent token in config{RESET}")
|
|
1682
1729
|
return Path(), False
|
|
1683
1730
|
|
|
1731
|
+
if sys.platform == "win32":
|
|
1732
|
+
return _write_proxy_env_windows(agent_token, proxy_url)
|
|
1733
|
+
|
|
1684
1734
|
CONDUCT_DIR.mkdir(parents=True, exist_ok=True)
|
|
1685
|
-
token = agent_token # cond_agt_* used directly as the
|
|
1735
|
+
token = agent_token # cond_agt_* used directly as the bearer value
|
|
1686
1736
|
proxy = proxy_url.rstrip("/")
|
|
1687
1737
|
|
|
1688
1738
|
PROXY_ENV_FILE.write_text("\n".join([
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
At the start of every conversation:
|
|
2
|
+
- Call `guard_activity` once with a one-line summary of the user's request.
|
|
3
|
+
|
|
4
|
+
Before every shell command, file write, network request, API call, or code change:
|
|
5
|
+
- Call `guard_check` with the proposed action and parameters.
|
|
6
|
+
- If BLOCKED: stop and explain the policy rule.
|
|
7
|
+
- If WARNING: proceed and surface the warning.
|
|
8
|
+
- If ALLOWED: proceed normally.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
1. User asks to fix a bug → call `guard_activity` once.
|
|
12
|
+
2. Before reading files or running tests → call `guard_check`.
|
|
13
|
+
3. Before editing code → call `guard_check` again.
|
|
14
|
+
|
|
15
|
+
Never write secrets to files or output.
|
|
16
|
+
Never send PII to external endpoints.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Shared primitives for all ConductGuard hook modules.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
with no shell rc sourced.
|
|
3
|
+
Depends only on stdlib + psutil (a hard conduct-cli dep), so hooks work from a
|
|
4
|
+
bare `pip install conduct-cli` with no shell rc sourced.
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
@@ -10,6 +10,8 @@ import subprocess
|
|
|
10
10
|
import sys
|
|
11
11
|
import time
|
|
12
12
|
import urllib.request
|
|
13
|
+
|
|
14
|
+
import psutil as _psutil
|
|
13
15
|
from dataclasses import dataclass, field
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Literal, Optional
|
|
@@ -57,6 +59,30 @@ def active_policy_path() -> Path:
|
|
|
57
59
|
return POLICY_PATH
|
|
58
60
|
|
|
59
61
|
|
|
62
|
+
def restrict_to_owner(path: Path) -> None:
|
|
63
|
+
"""chmod 600 equivalent — POSIX uses chmod, Windows uses icacls to remove
|
|
64
|
+
inheritance and grant sole access to the current user."""
|
|
65
|
+
try:
|
|
66
|
+
if sys.platform == "win32":
|
|
67
|
+
# ponytail: icacls is built-in on windows; pywin32 would be a whole
|
|
68
|
+
# dep for one SetSecurityInfo call. Upgrade only if this breaks on
|
|
69
|
+
# non-English locales that rename builtin groups.
|
|
70
|
+
import os as _os
|
|
71
|
+
user = _os.environ.get("USERNAME") or _os.environ.get("USER") or ""
|
|
72
|
+
if not user:
|
|
73
|
+
return
|
|
74
|
+
subprocess.run(
|
|
75
|
+
["icacls", str(path), "/inheritance:r", "/grant:r", f"{user}:F"],
|
|
76
|
+
check=False,
|
|
77
|
+
stdout=subprocess.DEVNULL,
|
|
78
|
+
stderr=subprocess.DEVNULL,
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
path.chmod(0o600)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
60
86
|
# ── Repo / tool detection ─────────────────────────────────────────────────────
|
|
61
87
|
|
|
62
88
|
def detect_repo() -> Optional[str]:
|
|
@@ -168,19 +194,18 @@ def _refresh_policy_from_api() -> None:
|
|
|
168
194
|
|
|
169
195
|
def drain_daemon_status() -> tuple[str, int | None]:
|
|
170
196
|
"""Return (status, pid) where status is 'running'|'stale'|'dead'."""
|
|
171
|
-
import os as _os
|
|
172
197
|
if not JOURNAL_PID_PATH.exists():
|
|
173
198
|
return "dead", None
|
|
174
199
|
try:
|
|
175
200
|
pid = int(JOURNAL_PID_PATH.read_text().strip())
|
|
176
|
-
|
|
201
|
+
# ponytail: psutil (already a dep) is portable — os.kill(pid, 0) hangs on Windows
|
|
202
|
+
if not _psutil.pid_exists(pid):
|
|
203
|
+
return "dead", None
|
|
177
204
|
age = time.time() - JOURNAL_PID_PATH.stat().st_mtime
|
|
178
205
|
if age > _DAEMON_STALE_SECS:
|
|
179
206
|
return "stale", pid
|
|
180
207
|
return "running", pid
|
|
181
|
-
except (
|
|
182
|
-
return "dead", None
|
|
183
|
-
except Exception:
|
|
208
|
+
except (ValueError, Exception):
|
|
184
209
|
return "dead", None
|
|
185
210
|
|
|
186
211
|
|
|
@@ -197,18 +222,29 @@ def ensure_drain_daemon(hook_module_path: Optional[Path] = None) -> None:
|
|
|
197
222
|
return
|
|
198
223
|
# Kill stale process before spawning replacement — prevents double-drain race
|
|
199
224
|
if status == "stale" and stale_pid is not None:
|
|
200
|
-
import os as _os
|
|
201
225
|
try:
|
|
202
|
-
|
|
226
|
+
_psutil.Process(stale_pid).terminate()
|
|
203
227
|
except Exception:
|
|
204
228
|
pass
|
|
205
229
|
if hook_module_path is None:
|
|
206
230
|
return
|
|
231
|
+
popen_kwargs: dict = {
|
|
232
|
+
"stdout": subprocess.DEVNULL,
|
|
233
|
+
"stderr": subprocess.DEVNULL,
|
|
234
|
+
}
|
|
235
|
+
if sys.platform == "win32":
|
|
236
|
+
# ponytail: DETACHED_PROCESS + CREATE_NEW_PROCESS_GROUP are the
|
|
237
|
+
# Windows equivalent of POSIX start_new_session — both constants
|
|
238
|
+
# only exist on Windows Python, so gate the reference behind the
|
|
239
|
+
# platform check.
|
|
240
|
+
popen_kwargs["creationflags"] = (
|
|
241
|
+
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
popen_kwargs["start_new_session"] = True
|
|
207
245
|
subprocess.Popen(
|
|
208
246
|
[sys.executable, str(hook_module_path), "drain"],
|
|
209
|
-
|
|
210
|
-
stderr=subprocess.DEVNULL,
|
|
211
|
-
start_new_session=True,
|
|
247
|
+
**popen_kwargs,
|
|
212
248
|
)
|
|
213
249
|
except Exception:
|
|
214
250
|
pass
|
|
@@ -20,10 +20,12 @@ MAX_AGE_HOURS = 2
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _check_proxy_token() -> None:
|
|
23
|
-
"""Warn + alert server if the proxy token is missing or
|
|
23
|
+
"""Warn + alert server if the proxy token is missing, malformed, or expired.
|
|
24
24
|
|
|
25
25
|
Reads ~/.conduct/env directly (not shell env) so this works regardless
|
|
26
|
-
of whether the user sourced their rc file in this session.
|
|
26
|
+
of whether the user sourced their rc file in this session. Also cross-checks
|
|
27
|
+
token_expires_at from ~/.conduct/config.json so the hint says `conduct login`
|
|
28
|
+
(verb that actually refreshes) when the session token has aged out.
|
|
27
29
|
"""
|
|
28
30
|
if not CONDUCT_ENV_PATH.exists():
|
|
29
31
|
return
|
|
@@ -35,22 +37,42 @@ def _check_proxy_token() -> None:
|
|
|
35
37
|
)
|
|
36
38
|
if not has_base_url:
|
|
37
39
|
return
|
|
38
|
-
|
|
40
|
+
|
|
41
|
+
# Expired-session check: token present in env but config TTL already past.
|
|
42
|
+
expired = False
|
|
43
|
+
try:
|
|
44
|
+
cfg = load_config()
|
|
45
|
+
exp = cfg.get("token_expires_at", "")
|
|
46
|
+
if exp:
|
|
47
|
+
expired = datetime.fromisoformat(exp) < datetime.now(timezone.utc)
|
|
48
|
+
except Exception:
|
|
49
|
+
cfg = {}
|
|
50
|
+
|
|
51
|
+
if has_valid_token and not expired:
|
|
39
52
|
return
|
|
40
53
|
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
if expired:
|
|
55
|
+
print("## ConductGuard: session token expired")
|
|
56
|
+
print("Run `conduct login` to start a new session before making LLM calls.\n")
|
|
57
|
+
note = "token_expires_at in the past"
|
|
58
|
+
rule = "proxy_token_expired"
|
|
59
|
+
msg = "Developer proxy session token expired — run conduct login"
|
|
60
|
+
else:
|
|
61
|
+
print("## ConductGuard: proxy token missing or malformed")
|
|
62
|
+
print("Run `conduct login` to refresh your token before making LLM calls.\n")
|
|
63
|
+
note = "proxy token not found in ~/.conduct/env"
|
|
64
|
+
rule = "proxy_token_missing"
|
|
65
|
+
msg = "Developer proxy token missing or malformed — run conduct login"
|
|
43
66
|
|
|
44
67
|
try:
|
|
45
|
-
cfg
|
|
46
|
-
|
|
47
|
-
clerk_user_id = cfg.get("clerk_user_id", "") or cfg.get("user_email", "")
|
|
68
|
+
workspace_id = (cfg or {}).get("workspace_id", "")
|
|
69
|
+
clerk_user_id = (cfg or {}).get("clerk_user_id", "") or (cfg or {}).get("user_email", "")
|
|
48
70
|
post_event(
|
|
49
71
|
"session_start",
|
|
50
|
-
{"note":
|
|
72
|
+
{"note": note},
|
|
51
73
|
"warned",
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
rule,
|
|
75
|
+
msg,
|
|
54
76
|
session_id=None,
|
|
55
77
|
)
|
|
56
78
|
except Exception:
|
|
@@ -358,7 +358,7 @@ def _report_tool_coverage() -> None:
|
|
|
358
358
|
|
|
359
359
|
payload = json.dumps({"email": email, "tools": tools}).encode()
|
|
360
360
|
headers = {"Content-Type": "application/json"}
|
|
361
|
-
if api_key
|
|
361
|
+
if api_key:
|
|
362
362
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
363
363
|
elif token:
|
|
364
364
|
headers["Authorization"] = f"Bearer {token}"
|
|
@@ -1348,10 +1348,8 @@ def _atomic_write(path: Path, data: dict) -> None:
|
|
|
1348
1348
|
tmp = path.with_suffix(".tmp")
|
|
1349
1349
|
tmp.write_text(json.dumps(existing, indent=2))
|
|
1350
1350
|
os.replace(tmp, path)
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
except Exception:
|
|
1354
|
-
pass
|
|
1351
|
+
from conduct_cli.hooks.base import restrict_to_owner
|
|
1352
|
+
restrict_to_owner(path)
|
|
1355
1353
|
|
|
1356
1354
|
|
|
1357
1355
|
def cmd_switch(args):
|
|
@@ -1445,8 +1443,9 @@ def cmd_switch(args):
|
|
|
1445
1443
|
def cmd_whoami(args):
|
|
1446
1444
|
cfg = _load_config()
|
|
1447
1445
|
|
|
1448
|
-
workspace_id
|
|
1449
|
-
|
|
1446
|
+
# cfg key drift: canonical keys are workspace_id / api_url, older configs used workspace / server
|
|
1447
|
+
workspace_id = cfg.get("workspace_id") or cfg.get("workspace") or ""
|
|
1448
|
+
server = cfg.get("api_url") or cfg.get("server") or "—"
|
|
1450
1449
|
api_key = cfg.get("agent_token", "")
|
|
1451
1450
|
|
|
1452
1451
|
# Try to resolve workspace name from /projects
|
|
@@ -1458,8 +1457,8 @@ def cmd_whoami(args):
|
|
|
1458
1457
|
match = next((p for p in projects if str(p.get("id", "")) == str(workspace_id)), None)
|
|
1459
1458
|
if match:
|
|
1460
1459
|
workspace_name = match["name"]
|
|
1461
|
-
except Exception:
|
|
1462
|
-
pass
|
|
1460
|
+
except (Exception, SystemExit):
|
|
1461
|
+
pass # server error on /projects (e.g. agent token → 500) is cosmetic
|
|
1463
1462
|
|
|
1464
1463
|
ws_display = workspace_name if workspace_name else workspace_id
|
|
1465
1464
|
ws_id_hint = f" ({workspace_id[:8]})" if workspace_id else ""
|
|
@@ -1467,7 +1466,7 @@ def cmd_whoami(args):
|
|
|
1467
1466
|
|
|
1468
1467
|
print(f"\n{BOLD}Workspace:{RESET} {ws_display}{ws_id_hint}")
|
|
1469
1468
|
print(f"{BOLD}Server:{RESET} {server}")
|
|
1470
|
-
print(f"{BOLD}
|
|
1469
|
+
print(f"{BOLD}Agent token:{RESET} {api_key_display}")
|
|
1471
1470
|
|
|
1472
1471
|
# Guard section — all under ~/.conduct/
|
|
1473
1472
|
policy_path = Path.home() / ".conduct" / "policy.json"
|
|
@@ -3197,7 +3196,7 @@ def main():
|
|
|
3197
3196
|
sub.add_parser("token", help="Show your agent token (masked by default)")
|
|
3198
3197
|
|
|
3199
3198
|
# conduct whoami
|
|
3200
|
-
sub.add_parser("whoami", help="Show current workspace, server,
|
|
3199
|
+
sub.add_parser("whoami", help="Show current workspace, server, agent token, and Guard/Booster status")
|
|
3201
3200
|
|
|
3202
3201
|
# conduct guard
|
|
3203
3202
|
guard_p, _guard_sub = _guard.register_guard_parser(sub)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conduct-cli
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.5
|
|
4
4
|
Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
|
|
5
5
|
Author-email: Conduct AI <hello@conductai.ai>
|
|
6
6
|
License: MIT
|
|
@@ -31,6 +31,8 @@ Requires-Dist: agent-booster[watch]>=0.6.6; extra == "booster"
|
|
|
31
31
|
|
|
32
32
|
Official CLI for [Conduct AI](https://conductai.ai) — install AI agents, manage projects, run end-to-end tests, and enforce team AI policies with ConductGuard.
|
|
33
33
|
|
|
34
|
+
Runs on Linux, macOS, and Windows. Python 3.9–3.12.
|
|
35
|
+
|
|
34
36
|

|
|
35
37
|
|
|
36
38
|
## Install
|
|
@@ -45,7 +47,7 @@ pip install conduct-cli
|
|
|
45
47
|
# Authenticate (one-time)
|
|
46
48
|
conduct login \
|
|
47
49
|
--server https://api.conductai.ai \
|
|
48
|
-
--
|
|
50
|
+
--token cond_agt_xxx \
|
|
49
51
|
--workspace <workspace-id>
|
|
50
52
|
|
|
51
53
|
# Browse available agents
|
|
@@ -81,10 +83,10 @@ conduct test --all
|
|
|
81
83
|
|
|
82
84
|
## Authentication
|
|
83
85
|
|
|
84
|
-
Generate an
|
|
86
|
+
Generate an agent token from **Settings → Agents → Issue token** in the Conduct AI dashboard. Tokens start with `cond_agt_` and are stored as SHA-256 hashes — the plaintext is shown only once.
|
|
85
87
|
|
|
86
88
|
```bash
|
|
87
|
-
conduct login --server https://api.conductai.ai --
|
|
89
|
+
conduct login --server https://api.conductai.ai --token cond_agt_xxx --workspace <id>
|
|
88
90
|
```
|
|
89
91
|
|
|
90
92
|
## Install all agents
|
|
@@ -313,7 +315,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
|
|
|
313
315
|
}
|
|
314
316
|
```
|
|
315
317
|
|
|
316
|
-
Run `conduct login` first — `conduct-mcp` reads credentials from `~/.conduct/config.json`. Get your
|
|
318
|
+
Run `conduct login` first — `conduct-mcp` reads credentials from `~/.conduct/config.json`. Get your agent token from **Settings → Agents → Issue token** in the Conduct AI dashboard.
|
|
317
319
|
|
|
318
320
|
---
|
|
319
321
|
|
|
@@ -4,6 +4,7 @@ setup.py
|
|
|
4
4
|
src/conduct_cli/__init__.py
|
|
5
5
|
src/conduct_cli/api.py
|
|
6
6
|
src/conduct_cli/guard.py
|
|
7
|
+
src/conduct_cli/guard_policy.md
|
|
7
8
|
src/conduct_cli/guardmcp.py
|
|
8
9
|
src/conduct_cli/log_util.py
|
|
9
10
|
src/conduct_cli/main.py
|
|
@@ -41,6 +42,7 @@ tests/test_journal_drain.py
|
|
|
41
42
|
tests/test_log_util.py
|
|
42
43
|
tests/test_proxy_coverage.py
|
|
43
44
|
tests/test_proxy_env.py
|
|
45
|
+
tests/test_report_tool_coverage.py
|
|
44
46
|
tests/test_signed_policy.py
|
|
45
47
|
tests/test_switch.py
|
|
46
48
|
tests/test_token_lifecycle.py
|
|
@@ -549,38 +549,6 @@ class TestInvalidStdin:
|
|
|
549
549
|
class TestFailClosedApiUnreachable:
|
|
550
550
|
"""fail_closed + policy exists + budget API unreachable → blocks with clear message."""
|
|
551
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
552
|
def test_api_unreachable_fail_open_passes(self, monkeypatch, tmp_path):
|
|
585
553
|
"""Same scenario but fail_open → should NOT block."""
|
|
586
554
|
import conduct_cli.hooks.pretooluse as pt
|
|
@@ -63,18 +63,6 @@ def test_headers_sets_bearer_when_token_given():
|
|
|
63
63
|
assert "X-Api-Key" not in h
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
def test_headers_sets_api_key_and_no_authorization():
|
|
67
|
-
h = headers("ws-1", api_key="key-abc")
|
|
68
|
-
assert h["X-Api-Key"] == "key-abc"
|
|
69
|
-
assert "Authorization" not in h
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def test_headers_api_key_takes_precedence_over_token():
|
|
73
|
-
h = headers("ws-1", token="tok", api_key="key")
|
|
74
|
-
assert h["X-Api-Key"] == "key"
|
|
75
|
-
assert "Authorization" not in h
|
|
76
|
-
|
|
77
|
-
|
|
78
66
|
def test_headers_custom_content_type():
|
|
79
67
|
h = headers("ws-1", content_type="text/plain")
|
|
80
68
|
assert h["Content-Type"] == "text/plain"
|