zettcode-engine 0.3.4__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 (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,197 @@
1
+ """How much of the shell the agent may use without asking, and what enforces it.
2
+
3
+ Four levels, from the strictest to the loosest, and they are one setting rather
4
+ than four features because the thing they decide is one question — *when a shell
5
+ command arrives, who answers?*
6
+
7
+ ======= ===============================================================
8
+ ``deny`` Nobody runs it: the command fails and the model is told why.
9
+ ``ask`` The reader answers each one (the default, and what the prompt does).
10
+ ``safe`` Every command runs, inside an operating-system sandbox that lets it
11
+ write the workspace and ``/tmp`` only (:mod:`.sandbox`). Where the
12
+ machine has no sandbox: read-only commands run and the rest ask.
13
+ ``full`` Every command runs with the user's own access. (Formerly ``auto``.)
14
+ ======= ===============================================================
15
+
16
+ The level is shared state between the shell, which changes it, and two things on
17
+ the agent's side that read it on every shell call: the approval memory, which is
18
+ what lets ``safe`` and ``full`` skip the prompt, and :class:`ShellDenyExtension`,
19
+ which is what makes ``deny`` refuse. Neither keeps its own copy, so a change takes
20
+ effect on the next command, including one the model is about to issue in the
21
+ request that is running now.
22
+
23
+ Only the shell is governed. The file tools, the MCP tools, and the web tools are
24
+ not behind a prompt in this application and this setting does not put them behind
25
+ one: the level is named for what it controls, and the picker says so.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from pathlib import Path
31
+
32
+ from zett_agent.agent import AgentRunContext
33
+ from zett_agent.extensions.base import AgentExtension, ToolCallNext
34
+ from zett_agent.extensions.shell_approval import SHELL_APPROVAL_TOOL_NAME, ShellApprovalMode
35
+ from zett_agent.messages import ToolCall
36
+ from zett_agent.sandbox.base import Sandbox
37
+ from zett_agent.tools.base import ToolResult
38
+
39
+ from zettcode._compat import StrEnum
40
+
41
+ from ..tools.boundary import Boundary, Sandboxer
42
+ from .readonly import is_read_only
43
+ from .sandbox import ShellSandbox
44
+
45
+
46
+ class Permission(StrEnum):
47
+ """How much of the shell may run without a person answering for it.
48
+
49
+ Attributes:
50
+ DENY: No shell command runs; the model is told, and can carry on without.
51
+ ASK: Every command that is not already approved is put to the reader.
52
+ SAFE: Every command runs unasked, in a sandbox that writes only the workspace and the temporary
53
+ directory. On a machine with no sandbox: only commands that cannot change anything run unasked.
54
+ FULL: Every command runs unasked with the user's own access.
55
+ AUTO: The old name of ``FULL``, kept so code and config written before the rename still work.
56
+ """
57
+
58
+ DENY = "deny"
59
+ ASK = "ask"
60
+ SAFE = "safe"
61
+ FULL = "full"
62
+ AUTO = "full"
63
+
64
+
65
+ #: The levels strictest first, which is the order the picker lists them in.
66
+ PERMISSION_LEVELS: tuple[Permission, ...] = (Permission.DENY, Permission.ASK, Permission.SAFE, Permission.FULL)
67
+
68
+ #: A shell command refused by ``deny``, as the model reads it. It says what the
69
+ #: reader did and what is still open to the model, because a bare "refused" sends
70
+ #: a model looking for a way around it.
71
+ DENIED_MESSAGE = (
72
+ "Shell commands are turned off by the user (permission: deny). "
73
+ "Use the file tools to read and edit, or tell the user what command you need."
74
+ )
75
+
76
+
77
+ def parse_permission(name: str) -> Permission:
78
+ """Return the level a user typed, or raise with the names that are valid.
79
+
80
+ Args:
81
+ name: A level's name; case and surrounding space do not matter. ``auto``, the name ``full`` had before
82
+ it was renamed, is still taken.
83
+
84
+ Raises:
85
+ ValueError: The name is not one of the four, and the message lists them.
86
+ """
87
+ wanted = name.strip().lower()
88
+ try:
89
+ return Permission("full" if wanted == "auto" else wanted)
90
+ except ValueError:
91
+ levels = ", ".join(level.value for level in PERMISSION_LEVELS)
92
+ raise ValueError(f"Unknown permission: {name}. Try one of: {levels}") from None
93
+
94
+
95
+ class ShellPolicy(Boundary):
96
+ """The level in force, and the question each level asks of a command.
97
+
98
+ One instance is shared by the shell, the approval memory, and the deny
99
+ extension. It holds a single enum member, which is read and replaced
100
+ atomically, so the shell thread changing it and the agent's loop reading it
101
+ need no lock: a command in flight sees the old level or the new one, never a
102
+ mixture.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ level: Permission = Permission.ASK,
108
+ workspace: Path | None = None,
109
+ sandbox: ShellSandbox | None = None,
110
+ ) -> None:
111
+ """Start at a level, for commands that run in a workspace.
112
+
113
+ Args:
114
+ level: The level in force until it is changed.
115
+ workspace: Directory commands run in; ``git`` is only ever judged
116
+ read-only against a repository that can be found from here.
117
+ sandbox: What ``safe`` runs commands in. ``None`` builds the default for ``workspace`` (no network),
118
+ and with no workspace there is no sandbox at all.
119
+ """
120
+ self.level = level
121
+ self.workspace = workspace
122
+ self.sandbox = sandbox if sandbox is not None else (ShellSandbox(workspace) if workspace else None)
123
+
124
+ @property
125
+ def sandboxed(self) -> bool:
126
+ """Return whether a command is run inside the sandbox right now: level ``safe`` on a machine that has one."""
127
+ return self.sandbox_now() is not None
128
+
129
+ def sandbox_now(self) -> Sandboxer | None:
130
+ """Return the sandbox the next command runs in, or ``None`` when it runs as given."""
131
+ if self.level is Permission.SAFE and self.sandbox is not None and self.sandbox.available:
132
+ return self.sandbox
133
+ return None
134
+
135
+ def jit_sandbox(self) -> Sandbox | None:
136
+ """Return the boundary code the model writes runs in, or ``None`` when it gets the user's own access.
137
+
138
+ One answer for one decision: the just-in-time harness asks this instead of reading the level itself, so
139
+ the shell and the code the model wrote cannot disagree about what a level means. `full` is the level that
140
+ grants the model the reader's access, so nothing is left for a boundary to hold back; every other level
141
+ bounds it with the boundary `safe` uses, and a machine with no sandbox has nothing to pass on — the
142
+ reader is told, at the level that claimed a boundary (see :meth:`sandbox_missing`).
143
+ """
144
+ if self.level is Permission.FULL:
145
+ return None
146
+ return self.sandbox.boundary if self.sandbox is not None else None
147
+
148
+ def sandbox_missing(self) -> bool:
149
+ """Return whether the level in force promises a boundary this machine does not have."""
150
+ return self.level is not Permission.FULL and not (self.sandbox is not None and self.sandbox.available)
151
+
152
+ @classmethod
153
+ def from_mode(
154
+ cls, mode: ShellApprovalMode, workspace: Path | None = None, sandbox: ShellSandbox | None = None
155
+ ) -> ShellPolicy:
156
+ """Start at the level the configured approval mode stands for."""
157
+ return cls(Permission.FULL if mode is ShellApprovalMode.ALLOW_ALL else Permission.ASK, workspace, sandbox)
158
+
159
+ def allows_unasked(self, command: str) -> bool:
160
+ """Return whether this level lets a command run without putting it to the reader.
161
+
162
+ ``deny`` never reaches here — the deny extension has already refused —
163
+ but it answers no all the same, so a caller that asks cannot be told yes
164
+ by mistake.
165
+ """
166
+ if self.level is Permission.FULL:
167
+ return True
168
+ if self.level is Permission.SAFE:
169
+ # The sandbox is the boundary, so any command may run in it. Without one, only a command that
170
+ # provably changes nothing is trusted.
171
+ return self.sandboxed or is_read_only(command, self.workspace)
172
+ return False
173
+
174
+
175
+ class ShellDenyExtension(AgentExtension):
176
+ """Refuse every shell command while the level is ``deny``.
177
+
178
+ The approval extension can say yes or ask but has no way to say no on the
179
+ reader's behalf, and answering "abort" to a prompt that was never shown would
180
+ mean putting the panel up and tearing it down. So this runs before it, as the
181
+ outer layer of the onion, and raises into ordinary tool-failure handling: the
182
+ model sees a failed ``bash`` call whose message says why.
183
+ """
184
+
185
+ #: Before the approval extension, which is the layer that would otherwise put
186
+ #: the command to the reader.
187
+ priority = 50
188
+
189
+ def __init__(self, policy: ShellPolicy) -> None:
190
+ """Read the level from the policy the shell changes."""
191
+ self._policy = policy
192
+
193
+ async def on_tool_call(self, context: AgentRunContext, call: ToolCall, call_next: ToolCallNext) -> ToolResult:
194
+ """Let every tool through except a shell command, which ``deny`` refuses."""
195
+ if self._policy.level is Permission.DENY and call.name == SHELL_APPROVAL_TOOL_NAME:
196
+ raise PermissionError(DENIED_MESSAGE)
197
+ return await call_next()
@@ -0,0 +1,66 @@
1
+ """What the shell approval prompt is allowed to remember.
2
+
3
+ ``zett-agent``'s shell extension asks for approval through an external event,
4
+ and can remember a decision only when it is given something to remember it in:
5
+ without storage it reports ``remember_supported=False`` and the prompt cannot
6
+ offer "always allow this command".
7
+
8
+ ZettCode keeps that memory in the process, which is what its prompt promises —
9
+ the exact command is approved for the rest of this run, and the next start
10
+ asks again, exactly like the run-wide policy beside it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from zett_agent.extensions.shell_approval import ShellApprovalMode
16
+
17
+ from . import ShellPolicy
18
+
19
+
20
+ class ShellApprovalMemory:
21
+ """Approval memory for one process: a policy per session, exact commands.
22
+
23
+ This satisfies ``zett-agent``'s ``ShellApprovalStorage``, which the shell
24
+ extension uses to look up a session's mode and to check and record one
25
+ approved command.
26
+
27
+ The shell extension asks :meth:`is_allowed` before it puts a command to the
28
+ reader, which makes it the one place a permission level can say "do not ask":
29
+ ``safe`` answers yes for a command that cannot change anything and ``auto``
30
+ answers yes for every command, while ``ask`` falls through to the commands the
31
+ reader approved by hand. ``deny`` is not decided here, because refusing is not
32
+ a thing this question can express.
33
+ """
34
+
35
+ def __init__(self, policy: ShellPolicy | None = None) -> None:
36
+ """Start with no session policy and nothing approved.
37
+
38
+ Args:
39
+ policy: The permission level to consult; without one every command
40
+ is put to the reader, which is what ``ask`` does.
41
+ """
42
+ self._modes: dict[str, ShellApprovalMode] = {}
43
+ self._allowed: set[str] = set()
44
+ self._policy = policy
45
+
46
+ async def get_session_mode(self, session_id: str) -> ShellApprovalMode:
47
+ """Return a session's policy, which is review until one is set."""
48
+ return self._modes.get(session_id, ShellApprovalMode.REVIEW)
49
+
50
+ async def set_session_mode(self, session_id: str, mode: ShellApprovalMode) -> None:
51
+ """Record one session's policy for the rest of the process."""
52
+ self._modes[session_id] = mode
53
+
54
+ async def clear_session_mode(self, session_id: str) -> None:
55
+ """Forget a session's policy, so the next prompt reviews again."""
56
+ self._modes.pop(session_id, None)
57
+
58
+ async def is_allowed(self, session_id: str, command: str) -> bool:
59
+ """Return whether a command may run without asking: by level, or approved by hand."""
60
+ if self._policy is not None and self._policy.allows_unasked(command):
61
+ return True
62
+ return command in self._allowed
63
+
64
+ async def allow_command(self, command: str) -> None:
65
+ """Remember one exact command for the rest of this run."""
66
+ self._allowed.add(command)
@@ -0,0 +1,435 @@
1
+ """Which shell commands cannot change anything, so the ``safe`` level may skip asking.
2
+
3
+ The question this answers is narrow and the answer is fail-closed: a command is
4
+ read-only only when every part of it is recognised, and anything this module does
5
+ not positively know is *not* read-only. A wrong "yes" runs something nobody
6
+ approved; a wrong "no" costs one keypress at the prompt — so the two errors are
7
+ not weighed equally, and every rule below leans the same way.
8
+
9
+ Three layers, each of which can only say no:
10
+
11
+ 1. **Shape.** The command must be plain words joined by ``|``. The characters
12
+ that make a shell do more than run a program — ``; & < > ( ) $ ` \\ { } !`` and
13
+ newlines — are refused outside quotes, and ``$ ` \\ !`` inside double quotes,
14
+ where the shell still expands them. A word is never *interpreted* here, only
15
+ split and unquoted, so there is no expansion for a hostile string to hide in.
16
+ 2. **Program.** The first word of each pipeline stage must equal a name on a
17
+ short list of programs that have no way to write a file or run another
18
+ program, or one of the three that need their arguments checked (``find``,
19
+ ``rg``, ``git``). Equal, not similar: ``./ls`` is whatever the workspace says
20
+ it is, and ``LD_PRELOAD=x ls`` is not ``ls``, and neither is on the list.
21
+ 3. **Arguments.** For those three, an option is allowed only when it is known to
22
+ be harmless. ``git`` takes options from an allowlist rather than a denylist
23
+ because it accepts any unambiguous *abbreviation* of a long option: a denylist
24
+ that names ``--output`` does not stop ``--outp``, an allowlist that names only
25
+ whole, harmless options stops both.
26
+
27
+ What this does **not** do is make reading harmless. A read-only command can still
28
+ print a secret that sits in the workspace; ``safe`` is a promise that nothing will
29
+ change, not that nothing will be seen. And ``git`` reads its own configuration,
30
+ which can name programs to run (``core.fsmonitor``, ``diff.external``, a textconv
31
+ driver): the repository's config file is scanned for those keys, and a repository
32
+ that has any is treated as one ``git`` cannot be trusted in. The user's global git
33
+ configuration is not scanned.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from pathlib import Path
40
+
41
+ #: Characters that may appear outside quotes: letters, digits, space, and the
42
+ #: punctuation of paths, options, globs, and numbers. Everything else makes the
43
+ #: command something other than a plain word list, and is refused.
44
+ _UNQUOTED = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-./:=,+@%*?[]~^")
45
+
46
+ #: Characters a double-quoted string still expands, which a literal never does.
47
+ _EXPANDING = frozenset("$`\\!")
48
+
49
+ #: Programs with no way to write a file or run another program, whatever the
50
+ #: arguments. Each was checked for output-file options, ``-exec``-style options,
51
+ #: and a pager: ``sort -o``, ``uniq in out``, ``sed -i``/``w``/``e``, ``awk``'s
52
+ #: ``system``, ``tee``, ``xargs``, ``env``, and ``file -C`` are all left off for
53
+ #: exactly that reason.
54
+ _PLAIN = frozenset(
55
+ {
56
+ "pwd",
57
+ "ls",
58
+ "cat",
59
+ "head",
60
+ "tail",
61
+ "wc",
62
+ "stat",
63
+ "du",
64
+ "df",
65
+ "which",
66
+ "whoami",
67
+ "id",
68
+ "uname",
69
+ "basename",
70
+ "dirname",
71
+ "realpath",
72
+ "readlink",
73
+ "echo",
74
+ "grep",
75
+ "diff",
76
+ "cut",
77
+ "jq",
78
+ }
79
+ )
80
+
81
+ #: ``find`` primaries that only match or print. ``-exec``, ``-execdir``, ``-ok``,
82
+ #: ``-okdir``, ``-delete``, and the ``-fprint`` family are absent on purpose: an
83
+ #: option not on this list makes the command one that asks.
84
+ _FIND_OPTIONS = frozenset(
85
+ {
86
+ "-H",
87
+ "-L",
88
+ "-P",
89
+ "-name",
90
+ "-iname",
91
+ "-path",
92
+ "-ipath",
93
+ "-wholename",
94
+ "-regex",
95
+ "-iregex",
96
+ "-type",
97
+ "-xtype",
98
+ "-size",
99
+ "-mtime",
100
+ "-atime",
101
+ "-ctime",
102
+ "-mmin",
103
+ "-amin",
104
+ "-cmin",
105
+ "-newer",
106
+ "-maxdepth",
107
+ "-mindepth",
108
+ "-depth",
109
+ "-empty",
110
+ "-perm",
111
+ "-user",
112
+ "-group",
113
+ "-nouser",
114
+ "-nogroup",
115
+ "-readable",
116
+ "-writable",
117
+ "-executable",
118
+ "-links",
119
+ "-inum",
120
+ "-print",
121
+ "-print0",
122
+ "-prune",
123
+ "-not",
124
+ "-a",
125
+ "-o",
126
+ "-and",
127
+ "-or",
128
+ "-true",
129
+ "-false",
130
+ "-follow",
131
+ }
132
+ )
133
+
134
+ #: Options of ``rg`` that run a program or read a compressed file through one.
135
+ _RG_FORBIDDEN = frozenset({"--pre", "--pre-glob", "--hostname-bin", "--search-zip"})
136
+
137
+ #: ``git`` subcommands that read the repository and nothing else.
138
+ _GIT_SUBCOMMANDS = frozenset(
139
+ {
140
+ "status",
141
+ "log",
142
+ "diff",
143
+ "show",
144
+ "blame",
145
+ "ls-files",
146
+ "ls-tree",
147
+ "rev-parse",
148
+ "rev-list",
149
+ "describe",
150
+ "shortlog",
151
+ "cat-file",
152
+ "merge-base",
153
+ "name-rev",
154
+ "show-ref",
155
+ "diff-tree",
156
+ "grep",
157
+ "branch",
158
+ }
159
+ )
160
+
161
+ #: Long options of those subcommands that are known to be harmless. Whole names
162
+ #: only: git accepts any unambiguous abbreviation of a long option, so a
163
+ #: shortened ``--outp`` is not here and therefore not allowed, which is the point.
164
+ _GIT_LONG_OPTIONS = frozenset(
165
+ {
166
+ "--oneline",
167
+ "--graph",
168
+ "--decorate",
169
+ "--no-decorate",
170
+ "--stat",
171
+ "--shortstat",
172
+ "--numstat",
173
+ "--name-only",
174
+ "--name-status",
175
+ "--patch",
176
+ "--no-patch",
177
+ "--cached",
178
+ "--staged",
179
+ "--all",
180
+ "--abbrev-commit",
181
+ "--abbrev",
182
+ "--no-color",
183
+ "--color",
184
+ "--short",
185
+ "--long",
186
+ "--branch",
187
+ "--porcelain",
188
+ "--untracked-files",
189
+ "--ignored",
190
+ "--format",
191
+ "--pretty",
192
+ "--date",
193
+ "--since",
194
+ "--until",
195
+ "--after",
196
+ "--before",
197
+ "--author",
198
+ "--committer",
199
+ "--grep",
200
+ "--max-count",
201
+ "--skip",
202
+ "--reverse",
203
+ "--merges",
204
+ "--no-merges",
205
+ "--first-parent",
206
+ "--follow",
207
+ "--no-renames",
208
+ "--word-diff",
209
+ "--check",
210
+ "--unified",
211
+ "--raw",
212
+ "--diff-filter",
213
+ "--relative",
214
+ "--no-ext-diff",
215
+ "--no-textconv",
216
+ "--summary",
217
+ "--compact-summary",
218
+ "--tags",
219
+ "--heads",
220
+ "--verify",
221
+ "--show-current",
222
+ "--list",
223
+ "--remotes",
224
+ "--verbose",
225
+ "--abbrev-ref",
226
+ "--symbolic-full-name",
227
+ "--is-inside-work-tree",
228
+ "--show-toplevel",
229
+ "--count",
230
+ "--line-number",
231
+ "--files-with-matches",
232
+ "--ignore-case",
233
+ "--fixed-strings",
234
+ "--extended-regexp",
235
+ "--perl-regexp",
236
+ "--word-regexp",
237
+ "--invert-match",
238
+ "--merged",
239
+ "--no-merged",
240
+ "--contains",
241
+ "--no-contains",
242
+ }
243
+ )
244
+
245
+ #: ``git`` config keys that name a program, and the words that make a config file
246
+ #: something other than a list of settings. Any of them in the repository's own
247
+ #: config means ``git`` there can run what the file says.
248
+ _GIT_CONFIG_TRIPWIRES = (
249
+ "fsmonitor",
250
+ "external",
251
+ "textconv",
252
+ "pager",
253
+ "sshcommand",
254
+ "hookspath",
255
+ "askpass",
256
+ "program",
257
+ "helper",
258
+ "alias",
259
+ "command",
260
+ "cmd",
261
+ "include",
262
+ "!",
263
+ )
264
+
265
+ _NUMBER_OPTION = re.compile(r"-\d+[A-Za-z]*$")
266
+
267
+
268
+ def is_read_only(command: str, workspace: Path | None = None) -> bool:
269
+ """Return whether a shell command is recognised as unable to change anything.
270
+
271
+ Args:
272
+ command: The command line exactly as the model asked to run it.
273
+ workspace: Directory the command runs in. ``git`` needs it, because it is
274
+ only trusted when the repository's own config names no program to
275
+ run; without it ``git`` is never read-only.
276
+
277
+ Returns:
278
+ ``True`` only when the whole command is understood and harmless; ``False``
279
+ for everything else, including every command this module is unsure of.
280
+ """
281
+ stages = _split(command)
282
+ if stages is None:
283
+ return False
284
+ return all(_stage_is_read_only(words, workspace) for words in stages)
285
+
286
+
287
+ def _split(command: str) -> list[list[str]] | None:
288
+ """Split a command into pipeline stages of unquoted words, or ``None``.
289
+
290
+ This is a scanner, not an interpreter: it walks the string once, tracking
291
+ whether it is inside quotes, and it gives up the moment it meets anything it
292
+ would have to *understand* — an operator, an expansion, a control character,
293
+ a quote that does not close, or a character outside the allowed set.
294
+ """
295
+ if not command or len(command) > 4_000 or any(ord(char) < 32 or ord(char) == 127 for char in command):
296
+ return None
297
+ stages: list[list[str]] = [[]]
298
+ word: list[str] | None = None
299
+ quote = ""
300
+ index = 0
301
+ while index < len(command):
302
+ char = command[index]
303
+ index += 1
304
+ if quote == "'":
305
+ if char == "'":
306
+ quote = ""
307
+ else:
308
+ word.append(char) # type: ignore[union-attr]
309
+ continue
310
+ if quote == '"':
311
+ if char == '"':
312
+ quote = ""
313
+ elif char in _EXPANDING:
314
+ return None
315
+ else:
316
+ word.append(char) # type: ignore[union-attr]
317
+ continue
318
+ if char == " ":
319
+ if word is not None:
320
+ stages[-1].append("".join(word))
321
+ word = None
322
+ elif char == "|":
323
+ if index < len(command) and command[index] == "|":
324
+ return None
325
+ if word is not None:
326
+ stages[-1].append("".join(word))
327
+ word = None
328
+ if not stages[-1]:
329
+ return None
330
+ stages.append([])
331
+ elif char in "'\"":
332
+ quote = char
333
+ if word is None:
334
+ word = []
335
+ elif char in _UNQUOTED:
336
+ if word is None:
337
+ word = []
338
+ word.append(char)
339
+ else:
340
+ return None
341
+ if quote:
342
+ return None
343
+ if word is not None:
344
+ stages[-1].append("".join(word))
345
+ return stages if all(stages) else None
346
+
347
+
348
+ def _stage_is_read_only(words: list[str], workspace: Path | None) -> bool:
349
+ """Judge one pipeline stage: its program, then the arguments that need a look."""
350
+ # The program word has to *equal* a name on the list, which is the whole check
351
+ # that `./ls` is not `ls` and `LD_PRELOAD=x ls` is not either: neither is equal
352
+ # to anything on it, so neither needs a rule of its own.
353
+ program, args = words[0], words[1:]
354
+ if program in _PLAIN:
355
+ return True
356
+ if program == "find":
357
+ return _find_is_read_only(args)
358
+ if program == "rg":
359
+ return _rg_is_read_only(args)
360
+ if program == "git":
361
+ return workspace is not None and _git_is_read_only(args, workspace)
362
+ return False
363
+
364
+
365
+ def _find_is_read_only(args: list[str]) -> bool:
366
+ """Allow ``find`` when every option it carries is a matching or printing one."""
367
+ return all(arg in _FIND_OPTIONS or _NUMBER_OPTION.match(arg) for arg in args if arg.startswith("-"))
368
+
369
+
370
+ def _rg_is_read_only(args: list[str]) -> bool:
371
+ """Allow ``rg`` unless an option hands a file to a program."""
372
+ for arg in args:
373
+ if arg == "--":
374
+ break
375
+ name = arg.split("=", 1)[0]
376
+ if name in _RG_FORBIDDEN:
377
+ return False
378
+ if arg.startswith("-") and not arg.startswith("--") and "z" in arg:
379
+ return False
380
+ return True
381
+
382
+
383
+ def _git_is_read_only(args: list[str], workspace: Path) -> bool:
384
+ """Allow a read-only ``git`` subcommand with known-harmless options.
385
+
386
+ The subcommand must be the first word: anything before it is a global option
387
+ such as ``-c`` or ``--exec-path``, which can name a program to run.
388
+ """
389
+ if not args or args[0] not in _GIT_SUBCOMMANDS:
390
+ return False
391
+ subcommand, rest = args[0], args[1:]
392
+ options = []
393
+ for arg in rest:
394
+ if arg == "--":
395
+ break
396
+ if arg.startswith("--"):
397
+ if arg.split("=", 1)[0] not in _GIT_LONG_OPTIONS:
398
+ return False
399
+ options.append(arg.split("=", 1)[0])
400
+ elif arg.startswith("-") and len(arg) > 1:
401
+ # ``-O`` hands ``git grep`` a pager to run; every other short option
402
+ # reads.
403
+ if "O" in arg:
404
+ return False
405
+ options.append(arg)
406
+ if subcommand == "branch":
407
+ # A bare name after ``git branch`` creates a branch, so a name is only
408
+ # a pattern when ``--list`` says it is.
409
+ positional = [arg for arg in rest if not arg.startswith("-")]
410
+ if positional and "--list" not in options:
411
+ return False
412
+ if any(option in {"-d", "-D", "-m", "-M", "-c", "-C", "-u", "-f", "--force"} for option in options):
413
+ return False
414
+ return _git_config_is_inert(workspace)
415
+
416
+
417
+ def _git_config_is_inert(workspace: Path) -> bool:
418
+ """Return whether the repository's own config names no program for git to run.
419
+
420
+ The repository is found the way git finds it, by looking in each parent of the
421
+ workspace in turn, so a shell started in a subdirectory is judged by the config
422
+ of the repository it is in. A ``.git`` that is a file — a worktree or a
423
+ submodule — points somewhere this does not follow, and is not inert.
424
+ """
425
+ for directory in (workspace, *workspace.parents):
426
+ git = directory / ".git"
427
+ try:
428
+ if git.is_dir():
429
+ text = (git / "config").read_text(encoding="utf-8", errors="replace").lower()
430
+ return not any(word in text for word in _GIT_CONFIG_TRIPWIRES)
431
+ if git.exists():
432
+ return False
433
+ except OSError:
434
+ return False
435
+ return False