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.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- 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
|