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.
- conduct_cli/__init__.py +0 -0
- conduct_cli/api.py +100 -0
- conduct_cli/guard.py +421 -0
- conduct_cli/main.py +1154 -0
- conduct_cli-0.2.0.dist-info/METADATA +108 -0
- conduct_cli-0.2.0.dist-info/RECORD +9 -0
- conduct_cli-0.2.0.dist-info/WHEEL +5 -0
- conduct_cli-0.2.0.dist-info/entry_points.txt +2 -0
- conduct_cli-0.2.0.dist-info/top_level.txt +1 -0
conduct_cli/__init__.py
ADDED
|
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)
|