writ-sdk 0.1.0__py3-none-any.whl
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.
- writ_sdk/__init__.py +61 -0
- writ_sdk/claude_agent_sdk.py +242 -0
- writ_sdk/client.py +509 -0
- writ_sdk/errors.py +63 -0
- writ_sdk/guard.py +485 -0
- writ_sdk/langgraph.py +279 -0
- writ_sdk/openai_agents.py +177 -0
- writ_sdk/py.typed +0 -0
- writ_sdk/toolmap.py +93 -0
- writ_sdk/types.py +248 -0
- writ_sdk-0.1.0.dist-info/METADATA +258 -0
- writ_sdk-0.1.0.dist-info/RECORD +14 -0
- writ_sdk-0.1.0.dist-info/WHEEL +4 -0
- writ_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
writ_sdk/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""writ-sdk: policy-gated tool calls for Python agent frameworks.
|
|
2
|
+
|
|
3
|
+
Every tool call is sent to ``writ check --stdio`` (docs/INTERFACES.md,
|
|
4
|
+
Contract 6), checked against ``writ.yaml`` (allow / deny / ask / redact) and
|
|
5
|
+
recorded to writ's hash-chained ledger before the tool runs. Any failure to
|
|
6
|
+
get a clear "dispatch" answer from writ blocks the call.
|
|
7
|
+
|
|
8
|
+
Framework integrations live in submodules and import their framework only
|
|
9
|
+
when imported: ``writ_sdk.langgraph``, ``writ_sdk.openai_agents``,
|
|
10
|
+
``writ_sdk.claude_agent_sdk``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .client import AsyncWritClient, WritClient, find_writ_binary
|
|
14
|
+
from .errors import (
|
|
15
|
+
WritApprovalRejected,
|
|
16
|
+
WritBlocked,
|
|
17
|
+
WritClosed,
|
|
18
|
+
WritDenied,
|
|
19
|
+
WritError,
|
|
20
|
+
WritGatewayCrashed,
|
|
21
|
+
WritGatewayError,
|
|
22
|
+
WritProtocolError,
|
|
23
|
+
WritTimeout,
|
|
24
|
+
WritUnavailable,
|
|
25
|
+
)
|
|
26
|
+
from .guard import Writ, deny_all, get_default_writ, set_default_writ, to_text, writ_tool
|
|
27
|
+
from .toolmap import MappedTool, map_claude_tool
|
|
28
|
+
from .types import Approval, ApprovalRequest, Caller, Completion, Decision, Server, ToolCall
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Approval",
|
|
34
|
+
"ApprovalRequest",
|
|
35
|
+
"AsyncWritClient",
|
|
36
|
+
"Caller",
|
|
37
|
+
"Completion",
|
|
38
|
+
"Decision",
|
|
39
|
+
"MappedTool",
|
|
40
|
+
"Server",
|
|
41
|
+
"ToolCall",
|
|
42
|
+
"Writ",
|
|
43
|
+
"WritApprovalRejected",
|
|
44
|
+
"WritBlocked",
|
|
45
|
+
"WritClient",
|
|
46
|
+
"WritClosed",
|
|
47
|
+
"WritDenied",
|
|
48
|
+
"WritError",
|
|
49
|
+
"WritGatewayCrashed",
|
|
50
|
+
"WritGatewayError",
|
|
51
|
+
"WritProtocolError",
|
|
52
|
+
"WritTimeout",
|
|
53
|
+
"WritUnavailable",
|
|
54
|
+
"deny_all",
|
|
55
|
+
"find_writ_binary",
|
|
56
|
+
"get_default_writ",
|
|
57
|
+
"map_claude_tool",
|
|
58
|
+
"set_default_writ",
|
|
59
|
+
"to_text",
|
|
60
|
+
"writ_tool",
|
|
61
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Claude Agent SDK integration (package ``claude-agent-sdk``).
|
|
2
|
+
|
|
3
|
+
Integration point: SDK hook callbacks registered through
|
|
4
|
+
``ClaudeAgentOptions(hooks=...)``:
|
|
5
|
+
|
|
6
|
+
* ``PreToolUse`` -> ``decide`` (+ approver + ``resolve`` for deferred asks).
|
|
7
|
+
Answers ``permissionDecision: "allow"`` or ``"deny"`` with writ's reason.
|
|
8
|
+
* ``PostToolUse`` -> ``complete``. For a redact verdict it returns
|
|
9
|
+
``updatedToolOutput`` with the redacted result.
|
|
10
|
+
* ``PostToolUseFailure`` -> ``complete(ok=False)``.
|
|
11
|
+
|
|
12
|
+
Why hooks and not ``can_use_tool``: the CLI consults ``can_use_tool`` only
|
|
13
|
+
when its own permission rules would prompt; tools already allowed by
|
|
14
|
+
``allowed_tools`` / ``permission_mode`` never reach it, and it has no
|
|
15
|
+
post-execution step for ``complete`` or redaction. A ``PreToolUse`` hook
|
|
16
|
+
with no matcher runs for every tool call.
|
|
17
|
+
|
|
18
|
+
Fail closed: the callbacks never raise (an exception in an SDK hook becomes
|
|
19
|
+
an error reply whose handling is up to the CLI). Every failure answers
|
|
20
|
+
``deny`` in ``PreToolUse``; in ``PostToolUse`` a redacted call whose
|
|
21
|
+
redaction cannot be completed has every string in its output masked.
|
|
22
|
+
|
|
23
|
+
Tool names and inputs are mapped with :func:`writ_sdk.toolmap.map_claude_tool`,
|
|
24
|
+
the same table ``writ check --format claude-code`` uses.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
from typing import Any, Mapping
|
|
32
|
+
|
|
33
|
+
from claude_agent_sdk import HookMatcher
|
|
34
|
+
|
|
35
|
+
from .errors import WritError
|
|
36
|
+
from .guard import DEFAULT_APPROVAL_TIMEOUT, Writ, refusal_text, to_text
|
|
37
|
+
from .toolmap import map_claude_tool
|
|
38
|
+
from .types import Caller, Decision
|
|
39
|
+
|
|
40
|
+
try: # pragma: no cover
|
|
41
|
+
from importlib.metadata import version as _pkg_version
|
|
42
|
+
|
|
43
|
+
_SDK_VERSION: str | None = _pkg_version("claude-agent-sdk")
|
|
44
|
+
except Exception: # pragma: no cover
|
|
45
|
+
_SDK_VERSION = None
|
|
46
|
+
|
|
47
|
+
MASK = "[redacted-by-writ]"
|
|
48
|
+
_EXIT_RE = re.compile(r"Exit code (\d+)")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _mask_all(v: Any) -> Any:
|
|
52
|
+
if isinstance(v, str):
|
|
53
|
+
return MASK
|
|
54
|
+
if isinstance(v, list):
|
|
55
|
+
return [_mask_all(x) for x in v]
|
|
56
|
+
if isinstance(v, dict):
|
|
57
|
+
return {k: _mask_all(x) for k, x in v.items()}
|
|
58
|
+
return v
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _same_shape(a: Any, b: Any) -> bool:
|
|
62
|
+
if isinstance(a, dict):
|
|
63
|
+
return isinstance(b, dict) and a.keys() == b.keys() and all(_same_shape(a[k], b[k]) for k in a)
|
|
64
|
+
if isinstance(a, list):
|
|
65
|
+
return isinstance(b, list) and len(a) == len(b) and all(_same_shape(x, y) for x, y in zip(a, b))
|
|
66
|
+
if isinstance(a, str):
|
|
67
|
+
return isinstance(b, str)
|
|
68
|
+
return type(a) is type(b) and a == b
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pre(decision: str, reason: str | None = None) -> dict[str, Any]:
|
|
72
|
+
out: dict[str, Any] = {"hookEventName": "PreToolUse", "permissionDecision": decision}
|
|
73
|
+
if reason:
|
|
74
|
+
out["permissionDecisionReason"] = reason
|
|
75
|
+
return {"hookSpecificOutput": out}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class WritClaudeHooks:
|
|
79
|
+
"""writ hooks for ``ClaudeAgentOptions(hooks=WritClaudeHooks(writ).hooks())``.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
writ: the :class:`~writ_sdk.Writ` gate. Give it an ``approver`` to
|
|
83
|
+
handle ``ask`` verdicts (the client then runs ``--ask defer``);
|
|
84
|
+
without one, asks are rejected.
|
|
85
|
+
session_id: override the SDK's session id.
|
|
86
|
+
mcp_transports: ``{server name: "stdio"|"sse"|"http"|"sdk"}`` for the
|
|
87
|
+
``server.transport`` writ records (default ``"unknown"``).
|
|
88
|
+
allow_decision: what a writ allow answers in ``PreToolUse``:
|
|
89
|
+
``"allow"`` (default, as ``writ check --format claude-code`` does)
|
|
90
|
+
skips the SDK's own permission prompt; ``None`` returns no
|
|
91
|
+
decision so the SDK's permission rules still apply on top.
|
|
92
|
+
hook_timeout: seconds the CLI waits for the ``PreToolUse`` hook.
|
|
93
|
+
Default covers the gateway timeout plus the approval timeout.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
writ: Writ,
|
|
99
|
+
*,
|
|
100
|
+
session_id: str | None = None,
|
|
101
|
+
mcp_transports: Mapping[str, str] | None = None,
|
|
102
|
+
allow_decision: str | None = "allow",
|
|
103
|
+
hook_timeout: float | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
if allow_decision not in ("allow", None):
|
|
106
|
+
raise ValueError("allow_decision must be 'allow' or None")
|
|
107
|
+
self.writ = writ
|
|
108
|
+
self.session_id = session_id
|
|
109
|
+
self.mcp_transports = dict(mcp_transports or {})
|
|
110
|
+
self.allow_decision = allow_decision
|
|
111
|
+
self.hook_timeout = hook_timeout
|
|
112
|
+
self._pending: dict[tuple[str, str], Decision] = {}
|
|
113
|
+
|
|
114
|
+
# -- helpers -----------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _caller(self, inp: Mapping[str, Any]) -> Caller:
|
|
117
|
+
c = self.writ.caller
|
|
118
|
+
if c.agent == "writ-sdk":
|
|
119
|
+
agent_id = inp.get("agent_id")
|
|
120
|
+
return Caller(
|
|
121
|
+
agent="claude-agent-sdk",
|
|
122
|
+
agent_version=_SDK_VERSION,
|
|
123
|
+
user=c.user,
|
|
124
|
+
non_human_id=str(agent_id) if agent_id else c.non_human_id,
|
|
125
|
+
)
|
|
126
|
+
return c
|
|
127
|
+
|
|
128
|
+
def _key(self, inp: Mapping[str, Any], tool_use_id: str | None) -> tuple[str, str]:
|
|
129
|
+
session = self.session_id or str(inp.get("session_id") or self.writ.session_id)
|
|
130
|
+
return session, str(inp.get("tool_use_id") or tool_use_id or "")
|
|
131
|
+
|
|
132
|
+
# -- hooks -------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
async def pre_tool_use(self, input: Any, tool_use_id: str | None, context: Any) -> dict[str, Any]:
|
|
135
|
+
try:
|
|
136
|
+
inp: Mapping[str, Any] = input if isinstance(input, Mapping) else {}
|
|
137
|
+
session, use_id = self._key(inp, tool_use_id)
|
|
138
|
+
name = inp.get("tool_name")
|
|
139
|
+
if not isinstance(name, str) or not name:
|
|
140
|
+
return _pre("deny", "writ: hook input has no tool_name (fail closed)")
|
|
141
|
+
tool_input = inp.get("tool_input")
|
|
142
|
+
mapped = map_claude_tool(
|
|
143
|
+
name, tool_input if isinstance(tool_input, Mapping) else {}, mcp_transports=self.mcp_transports
|
|
144
|
+
)
|
|
145
|
+
call = self.writ.make_call(
|
|
146
|
+
mapped.tool,
|
|
147
|
+
mapped.args,
|
|
148
|
+
session_id=session,
|
|
149
|
+
call_id=use_id or None,
|
|
150
|
+
server=mapped.server,
|
|
151
|
+
caller=self._caller(inp),
|
|
152
|
+
)
|
|
153
|
+
d = await self.writ.aauthorize(call)
|
|
154
|
+
if use_id:
|
|
155
|
+
self._pending[(session, use_id)] = d
|
|
156
|
+
if self.allow_decision is None:
|
|
157
|
+
return {}
|
|
158
|
+
return _pre("allow", f"writ {d.decision}" + (f" (rule '{d.rule_id}')" if d.rule_id else ""))
|
|
159
|
+
except WritError as e:
|
|
160
|
+
return _pre("deny", refusal_text(e))
|
|
161
|
+
except Exception as e: # noqa: BLE001 - any adapter bug must deny, not raise
|
|
162
|
+
return _pre("deny", f"writ adapter error ({type(e).__name__}: {e}); the tool did not run")
|
|
163
|
+
|
|
164
|
+
async def post_tool_use(self, input: Any, tool_use_id: str | None, context: Any) -> dict[str, Any]:
|
|
165
|
+
inp: Mapping[str, Any] = input if isinstance(input, Mapping) else {}
|
|
166
|
+
d = self._pending.pop(self._key(inp, tool_use_id), None)
|
|
167
|
+
if d is None:
|
|
168
|
+
return {} # no decision of ours (e.g. PreToolUse denied elsewhere)
|
|
169
|
+
response = inp.get("tool_response")
|
|
170
|
+
try:
|
|
171
|
+
redacted = await self.writ.arecord(d, ok=True, output=to_text(response))
|
|
172
|
+
except WritError as e:
|
|
173
|
+
if not d.is_redact:
|
|
174
|
+
return {"systemMessage": f"writ could not record this tool execution: {e}"}
|
|
175
|
+
redacted = None
|
|
176
|
+
except Exception: # noqa: BLE001
|
|
177
|
+
if not d.is_redact:
|
|
178
|
+
return {}
|
|
179
|
+
redacted = None
|
|
180
|
+
if not d.is_redact:
|
|
181
|
+
return {}
|
|
182
|
+
return {
|
|
183
|
+
"hookSpecificOutput": {
|
|
184
|
+
"hookEventName": "PostToolUse",
|
|
185
|
+
"updatedToolOutput": self._redacted_output(response, redacted),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _redacted_output(original: Any, redacted: str | None) -> Any:
|
|
191
|
+
"""Shape-preserving redacted tool_response; masks everything on doubt."""
|
|
192
|
+
if redacted is None:
|
|
193
|
+
return _mask_all(original)
|
|
194
|
+
if isinstance(original, str):
|
|
195
|
+
return redacted
|
|
196
|
+
try:
|
|
197
|
+
parsed = json.loads(redacted)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return _mask_all(original)
|
|
200
|
+
# Masking may only change string contents. Anything else means a
|
|
201
|
+
# pattern hit JSON syntax, and the result is not trusted.
|
|
202
|
+
return parsed if _same_shape(original, parsed) else _mask_all(original)
|
|
203
|
+
|
|
204
|
+
async def post_tool_use_failure(self, input: Any, tool_use_id: str | None, context: Any) -> dict[str, Any]:
|
|
205
|
+
inp: Mapping[str, Any] = input if isinstance(input, Mapping) else {}
|
|
206
|
+
d = self._pending.pop(self._key(inp, tool_use_id), None)
|
|
207
|
+
if d is None:
|
|
208
|
+
return {}
|
|
209
|
+
err = str(inp.get("error") or "")
|
|
210
|
+
m = _EXIT_RE.search(err)
|
|
211
|
+
try:
|
|
212
|
+
await self.writ.arecord(d, ok=False, output=err, exit=int(m.group(1)) if m else 1)
|
|
213
|
+
except Exception: # noqa: BLE001 - nothing to withhold; the tool already failed
|
|
214
|
+
pass
|
|
215
|
+
return {}
|
|
216
|
+
|
|
217
|
+
# -- wiring ------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def _pre_timeout(self) -> float:
|
|
220
|
+
if self.hook_timeout is not None:
|
|
221
|
+
return self.hook_timeout
|
|
222
|
+
approval = self.writ.approval_timeout or DEFAULT_APPROVAL_TIMEOUT
|
|
223
|
+
return 2 * self.writ.client.timeout + (approval if self.writ.client.ask == "defer" else 0) + 5
|
|
224
|
+
|
|
225
|
+
def hooks(self) -> dict[str, list[HookMatcher]]:
|
|
226
|
+
"""The ``hooks=`` mapping for ``ClaudeAgentOptions``; merge with your own if needed."""
|
|
227
|
+
post_timeout = self.writ.client.timeout + 5
|
|
228
|
+
return {
|
|
229
|
+
"PreToolUse": [HookMatcher(matcher=None, hooks=[self.pre_tool_use], timeout=self._pre_timeout())],
|
|
230
|
+
"PostToolUse": [HookMatcher(matcher=None, hooks=[self.post_tool_use], timeout=post_timeout)],
|
|
231
|
+
"PostToolUseFailure": [
|
|
232
|
+
HookMatcher(matcher=None, hooks=[self.post_tool_use_failure], timeout=post_timeout)
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def writ_hooks(writ: Writ, **kwargs: Any) -> dict[str, list[HookMatcher]]:
|
|
238
|
+
"""Shorthand: ``ClaudeAgentOptions(hooks=writ_hooks(writ))``."""
|
|
239
|
+
return WritClaudeHooks(writ, **kwargs).hooks()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
__all__ = ["WritClaudeHooks", "writ_hooks"]
|