conduct-cli 0.7.23__tar.gz → 0.7.25__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 (44) hide show
  1. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/guard.py +55 -15
  4. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/base.py +14 -0
  5. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/pretooluse.py +5 -14
  6. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/main.py +181 -79
  7. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  8. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/README.md +0 -0
  9. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/setup.cfg +0 -0
  10. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/setup.py +0 -0
  11. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/__init__.py +0 -0
  12. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/api.py +0 -0
  13. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/guardmcp.py +0 -0
  14. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/__init__.py +0 -0
  15. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/posttooluse.py +0 -0
  16. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/precompact.py +0 -0
  17. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/session_parser.py +0 -0
  18. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/session_report_push.py +0 -0
  19. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/session_start.py +0 -0
  20. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/stop.py +0 -0
  21. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/log_util.py +0 -0
  22. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/mcp_server.py +0 -0
  23. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/memory.py +0 -0
  24. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/paxel.py +0 -0
  25. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/tool_groups.py +0 -0
  26. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  27. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  28. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  29. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/requires.txt +0 -0
  30. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/top_level.txt +0 -0
  31. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_advisory_and_hook.py +0 -0
  32. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_bash_operator_signature.py +0 -0
  33. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_blast_radius.py +0 -0
  34. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_command_word_matcher.py +0 -0
  35. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_drain_daemon_health.py +0 -0
  36. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_guard_policy.py +0 -0
  37. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_guard_savings.py +0 -0
  38. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_hook_dispatch.py +0 -0
  39. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_journal_drain.py +0 -0
  40. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_log_util.py +0 -0
  41. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_proxy_coverage.py +0 -0
  42. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_proxy_env.py +0 -0
  43. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_signed_policy.py +0 -0
  44. {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.23
3
+ Version: 0.7.25
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.7.23"
7
+ version = "0.7.25"
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" }
@@ -335,7 +335,14 @@ def _load_guard_config(workspace_id: str | None = None) -> dict:
335
335
 
336
336
  def _save_guard_config(data: dict, workspace_id: str | None = None):
337
337
  CONDUCT_HOME.mkdir(parents=True, exist_ok=True)
338
- CONFIG_PATH.write_text(json.dumps(data, indent=2))
338
+ existing = {}
339
+ if CONFIG_PATH.exists():
340
+ try:
341
+ existing = json.loads(CONFIG_PATH.read_text())
342
+ except Exception:
343
+ pass
344
+ existing.update(data)
345
+ CONFIG_PATH.write_text(json.dumps(existing, indent=2))
339
346
  CONFIG_PATH.chmod(0o600)
340
347
 
341
348
 
@@ -343,10 +350,10 @@ def _require_guard_config() -> dict:
343
350
  cfg = _load_guard_config()
344
351
  ws = cfg.get("workspace_id") or cfg.get("workspace")
345
352
  if not cfg or not ws:
346
- print(f"{RED}Guard not connected. Run: conduct login --api-key <key>{RESET}", file=sys.stderr)
353
+ print(f"{RED}Guard not connected. Run: conduct login{RESET}", file=sys.stderr)
347
354
  sys.exit(0)
348
- if not cfg.get("api_key"):
349
- print(f"{RED}Guard config is missing API key. Re-run: conduct login --api-key <key>{RESET}", file=sys.stderr)
355
+ if not cfg.get("agent_token") and not cfg.get("api_key"):
356
+ print(f"{RED}Guard config is missing credentials. Run: conduct login{RESET}", file=sys.stderr)
350
357
  sys.exit(0)
351
358
  return cfg
352
359
 
@@ -1153,11 +1160,45 @@ def _check_and_upgrade_packages() -> None:
1153
1160
  print(f" {YELLOW}Auto-update failed:{RESET} {e} — run: pip install --upgrade {' '.join(p for p,_,_ in stale)}")
1154
1161
 
1155
1162
 
1163
+ def _proactive_token_refresh() -> None:
1164
+ """Refresh agent_token if it expires within 5 min. No-op if no refresh_token."""
1165
+ cfg = _load_guard_config()
1166
+ if not cfg.get("refresh_token"):
1167
+ return
1168
+ import datetime as _dt, json as _json
1169
+ exp = cfg.get("token_expires_at", "")
1170
+ try:
1171
+ if exp and _dt.datetime.fromisoformat(exp) >= _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(minutes=5):
1172
+ return # still valid
1173
+ except ValueError:
1174
+ pass
1175
+ api_url = _api_url(cfg)
1176
+ try:
1177
+ req = urllib.request.Request(
1178
+ f"{api_url}/auth/refresh",
1179
+ data=_json.dumps({"refresh_token": cfg["refresh_token"]}).encode(),
1180
+ headers={"Content-Type": "application/json"},
1181
+ method="POST",
1182
+ )
1183
+ with urllib.request.urlopen(req, timeout=10) as resp:
1184
+ data = _json.loads(resp.read())
1185
+ cfg["agent_token"] = data["agent_token"]
1186
+ cfg["refresh_token"] = data["refresh_token"]
1187
+ cfg["workspace_id"] = data["workspace_id"]
1188
+ cfg["workspace"] = data["workspace_id"]
1189
+ cfg["token_expires_at"] = (_dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(hours=8)).isoformat()
1190
+ _save_guard_config(cfg)
1191
+ except Exception:
1192
+ print(f" {YELLOW}⚠ Token refresh failed — run `conduct login` if sync fails{RESET}")
1193
+
1194
+
1156
1195
  def cmd_guard_sync(args):
1196
+ _proactive_token_refresh()
1157
1197
  cfg = _require_guard_config()
1158
1198
  workspace_id = cfg.get("workspace_id") or cfg.get("workspace")
1159
- api_key = cfg.get("api_key", "")
1199
+ agent_token = cfg.get("agent_token", "")
1160
1200
  base_url = _api_url(cfg)
1201
+ _token = agent_token or None
1161
1202
 
1162
1203
  # Persona selection — prompt once, skip if already chosen
1163
1204
 
@@ -1169,7 +1210,7 @@ def cmd_guard_sync(args):
1169
1210
  policy = _req(
1170
1211
  "GET",
1171
1212
  f"{base_url}/guard/policies/sync?workspace_id={workspace_id}",
1172
- api_key=api_key,
1213
+ token=_token,
1173
1214
  )
1174
1215
  except Exception as e:
1175
1216
  print(f"Guard sync skipped: {e}", file=sys.stderr)
@@ -1220,7 +1261,7 @@ def cmd_guard_sync(args):
1220
1261
  installed = _req(
1221
1262
  "GET",
1222
1263
  f"{base_url}/guard/config/installed?workspace_id={workspace_id}",
1223
- api_key=api_key,
1264
+ token=_token,
1224
1265
  )
1225
1266
  fresh_agent_token = installed.get("agent_token") or ""
1226
1267
  if fresh_agent_token:
@@ -1238,7 +1279,7 @@ def cmd_guard_sync(args):
1238
1279
  if not proxy_url:
1239
1280
  try:
1240
1281
  import urllib.request as _ur, urllib.error as _ue
1241
- _headers = {"X-Api-Key": api_key, "Content-Type": "application/json"}
1282
+ _headers = {"Content-Type": "application/json"}
1242
1283
  _token = cfg.get("agent_token", "")
1243
1284
  if _token:
1244
1285
  _headers["Authorization"] = f"Bearer {_token}"
@@ -1305,7 +1346,7 @@ def cmd_guard_sync(args):
1305
1346
  _ensure_booster(Path.cwd())
1306
1347
 
1307
1348
  # Capture savings from RTK and Agent Booster
1308
- _report_savings(cfg, base_url, api_key)
1349
+ _report_savings(cfg, base_url)
1309
1350
 
1310
1351
  # Report AI tool coverage
1311
1352
  try:
@@ -1327,14 +1368,14 @@ def cmd_guard_sync(args):
1327
1368
 
1328
1369
  print(f"\n{BOLD}Policy refreshed ({rule_count} rule(s)).{RESET}")
1329
1370
 
1330
- # Print Claude.ai remote MCP URL — requires one-time browser paste
1371
+ # Print remote MCP URL for any MCP-compatible client
1331
1372
  agent_token = cfg.get("agent_token", "")
1332
1373
  if workspace_id and agent_token:
1333
1374
  mcp_url = "https://api.conductai.ai/guard/mcp"
1334
- print(f"\n{BOLD}Claude.ai{RESET} (one-time browser setup):")
1335
- print(f" Settings → MCP Servers → Add custom server")
1375
+ masked = agent_token[:13] + "•" * 20
1376
+ print(f"\n{BOLD}MCP server{RESET} (Claude.ai, Cursor, Windsurf, any MCP client):")
1336
1377
  print(f" URL: {CYAN}{mcp_url}{RESET}")
1337
- print(f" Auth: {CYAN}Bearer {agent_token}{RESET}\n")
1378
+ print(f" Auth: {CYAN}Bearer {masked}{RESET} {GRAY}(run `conduct token` to reveal){RESET}\n")
1338
1379
 
1339
1380
 
1340
1381
  CONDUCT_DIR = Path.home() / ".conduct"
@@ -1422,7 +1463,6 @@ def _post_local_findings(cfg: dict, base_url: str, findings: list[dict]) -> None
1422
1463
  "user_email": cfg.get("user_email", ""),
1423
1464
  "findings": findings,
1424
1465
  },
