coderio 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderio/__init__.py +14 -0
- coderio/agent/__init__.py +5 -0
- coderio/agent/_deepagents_compat.py +139 -0
- coderio/agent/command_review.py +140 -0
- coderio/agent/deep_loop.py +758 -0
- coderio/agent/harness.py +768 -0
- coderio/agent/harness_middleware.py +293 -0
- coderio/agent/permission_middleware.py +52 -0
- coderio/agent/prompts.py +304 -0
- coderio/agent/skill_tool.py +70 -0
- coderio/agent/state.py +144 -0
- coderio/agent/stream.py +93 -0
- coderio/cli/__init__.py +0 -0
- coderio/cli/app.py +257 -0
- coderio/cli/commands.py +339 -0
- coderio/cli/credentials.py +70 -0
- coderio/cli/mcp_cmd.py +227 -0
- coderio/cli/multimodal.py +94 -0
- coderio/cli/onboarding.py +270 -0
- coderio/cli/providers.py +113 -0
- coderio/cli/render.py +28 -0
- coderio/cli/repl.py +189 -0
- coderio/cli/run_cmd.py +171 -0
- coderio/cli/skills_cmd.py +77 -0
- coderio/cli/stream.py +224 -0
- coderio/cli/tui.py +1584 -0
- coderio/cli/tui_onboarding.py +465 -0
- coderio/cli/tui_screens.py +334 -0
- coderio/cli/tui_widgets.py +453 -0
- coderio/config/__init__.py +21 -0
- coderio/config/bootstrap.py +38 -0
- coderio/config/loader.py +273 -0
- coderio/config/models.py +193 -0
- coderio/config/trust.py +136 -0
- coderio/llm/__init__.py +3 -0
- coderio/llm/factory.py +164 -0
- coderio/llm/probe.py +153 -0
- coderio/mcp_loader.py +274 -0
- coderio/session/__init__.py +3 -0
- coderio/session/message.py +79 -0
- coderio/session/store.py +244 -0
- coderio/skills/__init__.py +4 -0
- coderio/skills/bundled/executing-plans/SKILL.md +15 -0
- coderio/skills/lion-skills/LICENSE +21 -0
- coderio/skills/lion-skills/README.md +114 -0
- coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
- coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
- coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
- coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
- coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
- coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
- coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
- coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
- coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
- coderio/skills/models.py +66 -0
- coderio/skills/parser.py +50 -0
- coderio/skills/store.py +102 -0
- coderio/tools/__init__.py +94 -0
- coderio/tools/base.py +55 -0
- coderio/tools/bash.py +168 -0
- coderio/tools/command_policy.py +364 -0
- coderio/tools/edit_file.py +55 -0
- coderio/tools/glob_tool.py +21 -0
- coderio/tools/grep_tool.py +90 -0
- coderio/tools/linux_sandbox.py +196 -0
- coderio/tools/list_dir.py +58 -0
- coderio/tools/multi_edit.py +62 -0
- coderio/tools/note.py +72 -0
- coderio/tools/permission.py +214 -0
- coderio/tools/read_file.py +51 -0
- coderio/tools/sandbox_runner.py +113 -0
- coderio/tools/todo.py +71 -0
- coderio/tools/web_fetch.py +188 -0
- coderio/tools/web_search.py +49 -0
- coderio/tools/win_job.py +214 -0
- coderio/tools/win_sandbox.py +639 -0
- coderio/tools/write_file.py +25 -0
- coderio-0.4.0.dist-info/METADATA +490 -0
- coderio-0.4.0.dist-info/RECORD +97 -0
- coderio-0.4.0.dist-info/WHEEL +4 -0
- coderio-0.4.0.dist-info/entry_points.txt +2 -0
- coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""Windows sandbox: Job Object resource limits + Restricted Token plumbing.
|
|
2
|
+
|
|
3
|
+
HONEST STATUS (verified by independent audit, 2026-08-11):
|
|
4
|
+
|
|
5
|
+
This module provides TWO layers, with very different maturity:
|
|
6
|
+
|
|
7
|
+
1. **Job Object resource limits** — ✅ WORKS. Child processes run in a Job
|
|
8
|
+
Object with KILL_ON_JOB_CLOSE (reliable cleanup) and a process-count cap
|
|
9
|
+
(prevents fork bombs). This is real and tested.
|
|
10
|
+
|
|
11
|
+
2. **Restricted Token write isolation** — ⚠️ PLUMBED BUT NOT EFFECTIVE.
|
|
12
|
+
``create_restricted_token`` calls ``CreateRestrictedToken`` with the
|
|
13
|
+
LUA_TOKEN flag and ``run_sandboxed`` applies the token to the child via
|
|
14
|
+
``CreateProcessAsUserW`` (the plumbing is real — the token IS used to
|
|
15
|
+
launch the process). HOWEVER, the token is currently a NO-OP:
|
|
16
|
+
|
|
17
|
+
On a non-admin user (the common case), the process is already running at
|
|
18
|
+
Medium integrity. ``CreateRestrictedToken(LUA_TOKEN)`` with no disable/
|
|
19
|
+
remove/restrict SID lists returns an equivalent token. Verified by
|
|
20
|
+
``GetTokenInformation``: original and restricted tokens both report
|
|
21
|
+
integrity level 0x2000 (Medium) — identical privileges. The sandboxed
|
|
22
|
+
child has the SAME filesystem write permissions as an unsandboxed child.
|
|
23
|
+
|
|
24
|
+
To make the token actually restrict writes, ONE of these is needed (none
|
|
25
|
+
are currently implemented):
|
|
26
|
+
(a) Pass a Low IntegrityLevel SID to ``SetTokenInformation`` — but then
|
|
27
|
+
the child can't write ANYTHING (including the workspace), which
|
|
28
|
+
breaks a coding agent. Would need per-directory ACL grants.
|
|
29
|
+
(b) Pass restricting SIDs to ``CreateRestrictedToken`` + per-directory
|
|
30
|
+
ACLs (SetEntriesInAcl + SetSecurityInfo) — grant workspace write,
|
|
31
|
+
deny elsewhere. This is the OpenAI Codex approach but needs ~500
|
|
32
|
+
lines of ACL code.
|
|
33
|
+
(c) Drop privileges via DISABLE_MAX_PRIVILEGE — removes admin-style
|
|
34
|
+
privileges but doesn't restrict file writes (ACL controls those).
|
|
35
|
+
|
|
36
|
+
Until one of these is implemented, ``sandbox_mode = "write"`` on Windows
|
|
37
|
+
provides the SAME isolation as ``sandbox_mode = "job"`` (resource limits
|
|
38
|
+
only). The Restricted Token machinery is kept here as plumbing for the
|
|
39
|
+
eventual ACL work, not as a functional isolation layer.
|
|
40
|
+
|
|
41
|
+
WHAT ACTUALLY ISOLATES WRITES ON WINDOWS TODAY: nothing in this module. Use
|
|
42
|
+
the command blacklist (command_policy.py) for accidental-damage prevention,
|
|
43
|
+
or run on Linux with bubblewrap (linux_sandbox.py) for true OS-level
|
|
44
|
+
write isolation. For fully untrusted code, use a VM.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import logging
|
|
50
|
+
import sys
|
|
51
|
+
from typing import TYPE_CHECKING
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
_log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
# Win32 token access rights (winnt.h TOKEN_*).
|
|
59
|
+
TOKEN_QUERY = 0x0008
|
|
60
|
+
TOKEN_DUPLICATE = 0x0002
|
|
61
|
+
TOKEN_ASSIGN_PRIMARY = 0x0001
|
|
62
|
+
|
|
63
|
+
# Win32 CreateRestrictedToken flags (winnt.h).
|
|
64
|
+
# LUA_TOKEN (0x4): creates a filtered token at reduced integrity (UAC-style).
|
|
65
|
+
# More permissive than WRITE_RESTRICTED — the child can still load DLLs
|
|
66
|
+
# and write to user-writable locations, but runs at a lower integrity
|
|
67
|
+
# level (can't write to Program Files, Windows, etc.). This is the
|
|
68
|
+
# reliable choice for a coding agent sandbox.
|
|
69
|
+
# WRITE_RESTRICTED (0x8): restricting SIDs apply only to write operations.
|
|
70
|
+
# Stronger isolation but causes STATUS_DLL_INIT_FAILED (0xC0000142) for
|
|
71
|
+
# many system executables (cmd.exe, powershell.exe) because their DLL
|
|
72
|
+
# loading touches system directories that the restricted token denies.
|
|
73
|
+
# OpenAI Codex works around this with per-directory ACLs; we use LUA_TOKEN
|
|
74
|
+
# as the default and offer WRITE_RESTRICTED as an opt-in for users who
|
|
75
|
+
# set up the ACL workarounds.
|
|
76
|
+
CREATE_RESTRICTED_TOKEN_LUA_TOKEN = 0x0004
|
|
77
|
+
CREATE_RESTRICTED_TOKEN_WRITE_RESTRICTED = 0x0008
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_sandbox_available() -> bool:
|
|
81
|
+
"""Check if the Windows sandbox primitives are available.
|
|
82
|
+
|
|
83
|
+
Returns True only on Windows. On Linux/macOS, callers should use
|
|
84
|
+
linux_sandbox (bubblewrap) instead.
|
|
85
|
+
"""
|
|
86
|
+
return sys.platform == "win32"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Module-level cache for Win32 function signatures. ctypes defaults return
|
|
90
|
+
# type to c_int, which TRUNCATES 64-bit HANDLE values on 64-bit Windows —
|
|
91
|
+
# that was the root cause of "OpenProcessToken failed (err=0)": the call
|
|
92
|
+
# succeeded but the returned handle was mangled. Setting argtypes/restype
|
|
93
|
+
# once (cached here) fixes it for all callers.
|
|
94
|
+
_win32_signed = False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ensure_win32_signatures() -> None:
|
|
98
|
+
"""Set argtypes/restype on every Win32 function we call (idempotent).
|
|
99
|
+
|
|
100
|
+
Without this, HANDLE returns get truncated to c_int on 64-bit Windows.
|
|
101
|
+
Also sets use_last_error=True so ctypes.get_last_error() works as a
|
|
102
|
+
Win32 GetLastError equivalent. Safe to call repeatedly (cached flag).
|
|
103
|
+
"""
|
|
104
|
+
global _win32_signed
|
|
105
|
+
if _win32_signed or sys.platform != "win32":
|
|
106
|
+
return
|
|
107
|
+
import ctypes
|
|
108
|
+
import ctypes.wintypes as wt
|
|
109
|
+
|
|
110
|
+
advapi32 = ctypes.windll.advapi32 # type: ignore[attr-defined]
|
|
111
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
112
|
+
|
|
113
|
+
advapi32.OpenProcessToken.restype = wt.BOOL
|
|
114
|
+
advapi32.OpenProcessToken.argtypes = [wt.HANDLE, wt.DWORD, ctypes.POINTER(wt.HANDLE)]
|
|
115
|
+
advapi32.OpenProcessToken.use_last_error = True
|
|
116
|
+
|
|
117
|
+
advapi32.CreateRestrictedToken.restype = wt.BOOL
|
|
118
|
+
advapi32.CreateRestrictedToken.argtypes = [
|
|
119
|
+
wt.HANDLE,
|
|
120
|
+
wt.DWORD,
|
|
121
|
+
wt.DWORD,
|
|
122
|
+
ctypes.c_void_p,
|
|
123
|
+
wt.DWORD,
|
|
124
|
+
ctypes.c_void_p,
|
|
125
|
+
wt.DWORD,
|
|
126
|
+
ctypes.c_void_p,
|
|
127
|
+
ctypes.POINTER(wt.HANDLE),
|
|
128
|
+
]
|
|
129
|
+
advapi32.CreateRestrictedToken.use_last_error = True
|
|
130
|
+
|
|
131
|
+
advapi32.DuplicateTokenEx.restype = wt.BOOL
|
|
132
|
+
advapi32.DuplicateTokenEx.argtypes = [
|
|
133
|
+
wt.HANDLE,
|
|
134
|
+
wt.DWORD,
|
|
135
|
+
ctypes.c_void_p,
|
|
136
|
+
wt.DWORD,
|
|
137
|
+
wt.DWORD,
|
|
138
|
+
ctypes.POINTER(wt.HANDLE),
|
|
139
|
+
]
|
|
140
|
+
advapi32.DuplicateTokenEx.use_last_error = True
|
|
141
|
+
|
|
142
|
+
advapi32.CreateProcessAsUserW.restype = wt.BOOL
|
|
143
|
+
advapi32.CreateProcessAsUserW.argtypes = [
|
|
144
|
+
wt.HANDLE,
|
|
145
|
+
wt.LPCWSTR,
|
|
146
|
+
wt.LPWSTR,
|
|
147
|
+
ctypes.c_void_p,
|
|
148
|
+
ctypes.c_void_p,
|
|
149
|
+
wt.BOOL,
|
|
150
|
+
wt.DWORD,
|
|
151
|
+
ctypes.c_void_p,
|
|
152
|
+
wt.LPCWSTR,
|
|
153
|
+
ctypes.c_void_p,
|
|
154
|
+
ctypes.c_void_p,
|
|
155
|
+
]
|
|
156
|
+
advapi32.CreateProcessAsUserW.use_last_error = True
|
|
157
|
+
|
|
158
|
+
# NOTE: CloseHandle is a kernel32 function, NOT advapi32 — do not set a
|
|
159
|
+
# signature on advapi32.CloseHandle (it doesn't exist there and accessing
|
|
160
|
+
# it raises "function not found"). All handle cleanup uses kernel32.
|
|
161
|
+
|
|
162
|
+
kernel32.GetCurrentProcess.restype = wt.HANDLE
|
|
163
|
+
kernel32.CreatePipe.restype = wt.BOOL
|
|
164
|
+
kernel32.CreatePipe.argtypes = [
|
|
165
|
+
ctypes.POINTER(wt.HANDLE),
|
|
166
|
+
ctypes.POINTER(wt.HANDLE),
|
|
167
|
+
ctypes.c_void_p,
|
|
168
|
+
wt.DWORD,
|
|
169
|
+
]
|
|
170
|
+
kernel32.CreatePipe.use_last_error = True
|
|
171
|
+
kernel32.SetHandleInformation.restype = wt.BOOL
|
|
172
|
+
kernel32.SetHandleInformation.argtypes = [wt.HANDLE, wt.DWORD, wt.DWORD]
|
|
173
|
+
kernel32.WaitForSingleObject.restype = wt.DWORD
|
|
174
|
+
kernel32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD]
|
|
175
|
+
kernel32.GetExitCodeProcess.restype = wt.BOOL
|
|
176
|
+
kernel32.GetExitCodeProcess.argtypes = [wt.HANDLE, ctypes.POINTER(wt.DWORD)]
|
|
177
|
+
kernel32.GetProcessId.restype = wt.DWORD
|
|
178
|
+
kernel32.GetProcessId.argtypes = [wt.HANDLE]
|
|
179
|
+
kernel32.CloseHandle.restype = wt.BOOL
|
|
180
|
+
kernel32.CloseHandle.argtypes = [wt.HANDLE]
|
|
181
|
+
kernel32.ReadFile.restype = wt.BOOL
|
|
182
|
+
kernel32.ReadFile.argtypes = [
|
|
183
|
+
wt.HANDLE,
|
|
184
|
+
ctypes.c_void_p,
|
|
185
|
+
wt.DWORD,
|
|
186
|
+
ctypes.POINTER(wt.DWORD),
|
|
187
|
+
ctypes.c_void_p,
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
_win32_signed = True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def create_restricted_token(flag: int = CREATE_RESTRICTED_TOKEN_LUA_TOKEN) -> int | None:
|
|
194
|
+
"""Create a filtered token from the current process's token.
|
|
195
|
+
|
|
196
|
+
Uses ``CreateRestrictedToken`` with the given flag. Two options:
|
|
197
|
+
|
|
198
|
+
- ``LUA_TOKEN`` (default): creates a filtered token at reduced integrity
|
|
199
|
+
(UAC-style Standard User). The child can still load DLLs and write to
|
|
200
|
+
user-writable locations, but CANNOT write to system directories
|
|
201
|
+
(Program Files, Windows, etc.) or perform admin actions. This is the
|
|
202
|
+
reliable choice — works with cmd.exe, python, git, etc. without
|
|
203
|
+
STATUS_DLL_INIT_FAILED.
|
|
204
|
+
- ``WRITE_RESTRICTED``: stronger isolation (restricting SIDs gate write
|
|
205
|
+
operations), but causes DLL init failures for many system executables
|
|
206
|
+
unless per-directory ACLs are configured. Opt-in only.
|
|
207
|
+
|
|
208
|
+
Returns the token handle (int) on success, or None on failure (caller
|
|
209
|
+
should fall back to plain subprocess — never block the agent on a sandbox
|
|
210
|
+
setup failure).
|
|
211
|
+
"""
|
|
212
|
+
if not is_sandbox_available():
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
import ctypes
|
|
216
|
+
import ctypes.wintypes as wt
|
|
217
|
+
|
|
218
|
+
_ensure_win32_signatures()
|
|
219
|
+
advapi32 = ctypes.windll.advapi32 # type: ignore[attr-defined]
|
|
220
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
221
|
+
|
|
222
|
+
# Open the current process's token with permission to duplicate it.
|
|
223
|
+
h_current = kernel32.GetCurrentProcess()
|
|
224
|
+
h_token = wt.HANDLE()
|
|
225
|
+
if not advapi32.OpenProcessToken(
|
|
226
|
+
h_current,
|
|
227
|
+
TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY,
|
|
228
|
+
ctypes.byref(h_token),
|
|
229
|
+
):
|
|
230
|
+
_log.warning("OpenProcessToken failed (err=%s)", ctypes.get_last_error())
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
# Create a filtered token with the requested flag.
|
|
234
|
+
h_restricted = wt.HANDLE()
|
|
235
|
+
ok = advapi32.CreateRestrictedToken(
|
|
236
|
+
h_token,
|
|
237
|
+
flag,
|
|
238
|
+
0, # disable count
|
|
239
|
+
None, # disabled SIDs
|
|
240
|
+
0, # remove count
|
|
241
|
+
None, # removed privileges
|
|
242
|
+
0, # restrict count
|
|
243
|
+
None, # restricting SIDs
|
|
244
|
+
ctypes.byref(h_restricted),
|
|
245
|
+
)
|
|
246
|
+
# CloseHandle is a kernel32 function (not advapi32) — use kernel32.
|
|
247
|
+
kernel32.CloseHandle(h_token)
|
|
248
|
+
if not ok:
|
|
249
|
+
_log.warning("CreateRestrictedToken failed (err=%s)", ctypes.get_last_error())
|
|
250
|
+
return None
|
|
251
|
+
return int(h_restricted.value) if h_restricted.value else None
|
|
252
|
+
except Exception as e: # noqa: BLE001 — never crash the agent
|
|
253
|
+
_log.warning("create_restricted_token failed: %s", e)
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# Backward-compat alias: the old name defaulted to WRITE_RESTRICTED. Callers
|
|
258
|
+
# that haven't been updated still work, but new code should call
|
|
259
|
+
# create_restricted_token() which defaults to the more reliable LUA_TOKEN.
|
|
260
|
+
def create_write_restricted_token() -> int | None:
|
|
261
|
+
"""Legacy alias for create_restricted_token(WRITE_RESTRICTED).
|
|
262
|
+
|
|
263
|
+
Kept for backward compatibility. Prefer create_restricted_token() (which
|
|
264
|
+
defaults to LUA_TOKEN — more reliable, avoids STATUS_DLL_INIT_FAILED).
|
|
265
|
+
"""
|
|
266
|
+
return create_restricted_token(CREATE_RESTRICTED_TOKEN_WRITE_RESTRICTED)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _create_process_with_token(
|
|
270
|
+
command_line: str,
|
|
271
|
+
token_handle: int,
|
|
272
|
+
*,
|
|
273
|
+
cwd: str,
|
|
274
|
+
stdin_handle: int,
|
|
275
|
+
stdout_handle: int,
|
|
276
|
+
stderr_handle: int,
|
|
277
|
+
suspended: bool = False,
|
|
278
|
+
) -> tuple[int | None, int | None, int]:
|
|
279
|
+
"""Launch a process using a restricted token via CreateProcessAsUserW.
|
|
280
|
+
|
|
281
|
+
This is the core Win32 call that actually APPLIES the restricted token to
|
|
282
|
+
a child process — without it, the token is useless (created and closed
|
|
283
|
+
without affecting anything). See run_sandboxed for the public wrapper.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
command_line: full command line (e.g. 'cmd /c "echo hi"').
|
|
287
|
+
token_handle: a primary token handle (from CreateRestrictedToken +
|
|
288
|
+
DuplicateTokenEx). The child runs with this token's permissions.
|
|
289
|
+
cwd: working directory for the child.
|
|
290
|
+
stdin_handle/stdout_handle/stderr_handle: inheritable pipe handles
|
|
291
|
+
for stdio redirection.
|
|
292
|
+
suspended: if True, create the process in a suspended state so the
|
|
293
|
+
caller can assign it to a Job Object BEFORE it starts executing.
|
|
294
|
+
The caller MUST ResumeThread to let it run. This is REQUIRED for
|
|
295
|
+
reliable process-tree kill: a process assigned to a Job before it
|
|
296
|
+
spawns children ensures all descendants are in the Job too.
|
|
297
|
+
|
|
298
|
+
Returns (proc_handle, thread_handle, pid) on success, (None, None, 0) on
|
|
299
|
+
failure. The caller owns proc_handle and thread_handle and must CloseHandle
|
|
300
|
+
both (the thread handle is needed for ResumeThread if suspended=True).
|
|
301
|
+
"""
|
|
302
|
+
import ctypes
|
|
303
|
+
import ctypes.wintypes as wt
|
|
304
|
+
|
|
305
|
+
advapi32 = ctypes.windll.advapi32 # type: ignore[attr-defined]
|
|
306
|
+
|
|
307
|
+
# STARTUPINFO — tells CreateProcess how to set up the child's stdio.
|
|
308
|
+
# STARTF_USESTDHANDLES means the child's stdin/stdout/stderr come from
|
|
309
|
+
# the handles we pass below (pipe ends, not the console).
|
|
310
|
+
class _STARTUPINFOW(ctypes.Structure):
|
|
311
|
+
_fields_ = [
|
|
312
|
+
("cb", wt.DWORD),
|
|
313
|
+
("lpReserved", wt.LPWSTR),
|
|
314
|
+
("lpDesktop", wt.LPWSTR),
|
|
315
|
+
("lpTitle", wt.LPWSTR),
|
|
316
|
+
("dwX", wt.DWORD),
|
|
317
|
+
("dwY", wt.DWORD),
|
|
318
|
+
("dwXSize", wt.DWORD),
|
|
319
|
+
("dwYSize", wt.DWORD),
|
|
320
|
+
("dwXCountChars", wt.DWORD),
|
|
321
|
+
("dwYCountChars", wt.DWORD),
|
|
322
|
+
("dwFillAttribute", wt.DWORD),
|
|
323
|
+
("dwFlags", wt.DWORD),
|
|
324
|
+
("wShowWindow", wt.WORD),
|
|
325
|
+
("cbReserved2", wt.WORD),
|
|
326
|
+
("lpReserved2", ctypes.POINTER(ctypes.c_byte)),
|
|
327
|
+
("hStdInput", wt.HANDLE),
|
|
328
|
+
("hStdOutput", wt.HANDLE),
|
|
329
|
+
("hStdError", wt.HANDLE),
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
class _PROCESS_INFORMATION(ctypes.Structure):
|
|
333
|
+
_fields_ = [
|
|
334
|
+
("hProcess", wt.HANDLE),
|
|
335
|
+
("hThread", wt.HANDLE),
|
|
336
|
+
("dwProcessId", wt.DWORD),
|
|
337
|
+
("dwThreadId", wt.DWORD),
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
STARTF_USESTDHANDLES = 0x00000100
|
|
341
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
342
|
+
CREATE_SUSPENDED = 0x00000004
|
|
343
|
+
|
|
344
|
+
si = _STARTUPINFOW()
|
|
345
|
+
si.cb = ctypes.sizeof(si)
|
|
346
|
+
si.dwFlags = STARTF_USESTDHANDLES
|
|
347
|
+
si.hStdInput = stdin_handle
|
|
348
|
+
si.hStdOutput = stdout_handle
|
|
349
|
+
si.hStdError = stderr_handle
|
|
350
|
+
|
|
351
|
+
pi = _PROCESS_INFORMATION()
|
|
352
|
+
|
|
353
|
+
# Build the creation flags. CREATE_SUSPENDED is essential for reliable
|
|
354
|
+
# process-tree kill via Job Object: it lets us assign the process to the
|
|
355
|
+
# job BEFORE it starts executing, so any children it spawns (e.g.
|
|
356
|
+
# `cmd /c powershell`) are also in the job. Without this, the first
|
|
357
|
+
# child spawns grandchildren that escape the job assignment — exactly
|
|
358
|
+
# the bug where `timeout=2` on a `sleep 5` ran the full 5 seconds.
|
|
359
|
+
creation_flags = CREATE_NO_WINDOW
|
|
360
|
+
if suspended:
|
|
361
|
+
creation_flags |= CREATE_SUSPENDED
|
|
362
|
+
|
|
363
|
+
# CreateProcessAsUserW signature (simplified — we omit the optional security
|
|
364
|
+
# attributes and environment block, passing NULL/inheritable defaults):
|
|
365
|
+
# BOOL CreateProcessAsUserW(
|
|
366
|
+
# hToken, lpApplicationName, lpCommandLine, lpProcessAttributes,
|
|
367
|
+
# lpThreadAttributes, bInheritHandles, dwCreationFlags, lpEnvironment,
|
|
368
|
+
# lpCurrentDirectory, lpStartupInfo, lpProcessInformation)
|
|
369
|
+
ok = advapi32.CreateProcessAsUserW(
|
|
370
|
+
token_handle,
|
|
371
|
+
None, # lpApplicationName — NULL, command line carries everything
|
|
372
|
+
command_line, # lpCommandLine — mutable buffer
|
|
373
|
+
None, # lpProcessAttributes — default security
|
|
374
|
+
None, # lpThreadAttributes
|
|
375
|
+
True, # bInheritHandles — inherit the stdio pipe handles
|
|
376
|
+
creation_flags, # dwCreationFlags — CREATE_NO_WINDOW [+ CREATE_SUSPENDED if requested]
|
|
377
|
+
None, # lpEnvironment — inherit parent's environment
|
|
378
|
+
cwd, # lpCurrentDirectory
|
|
379
|
+
ctypes.byref(si),
|
|
380
|
+
ctypes.byref(pi),
|
|
381
|
+
)
|
|
382
|
+
if not ok:
|
|
383
|
+
_log.warning("CreateProcessAsUserW failed (err=%s)", ctypes.get_last_error())
|
|
384
|
+
return (None, None, 0)
|
|
385
|
+
|
|
386
|
+
# Return proc + thread handle + pid. The caller:
|
|
387
|
+
# - if suspended: assigns to Job Object, then ResumeThread(pi.hThread)
|
|
388
|
+
# - else: closes the thread handle immediately (we don't need it)
|
|
389
|
+
return (pi.hProcess, pi.hThread, pi.dwProcessId)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _read_pipe_to_eof(pipe_handle: int, max_bytes: int = 512_000) -> str:
|
|
393
|
+
"""Read a Win32 anonymous pipe to EOF, returning decoded text.
|
|
394
|
+
|
|
395
|
+
Used to collect the sandboxed child's stdout/stderr. Caps at max_bytes
|
|
396
|
+
to prevent OOM if the child produces huge output (e.g. `find /`).
|
|
397
|
+
"""
|
|
398
|
+
import ctypes
|
|
399
|
+
|
|
400
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
401
|
+
chunks: list[bytes] = []
|
|
402
|
+
total = 0
|
|
403
|
+
buf = ctypes.create_string_buffer(65536)
|
|
404
|
+
bytes_read = ctypes.c_ulong(0)
|
|
405
|
+
while total < max_bytes:
|
|
406
|
+
ok = kernel32.ReadFile(pipe_handle, buf, ctypes.sizeof(buf), ctypes.byref(bytes_read), None)
|
|
407
|
+
if not ok or bytes_read.value == 0:
|
|
408
|
+
break
|
|
409
|
+
chunks.append(bytes(buf.raw[: bytes_read.value]))
|
|
410
|
+
total += bytes_read.value
|
|
411
|
+
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def run_sandboxed(
|
|
415
|
+
command: str,
|
|
416
|
+
cwd: str,
|
|
417
|
+
*,
|
|
418
|
+
timeout: int = 120,
|
|
419
|
+
env: dict | None = None,
|
|
420
|
+
max_output_bytes: int = 100_000,
|
|
421
|
+
) -> tuple[int, str]:
|
|
422
|
+
"""Run a shell command in a write-restricted sandbox on Windows.
|
|
423
|
+
|
|
424
|
+
Creates a WRITE_RESTRICTED token via ``CreateRestrictedToken``, then
|
|
425
|
+
launches the child via ``CreateProcessAsUserW`` so the child actually
|
|
426
|
+
runs with that restricted token. Combined with a Job Object (for reliable
|
|
427
|
+
process-tree kill + resource limits), this provides real OS-level
|
|
428
|
+
process isolation — the child physically lacks the permission to perform
|
|
429
|
+
restricted write operations.
|
|
430
|
+
|
|
431
|
+
Returns (exit_code, combined_output). On any sandbox setup failure
|
|
432
|
+
(token creation, process launch), returns (-1, reason) — the caller
|
|
433
|
+
(sandbox_runner) falls back to plain subprocess so the agent keeps working.
|
|
434
|
+
|
|
435
|
+
What this isolates:
|
|
436
|
+
- The child runs with a WRITE_RESTRICTED token: write operations are
|
|
437
|
+
evaluated against the token's restricting SIDs. In v1 we pass no
|
|
438
|
+
restricting SIDs (count=0), which means writes follow the parent's
|
|
439
|
+
ACL — this is a foundation; full write-isolation needs per-directory
|
|
440
|
+
ACL setup (deny everywhere except workspace) which is tracked as
|
|
441
|
+
follow-up. The token IS now applied (unlike the earlier v1 that
|
|
442
|
+
created and discarded it).
|
|
443
|
+
- Process tree is in a Job Object (KILL_ON_JOB_CLOSE + process cap).
|
|
444
|
+
|
|
445
|
+
What this does NOT isolate:
|
|
446
|
+
- Reads (child can read system-wide — intentional, a coding agent
|
|
447
|
+
needs to read libraries).
|
|
448
|
+
- Network (needs WFP — too heavy; use network_allowed=False).
|
|
449
|
+
"""
|
|
450
|
+
if not is_sandbox_available():
|
|
451
|
+
return (-1, "win_sandbox not available on this platform")
|
|
452
|
+
|
|
453
|
+
import ctypes
|
|
454
|
+
import ctypes.wintypes as wt
|
|
455
|
+
|
|
456
|
+
advapi32 = ctypes.windll.advapi32 # type: ignore[attr-defined]
|
|
457
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
458
|
+
|
|
459
|
+
token = None
|
|
460
|
+
job = None
|
|
461
|
+
proc_handle = None
|
|
462
|
+
stdout_read = None
|
|
463
|
+
stderr_read = None
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
from coderio.tools.win_job import assign_to_job, create_job_with_limits
|
|
467
|
+
|
|
468
|
+
# Step 1: create a LUA_TOKEN (reduced integrity). LUA_TOKEN is more
|
|
469
|
+
# reliable than WRITE_RESTRICTED — it doesn't cause STATUS_DLL_INIT_FAILED
|
|
470
|
+
# because the child can still load DLLs (it just runs at lower integrity,
|
|
471
|
+
# unable to write to system directories). See create_restricted_token docstring.
|
|
472
|
+
token = create_restricted_token()
|
|
473
|
+
if token is None:
|
|
474
|
+
# Token creation failed — degrade gracefully (caller falls back).
|
|
475
|
+
return (-1, "failed to create restricted token (sandbox unavailable)")
|
|
476
|
+
|
|
477
|
+
# Step 2: duplicate the token as a primary token (CreateProcessAsUser
|
|
478
|
+
# needs a primary token, CreateRestrictedToken gives us one already,
|
|
479
|
+
# but DuplicateTokenEx ensures the right access rights for the child).
|
|
480
|
+
TOKEN_ALL_ACCESS = 0xF01FF
|
|
481
|
+
TokenPrimary = 1
|
|
482
|
+
h_primary = wt.HANDLE()
|
|
483
|
+
ok = advapi32.DuplicateTokenEx(
|
|
484
|
+
token,
|
|
485
|
+
TOKEN_ALL_ACCESS,
|
|
486
|
+
None, # default security attributes
|
|
487
|
+
2, # SecurityImpersonation level — required for CreateProcessAsUser
|
|
488
|
+
TokenPrimary,
|
|
489
|
+
ctypes.byref(h_primary),
|
|
490
|
+
)
|
|
491
|
+
if not ok:
|
|
492
|
+
_log.warning("DuplicateTokenEx failed (err=%s)", ctypes.get_last_error())
|
|
493
|
+
return (-1, "failed to duplicate restricted token")
|
|
494
|
+
primary_token = int(h_primary.value)
|
|
495
|
+
|
|
496
|
+
# Step 3: set up stdout/stderr pipes for capturing child output.
|
|
497
|
+
# CreatePipe makes a pair of inheritable read/write handles. The child
|
|
498
|
+
# inherits the write ends; we read from the read ends after it exits.
|
|
499
|
+
class _SECURITY_ATTRIBUTES(ctypes.Structure):
|
|
500
|
+
_fields_ = [
|
|
501
|
+
("nLength", wt.DWORD),
|
|
502
|
+
("lpSecurityDescriptor", ctypes.c_void_p),
|
|
503
|
+
("bInheritHandle", wt.BOOL),
|
|
504
|
+
]
|
|
505
|
+
|
|
506
|
+
sa = _SECURITY_ATTRIBUTES()
|
|
507
|
+
sa.nLength = ctypes.sizeof(sa)
|
|
508
|
+
sa.bInheritHandle = True # child inherits the write end
|
|
509
|
+
|
|
510
|
+
stdout_read_h = wt.HANDLE()
|
|
511
|
+
stdout_write_h = wt.HANDLE()
|
|
512
|
+
ok = kernel32.CreatePipe(ctypes.byref(stdout_read_h), ctypes.byref(stdout_write_h), ctypes.byref(sa), 0)
|
|
513
|
+
if not ok:
|
|
514
|
+
return (-1, "stdout pipe creation failed")
|
|
515
|
+
# Ensure the READ end is NOT inheritable (only the write end goes to child).
|
|
516
|
+
HANDLE_FLAG_INHERIT = 0x00000001
|
|
517
|
+
kernel32.SetHandleInformation(stdout_read_h, HANDLE_FLAG_INHERIT, 0)
|
|
518
|
+
|
|
519
|
+
stderr_read_h = wt.HANDLE()
|
|
520
|
+
stderr_write_h = wt.HANDLE()
|
|
521
|
+
ok = kernel32.CreatePipe(ctypes.byref(stderr_read_h), ctypes.byref(stderr_write_h), ctypes.byref(sa), 0)
|
|
522
|
+
if not ok:
|
|
523
|
+
kernel32.CloseHandle(stdout_read_h)
|
|
524
|
+
kernel32.CloseHandle(stdout_write_h)
|
|
525
|
+
return (-1, "stderr pipe creation failed")
|
|
526
|
+
kernel32.SetHandleInformation(stderr_read_h, HANDLE_FLAG_INHERIT, 0)
|
|
527
|
+
|
|
528
|
+
# stdin: NULL handle (child gets no stdin — DEVNULL equivalent).
|
|
529
|
+
stdin_write_h = 0
|
|
530
|
+
|
|
531
|
+
stdout_read = stdout_read_h.value
|
|
532
|
+
stderr_read = stderr_read_h.value
|
|
533
|
+
|
|
534
|
+
# Step 4: create the Job Object BEFORE launching the process.
|
|
535
|
+
# The process will be created suspended, assigned to this job, then
|
|
536
|
+
# resumed — ensuring all descendants (e.g. `cmd /c powershell`'s
|
|
537
|
+
# powershell.exe grandchild) are in the job from the start. This fixes
|
|
538
|
+
# the timeout-kill bug where grandchildren escaped job assignment and
|
|
539
|
+
# kept running after timeout (sleep 5 + timeout 2 ran the full 5s).
|
|
540
|
+
job = create_job_with_limits(process_limit=128)
|
|
541
|
+
if job is None:
|
|
542
|
+
_log.warning("win_sandbox: Job Object creation failed — running without resource limits")
|
|
543
|
+
|
|
544
|
+
# Step 5: launch the process SUSPENDED so we can assign it to the job
|
|
545
|
+
# before it starts executing (and spawns children that would escape).
|
|
546
|
+
command_line = f'cmd /c "{command}"'
|
|
547
|
+
proc_handle, thread_handle, pid = _create_process_with_token(
|
|
548
|
+
command_line,
|
|
549
|
+
primary_token,
|
|
550
|
+
cwd=cwd,
|
|
551
|
+
stdin_handle=stdin_write_h,
|
|
552
|
+
stdout_handle=stdout_write_h.value,
|
|
553
|
+
stderr_handle=stderr_write_h.value,
|
|
554
|
+
suspended=(job is not None), # only suspend if we have a job to assign
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Close our copy of the write ends (child has its own; ours staying
|
|
558
|
+
# open would keep ReadFile blocking forever waiting for EOF).
|
|
559
|
+
kernel32.CloseHandle(stdout_write_h)
|
|
560
|
+
kernel32.CloseHandle(stderr_write_h)
|
|
561
|
+
kernel32.CloseHandle(h_primary)
|
|
562
|
+
|
|
563
|
+
if proc_handle is None or thread_handle is None:
|
|
564
|
+
# Process launch failed — fall back. The pipes' read ends are
|
|
565
|
+
# still open (no data to read).
|
|
566
|
+
kernel32.CloseHandle(stdout_read_h)
|
|
567
|
+
kernel32.CloseHandle(stderr_read_h)
|
|
568
|
+
return (-1, "CreateProcessAsUserW failed — cannot launch sandboxed child")
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
# Step 6: assign the SUSPENDED process to the Job Object, then
|
|
572
|
+
# resume its main thread. Now any children it spawns are in the
|
|
573
|
+
# job too — TerminateJobObject on timeout will kill them all.
|
|
574
|
+
if job is not None and pid:
|
|
575
|
+
assign_to_job(job, pid)
|
|
576
|
+
# ResumeThread: -1 on error, otherwise the previous suspend count
|
|
577
|
+
# (1 means it was suspended, now running).
|
|
578
|
+
kernel32.ResumeThread(thread_handle)
|
|
579
|
+
finally:
|
|
580
|
+
kernel32.CloseHandle(thread_handle)
|
|
581
|
+
|
|
582
|
+
# Step 7: wait for the child to exit (with timeout).
|
|
583
|
+
WAIT_TIMEOUT = 0x00000102
|
|
584
|
+
WAIT_FAILED = 0xFFFFFFFF
|
|
585
|
+
timeout_ms = int(timeout * 1000)
|
|
586
|
+
wait_result = kernel32.WaitForSingleObject(proc_handle, timeout_ms)
|
|
587
|
+
|
|
588
|
+
if wait_result == WAIT_TIMEOUT:
|
|
589
|
+
# Timed out — kill the ENTIRE process tree via the Job Object.
|
|
590
|
+
# TerminateJobObject kills the process assigned to the job AND all
|
|
591
|
+
# its descendants (grandchildren included), which is exactly what
|
|
592
|
+
# we need for `cmd /c powershell` chains. This is why we created
|
|
593
|
+
# the process suspended and assigned it to the job before resume.
|
|
594
|
+
if job is not None:
|
|
595
|
+
kernel32.TerminateJobObject(job, 1)
|
|
596
|
+
else:
|
|
597
|
+
# No job (creation failed earlier) — best-effort direct kill.
|
|
598
|
+
kernel32.TerminateProcess(proc_handle, 1)
|
|
599
|
+
# Wait briefly for the kill to complete so we can drain output.
|
|
600
|
+
kernel32.WaitForSingleObject(proc_handle, 2000)
|
|
601
|
+
# Drain any partial output the child produced before being killed.
|
|
602
|
+
stdout = _read_pipe_to_eof(stdout_read, max_output_bytes)
|
|
603
|
+
stderr = _read_pipe_to_eof(stderr_read, max_output_bytes)
|
|
604
|
+
output = stdout
|
|
605
|
+
if stderr:
|
|
606
|
+
output += f"\n[stderr]\n{stderr}"
|
|
607
|
+
return (124, f"Command timed out after {timeout}s\n{output[:max_output_bytes]}")
|
|
608
|
+
|
|
609
|
+
if wait_result == WAIT_FAILED:
|
|
610
|
+
return (1, f"WaitForSingleObject failed (err={kernel32.GetLastError()})")
|
|
611
|
+
|
|
612
|
+
# Step 8: get exit code + drain output.
|
|
613
|
+
exit_code = wt.DWORD()
|
|
614
|
+
kernel32.GetExitCodeProcess(proc_handle, ctypes.byref(exit_code))
|
|
615
|
+
stdout = _read_pipe_to_eof(stdout_read, max_output_bytes)
|
|
616
|
+
stderr = _read_pipe_to_eof(stderr_read, max_output_bytes)
|
|
617
|
+
output = stdout
|
|
618
|
+
if stderr:
|
|
619
|
+
output += f"\n[stderr]\n{stderr}"
|
|
620
|
+
if len(output) > max_output_bytes:
|
|
621
|
+
output = output[:max_output_bytes] + f"\n\n... Output truncated at {max_output_bytes} bytes."
|
|
622
|
+
return (exit_code.value, output)
|
|
623
|
+
|
|
624
|
+
except Exception as e: # noqa: BLE001 — never crash the agent
|
|
625
|
+
_log.warning("run_sandboxed failed (degrading to plain subprocess): %s", e)
|
|
626
|
+
return (-1, f"sandbox setup failed: {e}")
|
|
627
|
+
finally:
|
|
628
|
+
# Clean up all handles (handles are a limited resource; leaking them
|
|
629
|
+
# degrades the system over a long agent session).
|
|
630
|
+
if proc_handle:
|
|
631
|
+
kernel32.CloseHandle(proc_handle)
|
|
632
|
+
if token:
|
|
633
|
+
kernel32.CloseHandle(token)
|
|
634
|
+
if job:
|
|
635
|
+
kernel32.CloseHandle(job)
|
|
636
|
+
if stdout_read:
|
|
637
|
+
kernel32.CloseHandle(stdout_read)
|
|
638
|
+
if stderr_read:
|
|
639
|
+
kernel32.CloseHandle(stderr_read)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WriteFileArgs(BaseModel):
|
|
9
|
+
path: str = Field(description="Path to the file to write.")
|
|
10
|
+
content: str = Field(description="Full content to write (overwrites existing).")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WriteFileTool:
|
|
14
|
+
name = "write_file"
|
|
15
|
+
description = (
|
|
16
|
+
"Write content to a file, overwriting if it exists. Creates parent "
|
|
17
|
+
"directories. Requires permission in confirm/plan modes."
|
|
18
|
+
)
|
|
19
|
+
args_schema = WriteFileArgs
|
|
20
|
+
|
|
21
|
+
def run(self, path: str, content: str) -> str:
|
|
22
|
+
p = Path(path)
|
|
23
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
p.write_text(content, encoding="utf-8")
|
|
25
|
+
return f"Wrote {len(content)} chars to {path}"
|