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.
- semora_coding-0.2.0/.gitignore +28 -0
- semora_coding-0.2.0/PKG-INFO +32 -0
- semora_coding-0.2.0/README.md +13 -0
- semora_coding-0.2.0/pyproject.toml +34 -0
- semora_coding-0.2.0/src/semora_coding/__init__.py +7 -0
- semora_coding-0.2.0/src/semora_coding/builtins/__init__.py +249 -0
- semora_coding-0.2.0/src/semora_coding/builtins/_exec.py +303 -0
- semora_coding-0.2.0/src/semora_coding/builtins/_files.py +257 -0
- semora_coding-0.2.0/src/semora_coding/builtins/_search.py +384 -0
- semora_coding-0.2.0/src/semora_coding/builtins/_types.py +118 -0
- semora_coding-0.2.0/src/semora_coding/builtins/_web.py +209 -0
- semora_coding-0.2.0/src/semora_coding/goal.py +87 -0
- semora_coding-0.2.0/src/semora_coding/plan_mode.py +134 -0
- semora_coding-0.2.0/src/semora_coding/prompts.py +98 -0
- semora_coding-0.2.0/src/semora_coding/py.typed +0 -0
- semora_coding-0.2.0/src/semora_coding/skills.py +424 -0
- semora_coding-0.2.0/src/semora_coding/tool_search.py +224 -0
|
@@ -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
|
+
}
|