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 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"]