conduct-cli 0.14.6__tar.gz → 0.14.8__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 (61) hide show
  1. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/PKG-INFO +1 -1
  2. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/pyproject.toml +1 -1
  3. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/guard.py +4 -7
  4. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/hooks/pretooluse.py +18 -5
  5. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/main.py +265 -0
  6. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  7. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  8. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_advisory_and_hook.py +17 -14
  9. conduct_cli-0.14.8/tests/test_gateway_config_import_export.py +293 -0
  10. conduct_cli-0.14.8/tests/test_sync_credential_isolation.py +44 -0
  11. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/LICENSE +0 -0
  12. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/README.md +0 -0
  13. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/setup.cfg +0 -0
  14. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/setup.py +0 -0
  15. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/__init__.py +0 -0
  16. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/api.py +0 -0
  17. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/guard_policy.md +0 -0
  18. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/hooks/__init__.py +0 -0
  19. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/hooks/base.py +0 -0
  20. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/hooks/posttooluse.py +0 -0
  21. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/hooks/precompact.py +0 -0
  22. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/hooks/session_parser.py +0 -0
  23. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/hooks/session_report_push.py +0 -0
  24. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/hooks/session_start.py +0 -0
  25. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/hooks/stop.py +0 -0
  26. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/log_util.py +0 -0
  27. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/memory.py +0 -0
  28. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/paxel.py +0 -0
  29. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli/tool_groups.py +0 -0
  30. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  31. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  32. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/requires.txt +0 -0
  33. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/top_level.txt +0 -0
  34. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_api_client.py +0 -0
  35. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_bash_operator_signature.py +0 -0
  36. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_bash_scan_target.py +0 -0
  37. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_blast_radius.py +0 -0
  38. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_cli_matrix.py +0 -0
  39. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_cmd_run.py +0 -0
  40. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_command_word_matcher.py +0 -0
  41. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_drain_daemon_health.py +0 -0
  42. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_guard_approvals.py +0 -0
  43. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_guard_policy.py +0 -0
  44. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_guard_savings.py +0 -0
  45. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_guard_status.py +0 -0
  46. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_hook_dispatch.py +0 -0
  47. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_hook_journal.py +0 -0
  48. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_hook_receipt.py +0 -0
  49. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_install_all.py +0 -0
  50. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_instruction_files.py +0 -0
  51. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_journal_drain.py +0 -0
  52. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_log_util.py +0 -0
  53. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_pretooluse_approval_e2e.py +0 -0
  54. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_pretooluse_check_policy.py +0 -0
  55. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_proxy_coverage.py +0 -0
  56. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_proxy_env.py +0 -0
  57. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_report_tool_coverage.py +0 -0
  58. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_session_start.py +0 -0
  59. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_signed_policy.py +0 -0
  60. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/tests/test_switch.py +0 -0
  61. {conduct_cli-0.14.6 → conduct_cli-0.14.8}/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.14.6
3
+ Version: 0.14.8
4
4
  Summary: Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.14.6"
7
+ version = "0.14.8"
8
8
  description = "Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -1550,26 +1550,23 @@ def cmd_guard_sync(args):
1550
1550
  _save_policy(policy)
1551
1551
  print(f" {GREEN}Policy refreshed:{RESET} {rule_count} hook rule(s). Proxy and non-hook rules run server-side.")
1552
1552
 
1553
- # Refresh agent token from server via refresh_token grant.
1554
- # Never rely on the locally-cached value — it may be stale or missing.
1553
+ # Installation metadata is not a refresh grant. Keep the access/refresh pair
1554
+ # together; another OAuth client's token must never replace this session.
1555
1555
  try:
1556
1556
  installed = _req(
1557
1557
  "GET",
1558
1558
  f"{base_url}/guard/config/installed?workspace_id={workspace_id}",
1559
1559
  token=_token,
1560
1560
  )
1561
- fresh_agent_token = installed.get("agent_token") or ""
1562
- if fresh_agent_token:
1563
- cfg["agent_token"] = fresh_agent_token
1564
1561
  if installed.get("user_email"):
1565
1562
  cfg["user_email"] = installed["user_email"]
1566
1563
  if installed.get("clerk_user_id"):
1567
1564
  cfg["clerk_user_id"] = installed["clerk_user_id"]
1568
1565
  _save_guard_config(cfg)
1569
- if not fresh_agent_token and not cfg.get("agent_token"):
1566
+ if not cfg.get("agent_token"):
1570
1567
  print(f" {YELLOW}Warning: server returned no token — proxy env may be stale{RESET}")
1571
1568
  except Exception as e:
1572
- print(f" {YELLOW}Warning: could not refresh token ({e}) — using cached value{RESET}")
1569
+ print(f" {YELLOW}Warning: could not fetch installation metadata ({e}){RESET}")
1573
1570
 
1574
1571
  # Write LLM proxy env vars so any AI tool (Claude Code, Cursor, Codex, …)
1575
1572
  # routes through Conduct Guard. Customer-overridable via --proxy-url or
