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.
- semora_permissions-0.1.0/.gitignore +28 -0
- semora_permissions-0.1.0/PKG-INFO +16 -0
- semora_permissions-0.1.0/pyproject.toml +30 -0
- semora_permissions-0.1.0/src/semora_permissions/__init__.py +19 -0
- semora_permissions-0.1.0/src/semora_permissions/py.typed +0 -0
- semora_permissions-0.1.0/src/semora_permissions/rules.py +320 -0
|
@@ -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
|
+
]
|
|
File without changes
|
|
@@ -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}
|