conduct-cli 0.4.95__tar.gz → 0.4.97__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 (27) hide show
  1. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/PKG-INFO +1 -1
  2. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/pyproject.toml +1 -1
  3. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/guard.py +8 -0
  4. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/guardmcp.py +157 -9
  5. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/hook_template.py +40 -1
  6. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/main.py +1 -1
  7. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  8. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/README.md +0 -0
  9. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/setup.cfg +0 -0
  10. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/setup.py +0 -0
  11. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/__init__.py +0 -0
  12. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/api.py +0 -0
  13. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/hook_precompact_template.py +0 -0
  14. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/hook_session_start_template.py +0 -0
  15. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/hook_stop_template.py +0 -0
  16. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/mcp_server.py +0 -0
  17. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/memory.py +0 -0
  18. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli/paxel.py +0 -0
  19. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  20. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  21. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  22. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli.egg-info/requires.txt +0 -0
  23. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/src/conduct_cli.egg-info/top_level.txt +0 -0
  24. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/tests/test_guard_policy.py +0 -0
  25. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/tests/test_guard_savings.py +0 -0
  26. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/tests/test_hook_syntax.py +0 -0
  27. {conduct_cli-0.4.95 → conduct_cli-0.4.97}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.95
3
+ Version: 0.4.97
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.4.95"
7
+ version = "0.4.97"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -761,6 +761,14 @@ def cmd_guard_sync(args):
761
761
 
762
762
  print(f"\n{BOLD}Policy refreshed ({rule_count} rule(s)).{RESET}")
763
763
 
764
+ # Print Claude.ai remote MCP URL — requires one-time browser paste
765
+ member_token = cfg.get("member_token", "")
766
+ if workspace_id and member_token:
767
+ mcp_url = f"https://api.conductai.ai/guard/mcp?workspace_id={workspace_id}&token={member_token}"
768
+ print(f"\n{BOLD}Claude.ai{RESET} (one-time browser setup):")
769
+ print(f" Settings → MCP Servers → Add custom server → paste:")
770
+ print(f"\n {CYAN}{mcp_url}{RESET}\n")
771
+
764
772
 
765
773
  def _ensure_booster(root: Path) -> None:
766
774
  """Auto-init and background-index booster if installed but not yet set up."""
@@ -16,6 +16,7 @@ import argparse
16
16
  import json
17
17
  import re
18
18
  import sys
19
+ import uuid
19
20
  import urllib.request
20
21
  import urllib.error
21
22
  from pathlib import Path
@@ -26,6 +27,12 @@ CONFIG_PATH = GUARD_DIR / "config.json"
26
27
 
27
28
  PROTOCOL_VERSION = "2024-11-05"
28
29
 
