conduct-cli 0.5.5__tar.gz → 0.5.7__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.5 → conduct_cli-0.5.7}/PKG-INFO +1 -1
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/pyproject.toml +1 -1
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/hook_template.py +37 -1
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/main.py +16 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- conduct_cli-0.5.7/tests/test_bash_operator_signature.py +82 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/README.md +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/setup.cfg +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/setup.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/guard.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/hook_session_start_template.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.5.5 → conduct_cli-0.5.7}/tests/test_switch.py +0 -0
|
@@ -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
|
|
@@ -127,6 +128,35 @@ def _fetch_budget_status():
|
|
|
127
128
|
try:
|
|
128
129
|
from conduct_cli.guard import _check_policy
|
|
129
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
|
+
|
|
130
160
|
def _check_policy(tool_name, tool_input, tokens_before=0):
|
|
131
161
|
"""Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
|
|
132
162
|
if not POLICY_PATH.exists():
|
|
@@ -137,7 +167,13 @@ except Exception:
|
|
|
137
167
|
return None, "allow", None, None
|
|
138
168
|
|
|
139
169
|
rules = policy.get("rules", [])
|
|
140
|
-
|
|
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)
|
|
141
177
|
path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
|
|
142
178
|
|
|
143
179
|
for rule in rules:
|
|
@@ -2431,6 +2431,22 @@ def cmd_run(args):
|
|
|
2431
2431
|
hint = "export GITHUB_TOKEN=<token>" if not gh_token else "workflow has no github_hook_repo"
|
|
2432
2432
|
print(f" {GRAY}No GitHub token — using test payload. ({hint}){RESET}\n")
|
|
2433
2433
|
|
|
2434
|
+
# #734: validate required inputs locally before POST — fail fast with clear error.
|
|
2435
|
+
try:
|
|
2436
|
+
vres = api.req("POST", f"{server}/workflows/{workflow_id}/validate-inputs", json_h,
|
|
2437
|
+
{"inputs": body.get("inputs") or {}, "phase": "run"})
|
|
2438
|
+
missing = (vres or {}).get("missing") or []
|
|
2439
|
+
if missing:
|
|
2440
|
+
print(f"{RED}✗ Missing required inputs:{RESET}")
|
|
2441
|
+
for m in missing:
|
|
2442
|
+
print(f" - {m['label']} ({m['key']})")
|
|
2443
|
+
print(f"\n{GRAY}Provide with --input {missing[0]['key']}=<value>{RESET}")
|
|
2444
|
+
sys.exit(2)
|
|
2445
|
+
except SystemExit:
|
|
2446
|
+
raise
|
|
2447
|
+
except Exception:
|
|
2448
|
+
pass # validate endpoint is best-effort; backend will re-check on /trigger
|
|
2449
|
+
|
|
2434
2450
|
# Fix 3: /trigger returns run_id, not id.
|
|
2435
2451
|
if getattr(args, "max_turns", None):
|
|
2436
2452
|
body["__max_turns"] = args.max_turns
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|