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.
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/PKG-INFO +1 -1
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/pyproject.toml +1 -1
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/api.py +2 -2
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/guard.py +61 -31
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/guardmcp.py +44 -19
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/hook_template.py +59 -5
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/main.py +82 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- conduct_cli-0.5.6/tests/test_bash_operator_signature.py +82 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/README.md +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/setup.cfg +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/setup.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/hook_session_start_template.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.5.4 → conduct_cli-0.5.6}/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}")
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
@@ -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
|
|
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
|