conduct-cli 0.9.7__tar.gz → 0.9.9__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 (54) hide show
  1. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/PKG-INFO +1 -1
  2. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/pyproject.toml +1 -1
  3. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/main.py +49 -0
  4. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  5. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_switch.py +182 -0
  6. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/README.md +0 -0
  7. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/setup.cfg +0 -0
  8. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/setup.py +0 -0
  9. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/__init__.py +0 -0
  10. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/api.py +0 -0
  11. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/guard.py +0 -0
  12. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/guard_policy.md +0 -0
  13. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/guardmcp.py +0 -0
  14. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/hooks/__init__.py +0 -0
  15. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/hooks/base.py +0 -0
  16. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/hooks/posttooluse.py +0 -0
  17. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/hooks/precompact.py +0 -0
  18. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/hooks/pretooluse.py +0 -0
  19. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/hooks/session_parser.py +0 -0
  20. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/hooks/session_report_push.py +0 -0
  21. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/hooks/session_start.py +0 -0
  22. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/hooks/stop.py +0 -0
  23. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/log_util.py +0 -0
  24. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/mcp_server.py +0 -0
  25. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/memory.py +0 -0
  26. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/paxel.py +0 -0
  27. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli/tool_groups.py +0 -0
  28. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  29. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  30. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  31. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli.egg-info/requires.txt +0 -0
  32. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/src/conduct_cli.egg-info/top_level.txt +0 -0
  33. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_advisory_and_hook.py +0 -0
  34. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_api_client.py +0 -0
  35. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_bash_operator_signature.py +0 -0
  36. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_bash_scan_target.py +0 -0
  37. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_blast_radius.py +0 -0
  38. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_cli_matrix.py +0 -0
  39. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_cmd_run.py +0 -0
  40. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_command_word_matcher.py +0 -0
  41. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_conductguard_mcp_stdio.py +0 -0
  42. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_drain_daemon_health.py +0 -0
  43. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_guard_approvals.py +0 -0
  44. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_guard_policy.py +0 -0
  45. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_guard_savings.py +0 -0
  46. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_hook_dispatch.py +0 -0
  47. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_instruction_files.py +0 -0
  48. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_journal_drain.py +0 -0
  49. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_log_util.py +0 -0
  50. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_proxy_coverage.py +0 -0
  51. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_proxy_env.py +0 -0
  52. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_report_tool_coverage.py +0 -0
  53. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/tests/test_signed_policy.py +0 -0
  54. {conduct_cli-0.9.7 → conduct_cli-0.9.9}/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.9.7
3
+ Version: 0.9.9
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.9.7"
7
+ version = "0.9.9"
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" }
@@ -552,6 +552,10 @@ def cmd_login(args):
552
552
  api_url = (getattr(args, "server", None) or _load_config().get("api_url", _DEFAULT_API_URL)).rstrip("/")
553
553
  web_url = _DEFAULT_WEB_URL
554
554
 
555
+ # Capture the workspace the user was on BEFORE login — so we can restore
556
+ # it after the login flow (which always returns the account's default).
557
+ prior_ws = _load_config().get("workspace_id") or _load_config().get("workspace") or ""
558
+
555
559
  # --token escape hatch: paste agent_token directly (no browser)
556
560
  manual_token = getattr(args, "token", None)
557
561
  if manual_token:
@@ -576,6 +580,28 @@ def cmd_login(args):
576
580
  cfg.pop("api_key", None)
577
581
  _atomic_write(CONFIG_PATH, cfg)
578
582
 
