conduct-cli 0.2.0__py3-none-any.whl

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.
File without changes
conduct_cli/api.py ADDED
@@ -0,0 +1,100 @@
1
+ import json
2
+ import socket
3
+ import sys
4
+ import urllib.request
5
+ import urllib.error
6
+
7
+ RED = "\033[31m"
8
+ RESET = "\033[0m"
9
+ DEV_WORKSPACE = "00000000-0000-0000-0000-000000000001"
10
+
11
+
12
+ def headers(workspace_id: str, token=None, content_type="application/json", api_key=None) -> dict:
13
+ h = {"Content-Type": content_type, "X-Workspace-Id": workspace_id}
14
+ if api_key:
15
+ h["X-Api-Key"] = api_key
16
+ elif token:
17
+ h["Authorization"] = f"Bearer {token}"
18
+ return h
19
+
20
+
21
+ def req(method: str, url: str, hdrs: dict, body=None, timeout: int = 30) -> dict:
22
+ data = json.dumps(body).encode() if body is not None else None
23
+ r = urllib.request.Request(url, data=data, headers=hdrs, method=method)
24
+ try:
25
+ with urllib.request.urlopen(r, timeout=timeout) as resp:
26
+ raw = resp.read()
27
+ return json.loads(raw) if raw else {}
28
+ except urllib.error.HTTPError as e:
29
+ raw = e.read().decode()
30
+ try:
31
+ detail = json.loads(raw).get("detail", raw)
32
+ except Exception:
33
+ detail = raw
34
+ print(f"{RED}HTTP {e.code}: {detail}{RESET}")
35
+ sys.exit(1)
36
+ except (socket.timeout, TimeoutError):
37
+ print(f"{RED}Request timed out: {url}{RESET}")
38
+ sys.exit(1)
39
+
40
+
41
+ def req_text(method: str, url: str, hdrs: dict, body_text: str, timeout: int = 30) -> dict:
42
+ r = urllib.request.Request(url, data=body_text.encode(), headers=hdrs, method=method)
43
+ try:
44
+ with urllib.request.urlopen(r, timeout=timeout) as resp:
45
+ raw = resp.read()
46
+ return json.loads(raw) if raw else {}
47
+ except urllib.error.HTTPError as e:
48
+ raw = e.read().decode()
49
+ try:
50
+ detail = json.loads(raw).get("detail", raw)
51
+ except Exception:
52
+ detail = raw
53
+ print(f"{RED}HTTP {e.code}: {detail}{RESET}")
54
+ sys.exit(1)
55
+ except (socket.timeout, TimeoutError):
56
+ print(f"{RED}Request timed out: {url}{RESET}")
57
+ sys.exit(1)
58
+
59
+
60
+ def stream(url: str, hdrs=None):
61
+ """Yield parsed SSE data dicts."""
62
+ r = urllib.request.Request(url, headers=hdrs or {})
63
+ try:
64
+ resp = urllib.request.urlopen(r)
65
+ except urllib.error.HTTPError as e:
66
+ msg = e.read().decode()
67
+ raise RuntimeError(f"Stream {e.code}: {msg}")
68
+
69
+ buf = b""
70
+ while True:
71
+ chunk = resp.read(1)
72
+ if not chunk:
73
+ break
74
+ buf += chunk
75
+ if buf.endswith(b"\n\n"):
76
+ text = buf.decode().strip()
77
+ buf = b""
78
+ event = "message"
79
+ for line in text.splitlines():
80
+ if line.startswith("event:"):
81
+ event = line[6:].strip()
82
+ elif line.startswith("data:"):
83
+ try:
84
+ data = json.loads(line[5:].strip())
85
+ except json.JSONDecodeError:
86
+ data = {"raw": line[5:].strip()}
87
+ data.setdefault("kind", event)
88
+ yield data
89
+ event = "message"
90
+
91
+
92
+ def find_or_create_workflow(server: str, name: str, hdrs: dict) -> str:
93
+ for wf in req("GET", f"{server}/workflows", hdrs):
94
+ if wf["name"] == name:
95
+ return wf["id"]
96
+ wf = req("POST", f"{server}/workflows", hdrs, {
97
+ "name": name,
98
+ "graph": {"nodes": [], "edges": []},
99
+ })
100
+ return wf["id"]
conduct_cli/guard.py ADDED
@@ -0,0 +1,421 @@
1
+ """conduct guard — team policy + MCP registration subcommand."""
2
+
3
+ import json
4
+ import sys
5
+ import urllib.error
6
+ import urllib.request
7
+ from datetime import datetime, timedelta, timezone
8
+ from pathlib import Path
9
+
10
+ RESET = "\033[0m"
11
+ BOLD = "\033[1m"
12
+ GREEN = "\033[32m"
13
+ RED = "\033[31m"
14
+ BLUE = "\033[34m"
15
+ GRAY = "\033[90m"
16
+ CYAN = "\033[36m"
17
+ YELLOW = "\033[33m"
18
+
19
+ GUARD_DIR = Path.home() / ".conductguard"
20
+ CONFIG_PATH = GUARD_DIR / "config.json"
21
+ POLICY_PATH = GUARD_DIR / "policy.json"
22
+
23
+ # AI tool config files and the label to show the user
24
+ _MCP_TARGETS = [
25
+ (Path.home() / ".claude" / "settings.json", "Claude Code"),
26
+ (Path.home() / ".cursor" / "mcp.json", "Cursor"),
27
+ (Path.home() / ".windsurf" / "mcp.json", "Windsurf"),
28
+ (Path.home() / ".codex" / "mcp.json", "Codex"),
29
+ ]
30
+
31
+
32
+ # ── Guard config helpers ──────────────────────────────────────────────────────
33
+
34
+ def _load_guard_config() -> dict:
35
+ if CONFIG_PATH.exists():
36
+ return json.loads(CONFIG_PATH.read_text())
37
+ return {}
38
+
39
+
40
+ def _save_guard_config(data: dict):
41
+ GUARD_DIR.mkdir(parents=True, exist_ok=True)
42
+ CONFIG_PATH.write_text(json.dumps(data, indent=2))
43
+
44
+
45
+ def _require_guard_config() -> dict:
46
+ cfg = _load_guard_config()
47
+ if not cfg or not cfg.get("team_id"):
48
+ print(f"{RED}Not connected. Run: conduct guard join <invite-code>{RESET}")
49
+ sys.exit(1)
50
+ return cfg
51
+
52
+
53
+ def _api_url(cfg: dict) -> str:
54
+ return cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
55
+
56
+
57
+ # ── HTTP helpers (no third-party deps — mirrors api.py style) ─────────────────
58
+
59
+ def _req(method: str, url: str, body=None, token: str = None, timeout: int = 20) -> dict:
60
+ headers = {"Content-Type": "application/json"}
61
+ if token:
62
+ headers["Authorization"] = f"Bearer {token}"
63
+ data = json.dumps(body).encode() if body is not None else None
64
+ r = urllib.request.Request(url, data=data, headers=headers, method=method)
65
+ try:
66
+ with urllib.request.urlopen(r, timeout=timeout) as resp:
67
+ raw = resp.read()
68
+ return json.loads(raw) if raw else {}
69
+ except urllib.error.HTTPError as e:
70
+ raw = e.read().decode()
71
+ try:
72
+ detail = json.loads(raw).get("detail", raw)
73
+ except Exception:
74
+ detail = raw
75
+ print(f"{RED}HTTP {e.code}: {detail}{RESET}")
76
+ sys.exit(1)
77
+ except Exception:
78
+ print(f"{RED}Could not reach ConductAI API. Check your connection.{RESET}")
79
+ sys.exit(1)
80
+
81
+
82
+ # ── MCP registration helpers ──────────────────────────────────────────────────
83
+
84
+ def _mcp_entry(team_id: str, member_token: str) -> dict:
85
+ return {
86
+ "command": "conductguard-mcp",
87
+ "args": ["--team", team_id, "--token", member_token],
88
+ }
89
+
90
+
91
+ def _register_mcp(team_id: str, member_token: str) -> list[tuple[str, bool]]:
92
+ """Write MCP entry into every found AI tool config. Returns list of (label, registered_now)."""
93
+ entry = _mcp_entry(team_id, member_token)
94
+ results = []
95
+
96
+ for cfg_path, label in _MCP_TARGETS:
97
+ if not cfg_path.exists():
98
+ continue
99
+
100
+ try:
101
+ existing = json.loads(cfg_path.read_text())
102
+ except (json.JSONDecodeError, OSError):
103
+ existing = {}
104
+
105
+ mcp_servers = existing.get("mcpServers", {})
106
+ current = mcp_servers.get("conductguard", {})
107
+
108
+ # Idempotent: only write if missing or token changed
109
+ if (current.get("command") == entry["command"]
110
+ and current.get("args") == entry["args"]):
111
+ results.append((label, False))
112
+ continue
113
+
114
+ mcp_servers["conductguard"] = entry
115
+ existing["mcpServers"] = mcp_servers
116
+
117
+ try:
118
+ cfg_path.write_text(json.dumps(existing, indent=2))
119
+ results.append((label, True))
120
+ except OSError:
121
+ print(f"{YELLOW}Warning: could not write to {cfg_path} — skipping.{RESET}")
122
+ results.append((label, False))
123
+
124
+ return results
125
+
126
+
127
+ def _save_policy(policy: dict):
128
+ GUARD_DIR.mkdir(parents=True, exist_ok=True)
129
+ POLICY_PATH.write_text(json.dumps(policy, indent=2))
130
+
131
+
132
+ # ── since-string parser ───────────────────────────────────────────────────────
133
+
134
+ def _parse_since(since_str: str) -> str:
135
+ """Convert '7d', '24h', '1h', '30d' to an ISO-8601 UTC timestamp string."""
136
+ unit = since_str[-1].lower()
137
+ value = int(since_str[:-1])
138
+ delta_map = {"h": timedelta(hours=value), "d": timedelta(days=value)}
139
+ if unit not in delta_map:
140
+ print(f"{RED}Invalid --since value '{since_str}'. Use: 1h, 24h, 7d, 30d{RESET}")
141
+ sys.exit(1)
142
+ return (datetime.now(tz=timezone.utc) - delta_map[unit]).isoformat()
143
+
144
+
145
+ # ── Commands ──────────────────────────────────────────────────────────────────
146
+
147
+ def cmd_guard_join(args):
148
+ invite_code = args.invite_code
149
+
150
+ # Prompt for email if not supplied
151
+ email = getattr(args, "email", None) or input("Email address: ").strip()
152
+ if not email:
153
+ print(f"{RED}Email is required.{RESET}")
154
+ sys.exit(1)
155
+
156
+ # Use configured API URL or default
157
+ existing_cfg = _load_guard_config()
158
+ base_url = existing_cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
159
+
160
+ print(f"\nJoining team with invite code {CYAN}{invite_code}{RESET}…")
161
+
162
+ payload = {
163
+ "invite_code": invite_code,
164
+ "email": email,
165
+ }
166
+ result = _req("POST", f"{base_url}/guard/teams/join", body=payload)
167
+
168
+ team_id = result["team_id"]
169
+ team_name = result.get("team_name", team_id)
170
+ member_id = result["member_id"]
171
+ member_token = result.get("member_token", "")
172
+ policy = result.get("policy", {"team_id": team_id, "version": "", "rules": []})
173
+
174
+ # Download and persist policy
175
+ _save_policy(policy)
176
+ rule_count = len(policy.get("rules", []))
177
+ print(f" {GREEN}Policy downloaded:{RESET} {rule_count} rule(s)")
178
+
179
+ # Register MCP in all found tool configs
180
+ registered = _register_mcp(team_id, member_token)
181
+ new_tools = [label for label, is_new in registered if is_new]
182
+ all_tools = [label for label, _ in registered]
183
+
184
+ for label, is_new in registered:
185
+ icon = f"{GREEN}registered{RESET}" if is_new else f"{GRAY}already registered{RESET}"
186
+ print(f" {label} -> {icon}")
187
+
188
+ # Persist guard config
189
+ cfg = {
190
+ "team_id": team_id,
191
+ "team_name": team_name,
192
+ "member_id": member_id,
193
+ "user_email": email,
194
+ "api_url": base_url,
195
+ }
196
+ if member_token:
197
+ cfg["member_token"] = member_token
198
+ _save_guard_config(cfg)
199
+
200
+ print(
201
+ f"\n{BOLD}{GREEN}Connected to {team_name}.{RESET} "
202
+ f"{len(all_tools)} AI tool(s) registered. "
203
+ f"{rule_count} polic{'y' if rule_count == 1 else 'ies'} active."
204
+ )
205
+
206
+
207
+ def cmd_guard_sync(args):
208
+ cfg = _require_guard_config()
209
+ team_id = cfg["team_id"]
210
+ member_token = cfg.get("member_token", "")
211
+ base_url = _api_url(cfg)
212
+
213
+ print(f"Syncing policy for team {CYAN}{cfg.get('team_name', team_id)}{RESET}…")
214
+
215
+ policy = _req(
216
+ "GET",
217
+ f"{base_url}/guard/policies/sync?team_id={team_id}",
218
+ token=member_token,
219
+ )
220
+ _save_policy(policy)
221
+ rule_count = len(policy.get("rules", []))
222
+ print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
223
+
224
+ # Re-scan and register any newly found tool configs
225
+ registered = _register_mcp(team_id, member_token)
226
+ new_tools = [(label, is_new) for label, is_new in registered if is_new]
227
+
228
+ if new_tools:
229
+ for label, _ in new_tools:
230
+ print(f" {label} newly detected -> {GREEN}registered{RESET}")
231
+ else:
232
+ print(f" {GRAY}No new AI tool configs detected.{RESET}")
233
+
234
+ print(f"\n{BOLD}Policy refreshed ({rule_count} rule(s)).{RESET}")
235
+
236
+
237
+ def cmd_guard_status(args):
238
+ cfg = _require_guard_config()
239
+ team_id = cfg["team_id"]
240
+ user_email = cfg.get("user_email", "")
241
+ team_name = cfg.get("team_name", team_id)
242
+ member_token = cfg.get("member_token", "")
243
+ base_url = _api_url(cfg)
244
+
245
+ # Load local policy for rule count
246
+ rule_count = 0
247
+ if POLICY_PATH.exists():
248
+ try:
249
+ policy = json.loads(POLICY_PATH.read_text())
250
+ rule_count = len(policy.get("rules", []))
251
+ except Exception:
252
+ pass
253
+
254
+ # Fetch today's spend
255
+ spend = {}
256
+ try:
257
+ spend = _req(
258
+ "GET",
259
+ f"{base_url}/guard/spend?team_id={team_id}",
260
+ token=member_token,
261
+ )
262
+ except SystemExit:
263
+ pass
264
+
265
+ # Fetch recent violations (today)
266
+ today_iso = datetime.now(tz=timezone.utc).replace(
267
+ hour=0, minute=0, second=0, microsecond=0
268
+ ).isoformat()
269
+ events: list = []
270
+ try:
271
+ events = _req(
272
+ "GET",
273
+ (
274
+ f"{base_url}/guard/events"
275
+ f"?team_id={team_id}"
276
+ f"&user_email={user_email}"
277
+ f"&since={today_iso}"
278
+ f"&limit=20"
279
+ ),
280
+ token=member_token,
281
+ )
282
+ if not isinstance(events, list):
283
+ events = events.get("events", [])
284
+ except SystemExit:
285
+ pass
286
+
287
+ violations = [e for e in events if e.get("decision") == "blocked"]
288
+
289
+ # Format spend figures
290
+ sessions = spend.get("sessions", 0)
291
+ tokens_used = spend.get("tokens_used", 0)
292
+ token_saved_pct = spend.get("token_saved_pct", 0)
293
+ cost = spend.get("cost_usd", 0.0)
294
+ cost_saved = spend.get("cost_saved_usd", 0.0)
295
+
296
+ viol_summary = ""
297
+ if violations:
298
+ rule_names = ", ".join(
299
+ v.get("rule", "unknown") for v in violations[:3]
300
+ )
301
+ viol_summary = f" ({rule_names} — blocked)"
302
+
303
+ print(f"\n{BOLD}Guard status{RESET} — {user_email}")
304
+ print(f"Team: {team_name} · {rule_count} polic{'y' if rule_count == 1 else 'ies'} active")
305
+ print()
306
+ print(f"Today:")
307
+ print(f" Sessions: {sessions}")
308
+ print(f" Tokens used: {tokens_used:,} (saved {token_saved_pct}% via optimization)")
309
+ print(f" Cost: ${cost:.2f} (saved ${cost_saved:.2f})")
310
+ print(f" Violations: {len(violations)}{viol_summary}")
311
+ print()
312
+
313
+
314
+ def cmd_guard_audit(args):
315
+ cfg = _require_guard_config()
316
+ team_id = cfg["team_id"]
317
+ user_email = cfg.get("user_email", "")
318
+ member_token = cfg.get("member_token", "")
319
+ base_url = _api_url(cfg)
320
+
321
+ since_str = getattr(args, "since", None) or "24h"
322
+ since_iso = _parse_since(since_str)
323
+
324
+ events_resp = _req(
325
+ "GET",
326
+ (
327
+ f"{base_url}/guard/events"
328
+ f"?team_id={team_id}"
329
+ f"&user_email={user_email}"
330
+ f"&since={since_iso}"
331
+ f"&limit=50"
332
+ ),
333
+ token=member_token,
334
+ )
335
+ events = events_resp if isinstance(events_resp, list) else events_resp.get("events", [])
336
+
337
+ if not events:
338
+ print(f"{GRAY}No events in the last {since_str}.{RESET}")
339
+ return
340
+
341
+ # Table header
342
+ ts_w = 22
343
+ tool_w = 14
344
+ action_w = 28
345
+ dec_w = 10
346
+ print()
347
+ print(
348
+ f"{BOLD}"
349
+ f"{'Timestamp':<{ts_w}} "
350
+ f"{'Tool':<{tool_w}} "
351
+ f"{'Action':<{action_w}} "
352
+ f"{'Decision':<{dec_w}} "
353
+ f"{'Rule'}"
354
+ f"{RESET}"
355
+ )
356
+ print("─" * (ts_w + tool_w + action_w + dec_w + 20))
357
+
358
+ for ev in events:
359
+ ts_raw = ev.get("timestamp", ev.get("created_at", ""))
360
+ ts = ts_raw[:19].replace("T", " ") if ts_raw else "—"
361
+ tool = (ev.get("tool") or "—")[:tool_w - 1]
362
+ action = (ev.get("action") or "—")[:action_w - 1]
363
+ decision = ev.get("decision", "—")
364
+ rule = ev.get("rule", "—")
365
+
366
+ dec_color = RED if decision == "blocked" else GREEN if decision == "allowed" else GRAY
367
+ print(
368
+ f" {GRAY}{ts:<{ts_w}}{RESET} "
369
+ f"{tool:<{tool_w}} "
370
+ f"{action:<{action_w}} "
371
+ f"{dec_color}{decision:<{dec_w}}{RESET} "
372
+ f"{GRAY}{rule}{RESET}"
373
+ )
374
+
375
+ print()
376
+
377
+
378
+ # ── Subparser registration (called from main.py) ──────────────────────────────
379
+
380
+ def register_guard_parser(sub):
381
+ """Attach the `guard` subparser tree to an existing argparse subparsers object."""
382
+ guard_p = sub.add_parser("guard", help="Guard — team policies and MCP registration")
383
+ guard_sub = guard_p.add_subparsers(dest="guard_command")
384
+
385
+ # conduct guard join <invite-code>
386
+ join_p = guard_sub.add_parser("join", help="Join a team with an invite code")
387
+ join_p.add_argument("invite_code", help="Team invite code")
388
+ join_p.add_argument("--email", help="Your email address (prompted if omitted)")
389
+
390
+ # conduct guard sync
391
+ guard_sub.add_parser("sync", help="Refresh policy and re-scan for AI tools")
392
+
393
+ # conduct guard status
394
+ guard_sub.add_parser("status", help="Show today's spend and violations")
395
+
396
+ # conduct guard audit [--since 7d]
397
+ audit_p = guard_sub.add_parser("audit", help="Show recent guard events")
398
+ audit_p.add_argument(
399
+ "--since",
400
+ default="24h",
401
+ metavar="PERIOD",
402
+ help="Time window: 1h, 24h, 7d, 30d (default: 24h)",
403
+ )
404
+
405
+ return guard_p, guard_sub
406
+
407
+
408
+ def dispatch_guard(args, guard_p):
409
+ """Dispatch to the correct guard handler. Called from main()."""
410
+ guard_command = getattr(args, "guard_command", None)
411
+ if guard_command == "join":
412
+ cmd_guard_join(args)
413
+ elif guard_command == "sync":
414
+ cmd_guard_sync(args)
415
+ elif guard_command == "status":
416
+ cmd_guard_status(args)
417
+ elif guard_command == "audit":
418
+ cmd_guard_audit(args)
419
+ else:
420
+ guard_p.print_help()
421
+ sys.exit(1)