1425
- api_key=cfg.get("api_key", ""),
1426
1466
  token=cfg.get("agent_token", ""),
1427
1467
  )
1428
1468
  except Exception as e:
@@ -1627,7 +1667,7 @@ def _ensure_booster(root: Path) -> None:
1627
1667
  pass
1628
1668
 
1629
1669
 
1630
- def _report_savings(cfg: dict, base_url: str, api_key: str) -> None:
1670
+ def _report_savings(cfg: dict, base_url: str, api_key: str = "") -> None:
1631
1671
  import subprocess
1632
1672
 
1633
1673
  rtk_data = {}
@@ -94,6 +94,20 @@ def detect_ai_tool() -> str:
94
94
  return "cursor"
95
95
  if term == "windsurf":
96
96
  return "windsurf"
97
+ # ── Ancestor process check — Claude Code spawns via zsh, so check grandparent
98
+ try:
99
+ import psutil as _psutil
100
+ _proc = _psutil.Process(os.getppid())
101
+ for _ in range(3): # check up to 3 levels
102
+ _cmd = " ".join(_proc.cmdline()).lower()
103
+ _name = _proc.name().lower()
104
+ if ".claude/shell-snapshots" in _cmd or ("claude" in _cmd and "claude" in _name):
105
+ return "claude-code"
106
+ _proc = _proc.parent()
107
+ if _proc is None:
108
+ break
109
+ except Exception:
110
+ pass
97
111
  # ── PATH heuristics ───────────────────────────────────────────────────────
