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.
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/PKG-INFO +1 -1
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/pyproject.toml +1 -1
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/guard.py +55 -15
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/base.py +14 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/pretooluse.py +5 -14
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/main.py +181 -79
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/README.md +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/setup.cfg +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/setup.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.7.23 → conduct_cli-0.7.25}/tests/test_switch.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {"
|
|
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
|
|
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
|
|
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
|
-
|
|
1335
|
-
print(f"
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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}
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
except
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
2932
|
-
login_p.add_argument("--server",
|
|
2933
|
-
login_p.add_argument("--
|
|
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":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|