@@ -8,6 +8,7 @@ import shlex
8
8
  import subprocess
9
9
  import sys
10
10
  import time
11
+ import urllib.parse
11
12
  import urllib.request
12
13
  from pathlib import Path
13
14
 
@@ -230,9 +231,18 @@ def _fetch_budget_status():
230
231
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
231
232
  if not workspace_id:
232
233
  return False, "unconfigured"
234
+ # ai_tool lets the server scope enforcement per-tool so an overspend in
235
+ # one client (e.g. codex-desktop) doesn't block others (e.g. claude-code).
236
+ try:
237
+ from conduct_cli.hooks.base import detect_ai_tool
238
+ ai_tool = detect_ai_tool() or ""
239
+ except Exception:
240
+ ai_tool = ""
233
241
  url = f"{api_url}/guard/spend/budget-check?workspace_id={workspace_id}"
234
242
  if clerk_user_id:
235
- url += f"&clerk_user_id={clerk_user_id}"
243
+ url += f"&clerk_user_id={urllib.parse.quote(clerk_user_id, safe='')}"
244
+ if ai_tool:
245
+ url += f"&ai_tool={urllib.parse.quote(ai_tool, safe='')}"
236
246
  try:
237
247
  with urllib.request.urlopen(urllib.request.Request(url), timeout=5) as resp:
238
248
  data = json.loads(resp.read())
@@ -828,9 +838,12 @@ def main() -> None:
828
838
  sys.exit(2)
829
839
 
830
840
  decision = {"block": "blocked", "warn": "warned"}.get(action, "allowed")
831
- if action == "warn" and session_id and rule_id and _already_warned_this_session(session_id, rule_id):
832
- sys.exit(0)
833
- if action == "warn" and session_id and rule_id:
841
+ # Deduplicate the warning message, never the audit event.
842
+ suppress_warning = bool(
843
+ action == "warn" and session_id and rule_id
844
+ and _already_warned_this_session(session_id, rule_id)
845
+ )
846
+ if action == "warn" and session_id and rule_id and not suppress_warning:
834
847
  _record_session_warn(session_id, rule_id)
835
848
 
836
849
  # Pre-mint the audit-row id ONLY on block. That way the receipt URL
@@ -855,7 +868,7 @@ def main() -> None:
855
868
  print(msg)
856
869
  print(_boxed(msg, receipt_url), file=sys.stderr)
857
870
  sys.exit(2)
858
- if action == "warn":
871
+ if action == "warn" and not suppress_warning:
859
872
  print(f"[ConductGuard] {message}")
860
873
 
861
874
  sys.exit(0)
@@ -2885,6 +2885,215 @@ def cmd_import_cedar(args):
2885
2885
  print(f"\nThis was a preview. Re-run with --install to create the pack in your workspace.")
2886
2886
 
2887
2887
 
