conduct-cli 0.14.7__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.
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/PKG-INFO +1 -1
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/pyproject.toml +1 -1
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/pretooluse.py +11 -1
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/main.py +265 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- conduct_cli-0.14.8/tests/test_gateway_config_import_export.py +293 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/LICENSE +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/README.md +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/setup.cfg +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/setup.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/guard.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/base.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_api_client.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_bash_scan_target.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_cli_matrix.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_guard_approvals.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_guard_status.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_hook_journal.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_hook_receipt.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_install_all.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_log_util.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_pretooluse_approval_e2e.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_pretooluse_check_policy.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_report_tool_coverage.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_session_start.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_switch.py +0 -0
- {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_sync_credential_isolation.py +0 -0
- {conduct_cli-0.14.7 → 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.
|
|
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.
|
|
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" }
|
|
@@ -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())
|
|
@@ -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.
|
|
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
|
|
@@ -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": []}
|
|
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
|
|
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
|