conduct-cli 0.14.1__tar.gz → 0.14.3__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 (59) hide show
  1. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/PKG-INFO +8 -8
  2. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/README.md +7 -7
  3. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/pyproject.toml +1 -1
  4. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/guard.py +37 -0
  5. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/base.py +78 -6
  6. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/posttooluse.py +5 -15
  7. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/pretooluse.py +8 -34
  8. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/PKG-INFO +8 -8
  9. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
  10. conduct_cli-0.14.3/tests/test_guard_status.py +58 -0
  11. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_hook_dispatch.py +1 -1
  12. conduct_cli-0.14.3/tests/test_hook_journal.py +188 -0
  13. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_journal_drain.py +6 -0
  14. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_log_util.py +1 -1
  15. conduct_cli-0.14.1/tests/test_hook_journal.py +0 -64
  16. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/LICENSE +0 -0
  17. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/setup.cfg +0 -0
  18. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/setup.py +0 -0
  19. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/__init__.py +0 -0
  20. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/api.py +0 -0
  21. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/guard_policy.md +0 -0
  22. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/__init__.py +0 -0
  23. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/precompact.py +0 -0
  24. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/session_parser.py +0 -0
  25. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/session_report_push.py +0 -0
  26. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/session_start.py +0 -0
  27. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/stop.py +0 -0
  28. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/log_util.py +0 -0
  29. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/main.py +0 -0
  30. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/memory.py +0 -0
  31. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/paxel.py +0 -0
  32. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/tool_groups.py +0 -0
  33. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  34. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  35. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/requires.txt +0 -0
  36. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/top_level.txt +0 -0
  37. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_advisory_and_hook.py +0 -0
  38. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_api_client.py +0 -0
  39. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_bash_operator_signature.py +0 -0
  40. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_bash_scan_target.py +0 -0
  41. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_blast_radius.py +0 -0
  42. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_cli_matrix.py +0 -0
  43. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_cmd_run.py +0 -0
  44. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_command_word_matcher.py +0 -0
  45. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_drain_daemon_health.py +0 -0
  46. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_guard_approvals.py +0 -0
  47. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_guard_policy.py +0 -0
  48. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_guard_savings.py +0 -0
  49. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_hook_receipt.py +0 -0
  50. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_install_all.py +0 -0
  51. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_instruction_files.py +0 -0
  52. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_pretooluse_approval_e2e.py +0 -0
  53. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_pretooluse_check_policy.py +0 -0
  54. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_proxy_coverage.py +0 -0
  55. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_proxy_env.py +0 -0
  56. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_report_tool_coverage.py +0 -0
  57. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_signed_policy.py +0 -0
  58. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_switch.py +0 -0
  59. {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_token_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.14.1
3
+ Version: 0.14.3
4
4
  Summary: Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: Apache-2.0
@@ -125,8 +125,8 @@ Admin configures policies and budgets in the Guard dashboard
125
125
  └─ developers are workspace members automatically — no invite step needed
126
126
 
127
127
  Developer runs: conduct guard sync
128
- ├─ pulls latest policy to ~/.conductguard/policy.json
129
- ├─ writes PreToolUse hook → ~/.conductguard/hook.py
128
+ ├─ pulls latest policy to ~/.conduct/policy.json
129
+ ├─ writes PreToolUse hook → ~/.conduct/hook.py
130
130
  ├─ registers hook → ~/.claude/settings.json
131
131
  └─ registers conductguard-mcp → ~/.claude/settings.json (mcpServers) + Codex
132
132
 
@@ -153,7 +153,7 @@ That's it. Policy enforcement is active from the next tool call.
153
153
 
154
154
  | Command | Description |
155
155
  |---------|-------------|
156
- | `conduct guard sync` | Pull latest policy, write hook to `~/.conductguard/hook.py`, register hook + MCP |
156
+ | `conduct guard sync` | Pull latest policy, write hook to `~/.conduct/hook.py`, register hook + MCP |
157
157
  | `conduct guard status` | Show today's spend, session count, and violations |
158
158
  | `conduct guard audit [--since 7d]` | Print recent guard events in a table |
159
159
  | `conduct verify [--evidence FILE] [--strict] [--format json]` | Map guard events to OWASP Agentic Top 10; exit 1 in CI if blocked events (--strict) |
@@ -190,7 +190,7 @@ OWASP mapping: `no-rm-rf` → A04 Excessive Agency, `no-sudo` → A09 Privilege
190
190
 
191
191
  ### How the PreToolUse hook works
192
192
 
193
- When you run `conduct guard sync`, the CLI writes a Python script to `~/.conductguard/hook.py` and registers it as a `PreToolUse` hook in `~/.claude/settings.json`:
193
+ When you run `conduct guard sync`, the CLI writes a Python script to `~/.conduct/hook.py` and registers it as a `PreToolUse` hook in `~/.claude/settings.json`:
194
194
 
195
195
  ```json
196
196
  {
@@ -198,7 +198,7 @@ When you run `conduct guard sync`, the CLI writes a Python script to `~/.conduct
198
198
  "PreToolUse": [
199
199
  {
200
200
  "matcher": ".*",
201
- "hooks": [{ "type": "command", "command": "python3 ~/.conductguard/hook.py" }]
201
+ "hooks": [{ "type": "command", "command": "python3 ~/.conduct/hook.py" }]
202
202
  }
203
203
  ]
204
204
  }