2888
+ def cmd_import_gateway_config(args):
2889
+ """conduct import --gateway-config <file.json> [--name NEW_NAME]
2890
+
2891
+ Import a Gateway Profile v2 from a portable JSON file. Server strips
2892
+ every ``credential_ref`` on import — the admin then picks vault +
2893
+ credential handle per target in the UI (Save gate blocks until
2894
+ filled).
2895
+
2896
+ The JSON shape is the ``working_copy`` dict — same shape
2897
+ ``conduct export --gateway-config`` emits and the same shape the
2898
+ editor's Save call posts. Round-trip is safe: export → import
2899
+ produces a draft the admin can complete in one editor session.
2900
+
2901
+ Exit codes: 0 on success, 1 on any failure (auth, missing file,
2902
+ invalid JSON, backend rejection). Error output goes to stderr so
2903
+ callers can pipe stdout without noise.
2904
+ """
2905
+ server, workspace, token = _require_auth(args)
2906
+ if not token:
2907
+ print(f"{RED}Not authenticated. Run `conduct login` first.{RESET}", file=sys.stderr)
2908
+ sys.exit(1)
2909
+
2910
+ path = Path(args.file)
2911
+ if not path.exists():
2912
+ print(f"{RED}File not found: {path}{RESET}", file=sys.stderr)
2913
+ sys.exit(1)
2914
+
2915
+ try:
2916
+ working_copy = json.loads(path.read_text())
2917
+ except json.JSONDecodeError as e:
2918
+ print(f"{RED}Invalid JSON in {path}: {e}{RESET}", file=sys.stderr)
2919
+ sys.exit(1)
2920
+
2921
+ if not isinstance(working_copy, dict):
2922
+ print(
2923
+ f"{RED}Expected a JSON object at the top level (the "
2924
+ f"working_copy dict); got {type(working_copy).__name__}.{RESET}",
2925
+ file=sys.stderr,
2926
+ )
2927
+ sys.exit(1)
2928
+
2929
+ body_dict = {"working_copy": working_copy}
2930
+ if getattr(args, "name", None):
2931
+ body_dict["name_override"] = args.name
2932
+
2933
+ req = urllib.request.Request(
2934
+ f"{server}/workspaces/{workspace}/gateway-profiles-v2/import",
2935
+ data=json.dumps(body_dict).encode("utf-8"),
2936
+ headers={
2937
+ "Content-Type": "application/json",
2938
+ "Authorization": f"Bearer {token}",
2939
+ "X-Workspace-Id": workspace or "",
2940
+ },
2941
+ method="POST",
2942
+ )
2943
+
2944
+ try:
2945
+ with urllib.request.urlopen(req, timeout=30) as r:
2946
+ result = json.loads(r.read())
2947
+ except urllib.error.HTTPError as e:
2948
+ body_text = e.read().decode(errors="replace")
2949
+ # Backend returns structured errors (PR #2033) — parse them
2950
+ # into a readable per-line summary when possible.
2951
+ try:
2952
+ payload = json.loads(body_text)
2953
+ detail = payload.get("detail")
2954
+ if isinstance(detail, dict) and isinstance(detail.get("errors"), list):
2955
+ lines = [detail.get("summary", "import failed")]
2956
+ for err in detail["errors"][:5]:
2957
+ where = err.get("path") or "(profile)"
2958
+ lines.append(f" {where}: {err.get('message', 'invalid')}")
2959
+ if len(detail["errors"]) > 5:
2960
+ lines.append(f" +{len(detail['errors']) - 5} more")
2961
+ detail_text = "\n".join(lines)
2962
+ else:
2963
+ detail_text = str(detail or body_text)[:600]
2964
+ except Exception:
2965
+ detail_text = body_text[:600]
2966
+ print(f"{RED}Import failed ({e.code}):{RESET}\n{detail_text}", file=sys.stderr)
2967
+ sys.exit(1)
2968
+ except Exception as e:
2969
+ print(f"{RED}Request failed: {e}{RESET}", file=sys.stderr)
2970
+ sys.exit(1)
2971
+
2972
+ profile = result.get("profile") or {}
2973
+ gaps = result.get("credential_gaps") or []
2974
+ next_url = result.get("next_url") or ""
2975
+
2976
+ print(f"{BOLD}Gateway profile imported{RESET}")
2977
+ print(f" {GREEN}Name:{RESET} {profile.get('name', '?')}")
2978
+ print(f" {GREEN}Profile ID:{RESET} {profile.get('id', '?')}")
2979
+ print(f" {GREEN}Cond code:{RESET} {profile.get('cond_code', '?')}")
2980
+
2981
+ if gaps:
2982
+ print(f"\n{YELLOW}{len(gaps)} target(s) need credentials before publish:{RESET}")
2983
+ for g in gaps:
2984
+ print(
2985
+ f" #{g.get('target_index', '?') + 1 if isinstance(g.get('target_index'), int) else '?'} "
2986
+ f"{g.get('target_id', '?')} ({g.get('transport', '?')}): "
2987
+ f"{g.get('reason', 'pick a vault')}"
2988
+ )
2989
+
2990
+ if next_url:
2991
+ print(f"\n{BLUE}Finish setup:{RESET} {next_url}")
2992
+
2993
+
2994
+ def cmd_export_gateway_config(args):
2995
+ """conduct export --gateway-config <profile-id-or-cond-code> [--out FILE]
2996
+
2997
+ Pull a Gateway Profile v2 as portable JSON. Server strips every
2998
+ ``credential_ref`` on export (safe to commit / share). Writes to
2999
+ stdout by default; ``--out FILE`` writes to a file instead.
3000
+
3001
+ ``target`` is one of:
3002
+ * a profile UUID (e.g. ``123e4567-e89b-12d3-a456-426614174000``),
3003
+ * a bare cond_code (e.g. ``abc12345``), OR
3004
+ * the full ``cond-<code>-<alias>`` string admins see in the
3005
+ "How to use" panel (e.g. ``cond-abc12345-my-profile``).
3006
+
3007
+ Bare model aliases (e.g. ``claude-sonnet-4-6``) are NOT resolved —
3008
+ they map to targets inside a profile, not to profiles themselves.
3009
+
3010
+ Exit codes: 0 on success, 1 on any failure (auth, unresolved
3011
+ target, backend error). Errors go to stderr so ``--out`` piping
3012
+ stays clean.
3013
+ """
3014
+ server, workspace, token = _require_auth(args)
3015
+ if not token:
3016
+ print(f"{RED}Not authenticated. Run `conduct login` first.{RESET}", file=sys.stderr)
3017
+ sys.exit(1)
3018
+
3019
+ profile_id = _resolve_gateway_profile_id(server, workspace, token, args.target)
3020
+ if not profile_id:
3021
+ # _resolve_gateway_profile_id already printed the specific
3022
+ # error to stderr — just exit nonzero here.
3023
+ sys.exit(1)
3024
+
3025
+ req = urllib.request.Request(
3026
+ f"{server}/workspaces/{workspace}/gateway-profiles-v2/{profile_id}/export",
3027
+ headers={
3028
+ "Authorization": f"Bearer {token}",
3029
+ "X-Workspace-Id": workspace or "",
3030
+ },
3031
+ method="GET",
3032
+ )
3033
+ try:
3034
+ with urllib.request.urlopen(req, timeout=30) as r:
3035
+ snapshot = json.loads(r.read())
3036
+ except urllib.error.HTTPError as e:
3037
+ body_text = e.read().decode(errors="replace")
3038
+ print(f"{RED}Export failed ({e.code}): {body_text[:400]}{RESET}", file=sys.stderr)
3039
+ sys.exit(1)
3040
+ except Exception as e:
3041
+ print(f"{RED}Request failed: {e}{RESET}", file=sys.stderr)
3042
+ sys.exit(1)
3043
+
3044
+ out_json = json.dumps(snapshot, indent=2)
3045
+ out_path = getattr(args, "out", None)
3046
+ if out_path:
3047
+ Path(out_path).write_text(out_json + "\n")
3048
+ print(f"{GREEN}Wrote:{RESET} {out_path}", file=sys.stderr)
3049
+ else:
3050
+ print(out_json)
3051
+
3052
+
3053
+ def _resolve_gateway_profile_id(server: str, workspace: str, token: str, target: str) -> str | None:
3054
+ """Accept either a UUID or a cond_code. Returns the UUID or prints
3055
+ an error + returns None."""
3056
+ # UUID shape: 8-4-4-4-12 hex.
3057
+ import re as _re
3058
+ if _re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", target, _re.IGNORECASE):
3059
+ return target
3060
+
3061
+ # cond_code lookup — fetch the list, match by cond_code.
3062
+ req = urllib.request.Request(
3063
+ f"{server}/workspaces/{workspace}/gateway-profiles-v2",
3064
+ headers={
3065
+ "Authorization": f"Bearer {token}",
3066
+ "X-Workspace-Id": workspace or "",
3067
+ },
3068
+ method="GET",
3069
+ )
3070
+ try:
3071
+ with urllib.request.urlopen(req, timeout=30) as r:
3072
+ profiles = json.loads(r.read())
3073
+ except Exception as e:
3074
+ print(
3075
+ f"{RED}Could not list profiles to resolve {target!r}: {e}{RESET}",
3076
+ file=sys.stderr,
3077
+ )
3078
+ return None
3079
+
3080
+ for p in profiles or []:
3081
+ if p.get("cond_code") == target:
3082
+ return p.get("id")
3083
+ # Also accept "cond-<code>-<alias>" for convenience.
3084
+ alias = p.get("model_alias") or ""
3085
+ full = f"cond-{p.get('cond_code', '')}-{alias}"
3086
+ if target == full:
3087
+ return p.get("id")
3088
+
3089
+ print(
3090
+ f"{RED}No profile matched {target!r}. Pass a profile UUID, "
3091
+ f"a cond_code, or the full cond-<code>-<alias> identifier.{RESET}",
3092
+ file=sys.stderr,
3093
+ )
3094
+ return None
3095
+
3096
+
2888
3097
  def cmd_skill(args):