98
112
  path = os.environ.get("PATH", "")
99
113
  if "Codex.app" in path:
@@ -110,7 +110,7 @@ def _post_signature_invalid_event(expected_sig, computed_sig, policy_version, ho
110
110
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
111
111
  except Exception:
112
112
  return
113
- workspace_id = cfg.get("workspace_id")
113
+ workspace_id = cfg.get("workspace_id") or cfg.get("workspace")
114
114
  if not workspace_id:
115
115
  return
116
116
  import platform as _platform
@@ -155,7 +155,7 @@ def _maybe_sync_policy() -> None:
155
155
  """Sync policy from daemon (instant) or remote API (once per minute). Never raises."""
156
156
  try:
157
157
  cfg = load_config()
158
- workspace_id = cfg.get("workspace_id")
158
+ workspace_id = cfg.get("workspace_id") or cfg.get("workspace")
159
159
  api_key = cfg.get("api_key", "")
160
160
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
161
161
  if not workspace_id:
@@ -249,11 +249,11 @@ def _fetch_budget_status():
249
249
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
250
250
  except Exception:
251
251
  return False, None
252
- workspace_id = cfg.get("workspace_id")
252
+ workspace_id = cfg.get("workspace_id") or cfg.get("workspace")
253
253
  clerk_user_id = cfg.get("clerk_user_id") or ""
254
254
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
255
255
  if not workspace_id:
256
- return False, None
256
+ return False, "unconfigured"
257
257
  url = f"{api_url}/guard/spend/budget-check?workspace_id={workspace_id}"
258
258
  if clerk_user_id:
259
259
  url += f"&clerk_user_id={clerk_user_id}"
@@ -435,19 +435,10 @@ def main() -> None:
435
435
  post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id, drain_via=_this_file)
436
436
  sys.exit(2)
437
437
 
438
- # Hard budget cap (cached 5 min)
438
+ # Hard budget cap (cached 5 min) — only block if server explicitly says so
439
439
  hard_blocked, reason = _load_budget_cache()
440
440
  if hard_blocked is None:
441
441
  hard_blocked, reason = _fetch_budget_status()
