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.
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/PKG-INFO +8 -8
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/README.md +7 -7
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/pyproject.toml +1 -1
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/guard.py +37 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/base.py +78 -6
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/posttooluse.py +5 -15
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/pretooluse.py +8 -34
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/PKG-INFO +8 -8
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- conduct_cli-0.14.3/tests/test_guard_status.py +58 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_hook_dispatch.py +1 -1
- conduct_cli-0.14.3/tests/test_hook_journal.py +188 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_journal_drain.py +6 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_log_util.py +1 -1
- conduct_cli-0.14.1/tests/test_hook_journal.py +0 -64
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/LICENSE +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/setup.cfg +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/setup.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_api_client.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_bash_scan_target.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_cli_matrix.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_guard_approvals.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_hook_receipt.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_install_all.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_pretooluse_approval_e2e.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_pretooluse_check_policy.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_report_tool_coverage.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.14.1 → conduct_cli-0.14.3}/tests/test_switch.py +0 -0
- {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.
|
|
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 ~/.
|
|
129
|
-
├─ writes PreToolUse hook → ~/.
|
|
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 `~/.
|
|
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 `~/.
|
|
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 ~/.
|
|
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 `~/.
|
|
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 `~/.
|
|
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 ~/.
|
|
99
|
-
├─ writes PreToolUse hook → ~/.
|
|
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 `~/.
|
|
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 `~/.
|
|
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 ~/.
|
|
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 `~/.
|
|
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 `~/.
|
|
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.
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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}
|
|
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("
|
|
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":
|
|
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
|
-
"""
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
121
|
-
"
|
|
122
|
-
|
|
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
|
-
}
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
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.
|
|
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 ~/.
|
|
129
|
-
├─ writes PreToolUse hook → ~/.
|
|
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 `~/.
|
|
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 `~/.
|
|
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 ~/.
|
|
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 `~/.
|
|
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 `~/.
|
|
263
|
+
Policy is stored at `~/.conduct/policy.json` and synced from the server:
|
|
264
264
|
|
|
265
265
|
```json
|
|
266
266
|
{
|
|
@@ -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 ~/.
|
|
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 ~/.
|
|
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
|
|
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
|
|
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
|