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.
Files changed (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. 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}"