2889
3098
  """conduct skill list | install <slug> | uninstall <slug>"""
2890
3099
  skill_command = getattr(args, "skill_command", None)
@@ -3102,6 +3311,46 @@ def main():
3102
3311
  ic_p.add_argument("--install", action="store_true", help="Install immediately (default is preview only)")
3103
3312
  ic_p.add_argument("--yes", action="store_true", help="Skip confirmation prompt when installing")
3104
3313
 
3314
+ # conduct import --gateway-config <file>
3315
+ # conduct export --gateway-config <profile-id-or-cond-code> [--out FILE]
3316
+ #
3317
+ # Both commands share ``--gateway-config`` as the surface. Adding
3318
+ # future importable resource types (e.g. --guard-pack, --persona)
3319
+ # is a matter of extending these two parsers, keeping the noun
3320
+ # namespace ``conduct import`` / ``conduct export`` uncluttered.
3321
+ imp_p = sub.add_parser(
3322
+ "import",
3323
+ help="Import a resource from JSON (currently: Gateway Profile v2)",
3324
+ )
3325
+ imp_p.add_argument(
3326
+ "--gateway-config",
3327
+ metavar="FILE",
3328
+ help="Path to a Gateway Profile v2 JSON file (working_copy shape).",
3329
+ )
3330
+ imp_p.add_argument(
3331
+ "--name",
3332
+ metavar="NAME",
3333
+ default=None,
3334
+ help="Override the profile name in the JSON. Useful when the "
3335
+ "source name is already taken in the target workspace.",
3336
+ )
3337
+
3338
+ exp_p = sub.add_parser(
3339
+ "export",
3340
+ help="Export a resource as portable JSON (currently: Gateway Profile v2)",
3341
+ )
3342
+ exp_p.add_argument(
3343
+ "--gateway-config",
3344
+ metavar="PROFILE",
3345
+ help="Profile UUID, cond_code (8 chars), or full cond-<code>-<alias> identifier.",
3346
+ )
3347
+ exp_p.add_argument(
3348
+ "--out",
3349
+ metavar="FILE",
3350
+ default=None,
3351
+ help="Write JSON to FILE (default: stdout).",
3352
+ )
3353
+
3105
3354
  # conduct sync