30
+ # Session ID shared across all events in this MCP server process
31
+ _SESSION_ID = str(uuid.uuid4())
32
+
33
+ # Populated during MCP initialize handshake — used to identify the surface
34
+ _CLIENT_INFO: dict = {}
35
+
29
36
  _TOOLS = [
30
37
  {
31
38
  "name": "guard_status",
@@ -38,20 +45,28 @@ _TOOLS = [
38
45
  {
39
46
  "name": "guard_check",
40
47
  "description": (
41
- "Check whether a specific tool call would be blocked, warned, or allowed "
42
- "by your team's ConductGuard policy. Use this before taking an action you "
43
- "are unsure about."
48
+ "ALWAYS call this before executing any of the following actions: "
49
+ "running shell commands, reading or writing files, accessing the network, "
50
+ "calling external APIs, modifying code, deleting data, or any action that "
51
+ "affects the filesystem or environment. "
52
+ "This enforces your team's ConductGuard security policy — the response will "
53
+ "be ALLOWED, BLOCKED, or WARNING. "
54
+ "If BLOCKED: stop immediately and tell the user the policy rule that blocked it. "
55
+ "If WARNING: proceed but surface the warning to the user. "
56
+ "If ALLOWED: proceed normally. "
57
+ "Pass tool_name as the action you are about to take (e.g. 'bash', 'read_file', "
58
+ "'write_file', 'curl', 'git', 'npm') and tool_input as the relevant parameters."
44
59
  ),
45
60
  "inputSchema": {
46
61
  "type": "object",
47
62
  "properties": {
48
63
  "tool_name": {
49
64
  "type": "string",
50
- "description": "Name of the tool to check (e.g. 'bash', 'edit', 'write')",
65
+ "description": "The action you are about to take (e.g. bash, read_file, write_file, curl, git)",
51
66
  },
52
67
  "tool_input": {
53
68
  "type": "object",
54
- "description": "Input dict that would be passed to the tool",
69
+ "description": "Relevant parameters e.g. {\"command\": \"rm -rf /\"} or {\"file_path\": \"/etc/passwd\"}",
55
70
  },
56
71
  },
57
72
  "required": ["tool_name"],
@@ -65,6 +80,39 @@ _TOOLS = [
65
80
  ),
66
81
  "inputSchema": {"type": "object", "properties": {}, "required": []},
67
82
  },
83
+ {
84
+ "name": "guard_enable",
85
+ "description": (
86
+ "Call this when the user asks to 'enable conductguard', 'load mcp', 'activate guard', "
87
+ "or any similar onboarding request. Confirms ConductGuard is connected, returns the "
88
+ "number of active policy rules, and provides the Project Instruction snippet the user "
89
+ "should paste into their Claude.ai Project settings to make guard_check fire automatically."
90
+ ),
91
+ "inputSchema": {"type": "object", "properties": {}, "required": []},
92
+ },
93
+ {
94
+ "name": "guard_activity",
95
+ "description": (
96
+ "ALWAYS call this at the start of every conversation, immediately after the user sends "
97
+ "their first message. Pass a one-line summary of what the user is asking you to do. "
98
+ "This logs session intent to the team's ConductGuard audit trail so admins can see "
99
+ "what work is being done across the team's AI usage."
100
+ ),
101
+ "inputSchema": {
102
+ "type": "object",
103
+ "properties": {
104
+ "summary": {
105
+ "type": "string",
106
+ "description": "One-line summary of what the user is asking you to do in this conversation.",
107
+ },
108
+ "category": {
109
+ "type": "string",
110
+ "description": "Optional category: coding, debugging, review, research, writing, devops, security, other",
111
+ },
112
+ },
113
+ "required": ["summary"],
114
+ },
115
+ },
68
116
  ]
69
117
 
70
118
 
@@ -88,6 +136,64 @@ def _load_config() -> dict:
88
136
  return {}
89
137
 
90
138
 
139
+ def _detect_surface(client_info: dict) -> str:
140
+ """Map MCP clientInfo.name → ai_tool label sent to Guard API."""
141
+ name = (client_info.get("name") or "").lower()
142
+ if "desktop" in name:
143
+ return "claude_desktop"
144
+ if "work" in name or "teams" in name or "enterprise" in name:
145
+ return "claude_work"
146
+ if "claude" in name:
147
+ return "claude_chat"
148
+ if "codex" in name:
149
+ return "codex"
150
+ if "cursor" in name:
151
+ return "cursor"
152
+ if "windsurf" in name:
153
+ return "windsurf"
154
+ return "unknown"
155
+
156
+
157
+ def _post_audit_event(
158
+ tool_name: str,
159
+ tool_input: dict,
160
+ decision: str,
161
+ rule_id: str | None,
162
+ workspace_id: str,
163
+ token: str,
164
+ ai_tool: str,
165
+ ) -> None:
166
+ """Fire-and-forget: post a guard audit event to the Conduct API."""
167
+ if not workspace_id:
168
+ return
169
+ cfg = _load_config()
170
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
171
+ payload = json.dumps({
172
+ "workspace_id": workspace_id,
173
+ "clerk_user_id": cfg.get("user_email", ""),
174
+ "user_email": cfg.get("user_email", ""),
175
+ "ai_tool": ai_tool,
176
+ "tool_call": tool_name,
177
+ "input_summary": json.dumps(tool_input)[:200],
178
+ "decision": decision,
179
+ "rule_id": rule_id,
180
+ "hook_session_id": _SESSION_ID,
181
+ }).encode()
182
+ try:
183
+ req = urllib.request.Request(
184
+ f"{api_url}/guard/events",
185
+ data=payload,
186
+ headers={
187
+ "Content-Type": "application/json",
188
+ "Authorization": f"Bearer {token}",
189
+ },
190
+ method="POST",
191
+ )
192
+ urllib.request.urlopen(req, timeout=5)
193
+ except Exception:
194
+ pass # never crash the MCP server over telemetry
195
+
196
+
91
197
  def _match_policy(tool_name: str, tool_input: dict) -> dict | None:
92
198
  """Return the first matching rule dict, or None if no rule fires."""
93
199
  policy = _load_policy()
@@ -137,12 +243,13 @@ def _handle_guard_status(workspace_id: str) -> str:
137
243
  }, indent=2)
138
244
 
139
245
 
140
- def _handle_guard_check(arguments: dict) -> str:
246
+ def _handle_guard_check(arguments: dict, workspace_id: str, token: str, ai_tool: str) -> str:
141
247
  tool_name = arguments.get("tool_name", "")
142
248
  tool_input = arguments.get("tool_input") or {}
143
249
 
144
250
  rule = _match_policy(tool_name, tool_input)
145
251
  if rule is None:
252
+ _post_audit_event(tool_name, tool_input, "allowed", None, workspace_id, token, ai_tool)
146
253
  return f"ALLOWED — no policy rule matches '{tool_name}'."
147
254
 
148
255
  action = rule.get("action", "audit")
@@ -150,9 +257,13 @@ def _handle_guard_check(arguments: dict) -> str:
150
257
  message = rule.get("message") or f"Policy violation ({rule_id})"
151
258
 
152
259
  if action == "block":
260
+ _post_audit_event(tool_name, tool_input, "blocked", rule_id, workspace_id, token, ai_tool)
153
261
  return f"BLOCKED — {message} [rule: {rule_id}]"
154
262
  if action in ("warn", "approval"):
263
+ _post_audit_event(tool_name, tool_input, "warned", rule_id, workspace_id, token, ai_tool)
155
264
  return f"WARNING — {message} [rule: {rule_id}]"
265
+
266
+ _post_audit_event(tool_name, tool_input, "audited", rule_id, workspace_id, token, ai_tool)
156
267
  return f"AUDITED — {message} [rule: {rule_id}]"
157
268
 
158
269
 
@@ -181,13 +292,35 @@ def _handle_guard_sync(workspace_id: str, token: str) -> str:
181
292
  return f"Sync failed — {e}"
182
293
 
183
294
 
184
- def _dispatch_tool(name: str, arguments: dict, workspace_id: str, token: str) -> str:
295
+ def _dispatch_tool(name: str, arguments: dict, workspace_id: str, token: str, ai_tool: str) -> str:
185
296
  if name == "guard_status":
186
297
  return _handle_guard_status(workspace_id)
187
298
  if name == "guard_check":
188
- return _handle_guard_check(arguments)
299
+ return _handle_guard_check(arguments, workspace_id, token, ai_tool)
189
300
  if name == "guard_sync":
190
301
  return _handle_guard_sync(workspace_id, token)
302
+ if name == "guard_activity":
303
+ summary = arguments.get("summary", "")
304
+ category = arguments.get("category", "other")
305
+ _post_audit_event("guard_activity", {"summary": summary, "category": category}, "allowed", None, workspace_id, token, ai_tool)
306
+ return f"Activity logged — '{summary}'"
307
+ if name == "guard_enable":
308
+ policy = _load_policy()
309
+ rule_count = len(policy.get("rules", []))
310
+ snippet = (
311
+ "You have ConductGuard active. "
312
+ "ALWAYS call guard_check before running shell commands, reading or writing files, "
313
+ "calling external APIs, or any action that affects the filesystem or environment. "
314
+ "If the response is BLOCKED, stop immediately and explain the policy rule to the user. "
315
+ "If WARNING, proceed but surface the warning."
316
+ )
317
+ return (
318
+ f"✓ ConductGuard is connected — {rule_count} active rule(s).\n\n"
319
+ f"To make Guard fire automatically on every conversation, paste this into your "
320
+ f"Claude.ai Project Instructions (Projects → your project → Instructions):\n\n"
321
+ f"---\n{snippet}\n---\n\n"
322
+ f"Until then, Guard is active for this conversation only."
323
+ )
191
324
  return f"Unknown tool: {name}"
192
325
 
193
326
 
@@ -217,6 +350,7 @@ def main() -> None:
217
350
  cfg = _load_config()
218
351
  workspace_id = args.workspace or cfg.get("workspace_id", "")
219
352
  token = args.token or cfg.get("member_token", "")
353
+ ai_tool = "unknown" # resolved from clientInfo during initialize
220
354
 
221
355
  for raw in sys.stdin:
222
356
  raw = raw.strip()
@@ -233,10 +367,24 @@ def main() -> None:
233
367
  params = msg.get("params") or {}
234
368
 
235
369
  if method == "initialize":
370
+ client_info = params.get("clientInfo") or {}
371
+ _CLIENT_INFO.update(client_info)
372
+ ai_tool = _detect_surface(client_info)
236
373
  _ok(msg_id, {
237
374
  "protocolVersion": PROTOCOL_VERSION,
238
375
  "capabilities": {"tools": {}},
239
376
  "serverInfo": {"name": "conductguard", "version": "1.0.0"},
377
+ "instructions": (
378
+ "ConductGuard is active and enforcing your team's security policy. "
379
+ "ALWAYS call guard_activity at the start of every conversation with a one-line summary of what the user is asking you to do. "
380
+ "ALWAYS call guard_check before executing any of the following: "
381
+ "shell commands, file reads or writes, network requests, external API calls, "
382
+ "code modifications, or any action that affects the filesystem or environment. "
383
+ "If the response is BLOCKED: stop immediately and explain the policy rule to the user. "
384
+ "If WARNING: proceed but surface the warning to the user. "
385
+ "If ALLOWED: proceed normally. "
386
+ "Never skip guard_activity or guard_check — both are required for compliance."
387
+ ),
240
388
  })
241
389
 
242
390
  elif method == "notifications/initialized":
@@ -248,7 +396,7 @@ def main() -> None:
248
396
  elif method == "tools/call":
249
397
  tool_name = params.get("name", "")
250
398
  arguments = params.get("arguments") or {}
251
- text = _dispatch_tool(tool_name, arguments, workspace_id, token)
399
+ text = _dispatch_tool(tool_name, arguments, workspace_id, token, ai_tool)
252
400
  _ok(msg_id, {"content": [{"type": "text", "text": text}]})
253
401
 
254
402
  elif method == "ping":
@@ -15,6 +15,7 @@ BUDGET_CACHE_PATH = GUARD_DIR / "budget_cache.json"
15
15
  BUDGET_CACHE_TTL = 300 # 5 minutes
16
16
  VERSION_CACHE_PATH = GUARD_DIR / "version_cache.json"
17
17
  VERSION_CACHE_TTL = 60 # 1 minute — matches server poll window
18
+ WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
18
19
 
19
20
 
20
21
  def _maybe_sync_policy():
@@ -159,6 +160,35 @@ def _detect_ai_tool():
159
160
  return "unknown"
160
161
 
161
162
 
163
+ def _already_warned_this_session(session_id: str, rule_id: str) -> bool:
164
+ """Return True if this rule already fired a warning in the current session."""
165
+ try:
166
+ data = json.loads(WARNED_RULES_PATH.read_text()) if WARNED_RULES_PATH.exists() else {}
167
+ except Exception:
168
+ data = {}
169
+ return rule_id in data.get(session_id, [])
170
+
171
+
172
+ def _record_session_warn(session_id: str, rule_id: str) -> None:
173
+ try:
174
+ data = json.loads(WARNED_RULES_PATH.read_text()) if WARNED_RULES_PATH.exists() else {}
175
+ except Exception:
176
+ data = {}
177
+ data.setdefault(session_id, [])
178
+ if rule_id not in data[session_id]:
179
+ data[session_id].append(rule_id)
180
+ # Trim to last 50 sessions to prevent unbounded growth
181
+ if len(data) > 50:
182
+ oldest = list(data.keys())[:-50]
183
+ for k in oldest:
184
+ del data[k]
185
+ try:
186
+ GUARD_DIR.mkdir(parents=True, exist_ok=True)
187
+ WARNED_RULES_PATH.write_text(json.dumps(data))
188
+ except Exception:
189
+ pass
190
+
191
+
162
192
  def _post_event(tool_name, tool_input, decision, rule_id=None, message=None, session_id=None):
163
193
  try:
164
194
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
@@ -533,7 +563,12 @@ def post_usage_main():
533
563
  _, action, rule_id, message = _check_policy(tool_name, {}, tokens_before=tokens_input)
534
564
  if action in ("warn", "block"):
535
565
  decision = "warned" if action == "warn" else "blocked"
536
- _post_event(tool_name, {}, decision, rule_id, message, session_id=session_id)
566
+ if action == "warn" and session_id and rule_id and _already_warned_this_session(session_id, rule_id):
567
+ pass # already warned once this session — skip
568
+ else:
569
+ if action == "warn" and session_id and rule_id:
570
+ _record_session_warn(session_id, rule_id)
571
+ _post_event(tool_name, {}, decision, rule_id, message, session_id=session_id)
537
572
 
538
573
  # Security classifier runs regardless of transcript_path — scan every tool response
539
574
  tool_response = data.get("tool_response") or data.get("output") or ""
@@ -584,6 +619,10 @@ def main():
584
619
 
585
620
  # Always post an event — "allowed" for normal calls, "blocked"/"warned" for violations
586
621
  decision = {"block": "blocked", "warn": "warned", "approval": "blocked"}.get(action, "allowed")
622
+ if action == "warn" and session_id and rule_id and _already_warned_this_session(session_id, rule_id):
623
+ sys.exit(0) # already warned once this session — skip silently
624
+ if action == "warn" and session_id and rule_id:
625
+ _record_session_warn(session_id, rule_id)
587
626
  _post_event(tool_name, tool_input, decision, rule_id, message, session_id=session_id)
588
627
 
589
628
  if action == "block":
@@ -156,7 +156,7 @@ def _stream_run(server: str, workflow_id: str, run_id: str, workspace_id: str, t
156
156
  print(f"{RED} ✗ {prefix}{err}{RESET}")
157
157
  elif kind == "brain_tool_call":
158
158
  summary = payload.get("summary", payload.get("tool", ""))
159
- print(f"{GRAY} · {summary}{RESET}")
159
+ print(f" · {summary}{RESET}")
160
160
  elif kind == "run_completed":
161
161
  print(f"{BOLD}{GREEN} ✓ done{RESET}")
162
162
  elif kind == "run_failed":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.95
3
+ Version: 0.4.97
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
File without changes
File without changes
File without changes