583
+ # Restore prior workspace if login returned a different default. Otherwise
584
+ # every `conduct login` silently blows away the last `conduct switch`.
585
+ if prior_ws and str(prior_ws) != str(result["workspace_id"]):
586
+ try:
587
+ _hdrs_restore = {"Authorization": f"Bearer {result['agent_token']}", "Content-Type": "application/json"}
588
+ _r = api.req("POST", f"{api_url}/auth/switch-workspace", _hdrs_restore, {"workspace_id": prior_ws})
589
+ if _r and _r.get("agent_token"):
590
+ cfg["agent_token"] = _r["agent_token"]
591
+ if _r.get("refresh_token"):
592
+ cfg["refresh_token"] = _r["refresh_token"]
593
+ cfg["workspace"] = prior_ws
594
+ cfg["workspace_id"] = prior_ws
595
+ cfg["token_expires_at"] = (_dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(seconds=int(_r.get("expires_in", 28800)))).isoformat()
596
+ _atomic_write(CONFIG_PATH, cfg)
597
+ # Update `result` so subsequent code (name display, sync) targets prior workspace
598
+ result["workspace_id"] = prior_ws
599
+ result["agent_token"] = cfg["agent_token"]
600
+ except SystemExit:
601
+ pass # not a member of prior workspace anymore, or endpoint missing — keep server default
602
+ except Exception:
603
+ pass
604
+
579
605
  # Fetch workspaces so we can display the current one by name and nudge
580
606
  # the user if their account has more than one (cross-surface switch drift).
581
607
  try:
@@ -1438,10 +1464,33 @@ def cmd_switch(args):
1438
1464
  new_id = str(chosen["id"])
1439
1465
  new_name = chosen["name"]
1440
1466
 
1467
+ # Re-mint agent token bound to the new workspace so server-side attribution
1468
+ # follows the switch. Without this, POSTs (discover, audit, heartbeats)
1469
+ # keep hitting the workspace the token was originally minted for.
1470
+ try:
1471
+ _r = api.req("POST", f"{server}/auth/switch-workspace", hdrs, {"workspace_id": new_id})
1472
+ if _r and _r.get("agent_token"):
1473
+ cfg["agent_token"] = _r["agent_token"]
1474
+ if _r.get("refresh_token"):
1475
+ cfg["refresh_token"] = _r["refresh_token"]
1476
+ import datetime as _dt
1477
+ cfg["token_expires_at"] = (_dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(seconds=int(_r.get("expires_in", 28800)))).isoformat()
1478
+ except SystemExit:
1479
+ print(f" {YELLOW}⚠ Could not re-mint token for new workspace — falling back to config-only switch{RESET}")
1480
+ except Exception as e:
1481
+ print(f" {YELLOW}⚠ Token re-mint failed ({e}) — falling back to config-only switch{RESET}")
1482
+
1441
1483
  # Update ~/.conduct/config.json atomically
1442
1484
  cfg["workspace"] = new_id; cfg["workspace_id"] = new_id
1443
1485
  _atomic_write(CONFIG_PATH, cfg)
1444
1486
 
1487
+ # Fire coverage POST so the new workspace's "developer connected" widget
1488
+ # populates immediately. Without this the switch works but the widget
1489
+ # stays empty until the next `conduct login`.
1490
+ try:
1491
+ _report_tool_coverage()
1492
+ except Exception:
1493
+ pass
1445
1494
 
1446
1495
  # Re-sync Guard policies for the new workspace
1447
1496
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.9.7
3
+ Version: 0.9.9
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
@@ -288,3 +288,185 @@ def test_login_omits_nudge_when_single_workspace(tmp_path, capsys):
288
288
  out = capsys.readouterr().out
289
289
  assert "Only" in out