3106
3355
  sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
3107
3356
 
@@ -3205,6 +3454,22 @@ def main():
3205
3454
  cmd_skill(args)
3206
3455
  elif args.command == "import-cedar":
3207
3456
  cmd_import_cedar(args)
3457
+ elif args.command == "import":
3458
+ # Only Gateway Profile v2 import today. Other imports (Guard
3459
+ # skill packs, personas, etc.) already have their own commands.
3460
+ if getattr(args, "gateway_config", None):
3461
+ args.file = args.gateway_config
3462
+ cmd_import_gateway_config(args)
3463
+ else:
3464
+ print(f"{RED}Usage: conduct import --gateway-config <file.json>{RESET}", file=sys.stderr)
3465
+ sys.exit(1)
3466
+ elif args.command == "export":
3467
+ if getattr(args, "gateway_config", None):
3468
+ args.target = args.gateway_config
3469
+ cmd_export_gateway_config(args)
3470
+ else:
3471
+ print(f"{RED}Usage: conduct export --gateway-config <profile-id-or-cond-code>{RESET}", file=sys.stderr)
3472
+ sys.exit(1)
3208
3473
  elif args.command == "sync":
3209
3474
  cmd_sync(args)
3210
3475
  elif args.command == "verify":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.14.6
3
+ Version: 0.14.8
4
4
  Summary: Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: Apache-2.0
@@ -35,6 +35,7 @@ tests/test_cli_matrix.py
35
35
  tests/test_cmd_run.py
36
36
  tests/test_command_word_matcher.py
37
37
  tests/test_drain_daemon_health.py
38
+ tests/test_gateway_config_import_export.py
38
39
  tests/test_guard_approvals.py
39
40
  tests/test_guard_policy.py
40
41
  tests/test_guard_savings.py
@@ -54,4 +55,5 @@ tests/test_report_tool_coverage.py
54
55
  tests/test_session_start.py
55
56
  tests/test_signed_policy.py
56
57
  tests/test_switch.py
58
+ tests/test_sync_credential_isolation.py
57
59
  tests/test_token_lifecycle.py
@@ -308,7 +308,8 @@ class TestWarnDedup:
308
308
  posted = self._run_warn(monkeypatch, "sess-abc")
309
309
  assert "warned" in posted
310
310
 
311
- def test_second_warn_same_session_skips(self, monkeypatch, tmp_path):
311
+ @pytest.mark.parametrize("tool", ["Bash", "exec_command", "apply_patch"])
312
+ def test_repeated_warn_records_every_call_but_prints_once(self, monkeypatch, capsys, tool):
312
313
  import conduct_cli.hooks.pretooluse as pt
313
314
  warns: dict[str, set] = {}
314
315
 
@@ -321,9 +322,6 @@ class TestWarnDedup:
321
322
  monkeypatch.setattr(pt, "_already_warned_this_session", _fake_already)
322
323
  monkeypatch.setattr(pt, "_record_session_warn", _fake_record)
323
324
 
324
- # First call records the warn
325
- _fake_record("sess-xyz", "no-echo")
326
-
327
325
  monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: ({}, "warn", "no-echo", "watch out"))
328
326
  monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
329
327
  monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
@@ -332,16 +330,21 @@ class TestWarnDedup:
332
330
  monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
333
331
  monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
334
332
 
335
- posted: list[str] = []
336
- monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append(d))
337
- monkeypatch.setattr(sys, "stdin", StringIO(_hook_input(session="sess-xyz")))
338
-
339
- try:
340
- pt.main()
341
- except SystemExit:
342
- pass
343
-
344
- assert posted == [], "second warn same session should be skipped"
333
+ posted = []
334
+ monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append((tn, ti, d, r, s)))
335
+ monkeypatch.setattr(pt, "record_hook_heartbeat", lambda *args: None)
336
+
337
+ for index, session in enumerate(["sess-xyz", "sess-xyz", "sess-new"]):
338
+ command = f"echo call-{index}"
339
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input(tool=tool, cmd=command, session=session)))
340
+ with pytest.raises(SystemExit) as exc:
341
+ pt.main()
342
+ assert exc.value.code == 0
343
+ assert posted[-1] == (tool.lower(), {"command": command}, "warned", "no-echo", session)
344
+ assert len(posted) == index + 1
345
+ output = capsys.readouterr()
346
+ assert output.out == ("" if index == 1 else "[ConductGuard] watch out\n")
347
+ assert output.err == ""
345
348
 
346
349
 
347
350
  # ── advisory_mode mirrored on sync ────────────────────────────────────────────
