Skip to content

Commit 9416577

Browse files
nhortonclaude
andcommitted
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>
1 parent 589f6b8 commit 9416577

22 files changed

Lines changed: 2199 additions & 0 deletions

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+
)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""PreToolUse hook for tool requirements policy enforcement.
2+
3+
Fires before every tool call. Contacts the MCP sidecar server to check
4+
whether the call complies with policies defined in
5+
.deepwork/tool_requirements/*.yml.
6+
7+
Fail-closed: if the sidecar is unreachable, the hook denies the call
8+
with a message to restart the MCP server.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import http.client
14+
import json
15+
import os
16+
import sys
17+
from pathlib import Path
18+
from typing import Any
19+
20+
from deepwork.hooks.wrapper import (
21+
HookInput,
22+
HookOutput,
23+
NormalizedEvent,
24+
Platform,
25+
output_hook_error,
26+
run_hook,
27+
)
28+
from deepwork.tool_requirements.sidecar import discover_sidecar
29+
30+
# Tool name substrings to skip (loop prevention)
31+
_SKIP_TOOLS = ("appeal_tool_requirement",)
32+
33+
34+
def tool_requirements_hook(hook_input: HookInput) -> HookOutput:
35+
"""Pre-tool hook: check tool call against requirement policies."""
36+
if hook_input.event != NormalizedEvent.BEFORE_TOOL:
37+
return HookOutput()
38+
39+
# Loop prevention: skip the appeal MCP tool itself
40+
raw_tool = hook_input.raw_input.get("tool_name", "")
41+
for skip in _SKIP_TOOLS:
42+
if skip in raw_tool:
43+
return HookOutput()
44+
45+
cwd = hook_input.cwd or os.getcwd()
46+
session_id = hook_input.session_id or ""
47+
48+
# Discover sidecar
49+
sidecar = discover_sidecar(Path(cwd), session_id)
50+
if sidecar is None:
51+
return _deny(
52+
"DeepWork Tool Requirements: MCP server is not running. "
53+
"The tool_requirements system requires the MCP server to be active. "
54+
"Please restart the MCP server."
55+
)
56+
57+
# Send check request to sidecar
58+
try:
59+
response = _http_post(sidecar["port"], "/check", {
60+
"tool_name": hook_input.tool_name,
61+
"tool_input": hook_input.tool_input,
62+
"raw_tool_name": raw_tool,
63+
"session_id": session_id,
64+
})
65+
except Exception as e:
66+
return _deny(
67+
f"DeepWork Tool Requirements: Failed to reach MCP server sidecar: {e}. "
68+
"Please restart the MCP server."
69+
)
70+
71+
if response.get("decision") == "allow":
72+
return HookOutput()
73+
74+
if response.get("decision") == "deny":
75+
reason = response.get("reason", "Tool call blocked by policy")
76+
return _deny(reason)
77+
78+
# Unexpected response — allow (fail-open only for malformed responses
79+
# from an actually-running sidecar, not for missing sidecars)
80+
return HookOutput()
81+
82+
83+
def _deny(reason: str) -> HookOutput:
84+
"""Create a deny output for PreToolUse with proper Claude Code format."""
85+
return HookOutput(
86+
raw_output={
87+
"hookSpecificOutput": {
88+
"hookEventName": "PreToolUse",
89+
"permissionDecision": "deny",
90+
"permissionDecisionReason": reason,
91+
}
92+
}
93+
)
94+
95+
96+
def _http_post(port: int, path: str, body: dict[str, Any]) -> dict[str, Any]:
97+
"""Send an HTTP POST to the sidecar on localhost."""
98+
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=30)
99+
try:
100+
payload = json.dumps(body).encode("utf-8")
101+
conn.request(
102+
"POST",
103+
path,
104+
body=payload,
105+
headers={"Content-Type": "application/json"},
106+
)
107+
response = conn.getresponse()
108+
data = response.read()
109+
result: dict[str, Any] = json.loads(data)
110+
return result
111+
finally:
112+
conn.close()
113+
114+
115+
def main() -> int:
116+
"""Entry point for the hook CLI."""
117+
platform = Platform(os.environ.get("DEEPWORK_HOOK_PLATFORM", "claude"))
118+
return run_hook(tool_requirements_hook, platform)
119+
120+
121+
if __name__ == "__main__":
122+
try:
123+
sys.exit(main())
124+
except Exception as e:
125+
output_hook_error(e, context="tool_requirements hook")
126+
sys.exit(0)

src/deepwork/jobs/mcp/server.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,21 @@ def _log_tool_call(
149149
log_data["params"] = params
150150
logger.info("MCP tool call: %s", log_data)
151151

152+
# Track whether session has been registered for tool requirements sidecar
153+
_registered_sessions: set[str] = set()
154+
155+
def _maybe_register_session(session_id: str | None) -> None:
156+
"""Register session with the tool requirements sidecar on first tool call."""
157+
if not session_id or session_id in _registered_sessions:
158+
return
159+
_registered_sessions.add(session_id)
160+
try:
161+
from deepwork.tool_requirements.sidecar import register_session
162+
163+
register_session(project_path, session_id)
164+
except Exception:
165+
pass # Best-effort — sidecar may not be running
166+
152167
@mcp.tool(
153168
description=(
154169
"List all available DeepWork workflows. "
@@ -186,6 +201,7 @@ async def start_workflow(
186201
agent_id: str | None = None,
187202
) -> dict[str, Any]:
188203
"""Start a workflow and get first step instructions."""
204+
_maybe_register_session(session_id)
189205
_log_tool_call(
190206
"start_workflow",
191207
{
@@ -505,6 +521,84 @@ async def mark_review_as_passed(review_id: str, ctx: Context) -> str:
505521
except ValueError as e:
506522
return f"Validation error: {e}"
507523

524+
# ---- Tool Requirements: appeal tool ----
525+
526+
@mcp.tool(
527+
description=(
528+
"Appeal a tool requirement policy denial. When a tool call is blocked "
529+
"by a tool requirement policy, call this to appeal specific failed "
530+
"checks by providing justifications. "
531+
"Required: tool_name (the normalized tool name that was blocked), "
532+
"tool_input (the exact tool_input that was blocked), "
533+
"policy_justification (dict mapping each failed check name to a "
534+
"justification string explaining why the check should pass). "
535+
"Optional: session_id (CLAUDE_CODE_SESSION_ID). "
536+
"Some checks are marked no_exception and cannot be appealed. "
537+
"If the appeal succeeds, the tool call is cached as approved and "
538+
"you can retry the original tool call."
539+
)
540+
)
541+
async def appeal_tool_requirement(
542+
tool_name: str,
543+
tool_input: dict[str, Any],
544+
policy_justification: dict[str, str],
545+
ctx: Context,
546+
session_id: str | None = None,
547+
) -> dict[str, Any]:
548+
"""Appeal a tool requirement denial with justifications."""
549+
_log_tool_call(
550+
"appeal_tool_requirement",
551+
{
552+
"tool_name": tool_name,
553+
"justification_keys": list(policy_justification.keys()),
554+
},
555+
session_id=session_id,
556+
)
557+
_maybe_register_session(session_id)
558+
559+
root = await root_resolver.get_root(ctx)
560+
561+
# Delegate to sidecar (same process) or engine directly
562+
try:
563+
from deepwork.tool_requirements.sidecar import discover_sidecar
564+
565+
sidecar = discover_sidecar(root, session_id or "")
566+
if sidecar is None:
567+
return {
568+
"passed": False,
569+
"reason": "Tool requirements sidecar is not running. "
570+
"Please restart the MCP server.",
571+
}
572+
573+
import http.client
574+
import json as json_mod
575+
576+
conn = http.client.HTTPConnection(
577+
"127.0.0.1", sidecar["port"], timeout=60
578+
)
579+
try:
580+
payload = json_mod.dumps({
581+
"tool_name": tool_name,
582+
"tool_input": tool_input,
583+
"policy_justification": policy_justification,
584+
}).encode("utf-8")
585+
conn.request(
586+
"POST",
587+
"/appeal",
588+
body=payload,
589+
headers={"Content-Type": "application/json"},
590+
)
591+
response = conn.getresponse()
592+
return json_mod.loads(response.read())
593+
finally:
594+
conn.close()
595+
except Exception as e:
596+
logger.exception("Error in appeal_tool_requirement")
597+
return {
598+
"passed": False,
599+
"reason": f"Appeal failed: {e}",
600+
}
601+
508602
return mcp
509603

510604

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Tool Requirements Policy",
4+
"description": "Schema for .deepwork/tool_requirements/*.yml policy files that define RFC 2119-style rules for AI agent tool calls.",
5+
"type": "object",
6+
"required": ["tools", "requirements"],
7+
"additionalProperties": false,
8+
"properties": {
9+
"summary": {
10+
"type": "string",
11+
"description": "Human-readable summary of what this policy enforces."
12+
},
13+
"tools": {
14+
"type": "array",
15+
"description": "Normalized tool names (shell, write_file, edit_file, etc.) or MCP tool names (mcp__server__tool) this policy applies to.",
16+
"items": {
17+
"type": "string"
18+
},
19+
"minItems": 1
20+
},
21+
"match": {
22+
"type": "object",
23+
"description": "Optional parameter-level filtering. Keys are tool_input parameter names, values are regex patterns. Policy only applies when at least one pattern matches.",
24+
"patternProperties": {
25+
"^[a-zA-Z0-9_-]+$": {
26+
"type": "string"
27+
}
28+
},
29+
"additionalProperties": false
30+
},
31+
"extends": {
32+
"type": "array",
33+
"description": "List of policy file stems to inherit requirements from.",
34+
"items": {
35+
"type": "string"
36+
}
37+
},
38+
"requirements": {
39+
"type": "object",
40+
"description": "RFC 2119 keyed requirements. Keys are requirement identifiers, values define the rule and exception policy.",
41+
"patternProperties": {
42+
"^[a-zA-Z0-9_-]+$": {
43+
"type": "object",
44+
"required": ["rule"],
45+
"additionalProperties": false,
46+
"properties": {
47+
"rule": {
48+
"type": "string",
49+
"description": "RFC 2119 statement (using MUST, SHOULD, MAY, etc.)."
50+
},
51+
"no_exception": {
52+
"type": "boolean",
53+
"description": "If true, this requirement cannot be appealed. Defaults to false.",
54+
"default": false
55+
}
56+
}
57+
}
58+
},
59+
"additionalProperties": false
60+
}
61+
}
62+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tool Requirements — policy enforcement for AI agent tool calls."""

0 commit comments

Comments
 (0)