turnloop 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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Permission decisions.
|
|
2
|
+
|
|
3
|
+
Precedence is flat and evaluated in a fixed order. There is deliberately no
|
|
4
|
+
specificity scoring — "the most specific rule wins" is impossible to predict
|
|
5
|
+
once patterns overlap, and a permission system nobody can predict is a
|
|
6
|
+
permission system people disable.
|
|
7
|
+
|
|
8
|
+
1. deny match -> DENY, unconditionally, even in bypass mode
|
|
9
|
+
2. plan mode + mutating tool -> DENY (read-only enforcement)
|
|
10
|
+
3. bypass mode -> ALLOW
|
|
11
|
+
4. allow match (incl. session grants) -> ALLOW
|
|
12
|
+
5. ask match -> ASK
|
|
13
|
+
6. mode default:
|
|
14
|
+
default -> ASK for mutating tools, ALLOW for read-only
|
|
15
|
+
auto -> ALLOW
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from turnloop.config import PermissionConfig, PermissionMode
|
|
26
|
+
from turnloop.permissions.rules import Rule, parse_rules
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from turnloop.tools.base import Tool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Verdict(str, Enum):
|
|
33
|
+
ALLOW = "allow"
|
|
34
|
+
DENY = "deny"
|
|
35
|
+
ASK = "ask"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class Decision:
|
|
40
|
+
verdict: Verdict
|
|
41
|
+
reason: str = ""
|
|
42
|
+
rule: Rule | None = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def allowed(self) -> bool:
|
|
46
|
+
return self.verdict is Verdict.ALLOW
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class PermissionRequest:
|
|
51
|
+
"""Handed to the UI when a call needs a human answer."""
|
|
52
|
+
|
|
53
|
+
tool_name: str
|
|
54
|
+
target: str
|
|
55
|
+
summary: str
|
|
56
|
+
args_preview: str
|
|
57
|
+
is_read_only: bool
|
|
58
|
+
suggested_rule: str
|
|
59
|
+
reason: str = ""
|
|
60
|
+
subagent_id: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Scope(str, Enum):
|
|
64
|
+
ONCE = "once"
|
|
65
|
+
SESSION = "session"
|
|
66
|
+
PROJECT = "project" # persisted to .turnloop/settings.local.json
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class PermissionDecision:
|
|
71
|
+
"""The human's answer."""
|
|
72
|
+
|
|
73
|
+
approved: bool
|
|
74
|
+
scope: Scope = Scope.ONCE
|
|
75
|
+
rule: str | None = None
|
|
76
|
+
reason: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class PermissionEngine:
|
|
81
|
+
mode: PermissionMode = "default"
|
|
82
|
+
allow: list[Rule] = field(default_factory=list)
|
|
83
|
+
deny: list[Rule] = field(default_factory=list)
|
|
84
|
+
ask: list[Rule] = field(default_factory=list)
|
|
85
|
+
# Grants the human gave for this session only. Not persisted.
|
|
86
|
+
session_grants: list[Rule] = field(default_factory=list)
|
|
87
|
+
# Populated for auditing; the session log is the durable record.
|
|
88
|
+
decisions: list[tuple[str, str, Verdict]] = field(default_factory=list)
|
|
89
|
+
project_root: Path | None = None
|
|
90
|
+
# The agent's working directory. Model-supplied relative paths resolve against
|
|
91
|
+
# this, not against the interpreter's cwd.
|
|
92
|
+
cwd: Path | None = None
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_config(cls, cfg: PermissionConfig, mode: PermissionMode,
|
|
96
|
+
project_root: Path | None = None,
|
|
97
|
+
cwd: Path | None = None) -> PermissionEngine:
|
|
98
|
+
return cls(
|
|
99
|
+
mode=mode,
|
|
100
|
+
allow=parse_rules(cfg.allow),
|
|
101
|
+
deny=parse_rules(cfg.deny),
|
|
102
|
+
ask=parse_rules(cfg.ask),
|
|
103
|
+
project_root=project_root,
|
|
104
|
+
cwd=cwd or project_root,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# --- the decision ------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def check(self, tool: Tool, args: Any) -> Decision:
|
|
110
|
+
target = tool.permission_target(args)
|
|
111
|
+
read_only = tool.is_read_only_for(args)
|
|
112
|
+
|
|
113
|
+
if rule := self._first_match(self.deny, tool, target):
|
|
114
|
+
return self._record(
|
|
115
|
+
tool, target,
|
|
116
|
+
Decision(Verdict.DENY, f"denied by rule {rule}", rule),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if self.mode == "plan" and not read_only:
|
|
120
|
+
return self._record(
|
|
121
|
+
tool, target,
|
|
122
|
+
Decision(
|
|
123
|
+
Verdict.DENY,
|
|
124
|
+
"plan mode is read-only: propose the change instead of making it",
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if self.mode == "bypass":
|
|
129
|
+
return self._record(tool, target, Decision(Verdict.ALLOW, "bypass mode"))
|
|
130
|
+
|
|
131
|
+
if rule := self._first_match(self.allow + self.session_grants, tool, target):
|
|
132
|
+
return self._record(tool, target, Decision(Verdict.ALLOW, f"allowed by {rule}", rule))
|
|
133
|
+
|
|
134
|
+
if rule := self._first_match(self.ask, tool, target):
|
|
135
|
+
return self._record(
|
|
136
|
+
tool, target, Decision(Verdict.ASK, f"confirmation required by {rule}", rule)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if self.mode == "auto":
|
|
140
|
+
return self._record(tool, target, Decision(Verdict.ALLOW, "auto mode"))
|
|
141
|
+
|
|
142
|
+
if read_only:
|
|
143
|
+
return self._record(tool, target, Decision(Verdict.ALLOW, "read-only tool"))
|
|
144
|
+
|
|
145
|
+
return self._record(
|
|
146
|
+
tool, target, Decision(Verdict.ASK, "modifies state and no rule covers it")
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _first_match(self, rules: list[Rule], tool: Tool, target: str) -> Rule | None:
|
|
150
|
+
for rule in rules:
|
|
151
|
+
if not rule.tool_matches(tool.name):
|
|
152
|
+
continue
|
|
153
|
+
if rule.pattern is None:
|
|
154
|
+
return rule
|
|
155
|
+
if tool.match_target(target, rule.pattern, self.project_root, self.cwd):
|
|
156
|
+
return rule
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def _record(self, tool: Tool, target: str, decision: Decision) -> Decision:
|
|
160
|
+
self.decisions.append((tool.name, target, decision.verdict))
|
|
161
|
+
return decision
|
|
162
|
+
|
|
163
|
+
# --- grants ------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def grant(self, rule_text: str, scope: Scope = Scope.SESSION) -> Rule:
|
|
166
|
+
from turnloop.permissions.rules import parse_rule
|
|
167
|
+
|
|
168
|
+
rule = parse_rule(rule_text, source=scope.value)
|
|
169
|
+
if scope is Scope.SESSION:
|
|
170
|
+
self.session_grants.append(rule)
|
|
171
|
+
elif scope is Scope.PROJECT:
|
|
172
|
+
self.allow.append(rule)
|
|
173
|
+
return rule
|
|
174
|
+
|
|
175
|
+
def suggested_rule(self, tool: Tool, args: Any) -> str:
|
|
176
|
+
"""The rule text offered as "always allow this".
|
|
177
|
+
|
|
178
|
+
Deliberately narrow. For Bash it suggests the first two words plus a
|
|
179
|
+
wildcard (`git commit *`), never a bare `Bash`, because a broad grant
|
|
180
|
+
made in a hurry is how permission systems stop meaning anything.
|
|
181
|
+
"""
|
|
182
|
+
target = tool.permission_target(args)
|
|
183
|
+
if tool.name == "Bash":
|
|
184
|
+
from turnloop.permissions.rules import normalize_command, split_shell_command
|
|
185
|
+
|
|
186
|
+
segments = split_shell_command(target).segments
|
|
187
|
+
first = normalize_command(segments[0] if segments else target)
|
|
188
|
+
words = first.split()
|
|
189
|
+
head = " ".join(words[:2]) if len(words) > 1 else (words[0] if words else target)
|
|
190
|
+
return f"Bash({head} *)"
|
|
191
|
+
if target == tool.name:
|
|
192
|
+
return tool.name
|
|
193
|
+
try:
|
|
194
|
+
rel = Path(target)
|
|
195
|
+
if self.project_root and rel.is_absolute():
|
|
196
|
+
rel = rel.resolve().relative_to(self.project_root.resolve())
|
|
197
|
+
parent = rel.parent.as_posix()
|
|
198
|
+
if parent in ("", "."):
|
|
199
|
+
return f"{tool.name}({rel.name})"
|
|
200
|
+
return f"{tool.name}({parent}/**)"
|
|
201
|
+
except ValueError:
|
|
202
|
+
return f"{tool.name}({target})"
|
|
203
|
+
|
|
204
|
+
def snapshot(self) -> dict:
|
|
205
|
+
return {
|
|
206
|
+
"mode": self.mode,
|
|
207
|
+
"allow": [str(r) for r in self.allow],
|
|
208
|
+
"deny": [str(r) for r in self.deny],
|
|
209
|
+
"ask": [str(r) for r in self.ask],
|
|
210
|
+
"session_grants": [str(r) for r in self.session_grants],
|
|
211
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Permission rule grammar and matching.
|
|
2
|
+
|
|
3
|
+
A rule is `Tool` or `Tool(pattern)`:
|
|
4
|
+
|
|
5
|
+
Bash every Bash call
|
|
6
|
+
Bash(git *) bash commands starting with "git "
|
|
7
|
+
Edit(src/**) edits under src/
|
|
8
|
+
Read(**/.env) any .env file, at any depth
|
|
9
|
+
mcp__github__* every tool from the github MCP server
|
|
10
|
+
|
|
11
|
+
Matching is delegated to the tool class, because a glob means different things
|
|
12
|
+
for a shell command than for a path. The centralized part is only the parsing
|
|
13
|
+
and the shell-segment splitting, which is shared by Bash and by hook commands.
|
|
14
|
+
|
|
15
|
+
The single most important behavior in this file: a compound shell command is
|
|
16
|
+
allowed only if *every* segment is allowed. Without that, `Bash(git *)` grants
|
|
17
|
+
`git status && rm -rf /`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from fnmatch import fnmatchcase
|
|
25
|
+
from pathlib import Path, PurePosixPath
|
|
26
|
+
|
|
27
|
+
from turnloop.errors import ConfigError
|
|
28
|
+
|
|
29
|
+
# The tool position allows `*` so MCP servers can be granted wholesale
|
|
30
|
+
# (`mcp__github__*`), which is the only practical way to authorize a server whose
|
|
31
|
+
# tool list is discovered at runtime.
|
|
32
|
+
_RULE_RE = re.compile(r"^(?P<tool>[A-Za-z_][\w*-]*(?:__[\w*-]+)*)\s*(?:\((?P<pattern>.*)\))?$", re.S)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Rule:
|
|
37
|
+
tool: str
|
|
38
|
+
pattern: str | None = None
|
|
39
|
+
source: str = "settings"
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return f"{self.tool}({self.pattern})" if self.pattern is not None else self.tool
|
|
43
|
+
|
|
44
|
+
def tool_matches(self, tool_name: str) -> bool:
|
|
45
|
+
if self.tool == tool_name:
|
|
46
|
+
return True
|
|
47
|
+
# Wildcards in the tool position exist for MCP: mcp__github__*
|
|
48
|
+
return "*" in self.tool and fnmatchcase(tool_name, self.tool)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_rule(raw: str, source: str = "settings") -> Rule:
|
|
52
|
+
text = raw.strip()
|
|
53
|
+
if not text:
|
|
54
|
+
raise ConfigError("empty permission rule")
|
|
55
|
+
match = _RULE_RE.match(text)
|
|
56
|
+
if not match:
|
|
57
|
+
raise ConfigError(
|
|
58
|
+
f"malformed permission rule {raw!r}. Expected 'Tool' or 'Tool(pattern)'."
|
|
59
|
+
)
|
|
60
|
+
pattern = match.group("pattern")
|
|
61
|
+
return Rule(
|
|
62
|
+
tool=match.group("tool"),
|
|
63
|
+
pattern=pattern.strip() if pattern is not None else None,
|
|
64
|
+
source=source,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_rules(raws: list[str], source: str = "settings") -> list[Rule]:
|
|
69
|
+
return [parse_rule(r, source) for r in raws]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
# path matching
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def match_path_pattern(target: str, pattern: str, root: Path | None = None,
|
|
78
|
+
cwd: Path | None = None) -> bool:
|
|
79
|
+
"""Match a filesystem target against a glob.
|
|
80
|
+
|
|
81
|
+
Patterns are interpreted relative to the project root. A target outside the
|
|
82
|
+
root therefore never matches a relative pattern — which is what stops
|
|
83
|
+
`Edit(src/**)` from accidentally authorizing `../../etc/hosts` after path
|
|
84
|
+
traversal. Absolute patterns are matched absolutely.
|
|
85
|
+
|
|
86
|
+
`cwd` matters: a model supplies paths relative to *its* working directory, not
|
|
87
|
+
to the interpreter's. Resolving against `Path.cwd()` instead would make every
|
|
88
|
+
relative path fall outside the root and silently match nothing — a permission
|
|
89
|
+
system that quietly stops matching is worse than one that errors.
|
|
90
|
+
|
|
91
|
+
Both `**` (any depth) and `*` (one segment) are supported, with the usual
|
|
92
|
+
convenience that a bare directory pattern matches everything beneath it.
|
|
93
|
+
"""
|
|
94
|
+
target_path = Path(target).expanduser()
|
|
95
|
+
if not target_path.is_absolute() and cwd is not None:
|
|
96
|
+
target_path = cwd / target_path
|
|
97
|
+
pattern = pattern.strip()
|
|
98
|
+
if not pattern:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
absolute_pattern = pattern.startswith(("/", "\\")) or (
|
|
102
|
+
len(pattern) > 1 and pattern[1] == ":"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if absolute_pattern:
|
|
106
|
+
candidate = _posix(target_path)
|
|
107
|
+
pat = _posix(Path(pattern))
|
|
108
|
+
else:
|
|
109
|
+
if root is None:
|
|
110
|
+
candidate = _posix(target_path)
|
|
111
|
+
else:
|
|
112
|
+
try:
|
|
113
|
+
candidate = _posix(target_path.resolve().relative_to(root.resolve()))
|
|
114
|
+
except ValueError:
|
|
115
|
+
return False # outside the root; a relative rule cannot reach it
|
|
116
|
+
pat = pattern.replace("\\", "/")
|
|
117
|
+
|
|
118
|
+
if _glob_match(candidate, pat):
|
|
119
|
+
return True
|
|
120
|
+
# A directory pattern implies its contents: Edit(src) covers src/a/b.py
|
|
121
|
+
if not any(ch in pat for ch in "*?[") and candidate.startswith(pat.rstrip("/") + "/"):
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _posix(path: Path | PurePosixPath) -> str:
|
|
127
|
+
return str(path).replace("\\", "/")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _glob_match(candidate: str, pattern: str) -> bool:
|
|
131
|
+
"""fnmatch, but with `*` not crossing separators and `**` doing so.
|
|
132
|
+
|
|
133
|
+
fnmatch alone treats `*` as matching `/`, which would make `Edit(*.py)`
|
|
134
|
+
match `vendor/deep/thing.py` — too permissive for a rule the user wrote to
|
|
135
|
+
mean "python files at the top level".
|
|
136
|
+
"""
|
|
137
|
+
regex = _glob_to_regex(pattern)
|
|
138
|
+
return re.fullmatch(regex, candidate) is not None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _glob_to_regex(pattern: str) -> str:
|
|
142
|
+
out: list[str] = []
|
|
143
|
+
i = 0
|
|
144
|
+
n = len(pattern)
|
|
145
|
+
while i < n:
|
|
146
|
+
ch = pattern[i]
|
|
147
|
+
if ch == "*":
|
|
148
|
+
if pattern.startswith("**/", i):
|
|
149
|
+
out.append("(?:.*/)?")
|
|
150
|
+
i += 3
|
|
151
|
+
continue
|
|
152
|
+
if pattern.startswith("**", i):
|
|
153
|
+
out.append(".*")
|
|
154
|
+
i += 2
|
|
155
|
+
continue
|
|
156
|
+
out.append("[^/]*")
|
|
157
|
+
i += 1
|
|
158
|
+
continue
|
|
159
|
+
if ch == "?":
|
|
160
|
+
out.append("[^/]")
|
|
161
|
+
i += 1
|
|
162
|
+
continue
|
|
163
|
+
if ch == "[":
|
|
164
|
+
close = pattern.find("]", i)
|
|
165
|
+
if close == -1:
|
|
166
|
+
out.append(re.escape(ch))
|
|
167
|
+
i += 1
|
|
168
|
+
continue
|
|
169
|
+
body = pattern[i + 1 : close]
|
|
170
|
+
if body.startswith("!"):
|
|
171
|
+
body = "^" + body[1:]
|
|
172
|
+
out.append(f"[{body}]")
|
|
173
|
+
i = close + 1
|
|
174
|
+
continue
|
|
175
|
+
out.append(re.escape(ch))
|
|
176
|
+
i += 1
|
|
177
|
+
return "".join(out)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --------------------------------------------------------------------------
|
|
181
|
+
# shell command splitting
|
|
182
|
+
# --------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
_OPERATORS = (";", "&&", "||", "|", "\n")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class ShellAnalysis:
|
|
189
|
+
segments: tuple[str, ...]
|
|
190
|
+
has_redirect: bool
|
|
191
|
+
has_substitution: bool
|
|
192
|
+
unbalanced_quotes: bool
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def suspicious(self) -> bool:
|
|
196
|
+
"""Anything that can write, or that we could not parse confidently."""
|
|
197
|
+
return self.has_redirect or self.has_substitution or self.unbalanced_quotes
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def split_shell_command(command: str) -> ShellAnalysis:
|
|
201
|
+
"""Split on top-level operators, respecting quotes.
|
|
202
|
+
|
|
203
|
+
shlex cannot do this — it discards operators, so `git status && rm -rf /`
|
|
204
|
+
comes back as a flat token list where the `rm` looks like an argument to
|
|
205
|
+
`git`. Hence a small hand-rolled scanner.
|
|
206
|
+
"""
|
|
207
|
+
segments: list[str] = []
|
|
208
|
+
current: list[str] = []
|
|
209
|
+
quote: str | None = None
|
|
210
|
+
escape = False
|
|
211
|
+
has_redirect = False
|
|
212
|
+
has_substitution = False
|
|
213
|
+
depth = 0 # ( ) and $( ) nesting
|
|
214
|
+
|
|
215
|
+
i = 0
|
|
216
|
+
n = len(command)
|
|
217
|
+
while i < n:
|
|
218
|
+
ch = command[i]
|
|
219
|
+
|
|
220
|
+
if escape:
|
|
221
|
+
current.append(ch)
|
|
222
|
+
escape = False
|
|
223
|
+
i += 1
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
if ch == "\\" and quote != "'":
|
|
227
|
+
current.append(ch)
|
|
228
|
+
escape = True
|
|
229
|
+
i += 1
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
if quote:
|
|
233
|
+
if ch == quote:
|
|
234
|
+
quote = None
|
|
235
|
+
elif quote == '"' and command.startswith("$(", i):
|
|
236
|
+
has_substitution = True
|
|
237
|
+
elif quote == '"' and ch == "`":
|
|
238
|
+
has_substitution = True
|
|
239
|
+
current.append(ch)
|
|
240
|
+
i += 1
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
if ch in "'\"":
|
|
244
|
+
quote = ch
|
|
245
|
+
current.append(ch)
|
|
246
|
+
i += 1
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
if command.startswith("$(", i):
|
|
250
|
+
has_substitution = True
|
|
251
|
+
depth += 1
|
|
252
|
+
current.append(command[i : i + 2])
|
|
253
|
+
i += 2
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
if ch == "`":
|
|
257
|
+
has_substitution = True
|
|
258
|
+
current.append(ch)
|
|
259
|
+
i += 1
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
if ch == "(":
|
|
263
|
+
depth += 1
|
|
264
|
+
current.append(ch)
|
|
265
|
+
i += 1
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
if ch == ")":
|
|
269
|
+
depth = max(0, depth - 1)
|
|
270
|
+
current.append(ch)
|
|
271
|
+
i += 1
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
if ch == ">" or (ch == "<" and command.startswith("<(", i)):
|
|
275
|
+
has_redirect = True
|
|
276
|
+
current.append(ch)
|
|
277
|
+
i += 1
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
if depth == 0:
|
|
281
|
+
matched = next((op for op in _OPERATORS if command.startswith(op, i)), None)
|
|
282
|
+
if matched:
|
|
283
|
+
segments.append("".join(current).strip())
|
|
284
|
+
current = []
|
|
285
|
+
i += len(matched)
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
current.append(ch)
|
|
289
|
+
i += 1
|
|
290
|
+
|
|
291
|
+
segments.append("".join(current).strip())
|
|
292
|
+
return ShellAnalysis(
|
|
293
|
+
segments=tuple(s for s in segments if s),
|
|
294
|
+
has_redirect=has_redirect,
|
|
295
|
+
has_substitution=has_substitution,
|
|
296
|
+
unbalanced_quotes=quote is not None or escape,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def normalize_command(command: str) -> str:
|
|
301
|
+
return " ".join(command.split())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def match_command_pattern(command: str, pattern: str) -> bool:
|
|
305
|
+
"""Match a shell command against a rule pattern, per segment.
|
|
306
|
+
|
|
307
|
+
The rule for compound commands is all-or-nothing: every segment must match,
|
|
308
|
+
because an allow rule is a statement about what may run, and `&&` runs more
|
|
309
|
+
than one thing. `Bash(git *)` must not authorize `git status && rm -rf /`.
|
|
310
|
+
"""
|
|
311
|
+
pattern = pattern.strip()
|
|
312
|
+
if not pattern:
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
analysis = split_shell_command(command)
|
|
316
|
+
if analysis.unbalanced_quotes:
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
# A pattern that itself contains an operator is an exact-ish match on the
|
|
320
|
+
# whole command line, so the user can allow a specific pipeline.
|
|
321
|
+
if any(op in pattern for op in (";", "&&", "||", "|")):
|
|
322
|
+
return fnmatchcase(normalize_command(command), pattern)
|
|
323
|
+
|
|
324
|
+
segments = analysis.segments or (normalize_command(command),)
|
|
325
|
+
return all(fnmatchcase(normalize_command(seg), pattern) for seg in segments)
|
|
File without changes
|