conduct-cli 0.5.4__tar.gz → 0.5.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/PKG-INFO +1 -1
  2. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/pyproject.toml +1 -1
  3. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/api.py +2 -2
  4. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/guard.py +61 -31
  5. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/guardmcp.py +44 -19
  6. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/hook_template.py +59 -5
  7. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/main.py +82 -0
  8. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  9. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
  10. conduct_cli-0.5.6/tests/test_bash_operator_signature.py +82 -0
  11. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/README.md +0 -0
  12. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/setup.cfg +0 -0
  13. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/setup.py +0 -0
  14. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/__init__.py +0 -0
  15. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/hook_precompact_template.py +0 -0
  16. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/hook_session_start_template.py +0 -0
  17. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/hook_stop_template.py +0 -0
  18. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/mcp_server.py +0 -0
  19. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/memory.py +0 -0
  20. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/paxel.py +0 -0
  21. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  22. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  23. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/requires.txt +0 -0
  24. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/top_level.txt +0 -0
  25. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/tests/test_guard_policy.py +0 -0
  26. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/tests/test_guard_savings.py +0 -0
  27. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/tests/test_hook_syntax.py +0 -0
  28. {conduct_cli-0.5.4 → conduct_cli-0.5.6}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.5.4
3
+ Version: 0.5.6
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.5.4"
7
+ version = "0.5.6"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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}")
@@ -245,6 +245,17 @@ def _api_url(cfg: dict) -> str:
245
245
  # ── MCP registration ──────────────────────────────────────────────────────────
246
246
 