290
290
  assert "conduct switch" not in out
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Fix A regression: cmd_switch must re-mint the agent token by calling
295
+ # POST /auth/switch-workspace so server-side attribution follows the local
296
+ # workspace flip. Without this, POSTs after switch keep hitting the
297
+ # original workspace.
298
+ # ---------------------------------------------------------------------------
299
+
300
+ def test_switch_remints_agent_token_via_endpoint(tmp_path, monkeypatch, capsys):
301
+ from conduct_cli import main as m
302
+ from conduct_cli import guard as g
303
+
304
+ cfg_path = tmp_path / "conduct" / "config.json"
305
+ cfg_path.parent.mkdir(parents=True)
306
+ cfg_path.write_text(json.dumps({
307
+ "api_url": "https://api.conductai.ai",
308
+ "agent_token": "cond_agt_ORIGINAL_engineering",
309
+ "workspace": "ef0a7e36-0000-0000-0000-000000000001",
310
+ }))
311
+
312
+ args = _make_args(workspace="Marketing")
313
+ remint_response = {
314
+ "agent_token": "cond_agt_NEW_marketing_scoped",
315
+ "refresh_token": "cond_ref_new_refresh",
316
+ "expires_in": 28800,
317
+ "workspace_id": "ab1b2c3d-0000-0000-0000-000000000002",
318
+ "user_id": "user_test",
319
+ }
320
+
321
+ def _api_req(method, url, hdrs, body=None, timeout=30):
322
+ if method == "GET" and url.endswith("/projects"):
323
+ return _fake_workspaces()
324
+ if method == "POST" and url.endswith("/auth/switch-workspace"):
325
+ assert body == {"workspace_id": "ab1b2c3d-0000-0000-0000-000000000002"}
326
+ return remint_response
327
+ raise AssertionError(f"unexpected call {method} {url}")
328
+
329
+ with (
330
+ patch.object(m, "CONFIG_PATH", cfg_path),
331
+ patch("pathlib.Path.home", return_value=tmp_path),
332
+ patch.object(m.api, "req", side_effect=_api_req),
333
+ patch.object(g, "_req", return_value={"version": "1", "rules": []}),
334
+ patch.object(g, "_save_policy"),
335
+ ):
336
+ m.cmd_switch(args)
337
+
338
+ updated_cfg = json.loads(cfg_path.read_text())
339
+ assert updated_cfg["agent_token"] == "cond_agt_NEW_marketing_scoped"
340
+ assert updated_cfg["refresh_token"] == "cond_ref_new_refresh"
341
+ assert updated_cfg["workspace_id"] == "ab1b2c3d-0000-0000-0000-000000000002"
342
+ assert "token_expires_at" in updated_cfg
343
+
344
+
345
+ def test_switch_falls_back_gracefully_when_remint_fails(tmp_path, monkeypatch, capsys):
346
+ """If the switch-workspace endpoint 401s (older server), config-only switch still applies."""
347
+ from conduct_cli import main as m
348
+ from conduct_cli import guard as g
349
+
350
+ cfg_path = tmp_path / "conduct" / "config.json"
351
+ cfg_path.parent.mkdir(parents=True)
352
+ cfg_path.write_text(json.dumps({
353
+ "api_url": "https://api.conductai.ai",
354
+ "agent_token": "cond_agt_ORIGINAL",
355
+ "workspace": "ef0a7e36-0000-0000-0000-000000000001",
356
+ }))
357
+ args = _make_args(workspace="Marketing")
358
+
359
+ def _api_req(method, url, hdrs, body=None, timeout=30):
360
+ if method == "GET" and url.endswith("/projects"):
361
+ return _fake_workspaces()
362
+ # Endpoint failure — mimic api.req's SystemExit on HTTP error
363
+ raise SystemExit(1)
364
+
365
+ with (
366
+ patch.object(m, "CONFIG_PATH", cfg_path),
367
+ patch("pathlib.Path.home", return_value=tmp_path),
368
+ patch.object(m.api, "req", side_effect=_api_req),
369
+ patch.object(g, "_req", return_value={"version": "1", "rules": []}),
370
+ patch.object(g, "_save_policy"),
371
+ ):
372
+ m.cmd_switch(args)
373
+
374
+ updated_cfg = json.loads(cfg_path.read_text())
375
+ # Token unchanged (fallback preserves the original) but workspace_id updated
376
+ assert updated_cfg["agent_token"] == "cond_agt_ORIGINAL"
377
+ assert updated_cfg["workspace_id"] == "ab1b2c3d-0000-0000-0000-000000000002"
378
+ out = capsys.readouterr().out
379
+ assert "falling back to config-only switch" in out
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # 0.9.9 — cmd_switch must fire _report_tool_coverage so the new workspace's
384
+ # "developer connected" widget populates immediately (not just after next login)
385
+ # ---------------------------------------------------------------------------
386
+
387
+ def test_switch_fires_tool_coverage(tmp_path, monkeypatch, capsys):
388
+ from conduct_cli import main as m
389
+ from conduct_cli import guard as g
390
+
391
+ cfg_path = tmp_path / "conduct" / "config.json"
392
+ cfg_path.parent.mkdir(parents=True)
393
+ cfg_path.write_text(json.dumps({
394
+ "api_url": "https://api.conductai.ai",
395
+ "agent_token": "cond_agt_test",
396
+ "workspace": "ef0a7e36-0000-0000-0000-000000000001",
397
+ }))
398
+ args = _make_args(workspace="Marketing")
399
+
400
+ def _api_req(method, url, hdrs, body=None, timeout=30):
401
+ if method == "GET" and url.endswith("/projects"):
402
+ return _fake_workspaces()
403
+ if method == "POST" and url.endswith("/auth/switch-workspace"):
404
+ return {"agent_token": "cond_agt_new", "expires_in": 28800, "workspace_id": body["workspace_id"], "user_id": "u1"}
405
+ raise AssertionError(f"unexpected call {method} {url}")
406
+
407
+ with (
408
+ patch.object(m, "CONFIG_PATH", cfg_path),
409
+ patch("pathlib.Path.home", return_value=tmp_path),
410
+ patch.object(m.api, "req", side_effect=_api_req),
411
+ patch.object(g, "_req", return_value={"version": "1", "rules": []}),
412
+ patch.object(g, "_save_policy"),
413
+ patch.object(m, "_report_tool_coverage") as coverage,
414
+ ):
415
+ m.cmd_switch(args)
416
+
417
+ coverage.assert_called_once()
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # 0.9.9 — cmd_login must preserve the prior workspace_id if it differs from
422
+ # the server-returned default. Otherwise every re-login blows away the user's
423
+ # last `conduct switch`.
424
+ # ---------------------------------------------------------------------------
425
+
426
+ def test_login_preserves_prior_workspace(tmp_path, capsys):
427
+ from conduct_cli import main as m
428
+
429
+ cfg_path = tmp_path / "config.json"
430
+ # Prior state: user was on Marketing (via a previous switch)
431
+ cfg_path.write_text(json.dumps({
432
+ "api_url": "https://api.conductai.ai",
433
+ "agent_token": "cond_agt_old",
434
+ "workspace": "ab1b2c3d-0000-0000-0000-000000000002",
435
+ "workspace_id": "ab1b2c3d-0000-0000-0000-000000000002",
436
+ }))
437
+ args = _make_args(server=None, token=None)
438
+
439
+ # Login returns Engineering (server default) — but prior was Marketing
440
+ fake_login_result = {
441
+ "agent_token": "cond_agt_from_login_engineering",
442
+ "workspace_id": "ef0a7e36-0000-0000-0000-000000000001",
443
+ }
444
+ remint_response = {
445
+ "agent_token": "cond_agt_reminted_for_marketing",
446
+ "refresh_token": "cond_ref_reminted",
447
+ "expires_in": 28800,
448
+ "workspace_id": "ab1b2c3d-0000-0000-0000-000000000002",
449
+ "user_id": "u1",
450
+ }
451
+
452
+ def _api_req(method, url, hdrs, body=None, timeout=30):
453
+ if method == "POST" and url.endswith("/auth/switch-workspace"):
454
+ # Should be called with prior_ws (Marketing UUID)
455
+ assert body == {"workspace_id": "ab1b2c3d-0000-0000-0000-000000000002"}
456
+ return remint_response
457
+ if method == "GET" and url.endswith("/projects"):
458
+ return _fake_workspaces()
459
+ raise AssertionError(f"unexpected call {method} {url}")
460
+
461
+ with (
462
+ patch.object(m, "CONFIG_PATH", cfg_path),
463
+ patch.object(m, "_web_login_flow", return_value=fake_login_result),
464
+ patch.object(m.api, "req", side_effect=_api_req),
465
+ patch("conduct_cli.guard.cmd_guard_sync", return_value=None),
466
+ ):
467
+ m.cmd_login(args)
468
+
469
+ updated_cfg = json.loads(cfg_path.read_text())
470
+ assert updated_cfg["workspace_id"] == "ab1b2c3d-0000-0000-0000-000000000002"
471
+ assert updated_cfg["agent_token"] == "cond_agt_reminted_for_marketing"
472
+ assert updated_cfg["refresh_token"] == "cond_ref_reminted"
File without changes
File without changes
File without changes