semora-coding 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.egg-info/
8
+ build/
9
+ dist/
10
+ .coverage
11
+ htmlcov/
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+
16
+
17
+ # Local tool/editor state — machine-specific, never pushed.
18
+ .claude/
19
+ .codecanvas/
20
+ .vscode/
21
+
22
+
23
+ # Superpowers design/spec scratch — working notes, not project documentation.
24
+ docs/superpowers/
25
+
26
+ # 로컬 자격증명 — 절대 커밋 금지.
27
+ a.txt
28
+ *.token
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.5
2
+ Name: semora-coding
3
+ Version: 0.2.0
4
+ Summary: A coding agent's toolset, prompts and policies, assembled over the semora core.
5
+ Project-URL: Homepage, https://github.com/donggyun112/semora
6
+ Project-URL: Source, https://github.com/donggyun112/semora
7
+ Project-URL: Changelog, https://github.com/donggyun112/semora/blob/main/CHANGELOG.md
8
+ Author: donggyun112
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: langchain-core<2,>=1
17
+ Requires-Dist: semora==0.2.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # semora-coding
21
+
22
+ The coding-agent layer over the `semora` core: built-in tools (`read`, `write`, `edit`, `grep`,
23
+ `glob`, `Bash`, `web_fetch`), system-prompt assembly, plan mode, goals, the skill catalog and
24
+ deferred tool search.
25
+
26
+ ```
27
+ uv add "semora[coding]"
28
+ ```
29
+
30
+ It is a reference assembly. The core promises that a tool's effect happens once and that every
31
+ decision about it has a seam; this package is one answer to what those tools and seams say to a
32
+ model. Replace any of it — the core does not know it is here.
@@ -0,0 +1,13 @@
1
+ # semora-coding
2
+
3
+ The coding-agent layer over the `semora` core: built-in tools (`read`, `write`, `edit`, `grep`,
4
+ `glob`, `Bash`, `web_fetch`), system-prompt assembly, plan mode, goals, the skill catalog and
5
+ deferred tool search.
6
+
7
+ ```
8
+ uv add "semora[coding]"
9
+ ```
10
+
11
+ It is a reference assembly. The core promises that a tool's effect happens once and that every
12
+ decision about it has a seam; this package is one answer to what those tools and seams say to a
13
+ model. Replace any of it — the core does not know it is here.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "semora-coding"
7
+ version = "0.2.0"
8
+ description = "A coding agent's toolset, prompts and policies, assembled over the semora core."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [{ name = "donggyun112" }]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Typing :: Typed",
19
+ ]
20
+ urls = { Homepage = "https://github.com/donggyun112/semora", Source = "https://github.com/donggyun112/semora", Changelog = "https://github.com/donggyun112/semora/blob/main/CHANGELOG.md" }
21
+ dependencies = [
22
+ "langchain-core>=1,<2",
23
+ "semora==0.2.0",
24
+ ]
25
+ # The product layer the core used to carry: what a coding agent reads and edits with, the words
26
+ # its plan mode and goal say, the catalog its skills are listed in. None of it decides whether an
27
+ # effect happens once — that stays in `semora` — so none of it belongs in the distribution that
28
+ # makes that promise. A reference assembly, installed on purpose, replaceable as a whole.
29
+
30
+ [tool.uv.sources]
31
+ semora = { workspace = true }
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/semora_coding"]
@@ -0,0 +1,7 @@
1
+ """The coding-agent layer over the semora core.
2
+
3
+ `semora` decides whether an effect happens once and where each decision about it is made. This
4
+ package is what a coding agent does with that: the tools it reads and edits with, the words its
5
+ plan mode and goal say, the catalog its skills are listed in. Import the piece you assemble with —
6
+ `semora_coding.builtins`, `.prompts`, `.plan_mode`, `.goal`, `.skills`, `.tool_search`.
7
+ """
@@ -0,0 +1,249 @@
1
+ """TS-compatible core built-in tool bundle.
2
+
3
+ The bundle intentionally contains file/process tools plus ``web_fetch``. It does not include
4
+ ``web_search``; applications can add their own search provider when they need one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from functools import partial
10
+ from typing import Any
11
+
12
+ from semora.workspace import ToolContext
13
+
14
+ from ._exec import exec_is_read_only, exec_tool
15
+ from ._files import edit_tool, read_tool, write_tool
16
+ from ._search import glob_tool, grep_tool
17
+ from ._types import (
18
+ BuiltinToolState,
19
+ ExecToolOptions,
20
+ Handler,
21
+ WebFetchResponse,
22
+ WebFetchSummarizer,
23
+ WebFetchToolOptions,
24
+ WebFetchTransport,
25
+ error_result,
26
+ )
27
+ from ._web import UrllibWebFetchTransport, web_fetch_tool
28
+
29
+ __all__ = [
30
+ "BuiltinTools",
31
+ "ExecToolOptions",
32
+ "UrllibWebFetchTransport",
33
+ "WebFetchResponse",
34
+ "WebFetchSummarizer",
35
+ "WebFetchToolOptions",
36
+ "WebFetchTransport",
37
+ "builtin_tools",
38
+ ]
39
+
40
+
41
+ class BuiltinTools:
42
+ """Context-aware implementation of the TS sandbox bundle plus ``web_fetch``."""
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ context: ToolContext | None = None,
48
+ exec_options: ExecToolOptions | None = None,
49
+ web_fetch_options: WebFetchToolOptions | None = None,
50
+ _state: BuiltinToolState | None = None,
51
+ ) -> None:
52
+ """Configure tool policy independently from a turn's workspace session."""
53
+ self._context = context or ToolContext(workdir=".")
54
+ self._exec_options = exec_options or ExecToolOptions()
55
+ self._web_fetch_options = web_fetch_options or WebFetchToolOptions()
56
+ self._state = _state or BuiltinToolState()
57
+ self._handlers: dict[str, Handler] = {
58
+ "read": read_tool,
59
+ "write": write_tool,
60
+ "edit": edit_tool,
61
+ "glob": glob_tool,
62
+ "grep": grep_tool,
63
+ "Bash": partial(exec_tool, options=self._exec_options),
64
+ "web_fetch": partial(web_fetch_tool, options=self._web_fetch_options),
65
+ }
66
+ self._definitions = _definitions(self._exec_options.allow_shell)
67
+
68
+ async def execute(self, name: str, call_id: str, arguments: Any) -> dict[str, Any]:
69
+ """Execute a named built-in against the currently bound context."""
70
+ handler = self._handlers.get(name)
71
+ if handler is None:
72
+ return error_result(f"Unknown tool: {name}")
73
+ return dict(await handler(call_id, arguments, self._context, self._state))
74
+
75
+ def get(self, name: str) -> dict[str, Any] | None:
76
+ """Return one model-visible tool definition."""
77
+ definition = self._definitions.get(name)
78
+ return dict(definition) if definition is not None else None
79
+
80
+ def list(self) -> list[dict[str, Any]]:
81
+ """Return the stable core set, deliberately excluding ``web_search``."""
82
+ return [dict(definition) for definition in self._definitions.values()]
83
+
84
+ def get_context(self) -> ToolContext:
85
+ """Return the immutable execution context currently bound to the tools."""
86
+ return self._context
87
+
88
+ def with_context(self, context: ToolContext) -> BuiltinTools:
89
+ """Bind a runtime workspace while retaining locks and fetch cache across turns."""
90
+ return BuiltinTools(
91
+ context=context,
92
+ exec_options=self._exec_options,
93
+ web_fetch_options=self._web_fetch_options,
94
+ _state=self._state,
95
+ )
96
+
97
+
98
+ def builtin_tools(
99
+ *,
100
+ context: ToolContext | None = None,
101
+ exec_options: ExecToolOptions | None = None,
102
+ web_fetch_options: WebFetchToolOptions | None = None,
103
+ ) -> BuiltinTools:
104
+ """Create the standard bundle, optionally bound to a caller-managed workspace context."""
105
+ return BuiltinTools(
106
+ context=context,
107
+ exec_options=exec_options,
108
+ web_fetch_options=web_fetch_options,
109
+ )
110
+
111
+
112
+ def _definitions(allow_shell: bool) -> dict[str, dict[str, Any]]:
113
+ return {
114
+ "read": {
115
+ "name": "read",
116
+ "description": (
117
+ "Read a file or directory from the workspace. Text is line-numbered and paged; "
118
+ "images return inline and Jupyter notebooks return readable cell text."
119
+ ),
120
+ "parameters": {
121
+ "type": "object",
122
+ "properties": {
123
+ "path": {"type": "string"},
124
+ "offset": {"type": "number"},
125
+ "limit": {"type": "number"},
126
+ "pages": {"type": "string"},
127
+ },
128
+ "required": ["path"],
129
+ },
130
+ "is_read_only": True,
131
+ "is_concurrency_safe": True,
132
+ },
133
+ "write": {
134
+ "name": "write",
135
+ "description": "Create or replace a UTF-8 file inside the workspace.",
136
+ "parameters": {
137
+ "type": "object",
138
+ "properties": {"path": {"type": "string"}, "content": {"type": "string"}},
139
+ "required": ["path", "content"],
140
+ },
141
+ "is_read_only": False,
142
+ "is_concurrency_safe": False,
143
+ },
144
+ "edit": {
145
+ "name": "edit",
146
+ "description": "Replace an exact string in a UTF-8 workspace file.",
147
+ "parameters": {
148
+ "type": "object",
149
+ "properties": {
150
+ "path": {"type": "string"},
151
+ "old_string": {"type": "string"},
152
+ "new_string": {"type": "string"},
153
+ "replace_all": {"type": "boolean"},
154
+ },
155
+ "required": ["path", "old_string", "new_string"],
156
+ },
157
+ "is_read_only": False,
158
+ "is_concurrency_safe": False,
159
+ },
160
+ "grep": {
161
+ "name": "grep",
162
+ "description": (
163
+ "Search workspace file contents with ripgrep, falling back to system grep. "
164
+ "Supports content, files_with_matches, and count output modes."
165
+ ),
166
+ "parameters": {
167
+ "type": "object",
168
+ "properties": {
169
+ "pattern": {"type": "string"},
170
+ "path": {"type": "string"},
171
+ "glob": {"type": "string"},
172
+ "type": {"type": "string"},
173
+ "output_mode": {
174
+ "type": "string",
175
+ "enum": ["content", "files_with_matches", "count"],
176
+ },
177
+ "-A": {"type": "number"},
178
+ "-B": {"type": "number"},
179
+ "-C": {"type": "number"},
180
+ "context": {"type": "number"},
181
+ "-n": {"type": "boolean"},
182
+ "-i": {"type": "boolean"},
183
+ "head_limit": {"type": "number"},
184
+ "offset": {"type": "number"},
185
+ "multiline": {"type": "boolean"},
186
+ },
187
+ "required": ["pattern"],
188
+ },
189
+ "is_read_only": True,
190
+ "is_concurrency_safe": True,
191
+ },
192
+ "glob": {
193
+ "name": "glob",
194
+ "description": "Find workspace files by glob pattern with ripgrep.",
195
+ "parameters": {
196
+ "type": "object",
197
+ "properties": {
198
+ "pattern": {"type": "string"},
199
+ "path": {"type": "string"},
200
+ "head_limit": {"type": "number"},
201
+ "offset": {"type": "number"},
202
+ },
203
+ "required": ["pattern"],
204
+ },
205
+ "is_read_only": True,
206
+ "is_concurrency_safe": True,
207
+ },
208
+ "Bash": {
209
+ "name": "Bash",
210
+ "description": (
211
+ "Execute an allow-listed command in the workspace. "
212
+ + (
213
+ "argv is preferred; shell strings are enabled for pipes and globs."
214
+ if allow_shell
215
+ else "Shell-string mode is disabled; use argv."
216
+ )
217
+ ),
218
+ "parameters": {
219
+ "type": "object",
220
+ "properties": {
221
+ "argv": {"type": "array", "items": {"type": "string"}},
222
+ "command": {"type": "string"},
223
+ "timeoutMs": {"type": "number"},
224
+ "cwd": {"type": "string"},
225
+ "run_in_background": {"type": "boolean"},
226
+ },
227
+ },
228
+ "is_read_only": exec_is_read_only,
229
+ "is_concurrency_safe": exec_is_read_only,
230
+ },
231
+ "web_fetch": {
232
+ "name": "web_fetch",
233
+ "description": (
234
+ "Fetch an HTTPS URL and return readable content. HTTP is upgraded to HTTPS; "
235
+ "results are cached by URL and prompt."
236
+ ),
237
+ "parameters": {
238
+ "type": "object",
239
+ "properties": {
240
+ "url": {"type": "string"},
241
+ "prompt": {"type": "string"},
242
+ "max_chars": {"type": "number"},
243
+ },
244
+ "required": ["url"],
245
+ },
246
+ "is_read_only": True,
247
+ "is_concurrency_safe": True,
248
+ },
249
+ }
@@ -0,0 +1,303 @@
1
+ """Sandboxed command execution built-in."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ from collections.abc import Mapping
8
+
9
+ from semora.workspace import SandboxCommand, ToolContext, WorkspaceViolation
10
+
11
+ from ._types import (
12
+ BuiltinToolState,
13
+ ExecToolOptions,
14
+ ToolResult,
15
+ error_result,
16
+ require_workspace,
17
+ text_result,
18
+ tool_environment,
19
+ )
20
+
21
+ MAX_OUTPUT_BYTES = 256 * 1024
22
+ MAX_PERSIST_BYTES = 16 * 1024 * 1024
23
+ MAX_TIMEOUT_MS = 600_000
24
+
25
+ SHELL_INTERPRETERS = frozenset(
26
+ {
27
+ "bash",
28
+ "sh",
29
+ "zsh",
30
+ "dash",
31
+ "ksh",
32
+ "tcsh",
33
+ "csh",
34
+ "fish",
35
+ "ash",
36
+ "busybox",
37
+ "python",
38
+ "ruby",
39
+ "perl",
40
+ "node",
41
+ "nodejs",
42
+ "deno",
43
+ "bun",
44
+ "php",
45
+ "lua",
46
+ "awk",
47
+ "gawk",
48
+ "mawk",
49
+ "nawk",
50
+ "sed",
51
+ "find",
52
+ "xargs",
53
+ "tar",
54
+ "cpio",
55
+ "zip",
56
+ "unzip",
57
+ "git",
58
+ "hg",
59
+ "svn",
60
+ "wget",
61
+ "curl",
62
+ "scp",
63
+ "rsync",
64
+ "ssh",
65
+ "sshpass",
66
+ "telnet",
67
+ "nc",
68
+ "ncat",
69
+ "socat",
70
+ "env",
71
+ "sudo",
72
+ "doas",
73
+ "su",
74
+ "docker",
75
+ "podman",
76
+ "kubectl",
77
+ "nsenter",
78
+ "chroot",
79
+ "unshare",
80
+ "setsid",
81
+ }
82
+ )
83
+
84
+
85
+ async def exec_tool(
86
+ call_id: str,
87
+ arguments: object,
88
+ context: ToolContext,
89
+ state: BuiltinToolState,
90
+ options: ExecToolOptions,
91
+ ) -> ToolResult:
92
+ """Execute through ``WorkspaceSession.run``, porting ``createExecTool().execute``."""
93
+ del state
94
+ params = arguments if isinstance(arguments, dict) else {}
95
+ if params.get("run_in_background") is True:
96
+ return error_result(
97
+ "run_in_background needs a background-task registry (not supported by this tool bundle)"
98
+ )
99
+ workspace = require_workspace(context)
100
+ if workspace is None:
101
+ return error_result("Bash requires an active workspace")
102
+
103
+ resolved = _resolve_command(params, options)
104
+ if isinstance(resolved, str):
105
+ return error_result(resolved)
106
+ argv, label = resolved
107
+
108
+ raw_cwd = params.get("cwd")
109
+ cwd = raw_cwd.strip() if isinstance(raw_cwd, str) and raw_cwd.strip() else "."
110
+ try:
111
+ resolved_cwd = await workspace.resolve(cwd, access="read")
112
+ except (OSError, ValueError, WorkspaceViolation) as error:
113
+ return error_result(f'cwd "{cwd}" is not inside the workspace: {error}')
114
+
115
+ timeout_ms = _timeout_ms(params.get("timeoutMs"), options.default_timeout_ms)
116
+ try:
117
+ result = await workspace.run(
118
+ SandboxCommand(
119
+ argv=argv,
120
+ cwd=str(resolved_cwd.path),
121
+ env=tool_environment(options.env_allow_list),
122
+ inherit_env=False,
123
+ timeout_seconds=timeout_ms / 1000,
124
+ require_isolation=options.require_isolation,
125
+ allowed_domains=options.allowed_domains,
126
+ )
127
+ )
128
+ except (OSError, ValueError, WorkspaceViolation) as error:
129
+ return error_result(f"sandboxed exec failed: {error}")
130
+
131
+ combined = result.stdout + (f"\n[stderr]\n{result.stderr}" if result.stderr else "")
132
+ encoded = combined.encode()
133
+ persisted_path: str | None = None
134
+ if len(encoded) > MAX_OUTPUT_BYTES:
135
+ safe_id = re.sub(r"[^A-Za-z0-9_-]", "_", call_id)[:64] or "out"
136
+ persisted_path = f".exec-output-{safe_id}.log"
137
+ try:
138
+ await workspace.fs.write_file(
139
+ persisted_path, encoded[:MAX_PERSIST_BYTES], atomic=True
140
+ )
141
+ except (OSError, ValueError, WorkspaceViolation):
142
+ persisted_path = None
143
+ text = _utf8_prefix(encoded, MAX_OUTPUT_BYTES) or "(no output)"
144
+ if persisted_path is not None:
145
+ text += (
146
+ f"\n\n[full output ({min(len(encoded), MAX_PERSIST_BYTES)} bytes) written to "
147
+ f"{persisted_path} — read this file for the complete output]"
148
+ )
149
+ elif len(encoded) > MAX_OUTPUT_BYTES:
150
+ text += f"\n\n[Output truncated at {MAX_OUTPUT_BYTES} bytes]"
151
+
152
+ if result.timed_out:
153
+ status = "timeout"
154
+ text += f"\n\n[Killed: timeout after {timeout_ms}ms]"
155
+ elif result.aborted:
156
+ status = "aborted"
157
+ text += "\n\n[Aborted by caller]"
158
+ elif result.signal:
159
+ status = f"signal {result.signal}"
160
+ text += f"\n\n[Killed by signal: {result.signal}]"
161
+ elif result.exit_code == 0:
162
+ status = "ok"
163
+ else:
164
+ status = f"exit {result.exit_code if result.exit_code is not None else 'unknown'}"
165
+ return text_result(text if status == "ok" else f"[{status}] {label}\n{text}")
166
+
167
+
168
+ def _resolve_command(
169
+ params: Mapping[str, object], options: ExecToolOptions
170
+ ) -> tuple[list[str], str] | str:
171
+ argv_value = params.get("argv")
172
+ allow = frozenset(options.allow_list)
173
+ allow_all = "*" in allow
174
+ if isinstance(argv_value, list) and argv_value:
175
+ if not all(isinstance(item, str) for item in argv_value):
176
+ return "argv must be an array of strings"
177
+ argv = [str(item) for item in argv_value]
178
+ error = _validate_program(argv[0])
179
+ if error is not None:
180
+ return error
181
+ if not allow:
182
+ return (
183
+ "Bash is unconfigured: ExecToolOptions requires a non-empty allow_list. "
184
+ "Pass explicit command names to enable them."
185
+ )
186
+ if not allow_all and argv[0] not in allow:
187
+ return f'Executable "{argv[0]}" not in allow_list. Allowed: {", ".join(sorted(allow))}'
188
+ if not options.allow_shell and _is_interpreter(argv[0]):
189
+ return (
190
+ f'Executable "{argv[0]}" is a shell/interpreter/exec-surface and is blocked. '
191
+ "Set allow_shell=True only when an OS sandbox is the real boundary."
192
+ )
193
+ return argv, " ".join(argv)
194
+
195
+ command = params.get("command")
196
+ if isinstance(command, str) and command.strip():
197
+ if not options.allow_shell:
198
+ return 'Shell-string mode is disabled. Use { argv: ["program", "arg1", ...] } instead.'
199
+ if not allow:
200
+ return (
201
+ "Bash is unconfigured: ExecToolOptions requires a non-empty allow_list. "
202
+ "Pass explicit command names to enable them."
203
+ )
204
+ if not allow_all:
205
+ programs = _shell_programs(command)
206
+ if programs is None:
207
+ return (
208
+ "Shell command could not be verified against the allow_list. "
209
+ "Use argv form or a simpler command."
210
+ )
211
+ offending = sorted(set(programs) - allow)
212
+ if offending:
213
+ return (
214
+ f"Command(s) not in allow_list: {', '.join(offending)}. "
215
+ f"Allowed: {', '.join(sorted(allow))}"
216
+ )
217
+ return ["bash", "-lc", command], command
218
+ return "Either argv (preferred) or command must be provided"
219
+
220
+
221
+ def _validate_program(program: str) -> str | None:
222
+ if not program:
223
+ return "program is empty"
224
+ if "/" in program or "\\" in program:
225
+ return f'program "{program}" must be a bare command name (no path separators)'
226
+ if ".." in program:
227
+ return f'program "{program}" must not contain ".."'
228
+ if program.startswith("-"):
229
+ return f'program "{program}" must not start with "-"'
230
+ return None
231
+
232
+
233
+ def _is_interpreter(program: str) -> bool:
234
+ canonical = re.sub(
235
+ r"^(python|ruby|perl|node|lua|php)(?:-|)(?:\d+(?:\.\d+)?)$", r"\1", program
236
+ )
237
+ if canonical == "nodejs":
238
+ canonical = "node"
239
+ return canonical in SHELL_INTERPRETERS
240
+
241
+
242
+ def _shell_programs(command: str) -> list[str] | None:
243
+ """Conservatively parse simple shell pipelines for per-command allow-list checks."""
244
+ if any(marker in command for marker in ("$", "`", "\n", "\r", "<", ">", "(", ")")):
245
+ return None
246
+ try:
247
+ lexer = shlex.shlex(command, posix=True, punctuation_chars="|&;")
248
+ lexer.whitespace_split = True
249
+ tokens = list(lexer)
250
+ except ValueError:
251
+ return None
252
+ programs: list[str] = []
253
+ expecting = True
254
+ for token in tokens:
255
+ if token in {"|", "||", "&&", ";", "&"}:
256
+ if expecting:
257
+ return None
258
+ expecting = True
259
+ continue
260
+ if expecting and "=" in token and not token.startswith("="):
261
+ continue
262
+ if expecting:
263
+ if _validate_program(token) is not None:
264
+ return None
265
+ programs.append(token)
266
+ expecting = False
267
+ return None if expecting or not programs else programs
268
+
269
+
270
+ def _timeout_ms(value: object, default: int) -> int:
271
+ selected = (
272
+ int(value)
273
+ if isinstance(value, (int, float)) and not isinstance(value, bool)
274
+ else default
275
+ )
276
+ return min(max(selected, 1_000), MAX_TIMEOUT_MS)
277
+
278
+
279
+ def _utf8_prefix(value: bytes, limit: int) -> str:
280
+ return value[:limit].decode("utf-8", errors="ignore")
281
+
282
+
283
+ def exec_is_read_only(arguments: object) -> bool:
284
+ """Fail-closed concurrency classification from TS ``classifyReadOnly``."""
285
+ if not isinstance(arguments, dict) or arguments.get("run_in_background") is True:
286
+ return False
287
+ argv = arguments.get("argv")
288
+ if not isinstance(argv, list) or not argv or not all(isinstance(item, str) for item in argv):
289
+ return False
290
+ return argv[0] in {
291
+ "cat",
292
+ "head",
293
+ "tail",
294
+ "wc",
295
+ "pwd",
296
+ "ls",
297
+ "rg",
298
+ "grep",
299
+ "find",
300
+ "stat",
301
+ "file",
302
+ "diff",
303
+ }