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.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. 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