conduct-cli 0.5.3__tar.gz → 0.5.5__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.5.3 → conduct_cli-0.5.5}/PKG-INFO +1 -1
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/pyproject.toml +1 -1
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/api.py +2 -2
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/guard.py +133 -38
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/guardmcp.py +44 -19
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/hook_template.py +22 -4
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/main.py +102 -9
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/README.md +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/setup.cfg +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/setup.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/hook_session_start_template.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.5.3 → conduct_cli-0.5.5}/tests/test_switch.py +0 -0
|
@@ -31,7 +31,7 @@ def req(method: str, url: str, hdrs: dict, body=None, timeout: int = 30) -> dict
|
|
|
31
31
|
detail = json.loads(raw).get("detail", raw)
|
|
32
32
|
except Exception:
|
|
33
33
|
detail = raw
|
|
34
|
-
print(f"{RED}HTTP {e.code}: {detail}{RESET}")
|
|
34
|
+
print(f"{RED}HTTP {e.code}: {detail} [{url}]{RESET}")
|
|
35
35
|
sys.exit(1)
|
|
36
36
|
except (socket.timeout, TimeoutError):
|
|
37
37
|
print(f"{RED}Request timed out: {url}{RESET}")
|
|
@@ -50,7 +50,7 @@ def req_text(method: str, url: str, hdrs: dict, body_text: str, timeout: int = 3
|
|
|
50
50
|
detail = json.loads(raw).get("detail", raw)
|
|
51
51
|
except Exception:
|
|
52
52
|
detail = raw
|
|
53
|
-
print(f"{RED}HTTP {e.code}: {detail}{RESET}")
|
|
53
|
+
print(f"{RED}HTTP {e.code}: {detail} [{url}]{RESET}")
|
|
54
54
|
sys.exit(1)
|
|
55
55
|
except (socket.timeout, TimeoutError):
|
|
56
56
|
print(f"{RED}Request timed out: {url}{RESET}")
|
|
@@ -159,6 +159,61 @@ def _install_session_hooks() -> None:
|
|
|
159
159
|
|
|
160
160
|
# ── Guard config helpers ──────────────────────────────────────────────────────
|
|
161
161
|
|
|
162
|
+
_PERSONA_LABELS = {
|
|
163
|
+
"conservative": "Conservative — production-safe, default deny",
|
|
164
|
+
"standard": "Standard — engineering teams, balanced",
|
|
165
|
+
"developer": "Developer — local dev, audit-first",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _ensure_persona(workspace_id: str, api_key: str, base_url: str) -> str:
|
|
170
|
+
"""Prompt for persona if none is set yet. Saves choice to guard config and API.
|
|
171
|
+
|
|
172
|
+
Returns the active persona name. Skips prompt silently if already set.
|
|
173
|
+
"""
|
|
174
|
+
cfg = _load_guard_config()
|
|
175
|
+
if cfg.get("persona"):
|
|
176
|
+
return cfg["persona"]
|
|
177
|
+
|
|
178
|
+
print(f"\n{BOLD}Choose a policy persona for your agents:{RESET}")
|
|
179
|
+
choices = list(_PERSONA_LABELS.keys())
|
|
180
|
+
for i, key in enumerate(choices, 1):
|
|
181
|
+
print(f" {i}. {_PERSONA_LABELS[key]}")
|
|
182
|
+
|
|
183
|
+
while True:
|
|
184
|
+
try:
|
|
185
|
+
raw = input(f"\nEnter 1-{len(choices)} [default: 2 — Standard]: ").strip()
|
|
186
|
+
if raw == "":
|
|
187
|
+
raw = "2"
|
|
188
|
+
idx = int(raw) - 1
|
|
189
|
+
if 0 <= idx < len(choices):
|
|
190
|
+
chosen = choices[idx]
|
|
191
|
+
break
|
|
192
|
+
print(f" Enter a number between 1 and {len(choices)}")
|
|
193
|
+
except (ValueError, EOFError):
|
|
194
|
+
chosen = "standard"
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
# Push to API
|
|
198
|
+
try:
|
|
199
|
+
_req(
|
|
200
|
+
"PATCH",
|
|
201
|
+
f"{base_url}/guard/config/persona",
|
|
202
|
+
body={"persona": chosen},
|
|
203
|
+
api_key=api_key,
|
|
204
|
+
)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass # non-fatal — local config still records the choice
|
|
207
|
+
|
|
208
|
+
# Persist locally so we skip the prompt on subsequent syncs
|
|
209
|
+
cfg = _load_guard_config()
|
|
210
|
+
cfg["persona"] = chosen
|
|
211
|
+
_save_guard_config(cfg)
|
|
212
|
+
|
|
213
|
+
print(f" {GREEN}Persona set:{RESET} {chosen.capitalize()}")
|
|
214
|
+
return chosen
|
|
215
|
+
|
|
216
|
+
|
|
162
217
|
def _load_guard_config() -> dict:
|
|
163
218
|
if CONFIG_PATH.exists():
|
|
164
219
|
return json.loads(CONFIG_PATH.read_text())
|
|
@@ -190,6 +245,17 @@ def _api_url(cfg: dict) -> str:
|
|
|
190
245
|
# ── MCP registration ──────────────────────────────────────────────────────────
|
|
191
246
|
|
|
192
247
|
# Tools that support mcpServers JSON — only write if the config file already exists
|
|
248
|
+
def _vscode_mcp_paths() -> list[tuple[Path, str]]:
|
|
249
|
+
"""VS Code Copilot MCP config locations (macOS + Linux). Only if Copilot is installed."""
|
|
250
|
+
ext_dir = Path.home() / ".vscode" / "extensions"
|
|
251
|
+
if not ext_dir.exists() or not any(p.name.startswith("github.copilot") for p in ext_dir.iterdir() if p.is_dir()):
|
|
252
|
+
return []
|
|
253
|
+
candidates = [
|
|
254
|
+
Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json",
|
|
255
|
+
Path.home() / ".config" / "Code" / "User" / "mcp.json",
|
|
256
|
+
]
|
|
257
|
+
return [(p, "VS Code Copilot") for p in candidates if p.parent.exists()]
|
|
258
|
+
|
|
193
259
|
_MCP_TARGETS = [
|
|
194
260
|
(Path.home() / ".claude" / "settings.json", "Claude Code"),
|
|
195
261
|
(Path.home() / ".cursor" / "mcp.json", "Cursor"),
|
|
@@ -212,8 +278,9 @@ def _register_mcp(workspace_id: str, member_token: str, api_url: str) -> None:
|
|
|
212
278
|
if shutil.which("booster"):
|
|
213
279
|
servers["agent-booster"] = {"command": "booster", "args": ["serve"]}
|
|
214
280
|
|
|
281
|
+
targets = list(_MCP_TARGETS) + _vscode_mcp_paths()
|
|
215
282
|
found_any = False
|
|
216
|
-
for cfg_path, label in
|
|
283
|
+
for cfg_path, label in targets:
|
|
217
284
|
if not cfg_path.exists():
|
|
218
285
|
continue
|
|
219
286
|
found_any = True
|
|
@@ -327,7 +394,7 @@ def _req(method: str, url: str, body=None, token: str = None, api_key: str = Non
|
|
|
327
394
|
detail = json.loads(raw).get("detail", raw)
|
|
328
395
|
except Exception:
|
|
329
396
|
detail = raw
|
|
330
|
-
print(f"{RED}HTTP {e.code}: {detail}{RESET}")
|
|
397
|
+
print(f"{RED}HTTP {e.code}: {detail} [{url}]{RESET}")
|
|
331
398
|
sys.exit(1)
|
|
332
399
|
except Exception:
|
|
333
400
|
print(f"{RED}Could not reach ConductAI API. Check your connection.{RESET}")
|
|
@@ -340,6 +407,13 @@ def _save_policy(policy: dict):
|
|
|
340
407
|
POLICY_PATH.write_text(json.dumps(policy, indent=2))
|
|
341
408
|
|
|
342
409
|
|
|
410
|
+
def _load_policy() -> dict:
|
|
411
|
+
try:
|
|
412
|
+
return json.loads(POLICY_PATH.read_text())
|
|
413
|
+
except Exception:
|
|
414
|
+
return {"rules": []}
|
|
415
|
+
|
|
416
|
+
|
|
343
417
|
# ── since-string parser ───────────────────────────────────────────────────────
|
|
344
418
|
|
|
345
419
|
def _parse_since(since_str: str) -> str:
|
|
@@ -475,14 +549,8 @@ def cmd_guard_install(args):
|
|
|
475
549
|
user_email = result.get("user_email") or ""
|
|
476
550
|
clerk_user_id = result.get("clerk_user_id") or ""
|
|
477
551
|
|
|
478
|
-
#
|
|
479
|
-
|
|
480
|
-
try:
|
|
481
|
-
sec = _req("GET", f"{server}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
|
|
482
|
-
if sec.get("installed"):
|
|
483
|
-
security_emit = True
|
|
484
|
-
except Exception:
|
|
485
|
-
pass
|
|
552
|
+
# Persona selection — prompt once, skip if already chosen
|
|
553
|
+
_ensure_persona(workspace_id, api_key, server)
|
|
486
554
|
|
|
487
555
|
# Persist guard config — include api_key so CLI commands can authenticate
|
|
488
556
|
import time as _time
|
|
@@ -493,11 +561,8 @@ def cmd_guard_install(args):
|
|
|
493
561
|
"clerk_user_id": clerk_user_id,
|
|
494
562
|
"api_key": api_key,
|
|
495
563
|
"api_url": server,
|
|
496
|
-
"security_emit_enabled": security_emit,
|
|
497
564
|
"last_synced_at": _time.time(),
|
|
498
565
|
})
|
|
499
|
-
if security_emit:
|
|
500
|
-
print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
|
|
501
566
|
|
|
502
567
|
# Download policies
|
|
503
568
|
try:
|
|
@@ -655,15 +720,19 @@ def _report_tools_to_server() -> None:
|
|
|
655
720
|
"hook_registered": False,
|
|
656
721
|
})
|
|
657
722
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
723
|
+
vscode_ext_dir = home / ".vscode" / "extensions"
|
|
724
|
+
copilot_installed = vscode_ext_dir.exists() and any(
|
|
725
|
+
p.name.startswith("github.copilot") for p in vscode_ext_dir.iterdir() if p.is_dir()
|
|
726
|
+
)
|
|
727
|
+
if copilot_installed:
|
|
728
|
+
vscode_candidates = [
|
|
729
|
+
home / "Library" / "Application Support" / "Code" / "User" / "settings.json",
|
|
730
|
+
home / ".config" / "Code" / "User" / "settings.json",
|
|
731
|
+
home / ".vscode" / "settings.json",
|
|
732
|
+
]
|
|
733
|
+
vscode_settings = next((p for p in vscode_candidates if p.exists()), None)
|
|
665
734
|
try:
|
|
666
|
-
d = json.loads(vscode_settings.read_text())
|
|
735
|
+
d = json.loads(vscode_settings.read_text()) if vscode_settings else {}
|
|
667
736
|
mcp_reg = "conduct" in d.get("mcp", {}).get("servers", {})
|
|
668
737
|
except Exception:
|
|
669
738
|
mcp_reg = False
|
|
@@ -718,7 +787,11 @@ def cmd_guard_sync(args):
|
|
|
718
787
|
api_key = cfg.get("api_key", "")
|
|
719
788
|
base_url = _api_url(cfg)
|
|
720
789
|
|
|
721
|
-
|
|
790
|
+
# Persona selection — prompt once, skip if already chosen
|
|
791
|
+
_ensure_persona(workspace_id, api_key, base_url)
|
|
792
|
+
|
|
793
|
+
dry_run = getattr(args, "dry_run", False)
|
|
794
|
+
print(f"{'[dry-run] ' if dry_run else ''}Syncing policy…")
|
|
722
795
|
|
|
723
796
|
try:
|
|
724
797
|
policy = _req(
|
|
@@ -729,28 +802,49 @@ def cmd_guard_sync(args):
|
|
|
729
802
|
except Exception as e:
|
|
730
803
|
print(f"Guard sync skipped: {e}", file=sys.stderr)
|
|
731
804
|
sys.exit(0)
|
|
805
|
+
|
|
806
|
+
rules = policy.get("rules", [])
|
|
807
|
+
rule_count = len(rules)
|
|
808
|
+
|
|
809
|
+
if dry_run:
|
|
810
|
+
current_policy = _load_policy()
|
|
811
|
+
current_rules = {r["rule_id"]: r for r in current_policy.get("rules", [])}
|
|
812
|
+
new_rules = {r["rule_id"]: r for r in rules}
|
|
813
|
+
|
|
814
|
+
added = [r for r in new_rules if r not in current_rules]
|
|
815
|
+
removed = [r for r in current_rules if r not in new_rules]
|
|
816
|
+
changed = [
|
|
817
|
+
r for r in new_rules
|
|
818
|
+
if r in current_rules and new_rules[r].get("action") != current_rules[r].get("action")
|
|
819
|
+
]
|
|
820
|
+
|
|
821
|
+
print(f"\n {BOLD}Dry run — no files written{RESET}")
|
|
822
|
+
print(f" Rules in policy: {rule_count}")
|
|
823
|
+
if added:
|
|
824
|
+
print(f"\n {GREEN}+ Added ({len(added)}){RESET}")
|
|
825
|
+
for rid in added:
|
|
826
|
+
r = new_rules[rid]
|
|
827
|
+
print(f" {rid} [{r.get('action','?').upper()}]")
|
|
828
|
+
if removed:
|
|
829
|
+
print(f"\n {RED}- Removed ({len(removed)}){RESET}")
|
|
830
|
+
for rid in removed:
|
|
831
|
+
print(f" {rid}")
|
|
832
|
+
if changed:
|
|
833
|
+
print(f"\n ~ Changed action ({len(changed)})")
|
|
834
|
+
for rid in changed:
|
|
835
|
+
old_a = current_rules[rid].get("action", "?")
|
|
836
|
+
new_a = new_rules[rid].get("action", "?")
|
|
837
|
+
print(f" {rid} {old_a} → {new_a}")
|
|
838
|
+
if not added and not removed and not changed:
|
|
839
|
+
print(f" {GREEN}No changes{RESET}")
|
|
840
|
+
return
|
|
841
|
+
|
|
732
842
|
_save_policy(policy)
|
|
733
|
-
rule_count = len(policy.get("rules", []))
|
|
734
843
|
print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
|
|
735
844
|
|
|
736
845
|
if getattr(args, "cursor", False):
|
|
737
846
|
_write_cursorrules(policy)
|
|
738
847
|
|
|
739
|
-
# Re-check Security Loop install status
|
|
740
|
-
try:
|
|
741
|
-
sec = _req("GET", f"{base_url}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
|
|
742
|
-
cfg["security_emit_enabled"] = bool(sec.get("installed"))
|
|
743
|
-
_save_guard_config(cfg)
|
|
744
|
-
if sec.get("installed"):
|
|
745
|
-
print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
|
|
746
|
-
try:
|
|
747
|
-
policies = _req("GET", f"{base_url}/secure/policies?workspace_id={workspace_id}", api_key=api_key)
|
|
748
|
-
policy_count = len(policies) if isinstance(policies, list) else 0
|
|
749
|
-
print(f" {GREEN}Security Loop policies:{RESET} {policy_count} rule(s) synced")
|
|
750
|
-
except Exception:
|
|
751
|
-
pass
|
|
752
|
-
except Exception:
|
|
753
|
-
pass
|
|
754
848
|
|
|
755
849
|
# Refresh hook script + re-register in all tools
|
|
756
850
|
hook_path = GUARD_DIR / "hook.py"
|
|
@@ -1198,6 +1292,7 @@ def register_guard_parser(sub):
|
|
|
1198
1292
|
# conduct guard sync
|
|
1199
1293
|
sync_p = guard_sub.add_parser("sync", help="Refresh policy and re-scan for AI tools")
|
|
1200
1294
|
sync_p.add_argument("--cursor", action="store_true", help="Write active Guard policies to .cursorrules")
|
|
1295
|
+
sync_p.add_argument("--dry-run", action="store_true", help="Preview policy changes without writing anything")
|
|
1201
1296
|
|
|
1202
1297
|
# conduct guard status
|
|
1203
1298
|
guard_sub.add_parser("status", help="Show today's spend and violations")
|
|
@@ -139,12 +139,31 @@ def _load_config() -> dict:
|
|
|
139
139
|
return {}
|
|
140
140
|
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
_DAEMON_URL = "http://127.0.0.1:7878"
|
|
143
|
+
_sync_lock = threading.Lock()
|
|
143
144
|
_SYNC_INTERVAL = 300 # 5 minutes
|
|
144
145
|
|
|
145
146
|
|
|
147
|
+
def _daemon_alive() -> bool:
|
|
148
|
+
try:
|
|
149
|
+
urllib.request.urlopen(f"{_DAEMON_URL}/health", timeout=0.3)
|
|
150
|
+
return True
|
|
151
|
+
except Exception:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
146
155
|
def _maybe_sync() -> None:
|
|
147
|
-
"""
|
|
156
|
+
"""Sync policy: daemon (instant) → conduct guard sync (every 5 min). Never raises."""
|
|
157
|
+
try:
|
|
158
|
+
if _daemon_alive():
|
|
159
|
+
cfg = _load_config()
|
|
160
|
+
ws_id = cfg.get("workspace_id", "")
|
|
161
|
+
if ws_id:
|
|
162
|
+
with urllib.request.urlopen(f"{_DAEMON_URL}/policy?workspace_id={ws_id}", timeout=1) as r:
|
|
163
|
+
POLICY_PATH.write_text(r.read().decode())
|
|
164
|
+
return
|
|
165
|
+
except Exception:
|
|
166
|
+
pass # fall through to CLI sync
|
|
148
167
|
cfg = _load_config()
|
|
149
168
|
last_sync = cfg.get("last_synced_at", 0)
|
|
150
169
|
if time.time() - last_sync < _SYNC_INTERVAL:
|
|
@@ -153,11 +172,7 @@ def _maybe_sync() -> None:
|
|
|
153
172
|
return # another thread is already syncing
|
|
154
173
|
def _run():
|
|
155
174
|
try:
|
|
156
|
-
subprocess.run(
|
|
157
|
-
["conduct", "guard", "sync"],
|
|
158
|
-
timeout=15,
|
|
159
|
-
capture_output=True,
|
|
160
|
-
)
|
|
175
|
+
subprocess.run(["conduct", "guard", "sync"], timeout=15, capture_output=True)
|
|
161
176
|
except Exception:
|
|
162
177
|
pass
|
|
163
178
|
finally:
|
|
@@ -192,12 +207,11 @@ def _post_audit_event(
|
|
|
192
207
|
token: str,
|
|
193
208
|
ai_tool: str,
|
|
194
209
|
) -> None:
|
|
195
|
-
"""Fire-and-forget:
|
|
210
|
+
"""Fire-and-forget: queue event via daemon (batch) or direct API fallback."""
|
|
196
211
|
if not workspace_id:
|
|
197
212
|
return
|
|
198
|
-
cfg
|
|
199
|
-
|
|
200
|
-
payload = json.dumps({
|
|
213
|
+
cfg = _load_config()
|
|
214
|
+
event = {
|
|
201
215
|
"workspace_id": workspace_id,
|
|
202
216
|
"clerk_user_id": cfg.get("user_email", ""),
|
|
203
217
|
"user_email": cfg.get("user_email", ""),
|
|
@@ -207,15 +221,26 @@ def _post_audit_event(
|
|
|
207
221
|
"decision": decision,
|
|
208
222
|
"rule_id": rule_id,
|
|
209
223
|
"hook_session_id": _SESSION_ID,
|
|
210
|
-
}
|
|
224
|
+
}
|
|
211
225
|
try:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
"
|
|
217
|
-
"
|
|
218
|
-
|
|
226
|
+
# Fast path: daemon batches events, reducing API calls 30:1
|
|
227
|
+
if _daemon_alive():
|
|
228
|
+
body = json.dumps(event).encode()
|
|
229
|
+
req = urllib.request.Request(
|
|
230
|
+
f"{_DAEMON_URL}/event", data=body,
|
|
231
|
+
headers={"Content-Type": "application/json"}, method="POST",
|
|
232
|
+
)
|
|
233
|
+
urllib.request.urlopen(req, timeout=1)
|
|
234
|
+
return
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
# Fallback: direct API post
|
|
238
|
+
try:
|
|
239
|
+
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
240
|
+
body = json.dumps(event).encode()
|
|
241
|
+
req = urllib.request.Request(
|
|
242
|
+
f"{api_url}/guard/events", data=body,
|
|
243
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
|
|
219
244
|
method="POST",
|
|
220
245
|
)
|
|
221
246
|
urllib.request.urlopen(req, timeout=5)
|
|
@@ -39,8 +39,19 @@ VERSION_CACHE_TTL = 60 # 1 minute — matches server poll window
|
|
|
39
39
|
WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
_DAEMON_URL = "http://127.0.0.1:7878"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _daemon_alive() -> bool:
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(f"{_DAEMON_URL}/health", timeout=0.3):
|
|
48
|
+
return True
|
|
49
|
+
except Exception:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
42
53
|
def _maybe_sync_policy():
|
|
43
|
-
"""
|
|
54
|
+
"""Sync policy from daemon (instant) or remote API (once per minute). Never raises."""
|
|
44
55
|
try:
|
|
45
56
|
cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
|
|
46
57
|
workspace_id = cfg.get("workspace_id")
|
|
@@ -48,18 +59,25 @@ def _maybe_sync_policy():
|
|
|
48
59
|
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
49
60
|
if not workspace_id:
|
|
50
61
|
return
|
|
51
|
-
|
|
62
|
+
|
|
63
|
+
# Fast path: daemon is running — it keeps policy fresh via WebSocket push
|
|
64
|
+
if _daemon_alive():
|
|
65
|
+
url = f"{_DAEMON_URL}/policy?workspace_id={workspace_id}"
|
|
66
|
+
with urllib.request.urlopen(url, timeout=1) as resp:
|
|
67
|
+
remote = json.loads(resp.read())
|
|
68
|
+
POLICY_PATH.write_text(json.dumps(remote, indent=2))
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# Slow path: no daemon — poll remote API once per minute
|
|
52
72
|
if VERSION_CACHE_PATH.exists():
|
|
53
73
|
cache = json.loads(VERSION_CACHE_PATH.read_text())
|
|
54
74
|
if time.time() - cache.get("ts", 0) < VERSION_CACHE_TTL:
|
|
55
75
|
return
|
|
56
|
-
# Fetch current version from server
|
|
57
76
|
url = f"{api_url}/guard/policies/sync?workspace_id={workspace_id}"
|
|
58
77
|
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"} if api_key else {})
|
|
59
78
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
60
79
|
remote = json.loads(resp.read())
|
|
61
80
|
remote_version = remote.get("version", "")
|
|
62
|
-
# Compare to local
|
|
63
81
|
local_version = ""
|
|
64
82
|
if POLICY_PATH.exists():
|
|
65
83
|
local_version = json.loads(POLICY_PATH.read_text()).get("version", "")
|
|
@@ -302,16 +302,21 @@ def _detect_ai_tools() -> list:
|
|
|
302
302
|
"hook_registered": False, # Windsurf uses MCP only
|
|
303
303
|
})
|
|
304
304
|
|
|
305
|
-
# VS Code (Copilot)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
305
|
+
# VS Code (Copilot) — only report if Copilot extension is actually installed
|
|
306
|
+
vscode_ext_dir = home / ".vscode" / "extensions"
|
|
307
|
+
copilot_installed = vscode_ext_dir.exists() and any(
|
|
308
|
+
p.name.startswith("github.copilot") for p in vscode_ext_dir.iterdir()
|
|
309
|
+
if p.is_dir()
|
|
310
|
+
)
|
|
311
|
+
if copilot_installed:
|
|
312
|
+
vscode_settings_candidates = [
|
|
313
|
+
home / "Library" / "Application Support" / "Code" / "User" / "settings.json",
|
|
314
|
+
home / ".config" / "Code" / "User" / "settings.json",
|
|
315
|
+
home / ".vscode" / "settings.json",
|
|
316
|
+
]
|
|
317
|
+
vscode_settings = next((p for p in vscode_settings_candidates if p.exists()), None)
|
|
313
318
|
try:
|
|
314
|
-
d = json.loads(vscode_settings.read_text())
|
|
319
|
+
d = json.loads(vscode_settings.read_text()) if vscode_settings else {}
|
|
315
320
|
mcp_reg = "conduct" in d.get("mcp", {}).get("servers", {})
|
|
316
321
|
except Exception:
|
|
317
322
|
mcp_reg = False
|
|
@@ -409,6 +414,12 @@ def cmd_mcp_install(args):
|
|
|
409
414
|
if uncovered:
|
|
410
415
|
print(f"{YELLOW} Not covered: {', '.join(uncovered)} — run: conduct mcp install{RESET}")
|
|
411
416
|
|
|
417
|
+
# Push updated coverage to Guard so the dashboard reflects the new state immediately
|
|
418
|
+
try:
|
|
419
|
+
_report_tool_coverage()
|
|
420
|
+
except Exception:
|
|
421
|
+
pass
|
|
422
|
+
|
|
412
423
|
|
|
413
424
|
def cmd_login(args):
|
|
414
425
|
server = args.server
|
|
@@ -2847,6 +2858,77 @@ def _check_guard_setup(command: str) -> None:
|
|
|
2847
2858
|
)
|
|
2848
2859
|
|
|
2849
2860
|
|
|
2861
|
+
|
|
2862
|
+
def cmd_skill(args):
|
|
2863
|
+
"""conduct skill list | install <slug> | uninstall <slug>"""
|
|
2864
|
+
skill_command = getattr(args, "skill_command", None)
|
|
2865
|
+
server, workspace, api_key, token = _require_auth(args)
|
|
2866
|
+
|
|
2867
|
+
headers = {"Content-Type": "application/json"}
|
|
2868
|
+
if api_key:
|
|
2869
|
+
headers["X-Api-Key"] = api_key
|
|
2870
|
+
elif token:
|
|
2871
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
2872
|
+
|
|
2873
|
+
def _req(method, path, expect=None):
|
|
2874
|
+
url = f"{server}{path}"
|
|
2875
|
+
req = urllib.request.Request(url, headers=headers, method=method)
|
|
2876
|
+
try:
|
|
2877
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
2878
|
+
data = json.loads(r.read())
|
|
2879
|
+
return data
|
|
2880
|
+
except urllib.error.HTTPError as e:
|
|
2881
|
+
body = e.read().decode()
|
|
2882
|
+
try:
|
|
2883
|
+
msg = json.loads(body).get("detail", body)
|
|
2884
|
+
except Exception:
|
|
2885
|
+
msg = body
|
|
2886
|
+
print(f"{RED}Error {e.code}: {msg}{RESET}")
|
|
2887
|
+
sys.exit(1)
|
|
2888
|
+
|
|
2889
|
+
if skill_command == "list":
|
|
2890
|
+
data = _req("GET", f"/compliance/packs/available?workspace_id={workspace}")
|
|
2891
|
+
installed_data = _req("GET", f"/compliance/packs/installed?workspace_id={workspace}")
|
|
2892
|
+
installed = set(installed_data.get("installed", []))
|
|
2893
|
+
|
|
2894
|
+
packs = data.get("packs", [])
|
|
2895
|
+
if not packs:
|
|
2896
|
+
print("No skill packs available.")
|
|
2897
|
+
return
|
|
2898
|
+
|
|
2899
|
+
print(f"\n{'Slug':<20} {'Name':<22} {'Tier':<8} {'Rules':<7} Status")
|
|
2900
|
+
print("─" * 70)
|
|
2901
|
+
for p in packs:
|
|
2902
|
+
slug = p.get("slug", "")
|
|
2903
|
+
name = p.get("name", "")
|
|
2904
|
+
tier = p.get("tier", "")
|
|
2905
|
+
rules = len(p.get("rules", []))
|
|
2906
|
+
status = f"{GREEN}installed{RESET}" if slug in installed else f"{GRAY}available{RESET}"
|
|
2907
|
+
print(f"{slug:<20} {name:<22} {tier:<8} {rules:<7} {status}")
|
|
2908
|
+
print()
|
|
2909
|
+
|
|
2910
|
+
elif skill_command == "install":
|
|
2911
|
+
slug = getattr(args, "slug", None)
|
|
2912
|
+
if not slug:
|
|
2913
|
+
print(f"{RED}Usage: conduct skill install <slug>{RESET}")
|
|
2914
|
+
sys.exit(1)
|
|
2915
|
+
_req("POST", f"/compliance/packs/{slug}/install?workspace_id={workspace}")
|
|
2916
|
+
print(f"{GREEN}✓{RESET} Installed {BOLD}{slug}{RESET}")
|
|
2917
|
+
print(f" Run {BOLD}conduct guard sync{RESET} to push the updated policy to your hook.")
|
|
2918
|
+
|
|
2919
|
+
elif skill_command == "uninstall":
|
|
2920
|
+
slug = getattr(args, "slug", None)
|
|
2921
|
+
if not slug:
|
|
2922
|
+
print(f"{RED}Usage: conduct skill uninstall <slug>{RESET}")
|
|
2923
|
+
sys.exit(1)
|
|
2924
|
+
_req("DELETE", f"/compliance/packs/{slug}/uninstall?workspace_id={workspace}")
|
|
2925
|
+
print(f"{GREEN}✓{RESET} Uninstalled {BOLD}{slug}{RESET}")
|
|
2926
|
+
print(f" Run {BOLD}conduct guard sync{RESET} to push the updated policy to your hook.")
|
|
2927
|
+
|
|
2928
|
+
else:
|
|
2929
|
+
print("Usage: conduct skill <list|install|uninstall> [slug]")
|
|
2930
|
+
|
|
2931
|
+
|
|
2850
2932
|
def main():
|
|
2851
2933
|
_auto_update()
|
|
2852
2934
|
|
|
@@ -2987,6 +3069,15 @@ def main():
|
|
|
2987
3069
|
mcp_sub = mcp_p.add_subparsers(dest="mcp_command")
|
|
2988
3070
|
mcp_sub.add_parser("install", help="Register conduct-mcp in Claude Code and Codex")
|
|
2989
3071
|
|
|
3072
|
+
# conduct skill
|
|
3073
|
+
skill_p = sub.add_parser("skill", help="Manage Guard skill packs")
|
|
3074
|
+
skill_sub = skill_p.add_subparsers(dest="skill_command")
|
|
3075
|
+
skill_sub.add_parser("list", help="List available and installed skill packs")
|
|
3076
|
+
skill_install_p = skill_sub.add_parser("install", help="Install a skill pack")
|
|
3077
|
+
skill_install_p.add_argument("slug", help="Pack slug, e.g. conduct-owasp")
|
|
3078
|
+
skill_uninstall_p = skill_sub.add_parser("uninstall", help="Uninstall a skill pack")
|
|
3079
|
+
skill_uninstall_p.add_argument("slug", help="Pack slug, e.g. conduct-owasp")
|
|
3080
|
+
|
|
2990
3081
|
# conduct sync
|
|
2991
3082
|
sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
|
|
2992
3083
|
|
|
@@ -3080,6 +3171,8 @@ def main():
|
|
|
3080
3171
|
cmd_mcp_install(args)
|
|
3081
3172
|
else:
|
|
3082
3173
|
mcp_p.print_help()
|
|
3174
|
+
elif args.command == "skill":
|
|
3175
|
+
cmd_skill(args)
|
|
3083
3176
|
elif args.command == "sync":
|
|
3084
3177
|
cmd_sync(args)
|
|
3085
3178
|
elif args.command == "test-guard":
|
|
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
|