442
- if not hard_blocked and reason is None and _get_fail_mode() == "fail_closed":
443
- tool_name = (data.get("tool_name") or "").lower()
444
- tool_input = data.get("tool_input") or {}
445
- session_id = data.get("session_id")
446
- msg = "[ConductGuard] Guard API unreachable — tool call blocked (fail-closed). Check your connection or ask your admin to set fail_open in Guard settings."
447
- print(msg)
448
- print(msg, file=sys.stderr)
449
- post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id, drain_via=_this_file)
450
- sys.exit(2)
451
442
  if hard_blocked:
452
443
  msg = f"[ConductGuard] {reason or 'Budget hard cap reached. Contact your manager.'}"
453
444
  print(msg)
@@ -97,6 +97,10 @@ def _load_config() -> dict:
97
97
  def _save_config(data: dict):
98
98
  CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
99
99
  CONFIG_PATH.write_text(json.dumps(data, indent=2))
100
+ try:
101
+ import os as _os; _os.chmod(CONFIG_PATH, 0o600)
102
+ except Exception:
103
+ pass
100
104
 
101
105
 
102
106
  def _resolve(args, key: str, config_key=None):
@@ -110,19 +114,21 @@ def _resolve(args, key: str, config_key=None):
110
114
 
111
115
  def _require_auth(args):
112
116
  """Return (server, workspace_id, api_key, token) — exit if not configured."""
113
- server = _resolve(args, "server")
114
- workspace = _resolve(args, "workspace")
117
+ cfg = _load_config()
118
+ server = _resolve(args, "server") or cfg.get("api_url", "")
119
+ workspace = _resolve(args, "workspace") or cfg.get("workspace_id") or cfg.get("workspace")
115
120
  api_key = _resolve(args, "api_key", "api_key")
116
- token = _resolve(args, "token")
121
+ # agent_token (new) takes precedence over legacy token
122
+ token = _resolve(args, "token") or cfg.get("agent_token")
117
123
 
118
124
  if not server:
119
- print(f"{RED}No server set. Run: conduct login --server <url> --api-key <key>{RESET}")
125
+ print(f"{RED}No server set. Run: conduct login{RESET}")
120
126
  sys.exit(1)
121
127
  if not workspace:
122
- print(f"{RED}No workspace set. Run: conduct login --workspace <id>{RESET}")
128
+ print(f"{RED}No workspace set. Run: conduct login{RESET}")
123
129
  sys.exit(1)
124
130
  if not api_key and not token:
125
- print(f"{RED}No credentials. Run: conduct login --api-key <key>{RESET}")
131
+ print(f"{RED}Not authenticated. Run: conduct login{RESET}")
126
132
  sys.exit(1)
127
133
 
128
134
  return server.rstrip("/"), workspace, api_key, token
@@ -414,84 +420,126 @@ def cmd_mcp_install(args):
414
420
  pass
415
421
 
416
422
 
417
- def cmd_login(args):
418
- server = args.server
419
- api_key = args.api_key
420
- workspace = args.workspace
421
- token = args.token
423
+ _DEFAULT_API_URL = "https://api.conductai.ai"
424
+ _DEFAULT_WEB_URL = "https://app.conductai.ai"
422
425
 
423
- if not server and not api_key and not workspace:
424
- cfg = _load_config()
425
- if cfg:
426
- print(f"{BOLD}Current config ({CONFIG_PATH}):{RESET}")
427
- print(f" server: {cfg.get('server', '—')}")
428
- print(f" workspace: {cfg.get('workspace', '—')}")
429
- print(f" api_key: {'set' if cfg.get('api_key') else '—'}")
430
- else:
431
- print("No config found. Run: conduct login --server <url> --api-key <key> --workspace <id>")
432
- return
433
426
 
434
- cfg = _load_config()
435
- if server: cfg["server"] = server.rstrip("/")
436
- if api_key: cfg["api_key"] = api_key
437
- if workspace: cfg["workspace"] = workspace
438
- if token: cfg["token"] = token
427
+ def _find_free_port() -> int:
428
+ import socket
429
+ with socket.socket() as s:
430
+ s.bind(("127.0.0.1", 0))
431
+ return s.getsockname()[1]
439
432
 
440
- s = cfg["server"]
441
- ak = cfg.get("api_key")
442
- tok = cfg.get("token")
443
433
 
