conduct-cli 0.8.4__tar.gz → 0.8.6__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.4 → conduct_cli-0.8.6}/PKG-INFO +7 -5
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/README.md +6 -4
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/pyproject.toml +1 -1
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/api.py +13 -3
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/guard.py +68 -6
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/hooks/base.py +48 -12
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/hooks/session_start.py +33 -11
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/main.py +10 -11
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli.egg-info/PKG-INFO +7 -5
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_advisory_and_hook.py +0 -32
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_api_client.py +0 -12
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_cmd_run.py +6 -17
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_drain_daemon_health.py +85 -9
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_guard_policy.py +4 -63
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_hook_dispatch.py +1 -1
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_proxy_env.py +72 -4
- conduct_cli-0.8.6/tests/test_report_tool_coverage.py +117 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_switch.py +15 -31
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/setup.cfg +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/setup.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/hooks/pretooluse.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_log_util.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.8.4 → conduct_cli-0.8.6}/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.6
|
|
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.6"
|
|
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" }
|
|
@@ -69,17 +69,27 @@ def req_text(method: str, url: str, hdrs: dict, body_text: str, timeout: int = 3
|
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def stream(url: str, hdrs=None):
|
|
72
|
-
"""Yield parsed SSE data dicts.
|
|
72
|
+
"""Yield parsed SSE data dicts.
|
|
73
|
+
|
|
74
|
+
Uses a socket read timeout to detect stalled streams. Server sends
|
|
75
|
+
': keepalive' pings every 15s, so a 60s timeout is safe.
|
|
76
|
+
"""
|
|
73
77
|
r = urllib.request.Request(url, headers=hdrs or {})
|
|
74
78
|
try:
|
|
75
|
-
resp = urllib.request.urlopen(r)
|
|
79
|
+
resp = urllib.request.urlopen(r, timeout=60)
|
|
76
80
|
except urllib.error.HTTPError as e:
|
|
77
81
|
msg = e.read().decode()
|
|
78
82
|
raise RuntimeError(f"Stream {e.code}: {msg}")
|
|
79
83
|
|
|
80
84
|
buf = b""
|
|
81
85
|
while True:
|
|
82
|
-
|
|
86
|
+
try:
|
|
87
|
+
chunk = resp.read(1)
|
|
88
|
+
except socket.timeout:
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
"Stream stalled (no data for 60s). The run may still be executing on the server — "
|
|
91
|
+
"check `conduct runs list` or the UI."
|
|
92
|
+
)
|
|
83
93
|
if not chunk:
|
|
84
94
|
break
|
|
85
95
|
buf += chunk
|
|
@@ -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:
|
|
@@ -1253,7 +1254,7 @@ def _report_tools_to_server() -> None:
|
|
|
1253
1254
|
|
|
1254
1255
|
payload = json.dumps({"email": email, "tools": tools}).encode()
|
|
1255
1256
|
headers = {"Content-Type": "application/json"}
|
|
1256
|
-
if conduct_agent_token
|
|
1257
|
+
if conduct_agent_token:
|
|
1257
1258
|
headers["Authorization"] = f"Bearer {conduct_agent_token}"
|
|
1258
1259
|
elif token:
|
|
1259
1260
|
headers["Authorization"] = f"Bearer {token}"
|
|
@@ -1438,7 +1439,7 @@ def cmd_guard_sync(args):
|
|
|
1438
1439
|
_save_policy(policy)
|
|
1439
1440
|
print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
|
|
1440
1441
|
|
|
1441
|
-
# Refresh
|
|
1442
|
+
# Refresh agent token from server via refresh_token grant.
|
|
1442
1443
|
# Never rely on the locally-cached value — it may be stale or missing.
|
|
1443
1444
|
try:
|
|
1444
1445
|
installed = _req(
|
|
@@ -1478,9 +1479,11 @@ def cmd_guard_sync(args):
|
|
|
1478
1479
|
agent_token = cfg.get("agent_token", "")
|
|
1479
1480
|
rc_path, newly_sourced = _write_proxy_env(agent_token, proxy_url)
|
|
1480
1481
|
if agent_token:
|
|
1481
|
-
|
|
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}")
|
|
1482
1485
|
if newly_sourced:
|
|
1483
|
-
print(f" {CYAN}Run `
|
|
1486
|
+
print(f" {CYAN}Run `{activate_cmd}` (or open a new shell) to activate.{RESET}")
|
|
1484
1487
|
|
|
1485
1488
|
_tools = _detect_ai_tools()
|
|
1486
1489
|
if _tools:
|
|
@@ -1656,6 +1659,62 @@ def _post_local_findings(cfg: dict, base_url: str, findings: list[dict]) -> None
|
|
|
1656
1659
|
print(f" {YELLOW}Audit upload skipped: {e}{RESET}")
|
|
1657
1660
|
|
|
1658
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
|
+
|
|
1659
1718
|
def _write_proxy_env(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
|
|
1660
1719
|
"""Write ~/.conduct/env with the 3 provider env-var pairs and ensure the
|
|
1661
1720
|
user's shell rc sources it.
|
|
@@ -1669,8 +1728,11 @@ def _write_proxy_env(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
|
|
|
1669
1728
|
print(f" {YELLOW}Proxy env skipped — no agent token in config{RESET}")
|
|
1670
1729
|
return Path(), False
|
|
1671
1730
|
|
|
1731
|
+
if sys.platform == "win32":
|
|
1732
|
+
return _write_proxy_env_windows(agent_token, proxy_url)
|
|
1733
|
+
|
|
1672
1734
|
CONDUCT_DIR.mkdir(parents=True, exist_ok=True)
|
|
1673
|
-
token = agent_token # cond_agt_* used directly as the
|
|
1735
|
+
token = agent_token # cond_agt_* used directly as the bearer value
|
|
1674
1736
|
proxy = proxy_url.rstrip("/")
|
|
1675
1737
|
|
|
1676
1738
|
PROXY_ENV_FILE.write_text("\n".join([
|
|
@@ -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.6
|
|
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
|
|
|
@@ -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"
|