@@ -208,7 +208,7 @@ When you run `conduct guard sync`, the CLI writes a Python script to `~/.conduct
208
208
  Before every tool call, Claude Code runs the hook. The hook:
209
209
 
210
210
  1. Reads `tool_name` and `tool_input` from stdin (JSON)
211
- 2. Loads `~/.conductguard/policy.json` (the team ruleset)
211
+ 2. Loads `~/.conduct/policy.json` (the team ruleset)
212
212
  3. Matches the call against each rule (`match_tool`, `match_pattern`, `match_path_pattern`)
213
213
  4. Takes the rule's action:
214
214
  - `block` — prints the policy message, exits with code `2` (Claude Code aborts the tool call)
@@ -260,7 +260,7 @@ guard_sync()
260
260
 
261
261
  ### Policy file format
262
262
 
263
- Policy is stored at `~/.conductguard/policy.json` and synced from the server:
263
+ Policy is stored at `~/.conduct/policy.json` and synced from the server:
264
264
 
265
265
  ```json
266
266
  {
@@ -95,8 +95,8 @@ Admin configures policies and budgets in the Guard dashboard
95
95
  └─ developers are workspace members automatically — no invite step needed
96
96
 
97
97
  Developer runs: conduct guard sync
98
- ├─ pulls latest policy to ~/.conductguard/policy.json
99
- ├─ writes PreToolUse hook → ~/.conductguard/hook.py
98
+ ├─ pulls latest policy to ~/.conduct/policy.json
99
+ ├─ writes PreToolUse hook → ~/.conduct/hook.py
100
100
  ├─ registers hook → ~/.claude/settings.json
101
101
  └─ registers conductguard-mcp → ~/.claude/settings.json (mcpServers) + Codex
102
102
 
@@ -123,7 +123,7 @@ That's it. Policy enforcement is active from the next tool call.
123
123
 
124
124
  | Command | Description |
125
125
  |---------|-------------|
126
- | `conduct guard sync` | Pull latest policy, write hook to `~/.conductguard/hook.py`, register hook + MCP |
126
+ | `conduct guard sync` | Pull latest policy, write hook to `~/.conduct/hook.py`, register hook + MCP |
127
127
  | `conduct guard status` | Show today's spend, session count, and violations |
128
128
  | `conduct guard audit [--since 7d]` | Print recent guard events in a table |
129
129
  | `conduct verify [--evidence FILE] [--strict] [--format json]` | Map guard events to OWASP Agentic Top 10; exit 1 in CI if blocked events (--strict) |
@@ -160,7 +160,7 @@ OWASP mapping: `no-rm-rf` → A04 Excessive Agency, `no-sudo` → A09 Privilege
160
160
 
161
161
  ### How the PreToolUse hook works
162
162
 
163
- When you run `conduct guard sync`, the CLI writes a Python script to `~/.conductguard/hook.py` and registers it as a `PreToolUse` hook in `~/.claude/settings.json`:
163
+ When you run `conduct guard sync`, the CLI writes a Python script to `~/.conduct/hook.py` and registers it as a `PreToolUse` hook in `~/.claude/settings.json`:
164
164
 
165
165
  ```json
166
166
  {
@@ -168,7 +168,7 @@ When you run `conduct guard sync`, the CLI writes a Python script to `~/.conduct
168
168
  "PreToolUse": [
169
169
  {
170
170
  "matcher": ".*",
171
- "hooks": [{ "type": "command", "command": "python3 ~/.conductguard/hook.py" }]
171
+ "hooks": [{ "type": "command", "command": "python3 ~/.conduct/hook.py" }]
172
172
  }
173
173
  ]
174
174
  }
@@ -178,7 +178,7 @@ When you run `conduct guard sync`, the CLI writes a Python script to `~/.conduct
178
178
  Before every tool call, Claude Code runs the hook. The hook:
179
179
 
180
180
  1. Reads `tool_name` and `tool_input` from stdin (JSON)
181
- 2. Loads `~/.conductguard/policy.json` (the team ruleset)
181
+ 2. Loads `~/.conduct/policy.json` (the team ruleset)
182
182
  3. Matches the call against each rule (`match_tool`, `match_pattern`, `match_path_pattern`)
183
183
  4. Takes the rule's action:
184
184
  - `block` — prints the policy message, exits with code `2` (Claude Code aborts the tool call)
@@ -230,7 +230,7 @@ guard_sync()
230
230
 
231
231
  ### Policy file format
232
232
 
233
- Policy is stored at `~/.conductguard/policy.json` and synced from the server:
233
+ Policy is stored at `~/.conduct/policy.json` and synced from the server:
234
234
 
235
235
  ```json
