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.
Files changed (50) hide show
  1. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/PKG-INFO +7 -5
  2. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/README.md +6 -4
  3. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/pyproject.toml +2 -2
  4. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/guard.py +73 -23
  5. conduct_cli-0.8.5/src/conduct_cli/guard_policy.md +16 -0
  6. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/base.py +48 -12
  7. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/session_start.py +33 -11
  8. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/main.py +10 -11
  9. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/PKG-INFO +7 -5
  10. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  11. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_advisory_and_hook.py +0 -32
  12. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_api_client.py +0 -12
  13. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_cmd_run.py +6 -17
  14. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_drain_daemon_health.py +85 -9
  15. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_guard_policy.py +4 -63
  16. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_hook_dispatch.py +1 -1
  17. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_proxy_env.py +72 -4
  18. conduct_cli-0.8.5/tests/test_report_tool_coverage.py +117 -0
  19. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_switch.py +15 -31
  20. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/setup.cfg +0 -0
  21. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/setup.py +0 -0
  22. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/__init__.py +0 -0
  23. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/api.py +0 -0
  24. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/guardmcp.py +0 -0
  25. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/__init__.py +0 -0
  26. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/posttooluse.py +0 -0
  27. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/precompact.py +0 -0
  28. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/pretooluse.py +0 -0
  29. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/session_parser.py +0 -0
  30. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/session_report_push.py +0 -0
  31. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/hooks/stop.py +0 -0
  32. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/log_util.py +0 -0
  33. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/mcp_server.py +0 -0
  34. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/memory.py +0 -0
  35. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/paxel.py +0 -0
  36. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli/tool_groups.py +0 -0
  37. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  38. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  39. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/requires.txt +0 -0
  40. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/src/conduct_cli.egg-info/top_level.txt +0 -0
  41. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_bash_operator_signature.py +0 -0
  42. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_blast_radius.py +0 -0
  43. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_command_word_matcher.py +0 -0
  44. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_guard_savings.py +0 -0
  45. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_instruction_files.py +0 -0
  46. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_journal_drain.py +0 -0
  47. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_log_util.py +0 -0
  48. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_proxy_coverage.py +0 -0
  49. {conduct_cli-0.8.3 → conduct_cli-0.8.5}/tests/test_signed_policy.py +0 -0
  50. {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
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
  ![Conduct CLI demo — whoami, switch workspaces with Guard policy sync, and run an agent](assets/conduct-cli-demo.gif)
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
- --api-key cond_live_xxx \
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 API key from **Settings → API Keys** in the Conduct AI dashboard. Keys start with `cond_live_` and are stored as SHA-256 hashes — the plaintext is shown only once.
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 --api-key cond_live_xxx --workspace <id>
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 member token from **Settings → API Keys** in the Conduct AI dashboard.
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
  ![Conduct CLI demo — whoami, switch workspaces with Guard policy sync, and run an agent](assets/conduct-cli-demo.gif)
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
- --api-key cond_live_xxx \
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 API key from **Settings → API Keys** in the Conduct AI dashboard. Keys start with `cond_live_` and are stored as SHA-256 hashes — the plaintext is shown only once.
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 --api-key cond_live_xxx --workspace <id>
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 member token from **Settings → API Keys** in the Conduct AI dashboard.
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.3"
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
- CONFIG_PATH.chmod(0o600)
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
- pattern = _re.compile(_re.escape(MARKER_START) + r".*?" + _re.escape(MARKER_END), _re.DOTALL)
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 MARKER_START in existing:
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 and conduct_agent_token.startswith("cond_live_"):
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 member token from server (workspace API key → fresh guard-mt- token).
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
- print(f" {GREEN}Proxy env written:{RESET} ~/.conduct/env → {proxy_url}")
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 `source {rc_path}` (or open a new shell) to activate.{RESET}")
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 API key value
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
- Everything here is stdlib-only so hooks work from a bare `pip install conduct-cli`
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
- _os.kill(pid, 0) # raises if process gone
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 (ProcessLookupError, PermissionError, ValueError):
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
- _os.kill(stale_pid, 15) # SIGTERM
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
- stdout=subprocess.DEVNULL,
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 malformed.
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
- if has_valid_token:
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
- print("## ConductGuard: proxy token missing or malformed")
42
- print("Run `conduct guard sync` to refresh your token before making LLM calls.\n")
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 = load_config()
46
- workspace_id = cfg.get("workspace_id", "")
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": "proxy token not found in ~/.conduct/env"},
72
+ {"note": note},
51
73
  "warned",
52
- "proxy_token_missing",
53
- "Developer proxy token missing or malformed — run conduct guard sync",
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 and api_key.startswith("cond_live_"):
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
- try:
1352
- os.chmod(path, 0o600)
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 = cfg.get("workspace", "")
1449
- server = cfg.get("server", "—")
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}API key:{RESET} {api_key_display}")
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, API key, and Guard/Booster status")
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
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
  ![Conduct CLI demo — whoami, switch workspaces with Guard policy sync, and run an agent](assets/conduct-cli-demo.gif)
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
- --api-key cond_live_xxx \
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 API key from **Settings → API Keys** in the Conduct AI dashboard. Keys start with `cond_live_` and are stored as SHA-256 hashes — the plaintext is shown only once.
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 --api-key cond_live_xxx --workspace <id>
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 member token from **Settings → API Keys** in the Conduct AI dashboard.
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"