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.
Files changed (65) hide show
  1. openscript_sdk/__init__.py +217 -0
  2. openscript_sdk/contracts/__init__.py +82 -0
  3. openscript_sdk/contracts/interceptor.py +98 -0
  4. openscript_sdk/contracts/server_types.py +87 -0
  5. openscript_sdk/contracts/session_graph.py +123 -0
  6. openscript_sdk/contracts/types.py +76 -0
  7. openscript_sdk/events/__init__.py +0 -0
  8. openscript_sdk/events/approvals.py +309 -0
  9. openscript_sdk/events/models.py +26 -0
  10. openscript_sdk/events/store.py +79 -0
  11. openscript_sdk/events/writer.py +100 -0
  12. openscript_sdk/hooks/claude_code.py +479 -0
  13. openscript_sdk/hooks/claude_code_example.yaml +62 -0
  14. openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
  15. openscript_sdk/hooks/config.py +144 -0
  16. openscript_sdk/hooks/settings_example.json +28 -0
  17. openscript_sdk/hooks/settings_strict_example.json +28 -0
  18. openscript_sdk/integrations/langchain.py +32 -0
  19. openscript_sdk/integrations/langgraph.py +32 -0
  20. openscript_sdk/interceptors/base.py +15 -0
  21. openscript_sdk/interceptors/event_writer.py +131 -0
  22. openscript_sdk/interceptors/pii.py +168 -0
  23. openscript_sdk/interceptors/threat.py +674 -0
  24. openscript_sdk/logging.py +94 -0
  25. openscript_sdk/middleware/middleware.py +543 -0
  26. openscript_sdk/middleware/tool.py +247 -0
  27. openscript_sdk/observability/__init__.py +11 -0
  28. openscript_sdk/observability/metrics.py +105 -0
  29. openscript_sdk/observability/risk.py +54 -0
  30. openscript_sdk/observability/tracing.py +67 -0
  31. openscript_sdk/policies/compliance.py +261 -0
  32. openscript_sdk/policies/config.py +244 -0
  33. openscript_sdk/policies/harmful_request.py +617 -0
  34. openscript_sdk/policies/normalize.py +489 -0
  35. openscript_sdk/policies/output_schema.py +353 -0
  36. openscript_sdk/policies/policies_example.yaml +36 -0
  37. openscript_sdk/policies/redaction.py +359 -0
  38. openscript_sdk/policies/secrets.py +239 -0
  39. openscript_sdk/policies/semantic.py +348 -0
  40. openscript_sdk/policies/semantic_head.py +9 -0
  41. openscript_sdk/policies/session_taint.py +399 -0
  42. openscript_sdk/policies/taint_store.py +111 -0
  43. openscript_sdk/policies/tool_firewall.py +434 -0
  44. openscript_sdk/policies/tools_example.yaml +81 -0
  45. openscript_sdk/policies/toxicity.py +247 -0
  46. openscript_sdk/py.typed +0 -0
  47. openscript_sdk/server/__init__.py +1 -0
  48. openscript_sdk/server/app.py +153 -0
  49. openscript_sdk/server/auth.py +19 -0
  50. openscript_sdk/server/guard.py +126 -0
  51. openscript_sdk/server/guard_example.yaml +55 -0
  52. openscript_sdk/server/routers/__init__.py +0 -0
  53. openscript_sdk/server/routers/approvals.py +69 -0
  54. openscript_sdk/server/routers/guard.py +231 -0
  55. openscript_sdk/server/routers/sessions.py +80 -0
  56. openscript_sdk/server/routers/sse.py +69 -0
  57. openscript_sdk/server/routers/threat.py +119 -0
  58. openscript_sdk/server/static/dashboard/index.html +209 -0
  59. openscript_sdk/server/static/dashboard/session.html +339 -0
  60. openscript_sdk-0.1.0.dist-info/METADATA +225 -0
  61. openscript_sdk-0.1.0.dist-info/RECORD +65 -0
  62. openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
  63. openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  64. openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
  65. 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