semora-permissions 0.1.0__tar.gz

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.
@@ -0,0 +1,28 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.egg-info/
8
+ build/
9
+ dist/
10
+ .coverage
11
+ htmlcov/
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+
16
+
17
+ # Local tool/editor state — machine-specific, never pushed.
18
+ .claude/
19
+ .codecanvas/
20
+ .vscode/
21
+
22
+
23
+ # Superpowers design/spec scratch — working notes, not project documentation.
24
+ docs/superpowers/
25
+
26
+ # 로컬 자격증명 — 절대 커밋 금지.
27
+ a.txt
28
+ *.token
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.5
2
+ Name: semora-permissions
3
+ Version: 0.1.0
4
+ Summary: A Claude-Code-shaped permission rule table as pre_tool_use / on_resume stages.
5
+ Project-URL: Homepage, https://github.com/donggyun112/semora
6
+ Project-URL: Source, https://github.com/donggyun112/semora
7
+ Project-URL: Changelog, https://github.com/donggyun112/semora/blob/main/CHANGELOG.md
8
+ Author: donggyun112
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: semora==0.1.0
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "semora-permissions"
7
+ version = "0.1.0"
8
+ description = "A Claude-Code-shaped permission rule table as pre_tool_use / on_resume stages."
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ authors = [{ name = "donggyun112" }]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Typing :: Typed",
18
+ ]
19
+ urls = { Homepage = "https://github.com/donggyun112/semora", Source = "https://github.com/donggyun112/semora", Changelog = "https://github.com/donggyun112/semora/blob/main/CHANGELOG.md" }
20
+ dependencies = [
21
+ "semora==0.1.0",
22
+ ]
23
+ # Optional by construction: nothing in the runtime imports this. A host that wants the
24
+ # rule table installs it; a host with its own policy never sees it.
25
+
26
+ [tool.uv.sources]
27
+ semora = { workspace = true }
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/semora_permissions"]
@@ -0,0 +1,19 @@
1
+ """Permission rules and control stages for Semora tool execution."""
2
+
3
+ from .rules import (
4
+ Mode,
5
+ PermissionBehavior,
6
+ PolicyContext,
7
+ Rule,
8
+ escalation_guard,
9
+ resolve_rules,
10
+ )
11
+
12
+ __all__ = [
13
+ "Mode",
14
+ "PermissionBehavior",
15
+ "PolicyContext",
16
+ "Rule",
17
+ "escalation_guard",
18
+ "resolve_rules",
19
+ ]
@@ -0,0 +1,320 @@
1
+ """Ordered permission rules for tool execution and approval resumption.
2
+
3
+ Explicit denials, tool restrictions, and protected-path checks take precedence over execution
4
+ modes. Mode handling is applied only after all applicable rules have been evaluated.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import re
10
+ from collections.abc import Awaitable, Callable, Sequence
11
+ from typing import Any, Literal, NamedTuple
12
+
13
+ from semora.contracts.types import ToolCall
14
+ from semora.controls import Continue, Deny, ResumeInput, Suspend, ToolDecision
15
+
16
+ __all__ = [
17
+ "Mode",
18
+ "PermissionBehavior",
19
+ "PolicyContext",
20
+ "Rule",
21
+ "escalation_guard",
22
+ "resolve_rules",
23
+ ]
24
+
25
+ Mode = Literal["default", "bypass", "dont_ask"]
26
+ """Fallback behavior for tool calls not settled by explicit rules."""
27
+
28
+ PermissionBehavior = Literal["allow", "deny", "ask"]
29
+ """Decision expressed by a permission rule."""
30
+
31
+
32
+ class Rule(NamedTuple):
33
+ """Permission decision scoped to a tool and optional input prefix.
34
+
35
+ Attributes:
36
+ effect: Decision applied when the rule matches.
37
+ tool: Tool name to match.
38
+ content: Input prefix, or ``None`` to match every call to the tool.
39
+ """
40
+
41
+ effect: PermissionBehavior
42
+ tool: str
43
+ content: str | None = None
44
+
45
+
46
+ SAFETY_MARKERS = (".git/", ".agent/", ".ssh/", ".bashrc", ".zshrc", ".profile")
47
+ """Protected path fragments that always require approval."""
48
+
49
+
50
+ class PolicyContext(NamedTuple):
51
+ """Permission rules and execution mode owned by a supervisor.
52
+
53
+ Attributes:
54
+ rules: Ordered permission rules.
55
+ mode: Fallback behavior for unresolved calls.
56
+ version: Human-readable policy version for audit and telemetry.
57
+ """
58
+
59
+ rules: list[Rule] = [] # noqa: RUF012 — NamedTuple defaults are per-field, not shared
60
+ mode: Mode = "default"
61
+ version: str = ""
62
+
63
+ @property
64
+ def fingerprint(self) -> str:
65
+ """Return a deterministic identity for the effective rules and mode.
66
+
67
+ Use this value as ``rules_version`` when suspending and resuming a tool call. The audit
68
+ ``version`` label is intentionally excluded.
69
+ """
70
+ canonical = json.dumps(
71
+ {
72
+ "mode": self.mode,
73
+ # `None` and `""` are different rules — one matches the tool, the other matches
74
+ # every input of it — so they must sort and serialise apart rather than collapse.
75
+ "rules": sorted(
76
+ ([rule.effect, rule.tool, rule.content] for rule in self.rules),
77
+ key=lambda row: (row[0], row[1], row[2] is None, row[2] or ""),
78
+ ),
79
+ },
80
+ sort_keys=True,
81
+ separators=(",", ":"),
82
+ default=str,
83
+ )
84
+ return hashlib.sha256(canonical.encode()).hexdigest()[:32]
85
+
86
+ def stage(self, tools: Any) -> Callable[[Any, ToolCall], Awaitable[ToolDecision]]:
87
+ """Build a ``pre_tool_use`` stage for this policy.
88
+
89
+ Args:
90
+ tools: Tool registry used to read per-tool permission metadata.
91
+
92
+ Returns:
93
+ Async stage that allows, denies, or suspends each tool call.
94
+ """
95
+
96
+ async def evaluate(ctx: Any, call: ToolCall) -> ToolDecision:
97
+ result = await resolve_rules(
98
+ call,
99
+ rules=self.rules,
100
+ mode=self.mode,
101
+ definition=tools.get(call["name"]),
102
+ )
103
+ if result is None:
104
+ return Continue()
105
+ # Stamped on the refusal itself, which is what the audit event publishes and what a
106
+ # suspension stores. An approval request that cannot say who it is about is a request
107
+ # somebody has to correlate by hand before they can answer it.
108
+ subject = getattr(ctx, "subject", "")
109
+ decided = {**result, "subject": subject} if subject else result
110
+ return Deny(decided) if decided["type"] == "error" else Suspend(decided)
111
+
112
+ return evaluate
113
+
114
+ def resume_stage(self, tools: Any) -> Any:
115
+ """Build an ``on_resume`` stage that revalidates an approval.
116
+
117
+ Args:
118
+ tools: Tool registry used to read per-tool permission metadata.
119
+
120
+ Returns:
121
+ Async stage evaluated against the current policy fingerprint.
122
+ """
123
+
124
+ async def evaluate(ctx: Any, call: ToolCall, resume: ResumeInput) -> ToolDecision:
125
+ result = await resolve_rules(
126
+ call,
127
+ rules=self.rules,
128
+ mode=self.mode,
129
+ subscriber_answer={"type": "allow"},
130
+ definition=tools.get(call["name"]),
131
+ )
132
+ if result is None:
133
+ return Continue()
134
+ subject = getattr(ctx, "subject", "")
135
+ decided = {**result, "subject": subject} if subject else result
136
+ if decided["type"] == "error":
137
+ return Deny(decided)
138
+ if self.fingerprint == resume.suspended_rules_version:
139
+ return Continue()
140
+ # A second question, so it is stamped like the first: whoever answers this one is
141
+ # answering about the same subject, and the record has to say which.
142
+ return Suspend(decided)
143
+
144
+ return evaluate
145
+
146
+
147
+ def escalation_guard(
148
+ ceiling: Sequence[str] | None,
149
+ ) -> Callable[[Any, ToolCall], Awaitable[ToolDecision]]:
150
+ """Build a stage that denies tools outside a delegation ceiling.
151
+
152
+ Args:
153
+ ceiling: Allowed tool names, or ``None`` to permit every tool.
154
+
155
+ Returns:
156
+ A ``pre_tool_use`` stage that enforces the ceiling.
157
+ """
158
+ if ceiling is None:
159
+ return _permits_everything
160
+ allowed = frozenset(ceiling)
161
+
162
+ async def stage(_ctx: Any, call: ToolCall) -> ToolDecision:
163
+ if call["name"] in allowed:
164
+ return Continue()
165
+ return Deny(
166
+ {
167
+ "type": "error",
168
+ "message": (
169
+ f"{call['name']} is outside this agent's authority; "
170
+ "a delegated agent cannot reach a tool its parent could not"
171
+ ),
172
+ }
173
+ )
174
+
175
+ return stage
176
+
177
+
178
+ async def _permits_everything(_ctx: Any, _call: ToolCall) -> ToolDecision:
179
+ """Allow a call under an unrestricted authority ceiling."""
180
+ return Continue()
181
+
182
+
183
+ async def resolve_rules(
184
+ call: ToolCall,
185
+ *,
186
+ rules: list[Rule],
187
+ mode: Mode = "default",
188
+ subscriber_answer: dict[str, Any] | None = None,
189
+ definition: dict[str, Any] | None = None,
190
+ ) -> dict[str, Any] | None:
191
+ """Resolve the effective permission decision for a tool call.
192
+
193
+ Args:
194
+ call: LangChain tool call to evaluate.
195
+ rules: Ordered permission rules.
196
+ mode: Fallback behavior for unresolved calls.
197
+ subscriber_answer: Optional decision from an earlier control stage.
198
+ definition: Optional tool metadata such as ``deny`` or ``shell_content``.
199
+
200
+ Returns:
201
+ ``None`` to allow, an error result to deny, or a suspension result to request approval.
202
+ """
203
+ tool = call["name"]
204
+ content = _content_of(call)
205
+ definition = definition or {}
206
+ parts = _parts(content, definition)
207
+ asked: dict[str, Any] | None = None
208
+
209
+ # ── 0. the subscriber's opinion ────────────────────────────────────────────
210
+ if subscriber_answer is not None:
211
+ kind = subscriber_answer.get("type")
212
+ if kind == "error":
213
+ return subscriber_answer
214
+ if kind == "suspend":
215
+ asked = subscriber_answer
216
+
217
+ # ── 1a-1g. the immune region: nothing below can lift what these decide ─────
218
+ # Content-scoped too, not `tool_wide_only`: a deny is a deny at whatever scope it was
219
+ # written, and `Rule` already promises that. Checking only tool-wide ones here meant
220
+ # `deny bash(rm -rf:*)` decided nothing — it fell past every branch to "nothing matched",
221
+ # which asks a person in `default` and **runs the command in `bypass`**. The ask path below
222
+ # still splits tool-wide from content-scoped, because those two straddle the tool's own deny.
223
+ if _matches(rules, "deny", tool, parts):
224
+ return _deny(f"denied by rule: {tool}")
225
+ if _matches(rules, "ask", tool, parts, tool_wide_only=True):
226
+ asked = asked or _ask(call, f"rule asks about {tool}")
227
+
228
+ if definition.get("deny"):
229
+ return _deny(str(definition["deny"]))
230
+ if definition.get("requires_user_interaction"):
231
+ asked = asked or _ask(call, f"{tool} needs a person")
232
+
233
+ if _matches(rules, "ask", tool, parts):
234
+ asked = asked or _ask(call, f"rule asks about {tool} with this input")
235
+ if _unsafe(call):
236
+ asked = asked or _ask(call, "touches a protected path")
237
+
238
+ if asked is not None:
239
+ return _transform(asked, mode)
240
+
241
+ # ── 2a. only from here may anything answer "allow" ────────────────────────
242
+ if mode == "bypass":
243
+ return None
244
+ if _allowed(rules, tool, parts):
245
+ return None
246
+
247
+ # ── 3. nothing matched ───────────────────────────────────────────────────
248
+ return _transform(_ask(call, "no rule matched"), mode)
249
+
250
+
251
+ def _transform(asked: dict[str, Any], mode: Mode) -> dict[str, Any] | None:
252
+ """Apply fallback mode behavior to an approval request."""
253
+ if mode == "dont_ask":
254
+ return _deny(f"cannot ask: {asked.get('reason', 'approval required')}")
255
+ return asked
256
+
257
+
258
+ def _matches(
259
+ rules: list[Rule],
260
+ effect: PermissionBehavior,
261
+ tool: str,
262
+ parts: list[str],
263
+ *,
264
+ tool_wide_only: bool = False,
265
+ ) -> bool:
266
+ """Return whether any call part matches a deny or approval rule."""
267
+ for rule in rules:
268
+ if rule.effect != effect or rule.tool != tool:
269
+ continue
270
+ if rule.content is None:
271
+ return True
272
+ if not tool_wide_only and any(part.startswith(rule.content) for part in parts):
273
+ return True
274
+ return False
275
+
276
+
277
+ def _allowed(rules: list[Rule], tool: str, parts: list[str]) -> bool:
278
+ """Return whether allow rules cover every executable part of a call."""
279
+ scoped = [rule for rule in rules if rule.effect == "allow" and rule.tool == tool]
280
+ if any(rule.content is None for rule in scoped):
281
+ return True
282
+ return bool(parts) and all(
283
+ any(rule.content is not None and part.startswith(rule.content) for rule in scoped)
284
+ for part in parts
285
+ )
286
+
287
+
288
+ _SEPARATORS = re.compile(r"&&|\|\||>>?|[;&|\n]|\$\(|`")
289
+ """Shell operators that delimit separately evaluated command parts."""
290
+
291
+
292
+ def _parts(content: str, definition: dict[str, Any]) -> list[str]:
293
+ """Split shell-aware tool input into independently evaluated command parts."""
294
+ if not definition.get("shell_content"):
295
+ return [content]
296
+ return [part for raw in _SEPARATORS.split(content) if (part := raw.strip())] or [content]
297
+
298
+
299
+ def _content_of(call: ToolCall) -> str:
300
+ """Extract the tool input used by content-scoped rules."""
301
+ args = call.get("args") or {}
302
+ for key in ("command", "path", "url", "query"):
303
+ value = args.get(key)
304
+ if isinstance(value, str):
305
+ return value
306
+ return " ".join(str(v) for v in args.values())
307
+
308
+
309
+ def _unsafe(call: ToolCall) -> bool:
310
+ target = _content_of(call)
311
+ return any(part in target for part in SAFETY_MARKERS)
312
+
313
+
314
+ def _deny(message: str) -> dict[str, Any]:
315
+ return {"type": "error", "message": message}
316
+
317
+
318
+ def _ask(call: ToolCall, reason: str) -> dict[str, Any]:
319
+ """Build a non-blocking suspension keyed by the tool call identifier."""
320
+ return {"type": "suspend", "pending_id": call["id"], "reason": reason}