247
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
+
248
259
  _MCP_TARGETS = [
249
260
  (Path.home() / ".claude" / "settings.json", "Claude Code"),
250
261
  (Path.home() / ".cursor" / "mcp.json", "Cursor"),
@@ -267,8 +278,9 @@ def _register_mcp(workspace_id: str, member_token: str, api_url: str) -> None:
267
278
  if shutil.which("booster"):
268
279
  servers["agent-booster"] = {"command": "booster", "args": ["serve"]}
269
280
 
281
+ targets = list(_MCP_TARGETS) + _vscode_mcp_paths()
270
282
  found_any = False
271
- for cfg_path, label in _MCP_TARGETS:
283
+ for cfg_path, label in targets:
272
284
  if not cfg_path.exists():
273
285
  continue
274
286
  found_any = True
@@ -382,7 +394,7 @@ def _req(method: str, url: str, body=None, token: str = None, api_key: str = Non
382
394
  detail = json.loads(raw).get("detail", raw)
383
395
  except Exception:
384
396
  detail = raw
385
- print(f"{RED}HTTP {e.code}: {detail}{RESET}")
397
+ print(f"{RED}HTTP {e.code}: {detail} [{url}]{RESET}")
386
398
  sys.exit(1)
387
399
  except Exception:
388
400
  print(f"{RED}Could not reach ConductAI API. Check your connection.{RESET}")
@@ -395,6 +407,13 @@ def _save_policy(policy: dict):
395
407
  POLICY_PATH.write_text(json.dumps(policy, indent=2))
396
408
 
397
409
 
410
+ def _load_policy() -> dict:
411
+ try:
412
+ return json.loads(POLICY_PATH.read_text())
413
+ except Exception:
414
+ return {"rules": []}
415
+
416
+
398
417
  # ── since-string parser ───────────────────────────────────────────────────────
399
418
 
400
419
  def _parse_since(since_str: str) -> str:
@@ -530,15 +549,6 @@ def cmd_guard_install(args):
530
549
  user_email = result.get("user_email") or ""
531
550
  clerk_user_id = result.get("clerk_user_id") or ""
532
551
 
533
- # Check if Security Loop module is installed for this workspace
534
- security_emit = False
535
- try:
536
- sec = _req("GET", f"{server}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
537
- if sec.get("installed"):
538
- security_emit = True
539
- except Exception:
540
- pass
541
-
542
552
  # Persona selection — prompt once, skip if already chosen
543
553
  _ensure_persona(workspace_id, api_key, server)
544
554
 
@@ -551,11 +561,8 @@ def cmd_guard_install(args):
551
561
  "clerk_user_id": clerk_user_id,
552
562
  "api_key": api_key,
553
563
  "api_url": server,
554
- "security_emit_enabled": security_emit,
555
564
  "last_synced_at": _time.time(),
556
565
  })
557
- if security_emit:
558
- print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
559
566
 
560
567
  # Download policies
561
568
  try:
@@ -783,7 +790,8 @@ def cmd_guard_sync(args):
783
790
  # Persona selection — prompt once, skip if already chosen
784
791
  _ensure_persona(workspace_id, api_key, base_url)
785
792
 
786
- print(f"Syncing policy…")
793
+ dry_run = getattr(args, "dry_run", False)
794
+ print(f"{'[dry-run] ' if dry_run else ''}Syncing policy…")
787
795
 
788
796
  try:
789
797
  policy = _req(
@@ -794,28 +802,49 @@ def cmd_guard_sync(args):
794
802
  except Exception as e:
795
803
  print(f"Guard sync skipped: {e}", file=sys.stderr)
796
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
+
797
842
  _save_policy(policy)
798
- rule_count = len(policy.get("rules", []))
799
843
  print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
800
844
 
801
845
  if getattr(args, "cursor", False):
802
846
  _write_cursorrules(policy)
803
847
 
804
- # Re-check Security Loop install status
805
- try:
806
- sec = _req("GET", f"{base_url}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
807
- cfg["security_emit_enabled"] = bool(sec.get("installed"))
808
- _save_guard_config(cfg)
809
- if sec.get("installed"):
810
- print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
811
- try:
812
- policies = _req("GET", f"{base_url}/secure/policies?workspace_id={workspace_id}", api_key=api_key)
813
- policy_count = len(policies) if isinstance(policies, list) else 0
814
- print(f" {GREEN}Security Loop policies:{RESET} {policy_count} rule(s) synced")
815
- except Exception:
816
- pass
817
- except Exception:
818
- pass
819
848
 
820
849
  # Refresh hook script + re-register in all tools
821
850
  hook_path = GUARD_DIR / "hook.py"
@@ -1263,6 +1292,7 @@ def register_guard_parser(sub):
1263
1292
  # conduct guard sync
1264
1293
  sync_p = guard_sub.add_parser("sync", help="Refresh policy and re-scan for AI tools")
1265
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")
1266
1296
 
1267
1297
  # conduct guard status
1268
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
- _sync_lock = threading.Lock()
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
- """If config is stale (>5 min since last sync), run `conduct guard sync` in background."""
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: post a guard audit event to the Conduct API."""
210
+ """Fire-and-forget: queue event via daemon (batch) or direct API fallback."""
196
211
  if not workspace_id:
197
212
  return
198
- cfg = _load_config()
199
- api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
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
- }).encode()
224
+ }
211
225
  try:
212
- req = urllib.request.Request(
213
- f"{api_url}/guard/events",
214
- data=payload,
215
- headers={
216
- "Content-Type": "application/json",
217
- "Authorization": f"Bearer {token}",
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)
@@ -2,6 +2,7 @@
2
2
  """ConductGuard PreToolUse hook — enforces team policies, tracks all tool calls."""
3
3
  import json
4
4
  import re
5
+ import shlex
5
6
  import subprocess
6
7
  import sys
7
8
  import time
@@ -39,8 +40,19 @@ VERSION_CACHE_TTL = 60 # 1 minute — matches server poll window
39
40
  WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
40
41
 
41
42
 
43
+ _DAEMON_URL = "http://127.0.0.1:7878"
44
+
45
+
46
+ def _daemon_alive() -> bool:
47
+ try:
48
+ with urllib.request.urlopen(f"{_DAEMON_URL}/health", timeout=0.3):
49
+ return True
50
+ except Exception:
51
+ return False
52
+
53
+
42
54
  def _maybe_sync_policy():
43
- """Check server policy version once per minute; re-download if stale. Never raises."""
55
+ """Sync policy from daemon (instant) or remote API (once per minute). Never raises."""
44
56
  try:
45
57
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
46
58
  workspace_id = cfg.get("workspace_id")
@@ -48,18 +60,25 @@ def _maybe_sync_policy():
48
60
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
49
61
  if not workspace_id:
50
62
  return
51
- # Check cache TTL
63
+
64
+ # Fast path: daemon is running — it keeps policy fresh via WebSocket push
65
+ if _daemon_alive():
66
+ url = f"{_DAEMON_URL}/policy?workspace_id={workspace_id}"
67
+ with urllib.request.urlopen(url, timeout=1) as resp:
68
+ remote = json.loads(resp.read())
69
+ POLICY_PATH.write_text(json.dumps(remote, indent=2))
70
+ return
71
+
72
+ # Slow path: no daemon — poll remote API once per minute
52
73
  if VERSION_CACHE_PATH.exists():
53
74
  cache = json.loads(VERSION_CACHE_PATH.read_text())
54
75
  if time.time() - cache.get("ts", 0) < VERSION_CACHE_TTL:
55
76
  return
56
- # Fetch current version from server
57
77
  url = f"{api_url}/guard/policies/sync?workspace_id={workspace_id}"
58
78
  req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"} if api_key else {})
59
79
  with urllib.request.urlopen(req, timeout=2) as resp:
60
80
  remote = json.loads(resp.read())
61
81
  remote_version = remote.get("version", "")
62
- # Compare to local
63
82
  local_version = ""
64
83
  if POLICY_PATH.exists():
65
84
  local_version = json.loads(POLICY_PATH.read_text()).get("version", "")
@@ -109,6 +128,35 @@ def _fetch_budget_status():
109
128
  try:
110
129
  from conduct_cli.guard import _check_policy
111
130
  except Exception:
131
+ def _bash_operator_signature(command):
132
+ """Extract argv[0] + subcommand + flag tokens per shell segment.
133
+ Skips quoted argument values so patterns don't match content inside --body/-m strings.
134
+ Returns space-joined signature across segments.
135
+ """
136
+ if not command:
137
+ return ""
138
+ segments = re.split(r'&&|\|\||\|(?!\|)|;', command)
139
+ parts = []
140
+ for seg in segments:
141
+ try:
142
+ tokens = shlex.split(seg.strip())
143
+ except ValueError:
144
+ continue
145
+ if not tokens:
146
+ continue
147
+ sig = [tokens[0]]
148
+ i = 1
149
+ # Subcommand: only barewords (no whitespace means wasn't a quoted multi-word value)
150
+ if i < len(tokens) and not tokens[i].startswith("-") and " " not in tokens[i]:
151
+ sig.append(tokens[i])
152
+ i += 1
153
+ # Flags: only real flag tokens, not flag-like strings inside quoted content
154
+ for t in tokens[i:]:
155
+ if t.startswith("-") and " " not in t:
156
+ sig.append(t)
157
+ parts.append(" ".join(sig))
158
+ return " ; ".join(parts)
159
+
112
160
  def _check_policy(tool_name, tool_input, tokens_before=0):
113
161
  """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
114
162
  if not POLICY_PATH.exists():
@@ -119,7 +167,13 @@ except Exception:
119
167
  return None, "allow", None, None
120
168
 
121
169
  rules = policy.get("rules", [])
122
- input_text = json.dumps(tool_input)
170
+ # For Bash, match against operator signature (argv[0] + subcommand + flags) — not raw JSON.
171
+ # Prevents false positives where dangerous patterns appear inside quoted argument values
172
+ # (e.g. `gh issue create --body "...rm -rf..."` should not match no-rm-rf).
173
+ if tool_name == "Bash" and tool_input.get("command"):
174
+ input_text = _bash_operator_signature(tool_input["command"])
175
+ else:
176
+ input_text = json.dumps(tool_input)
123
177
  path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
124
178
 
125
179
  for rule in rules:
@@ -2858,6 +2858,77 @@ def _check_guard_setup(command: str) -> None:
2858
2858
  )
2859
2859
 
2860
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
+
2861
2932
  def main():
2862
2933
  _auto_update()
2863
2934
 
@@ -2998,6 +3069,15 @@ def main():
2998
3069
  mcp_sub = mcp_p.add_subparsers(dest="mcp_command")
2999
3070
  mcp_sub.add_parser("install", help="Register conduct-mcp in Claude Code and Codex")
3000
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
+
3001
3081
  # conduct sync
3002
3082
  sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
3003
3083
 
@@ -3091,6 +3171,8 @@ def main():
3091
3171
  cmd_mcp_install(args)
3092
3172
  else:
3093
3173
  mcp_p.print_help()
3174
+ elif args.command == "skill":
3175
+ cmd_skill(args)
3094
3176
  elif args.command == "sync":
3095
3177
  cmd_sync(args)
3096
3178
  elif args.command == "test-guard":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.5.4
3
+ Version: 0.5.6
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -19,6 +19,7 @@ src/conduct_cli.egg-info/dependency_links.txt
19
19
  src/conduct_cli.egg-info/entry_points.txt
20
20
  src/conduct_cli.egg-info/requires.txt
21
21
  src/conduct_cli.egg-info/top_level.txt
22
+ tests/test_bash_operator_signature.py
22
23
  tests/test_guard_policy.py
23
24
  tests/test_guard_savings.py
24
25
  tests/test_hook_syntax.py
@@ -0,0 +1,82 @@
1
+ """Tests for _bash_operator_signature — fixes #728 false positives."""
2
+ import re
3
+ import shlex
4
+
5
+
6
+ def _bash_operator_signature(command):
7
+ """Same logic as hook_template.py. Kept inline for unit testing."""
8
+ if not command:
9
+ return ""
10
+ segments = re.split(r'&&|\|\||\|(?!\|)|;', command)
11
+ parts = []
12
+ for seg in segments:
13
+ try:
14
+ tokens = shlex.split(seg.strip())
15
+ except ValueError:
16
+ continue
17
+ if not tokens:
18
+ continue
19
+ sig = [tokens[0]]
20
+ i = 1
21
+ # Subcommand: next token only if it's a bareword (no whitespace = wasn't a multi-word quoted value)
22
+ if i < len(tokens) and not tokens[i].startswith("-") and " " not in tokens[i]:
23
+ sig.append(tokens[i])
24
+ i += 1
25
+ # Flags only if they're real flags (no whitespace = not embedded in quoted content)
26
+ for t in tokens[i:]:
27
+ if t.startswith("-") and " " not in t:
28
+ sig.append(t)
29
+ parts.append(" ".join(sig))
30
+ return " ; ".join(parts)
31
+
32
+
33
+ def assert_match(pattern, command, should_match):
34
+ sig = _bash_operator_signature(command)
35
+ matched = bool(re.search(pattern, sig, re.IGNORECASE))
36
+ assert matched is should_match, f"{command!r} → sig={sig!r}, pattern={pattern!r}, expected={should_match}, got={matched}"
37
+
38
+
39
+ def test_rm_rf_actual_command_fires():
40
+ assert_match(r"\brm\s+-rf\b", "rm -rf /tmp/build", True)
41
+
42
+
43
+ def test_rm_rf_in_gh_body_does_not_fire():
44
+ assert_match(r"\brm\s+-rf\b", 'gh issue create --body "contains rm -rf example"', False)
45
+
46
+
47
+ def test_rm_rf_in_git_commit_does_not_fire():
48
+ assert_match(r"\brm\s+-rf\b", 'git commit -m "fix: remove rm -rf call from script"', False)
49
+
50
+
51
+ def test_force_push_fires():
52
+ assert_match(r"git\s+push.*--force", "git push --force origin main", True)
53
+
54
+
55
+ def test_force_push_in_commit_does_not_fire():
56
+ assert_match(r"git\s+push.*--force", 'git commit -m "add force-push docs"', False)
57
+
58
+
59
+ def test_vercel_prod_fires():
60
+ assert_match(r"vercel.*--prod", "vercel deploy --prod", True)
61
+
62
+
63
+ def test_vercel_prod_in_echo_does_not_fire():
64
+ assert_match(r"vercel.*--prod", 'echo "vercel deploy --prod example"', False)
65
+
66
+
67
+ def test_chained_rm_rf_fires():
68
+ assert_match(r"\brm\s+-rf\b", "git add . && rm -rf node_modules", True)
69
+
70
+
71
+ def test_chained_rm_in_commit_does_not_fire():
72
+ assert_match(r"\brm\s+-rf\b", 'git add . && git commit -m "rm -rf cleanup"', False)
73
+
74
+
75
+ def test_empty_command():
76
+ assert _bash_operator_signature("") == ""
77
+
78
+
79
+ def test_unterminated_quote_does_not_crash():
80
+ # Should skip the segment, not raise
81
+ sig = _bash_operator_signature('git commit -m "unterminated')
82
+ assert isinstance(sig, str)
File without changes
File without changes
File without changes