444
- # Fetch identity from API key — always refresh email/user_id
445
- if ak and ak.startswith("cond_live_"):
446
- try:
447
- hdrs = {"X-Api-Key": ak, "Content-Type": "application/json"}
448
- me = api.req("GET", f"{s}/me", hdrs)
449
- cfg["workspace"] = me["workspace_id"]
450
- if me.get("user_id"):
451
- cfg["user_id"] = me["user_id"]
452
- if me.get("email"):
453
- cfg["email"] = me["email"]
454
- if not cfg.get("workspace"):
455
- print(f"{GREEN}✓ Workspace discovered:{RESET} {cfg['workspace']}")
456
- except SystemExit:
457
- if not cfg.get("workspace"):
458
- print(f"{YELLOW}⚠ Could not auto-discover workspace. Pass --workspace <id> manually.{RESET}")
434
+ def _web_login_flow(api_url: str, web_url: str) -> dict:
435
+ """Open browser → localhost callback → return {agent_token, refresh_token, workspace_id}."""
436
+ import http.server
437
+ import secrets
438
+ import threading
439
+ import webbrowser
459
440
 
460
- ws = cfg.get("workspace", "")
461
- if ws and (ak or tok):
462
- hdrs = api.headers(ws, tok, "application/json", ak)
463
- try:
464
- api.req("GET", f"{s}/workflows", hdrs)
465
- print(f"{GREEN}✓ Connected to {s}{RESET}")
466
- except SystemExit:
467
- print(f"{RED}Could not connect — check your server URL, workspace ID, and API key.{RESET}")
468
- sys.exit(1)
441
+ state = secrets.token_urlsafe(16)
442
+ port = _find_free_port()
443
+ result: dict = {}
444
+ event = threading.Event()
469
445
 
470
- _save_config(cfg)
471
- print(f"{GREEN}✓ Config saved to {CONFIG_PATH}{RESET}")
446
+ class _Handler(http.server.BaseHTTPRequestHandler):
447
+ def do_GET(self):
448
+ parsed = urllib.parse.urlparse(self.path)
449
+ params = dict(urllib.parse.parse_qsl(parsed.query))
472
450
 
473
- # Auto-install Guard if available for this workspace
474
- if ak and ak.startswith("cond_live_"):
475
- try:
476
- from conduct_cli.guard import cmd_guard_install
477
- import types
478
- fake_args = types.SimpleNamespace(api_key=ak, server=s)
479
- cmd_guard_install(fake_args)
480
- except SystemExit:
481
- pass # Guard not found — skip silently
482
- except Exception:
483
- pass # Never block login on Guard errors
451
+ if parsed.path != "/callback":
452
+ self.send_response(404)
453
+ self.end_headers()
454
+ return
455
+
456
+ # Respond immediately so browser shows success
457
+ body = b"<html><body><h2>Login successful. You can close this tab.</h2></body></html>"
458
+ self.send_response(200)
459
+ self.send_header("Content-Type", "text/html")
460
+ self.send_header("Content-Length", str(len(body)))
461
+ self.end_headers()
462
+ self.wfile.write(body)
463
+
464
+ if params.get("state") == state:
465
+ result.update(params)
466
+ event.set()
467
+
468
+ def log_message(self, *_):
469
+ pass # silence server logs
470
+
471
+ server = http.server.HTTPServer(("127.0.0.1", port), _Handler)
472
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
473
+ thread.start()
474
+
475
+ auth_url = f"{web_url}/cli-auth?port={port}&state={state}&api={urllib.parse.quote(api_url, safe='')}"
476
+ print(f"\n{BOLD}Opening browser for authentication…{RESET}")
477
+ print(f"{GRAY}If the browser didn't open, visit:{RESET}")
478
+ print(f" {CYAN}{auth_url}{RESET}\n")
479
+ webbrowser.open(auth_url)
480
+
481
+ if not event.wait(timeout=300):
482
+ server.shutdown()
483
+ print(f"{RED}Login timed out (5 min). Try again.{RESET}")
484
+ sys.exit(1)
485
+
486
+ server.shutdown()
487
+
488
+ if not result.get("agent_token") or not result.get("workspace_id"):
489
+ print(f"{RED}Login failed — missing token in callback. Try again.{RESET}")
490
+ sys.exit(1)
484
491
 
485
- # Auto-register MCP servers in Claude Code / Codex
492
+ return result
493
+
494
+
495
+ def _token_login_flow(agent_token: str, api_url: str) -> dict:
496
+ """Manual --token flow: validate token against API and return workspace info."""
497
+ hdrs = {"Authorization": f"Bearer {agent_token}", "Content-Type": "application/json"}
486
498
  try:
