openscript-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.
- openscript_sdk/__init__.py +217 -0
- openscript_sdk/contracts/__init__.py +82 -0
- openscript_sdk/contracts/interceptor.py +98 -0
- openscript_sdk/contracts/server_types.py +87 -0
- openscript_sdk/contracts/session_graph.py +123 -0
- openscript_sdk/contracts/types.py +76 -0
- openscript_sdk/events/__init__.py +0 -0
- openscript_sdk/events/approvals.py +309 -0
- openscript_sdk/events/models.py +26 -0
- openscript_sdk/events/store.py +79 -0
- openscript_sdk/events/writer.py +100 -0
- openscript_sdk/hooks/claude_code.py +479 -0
- openscript_sdk/hooks/claude_code_example.yaml +62 -0
- openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
- openscript_sdk/hooks/config.py +144 -0
- openscript_sdk/hooks/settings_example.json +28 -0
- openscript_sdk/hooks/settings_strict_example.json +28 -0
- openscript_sdk/integrations/langchain.py +32 -0
- openscript_sdk/integrations/langgraph.py +32 -0
- openscript_sdk/interceptors/base.py +15 -0
- openscript_sdk/interceptors/event_writer.py +131 -0
- openscript_sdk/interceptors/pii.py +168 -0
- openscript_sdk/interceptors/threat.py +674 -0
- openscript_sdk/logging.py +94 -0
- openscript_sdk/middleware/middleware.py +543 -0
- openscript_sdk/middleware/tool.py +247 -0
- openscript_sdk/observability/__init__.py +11 -0
- openscript_sdk/observability/metrics.py +105 -0
- openscript_sdk/observability/risk.py +54 -0
- openscript_sdk/observability/tracing.py +67 -0
- openscript_sdk/policies/compliance.py +261 -0
- openscript_sdk/policies/config.py +244 -0
- openscript_sdk/policies/harmful_request.py +617 -0
- openscript_sdk/policies/normalize.py +489 -0
- openscript_sdk/policies/output_schema.py +353 -0
- openscript_sdk/policies/policies_example.yaml +36 -0
- openscript_sdk/policies/redaction.py +359 -0
- openscript_sdk/policies/secrets.py +239 -0
- openscript_sdk/policies/semantic.py +348 -0
- openscript_sdk/policies/semantic_head.py +9 -0
- openscript_sdk/policies/session_taint.py +399 -0
- openscript_sdk/policies/taint_store.py +111 -0
- openscript_sdk/policies/tool_firewall.py +434 -0
- openscript_sdk/policies/tools_example.yaml +81 -0
- openscript_sdk/policies/toxicity.py +247 -0
- openscript_sdk/py.typed +0 -0
- openscript_sdk/server/__init__.py +1 -0
- openscript_sdk/server/app.py +153 -0
- openscript_sdk/server/auth.py +19 -0
- openscript_sdk/server/guard.py +126 -0
- openscript_sdk/server/guard_example.yaml +55 -0
- openscript_sdk/server/routers/__init__.py +0 -0
- openscript_sdk/server/routers/approvals.py +69 -0
- openscript_sdk/server/routers/guard.py +231 -0
- openscript_sdk/server/routers/sessions.py +80 -0
- openscript_sdk/server/routers/sse.py +69 -0
- openscript_sdk/server/routers/threat.py +119 -0
- openscript_sdk/server/static/dashboard/index.html +209 -0
- openscript_sdk/server/static/dashboard/session.html +339 -0
- openscript_sdk-0.1.0.dist-info/METADATA +225 -0
- openscript_sdk-0.1.0.dist-info/RECORD +65 -0
- openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
- openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
- openscript_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""openscript-hook: OpenScript's tool policies as a Claude Code hook.
|
|
2
|
+
|
|
3
|
+
Claude Code's own permissions say which tools may run. This adds what they do
|
|
4
|
+
not: rules on a call's arguments (ToolFirewallPolicy), a memory of what the
|
|
5
|
+
session has read (SessionTaintPolicy), secrets masked in a tool's output
|
|
6
|
+
before the model reads it, and a record of every decision.
|
|
7
|
+
|
|
8
|
+
openscript-hook pre-tool-use # PreToolUse: deny, ask, or say nothing
|
|
9
|
+
openscript-hook post-tool-use # PostToolUse: note a read, redact the output
|
|
10
|
+
openscript-hook check # is the config valid, and what will it do
|
|
11
|
+
openscript-hook prune [--days N] # forget sessions not seen for N days
|
|
12
|
+
openscript-hook reset SESSION_ID # forget what one session has read
|
|
13
|
+
|
|
14
|
+
It only ever tightens. On a call it answers "deny" (exit 2, the reason on
|
|
15
|
+
stderr, which Claude sees) or "ask" (Claude Code shows its own prompt, with
|
|
16
|
+
OpenScript's reason), or nothing, and Claude Code's permissions decide as they
|
|
17
|
+
always would. It never answers "allow": that would skip Claude Code's prompt
|
|
18
|
+
and override the user's own settings.
|
|
19
|
+
|
|
20
|
+
It fails closed. Anything that goes wrong while deciding on a call, a missing
|
|
21
|
+
config included, blocks the call with the reason. Run it as
|
|
22
|
+
``openscript-hook pre-tool-use || exit 2`` so that a hook that cannot start
|
|
23
|
+
blocks too, until Claude Code's ``onFailure: "block"`` is available.
|
|
24
|
+
|
|
25
|
+
The config is ``--config``, else $OPENSCRIPT_HOOK_CONFIG, else
|
|
26
|
+
$CLAUDE_PROJECT_DIR/.claude/openscript.yaml. State and the audit log live in
|
|
27
|
+
$OPENSCRIPT_STATE_DIR (default ~/.local/state/openscript/claude-code).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
from datetime import UTC, datetime
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import IO, Any
|
|
40
|
+
|
|
41
|
+
#: Say which product spoke, in Claude Code's prompt and in Claude's context.
|
|
42
|
+
PREFIX = "OpenScript: "
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv: list[str] | None = None) -> int:
|
|
46
|
+
# stdout carries the decision and nothing else: Claude Code ignores a
|
|
47
|
+
# decision with anything printed around it. Keep the real stdout for the
|
|
48
|
+
# decision and point fd 1 at stderr, so a library that prints (structlog
|
|
49
|
+
# does, until configured) cannot spoil it.
|
|
50
|
+
sys.stdout.flush()
|
|
51
|
+
decision = os.fdopen(os.dup(1), "w", encoding="utf-8")
|
|
52
|
+
os.dup2(2, 1)
|
|
53
|
+
# pydantic looks for plugins among every installed package the first time
|
|
54
|
+
# a model is built. This process wants none, and Claude Code waits for it.
|
|
55
|
+
os.environ.setdefault("PYDANTIC_DISABLE_PLUGINS", "__all__")
|
|
56
|
+
|
|
57
|
+
parser = argparse.ArgumentParser(prog="openscript-hook", description=__doc__.split("\n")[0])
|
|
58
|
+
parser.add_argument("--config", type=Path, help="the YAML config (see the module docstring)")
|
|
59
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
60
|
+
commands.add_parser("pre-tool-use", help="decide on a call (PreToolUse)")
|
|
61
|
+
commands.add_parser("post-tool-use", help="note a read, redact output (PostToolUse)")
|
|
62
|
+
commands.add_parser("check", help="validate the config and say what it will do")
|
|
63
|
+
prune_parser = commands.add_parser("prune", help="forget sessions not seen for a while")
|
|
64
|
+
prune_parser.add_argument("--days", type=float, default=7.0)
|
|
65
|
+
reset_parser = commands.add_parser("reset", help="forget what one session has read")
|
|
66
|
+
reset_parser.add_argument("session_id")
|
|
67
|
+
args = parser.parse_args(argv)
|
|
68
|
+
|
|
69
|
+
if args.command == "pre-tool-use":
|
|
70
|
+
try:
|
|
71
|
+
return _pre_tool_use(_payload(), args.config, decision)
|
|
72
|
+
except Exception as error: # fail closed: no verdict is a refusal
|
|
73
|
+
return _block(f"the hook could not decide, so the call is blocked: {error}")
|
|
74
|
+
if args.command == "post-tool-use":
|
|
75
|
+
try:
|
|
76
|
+
return _post_tool_use(_payload(), args.config, decision)
|
|
77
|
+
except Exception as error: # the tool has run; say so and let it be
|
|
78
|
+
sys.stderr.write(f"{PREFIX}the hook could not check this output: {error}\n")
|
|
79
|
+
return 1
|
|
80
|
+
if args.command == "check":
|
|
81
|
+
return _check(args.config)
|
|
82
|
+
if args.command == "prune":
|
|
83
|
+
from openscript_sdk.policies.taint_store import prune
|
|
84
|
+
|
|
85
|
+
removed = prune(_state() / "sessions", older_than=args.days * 86400)
|
|
86
|
+
sys.stderr.write(f"removed {removed} session(s) not seen for {args.days:g} days\n")
|
|
87
|
+
return 0
|
|
88
|
+
from openscript_sdk.policies.taint_store import FileTaintStore
|
|
89
|
+
|
|
90
|
+
FileTaintStore(_state() / "sessions").clear(args.session_id)
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# -- the two hooks ------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _pre_tool_use(payload: dict[str, Any], config_path: Path | None, out: IO[str]) -> int:
|
|
98
|
+
name, args, session_id = _call(payload)
|
|
99
|
+
path = _config_path(config_path, payload)
|
|
100
|
+
guarded = _guarded_target(name, args, payload, path)
|
|
101
|
+
if guarded is not None:
|
|
102
|
+
# Before the config is read: a hook whose inputs the agent can rewrite
|
|
103
|
+
# guards nothing (docs/red-team.md, B5).
|
|
104
|
+
return _block(
|
|
105
|
+
f"{name} would change {guarded}, which decides what this hook does. "
|
|
106
|
+
"Change it yourself, outside the agent"
|
|
107
|
+
)
|
|
108
|
+
config = _settings(path)
|
|
109
|
+
asks: list[tuple[str, str]] = []
|
|
110
|
+
|
|
111
|
+
if config["rules"] is not None:
|
|
112
|
+
from openscript_sdk.policies.tool_firewall import ToolRules, validate_tool_call
|
|
113
|
+
|
|
114
|
+
rules = ToolRules.from_validated(config["rules"]) # validated when cached
|
|
115
|
+
verdict = validate_tool_call({"name": name, "args": args}, role=config["role"], rules=rules)
|
|
116
|
+
if not verdict["allowed"]:
|
|
117
|
+
_audit(config, payload, "deny", "tool_firewall", verdict["reason"])
|
|
118
|
+
return _block(f"the tool rules refuse this call: {verdict['reason']}")
|
|
119
|
+
if verdict["requires_approval"]:
|
|
120
|
+
asks.append(("tool_firewall", verdict["reason"]))
|
|
121
|
+
|
|
122
|
+
tainted_by: list[str] = []
|
|
123
|
+
if config["session"] is not None:
|
|
124
|
+
session = _session_policy(config)
|
|
125
|
+
entry = session.check(session_id, name, args)
|
|
126
|
+
tainted_by = entry["tainted_by"]
|
|
127
|
+
if not entry["allowed"]:
|
|
128
|
+
_audit(config, payload, "deny", "session_taint", entry["reason"], tainted_by)
|
|
129
|
+
return _block(entry["reason"])
|
|
130
|
+
if entry["requires_approval"]:
|
|
131
|
+
asks.append(("session_taint", entry["reason"]))
|
|
132
|
+
# Taint when a source is attempted rather than when it returns: a
|
|
133
|
+
# PostToolUse hook that never runs must not leave the session clean.
|
|
134
|
+
# The cost is a declined read that still taints.
|
|
135
|
+
session.record(session_id, name)
|
|
136
|
+
|
|
137
|
+
if not asks:
|
|
138
|
+
_audit(config, payload, "none", "", "no policy objects", tainted_by)
|
|
139
|
+
return 0
|
|
140
|
+
reason = "; ".join(text for _, text in asks)
|
|
141
|
+
_audit(config, payload, "ask", "+".join(p for p, _ in asks), reason, tainted_by)
|
|
142
|
+
_ask(out, reason)
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _post_tool_use(payload: dict[str, Any], config_path: Path | None, out: IO[str]) -> int:
|
|
147
|
+
name, _args, session_id = _call(payload)
|
|
148
|
+
config = _settings(_config_path(config_path, payload))
|
|
149
|
+
if config["session"] is not None:
|
|
150
|
+
_session_policy(config).record(session_id, name) # again, in case PreToolUse never ran
|
|
151
|
+
|
|
152
|
+
if not _redacts(config["redact"], name) or "tool_response" not in payload:
|
|
153
|
+
return 0
|
|
154
|
+
redacted, labels = _redact_output(name, payload["tool_response"], config)
|
|
155
|
+
if not labels:
|
|
156
|
+
return 0
|
|
157
|
+
_audit(config, payload, "redacted", "redaction", f"masked {', '.join(labels)}", labels=labels)
|
|
158
|
+
out.write(
|
|
159
|
+
json.dumps(
|
|
160
|
+
{
|
|
161
|
+
"hookSpecificOutput": {
|
|
162
|
+
"hookEventName": "PostToolUse",
|
|
163
|
+
"updatedToolOutput": redacted,
|
|
164
|
+
# Without this the model takes the stars for the real
|
|
165
|
+
# content, and tells the user the file holds asterisks.
|
|
166
|
+
"additionalContext": (
|
|
167
|
+
f"{PREFIX}secrets in this tool output ({', '.join(labels)}) were masked "
|
|
168
|
+
"before you saw it. The original is unchanged; do not suggest that the "
|
|
169
|
+
"masked text is what the source contains."
|
|
170
|
+
),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
out.flush()
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# -- pieces -------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _payload() -> dict[str, Any]:
|
|
183
|
+
payload = json.loads(sys.stdin.read())
|
|
184
|
+
if not isinstance(payload, dict):
|
|
185
|
+
raise ValueError("the hook input is not a JSON object")
|
|
186
|
+
return payload
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _call(payload: dict[str, Any]) -> tuple[str, dict[str, Any], str]:
|
|
190
|
+
name, args, session_id = (
|
|
191
|
+
payload.get("tool_name"),
|
|
192
|
+
payload.get("tool_input"),
|
|
193
|
+
payload.get("session_id"),
|
|
194
|
+
)
|
|
195
|
+
if not isinstance(name, str) or not name:
|
|
196
|
+
raise ValueError("the hook input names no tool")
|
|
197
|
+
if not isinstance(session_id, str) or not session_id:
|
|
198
|
+
raise ValueError("the hook input has no session id")
|
|
199
|
+
return name, args if isinstance(args, dict) else {}, session_id
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _config_path(given: Path | None, payload: dict[str, Any]) -> Path:
|
|
203
|
+
if given is not None:
|
|
204
|
+
return given
|
|
205
|
+
if os.environ.get("OPENSCRIPT_HOOK_CONFIG"):
|
|
206
|
+
return Path(os.environ["OPENSCRIPT_HOOK_CONFIG"])
|
|
207
|
+
project = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
|
208
|
+
return Path(project) / ".claude" / "openscript.yaml"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _state() -> Path:
|
|
212
|
+
from openscript_sdk.policies.taint_store import state_dir
|
|
213
|
+
|
|
214
|
+
return state_dir("claude-code")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _ConfigMissing(Exception):
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
#: The shape of a cached config. An entry of another shape, or another format
|
|
222
|
+
#: version, is validated again rather than trusted.
|
|
223
|
+
_CACHE_FORMAT = 3
|
|
224
|
+
_SETTINGS_KEYS = frozenset({"role", "rules", "session", "redact", "audit"})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _settings(path: Path) -> dict[str, Any]:
|
|
228
|
+
"""The validated config, as plain data.
|
|
229
|
+
|
|
230
|
+
Validating the YAML costs pydantic, which costs more than the rest of a
|
|
231
|
+
call. So the first call for a given config validates it in full
|
|
232
|
+
(openscript_sdk.hooks.config) and keeps the result in the state directory, keyed by
|
|
233
|
+
the file's bytes and those of any rules file it names; later calls read
|
|
234
|
+
that and import neither pydantic nor YAML. A changed file is a new key,
|
|
235
|
+
and a cache entry that cannot be read is simply validated again.
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
raw = path.read_bytes()
|
|
239
|
+
except FileNotFoundError:
|
|
240
|
+
raise _ConfigMissing(f"no OpenScript config at {path}") from None
|
|
241
|
+
key = hashlib.sha256(str(path.resolve()).encode("utf-8") + b"\0" + raw).hexdigest()
|
|
242
|
+
cache = _state() / "config-cache" / f"{key}.json"
|
|
243
|
+
try:
|
|
244
|
+
entry = json.loads(cache.read_text(encoding="utf-8"))
|
|
245
|
+
settings: dict[str, Any] = entry["settings"]
|
|
246
|
+
if (
|
|
247
|
+
entry["format"] == _CACHE_FORMAT
|
|
248
|
+
and set(settings) == _SETTINGS_KEYS
|
|
249
|
+
and isinstance(settings["redact"], dict)
|
|
250
|
+
and isinstance(settings["audit"], dict)
|
|
251
|
+
and all(_digest(Path(dep)) == digest for dep, digest in entry["depends_on"].items())
|
|
252
|
+
):
|
|
253
|
+
return settings
|
|
254
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
from openscript_sdk.hooks.config import load_config
|
|
258
|
+
|
|
259
|
+
config, rules = load_config(path)
|
|
260
|
+
settings = {
|
|
261
|
+
"role": config.role,
|
|
262
|
+
"rules": rules.model_dump(mode="json") if rules is not None else None,
|
|
263
|
+
"session": config.session.model_dump(mode="json") if config.session else None,
|
|
264
|
+
"redact": config.redact.model_dump(mode="json"),
|
|
265
|
+
"audit": config.audit.model_dump(mode="json"),
|
|
266
|
+
}
|
|
267
|
+
depends_on = {}
|
|
268
|
+
if config.firewall is not None and config.firewall.rules_path:
|
|
269
|
+
rules_file = (path.parent / config.firewall.rules_path).resolve()
|
|
270
|
+
depends_on[str(rules_file)] = _digest(rules_file)
|
|
271
|
+
cache.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
272
|
+
partial = cache.with_suffix(f".{os.getpid()}.tmp")
|
|
273
|
+
partial.write_text(
|
|
274
|
+
json.dumps({"format": _CACHE_FORMAT, "settings": settings, "depends_on": depends_on})
|
|
275
|
+
)
|
|
276
|
+
os.replace(partial, cache) # whole or not at all, for a hook running alongside
|
|
277
|
+
return settings
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
#: The built-in tools that write a file, and the argument that names it.
|
|
281
|
+
_WRITERS = {
|
|
282
|
+
"Write": "file_path",
|
|
283
|
+
"Edit": "file_path",
|
|
284
|
+
"MultiEdit": "file_path",
|
|
285
|
+
"NotebookEdit": "notebook_path",
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _guarded_target(
|
|
290
|
+
name: str, args: dict[str, Any], payload: dict[str, Any], config: Path
|
|
291
|
+
) -> str | None:
|
|
292
|
+
"""The file this call would write that decides what the hook does, if any.
|
|
293
|
+
|
|
294
|
+
That is the config, a firewall rules file it names, Claude Code's settings
|
|
295
|
+
files beside it, in the project and in the home directory, and the
|
|
296
|
+
hook's state: what sessions have read, the config cache, the audit log.
|
|
297
|
+
Paths are resolved, so a symlink or ".." does not get round it. Bash is
|
|
298
|
+
not looked at: a shell command's targets cannot be read off its text.
|
|
299
|
+
"""
|
|
300
|
+
key = _WRITERS.get(name)
|
|
301
|
+
target = args.get(key) if key else None
|
|
302
|
+
if not isinstance(target, str) or not target:
|
|
303
|
+
return None
|
|
304
|
+
resolved = (Path(payload.get("cwd") or os.getcwd()) / target).resolve()
|
|
305
|
+
config = config.resolve()
|
|
306
|
+
guarded = [config, _state().resolve()]
|
|
307
|
+
project = Path(os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd())
|
|
308
|
+
for directory in {config.parent, project / ".claude", Path.home() / ".claude"}:
|
|
309
|
+
guarded += [directory / "settings.json", directory / "settings.local.json"]
|
|
310
|
+
try:
|
|
311
|
+
import yaml
|
|
312
|
+
|
|
313
|
+
raw = yaml.safe_load(config.read_text(encoding="utf-8"))
|
|
314
|
+
rules_path = raw.get("firewall", {}).get("rules_path") if isinstance(raw, dict) else None
|
|
315
|
+
if isinstance(rules_path, str) and rules_path:
|
|
316
|
+
guarded.append(config.parent / rules_path)
|
|
317
|
+
except (OSError, ValueError, AttributeError):
|
|
318
|
+
pass # no config to read: every call is blocked further on anyway
|
|
319
|
+
for path in guarded:
|
|
320
|
+
path = path.resolve()
|
|
321
|
+
if resolved == path or path in resolved.parents:
|
|
322
|
+
return str(path)
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _digest(path: Path) -> str:
|
|
327
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _redacts(redact: dict[str, Any], name: str) -> bool:
|
|
331
|
+
return bool(redact["secrets"] or redact["pii"] or redact["internal_urls"]) and any(
|
|
332
|
+
name == tool or (tool.endswith("*") and name.startswith(tool[:-1]))
|
|
333
|
+
for tool in redact["tools"]
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _session_policy(config: dict[str, Any]) -> Any:
|
|
338
|
+
from openscript_sdk.policies.session_taint import SessionTaintPolicy
|
|
339
|
+
from openscript_sdk.policies.taint_store import FileTaintStore
|
|
340
|
+
|
|
341
|
+
session = config["session"]
|
|
342
|
+
return SessionTaintPolicy(
|
|
343
|
+
sources=session["sources"],
|
|
344
|
+
sinks=session["sinks"],
|
|
345
|
+
on_taint=session["on_taint"],
|
|
346
|
+
store=FileTaintStore(_state() / "sessions"),
|
|
347
|
+
known=session.get("known"),
|
|
348
|
+
updates=session.get("updates"),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _redact_output(name: str, response: Any, config: dict[str, Any]) -> tuple[Any, list[str]]:
|
|
353
|
+
"""The tool's output with secrets masked, in the shape the tool gave it.
|
|
354
|
+
|
|
355
|
+
Claude Code ignores a replacement for a built-in tool that does not match
|
|
356
|
+
the tool's output schema, so for those only the text fields change.
|
|
357
|
+
"""
|
|
358
|
+
from openscript_sdk.policies.redaction import redact_data
|
|
359
|
+
|
|
360
|
+
def redact(value: Any) -> tuple[Any, list[str]]:
|
|
361
|
+
redact_config = config["redact"]
|
|
362
|
+
return redact_data(
|
|
363
|
+
value,
|
|
364
|
+
secrets=redact_config["secrets"],
|
|
365
|
+
pii=redact_config["pii"],
|
|
366
|
+
internal_urls=redact_config["internal_urls"],
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if name == "Bash" and isinstance(response, dict):
|
|
370
|
+
out, labels = dict(response), []
|
|
371
|
+
for key in ("stdout", "stderr"):
|
|
372
|
+
if isinstance(out.get(key), str):
|
|
373
|
+
out[key], found = redact(out[key])
|
|
374
|
+
labels += found
|
|
375
|
+
return out, list(dict.fromkeys(labels))
|
|
376
|
+
if name == "Read" and isinstance(response, dict) and isinstance(response.get("file"), dict):
|
|
377
|
+
file = dict(response["file"])
|
|
378
|
+
if isinstance(file.get("content"), str):
|
|
379
|
+
file["content"], labels = redact(file["content"])
|
|
380
|
+
return {**response, "file": file}, labels
|
|
381
|
+
return response, []
|
|
382
|
+
return redact(response)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _ask(out: IO[str], reason: str) -> None:
|
|
386
|
+
decision = {
|
|
387
|
+
"hookSpecificOutput": {
|
|
388
|
+
"hookEventName": "PreToolUse",
|
|
389
|
+
"permissionDecision": "ask",
|
|
390
|
+
"permissionDecisionReason": PREFIX + reason,
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
out.write(json.dumps(decision))
|
|
394
|
+
out.flush()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _block(reason: str) -> int:
|
|
398
|
+
sys.stderr.write(PREFIX + reason + "\n")
|
|
399
|
+
return 2
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _audit(
|
|
403
|
+
config: dict[str, Any],
|
|
404
|
+
payload: dict[str, Any],
|
|
405
|
+
verdict: str,
|
|
406
|
+
policy: str,
|
|
407
|
+
reason: str,
|
|
408
|
+
tainted_by: list[str] | None = None,
|
|
409
|
+
labels: list[str] | None = None,
|
|
410
|
+
) -> None:
|
|
411
|
+
"""One line per decision. Never the tool's output, and the arguments only
|
|
412
|
+
as a digest unless asked for, and then with secrets masked."""
|
|
413
|
+
from openscript_sdk.policies.redaction import redact_data
|
|
414
|
+
from openscript_sdk.policies.taint_store import append_jsonl
|
|
415
|
+
|
|
416
|
+
args = payload.get("tool_input")
|
|
417
|
+
record: dict[str, Any] = {
|
|
418
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
419
|
+
"session_id": payload.get("session_id"),
|
|
420
|
+
"agent_id": payload.get("agent_id"),
|
|
421
|
+
"tool_use_id": payload.get("tool_use_id"),
|
|
422
|
+
"hook_event": payload.get("hook_event_name"),
|
|
423
|
+
"tool": payload.get("tool_name"),
|
|
424
|
+
"policy": policy,
|
|
425
|
+
"verdict": verdict,
|
|
426
|
+
"reason": reason,
|
|
427
|
+
"tainted_by": tainted_by or [],
|
|
428
|
+
"labels": labels or [],
|
|
429
|
+
"cwd": payload.get("cwd"),
|
|
430
|
+
"args_sha256": hashlib.sha256(
|
|
431
|
+
json.dumps(args, sort_keys=True, default=str).encode("utf-8")
|
|
432
|
+
).hexdigest(),
|
|
433
|
+
}
|
|
434
|
+
if config["audit"]["include_args"]:
|
|
435
|
+
record["args"] = redact_data(args)[0]
|
|
436
|
+
path = Path(config["audit"]["path"]) if config["audit"]["path"] else _state() / "audit.jsonl"
|
|
437
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
438
|
+
append_jsonl(path, record)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _check(config_path: Path | None) -> int:
|
|
442
|
+
from openscript_sdk.hooks.config import ConfigError, load_config, unknown_names
|
|
443
|
+
|
|
444
|
+
path = _config_path(config_path, {})
|
|
445
|
+
try:
|
|
446
|
+
config, rules = load_config(path)
|
|
447
|
+
except ConfigError as error:
|
|
448
|
+
sys.stderr.write(f"{error}\n")
|
|
449
|
+
return 2
|
|
450
|
+
lines = [f"config: {path}"]
|
|
451
|
+
lines.append(
|
|
452
|
+
f"tool rules for {len(rules.rules)} tool(s)"
|
|
453
|
+
+ (", everything else refused" if rules.default_deny else "")
|
|
454
|
+
if rules is not None
|
|
455
|
+
else "no tool rules"
|
|
456
|
+
)
|
|
457
|
+
if config.session is not None:
|
|
458
|
+
lines.append(f"untrusted sources: {', '.join(config.session.sources)}")
|
|
459
|
+
lines.append(
|
|
460
|
+
f"held after an untrusted read ({config.session.on_taint}): "
|
|
461
|
+
f"{', '.join(config.session.sinks)}"
|
|
462
|
+
)
|
|
463
|
+
else:
|
|
464
|
+
lines.append("no session rule")
|
|
465
|
+
kinds = [k for k in ("secrets", "pii", "internal_urls") if getattr(config.redact, k)]
|
|
466
|
+
lines.append(
|
|
467
|
+
f"redacting {', '.join(kinds)} in: {', '.join(config.redact.tools)}"
|
|
468
|
+
if kinds
|
|
469
|
+
else "no redaction"
|
|
470
|
+
)
|
|
471
|
+
lines.append(f"state and audit log: {_state()}")
|
|
472
|
+
for name in unknown_names(config, rules):
|
|
473
|
+
lines.append(f"warning: {name!r} is not a Claude Code tool or an MCP tool (a typo?)")
|
|
474
|
+
sys.stderr.write("\n".join(lines) + "\n")
|
|
475
|
+
return 0
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == "__main__":
|
|
479
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# OpenScript's policies for Claude Code. Copy this to .claude/openscript.yaml
|
|
2
|
+
# in your project, change the names to your MCP servers' tools, and run
|
|
3
|
+
# `openscript-hook check` to see what it will do. Every key is checked: a
|
|
4
|
+
# misspelt one is an error, not a setting quietly ignored.
|
|
5
|
+
version: 1
|
|
6
|
+
|
|
7
|
+
# Rules on a call's arguments: what Claude Code's own permissions cannot say.
|
|
8
|
+
# A rule refuses a call outright, or (requires_approval) asks you first.
|
|
9
|
+
firewall:
|
|
10
|
+
rules:
|
|
11
|
+
mcp__github__create_issue:
|
|
12
|
+
args:
|
|
13
|
+
owner: {one_of: [your-org]} # issues only in your own organisation
|
|
14
|
+
mcp__github__merge_pull_request:
|
|
15
|
+
requires_approval: true # always ask before a merge
|
|
16
|
+
mcp__github__delete_repository:
|
|
17
|
+
deny: true # never
|
|
18
|
+
mcp__slack__slack_post_message:
|
|
19
|
+
args:
|
|
20
|
+
channel_id: {one_of: [C0123456789]} # one channel, and no other
|
|
21
|
+
|
|
22
|
+
# Once the session has read something a stranger could have written (a web
|
|
23
|
+
# page, an issue, a message), these calls wait for you. Names ending in * are
|
|
24
|
+
# prefixes. List your MCP servers' reading tools as sources: "mcp__*" would
|
|
25
|
+
# count every MCP result as untrusted, the results of writes included. Bash,
|
|
26
|
+
# Write and Edit are not held: in a coding session that would ask on nearly
|
|
27
|
+
# every command after any web read.
|
|
28
|
+
session:
|
|
29
|
+
sources:
|
|
30
|
+
- WebFetch
|
|
31
|
+
- WebSearch
|
|
32
|
+
- "mcp__github__get_*"
|
|
33
|
+
- "mcp__github__list_*"
|
|
34
|
+
- "mcp__github__search_*"
|
|
35
|
+
- "mcp__slack__slack_get_*"
|
|
36
|
+
- "mcp__slack__slack_list_*"
|
|
37
|
+
sinks:
|
|
38
|
+
- mcp__github__create_issue
|
|
39
|
+
- mcp__github__add_issue_comment
|
|
40
|
+
- mcp__github__create_pull_request
|
|
41
|
+
- mcp__github__merge_pull_request
|
|
42
|
+
- mcp__github__push_files
|
|
43
|
+
- "mcp__slack__slack_post_*"
|
|
44
|
+
- mcp__slack__slack_reply_to_thread
|
|
45
|
+
on_taint: require_approval # or deny
|
|
46
|
+
# known: # let a held call through when it only sends
|
|
47
|
+
# mcp__slack__slack_post_message: # somewhere you have already sent
|
|
48
|
+
# channel_id: [C0123456789]
|
|
49
|
+
# A call that names no destination is held, unless its tool is in known and in
|
|
50
|
+
# updates: [tool, ...], for tools that edit something that already exists.
|
|
51
|
+
|
|
52
|
+
# Mask secrets in what these tools return, before the model reads it. PII is
|
|
53
|
+
# off by default: its email, phone and IP patterns fire on ordinary git logs.
|
|
54
|
+
redact:
|
|
55
|
+
secrets: true
|
|
56
|
+
pii: false
|
|
57
|
+
tools: [Bash, Read, WebFetch, WebSearch, "mcp__*"]
|
|
58
|
+
|
|
59
|
+
# One JSON line per decision, never with the tool's output. Arguments are kept
|
|
60
|
+
# only as a digest unless include_args is true, and then with secrets masked.
|
|
61
|
+
audit:
|
|
62
|
+
include_args: false
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# The strict profile: the example below, with Bash, Write and Edit held after
|
|
2
|
+
# an untrusted read as well. A shell command can reach anything the user can,
|
|
3
|
+
# so in the default profile a command run after a web read is not held. Here
|
|
4
|
+
# it waits for you. Use it with settings_strict_example.json, whose matcher
|
|
5
|
+
# sends Bash to the hook. Expect a prompt on most commands after any web read:
|
|
6
|
+
# that is the price of holding what a shell can do (docs/red-team.md, B4).
|
|
7
|
+
#
|
|
8
|
+
# OpenScript's policies for Claude Code. Copy this to .claude/openscript.yaml
|
|
9
|
+
# in your project, change the names to your MCP servers' tools, and run
|
|
10
|
+
# `openscript-hook check` to see what it will do. Every key is checked: a
|
|
11
|
+
# misspelt one is an error, not a setting quietly ignored.
|
|
12
|
+
version: 1
|
|
13
|
+
|
|
14
|
+
# Rules on a call's arguments: what Claude Code's own permissions cannot say.
|
|
15
|
+
# A rule refuses a call outright, or (requires_approval) asks you first.
|
|
16
|
+
firewall:
|
|
17
|
+
rules:
|
|
18
|
+
mcp__github__create_issue:
|
|
19
|
+
args:
|
|
20
|
+
owner: {one_of: [your-org]} # issues only in your own organisation
|
|
21
|
+
mcp__github__merge_pull_request:
|
|
22
|
+
requires_approval: true # always ask before a merge
|
|
23
|
+
mcp__github__delete_repository:
|
|
24
|
+
deny: true # never
|
|
25
|
+
mcp__slack__slack_post_message:
|
|
26
|
+
args:
|
|
27
|
+
channel_id: {one_of: [C0123456789]} # one channel, and no other
|
|
28
|
+
|
|
29
|
+
# Once the session has read something a stranger could have written (a web
|
|
30
|
+
# page, an issue, a message), these calls wait for you. Names ending in * are
|
|
31
|
+
# prefixes. List your MCP servers' reading tools as sources: "mcp__*" would
|
|
32
|
+
# count every MCP result as untrusted, the results of writes included. In this
|
|
33
|
+
# profile Bash, Write and Edit are held too.
|
|
34
|
+
session:
|
|
35
|
+
sources:
|
|
36
|
+
- WebFetch
|
|
37
|
+
- WebSearch
|
|
38
|
+
- "mcp__github__get_*"
|
|
39
|
+
- "mcp__github__list_*"
|
|
40
|
+
- "mcp__github__search_*"
|
|
41
|
+
- "mcp__slack__slack_get_*"
|
|
42
|
+
- "mcp__slack__slack_list_*"
|
|
43
|
+
sinks:
|
|
44
|
+
- mcp__github__create_issue
|
|
45
|
+
- mcp__github__add_issue_comment
|
|
46
|
+
- mcp__github__create_pull_request
|
|
47
|
+
- mcp__github__merge_pull_request
|
|
48
|
+
- mcp__github__push_files
|
|
49
|
+
- "mcp__slack__slack_post_*"
|
|
50
|
+
- mcp__slack__slack_reply_to_thread
|
|
51
|
+
- Bash
|
|
52
|
+
- Write
|
|
53
|
+
- Edit
|
|
54
|
+
- MultiEdit
|
|
55
|
+
- NotebookEdit
|
|
56
|
+
on_taint: require_approval # or deny
|
|
57
|
+
# known: # let a held call through when it only sends
|
|
58
|
+
# mcp__slack__slack_post_message: # somewhere you have already sent
|
|
59
|
+
# channel_id: [C0123456789]
|
|
60
|
+
# A call that names no destination is held, unless its tool is in known and in
|
|
61
|
+
# updates: [tool, ...], for tools that edit something that already exists.
|
|
62
|
+
|
|
63
|
+
# Mask secrets in what these tools return, before the model reads it. PII is
|
|
64
|
+
# off by default: its email, phone and IP patterns fire on ordinary git logs.
|
|
65
|
+
redact:
|
|
66
|
+
secrets: true
|
|
67
|
+
pii: false
|
|
68
|
+
tools: [Bash, Read, WebFetch, WebSearch, "mcp__*"]
|
|
69
|
+
|
|
70
|
+
# One JSON line per decision, never with the tool's output. Arguments are kept
|
|
71
|
+
# only as a digest unless include_args is true, and then with secrets masked.
|
|
72
|
+
audit:
|
|
73
|
+
include_args: false
|