Skip to content

Commit 4d8d6f1

Browse files
nhortonclaude
andauthored
feat: add tool requirements policy enforcement system (#383)
* feat: add tool requirements policy enforcement system Introduces a PreToolUse hook-based policy system that evaluates tool calls against RFC 2119-style requirements defined in .deepwork/tool_requirements/*.yml. Policies are checked via an HTTP sidecar server (spawned alongside the MCP server) using Haiku for semantic evaluation. Failed checks can be appealed via a new appeal_tool_requirement MCP tool. Approvals are cached with a 1-hour TTL. Key features: - Policy files with tools, match (param regex), requirements, extends (inheritance) - no_exception rules that cannot be appealed - Fail-closed: hook denies if MCP sidecar is unreachable - Loop prevention: appeal tool calls skip the hook - Multi-instance support via PID-keyed + session-keyed port files - Evaluator encapsulated behind ABC for future swap to direct API calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review findings - engine.py: rename loop variable `f` to `failure` for clarity - sidecar.py: move `import asyncio` to module level, fix event loop leak with try/finally, fix inaccurate comment, add session_id validation - evaluator.py: change `continue` to `break` on raw JSON array parse, filter non-dict items in _extract_json_array - discovery.py: fix double-name warning message, remove dead code - test_engine.py: add type hints to MockEvaluator.evaluate, remove unused imports - test_tool_requirements_hook.py: remove redundant test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update documentation for tool requirements system - doc/mcp_interface.md: add appeal_tool_requirement as tool #12, bump count - doc/architecture.md: add tool_requirements/ package and hook to structure - CLAUDE.md: add tool_requirements/ and hook to project structure appendix - src/deepwork/hooks/README.md: add tool_requirements.py to files table - CHANGELOG.md: add tool requirements feature to Unreleased Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address round-2 review findings - evaluator.py: fix comment accuracy, extract _filter_dicts to reduce DRY - discovery.py: fix diamond inheritance by copying visited set per parent - test_engine.py: remove redundant @pytest.mark.asyncio decorators, fix dict type annotation, replace internal cache access with call_count - test_evaluator.py: add tests for HaikuSubprocessEvaluator, deduplication, non-dict filtering, and invalid bracket JSON Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add DW-REQ-012 requirement spec and test traceability - Create DW-REQ-012-tool-requirements.md with 12 sub-requirements covering policy format, discovery, inheritance, matching, evaluation, check flow, appeal, caching, hook, sidecar, multi-instance, and startup - Add PLUG-REQ-001.15 for the PreToolUse hook registration - Add requirement ID references to all test module docstrings - Add THIS TEST VALIDATES traceability comments to critical tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address round-3 review findings - DW-REQ-012.5.3: make SHOULD violation criterion concrete and testable - PLUG-REQ-001: fix section ordering (001.14 before 001.15) - test_engine.py: use two-level REQ ID format (DW-REQ-012.6 not 012.6.3) - test_hook.py: use two-level REQ ID format, fix traceability comment placement - test_evaluator.py: move tests to correct class, remove redundant decorators Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address round-4 review findings - test_tool_requirements_hook.py: move import to module level (DRY) - test_evaluator.py: add missing blank line between classes (E302) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6a8d698 commit 4d8d6f1

29 files changed

Lines changed: 2486 additions & 6 deletions

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,8 @@ deepwork/
208208
│ │ └── deepplan/
209209
│ ├── standard_schemas/ # Built-in DeepSchema definitions
210210
│ ├── review/ # DeepWork Reviews system (.deepreview pipeline)
211-
│ ├── schemas/ # Definition schemas (deepreview, deepschema, doc_spec)
211+
│ ├── schemas/ # Definition schemas (deepreview, deepschema, doc_spec, tool_requirements)
212+
│ ├── tool_requirements/ # Tool requirements policy enforcement (config, discovery, matcher, evaluator, engine, cache, sidecar)
212213
│ └── utils/ # Utilities (fs, git, yaml, validation)
213214
├── platform/ # Shared platform-agnostic content
214215
│ └── skill-body.md # Canonical skill body (source of truth)
@@ -227,7 +228,7 @@ deepwork/
227228
│ │ │ ├── new_user/SKILL.md
228229
│ │ │ ├── record/SKILL.md
229230
│ │ │ └── review/SKILL.md
230-
│ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh, deepschema_write.sh
231+
│ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh, deepschema_write.sh, tool_requirements.sh
231232
│ │ └── .mcp.json # MCP server config
232233
│ └── gemini/ # Gemini CLI extension
233234
│ └── skills/deepwork/SKILL.md

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Tool requirements policy enforcement system: define RFC 2119-style rules in `.deepwork/tool_requirements/*.yml` to govern AI agent tool calls, with LLM-based semantic evaluation, appeal mechanism, and 1-hour TTL caching
13+
1214
### Changed
1315

1416
### Fixed
@@ -25,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2527

2628
### Fixed
2729

30+
- Settings schema missing `if`, `asyncRewake`, `once`, `shell` fields on hook definitions and missing `StopFailure`, `PermissionDenied`, `TaskCreated`, `FileChanged`, `CwdChanged` hook event types
31+
2832
### Removed
2933
## [0.13.7] - 2026-04-14
3034

doc/architecture.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,16 @@ deepwork/ # DeepWork tool repository
9393
│ ├── schemas/ # Definition schemas
9494
│ │ ├── deepreview_schema.json
9595
│ │ ├── deepschema_schema.json
96-
│ │ └── doc_spec_schema.py
96+
│ │ ├── doc_spec_schema.py
97+
│ │ └── tool_requirements_schema.json
98+
│ ├── tool_requirements/ # Tool requirements policy enforcement
99+
│ │ ├── cache.py # In-memory TTL cache for approved calls
100+
│ │ ├── config.py # ToolPolicy/Requirement dataclasses, parser
101+
│ │ ├── discovery.py # Load policies from .deepwork/tool_requirements/
102+
│ │ ├── engine.py # Check + appeal orchestration
103+
│ │ ├── evaluator.py # LLM evaluator (Haiku) for requirement checking
104+
│ │ ├── matcher.py # Match policies to tool calls
105+
│ │ └── sidecar.py # HTTP sidecar server for hook communication
97106
│ └── utils/
98107
│ ├── fs.py
99108
│ ├── git.py
@@ -119,7 +128,7 @@ deepwork/ # DeepWork tool repository
119128
│ │ │ ├── new_user/SKILL.md
120129
│ │ │ ├── record/SKILL.md
121130
│ │ │ └── review/SKILL.md
122-
│ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh, deepschema_write.sh
131+
│ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh, deepschema_write.sh, tool_requirements.sh
123132
│ │ └── .mcp.json # MCP server config
124133
│ └── gemini/ # Gemini CLI extension
125134
│ └── skills/deepwork/SKILL.md

doc/mcp_interface.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This document describes the Model Context Protocol (MCP) tools exposed by the De
1010

1111
## Tools
1212

13-
DeepWork exposes eleven MCP tools:
13+
DeepWork exposes twelve MCP tools:
1414

1515
### 1. `get_workflows`
1616

@@ -308,6 +308,29 @@ Retrieve the YAML content of a session-scoped job definition previously register
308308
}
309309
```
310310

311+
### 12. `appeal_tool_requirement`
312+
313+
Appeal a tool requirement policy denial. When a tool call is blocked by a tool requirement policy, call this to appeal specific failed checks by providing justifications. Some checks are marked `no_exception` and cannot be appealed. If the appeal succeeds, the tool call is cached as approved and you can retry the original tool call.
314+
315+
#### Parameters
316+
317+
| Parameter | Type | Required | Description |
318+
|-----------|------|----------|-------------|
319+
| `tool_name` | `string` | Yes | The normalized tool name that was blocked |
320+
| `tool_input` | `dict` | Yes | The exact tool_input that was blocked |
321+
| `policy_justification` | `dict[string, string]` | Yes | Map of failed check names to justification strings |
322+
| `session_id` | `string` | No | Session identifier (CLAUDE_CODE_SESSION_ID on Claude Code) |
323+
324+
#### Returns
325+
326+
```typescript
327+
{
328+
passed: boolean; // Whether the appeal succeeded
329+
reason: string; // Explanation of result
330+
no_exception_blocked?: string[]; // Checks that cannot be appealed
331+
}
332+
```
333+
311334
---
312335

313336
## Shared Types
@@ -491,6 +514,7 @@ Add to your `.mcp.json`:
491514

492515
| Version | Changes |
493516
|---------|---------|
517+
| 2.4.0 | Added `appeal_tool_requirement` tool for appealing tool requirement policy denials with justifications. |
494518
| 2.3.0 | Added `project_root` field to `ActiveStepInfo` — the absolute path to the MCP server's project root. Added `register_session_job` and `get_session_job` tools for transient session-scoped job definitions. Session jobs are discoverable by `start_workflow` via `session_id` lookup — they take priority over standard discovery. Added `deepplan` standard job with `create_deep_plan` workflow. |
495519
| 2.2.0 | `session_id` is now optional (`str | None`) on `start_workflow` only. On Claude Code (platform `"claude"`), the server raises `ToolError` if omitted. On other platforms, omitting it auto-generates a stable UUID; callers use the returned `begin_step.session_id` for all subsequent calls. `finished_step`, `abort_workflow`, and `go_to_step` continue to require `session_id`. Added `inputs` optional parameter to `start_workflow` for passing step argument values directly at workflow start. Added `issue_detected` optional field to all tool responses — present when the server detects configuration issues at startup; instructs agent to suggest repair to the user. |
496520
| 2.1.0 | Added `important_note` field to `StartWorkflowResponse` — instructs agents to clarify ambiguous user requests via `AskUserQuestion` when available. |
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# DW-REQ-012: Tool Requirements Policy Enforcement
2+
3+
## Overview
4+
5+
The tool requirements system enforces RFC 2119-style policies on AI agent tool calls. Users define rules in `.deepwork/tool_requirements/*.yml` files. A PreToolUse hook checks these rules via an HTTP sidecar server using LLM-based semantic evaluation. Failed checks can be appealed via an MCP tool. Approved calls are cached with a TTL.
6+
7+
## DW-REQ-012.1: Policy File Format
8+
9+
1. Policy files MUST be YAML files located in `.deepwork/tool_requirements/` with a `.yml` extension.
10+
2. Each policy file MUST be validated against the `tool_requirements_schema.json` JSON Schema.
11+
3. The `tools` field MUST be a non-empty array of normalized tool names (e.g., `shell`, `write_file`, `edit_file`) or MCP tool names (e.g., `mcp__server__tool`).
12+
4. The `requirements` field MUST be a mapping of requirement identifiers to objects containing a `rule` string (RFC 2119 statement) and an optional `no_exception` boolean (default: `false`).
13+
5. The `match` field MAY be a mapping of tool_input parameter names to regex patterns for parameter-level filtering.
14+
6. The `extends` field MAY be an array of policy file stems for inheritance.
15+
7. The `summary` field MAY be a human-readable description of the policy.
16+
17+
## DW-REQ-012.2: Policy Discovery
18+
19+
1. The system MUST scan `.deepwork/tool_requirements/` for `*.yml` files (single-directory, no tree walk).
20+
2. Files that fail to parse MUST be skipped with a warning logged — they MUST NOT prevent other policies from loading.
21+
3. If the `.deepwork/tool_requirements/` directory does not exist, the system MUST return an empty policy list without error.
22+
23+
## DW-REQ-012.3: Policy Inheritance
24+
25+
1. When a policy lists `extends`, the system MUST merge parent requirements into the child.
26+
2. Child requirements MUST override parent requirements with the same key.
27+
3. Unknown parent names MUST be logged as warnings and skipped — they MUST NOT cause errors.
28+
4. Circular inheritance MUST be detected and MUST NOT cause infinite loops.
29+
5. Diamond inheritance (two parents sharing a common ancestor) MUST be handled correctly — the common ancestor's requirements MUST be included once.
30+
31+
## DW-REQ-012.4: Policy Matching
32+
33+
1. A policy MUST match a tool call if the tool's normalized name is in the policy's `tools` list.
34+
2. If the policy has a `match` dict, the policy MUST match only when at least one parameter regex matches a value in `tool_input` (via `re.search`).
35+
3. If the policy has no `match` dict, it MUST match all calls to the listed tools.
36+
4. Multiple policies MAY match a single tool call; all matched requirements MUST be merged.
37+
5. If the same requirement key appears in multiple matched policies, the first occurrence MUST win.
38+
6. Invalid regex patterns in `match` MUST be skipped without error.
39+
40+
## DW-REQ-012.5: Requirement Evaluation
41+
42+
1. Requirements MUST be evaluated by an LLM evaluator (Haiku by default) that considers RFC 2119 keywords.
43+
2. `MUST`/`MUST NOT` violations MUST always result in failure.
44+
3. `SHOULD`/`SHOULD NOT` violations MUST result in failure only when the tool call could be trivially modified to comply (e.g., adding a flag, choosing a different command) — the evaluator prompt MUST instruct the LLM to apply this criterion.
45+
4. `MAY` requirements MUST always pass.
46+
5. The evaluator MUST return a verdict for every requirement — requirements not evaluated MUST fail closed.
47+
6. The evaluator MUST be encapsulated behind an abstract interface to allow implementation swapping.
48+
7. Large `tool_input` values MUST be truncated to avoid exceeding LLM token limits.
49+
50+
## DW-REQ-012.6: Check Flow
51+
52+
1. When a tool call is checked, the system MUST first check the cache — if approved, it MUST allow immediately.
53+
2. If no policies match the tool call, it MUST be allowed.
54+
3. If evaluation passes all requirements, the result MUST be cached and the call MUST be allowed.
55+
4. If any requirements fail, the response MUST include ALL failures (not one at a time).
56+
5. Each failure MUST include the requirement ID and an explanation.
57+
6. `no_exception` requirements MUST be labeled as such in the failure message.
58+
7. The failure message MUST include instructions for how to appeal via the `appeal_tool_requirement` MCP tool.
59+
60+
## DW-REQ-012.7: Appeal Mechanism
61+
62+
1. The system MUST provide an `appeal_tool_requirement` MCP tool.
63+
2. The tool MUST accept `tool_name`, `tool_input`, and `policy_justification` (a dict mapping failed check IDs to justification strings).
64+
3. `no_exception` requirements MUST NOT be appealable — appeals for them MUST be rejected immediately.
65+
4. For appealable requirements, the evaluator MUST re-evaluate considering the provided justifications.
66+
5. If the appeal succeeds, the result MUST be cached so the retried tool call passes the hook.
67+
6. If the appeal fails, the response MUST list all still-failing requirements.
68+
69+
## DW-REQ-012.8: Caching
70+
71+
1. Approved tool calls MUST be cached with a 1-hour TTL.
72+
2. The cache key MUST be deterministic, derived from the tool name and tool input.
73+
3. Expired cache entries MUST be evicted on lookup.
74+
4. The cache MUST be in-memory within the sidecar server process.
75+
76+
## DW-REQ-012.9: PreToolUse Hook
77+
78+
1. The hook MUST fire on all PreToolUse events (empty matcher).
79+
2. The hook MUST skip the `appeal_tool_requirement` MCP tool to prevent infinite loops (substring match on raw tool name).
80+
3. If the sidecar is unreachable (port file missing or PID dead), the hook MUST deny with an error message instructing the user to restart the MCP server (fail-closed).
81+
4. If communication with the sidecar fails, the hook MUST deny with an error message (fail-closed).
82+
5. The hook MUST use `hookSpecificOutput.permissionDecision: "deny"` format for Claude Code PreToolUse events.
83+
6. The hook MUST use the cross-platform wrapper system (`run_hook`, `HookInput`, `HookOutput`).
84+
85+
## DW-REQ-012.10: Sidecar HTTP Server
86+
87+
1. The sidecar MUST start as a daemon thread alongside the MCP server when policy files exist.
88+
2. The sidecar MUST bind to `127.0.0.1` on a random port.
89+
3. The sidecar MUST write a port file to `.deepwork/tmp/tool_req_sidecar/<PID>.json` containing `{"pid": <PID>, "port": <PORT>}`.
90+
4. The sidecar MUST provide `POST /check` and `POST /appeal` endpoints.
91+
5. The sidecar MUST clean up its port file and any session mapping files on exit.
92+
6. Session IDs used in filenames MUST be validated against `^[a-zA-Z0-9_-]+$` to prevent path traversal.
93+
94+
## DW-REQ-012.11: Multi-Instance Support
95+
96+
1. When the first MCP tool call arrives with a `session_id`, the server MUST write a session mapping file at `.deepwork/tmp/tool_req_sidecar/session_<SESSION_ID>.json`.
97+
2. The hook MUST look for a session-specific mapping file first, then fall back to scanning PID-keyed port files for live processes.
98+
3. Stale port files (PID no longer alive) MUST be cleaned up during discovery.
99+
100+
## DW-REQ-012.12: Sidecar Startup Gating
101+
102+
1. The sidecar MUST NOT start if no `.deepwork/tool_requirements/` directory exists.
103+
2. The sidecar MUST NOT start if the directory contains no `*.yml` files.
104+
3. If sidecar startup fails, the MCP server MUST continue running — the failure MUST be logged as a warning.

doc/specs/deepwork/cli_plugins/PLUG-REQ-001-claude-code-plugin.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,9 @@ The Claude Code plugin is the primary distribution mechanism for DeepWork on the
107107
4. The agent body MUST instruct the subagent to read the instruction file from the user prompt, perform the review against the criteria in that file, and call `mark_review_as_passed` to report results.
108108
5. The agent body MUST instruct the subagent not to edit files and not to explore beyond what the review instructions direct.
109109
6. When the review formatter renders tasks with no per-rule agent persona specified (`agent_name` is `None`), it MUST default to `"reviewer"` as the `subagent_type` (see REVIEW-REQ-006.3.3c).
110+
111+
### PLUG-REQ-001.15: Tool Requirements PreToolUse Hook
112+
113+
1. The plugin MUST register a PreToolUse hook in `plugins/claude/hooks/hooks.json` with an empty matcher (matches all tool calls).
114+
2. The hook MUST delegate to `deepwork hook tool_requirements` via a shell wrapper at `plugins/claude/hooks/tool_requirements.sh`.
115+
3. The hook MUST skip the `appeal_tool_requirement` MCP tool to prevent infinite loops (see DW-REQ-012.9).

plugins/claude/hooks/hooks.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
{
22
"hooks": {
3+
"PreToolUse": [
4+
{
5+
"matcher": "",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/tool_requirements.sh"
10+
}
11+
]
12+
}
13+
],
314
"SessionStart": [
415
{
516
"matcher": "",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env bash
2+
# tool_requirements.sh - PreToolUse hook for tool requirements enforcement
3+
#
4+
# Fires before every tool call. Delegates to the Python hook which contacts
5+
# the MCP sidecar to check policies.
6+
#
7+
# Input (stdin): JSON from Claude Code PreToolUse hook
8+
# Output (stdout): JSON with hookSpecificOutput.permissionDecision
9+
# Exit codes:
10+
# 0 - Always (decision encoded in JSON output)
11+
12+
INPUT=$(cat)
13+
export DEEPWORK_HOOK_PLATFORM="claude"
14+
echo "${INPUT}" | deepwork hook tool_requirements
15+
exit $?

src/deepwork/cli/serve.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ def _serve_mcp(
122122
"# Ignore everything in this directory\n*\n# But keep this .gitignore\n!.gitignore\n"
123123
)
124124

125+
# Start tool requirements sidecar (if policies exist)
126+
_start_tool_requirements_sidecar(project_path)
127+
125128
# Create and run server
126129
from deepwork.jobs.mcp.server import create_server
127130

@@ -135,3 +138,23 @@ def _serve_mcp(
135138
server.run(transport="stdio")
136139
else:
137140
server.run(transport="sse", port=port)
141+
142+
143+
def _start_tool_requirements_sidecar(project_path: Path) -> None:
144+
"""Start the tool requirements sidecar if policy files exist."""
145+
policy_dir = project_path / ".deepwork" / "tool_requirements"
146+
if not policy_dir.is_dir():
147+
return
148+
if not any(policy_dir.glob("*.yml")):
149+
return
150+
151+
try:
152+
from deepwork.tool_requirements.sidecar import start_sidecar
153+
154+
start_sidecar(project_path)
155+
except Exception:
156+
import logging
157+
158+
logging.getLogger("deepwork.tool_requirements").warning(
159+
"Failed to start tool requirements sidecar", exc_info=True
160+
)

src/deepwork/hooks/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ pytest tests/shell_script_tests/test_hook_wrappers.py -v
140140
| `wrapper.py` | Cross-platform input/output normalization |
141141
| `deepschema_write.py` | DeepSchema write-time validation hook |
142142
| `post_commit_reminder.py` | Post-commit hook that nudges the agent to run `/review` (skips if all reviews already passed) |
143+
| `tool_requirements.py` | PreToolUse hook for tool requirements policy enforcement |
143144
| `claude_hook.sh` | Shell wrapper for Claude Code |
144145
| `gemini_hook.sh` | Shell wrapper for Gemini CLI |
145146
| `.deepreview` | Review rule ensuring hooks use correct output routing (DW-REQ-006.6) |

0 commit comments

Comments
 (0)