487
- import types
488
- cmd_mcp_install(types.SimpleNamespace())
489
- except Exception:
490
- pass # Never block login on MCP registration errors
499
+ me = api.req("GET", f"{api_url}/me", hdrs)
500
+ return {"agent_token": agent_token, "workspace_id": me.get("workspace_id", "")}
501
+ except SystemExit:
502
+ print(f"{RED}Token rejected by server. Check your token and try again.{RESET}")
503
+ sys.exit(1)
504
+
505
+
506
+ def cmd_login(args):
507
+ api_url = (getattr(args, "server", None) or _load_config().get("api_url", _DEFAULT_API_URL)).rstrip("/")
508
+ web_url = _DEFAULT_WEB_URL
509
+
510
+ # --token escape hatch: paste agent_token directly (no browser)
511
+ manual_token = getattr(args, "token", None)
512
+ if manual_token:
513
+ if not manual_token.startswith("cond_agt_"):
514
+ print(f"{RED}Invalid token format. Expected cond_agt_... token from the dashboard.{RESET}")
515
+ sys.exit(1)
516
+ result = _token_login_flow(manual_token, api_url)
517
+ else:
518
+ result = _web_login_flow(api_url, web_url)
519
+
520
+ # Write config
521
+ import datetime as _dt
522
+ cfg = _load_config()
523
+ cfg["api_url"] = api_url
524
+ cfg["agent_token"] = result["agent_token"]
525
+ cfg["workspace"] = result["workspace_id"]
526
+ cfg["workspace_id"] = result["workspace_id"]
527
+ cfg["token_expires_at"] = (_dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(hours=8)).isoformat()
528
+ if result.get("refresh_token"):
529
+ cfg["refresh_token"] = result["refresh_token"]
530
+ # Clear legacy api_key — agent_token is the credential now
531
+ cfg.pop("api_key", None)
532
+ _atomic_write(CONFIG_PATH, cfg)
491
533
 
492
- # Report tool coverage to Guard
534
+ print(f"{GREEN}✓ Logged in{RESET} — workspace {GRAY}{result['workspace_id']}{RESET}")
535
+
536
+ # Auto-sync: register hooks + pull policy
493
537
  try:
494
- _report_tool_coverage()
538
+ import conduct_cli.guard as _g
539
+ import types
540
+ _g.cmd_guard_sync(types.SimpleNamespace())
541
+ except SystemExit:
542
+ pass
495
543
  except Exception:
496
544
  pass
497
545
 
@@ -1248,6 +1296,10 @@ def _atomic_write(path: Path, data: dict) -> None:
1248
1296
  tmp = path.with_suffix(".tmp")
1249
1297
  tmp.write_text(json.dumps(data, indent=2))
1250
1298
  os.replace(tmp, path)
1299
+ try:
1300
+ os.chmod(path, 0o600)
1301
+ except Exception:
1302
+ pass
1251
1303
 
1252
1304
 
1253
1305
  def cmd_switch(args):
@@ -1316,7 +1368,7 @@ def cmd_switch(args):
1316
1368
  new_name = chosen["name"]
1317
1369
 
1318
1370
  # Update ~/.conduct/config.json atomically
1319
- cfg["workspace"] = new_id
1371
+ cfg["workspace"] = new_id; cfg["workspace_id"] = new_id
1320
1372
  _atomic_write(CONFIG_PATH, cfg)
1321
1373
 
1322
1374
 
@@ -2428,9 +2480,48 @@ def cmd_run(args):
2428
2480
 
2429
2481
  # ── conduct sync / test-guard / test-security ────────────────────────────────
2430
2482
 