@@ -0,0 +1,293 @@
1
+ """Tests for gateway-config import/export CLI commands.
2
+
3
+ Scope: argparse wiring + happy path + error paths. HTTP layer stubbed
4
+ via ``urllib.request.urlopen`` so no real API calls.
5
+
6
+ Invariants locked:
7
+ - Import posts to the design-locked URL.
8
+ - ``--name`` translates to ``name_override``.
9
+ - Non-existent file → readable error, no HTTP call.
10
+ - Non-JSON file → readable error, no HTTP call.
11
+ - Structured backend errors (PR #2033) render per-line.
12
+ - Export resolves cond_code via the list endpoint first.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import json
18
+ from unittest.mock import MagicMock
19
+
20
+ import pytest
21
+
22
+ from conduct_cli import main as cli
23
+
24
+
25
+ UUID = "22222222-2222-2222-2222-222222222222"
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def _stub_auth(monkeypatch):
30
+ monkeypatch.setattr(
31
+ cli, "_require_auth",
32
+ lambda args: ("https://api.example.com", "ws-1", "guard-mt-fake"),
33
+ )
34
+
35
+
36
+ def _mk_response(payload, status=200):
37
+ class _CM:
38
+ def __enter__(self):
39
+ fake = MagicMock()
40
+ fake.read.return_value = json.dumps(payload).encode("utf-8")
41
+ fake.status = status
42
+ return fake
43
+ def __exit__(self, *a): return False
44
+ return _CM()
45
+
46
+
47
+ # ─── import ──────────────────────────────────────────────────────────
48
+
49
+
50
+ def test_import_posts_to_correct_url(tmp_path, monkeypatch):
51
+ src = tmp_path / "profile.json"
52
+ src.write_text(json.dumps({"name": "x", "targets": []}))
53
+ captured = {}
54
+ def _fake(req, timeout=None):
55
+ captured["url"] = req.full_url
56
+ captured["method"] = req.get_method()
57
+ return _mk_response({
58
+ "profile": {"id": UUID, "name": "x", "cond_code": "aa"},
59
+ "credential_gaps": [], "next_url": "",
60
+ })
61
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
62
+
63
+ args = MagicMock(); args.file = str(src); args.name = None
64
+ cli.cmd_import_gateway_config(args)
65
+
66
+ assert captured["url"].endswith("/workspaces/ws-1/gateway-profiles-v2/import")
67
+ assert captured["method"] == "POST"
68
+
69
+
70
+ def test_import_body_wraps_json_in_working_copy(tmp_path, monkeypatch):
71
+ src = tmp_path / "p.json"
72
+ payload = {"name": "x", "targets": [{"id": "a"}]}
73
+ src.write_text(json.dumps(payload))
74
+ captured = {}
75
+ def _fake(req, timeout=None):
76
+ captured["body"] = json.loads(req.data.decode("utf-8"))
77
+ return _mk_response({
78
+ "profile": {"id": UUID, "name": "x", "cond_code": "aa"},
79
+ "credential_gaps": [], "next_url": "",
80
+ })
81
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
82
+
83
+ args = MagicMock(); args.file = str(src); args.name = None
84
+ cli.cmd_import_gateway_config(args)
85
+
86
+ assert captured["body"]["working_copy"] == payload
87
+
88
+
89
+ def test_import_name_flag_becomes_override(tmp_path, monkeypatch):
90
+ src = tmp_path / "p.json"
91
+ src.write_text(json.dumps({"name": "orig"}))
92
+ captured = {}
93
+ def _fake(req, timeout=None):
94
+ captured["body"] = json.loads(req.data.decode("utf-8"))
95
+ return _mk_response({
96
+ "profile": {"id": UUID, "name": "renamed", "cond_code": "aa"},
97
+ "credential_gaps": [], "next_url": "",
98
+ })
99
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
100
+
101
+ args = MagicMock(); args.file = str(src); args.name = "renamed"
102
+ cli.cmd_import_gateway_config(args)
103
+
104
+ assert captured["body"].get("name_override") == "renamed"
105
+
106
+
107
+ def test_import_missing_file_no_http(tmp_path, monkeypatch, capsys):
108
+ called = []
109
+ monkeypatch.setattr(
110
+ cli.urllib.request, "urlopen",
111
+ lambda req, timeout=None: (called.append(True), _mk_response({}))[1],
112
+ )
113
+ args = MagicMock()
114
+ args.file = str(tmp_path / "nope.json"); args.name = None
115
+ # R5: missing file exits nonzero and prints the error to stderr,
116
+ # not stdout — scripts piping stdout must not see this noise.
117
+ with pytest.raises(SystemExit) as exc_info:
118
+ cli.cmd_import_gateway_config(args)
119
+ assert exc_info.value.code == 1
120
+ assert called == []
121
+ err = capsys.readouterr()
122
+ assert "File not found" in err.err
123
+ assert "File not found" not in err.out
124
+
125
+
126
+ def test_import_invalid_json_no_http(tmp_path, monkeypatch, capsys):
127
+ bad = tmp_path / "bad.json"; bad.write_text("{ not valid")
128
+ called = []
129
+ monkeypatch.setattr(
130
+ cli.urllib.request, "urlopen",
131
+ lambda req, timeout=None: (called.append(True), _mk_response({}))[1],
132
+ )
133
+ args = MagicMock(); args.file = str(bad); args.name = None
134
+ # R5: invalid JSON exits nonzero and prints to stderr.
135
+ with pytest.raises(SystemExit) as exc_info:
136
+ cli.cmd_import_gateway_config(args)
137
+ assert exc_info.value.code == 1
138
+ assert called == []
139
+ err = capsys.readouterr()
140
+ assert "Invalid JSON" in err.err
141
+ assert "Invalid JSON" not in err.out
142
+
143
+
144
+ def test_import_renders_structured_error_per_line(tmp_path, monkeypatch, capsys):
145
+ import urllib.error
146
+ src = tmp_path / "p.json"
147
+ src.write_text(json.dumps({"name": "x"}))
148
+ payload = {
149
+ "detail": {
150
+ "summary": "schema invalid",
151
+ "errors": [
152
+ {"path": "targets.0.credential_ref",
153
+ "message": "must not be empty",
154
+ "target_index": 0, "type": "value_error"},
155
+ {"path": "accepts", "message": "field required",
156
+ "target_index": None, "type": "missing"},
157
+ ],
158
+ }
159
+ }
160
+ err = urllib.error.HTTPError(
161
+ "http://x", 400, "Bad Request", {},
162
+ io.BytesIO(json.dumps(payload).encode("utf-8")),
163
+ )
164
+ monkeypatch.setattr(
165
+ cli.urllib.request, "urlopen",
166
+ lambda req, timeout=None: (_ for _ in ()).throw(err),
167
+ )
168
+ args = MagicMock(); args.file = str(src); args.name = None
169
+ # R5: backend rejection exits nonzero and lands on stderr — but
170
+ # the structured per-line rendering still happens so CI logs are
171
+ # readable.
172
+ with pytest.raises(SystemExit) as exc_info:
173
+ cli.cmd_import_gateway_config(args)
174
+ assert exc_info.value.code == 1
175
+ stderr = capsys.readouterr().err
176
+ assert "schema invalid" in stderr
177
+ assert "targets.0.credential_ref" in stderr
178
+ assert "must not be empty" in stderr
179
+
180
+
181
+ def test_import_prints_credential_gaps(tmp_path, monkeypatch, capsys):
182
+ src = tmp_path / "p.json"; src.write_text(json.dumps({"name": "x"}))
183
+ monkeypatch.setattr(cli.urllib.request, "urlopen", lambda req, timeout=None: _mk_response({
184
+ "profile": {"id": UUID, "name": "x", "cond_code": "aa"},
185
+ "credential_gaps": [
186
+ {"target_index": 0, "target_id": "primary", "transport": "native_http",
187
+ "reason": "credential_ref stripped on import"},
188
+ {"target_index": 1, "target_id": "fallback", "transport": "native_http",
189
+ "reason": "credential_ref stripped on import"},
190
+ ],
191
+ "next_url": "https://api.example.com/proxy/gateway-profiles",
192
+ }))
193
+ args = MagicMock(); args.file = str(src); args.name = None
194
+ cli.cmd_import_gateway_config(args)
195
+ out = capsys.readouterr().out
196
+ assert "2 target(s) need credentials" in out
197
+ assert "primary" in out and "fallback" in out
198
+ assert "/proxy/gateway-profiles" in out
199
+
200
+
201
+ # ─── export ──────────────────────────────────────────────────────────
202
+
203
+
204
+ def test_export_by_uuid_direct(monkeypatch, capsys):
205
+ calls = []
206
+ def _fake(req, timeout=None):
207
+ calls.append(req.full_url)
208
+ return _mk_response({"name": "claude", "targets": []})
209
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
210
+ args = MagicMock(); args.target = UUID; args.out = None
211
+ cli.cmd_export_gateway_config(args)
212
+ assert calls == [f"https://api.example.com/workspaces/ws-1/gateway-profiles-v2/{UUID}/export"]
213
+ assert '"name": "claude"' in capsys.readouterr().out
214
+
215
+
216
+ def test_export_by_cond_code_resolves_via_list(monkeypatch, capsys):
217
+ calls = []
218
+ def _fake(req, timeout=None):
219
+ calls.append(req.full_url)
220
+ if req.full_url.endswith("/gateway-profiles-v2"):
221
+ return _mk_response([
222
+ {"id": UUID, "cond_code": "abc12345", "model_alias": "claude"},
223
+ ])
224
+ return _mk_response({"name": "resolved", "targets": []})
225
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
226
+ args = MagicMock(); args.target = "abc12345"; args.out = None
227
+ cli.cmd_export_gateway_config(args)
228
+ assert len(calls) == 2
229
+ assert calls[0].endswith("/gateway-profiles-v2")
230
+ assert calls[1].endswith(f"/gateway-profiles-v2/{UUID}/export")
231
+
232
+
233
+ def test_export_unknown_cond_code_no_export_call(monkeypatch, capsys):
234
+ def _fake(req, timeout=None):
235
+ return _mk_response([{"id": UUID, "cond_code": "other"}])
236
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
237
+ args = MagicMock(); args.target = "unknown"; args.out = None
238
+ # R5: unresolved target exits nonzero, error on stderr, stdout
239
+ # stays clean so ``conduct export ... > file.json`` never writes
240
+ # an error message into the destination file.
241
+ with pytest.raises(SystemExit) as exc_info:
242
+ cli.cmd_export_gateway_config(args)
243
+ assert exc_info.value.code == 1
244
+ captured = capsys.readouterr()
245
+ assert "No profile matched" in captured.err
246
+ assert captured.out == ""
247
+
248
+
249
+ def test_export_stdout_stays_clean_on_backend_error(tmp_path, monkeypatch, capsys):
250
+ """R5: an HTTPError during export must NOT contaminate stdout —
251
+ ``conduct export ... > profile.json`` piping into a file must
252
+ only see the profile JSON, never an error message."""
253
+ import urllib.error
254
+ err = urllib.error.HTTPError(
255
+ "http://x", 500, "Server Error", {},
256
+ io.BytesIO(b"boom"),
257
+ )
258
+ calls = []
259
+ def _fake(req, timeout=None):
260
+ if req.full_url.endswith("/export"):
261
+ raise err
262
+ return _mk_response([{"id": UUID, "cond_code": "abc"}])
263
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
264
+ args = MagicMock(); args.target = UUID; args.out = None
265
+ with pytest.raises(SystemExit) as exc_info:
266
+ cli.cmd_export_gateway_config(args)
267
+ assert exc_info.value.code == 1
268
+ captured = capsys.readouterr()
269
+ assert captured.out == ""
270
+ assert "Export failed" in captured.err
271
+
272
+
273
+ def test_import_unauth_exits_nonzero_on_stderr(monkeypatch, capsys, tmp_path):
274
+ """R5: no token → exit 1, error on stderr."""
275
+ monkeypatch.setattr(
276
+ cli, "_require_auth",
277
+ lambda args: ("https://api.example.com", "ws-1", None),
278
+ )
279
+ src = tmp_path / "p.json"; src.write_text(json.dumps({"name": "x"}))
280
+ args = MagicMock(); args.file = str(src); args.name = None
281
+ with pytest.raises(SystemExit) as exc_info:
282
+ cli.cmd_import_gateway_config(args)
283
+ assert exc_info.value.code == 1
284
+ assert "Not authenticated" in capsys.readouterr().err
285
+
286
+
287
+ def test_export_out_writes_file(tmp_path, monkeypatch):
288
+ dest = tmp_path / "out.json"
289
+ monkeypatch.setattr(cli.urllib.request, "urlopen",
290
+ lambda req, timeout=None: _mk_response({"name": "x", "targets": []}))
291
+ args = MagicMock(); args.target = UUID; args.out = str(dest)
292
+ cli.cmd_export_gateway_config(args)
293
+ assert json.loads(dest.read_text()) == {"name": "x", "targets": []}
@@ -0,0 +1,44 @@
1
+ """A metadata fetch must never swap out a client's access/refresh pair."""
2
+ from types import SimpleNamespace
3
+ from unittest.mock import Mock
4
+
5
+ import pytest
6
+
7
+ from conduct_cli import guard
8
+
9
+
10
+ def test_sync_preserves_credentials_when_install_status_returns_another_token(monkeypatch):
11
+ cfg = {
12
+ "agent_token": "cond_agt_current-session",
13
+ "refresh_token": "cond_ref_current-session",
14
+ "workspace_id": "11111111-1111-4111-8111-111111111111",
15
+ "token_expires_at": "2099-01-01T00:00:00+00:00",
16
+ }
17
+ original = cfg.copy()
18
+ monkeypatch.setattr(guard, "_proactive_token_refresh", lambda: None)
19
+ monkeypatch.setattr(guard, "_require_guard_config", lambda: cfg)
20
+ monkeypatch.setattr(guard, "_api_url", lambda _: "https://api.example.test")
21
+ monkeypatch.setattr(guard, "_check_and_upgrade_packages", lambda: None)
22
+ monkeypatch.setattr(guard, "_save_policy", lambda _: None)
23
+ request = Mock(side_effect=[{"rules": []}, {
24
+ "agent_token": "cond_agt_another-client",
25
+ "user_email": "synthetic@example.test",
26
+ "clerk_user_id": "synthetic",
27
+ }])
28
+ monkeypatch.setattr(guard, "_req", request)
29
+
30
+ class MetadataSaved(BaseException):
31
+ pass
32
+
33
+ def save(value):
34
+ for key, expected in original.items():
35
+ assert value[key] == expected
36
+ assert value["clerk_user_id"] == "synthetic"
37
+ raise MetadataSaved()
38
+
39
+ monkeypatch.setattr(guard, "_save_guard_config", save)
40
+ # Stop before machine configuration, process launches or external requests.
41
+ with pytest.raises(MetadataSaved):
42
+ guard.cmd_guard_sync(SimpleNamespace(dry_run=False))
43
+ assert request.call_count == 2
44
+ assert all(call.kwargs["token"] == original["agent_token"] for call in request.call_args_list)
File without changes
File without changes
File without changes
File without changes