236
236
  {
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.14.1"
7
+ version = "0.14.3"
8
8
  description = "Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  import json
5
5
  import os
6
6
  import sys
7
+ import time
7
8
  import urllib.error
8
9
  import urllib.request
9
10
  from datetime import datetime, timedelta, timezone
@@ -2140,6 +2141,33 @@ def _report_savings(cfg: dict, base_url: str, agent_token: str = "") -> None:
2140
2141
  pass
2141
2142
 
2142
2143
 
2144
+ def cmd_guard_replay_events(args):
2145
+ """Requeue retained hook events after the authenticated ingest rollout."""
2146
+ if args.limit is not None and args.limit < 1:
2147
+ print(f"{RED}--limit must be at least 1.{RESET}")
2148
+ sys.exit(2)
2149
+ cfg = _require_guard_config()
2150
+ if not cfg.get("agent_token"):
2151
+ print(f"{RED}No Agent Identity token found. Run `conduct login` first.{RESET}")
2152
+ sys.exit(1)
2153
+
2154
+ from conduct_cli.hooks.base import (
2155
+ JOURNAL_DEAD_DIR,
2156
+ ensure_drain_daemon,
2157
+ requeue_dead_letters,
2158
+ )
2159
+
2160
+ available = len(list(JOURNAL_DEAD_DIR.glob("*.json"))) if JOURNAL_DEAD_DIR.exists() else 0
2161
+ selected = min(available, args.limit) if args.limit is not None else available
2162
+ if args.dry_run:
2163
+ print(f"{selected} of {available} dead-letter event(s) ready to replay")
2164
+ return
2165
+ moved = requeue_dead_letters(limit=args.limit)
2166
+ if moved:
2167
+ ensure_drain_daemon(GUARD_DIR / "hook.py")
2168
+ print(f"Requeued {moved} dead-letter event(s)")
2169
+
2170
+
2143
2171
  def cmd_guard_status(args):
2144
2172
  cfg = _require_guard_config()
2145
2173
  workspace_id = cfg.get("workspace_id")
@@ -2459,6 +2487,13 @@ def register_guard_parser(sub):
2459
2487
  # conduct guard status
2460
2488
  guard_sub.add_parser("status", help="Show today's spend and violations")
2461
2489
 
2490
+ replay_p = guard_sub.add_parser(
2491
+ "replay-events",
2492
+ help="Retry retained hook events after fixing delivery",
2493
+ )
2494
+ replay_p.add_argument("--limit", type=int, default=None, help="Maximum events to requeue")
2495
+ replay_p.add_argument("--dry-run", action="store_true", help="Show how many events would be requeued")
2496
+
2462
2497
  # conduct guard savings --team
2463
2498
  guard_sub.add_parser("savings", help="Show org-level token savings across all developers")
2464
2499
 
@@ -3407,6 +3442,8 @@ def dispatch_guard(args, guard_p):
3407
3442
  cmd_guard_sync(args)
3408
3443
  elif guard_command == "status":
3409
3444
  cmd_guard_status(args)
3445
+ elif guard_command == "replay-events":
3446
+ cmd_guard_replay_events(args)
3410
3447
  elif guard_command == "savings":
3411
3448
  cmd_guard_savings(args)
3412
3449
  elif guard_command == "audit":
@@ -5,6 +5,7 @@ bare `pip install conduct-cli` with no shell rc sourced.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import base64
8
9
  import json
9
10
  import subprocess
10
11
  import sys
@@ -165,14 +166,28 @@ def detect_ai_tool() -> str:
165
166
 
166
167
  # ── Journal / drain (fire-and-forget event posting) ───────────────────────────
167
168
 
168
- def journal_append(payload_str: str, api_url: str) -> None:
169
+ _JOURNAL_ENDPOINTS = {"/guard/events", "/guard/events/usage"}
170
+
171
+
172
+ def journal_append(
173
+ payload_str: str,
174
+ api_url: str,
175
+ endpoint: str = "/guard/events",
176
+ ) -> None:
169
177
  """Atomically write one event to the journal for the drain daemon to pick up."""
170
178
  try:
179
+ if endpoint not in _JOURNAL_ENDPOINTS:
180
+ return
171
181
  JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
172
182
  JOURNAL_DIR.chmod(0o700)
173
183
  import random
174
184
  name = f"{time.time_ns()}_{random.randint(0, 9999):04d}.json"
175
- entry = json.dumps({"api_url": api_url, "payload": payload_str, "attempts": 0})
185
+ entry = json.dumps({
186
+ "api_url": api_url,
187
+ "endpoint": endpoint,
188
+ "payload": payload_str,
189
+ "attempts": 0,
190
+ })
176
191
  tmp = JOURNAL_DIR / (name + ".tmp")
177
192
  tmp.write_text(entry)
178
193
  tmp.chmod(0o600)
@@ -288,7 +303,16 @@ def run_drain_daemon() -> None:
288
303
  entry: dict = {}
289
304
  try:
290
305
  entry = json.loads(f.read_text())
291
- api_url = entry["api_url"]
306
+ cfg = load_config()
307
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
308
+ if entry.get("api_url", "").rstrip("/") != api_url:
309
+ raise ValueError("journal API URL does not match active configuration")
310
+ endpoint = entry.get("endpoint", "/guard/events")
311
+ if endpoint not in _JOURNAL_ENDPOINTS:
312
+ raise ValueError("unsupported journal endpoint")
313
+ agent_token = cfg.get("agent_token", "")
314
+ if not agent_token:
315
+ raise ValueError("agent token missing")
292
316
  payload = (
293
317
  entry["payload"].encode()
294
318
  if isinstance(entry["payload"], str)
@@ -300,9 +324,10 @@ def run_drain_daemon() -> None:
300
324
  except Exception:
301
325
  _ua = "conduct-cli"
302
326
  req = urllib.request.Request(
303
- f"{api_url}/guard/events",
327
+ f"{api_url}{endpoint}",
304
328
  data=payload,
305
329
  headers={
330
+ "Authorization": f"Bearer {agent_token}",
306
331
  "Content-Type": "application/json",
307
332
  "User-Agent": _ua,
308
333
  },
@@ -363,6 +388,52 @@ def _safe_summary(tool_input: dict) -> str:
363
388
  return raw.translate(_WAF_CHARS)[:200]
364
389
 
365
390
 
391
+ def _encode_summary_text(summary: str) -> str:
392
+ return base64.urlsafe_b64encode(summary.encode()).decode()
393
+
394
+
395
+ def _encoded_summary(tool_input: dict) -> str:
396
+ """Encode command-like summaries so an ingress WAF cannot parse them as requests."""
397
+ return _encode_summary_text(_safe_summary(tool_input))
398
+
399
+
400
+ def requeue_dead_letters(limit: int | None = None) -> int:
401
+ """Move retained events back to the journal after upgrading the API and CLI."""
402
+ if not JOURNAL_DEAD_DIR.exists():
403
+ return 0
404
+ JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
405
+ JOURNAL_DIR.chmod(0o700)
406
+ moved = 0
407
+ for source in sorted(JOURNAL_DEAD_DIR.glob("*.json")):
408
+ if limit is not None and moved >= limit:
409
+ break
410
+ try:
411
+ entry = json.loads(source.read_text())
412
+ payload = json.loads(entry["payload"])
413
+ summary = payload.get("input_summary")
414
+ if summary is not None and not payload.get("input_summary_encoding"):
415
+ payload["input_summary"] = _encode_summary_text(str(summary))
416
+ payload["input_summary_encoding"] = "base64url"
417
+ # The authenticated API derives actor identity from the Agent Identity
418
+ # token. Older clients incorrectly stored an email in clerk_user_id.
419
+ payload.pop("clerk_user_id", None)
420
+ payload.pop("user_email", None)
421
+ entry["payload"] = json.dumps(payload)
422
+ entry["endpoint"] = entry.get("endpoint", "/guard/events")
423
+ entry["attempts"] = 0
424
+ entry.pop("last_error", None)
425
+ destination = JOURNAL_DIR / source.name
426
+ tmp = destination.with_suffix(".tmp")
427
+ tmp.write_text(json.dumps(entry))
428
+ tmp.chmod(0o600)
429
+ tmp.replace(destination)
430
+ source.unlink()
431
+ moved += 1
432
+ except Exception:
433
+ continue
434
+ return moved
435
+
436
+
366
437
  def post_event(
367
438
  tool_name: str,
368
439
  tool_input: dict,
@@ -393,11 +464,12 @@ def post_event(
393
464
  _gcfg = _json_gcfg.loads(_gcfg_path.read_text()) if _gcfg_path.exists() else {}
394
465
  payload = json.dumps({
395
466
  "workspace_id": workspace_id,
396
- "clerk_user_id": cfg.get("user_email"),
467
+ "clerk_user_id": cfg.get("clerk_user_id"),
397
468
  "user_email": cfg.get("user_email"),
398
469
  "ai_tool": detect_ai_tool(),
399
470
  "tool_call": tool_name[:255],
400
- "input_summary": _safe_summary(tool_input),
471
+ "input_summary": _encoded_summary(tool_input),
472
+ "input_summary_encoding": "base64url",
401
473
  "decision": decision,
402
474
  "rule_id": rule_id,
403
475
  "rule_message": message,
@@ -10,6 +10,8 @@ from conduct_cli.hooks.base import (
10
10
  CONFIG_PATH,
11
11
  GUARD_DIR,
12
12
  detect_ai_tool,
13
+ ensure_drain_daemon,
14
+ journal_append,
13
15
  load_config,
14
16
  post_event,
15
17
  record_hook_heartbeat,
@@ -192,7 +194,7 @@ def _compute_blast_radius(tool_name: str, tool_input: dict, tool_response: str)
192
194
 
193
195
 
194
196
  def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms, blast_radius=None, execution_status=None, result_summary=None) -> None:
195
- """Fire-and-forget POST to /guard/events/usage."""
197
+ """Queue a PostToolUse update for the authenticated drain daemon."""
196
198
  cfg = load_config()
197
199
  workspace_id = cfg.get("workspace_id")
198
200
  if not workspace_id or not session_id:
@@ -210,20 +212,8 @@ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms,
210
212
  "result_summary": result_summary,
211
213
  })
212
214
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
213
- script = (
214
- "import urllib.request\n"
215
- "try:\n"
216
- f" req = urllib.request.Request(\"{api_url}/guard/events/usage\","
217
- f" data={repr(payload.encode())}, headers={{\"Content-Type\": \"application/json\"}}, method=\"POST\")\n"
218
- " urllib.request.urlopen(req, timeout=5)\n"
219
- "except: pass\n"
220
- )
221
- subprocess.Popen(
222
- [sys.executable, "-c", script],
223
- stdout=subprocess.DEVNULL,
224
- stderr=subprocess.DEVNULL,
225
- start_new_session=True,
226
- )
215
+ journal_append(payload, api_url, endpoint="/guard/events/usage")
216
+ ensure_drain_daemon(GUARD_DIR / "hook.py")
227
217
 
228
218
 
229
219
  # ── Codex delayed reader (spawned as subprocess) ──────────────────────────────
@@ -108,46 +108,20 @@ def _verify_policy_signature(policy_dict: dict) -> bool:
108
108
 
109
109
  def _post_signature_invalid_event(expected_sig, computed_sig, policy_version, hostname):
110
110
  """Fire-and-forget audit event for a failed signature check."""
111
- try:
112
- cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
113
- except Exception:
114
- return
115
- workspace_id = cfg.get("workspace_id") or cfg.get("workspace")
116
- if not workspace_id:
117
- return
118
111
  import platform as _platform
119
112
  _hostname = hostname or _platform.node()
120
- payload = json.dumps({
121
- "workspace_id": workspace_id,
122
- "clerk_user_id": cfg.get("user_email"),
123
- "user_email": cfg.get("user_email"),
124
- "ai_tool": "hook",
125
- "tool_call": "policy_signature_invalid",
126
- "input_summary": json.dumps({
113
+ post_event(
114
+ "policy_signature_invalid",
115
+ {
127
116
  "expected_signature": expected_sig or "",
128
117
  "computed_signature": computed_sig or "",
129
118
  "policy_version": policy_version or "",
130
119
  "hostname": _hostname,
131
- })[:500],
132
- "decision": "blocked",
133
- "rule_id": "policy_signature_invalid",
134
- "rule_message": "Policy signature verification failed",
135
- "hostname": _hostname,
136
- })
137
- api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
138
- script = (
139
- "import urllib.request\n"
140
- "try:\n"
141
- f" req = urllib.request.Request(\"{api_url}/guard/events\","
142
- f" data={repr(payload.encode())}, headers={{\"Content-Type\": \"application/json\"}}, method=\"POST\")\n"
143
- " urllib.request.urlopen(req, timeout=5)\n"
144
- "except: pass\n"
145
- )
146
- subprocess.Popen(
147
- [sys.executable, "-c", script],
148
- stdout=subprocess.DEVNULL,
149
- stderr=subprocess.DEVNULL,
150
- start_new_session=True,
120
+ },
121
+ "blocked",
122
+ "policy_signature_invalid",
123
+ "Policy signature verification failed",
124
+ drain_via=GUARD_DIR / "hook.py",
151
125
  )
152
126
 
153
127
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.14.1
3
+ Version: 0.14.3
4
4
  Summary: Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: Apache-2.0
@@ -125,8 +125,8 @@ Admin configures policies and budgets in the Guard dashboard
125
125
  └─ developers are workspace members automatically — no invite step needed
126
126
 
127
127
  Developer runs: conduct guard sync
128
- ├─ pulls latest policy to ~/.conductguard/policy.json
129
- ├─ writes PreToolUse hook → ~/.conductguard/hook.py
128
+ ├─ pulls latest policy to ~/.conduct/policy.json
129
+ ├─ writes PreToolUse hook → ~/.conduct/hook.py
130
130
  ├─ registers hook → ~/.claude/settings.json
131
131
  └─ registers conductguard-mcp → ~/.claude/settings.json (mcpServers) + Codex
132
132
 
@@ -153,7 +153,7 @@ That's it. Policy enforcement is active from the next tool call.
153
153
 
154
154
  | Command | Description |
155
155
  |---------|-------------|
156
- | `conduct guard sync` | Pull latest policy, write hook to `~/.conductguard/hook.py`, register hook + MCP |
156
+ | `conduct guard sync` | Pull latest policy, write hook to `~/.conduct/hook.py`, register hook + MCP |
157
157
  | `conduct guard status` | Show today's spend, session count, and violations |
158
158
  | `conduct guard audit [--since 7d]` | Print recent guard events in a table |
159
159
  | `conduct verify [--evidence FILE] [--strict] [--format json]` | Map guard events to OWASP Agentic Top 10; exit 1 in CI if blocked events (--strict) |
@@ -190,7 +190,7 @@ OWASP mapping: `no-rm-rf` → A04 Excessive Agency, `no-sudo` → A09 Privilege
190
190
 
191
191
  ### How the PreToolUse hook works
192
192
 
193
- When you run `conduct guard sync`, the CLI writes a Python script to `~/.conductguard/hook.py` and registers it as a `PreToolUse` hook in `~/.claude/settings.json`:
193
+ When you run `conduct guard sync`, the CLI writes a Python script to `~/.conduct/hook.py` and registers it as a `PreToolUse` hook in `~/.claude/settings.json`:
194
194
 
195
195
  ```json
196
196
  {
@@ -198,7 +198,7 @@ When you run `conduct guard sync`, the CLI writes a Python script to `~/.conduct
198
198
  "PreToolUse": [
199
199
  {
200
200
  "matcher": ".*",
201
- "hooks": [{ "type": "command", "command": "python3 ~/.conductguard/hook.py" }]
201
+ "hooks": [{ "type": "command", "command": "python3 ~/.conduct/hook.py" }]
202
202
  }
203
203
  ]
204
204
  }
@@ -208,7 +208,7 @@ When you run `conduct guard sync`, the CLI writes a Python script to `~/.conduct
208
208
  Before every tool call, Claude Code runs the hook. The hook:
209
209
 
210
210
  1. Reads `tool_name` and `tool_input` from stdin (JSON)
211
- 2. Loads `~/.conductguard/policy.json` (the team ruleset)
211
+ 2. Loads `~/.conduct/policy.json` (the team ruleset)
212
212
  3. Matches the call against each rule (`match_tool`, `match_pattern`, `match_path_pattern`)
213
213
  4. Takes the rule's action:
214
214
  - `block` — prints the policy message, exits with code `2` (Claude Code aborts the tool call)
@@ -260,7 +260,7 @@ guard_sync()
260
260
 
261
261
  ### Policy file format
262
262
 
263
- Policy is stored at `~/.conductguard/policy.json` and synced from the server:
263
+ Policy is stored at `~/.conduct/policy.json` and synced from the server:
264
264
 
265
265
  ```json
266
266
  {
@@ -38,6 +38,7 @@ tests/test_drain_daemon_health.py
38
38
  tests/test_guard_approvals.py
39
39
  tests/test_guard_policy.py
40
40
  tests/test_guard_savings.py
41
+ tests/test_guard_status.py
41
42
  tests/test_hook_dispatch.py
42
43
  tests/test_hook_journal.py
43
44
  tests/test_hook_receipt.py
@@ -0,0 +1,58 @@
1
+ """Tests for local health details reported by ``conduct guard status``."""
2
+
3
+ import json
4
+ from types import SimpleNamespace
5
+ from unittest.mock import patch
6
+
7
+ import conduct_cli.guard as guard
8
+
9
+
10
+ def test_status_reports_recent_hook_heartbeat(tmp_path, capsys, monkeypatch):
11
+ conduct_home = tmp_path / ".conduct"
12
+ conduct_home.mkdir()
13
+ (conduct_home / "hook-heartbeat.json").write_text(
14
+ json.dumps({"event": "post_tool_use", "ts": 1_000.0})
15
+ )
16
+
17
+ monkeypatch.setattr(guard.Path, "home", staticmethod(lambda: tmp_path))
18
+ monkeypatch.setattr(guard.time, "time", lambda: 1_007.9)
19
+ monkeypatch.setattr(guard, "_load_policy", lambda: {"rules": []})
20
+ monkeypatch.setattr(
21
+ guard,
22
+ "_require_guard_config",
23
+ lambda: {
24
+ "workspace_id": "workspace-test",
25
+ "user_email": "developer@example.com",
26
+ "clerk_user_id": "user-test",
27
+ "agent_token": "token-test",
28
+ "api_url": "https://api.example.test",
29
+ },
30
+ )
31
+
32
+ with patch.object(guard, "_req", side_effect=[{}, []]), patch(
33
+ "conduct_cli.hooks.base.drain_daemon_status", return_value=("running", 123)
34
+ ):
35
+ guard.cmd_guard_status(None)
36
+
37
+ output = capsys.readouterr().out
38
+ assert "Hook heartbeat: post_tool_use" in output
39
+ assert "7s ago" in output
40
+
41
+
42
+ def test_replay_events_requeues_and_starts_drain(tmp_path, capsys, monkeypatch):
43
+ from conduct_cli.hooks import base
44
+
45
+ dead = tmp_path / "dead-letter"
46
+ dead.mkdir()
47
+ (dead / "event.json").write_text("{}")
48
+ monkeypatch.setattr(base, "JOURNAL_DEAD_DIR", dead)
49
+ monkeypatch.setattr(guard, "_require_guard_config", lambda: {"agent_token": "cond_agt_test"})
50
+
51
+ with patch.object(base, "requeue_dead_letters", return_value=1) as requeue, patch.object(
52
+ base, "ensure_drain_daemon"
53
+ ) as ensure:
54
+ guard.cmd_guard_replay_events(SimpleNamespace(limit=None, dry_run=False))
55
+
56
+ requeue.assert_called_once_with(limit=None)
57
+ ensure.assert_called_once()
58
+ assert "Requeued 1 dead-letter event" in capsys.readouterr().out
@@ -25,7 +25,7 @@ GUARD_DIR = Path(__file__).parent.parent / "src" / "conduct_cli"
25
25
  # ── 1. hook.py dispatch ───────────────────────────────────────────────────────
26
26
 
27
27
  def _hook_py_content() -> str:
28
- """Read the thin-launcher template that gets written to ~/.conductguard/hook.py."""
28
+ """Read the thin-launcher template that gets written to ~/.conduct/hook.py."""
29
29
  from conduct_cli.guard import _THIN_LAUNCHERS
30
30
  return _THIN_LAUNCHERS["pretooluse"]
31
31
 
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ from urllib.error import HTTPError
7
+
8
+ from conduct_cli.hooks import base, posttooluse
9
+
10
+
11
+ def _redirect_journal(tmp_path, monkeypatch):
12
+ journal = tmp_path / "journal"
13
+ monkeypatch.setattr(base, "JOURNAL_DIR", journal)
14
+ monkeypatch.setattr(base, "JOURNAL_DEAD_DIR", journal / "dead-letter")
15
+ monkeypatch.setattr(base, "JOURNAL_PID_PATH", journal / "drain.pid")
16
+ monkeypatch.setattr(base.time, "sleep", lambda _seconds: None)
17
+ monkeypatch.setattr(
18
+ base,
19
+ "load_config",
20
+ lambda: {"api_url": "https://api.test", "agent_token": "cond_agt_test"},
21
+ )
22
+ return journal
23
+
24
+
25
+ def test_permanent_delivery_failure_moves_event_to_dead_letter(tmp_path, monkeypatch):
26
+ journal = _redirect_journal(tmp_path, monkeypatch)
27
+ base.journal_append(json.dumps({"workspace_id": "workspace"}), "https://api.test")
28
+
29
+ def _fail(*args, **kwargs):
30
+ raise HTTPError("https://api.test", 400, "bad request", {}, None)
31
+
32
+ monkeypatch.setattr(base.urllib.request, "urlopen", _fail)
33
+ base.run_drain_daemon()
34
+
35
+ assert not list(journal.glob("*.json"))
36
+ dead = list((journal / "dead-letter").glob("*.json"))
37
+ assert len(dead) == 1
38
+ if os.name != "nt":
39
+ assert dead[0].stat().st_mode & 0o077 == 0
40
+ entry = json.loads(dead[0].read_text())
41
+ assert entry["attempts"] == 1
42
+ assert entry["last_error"] == "HTTP 400"
43
+
44
+
45
+ def test_successful_delivery_removes_event(tmp_path, monkeypatch):
46
+ journal = _redirect_journal(tmp_path, monkeypatch)
47
+ base.journal_append(json.dumps({"workspace_id": "workspace"}), "https://api.test")
48
+
49
+ class _Ok:
50
+ def __enter__(self):
51
+ return self
52
+
53
+ def __exit__(self, *args):
54
+ pass
55
+
56
+ monkeypatch.setattr(base.urllib.request, "urlopen", lambda *args, **kwargs: _Ok())
57
+ base.run_drain_daemon()
58
+ assert not list(journal.glob("*.json"))
59
+
60
+
61
+ def test_delivery_uses_current_agent_token_without_persisting_it(tmp_path, monkeypatch):
62
+ journal = _redirect_journal(tmp_path, monkeypatch)
63
+ base.journal_append("{}", "https://api.test")
64
+ entry = json.loads(next(journal.glob("*.json")).read_text())
65
+ assert "cond_agt_test" not in json.dumps(entry)
66
+
67
+ requests = []
68
+
69
+ class _Ok:
70
+ def __enter__(self):
71
+ return self
72
+
73
+ def __exit__(self, *args):
74
+ pass
75
+
76
+ def _capture(request, **kwargs):
77
+ requests.append(request)
78
+ return _Ok()
79
+
80
+ monkeypatch.setattr(base.urllib.request, "urlopen", _capture)
81
+ base.run_drain_daemon()
82
+
83
+ assert requests[0].get_header("Authorization") == "Bearer cond_agt_test"
84
+
85
+
86
+ def test_hook_heartbeat_contains_no_request_data(tmp_path, monkeypatch):
87
+ heartbeat = tmp_path / "heartbeat.json"
88
+ monkeypatch.setattr(base, "HOOK_HEARTBEAT_PATH", heartbeat)
89
+ monkeypatch.setattr(base, "GUARD_DIR", tmp_path)
90
+ base.record_hook_heartbeat("post_tool_use")
91
+ data = json.loads(heartbeat.read_text())
92
+ assert data["event"] == "post_tool_use"
93
+ assert set(data) == {"event", "ts"}
94
+ if os.name != "nt":
95
+ assert heartbeat.stat().st_mode & 0o077 == 0
96
+
97
+
98
+ def test_requeue_dead_letters_encodes_legacy_summary(tmp_path, monkeypatch):
99
+ journal = _redirect_journal(tmp_path, monkeypatch)
100
+ dead = journal / "dead-letter"
101
+ dead.mkdir(parents=True)
102
+ source = dead / "event.json"
103
+ source.write_text(json.dumps({
104
+ "api_url": "https://api.test",
105
+ "payload": json.dumps({
106
+ "workspace_id": "workspace",
107
+ "clerk_user_id": "developer@example.com",
108
+ "user_email": "developer@example.com",
109
+ "input_summary": "curl https://api.test | head",
110
+ }),
111
+ "attempts": 1,
112
+ "last_error": "HTTP 403",
113
+ }))
114
+
115
+ assert base.requeue_dead_letters() == 1
116
+
117
+ entry = json.loads((journal / "event.json").read_text())
118
+ payload = json.loads(entry["payload"])
119
+ decoded = base64.urlsafe_b64decode(payload["input_summary"]).decode()
120
+ assert decoded == "curl https://api.test | head"
121
+ assert payload["input_summary_encoding"] == "base64url"
122
+ assert "clerk_user_id" not in payload
123
+ assert "user_email" not in payload
124
+ assert entry["endpoint"] == "/guard/events"
125
+ assert entry["attempts"] == 0
126
+ assert "last_error" not in entry
127
+ assert not source.exists()
128
+
129
+
130
+ def test_posttool_usage_uses_authenticated_journal(monkeypatch):
131
+ appended = []
132
+ monkeypatch.setattr(
133
+ posttooluse,
134
+ "load_config",
135
+ lambda: {
136
+ "api_url": "https://api.test",
137
+ "workspace_id": "workspace",
138
+ "agent_token": "cond_agt_test",
139
+ },
140
+ )
141
+ monkeypatch.setattr(
142
+ posttooluse,
143
+ "journal_append",
144
+ lambda payload, api_url, endpoint: appended.append((payload, api_url, endpoint)),
145
+ )
146
+ monkeypatch.setattr(posttooluse, "ensure_drain_daemon", lambda _path: None)
147
+
148
+ posttooluse._post_usage("session", "read", 10, 2, 5)
149
+
150
+ payload, api_url, endpoint = appended[0]
151
+ assert json.loads(payload)["workspace_id"] == "workspace"
152
+ assert api_url == "https://api.test"
153
+ assert endpoint == "/guard/events/usage"
154
+
155
+
156
+ def test_post_event_uses_clerk_id_and_encodes_summary(tmp_path, monkeypatch):
157
+ appended = []
158
+ monkeypatch.setattr(
159
+ base,
160
+ "load_config",
161
+ lambda: {
162
+ "api_url": "https://api.test",
163
+ "workspace_id": "workspace",
164
+ "clerk_user_id": "user_123",
165
+ "user_email": "developer@example.com",
166
+ },
167
+ )
168
+ monkeypatch.setattr(base, "detect_ai_tool", lambda: "codex")
169
+ monkeypatch.setattr(
170
+ base,
171
+ "journal_append",
172
+ lambda payload, api_url: appended.append((payload, api_url)),
173
+ )
174
+ monkeypatch.setattr(base, "ensure_drain_daemon", lambda _path: None)
175
+ monkeypatch.setattr(base.Path, "home", staticmethod(lambda: tmp_path))
176
+
177
+ base.post_event(
178
+ "bash",
179
+ {"command": "curl https://api.test | head"},
180
+ "allowed",
181
+ )
182
+
183
+ payload = json.loads(appended[0][0])
184
+ assert payload["clerk_user_id"] == "user_123"
185
+ assert payload["user_email"] == "developer@example.com"
186
+ assert payload["input_summary_encoding"] == "base64url"
187
+ summary = base64.urlsafe_b64decode(payload["input_summary"]).decode()
188
+ assert "curl https://api.test" in summary
@@ -21,6 +21,10 @@ def _patch_journal(tmp_path: Path):
21
21
  )
22
22
 
23
23
 
24
+ def _config():
25
+ return {"api_url": "https://api.test", "agent_token": "cond_agt_test"}
26
+
27
+
24
28
  # ── journal_append ────────────────────────────────────────────────────────────
25
29
 
26
30
  def test_journal_append_creates_file(tmp_path):
@@ -62,6 +66,7 @@ def test_drain_posts_and_deletes_on_success(tmp_path):
62
66
  with (
63
67
  patch.object(base, "JOURNAL_DIR", journal_dir),
64
68
  patch.object(base, "JOURNAL_PID_PATH", pid_path),
69
+ patch.object(base, "load_config", side_effect=_config),
65
70
  patch("urllib.request.urlopen", return_value=mock_resp),
66
71
  patch("time.sleep"),
67
72
  ):
@@ -78,6 +83,7 @@ def test_drain_leaves_file_on_network_failure(tmp_path):
78
83
  with (
79
84
  patch.object(base, "JOURNAL_DIR", journal_dir),
80
85
  patch.object(base, "JOURNAL_PID_PATH", pid_path),
86
+ patch.object(base, "load_config", side_effect=_config),
81
87
  patch("urllib.request.urlopen", side_effect=OSError("network down")),
82
88
  patch("time.sleep"),
83
89
  ):
@@ -44,7 +44,7 @@ def test_interceptor_passes_through_systemexit(monkeypatch):
44
44
 
45
45
 
46
46
  def test_post_skipped_when_no_creds(monkeypatch, tmp_path):
47
- """No ~/.conductguard/config.json → no POST attempted."""
47
+ """No ~/.conduct/config.json means no POST is attempted."""
48
48
  monkeypatch.setattr(log_util, "_CONFIG", tmp_path / "missing.json")
49
49
  log_util._read_creds.cache_clear()
50
50
 
@@ -1,64 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import os
5
- from urllib.error import HTTPError
6
-
7
- from conduct_cli.hooks import base
8
-
9
-
10
- def _redirect_journal(tmp_path, monkeypatch):
11
- journal = tmp_path / "journal"
12
- monkeypatch.setattr(base, "JOURNAL_DIR", journal)
13
- monkeypatch.setattr(base, "JOURNAL_DEAD_DIR", journal / "dead-letter")
14
- monkeypatch.setattr(base, "JOURNAL_PID_PATH", journal / "drain.pid")
15
- monkeypatch.setattr(base.time, "sleep", lambda _seconds: None)
16
- return journal
17
-
18
-
19
- def test_permanent_delivery_failure_moves_event_to_dead_letter(tmp_path, monkeypatch):
20
- journal = _redirect_journal(tmp_path, monkeypatch)
21
- base.journal_append(json.dumps({"workspace_id": "workspace"}), "https://api.test")
22
-
23
- def _fail(*args, **kwargs):
24
- raise HTTPError("https://api.test", 400, "bad request", {}, None)
25
-
26
- monkeypatch.setattr(base.urllib.request, "urlopen", _fail)
27
- base.run_drain_daemon()
28
-
29
- assert not list(journal.glob("*.json"))
30
- dead = list((journal / "dead-letter").glob("*.json"))
31
- assert len(dead) == 1
32
- if os.name != "nt":
33
- assert dead[0].stat().st_mode & 0o077 == 0
34
- entry = json.loads(dead[0].read_text())
35
- assert entry["attempts"] == 1
36
- assert entry["last_error"] == "HTTP 400"
37
-
38
-
39
- def test_successful_delivery_removes_event(tmp_path, monkeypatch):
40
- journal = _redirect_journal(tmp_path, monkeypatch)
41
- base.journal_append(json.dumps({"workspace_id": "workspace"}), "https://api.test")
42
-
43
- class _Ok:
44
- def __enter__(self):
45
- return self
46
-
47
- def __exit__(self, *args):
48
- pass
49
-
50
- monkeypatch.setattr(base.urllib.request, "urlopen", lambda *args, **kwargs: _Ok())
51
- base.run_drain_daemon()
52
- assert not list(journal.glob("*.json"))
53
-
54
-
55
- def test_hook_heartbeat_contains_no_request_data(tmp_path, monkeypatch):
56
- heartbeat = tmp_path / "heartbeat.json"
57
- monkeypatch.setattr(base, "HOOK_HEARTBEAT_PATH", heartbeat)
58
- monkeypatch.setattr(base, "GUARD_DIR", tmp_path)
59
- base.record_hook_heartbeat("post_tool_use")
60
- data = json.loads(heartbeat.read_text())
61
- assert data["event"] == "post_tool_use"
62
- assert set(data) == {"event", "ts"}
63
- if os.name != "nt":
64
- assert heartbeat.stat().st_mode & 0o077 == 0
File without changes
File without changes
File without changes