2483
+ def _refresh_agent_token() -> bool:
2484
+ """Silently rotate agent_token using refresh_token. Returns True if refreshed."""
2485
+ cfg = _load_config()
2486
+ refresh_token = cfg.get("refresh_token", "")
2487
+ if not refresh_token:
2488
+ return False
2489
+ api_url = cfg.get("api_url", _DEFAULT_API_URL).rstrip("/")
2490
+ try:
2491
+ import json as _json
2492
+ req = urllib.request.Request(
2493
+ f"{api_url}/auth/refresh",
2494
+ data=_json.dumps({"refresh_token": refresh_token}).encode(),
2495
+ headers={"Content-Type": "application/json"},
2496
+ method="POST",
2497
+ )
2498
+ with urllib.request.urlopen(req, timeout=10) as resp:
2499
+ data = _json.loads(resp.read())
2500
+ import datetime as _dt
2501
+ cfg["agent_token"] = data["agent_token"]
2502
+ cfg["refresh_token"] = data["refresh_token"]
2503
+ cfg["workspace"] = data["workspace_id"]
2504
+ cfg["workspace_id"] = data["workspace_id"]
2505
+ cfg["token_expires_at"] = (_dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(hours=8)).isoformat()
2506
+ _atomic_write(CONFIG_PATH, cfg)
2507
+ return True
2508
+ except Exception:
2509
+ return False
2510
+
2511
+
2431
2512
  def cmd_sync(args):
2432
2513
  """Sync Guard policies (and Security Loop policies if installed)."""
2433
2514
  import conduct_cli.guard as _g
2515
+ # Proactive refresh if token expires within 5 min
2516
+ cfg = _load_config()
2517
+ if cfg.get("refresh_token"):
2518
+ import datetime as _dt
2519
+ exp = cfg.get("token_expires_at", "")
2520
+ try:
2521
+ if not exp or _dt.datetime.fromisoformat(exp) < _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(minutes=5):
2522
+ _refresh_agent_token()
2523
+ except ValueError:
2524
+ pass
2434
2525
  print(f"\n{BOLD}▶ conduct sync{RESET}\n")
2435
2526
  _g.cmd_guard_sync(args)
2436
2527
  print(f"\n{GREEN}Sync complete.{RESET}\n")
@@ -2928,11 +3019,9 @@ def main():
2928
3019
  sub = parser.add_subparsers(dest="command")
2929
3020
 
2930
3021
  # conduct login
2931
- login_p = sub.add_parser("login", help="Save connection config (~/.conduct/config.json)")
2932
- login_p.add_argument("--server", help="API base URL e.g. https://api.conductai.ai")
2933
- login_p.add_argument("--api-key", dest="api_key", help="CLI API key (set CLI_API_KEY on server)")
2934
- login_p.add_argument("--workspace", help="Workspace ID (auto-discovered from API key if omitted)")
2935
- login_p.add_argument("--token", help=argparse.SUPPRESS)
3022
+ login_p = sub.add_parser("login", help="Authenticate with Conduct (opens browser)")
3023
+ login_p.add_argument("--server", help="API base URL (default: https://api.conductai.ai)")
3024
+ login_p.add_argument("--token", help="Paste an agent token directly instead of opening a browser")
2936
3025
 
2937
3026
  # conduct agents
2938
3027
  agents_p = sub.add_parser("agents", help="List all agents")
@@ -3037,6 +3126,9 @@ def main():
3037
3126
  switch_p.add_argument("workspace", nargs="?", metavar="name_or_id",
3038
3127
  help="Workspace name or UUID prefix to switch to (omit to list)")
3039
3128
 
3129
+ # conduct token show
3130
+ sub.add_parser("token", help="Show your agent token (masked by default)")
3131
+
3040
3132
  # conduct whoami
3041
3133
  sub.add_parser("whoami", help="Show current workspace, server, API key, and Guard/Booster status")
3042
3134
 
@@ -3140,6 +3232,16 @@ def main():
3140
3232
  cmd_run(args)
3141
3233
  elif args.command == "switch":
3142
3234
  cmd_switch(args)
3235
+ elif args.command == "token":
3236
+ cfg = _load_config()
3237
+ token = cfg.get("agent_token", "")
3238
+ if not token:
3239
+ print(f"{RED}No agent token found. Run: conduct login{RESET}")
3240
+ sys.exit(1)
3241
+ print(f"\n{BOLD}Agent token{RESET} (paste as Bearer value in MCP server auth):\n")
3242
+ print(f" {CYAN}Bearer {token}{RESET}\n")
3243
+ print(f"{GRAY}Token expires: {cfg.get('token_expires_at', 'unknown')}{RESET}")
3244
+ print(f"{GRAY}Workspace: {cfg.get('workspace_id', '')}{RESET}\n")
3143
3245
  elif args.command == "whoami":
3144
3246
  cmd_whoami(args)
3145
3247
  elif args.command == "sessions":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.23
3
+ Version: 0.7.25
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
File without changes
File without changes
File without changes