mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Remove the retired Windows sandbox backend
Windows no longer enforces an OS-level shell sandbox, so the restricted-token and AppContainer backend in internal/winsandbox, the hidden helper subcommand dispatched by the CLI and Desktop entry points, the helper report channel, and the Spec fields only that backend read are deleted. Callers that classified helper failures or issued runner-authorization denial hints are simplified to the ordinary process-launch outcomes. The one part older installs still depend on moves to a leaf package, internal/winaclresidue: the conservative legacy credential DENY RX repair and a sweep of the crash-residue markers that v1.38.8 through v1.38.10 wrote before mutating ACLs. The sweep now runs in the background at process start instead of only before the next sandboxed command, and a marker is deleted only after every recorded ACE removal succeeded so a failed cleanup keeps its evidence. The repair no longer takes the sandbox root lock; live-owner markers still veto it. CI drops the sandbox wait knob and points the Windows credential ACL identity step at the new package. The Windows sandbox architecture document, its evidence directory, and the native verification script are removed with the backend.
This commit is contained in:
1 parent
46600ad09e
commit
2f0e350dc7
68 files changed
+550
-6622
No files matched your search
@@ -196,13 +196,12 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: node scripts/windows-go-tests.mjs smoke
|
||||
|
||||
- name: test (Windows credential ACL identity)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 3
|
||||
run: go test -timeout=2m -run '^TestCredentialAccessRepairsLegacyCredentialDeny|^TestRepairLegacyCredentialDenyMatchesFileAcrossPathAliases$' ./internal/config ./internal/winsandbox
|
||||
run: go test -timeout=2m -run '^TestCredentialAccessRepairsLegacyCredentialDeny|^TestRepairLegacyCredentialDenyMatchesFileAcrossPathAliases$' ./internal/config ./internal/winaclresidue
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -257,9 +256,6 @@ jobs:
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
# built around.
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: node scripts/windows-go-tests.mjs full
|
||||
|
||||
- name: test (Scoop desktop launch)
|
||||
@@ -329,7 +325,6 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: node scripts/windows-go-tests.mjs control
|
||||
|
||||
windows-isolated:
|
||||
@@ -374,7 +369,6 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: node scripts/windows-go-tests.mjs ${{ matrix.group }}
|
||||
|
||||
race:
|
||||
|
||||
+4
-12
@@ -13,8 +13,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/skill/skillwatch"
|
||||
"reasonix/internal/winaclresidue"
|
||||
|
||||
// Blank imports wire compile-time built-ins into their registries, exactly as
|
||||
// cmd/reasonix does — boot.Build resolves providers/tools from these registries.
|
||||
@@ -49,17 +49,7 @@ func macSelfUpdateAllowed() bool {
|
||||
}
|
||||
}
|
||||
|
||||
func runWindowsSandboxHelperIfRequested(argv []string) (int, bool) {
|
||||
if len(argv) > 1 && argv[1] == sandbox.WindowsHelperCommand {
|
||||
return sandbox.RunWindowsSandboxHelper(argv[2:], os.Stdin, os.Stdout, os.Stderr), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func main() {
|
||||
if code, ok := runWindowsSandboxHelperIfRequested(os.Args); ok {
|
||||
os.Exit(code)
|
||||
}
|
||||
// Contract generation is a build-time operation. Dispatch it before crash
|
||||
// capture so packaging cannot create files in the operator's Reasonix home.
|
||||
if dir, ok := emitContractDir(os.Args[1:]); ok {
|
||||
@@ -71,7 +61,9 @@ func main() {
|
||||
if skillwatch.MaybeRunHelper() {
|
||||
return
|
||||
}
|
||||
sandbox.RegisterHelperDispatch()
|
||||
// Older Windows builds could leave sandbox ACL residue behind after a
|
||||
// crash; sweep it in the background so startup never waits on icacls.
|
||||
go winaclresidue.SweepStaleMarkers()
|
||||
// The detached macOS self-update child must run before any shell starts.
|
||||
if handled, exitCode := maybeRunMacUpdateHandoff(os.Args[1:]); handled {
|
||||
os.Exit(exitCode)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/sandbox"
|
||||
)
|
||||
|
||||
// The Windows bash sandbox relaunches os.Executable() with
|
||||
// sandbox.WindowsHelperCommand as argv[1]. If the desktop binary loses this
|
||||
// route, every sandboxed command on Windows starts a second GUI instance and
|
||||
// returns empty output (#6051, #6067, #6072). These tests pin the route.
|
||||
|
||||
func TestWindowsSandboxHelperRouteRecognized(t *testing.T) {
|
||||
// argv[2:] is empty, so the helper rejects it with a usage error — the
|
||||
// point here is only that the route matched (ok == true) and the process
|
||||
// would exit instead of booting the GUI.
|
||||
code, ok := runWindowsSandboxHelperIfRequested([]string{"reasonix-desktop", sandbox.WindowsHelperCommand})
|
||||
if !ok {
|
||||
t.Fatal("helper subcommand not routed; sandboxed commands would boot a second GUI instance")
|
||||
}
|
||||
if code == 0 {
|
||||
t.Fatalf("helper with no payload should fail with a usage error, got exit code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsSandboxHelperRouteIgnoresNormalLaunch(t *testing.T) {
|
||||
for _, argv := range [][]string{
|
||||
{"reasonix-desktop"},
|
||||
{"reasonix-desktop", "--some-flag"},
|
||||
{"reasonix-desktop", "not-the-helper", sandbox.WindowsHelperCommand},
|
||||
} {
|
||||
if _, ok := runWindowsSandboxHelperIfRequested(argv); ok {
|
||||
t.Fatalf("argv %v should not be treated as a helper launch", argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -727,8 +727,7 @@ literal `/tmp` is no longer mapped by bubblewrap.
|
||||
**Windows note:** Windows has no OS-level shell sandbox. The restricted-token
|
||||
backend is retired from enforcement because denying the current user's own SID
|
||||
locked hosts out of their credential store and the token broke common
|
||||
toolchains (see [Windows sandbox architecture](./WINDOWS_SANDBOX.md) for the
|
||||
retired design). Permission presets still apply as Reasonix tool-layer
|
||||
toolchains. Permission presets still apply as Reasonix tool-layer
|
||||
boundaries: Read only refuses file writes and asks before every shell command,
|
||||
and Workspace write keeps file tools inside `workspace_root` and `allow_write`
|
||||
and asks before writing elsewhere. Shell commands in every preset run as the
|
||||
|
||||
+1
-1
@@ -579,7 +579,7 @@ MCP 等独立沙盒继续使用自己的隔离规范,不继承父会话临时
|
||||
|
||||
**Windows 说明:**Windows 没有 OS 级 Shell 沙箱。受限令牌后端已退出强制执行:
|
||||
它对当前用户自身 SID 加拒绝项会把宿主锁在自己的凭据存储之外,受限令牌也会破坏常见
|
||||
工具链(退役设计见[Windows 沙箱架构](./WINDOWS_SANDBOX.zh-CN.md))。权限模式仍作为
|
||||
工具链。权限模式仍作为
|
||||
Reasonix 工具层边界生效:仅可查看拒绝文件写入并在每条 Shell 命令前询问;工作区内修改
|
||||
把文件工具限定在 `workspace_root` 与 `allow_write` 内,越界写入前询问。所有模式下的
|
||||
Shell 命令都以当前系统账户运行、不受约束,因此 `[sandbox] network` 与 Shell 层的
|
||||
|
||||
@@ -18,9 +18,7 @@ macOS enforces restricted presets with Seatbelt and Linux uses bubblewrap. If
|
||||
the required backend cannot start there, restricted presets fail closed;
|
||||
Reasonix never offers to silently rerun the command without isolation. Windows
|
||||
has no OS-level shell sandbox: restricted presets still confine Reasonix file
|
||||
tools and request exact grants, but shell commands run as the current OS user
|
||||
(see [Windows sandbox architecture](./WINDOWS_SANDBOX.md) for the retired
|
||||
backend).
|
||||
tools and request exact grants, but shell commands run as the current OS user.
|
||||
|
||||
## Approval scope
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Reasonix 的桌面端、CLI、Serve、ACP、机器人、自动任务和子代理
|
||||
- **协作方式**(普通 / 计划 / 目标)决定 Reasonix 怎么推进任务。没有自动任务模式或可选质量底线;验证义务由真实工具动作、项目规则、任务风险和用户显式要求共同建立。
|
||||
- **权限预设**决定强制执行的文件系统与进程边界,以及何时请求精确授权。
|
||||
|
||||
macOS 使用 Seatbelt,Linux 使用 bubblewrap;这两个平台上受限模式所需的沙箱无法启动时,Reasonix 会失败关闭,不会提示用户关闭沙箱后重试。Windows 没有 OS 级 Shell 沙箱:受限模式仍约束 Reasonix 文件工具并请求精确授权,但 Shell 命令以当前系统账户运行(退役后端见 [Windows 沙箱架构](./WINDOWS_SANDBOX.zh-CN.md))。
|
||||
macOS 使用 Seatbelt,Linux 使用 bubblewrap;这两个平台上受限模式所需的沙箱无法启动时,Reasonix 会失败关闭,不会提示用户关闭沙箱后重试。Windows 没有 OS 级 Shell 沙箱:受限模式仍约束 Reasonix 文件工具并请求精确授权,但 Shell 命令以当前系统账户运行。
|
||||
|
||||
## 授权范围
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
# Windows sandbox architecture
|
||||
|
||||
> **Status: retired from enforcement.** Reasonix no longer launches the
|
||||
> restricted-token/AppContainer helper on Windows. `sandbox.Available()` is
|
||||
> always false there, `[sandbox] bash` resolves to `off`, and permission presets
|
||||
> apply only as Reasonix tool-layer boundaries. The backend locked hosts out of
|
||||
> their own credential store through same-user deny ACEs and broke common
|
||||
> toolchains (Git Bash/MSYS2, Node stdio pipes, mapped drives, cold-start
|
||||
> budgets). The remaining sections describe the retired design and stay here
|
||||
> until the code is removed; the credential ACL repair for older installs is
|
||||
> the only part still exercised.
|
||||
|
||||
Reasonix used the same restricted-token pattern as DeepSeek Harness commit
|
||||
`c291e7961a` for shell and writer processes on Windows. The implementation is a
|
||||
native Go port; it does not load Harness code at runtime.
|
||||
|
||||
## Permission mapping
|
||||
|
||||
| Permission preset | Windows execution |
|
||||
| --- | --- |
|
||||
| Read only | A `WRITE_RESTRICTED` primary token with no directory capability SID. Direct read-only tools may use AppContainer. |
|
||||
| Workspace write | A `WRITE_RESTRICTED` token containing only the capability SIDs for the canonical workspace, approved extra directories, and the session-private temp directory. |
|
||||
| Full access | The normal host execution path as the current OS user. The Windows sandbox, including its protected-root and network constraints, is not applied. Explicit host deny rules still apply before launch. |
|
||||
|
||||
The backend is reported as `windows-write-restricted+appcontainer` with
|
||||
`partial` enforcement. Capability snapshots also report the write, read, and
|
||||
network isolation mechanisms independently so a desktop or remote client does
|
||||
not mistake the Windows boundary for Seatbelt or bubblewrap parity.
|
||||
|
||||
## Write-restricted shell lane
|
||||
|
||||
For each canonical writable directory, Reasonix derives a deterministic
|
||||
capability SID from a Reasonix-specific domain, the capability purpose, and the
|
||||
case-folded path. A separate purpose derives the session-temp SID. The helper
|
||||
adds one object-and-container-inheritable Modify-class ACE to the exact
|
||||
directory. The mask excludes `WRITE_DAC` and `WRITE_OWNER`.
|
||||
|
||||
The ACE is standing state, while authority is per process. A subsequent token
|
||||
can use an ACE only when the host includes that exact SID in its restricting
|
||||
SID set. Downgrading to read only, revoking an extra directory, switching a
|
||||
session, or rotating the session temp omits the old SID and makes its ACE inert.
|
||||
The helper revalidates the directory's Windows file identity after changing its
|
||||
DACL and fails closed if the path was replaced.
|
||||
|
||||
Before changing a workspace DACL, the helper atomically writes a `preparing`
|
||||
record below the host-controlled Reasonix state root. After the exact ACE and
|
||||
file identity are verified, the same record becomes `active`. It contains the
|
||||
record version, canonical path, Windows object identity, SID, exact mask,
|
||||
inheritance, purpose, and owner process. Session-temp records are deliberately
|
||||
not persisted because the random generation directory and its ACE are deleted
|
||||
together.
|
||||
|
||||
The restricted token is created with `DISABLE_MAX_PRIVILEGE`, `LUA_TOKEN`, and
|
||||
`WRITE_RESTRICTED`. Its restricting set includes the logon SID and Everyone so
|
||||
PowerShell, DLL, CNG, pipe, and desktop initialization can succeed, plus the
|
||||
authorized capability SIDs in workspace-write mode. The default DACL uses the
|
||||
session-temp capability where available. The child starts suspended, is
|
||||
assigned to a kill-on-close Job Object with UI restrictions, and is then
|
||||
resumed. Closing or timing out the run terminates the process tree.
|
||||
|
||||
`CREATE_NO_WINDOW` is intentionally omitted for this lane because restricted
|
||||
PowerShell and Node initialization rely on normal console inheritance. The
|
||||
Reasonix helper process remains hidden by the desktop process launcher.
|
||||
|
||||
Like Harness, this lane supports child processes that inherit or ignore stdio
|
||||
and PowerShell pipelines. Node/libuv captured stdio uses named pipes whose
|
||||
fixed Windows default descriptor does not include a restricting SID, so
|
||||
`stdio: "pipe"` fails with `EPERM`. Reasonix reports this runtime denial with a
|
||||
single-use `denial_id`; after explicit approval, the exact command can be
|
||||
retried with `danger-full-access`. It never weakens the file write boundary to
|
||||
make named-pipe capture succeed.
|
||||
|
||||
## Direct-tool and protected-read lane
|
||||
|
||||
The existing AppContainer path remains for direct read-only tools and is the
|
||||
only Windows lane that can remove network capabilities. Explicitly configured
|
||||
`forbid_read` roots remain temporary deny ACEs because `WRITE_RESTRICTED` does
|
||||
not isolate reads. Those mutations are snapshotted, crash-marked, restored, and
|
||||
serialized for their mutation lifetime. Reasonix's global credential `.env` is
|
||||
not automatically added to Windows `forbid_read`: doing so would deny the same
|
||||
user identity used by the host settings process. Credential values are still
|
||||
filtered from model-controlled child-process environments, but Windows local
|
||||
tools can deliberately read files available to the current user.
|
||||
|
||||
Standing capability ACE creation uses short named-mutex critical sections.
|
||||
Parent and child paths share a lock domain, preventing concurrent DACL updates
|
||||
from losing an ACE. Ordinary commands sharing a workspace run concurrently
|
||||
after the exact ACE exists.
|
||||
|
||||
Protected Reasonix state cannot be inside a granted workspace, and arbitrary
|
||||
state descendants cannot become writable roots. The one explicit exception is
|
||||
the desktop's direct `<state>/global-workspace`: its ACE is placed on that exact
|
||||
descendant and does not grant its parent. Session temp must be disjoint from
|
||||
both workspace and protected roots.
|
||||
|
||||
## Migration from the earlier Windows backend
|
||||
|
||||
Credential reads first try normal file access. On access denied, Reasonix can
|
||||
remove one explicit current-user `DENY RX` entry from its global `.env` only
|
||||
when a matching sandbox crash-residue record has a provably exited owner and
|
||||
no matching record has a live or unverifiable owner. The repair takes the
|
||||
sandbox path locks and rechecks the ACL. Missing residue records, additional
|
||||
current-user deny entries, or unsupported ACE types prevent automatic repair;
|
||||
the denied access remains visible. An identical mask alone does not establish
|
||||
that a rule belongs to Reasonix. A successful repair consumes the dead run's
|
||||
residue record so it cannot authorize a later, unrelated ACL change.
|
||||
|
||||
The previous shell path used a low-integrity token and recursively changed
|
||||
workspace integrity labels. The new shell path does not relabel the workspace
|
||||
and does not restore large descriptor trees after each command. Existing
|
||||
AppContainer compatibility code may still use its historical ACL and integrity
|
||||
label handling for direct tools.
|
||||
|
||||
Old low-integrity labels are not required by the new shell lane. Capability
|
||||
ACEs are harmless without a token carrying their derived SID and may remain on
|
||||
a directory across upgrades. Removing a workspace or session-temp directory
|
||||
removes the corresponding ACE with it.
|
||||
|
||||
The helper payload is versioned and rejects missing versions, future versions,
|
||||
unknown fields, and trailing data. A missing native API, path-identity mismatch,
|
||||
protected-root conflict, or unsupported network request returns a structured
|
||||
sandbox failure; Reasonix does not silently rerun the command without a sandbox.
|
||||
|
||||
## Known Windows boundaries
|
||||
|
||||
- Restricted tokens isolate write-class file access. They do not isolate
|
||||
ordinary reads, network, registry, named objects, or process visibility.
|
||||
- Everyone remains in the restricting set for Windows runtime compatibility.
|
||||
A location that explicitly grants ambient writes to Everyone may remain
|
||||
writable.
|
||||
- NTFS hard links and filesystem objects with unusual ACL inheritance require
|
||||
native regression coverage. Reasonix revalidates directory identity but does
|
||||
not claim a general hard-link boundary.
|
||||
- AppContainer can disable network for direct tools. Shell and writer launches
|
||||
with `Network: false` fail closed because the restricted-token lane cannot
|
||||
enforce that boundary.
|
||||
- Windows therefore remains `partial`, including when every native API is
|
||||
available.
|
||||
|
||||
## Validation
|
||||
|
||||
Long-running Windows services must use `pwsh(run_in_background=true)`. The
|
||||
start call returns a `pwsh-*` job id without a foreground timeout; use
|
||||
`job_output` for incremental logs and status and `job_kill` to terminate the
|
||||
Job Object process tree. The ordinary `pwsh` path launches the sandbox
|
||||
runner and requested PowerShell command once; there is no nested readiness
|
||||
probe. Before-command helper failures use a private inherited handle to report
|
||||
`dependency`, `authorization`, or `launch`; a non-zero exit after PowerShell
|
||||
starts remains an `execution` failure. An `authorization` result under a
|
||||
restricted preset includes a short-lived `denial_id` for an explicitly
|
||||
approved retry with `danger-full-access`. Similar text printed by the command
|
||||
cannot change the phase, and Reasonix never retries outside the sandbox by
|
||||
itself. The report handle is cleared from the helper's environment and marked
|
||||
non-inheritable before launching the restricted child; the child handle allowlist
|
||||
contains only stdio. Its temporary backing file denies sharing and is deleted on
|
||||
last close. Reports never come from stdout/stderr, even if a command replays an
|
||||
old diagnostic and exits with the helper's failure code. Background runtime
|
||||
permission denials offer the same single-use approval retry as foreground calls,
|
||||
while retaining `execution` / `may_be_partial` metadata.
|
||||
|
||||
启动前错误通过 helper 专用诊断句柄传递,不再解析 stdout/stderr 中的错误标记。
|
||||
受限子进程不继承该句柄;临时诊断文件禁止共享打开,在最后一个句柄关闭时删除。
|
||||
即使命令重放旧诊断并使用相同退出码,也不会被误判为“未启动”。后台运行期权限
|
||||
拒绝与前台共用单次授权重试机制,同时保留可能已部分执行的状态。
|
||||
|
||||
Run the native suite and the 100-launch cold/warm benchmark on each supported
|
||||
Windows architecture:
|
||||
|
||||
```powershell
|
||||
./scripts/verify-windows-sandbox.ps1 -OutputDirectory .codex-build/windows-sandbox-native
|
||||
```
|
||||
|
||||
CI includes `reasonix/internal/winsandbox` in the Windows smoke group. Non-Windows
|
||||
hosts also cross-compile `internal/winsandbox` and `internal/sandbox` for
|
||||
Windows amd64 and arm64. Cross-compilation validates bindings and build tags;
|
||||
it is not a substitute for the native ACL, token, Job Object, PowerShell, and
|
||||
Electron checks described in the evidence README.
|
||||
|
||||
The ported design is derived from DeepSeek Harness' MIT-licensed
|
||||
`packages/sandbox/sandbox-windows-acl`; attribution is recorded in
|
||||
`internal/winsandbox/NOTICE.md`.
|
||||
@@ -1,126 +0,0 @@
|
||||
# Windows 沙箱架构
|
||||
|
||||
> **状态:已退出强制执行。**Reasonix 不再在 Windows 上启动受限令牌/AppContainer
|
||||
> helper。`sandbox.Available()` 在 Windows 上恒为 false,`[sandbox] bash` 解析为
|
||||
> `off`,权限模式仅作为 Reasonix 工具层边界生效。该后端曾通过对当前用户自身加拒绝
|
||||
> ACE 把宿主锁在自己的凭据存储之外,并破坏常见工具链(Git Bash/MSYS2、Node stdio
|
||||
> 管道、映射网络盘、冷启动预算)。以下章节描述的是退役设计,在代码移除前保留;
|
||||
> 旧安装的凭据 ACL 修复是其中唯一仍在使用的部分。
|
||||
|
||||
Reasonix 曾在 Windows 的 Shell 和写入进程中采用 DeepSeek Harness
|
||||
`c291e7961a` 的受限令牌思路。当前实现是原生 Go 移植,运行时不依赖
|
||||
Harness 代码。
|
||||
|
||||
## 权限模式映射
|
||||
|
||||
| 权限模式 | Windows 执行方式 |
|
||||
| --- | --- |
|
||||
| 仅可查看 | 使用不携带目录能力 SID 的 `WRITE_RESTRICTED` 主令牌;直接只读工具仍可使用 AppContainer。 |
|
||||
| 工作区内修改 | `WRITE_RESTRICTED` 令牌只携带当前工作区、已授权额外目录和会话私有临时目录的能力 SID。 |
|
||||
| 完全权限 | 以当前系统账户走宿主正常执行路径,不启用 Windows 沙箱及其中的受保护目录和网络约束;宿主仍在启动前执行显式禁止规则。 |
|
||||
|
||||
后端对外报告为 `windows-write-restricted+appcontainer`,强制等级为
|
||||
`partial`。权限快照还分别说明写入、读取和网络隔离方式,桌面端和远程端
|
||||
不会把 Windows 的边界误认为与 Seatbelt 或 bubblewrap 完全相同。
|
||||
|
||||
## WRITE_RESTRICTED Shell 通道
|
||||
|
||||
Reasonix 按规范化目录、用途和 Reasonix 专用域确定性派生能力 SID。工作区
|
||||
和会话临时目录使用不同用途,因此 SID 不会复用。Helper 在精确目录上增加
|
||||
一条可向文件与子目录继承的 Modify 类 ACE,并明确排除 `WRITE_DAC` 和
|
||||
`WRITE_OWNER`。
|
||||
|
||||
ACE 会保留在目录上,真正的授权则属于每次启动的进程令牌。只有宿主把同一
|
||||
SID 放进限制 SID 集合时,该进程才能使用这条 ACE。降级为“仅可查看”、撤销
|
||||
额外目录授权、切换会话或轮换临时目录后,新令牌不再携带旧 SID,原 ACE
|
||||
立即失去作用。Helper 修改 DACL 后会重新检查 Windows 文件对象身份;路径
|
||||
在准备期间被替换时失败关闭。
|
||||
|
||||
修改工作区 DACL 之前,Helper 会在宿主控制的 Reasonix 状态目录内原子写入
|
||||
`preparing` 记录;精确 ACE 和文件身份复核完成后,同一记录更新为 `active`。
|
||||
记录包含版本、规范路径、Windows 对象身份、SID、准确掩码、继承方式、用途和
|
||||
所有者进程。会话临时目录记录不持久化,因为随机 generation 目录删除时会连同
|
||||
它的 ACE 一起回收。
|
||||
|
||||
受限令牌使用 `DISABLE_MAX_PRIVILEGE`、`LUA_TOKEN` 和
|
||||
`WRITE_RESTRICTED`。限制集合包含登录 SID、Everyone,以及工作区模式下本次
|
||||
调用获准的能力 SID。保留登录 SID 和 Everyone 是为了让 PowerShell、DLL、
|
||||
CNG、管道及桌面初始化正常工作。默认 DACL 优先使用会话临时目录 SID。
|
||||
子进程先以挂起状态创建,加入启用了进程树清理和 UI 限制的 Job Object 后再
|
||||
恢复;任务停止或超时会终止整棵进程树。
|
||||
|
||||
此通道有意不使用 `CREATE_NO_WINDOW`,因为受限 PowerShell 和 Node 的初始化
|
||||
依赖正常控制台继承。Reasonix 的 Helper 本身仍由桌面进程启动器隐藏。
|
||||
|
||||
与 Harness 一致,此通道支持继承或忽略 stdio 的子进程,也支持 PowerShell
|
||||
管道。Node/libuv 的捕获 stdio 使用命名管道,而 Windows 为该管道生成的固定默认
|
||||
安全描述符不包含限制 SID,因此 `stdio: "pipe"` 会返回 `EPERM`。Reasonix 会为
|
||||
这个运行期拒绝返回单次使用的 `denial_id`;用户明确批准后,可用
|
||||
`danger-full-access` 精确重试同一命令。Reasonix 不会为了命名管道捕获而放宽文件
|
||||
写入边界。
|
||||
|
||||
## 直接工具、禁读和并发
|
||||
|
||||
直接只读工具继续使用 AppContainer;Windows 上只有这条通道可以移除网络
|
||||
能力。`WRITE_RESTRICTED` 不隔离读取。用户显式配置的 `forbid_read` 仍使用临时
|
||||
拒绝 ACE,并保留安全描述符快照、崩溃残留标记、恢复和全生命周期串行化;
|
||||
Reasonix 自己的全局凭据 `.env` 不会自动加入 Windows 的 `forbid_read`,避免
|
||||
同一用户身份的宿主设置进程被一并拒绝。凭据值仍会从模型控制的子进程环境中
|
||||
过滤,但 Windows 本地工具可以主动读取当前用户可读的文件。
|
||||
|
||||
能力 ACE 只在创建和复核时短暂持有命名互斥锁。父子路径共享锁域,避免并发
|
||||
DACL 修改丢失条目。精确 ACE 存在后,同一工作区的普通命令可以并发执行。
|
||||
|
||||
获准工作区不能包含 Reasonix 受保护状态目录,任意状态子目录也不能成为写入
|
||||
根。唯一例外是桌面的直属 `<state>/global-workspace`;能力只授予该精确子目录,
|
||||
不会授予父目录。会话临时目录必须同时与工作区、受保护目录分离。
|
||||
|
||||
## 从旧 Windows 后端迁移
|
||||
|
||||
凭据读取先尝试正常文件访问。遇到权限拒绝时,只有匹配的沙箱崩溃残留记录能够证明
|
||||
所属进程已退出,且没有对应的活跃或无法确认状态的记录,才会移除全局 `.env` 上唯一
|
||||
一条当前用户的显式 `DENY RX`。修复会获取沙箱路径锁并重新检查 ACL。缺少残留记录、
|
||||
存在其他当前用户拒绝条目或不支持的 ACE 类型时不会自动修复,权限错误仍会保留。
|
||||
仅凭相同权限掩码无法确定该规则属于 Reasonix。
|
||||
修复成功后会消费死进程的残留记录,避免它以后授权删除无关的 ACL 变更。
|
||||
|
||||
旧 Shell 通道使用低完整性令牌,并递归修改工作区完整性标签。新通道不再给
|
||||
工作区降完整性级别,也不再在每次命令结束后恢复大型目录树。为直接工具保留
|
||||
的 AppContainer 兼容代码仍可能使用原有 ACL 和完整性标签处理。
|
||||
|
||||
新 Shell 通道不依赖旧低完整性标签。能力 ACE 只有在令牌携带对应派生 SID
|
||||
时才有效,因此可以跨版本保留;删除工作区或会话临时目录时,对应 ACE 会随
|
||||
目录一并删除。
|
||||
|
||||
Helper 载荷带严格版本号;缺少版本、未来版本、未知字段或尾随数据都会被
|
||||
拒绝。原生 API 缺失、路径身份变化、受保护目录冲突或无法实现的网络约束都会
|
||||
返回明确的沙箱失败,Reasonix 不会静默改为无沙箱重试。
|
||||
|
||||
## Windows 已知边界
|
||||
|
||||
- 受限令牌约束文件写入类访问,不隔离普通读取、网络、注册表、命名对象或
|
||||
进程可见性。
|
||||
- 为兼容 Windows 运行时,Everyone 仍在限制集合中。若某个位置本身明确允许
|
||||
Everyone 写入,该位置可能仍可写。
|
||||
- 对 NTFS 硬链接和特殊 ACL 继承对象仍需原生回归验证。当前会复核目录对象
|
||||
身份,但不宣称具备通用硬链接隔离能力。
|
||||
- AppContainer 可以禁止直接工具联网。Shell 或写入进程要求 `Network: false`
|
||||
时会失败关闭,因为受限令牌通道无法执行该网络边界。
|
||||
- 即使全部原生 API 可用,Windows 的强制等级仍为 `partial`。
|
||||
|
||||
## 验证方式
|
||||
|
||||
在每个支持的 Windows 架构上运行原生测试和 100 次冷启动/热启动基准:
|
||||
|
||||
```powershell
|
||||
./scripts/verify-windows-sandbox.ps1 -OutputDirectory .codex-build/windows-sandbox-native
|
||||
```
|
||||
|
||||
CI 的 Windows smoke 组已经包含 `reasonix/internal/winsandbox`。非 Windows
|
||||
主机还会为 Windows amd64 和 arm64 交叉编译 `internal/winsandbox` 与
|
||||
`internal/sandbox`。交叉编译只能验证绑定和构建标签,不能代替原生 ACL、
|
||||
令牌、Job Object、PowerShell 和 Electron 验证;执行状态记录在证据说明中。
|
||||
|
||||
本实现参考 DeepSeek Harness 的 MIT 许可模块
|
||||
`packages/sandbox/sandbox-windows-acl`,归属信息见
|
||||
`internal/winsandbox/NOTICE.md`。
|
||||
@@ -1,26 +0,0 @@
|
||||
# Windows sandbox validation evidence
|
||||
|
||||
This directory tracks the validation contract for the WriteRestricted Windows
|
||||
sandbox port. Checked-in logs must identify the exact commit, Windows release,
|
||||
architecture, Go version, and command. Do not label cross-compiled binaries as
|
||||
native test results.
|
||||
|
||||
## Required native matrix
|
||||
|
||||
| Environment | Required checks | Current checked-in evidence |
|
||||
| --- | --- | --- |
|
||||
| Windows 11 amd64 | winsandbox/sandbox tests, 100 cold and warm launches, actual Electron shell flow | Not run in this macOS worktree |
|
||||
| Windows 11 arm64 | winsandbox/sandbox tests, 100 cold and warm launches, actual Electron shell flow | Not run in this macOS worktree |
|
||||
| Windows 10 or supported Server amd64 | winsandbox/sandbox tests, PowerShell and background process-tree termination | Not run in this macOS worktree |
|
||||
|
||||
Run `scripts/verify-windows-sandbox.ps1` on each machine and attach the emitted
|
||||
metadata, test, and benchmark logs here or to the release artifact. The native
|
||||
tests cover workspace/outside writes, session-temp separation, read-only mode,
|
||||
standing-ACE authorization, protected roots, forbid-read cleanup, AppContainer
|
||||
network denial, path replacement checks, concurrent commands after short ACL
|
||||
setup, PowerShell 5/7 and available Node/Python inline runtimes,
|
||||
stdio/environment/cwd, timeout, and Job Object process-tree cleanup.
|
||||
|
||||
The current non-Windows validation consists of Go unit tests for portable
|
||||
projection/protocol logic plus Windows amd64 and arm64 cross-compilation. It
|
||||
does not exercise ACL evaluation or Windows process creation.
|
||||
@@ -2719,16 +2719,10 @@ func applyMCPIsolation(spec *plugin.Spec, workspaceRoot string, opts PluginSpecO
|
||||
return
|
||||
}
|
||||
writerRoots := appendUniquePaths([]string{stateDir}, opts.WriterRoots...)
|
||||
readerRoots := []string{workspaceRoot}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
readerRoots = appendUniquePaths(readerRoots, home)
|
||||
}
|
||||
spec.Sandbox = sandbox.Spec{
|
||||
Mode: "enforce", WriteRoots: writerRoots,
|
||||
ReadRoots: readerRoots,
|
||||
AppContainerWriteRoots: append([]string(nil), writerRoots...),
|
||||
ForbidReadRoots: append([]string(nil), opts.ForbidReadRoots...),
|
||||
Network: opts.Network, MinimalWrites: true,
|
||||
ForbidReadRoots: append([]string(nil), opts.ForbidReadRoots...),
|
||||
Network: opts.Network, MinimalWrites: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -40,10 +40,10 @@ import (
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/provider/openai"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/serve"
|
||||
"reasonix/internal/sessiontemp"
|
||||
"reasonix/internal/telemetry"
|
||||
"reasonix/internal/winaclresidue"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/spf13/pflag"
|
||||
@@ -66,10 +66,9 @@ func Run(args []string, version string) int {
|
||||
// RunWithBuildInfo is the full CLI entry with optional build metadata for
|
||||
// `reasonix version --verbose` / `--json`.
|
||||
func RunWithBuildInfo(args []string, info BuildInfo) int {
|
||||
sandbox.RegisterHelperDispatch()
|
||||
if len(args) > 0 && args[0] == sandbox.WindowsHelperCommand {
|
||||
return sandbox.RunWindowsSandboxHelper(args[1:], os.Stdin, os.Stdout, os.Stderr)
|
||||
}
|
||||
// Older Windows builds could leave sandbox ACL residue behind after a
|
||||
// crash; sweep it in the background so no tool output waits on icacls.
|
||||
go winaclresidue.SweepStaleMarkers()
|
||||
info = info.withDefaults()
|
||||
version := info.Version
|
||||
// Usage recording is asynchronous so provider/UI paths never wait on disk.
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/winaclresidue"
|
||||
)
|
||||
|
||||
// readCredentialFile keeps successful reads independent of sandbox ACL locks.
|
||||
// Repair is attempted only for a denied read of the global credential store.
|
||||
// readCredentialFile repairs a denied read of the global credential store
|
||||
// only when a retired-sandbox marker proves the deny came from Reasonix.
|
||||
func readCredentialFile(path string) ([]byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil || !os.IsPermission(err) || runtimeGOOS != "windows" {
|
||||
@@ -27,7 +27,7 @@ func readCredentialFile(path string) ([]byte, error) {
|
||||
if real, evalErr := filepath.EvalSymlinks(path); evalErr == nil {
|
||||
repairPath = real
|
||||
}
|
||||
if repairErr := sandbox.RepairLegacyCredentialDeny(repairPath); repairErr != nil {
|
||||
if repairErr := winaclresidue.RepairLegacyCredentialDeny(repairPath); repairErr != nil {
|
||||
slog.Warn("config: legacy credential ACL repair failed", "path", path, "err", repairErr)
|
||||
return nil, repairErr
|
||||
}
|
||||
|
||||
@@ -1163,8 +1163,6 @@ func canonicalMCPRuntimeTransport(raw string) string {
|
||||
|
||||
func canonicalMCPRuntimeSandbox(in sandbox.Spec) sandbox.Spec {
|
||||
in.WriteRoots = nonEmptyStrings(in.WriteRoots)
|
||||
in.ReadRoots = nonEmptyStrings(in.ReadRoots)
|
||||
in.AppContainerWriteRoots = nonEmptyStrings(in.AppContainerWriteRoots)
|
||||
in.ForbidReadRoots = nonEmptyStrings(in.ForbidReadRoots)
|
||||
return in
|
||||
}
|
||||
|
||||
@@ -143,8 +143,7 @@ func TestMCPRuntimeSpecMatchesExactHostIdentity(t *testing.T) {
|
||||
LauncherLocator: "[email protected]", LauncherResolvedVersion: "1.0.0", LauncherDigest: "digest-a",
|
||||
ProcessMode: MCPProcessConfined,
|
||||
Sandbox: sandbox.Spec{
|
||||
Mode: "enforce", WriteRoots: []string{"/write"}, ReadRoots: []string{"/read"},
|
||||
AppContainerWriteRoots: []string{"/state"}, ForbidReadRoots: []string{"/secret"},
|
||||
Mode: "enforce", WriteRoots: []string{"/write"}, ForbidReadRoots: []string{"/secret"},
|
||||
Network: true, MinimalWrites: true, Shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/bash"},
|
||||
},
|
||||
StateDir: "/state", StripRawPrefix: "db_", LowPriority: true,
|
||||
|
||||
@@ -162,7 +162,6 @@ func prepareMCPPrivateStateForOS(s Spec, processSandbox sandbox.Spec, env []stri
|
||||
env = setEnvValue(env, key, value)
|
||||
}
|
||||
processSandbox.WriteRoots = append(processSandbox.WriteRoots, root, privateRoot)
|
||||
processSandbox.AppContainerWriteRoots = append(processSandbox.AppContainerWriteRoots, root, privateRoot)
|
||||
return processSandbox, env, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// RunWindowsSandboxHelper is the hidden helper entry point on Windows. Other
|
||||
// platforms keep a stub so the CLI can route the internal command uniformly.
|
||||
func RunWindowsSandboxHelper(_ []string, _ *os.File, _ *os.File, stderr *os.File) int {
|
||||
fmt.Fprintln(stderr, "windows sandbox helper is only available on Windows")
|
||||
return 2
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
func repairLegacyCredentialDeny(string) error { return nil }
|
||||
@@ -50,10 +50,7 @@ func PrepareArgs(spec Spec, args []string, sessionTemp string) Prepared {
|
||||
}
|
||||
|
||||
// PrepareShellArgs is the interactive-shell counterpart of PrepareShell.
|
||||
// Windows shells always use the restricted-token lane, including read-only
|
||||
// shells (ReadOnly still forbids writes). Generic read helpers use AppContainer.
|
||||
func PrepareShellArgs(spec Spec, args []string, sessionTemp string) Prepared {
|
||||
spec.DirectWrites = true
|
||||
return PrepareArgs(spec, args, sessionTemp)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// RunnerFailure is created only from the host-owned helper report channel,
|
||||
// never from a command's stdout/stderr or its numeric exit status.
|
||||
type RunnerFailure struct {
|
||||
Phase string
|
||||
Detail string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *RunnerFailure) Error() string { return e.Detail }
|
||||
func (e *RunnerFailure) Unwrap() error { return e.Cause }
|
||||
|
||||
func RunnerFailureFromError(err error) (string, string, bool) {
|
||||
var failure *RunnerFailure
|
||||
if errors.As(err, &failure) {
|
||||
return failure.Phase, failure.Detail, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
const runnerReportLimit = 4096
|
||||
|
||||
type runnerReport struct {
|
||||
Version int `json:"version"`
|
||||
Phase string `json:"phase"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
func writeRunnerReport(w io.Writer, phase string, err error) error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
detail := err.Error()
|
||||
if len(detail) > 512 {
|
||||
detail = detail[:512]
|
||||
}
|
||||
return json.NewEncoder(w).Encode(runnerReport{Version: 1, Phase: phase, Detail: detail})
|
||||
}
|
||||
|
||||
func readRunnerReport(r io.Reader, cause error) error {
|
||||
if cause == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r, runnerReportLimit+1))
|
||||
if err != nil || len(data) == 0 {
|
||||
return cause
|
||||
}
|
||||
var report runnerReport
|
||||
if len(data) > runnerReportLimit || json.Unmarshal(data, &report) != nil || report.Version != 1 {
|
||||
// Invalid/incomplete reports cannot prove the command never started.
|
||||
return fmt.Errorf("invalid sandbox runner report: %w", cause)
|
||||
}
|
||||
switch report.Phase {
|
||||
case WindowsSandboxFailureAuthorization, WindowsSandboxFailureDependency, WindowsSandboxFailureLaunch:
|
||||
return &RunnerFailure{Phase: report.Phase, Detail: report.Detail, Cause: cause}
|
||||
default:
|
||||
return fmt.Errorf("unknown sandbox runner phase: %w", cause)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func PrepareRunnerDiagnostics(_ *exec.Cmd) (func(error) error, error) {
|
||||
return func(err error) error { return err }, nil
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunnerReportRequiresCompleteTypedRecord(t *testing.T) {
|
||||
cause := errors.New("exit status 126")
|
||||
for _, phase := range []string{WindowsSandboxFailureAuthorization, WindowsSandboxFailureDependency, WindowsSandboxFailureLaunch} {
|
||||
var report bytes.Buffer
|
||||
if err := writeRunnerReport(&report, phase, errors.New("failed "+phase)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := readRunnerReport(&report, cause)
|
||||
got, _, ok := RunnerFailureFromError(result)
|
||||
if !ok || got != phase || !errors.Is(result, cause) {
|
||||
t.Fatalf("phase=%s err=%v", got, result)
|
||||
}
|
||||
}
|
||||
for _, data := range []string{"", `{"version":1`, `{"version":2,"phase":"launch"}`, `{"version":1,"phase":"execution"}`, strings.Repeat("x", runnerReportLimit+1)} {
|
||||
err := readRunnerReport(strings.NewReader(data), cause)
|
||||
if _, _, ok := RunnerFailureFromError(err); ok {
|
||||
t.Fatalf("untrusted/incomplete record accepted: %q", data)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("original exit lost: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const runnerReportEnvironment = "REASONIX_INTERNAL_RUNNER_REPORT_HANDLE"
|
||||
|
||||
// PrepareRunnerDiagnostics gives only the helper an inherited report handle.
|
||||
// The temporary file has sharing disabled and is deleted on last close, so the
|
||||
// restricted child cannot reopen it even if the temp directory is writable.
|
||||
// A file rather than a pipe also makes reads bounded after cancellation: a
|
||||
// stuck helper cannot hold the caller waiting for EOF. No path enters argv/env.
|
||||
func PrepareRunnerDiagnostics(cmd *exec.Cmd) (func(error) error, error) {
|
||||
if len(cmd.Args) < 4 || cmd.Args[1] != WindowsHelperCommand || cmd.Args[3] != "--" {
|
||||
return func(err error) error { return err }, nil
|
||||
}
|
||||
path, err := windows.UTF16PtrFromString(filepath.Join(os.TempDir(), "reasonix-runner-"+rand.Text()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sa := &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), InheritHandle: 1}
|
||||
handle, err := windows.CreateFile(path, windows.GENERIC_READ|windows.GENERIC_WRITE, 0, sa, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_TEMPORARY|windows.FILE_FLAG_DELETE_ON_CLOSE, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := os.NewFile(uintptr(handle), "sandbox runner report")
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.AdditionalInheritedHandles = append(cmd.SysProcAttr.AdditionalInheritedHandles, syscall.Handle(handle))
|
||||
env := cmd.Env
|
||||
if env == nil {
|
||||
env = os.Environ()
|
||||
}
|
||||
cmd.Env = make([]string, 0, len(env)+1)
|
||||
for _, entry := range env {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if !strings.EqualFold(key, runnerReportEnvironment) {
|
||||
cmd.Env = append(cmd.Env, entry)
|
||||
}
|
||||
}
|
||||
cmd.Env = append(cmd.Env, runnerReportEnvironment+"="+strconv.FormatUint(uint64(handle), 10))
|
||||
return func(cause error) error {
|
||||
defer f.Close()
|
||||
return readRunnerReport(io.NewSectionReader(f, 0, runnerReportLimit+1), cause)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func openRunnerReport() (*os.File, error) {
|
||||
value := os.Getenv(runnerReportEnvironment)
|
||||
_ = os.Unsetenv(runnerReportEnvironment)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
} // Standalone helper invocation.
|
||||
n, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || n == 0 {
|
||||
return nil, fmt.Errorf("invalid runner report handle")
|
||||
}
|
||||
handle := windows.Handle(n)
|
||||
// Clear inheritance before any child can start. winsandbox also passes an
|
||||
// explicit allowlist containing only duplicates of the three stdio handles.
|
||||
if err := windows.SetHandleInformation(handle, windows.HANDLE_FLAG_INHERIT, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(handle), "sandbox runner report"), nil
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestRunnerDiagnosticsProcess(t *testing.T) {
|
||||
mode := os.Getenv("REASONIX_TEST_RUNNER_REPORT")
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
report, err := openRunnerReport()
|
||||
if err != nil || report == nil {
|
||||
fmt.Fprintln(os.Stderr, "missing report", err)
|
||||
os.Exit(90)
|
||||
}
|
||||
if os.Getenv(runnerReportEnvironment) != "" {
|
||||
os.Exit(91)
|
||||
}
|
||||
if mode == "replay" {
|
||||
fmt.Fprintln(os.Stderr, "__reasonix_windows_sandbox_failure__:old-payload:authorization")
|
||||
} else {
|
||||
if err := writeRunnerReport(report, mode, errors.New("test runner failure")); err != nil {
|
||||
os.Exit(92)
|
||||
}
|
||||
}
|
||||
_ = report.Close()
|
||||
os.Exit(126)
|
||||
}
|
||||
|
||||
func TestWindowsRunnerReportIsPrivateAndNotCommandOutput(t *testing.T) {
|
||||
for _, phase := range []string{"authorization", "dependency", "launch", "replay"} {
|
||||
t.Run(phase, func(t *testing.T) {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestRunnerDiagnosticsProcess$")
|
||||
cmd.Env = append(os.Environ(), "REASONIX_TEST_RUNNER_REPORT="+phase)
|
||||
// Test binaries do not dispatch the private CLI entry point. Keep
|
||||
// the production report setup, then dispatch to this test helper.
|
||||
args := cmd.Args
|
||||
cmd.Args = []string{args[0], WindowsHelperCommand, "payload", "--", "pwsh"}
|
||||
finish, err := PrepareRunnerDiagnostics(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Args = args
|
||||
handle := windows.Handle(cmd.SysProcAttr.AdditionalInheritedHandles[0])
|
||||
buf := make([]uint16, 32768)
|
||||
n, err := windows.GetFinalPathNameByHandle(handle, &buf[0], uint32(len(buf)), 0)
|
||||
if err != nil || n >= uint32(len(buf)) {
|
||||
_ = finish(err)
|
||||
t.Fatalf("report path: %v", err)
|
||||
}
|
||||
path := windows.UTF16ToString(buf[:n])
|
||||
if file, err := os.OpenFile(path, os.O_WRONLY, 0); err == nil {
|
||||
file.Close()
|
||||
_ = finish(nil)
|
||||
t.Fatal("report could be reopened by name")
|
||||
}
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
err = finish(runErr)
|
||||
got, _, trusted := RunnerFailureFromError(err)
|
||||
if phase == "replay" {
|
||||
if trusted || !strings.Contains(string(output), "__reasonix_windows_sandbox_failure__") {
|
||||
t.Fatalf("command output became trusted: %v %s", err, output)
|
||||
}
|
||||
} else if !trusted || got != phase {
|
||||
t.Fatalf("phase=%s trusted=%v err=%v output=%s", got, trusted, err, output)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("report file survived last close: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeErrorTextCannotClaimPreCommandFailure(t *testing.T) {
|
||||
for _, text := range []string{"cleanup API unavailable", "wait not implemented"} {
|
||||
if _, before := windowsSandboxFailurePhase(errors.New(text)); before {
|
||||
t.Fatalf("post-start error misclassified: %s", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,7 @@
|
||||
// OSSandboxSupported). File-writer built-ins are confined in tool/builtin.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WindowsHelperCommand is the private subprocess entry point for the native
|
||||
// Windows sandbox. Host binaries must register and dispatch it before normal
|
||||
// startup so an enforced launch can never fall through into an unconfined GUI
|
||||
// or CLI process.
|
||||
const WindowsHelperCommand = "__reasonix_windows_sandbox"
|
||||
|
||||
var helperDispatchRegistered atomic.Bool
|
||||
|
||||
func RegisterHelperDispatch() { helperDispatchRegistered.Store(true) }
|
||||
|
||||
// RepairLegacyCredentialDeny removes only the exact current-user deny ACE that
|
||||
// older Windows builds placed on Reasonix's credential file. Other platforms
|
||||
// and unrelated ACLs are left untouched.
|
||||
func RepairLegacyCredentialDeny(path string) error {
|
||||
return repairLegacyCredentialDeny(path)
|
||||
}
|
||||
|
||||
const (
|
||||
WindowsSandboxFailureAuthorization = "authorization"
|
||||
WindowsSandboxFailureDependency = "dependency"
|
||||
WindowsSandboxFailureLaunch = "launch"
|
||||
)
|
||||
import "runtime"
|
||||
|
||||
// Spec describes how to confine one command. The zero value (Mode == "") does
|
||||
// not enforce, so an unconfigured caller runs commands unchanged.
|
||||
@@ -50,18 +23,6 @@ type Spec struct {
|
||||
// plus any configured extras). Platforms may add command-scoped temp/cache
|
||||
// roots so builds and package managers keep working without broad writes.
|
||||
WriteRoots []string
|
||||
// ReadRoots are explicit host paths a Windows AppContainer may read. The
|
||||
// macOS/Linux profiles already mount the host read-only by default.
|
||||
ReadRoots []string
|
||||
// AppContainerWriteRoots are the small subset of WriteRoots that a
|
||||
// read-only Windows AppContainer may write (for MCP this is only its
|
||||
// private state/temp tree). macOS and Linux already enforce this through
|
||||
// WriteRoots and ignore this platform-specific distinction.
|
||||
AppContainerWriteRoots []string
|
||||
// DirectWrites marks a raw-argv launch as a write-capable command. On
|
||||
// Windows this selects the WRITE_RESTRICTED writer lane; it is deliberately
|
||||
// false for ordinary read-only helpers such as rg.
|
||||
DirectWrites bool
|
||||
// ForbidReadRoots are files or directories the command may not read from
|
||||
// when confined. The OS sandbox denies access to these paths (macOS Seatbelt
|
||||
// deny file-read* rules, Linux bubblewrap masks); on other platforms the
|
||||
@@ -90,10 +51,6 @@ type Spec struct {
|
||||
// even when a broader WriteRoot such as the user's home directory would
|
||||
// otherwise cover them.
|
||||
ProtectedWriteRoots []string
|
||||
// WindowsLockWait bounds native ACL coordination. A short foreground
|
||||
// default avoids hanging an approval; background launches may opt into a
|
||||
// larger value. Other platforms ignore it.
|
||||
WindowsLockWait time.Duration
|
||||
}
|
||||
|
||||
// Enforce reports whether the spec asks for confinement.
|
||||
|
||||
@@ -2,184 +2,18 @@
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
// Windows has no OS-level shell sandbox (see OSSandboxSupported), so every
|
||||
// launch runs unwrapped as the current OS user.
|
||||
|
||||
"reasonix/internal/winsandbox"
|
||||
)
|
||||
|
||||
type windowsSandboxPayload struct {
|
||||
Version int `json:"version"`
|
||||
Spec Spec `json:"spec"`
|
||||
Writable bool `json:"writable"`
|
||||
// Command returns the shell invocation unwrapped.
|
||||
func Command(_ Spec, sh Shell, command string) ([]string, bool) {
|
||||
return sh.argv(command), false
|
||||
}
|
||||
|
||||
const windowsSandboxPayloadVersion = 1
|
||||
|
||||
// Command returns the shell invocation unwrapped: the Windows backend is
|
||||
// retired from enforcement (Available is always false), so the helper argv is
|
||||
// never produced. The wrapping code remains only until the backend is removed.
|
||||
func Command(spec Spec, sh Shell, command string) ([]string, bool) {
|
||||
if ValidateShellPolicy(spec, sh) != nil {
|
||||
return nil, false
|
||||
}
|
||||
// Shells must remain able to read ordinary user files, so they use the
|
||||
// restricted-token lane in every constrained preset. Read-only receives no
|
||||
// workspace write ACL; workspace access receives only its canonical roots.
|
||||
return windowsSandboxCommand(spec, sh.argv(command), true)
|
||||
// CommandArgs returns the raw argv unwrapped.
|
||||
func CommandArgs(_ Spec, args []string) ([]string, bool) {
|
||||
return args, false
|
||||
}
|
||||
|
||||
// CommandArgs is like Command but accepts the command as raw argv instead of a
|
||||
// shell command string.
|
||||
func CommandArgs(spec Spec, args []string) ([]string, bool) {
|
||||
return windowsSandboxCommand(spec, args, spec.DirectWrites)
|
||||
}
|
||||
|
||||
func windowsSandboxCommand(spec Spec, args []string, writable bool) ([]string, bool) {
|
||||
// Mirror the darwin/linux wrappers: when the backend (or this binary's
|
||||
// helper dispatch) is unavailable, return the argv unwrapped so the bash
|
||||
// tool takes its explicit fail-closed / escape-approval path instead of
|
||||
// spawning a helper that cannot work.
|
||||
if !spec.Enforce() || !Available() {
|
||||
return args, false
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil || exe == "" {
|
||||
return args, false
|
||||
}
|
||||
payload, err := encodeWindowsSandboxPayload(windowsSandboxPayload{Spec: spec, Writable: writable})
|
||||
if err != nil {
|
||||
return args, false
|
||||
}
|
||||
out := []string{exe, WindowsHelperCommand, payload, "--"}
|
||||
out = append(out, args...)
|
||||
return out, true
|
||||
}
|
||||
|
||||
// Available is always false on Windows: the restricted-token/AppContainer
|
||||
// backend is retired from enforcement (see OSSandboxSupported). Reporting it
|
||||
// as unavailable keeps every wrapper, MCP confinement, and capability surface
|
||||
// on the unwrapped path without a second platform switch, and it guarantees
|
||||
// the helper never mutates ACLs or creates restricted tokens on user machines.
|
||||
// Available is always false on Windows.
|
||||
func Available() bool { return false }
|
||||
|
||||
func repairLegacyCredentialDeny(path string) error {
|
||||
return winsandbox.RepairLegacyCredentialDeny(path)
|
||||
}
|
||||
|
||||
func encodeWindowsSandboxPayload(payload windowsSandboxPayload) (string, error) {
|
||||
payload.Version = windowsSandboxPayloadVersion
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func decodeWindowsSandboxPayload(s string) (windowsSandboxPayload, error) {
|
||||
var payload windowsSandboxPayload
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return payload, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(b))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return payload, err
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return payload, fmt.Errorf("multiple JSON values")
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
if payload.Version != windowsSandboxPayloadVersion {
|
||||
return payload, fmt.Errorf("unsupported payload version %d", payload.Version)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// RunWindowsSandboxHelper is the hidden process entry point used by Command and
|
||||
// CommandArgs on native Windows.
|
||||
func RunWindowsSandboxHelper(args []string, stdin *os.File, stdout *os.File, stderr *os.File) int {
|
||||
report, reportErr := openRunnerReport()
|
||||
if reportErr != nil {
|
||||
fmt.Fprintln(stderr, "windows sandbox report channel:", reportErr)
|
||||
return 126
|
||||
}
|
||||
if report != nil {
|
||||
defer report.Close()
|
||||
}
|
||||
reportFailure := func(phase string, err error) int {
|
||||
if report != nil {
|
||||
_ = writeRunnerReport(report, phase, err)
|
||||
}
|
||||
fmt.Fprintln(stderr, "windows sandbox:", err)
|
||||
return 126
|
||||
}
|
||||
if len(args) < 3 || args[1] != "--" {
|
||||
return reportFailure(WindowsSandboxFailureLaunch, fmt.Errorf("usage: reasonix %s <payload> -- <command> [args...]", WindowsHelperCommand))
|
||||
}
|
||||
payload, err := decodeWindowsSandboxPayload(args[0])
|
||||
if err != nil {
|
||||
return reportFailure(WindowsSandboxFailureDependency, fmt.Errorf("invalid windows sandbox payload: %w", err))
|
||||
}
|
||||
child := args[2:]
|
||||
if len(child) == 0 || strings.TrimSpace(child[0]) == "" {
|
||||
return reportFailure(WindowsSandboxFailureLaunch, fmt.Errorf("windows sandbox command is required"))
|
||||
}
|
||||
result, err := winsandbox.Run(convertWindowsSandboxSpec(payload.Spec, payload.Writable), child, winsandbox.RunOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
})
|
||||
if err != nil {
|
||||
if phase, preCommand := windowsSandboxFailurePhase(err); preCommand {
|
||||
return reportFailure(phase, err)
|
||||
} else {
|
||||
// The process was resumed before this failure. Keep it in the normal
|
||||
// execution lane so mutation risk remains may_be_partial.
|
||||
fmt.Fprintln(stderr, "windows sandbox:", err)
|
||||
}
|
||||
return 126
|
||||
}
|
||||
return result.ExitCode
|
||||
}
|
||||
|
||||
func windowsSandboxFailurePhase(err error) (string, bool) {
|
||||
if phase, ok := winsandbox.FailurePhaseOf(err); ok {
|
||||
return string(phase), true
|
||||
}
|
||||
if errors.Is(err, winsandbox.ErrUnsupported) {
|
||||
return WindowsSandboxFailureDependency, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func convertWindowsSandboxSpec(spec Spec, writable bool) winsandbox.Spec {
|
||||
roots := spec.WriteRoots
|
||||
if spec.ReadOnly {
|
||||
roots = spec.AppContainerWriteRoots
|
||||
}
|
||||
readableRoots := append([]string(nil), spec.ReadRoots...)
|
||||
return winsandbox.Spec{
|
||||
ReadableRoots: readableRoots,
|
||||
WritableRoots: append([]string(nil), roots...),
|
||||
ForbidReadRoots: append([]string(nil), spec.ForbidReadRoots...),
|
||||
ProtectedWriteRoots: append([]string(nil), spec.ProtectedWriteRoots...),
|
||||
Network: spec.Network,
|
||||
Writable: writable,
|
||||
ReadOnly: spec.ReadOnly,
|
||||
TempDir: spec.SessionTemp,
|
||||
TempPrefix: "reasonix-sandbox-",
|
||||
LockWait: spec.WindowsLockWait,
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Windows backend is retired from enforcement: even with the helper
|
||||
// dispatch registered and the native APIs present, no launch path may wrap a
|
||||
// command, and the effective spec must never demand confinement.
|
||||
func TestWindowsNeverWrapsCommands(t *testing.T) {
|
||||
RegisterHelperDispatch()
|
||||
if Available() {
|
||||
t.Fatal("Available() must be false on Windows")
|
||||
}
|
||||
if OSSandboxSupported() {
|
||||
t.Fatal("OSSandboxSupported() must be false on Windows")
|
||||
}
|
||||
spec := Spec{Mode: "enforce", WriteRoots: []string{`C:\work`}, Network: true}
|
||||
sh := Shell{Kind: ShellPowerShell, Path: "powershell"}
|
||||
if argv, wrapped := Command(spec, sh, "Write-Output ok"); wrapped || len(argv) == 0 || argv[0] != sh.Path {
|
||||
t.Fatalf("Command wrapped=%v argv=%v", wrapped, argv)
|
||||
}
|
||||
if argv, wrapped := CommandArgs(spec, []string{`C:\tools\rg.exe`, "needle"}); wrapped || argv[0] != `C:\tools\rg.exe` {
|
||||
t.Fatalf("CommandArgs wrapped=%v argv=%v", wrapped, argv)
|
||||
}
|
||||
for _, launch := range []Prepared{
|
||||
PrepareShell(spec, sh, "exit 0", `C:\private-temp`),
|
||||
PrepareShellArgs(spec, []string{sh.Path, "-NoLogo", "-NoProfile"}, `C:\private-temp`),
|
||||
} {
|
||||
if launch.Wrapped {
|
||||
t.Fatalf("prepared launch wrapped: %+v", launch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertWindowsSandboxSpec(t *testing.T) {
|
||||
sessionTemp := `C:\private\session-temp`
|
||||
protected := `C:\Users\agent\AppData\Roaming\Reasonix`
|
||||
spec := Spec{
|
||||
Mode: "enforce",
|
||||
WriteRoots: []string{`C:\work`},
|
||||
ForbidReadRoots: []string{`C:\work\secret`},
|
||||
ProtectedWriteRoots: []string{protected},
|
||||
SessionTemp: sessionTemp,
|
||||
Network: true,
|
||||
}
|
||||
got := convertWindowsSandboxSpec(spec, true)
|
||||
if !got.Writable || !got.Network || got.TempPrefix != "reasonix-sandbox-" {
|
||||
t.Fatalf("converted flags = %+v", got)
|
||||
}
|
||||
if len(got.WritableRoots) != 1 || got.WritableRoots[0] != spec.WriteRoots[0] {
|
||||
t.Fatalf("converted writable roots = %v", got.WritableRoots)
|
||||
}
|
||||
if len(got.ForbidReadRoots) != 1 || got.ForbidReadRoots[0] != spec.ForbidReadRoots[0] {
|
||||
t.Fatalf("converted forbid roots = %v", got.ForbidReadRoots)
|
||||
}
|
||||
if got.TempDir != sessionTemp {
|
||||
t.Fatalf("converted temp dir = %q, want %q", got.TempDir, sessionTemp)
|
||||
}
|
||||
if len(got.ProtectedWriteRoots) != 1 || got.ProtectedWriteRoots[0] != protected {
|
||||
t.Fatalf("converted protected roots = %v", got.ProtectedWriteRoots)
|
||||
}
|
||||
if len(got.WritableRoots) != 1 {
|
||||
t.Fatalf("session temp must remain a separate capability, got roots %v", got.WritableRoots)
|
||||
}
|
||||
|
||||
got.WritableRoots[0] = `C:\mutated`
|
||||
got.ForbidReadRoots[0] = `C:\mutated`
|
||||
if spec.WriteRoots[0] == got.WritableRoots[0] || spec.ForbidReadRoots[0] == got.ForbidReadRoots[0] {
|
||||
t.Fatal("converted spec should not alias Reasonix slices")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsSandboxPayloadRejectsUnknownOrStaleProtocol(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"missing version": `{"spec":{},"writable":true}`,
|
||||
"future version": `{"version":2,"spec":{},"writable":true}`,
|
||||
"unknown field": `{"version":1,"spec":{},"writable":true,"surprise":1}`,
|
||||
"trailing value": `{"version":1,"spec":{},"writable":true} {}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(raw))
|
||||
if _, err := decodeWindowsSandboxPayload(encoded); err == nil {
|
||||
t.Fatalf("decode accepted invalid payload %s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -41,19 +41,9 @@ or sandbox enforcement are weakened by this change.
|
||||
不兼容的 MSYS 子进程,所以业务命令中的此类失败仍按执行失败处理,不能声称整条命令
|
||||
没有产生修改。本次改动不放宽令牌、文件权限或沙箱限制。
|
||||
|
||||
Windows acceptance must run, not merely cross-compile:
|
||||
The restricted-token runner has since been removed: Windows commands start
|
||||
directly as the current OS user, so the MSYS/Cygwin token incompatibility no
|
||||
longer applies and the boundary above is historical context only.
|
||||
|
||||
Windows 验收必须实际运行,交叉编译不能替代:
|
||||
|
||||
```
|
||||
go test ./internal/shellrun -run TestWindowsNativeShellPreflightRetainsWriteBoundary -v
|
||||
go test ./internal/winsandbox -run 'TestWindows(SandboxAllowsWorkspaceWriteAndDeniesOutside|WriteRestrictedReadOnlyAllowsReadsAndDeniesWrites|RestrictedRuntimeCompatibility)' -v
|
||||
```
|
||||
|
||||
An MSYS compatibility repair additionally needs real Git Bash/grep child-process
|
||||
tests in both read-only and workspace-write modes, with denied outside writes,
|
||||
before changing the restricted-token implementation. This remains unqualified
|
||||
until a Windows host is available.
|
||||
|
||||
修改受限令牌实现前,还需要在真实 Windows 上验证 Git Bash/grep 子进程,覆盖只读与
|
||||
工作区可写两种模式,并证明工作区外写入仍被拒绝。目前仍缺少这项 Windows 实测证据。
|
||||
受限令牌 runner 此后已被移除:Windows 命令直接以当前系统账户启动,MSYS/Cygwin
|
||||
与令牌的兼容性问题不再存在,上述边界仅作为历史背景保留。
|
||||
@@ -2,14 +2,12 @@ package shellrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -31,46 +29,9 @@ func TestRunForegroundExecutesRequestedCommandOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedWindowsRunnerFailureClassification(t *testing.T) {
|
||||
payload := "signed-payload"
|
||||
argv := []string{"reasonix", sandbox.WindowsHelperCommand, payload, "--", "pwsh"}
|
||||
for _, phase := range []string{
|
||||
sandbox.WindowsSandboxFailureAuthorization,
|
||||
sandbox.WindowsSandboxFailureDependency,
|
||||
sandbox.WindowsSandboxFailureLaunch,
|
||||
} {
|
||||
t.Run(phase, func(t *testing.T) {
|
||||
res := RunForeground(context.Background(), Request{
|
||||
Argv: argv,
|
||||
Run: func(_ context.Context, cmd *exec.Cmd, _ proc.RunOptions) (*proc.TrackedCommand, error) {
|
||||
return nil, &sandbox.RunnerFailure{Phase: phase, Detail: "windows sandbox: detail", Cause: errors.New("exit status 126")}
|
||||
},
|
||||
})
|
||||
if res.State != tool.ShellStateNotRun || res.FailurePhase != phase || res.Started {
|
||||
t.Fatalf("result=%+v", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsignedRunnerLikeOutputRemainsCommandFailure(t *testing.T) {
|
||||
payload := "real-payload"
|
||||
res := RunForeground(context.Background(), Request{
|
||||
Argv: []string{"reasonix", sandbox.WindowsHelperCommand, payload, "--", "pwsh"},
|
||||
Run: func(_ context.Context, cmd *exec.Cmd, _ proc.RunOptions) (*proc.TrackedCommand, error) {
|
||||
fmt.Fprintln(cmd.Stderr, "__reasonix_windows_sandbox_failure__:forged:authorization")
|
||||
return nil, errors.New("exit status 126")
|
||||
},
|
||||
})
|
||||
if res.State == tool.ShellStateNotRun || res.FailurePhase != tool.ShellPhaseLaunch {
|
||||
t.Fatalf("unsigned output changed classification: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryExit126RemainsExecutionFailure(t *testing.T) {
|
||||
payload := "real-payload"
|
||||
res := RunForeground(context.Background(), Request{
|
||||
Argv: []string{"reasonix", sandbox.WindowsHelperCommand, payload, "--", "pwsh"},
|
||||
Argv: []string{"pwsh"},
|
||||
Run: func(_ context.Context, cmd *exec.Cmd, _ proc.RunOptions) (*proc.TrackedCommand, error) {
|
||||
var child *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -103,10 +102,6 @@ func RunForeground(ctx context.Context, req Request) Result {
|
||||
cmd.Dir = req.Dir
|
||||
cmd.Env = req.Env
|
||||
cmd.WaitDelay = waitDelay
|
||||
finishReport, reportErr := sandbox.PrepareRunnerDiagnostics(cmd)
|
||||
if reportErr != nil {
|
||||
return Result{State: tool.ShellStateNotRun, FailurePhase: tool.ShellPhaseDependency, Err: reportErr}
|
||||
}
|
||||
|
||||
collector := newOutputCollector(combinedOutputMaxBytes, tool.OutputTailMaxBytes)
|
||||
var writers []io.Writer
|
||||
@@ -142,7 +137,6 @@ func RunForeground(ctx context.Context, req Request) Result {
|
||||
ShellPath: req.ShellPath,
|
||||
CommandPreview: req.CommandPreview,
|
||||
})
|
||||
err = finishReport(err)
|
||||
|
||||
if progress != nil {
|
||||
progress.Flush()
|
||||
@@ -193,17 +187,6 @@ func classifyForegroundResult(runCtx context.Context, req Request, out Result, e
|
||||
out.OutputTail = ""
|
||||
return out
|
||||
}
|
||||
if phase, detail, ok := sandbox.RunnerFailureFromError(err); ok {
|
||||
out.Started = false
|
||||
out.State = tool.ShellStateNotRun
|
||||
out.FailurePhase = phase
|
||||
out.ExitCode = exitCodeFromErr(err)
|
||||
if detail == "" {
|
||||
detail = "windows sandbox runner failed before the command started"
|
||||
}
|
||||
out.Err = fmt.Errorf("%s", detail)
|
||||
return out
|
||||
}
|
||||
if code := exitCodeFromErr(err); code != nil {
|
||||
out.ExitCode = code
|
||||
out.Started = true
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package shellrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestReplayedRunnerMarkerCannotEraseExecution(t *testing.T) {
|
||||
payload := "same-payload-for-same-sandbox-settings"
|
||||
marker := fmt.Sprintf("__reasonix_windows_sandbox_failure__:%x:authorization", sha256.Sum256([]byte(payload)))
|
||||
mutated := filepath.Join(t.TempDir(), "mutated")
|
||||
result := RunForeground(t.Context(), Request{
|
||||
Argv: []string{"reasonix", sandbox.WindowsHelperCommand, payload, "--", "pwsh"},
|
||||
Run: func(_ context.Context, cmd *exec.Cmd, _ proc.RunOptions) (*proc.TrackedCommand, error) {
|
||||
var child *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
child = exec.Command("cmd", "/c", `echo changed>"`+mutated+`" & echo `+marker+` & exit /b 126`)
|
||||
} else {
|
||||
child = exec.Command("sh", "-c", `touch "$1"; printf '%s\n' "$2"; exit 126`, "test", mutated, marker)
|
||||
}
|
||||
child.Stdout, child.Stderr = cmd.Stdout, cmd.Stderr
|
||||
err := child.Run()
|
||||
cmd.Process, cmd.ProcessState = child.Process, child.ProcessState
|
||||
return nil, err
|
||||
},
|
||||
})
|
||||
if _, err := os.Stat(mutated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Started || result.FailurePhase != tool.ShellPhaseExecution || result.State != tool.ShellStateFailed {
|
||||
t.Fatalf("mutation misreported: %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -243,12 +243,7 @@ func (b bash) ExecuteDetailed(ctx context.Context, args json.RawMessage) (tool.D
|
||||
out, runEx, err := b.runForegroundDetailed(ctx, p, sh, argv, wrapped, cmdEnv)
|
||||
mergeRunInto(ex, runEx)
|
||||
ex.DurationMs = time.Since(start).Milliseconds()
|
||||
if runEx != nil && runEx.FailurePhase == tool.ShellPhaseAuthorization {
|
||||
out = appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command))
|
||||
out = appendRunnerAuthorizationHint(out, p.Command, string(sandbox.PermissionPresetFrom(ctx)))
|
||||
} else {
|
||||
out = b.appendWriteHints(ctx, out, err, p, wrapped)
|
||||
}
|
||||
out = b.appendWriteHints(ctx, out, err, p, wrapped)
|
||||
return tool.DetailedResult{
|
||||
Output: out,
|
||||
Execution: ex,
|
||||
@@ -520,17 +515,10 @@ func shouldTrackShellProcess(wrapped bool, sh sandbox.Shell, command string, pre
|
||||
if preserveBackgroundProcesses {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" && wrapped {
|
||||
return false
|
||||
}
|
||||
return !sh.Kind.IsPOSIX() || !hasExplicitBackgroundKeepalive(command)
|
||||
}
|
||||
|
||||
func runShellProcess(ctx context.Context, cmd *exec.Cmd, sh sandbox.Shell, command string, track bool) (*proc.TrackedCommand, error) {
|
||||
finishReport, err := sandbox.PrepareRunnerDiagnostics(cmd)
|
||||
if err != nil {
|
||||
return nil, &sandbox.RunnerFailure{Phase: sandbox.WindowsSandboxFailureDependency, Detail: err.Error(), Cause: err}
|
||||
}
|
||||
source := "bash_tool"
|
||||
if sh.Kind == sandbox.ShellPowerShell {
|
||||
source = "pwsh_tool"
|
||||
@@ -543,7 +531,7 @@ func runShellProcess(ctx context.Context, cmd *exec.Cmd, sh sandbox.Shell, comma
|
||||
ShellPath: sh.Path,
|
||||
CommandPreview: commandPreview(command),
|
||||
})
|
||||
return tracked, finishReport(err)
|
||||
return tracked, err
|
||||
}
|
||||
|
||||
func reapShellProcess(cmd *exec.Cmd, tracked *proc.TrackedCommand) {
|
||||
|
||||
@@ -56,7 +56,6 @@ func (b bash) startBackground(ctx context.Context, p bashParams, sh sandbox.Shel
|
||||
}
|
||||
runErr = normalizeBashRunError(jobCtx, runErr, p.PreserveBackgroundProcesses)
|
||||
execution, classifiedErr := classifyBackgroundShellExecution(jobCtx, sh, argv, capture.String(), runErr, started)
|
||||
classifiedErr = appendRunnerAuthorizationError(classifiedErr, execution, p.Command, permissionPreset)
|
||||
if classifiedErr != nil && execution.FailurePhase == tool.ShellPhaseExecution && wrapped {
|
||||
writeBackgroundSandboxHint(out, capture.String(), classifiedErr, p, jobSpec, permissionPreset)
|
||||
}
|
||||
@@ -120,18 +119,6 @@ func classifyBackgroundShellExecution(ctx context.Context, sh sandbox.Shell, arg
|
||||
ex.MutationRisk = tool.ShellMutationMayHaveCompleted
|
||||
return ex, nil
|
||||
}
|
||||
if phase, detail, ok := sandbox.RunnerFailureFromError(runErr); ok {
|
||||
ex.State = tool.ShellStateNotRun
|
||||
ex.FailurePhase = phase
|
||||
ex.MutationRisk = tool.ShellMutationNotStarted
|
||||
if exitErr := (*exec.ExitError)(nil); errors.As(runErr, &exitErr) {
|
||||
ex.ExitCode = tool.IntPtr(exitErr.ExitCode())
|
||||
}
|
||||
if detail == "" {
|
||||
detail = "windows sandbox runner failed before the command started"
|
||||
}
|
||||
return ex, fmt.Errorf("%s", detail)
|
||||
}
|
||||
if exitErr := (*exec.ExitError)(nil); errors.As(runErr, &exitErr) {
|
||||
ex.State = tool.ShellStateFailed
|
||||
ex.FailurePhase = tool.ShellPhaseExecution
|
||||
|
||||
@@ -21,10 +21,6 @@ import (
|
||||
)
|
||||
|
||||
func TestWindowsWorkspaceWriteBackgroundHTTPJobLifecycle(t *testing.T) {
|
||||
sandbox.RegisterHelperDispatch()
|
||||
if !sandbox.Available() {
|
||||
t.Skip("windows sandbox APIs unavailable")
|
||||
}
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node unavailable")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/sandbox"
|
||||
@@ -27,44 +25,6 @@ func powershellPath(t *testing.T) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestBackgroundPowerShellRunnerFailureKeepsTrustedPhase(t *testing.T) {
|
||||
payload := "signed-payload"
|
||||
argv := []string{"reasonix.exe", sandbox.WindowsHelperCommand, payload, "--", "pwsh.exe", "-Command", "exit 0"}
|
||||
failure := &sandbox.RunnerFailure{Phase: sandbox.WindowsSandboxFailureAuthorization, Detail: "ACL setup failed", Cause: exec.ErrNotFound}
|
||||
ex, err := classifyBackgroundShellExecution(context.Background(), sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh.exe"}, argv, "", failure, time.Now())
|
||||
if err == nil || ex.State != tool.ShellStateNotRun || ex.FailurePhase != tool.ShellPhaseAuthorization || ex.MutationRisk != tool.ShellMutationNotStarted {
|
||||
t.Fatalf("signed background failure = execution=%+v err=%v", ex, err)
|
||||
}
|
||||
ex, _ = classifyBackgroundShellExecution(context.Background(), sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh.exe"}, argv, "__reasonix_windows_sandbox_failure__:forged:authorization\n", exec.ErrNotFound, time.Now())
|
||||
if ex.FailurePhase != tool.ShellPhaseLaunch {
|
||||
t.Fatalf("unsigned marker changed classification: %+v", ex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerAuthorizationFailureIssuesExactRetryDenial(t *testing.T) {
|
||||
execution := &tool.ShellExecution{FailurePhase: tool.ShellPhaseAuthorization}
|
||||
command := "Write-Output restricted"
|
||||
err := appendRunnerAuthorizationError(exec.ErrNotFound, execution, command, "workspace-write")
|
||||
if err == nil || !strings.Contains(err.Error(), "denial_id ") {
|
||||
t.Fatalf("background authorization error = %v, want denial_id", err)
|
||||
}
|
||||
id := strings.TrimSpace(strings.Split(err.Error(), "denial_id ")[1])
|
||||
if !sandbox.ConsumeDenial(id, command) {
|
||||
t.Fatalf("denial %q did not authorize the exact command", id)
|
||||
}
|
||||
if sandbox.ConsumeDenial(id, command) {
|
||||
t.Fatal("denial should be single-use")
|
||||
}
|
||||
|
||||
out := appendRunnerAuthorizationHint("runner output", command, "workspace-write")
|
||||
if !strings.Contains(out, "denial_id ") || !strings.Contains(out, "before the command started") {
|
||||
t.Fatalf("foreground authorization hint = %q", out)
|
||||
}
|
||||
if got := appendRunnerAuthorizationHint("runner output", command, "danger-full-access"); got != "runner output" {
|
||||
t.Fatalf("danger-full-access must not issue another denial: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPowerShellCallWithoutDescriptionStillValidates(t *testing.T) {
|
||||
var params bashParams
|
||||
if err := json.Unmarshal([]byte(`{"command":"Write-Output legacy"}`), ¶ms); err != nil {
|
||||
|
||||
@@ -189,24 +189,3 @@ func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Sp
|
||||
}
|
||||
return appendSessionDataHint(out, hint)
|
||||
}
|
||||
|
||||
func appendRunnerAuthorizationHint(out, command, preset string) string {
|
||||
if strings.TrimSpace(preset) == string(permissionpreset.DangerFullAccess) {
|
||||
return out
|
||||
}
|
||||
hint := "The Windows sandbox runner could not configure restricted authorization before the command started."
|
||||
if denialID := sandbox.IssueDenial(command, preset); denialID != "" {
|
||||
hint += " After explicit authorization, retry this exact command with sandbox_permissions danger-full-access and denial_id " + denialID + "."
|
||||
}
|
||||
return appendSessionDataHint(out, hint)
|
||||
}
|
||||
|
||||
func appendRunnerAuthorizationError(err error, execution *tool.ShellExecution, command, preset string) error {
|
||||
if err == nil || execution == nil || execution.FailurePhase != tool.ShellPhaseAuthorization || strings.TrimSpace(preset) == string(permissionpreset.DangerFullAccess) {
|
||||
return err
|
||||
}
|
||||
if denialID := sandbox.IssueDenial(command, preset); denialID != "" {
|
||||
return fmt.Errorf("%w; after explicit authorization, retry this exact command with sandbox_permissions danger-full-access and denial_id %s", err, denialID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -98,9 +98,6 @@ func RebindBashWriteRoots(tl tool.Tool, roots []string) (tool.Tool, bool) {
|
||||
// Sub-agent claims are strict capability boundaries. Do not add the normal
|
||||
// build-cache and temporary-directory allowances outside the claimed roots.
|
||||
spec.MinimalWrites = true
|
||||
// Do not inherit a wider AppContainer write lane from the parent workspace
|
||||
// confinement — the claim roots are the only allowed write surface.
|
||||
spec.AppContainerWriteRoots = append([]string(nil), rs...)
|
||||
b.sb = spec
|
||||
// rootSet is preserved so later session grants still apply inside the claim.
|
||||
// sessionTemp is preserved: sub-agent write-root rebinding must not drop
|
||||
|
||||
@@ -115,9 +115,6 @@ func TestRebindBashWriteRootsUsesMinimalWriteSurface(t *testing.T) {
|
||||
if len(rebound.sb.WriteRoots) != 1 || rebound.sb.WriteRoots[0] != want[0] {
|
||||
t.Fatalf("write roots = %v, want %v", rebound.sb.WriteRoots, want)
|
||||
}
|
||||
if len(rebound.sb.AppContainerWriteRoots) != 1 || rebound.sb.AppContainerWriteRoots[0] != want[0] {
|
||||
t.Fatalf("app-container write roots = %v, want %v", rebound.sb.AppContainerWriteRoots, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReboundBashCannotWriteOutsideClaim(t *testing.T) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -93,17 +92,3 @@ func TestBackgroundDiagnosticCaptureKeepsFailureTail(t *testing.T) {
|
||||
t.Fatalf("bounded diagnostic lost failure tail: bytes=%d", len(w.String()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundReplayedRunnerMarkerRemainsExecutionFailure(t *testing.T) {
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/c", "exit 126")
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", "exit 126")
|
||||
}
|
||||
err := cmd.Run()
|
||||
ex, _ := classifyBackgroundShellExecution(t.Context(), sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}, []string{"reasonix", sandbox.WindowsHelperCommand, "payload", "--", "pwsh"}, "__reasonix_windows_sandbox_failure__:payload:authorization", err, time.Now())
|
||||
if ex.FailurePhase != tool.ShellPhaseExecution || ex.MutationRisk != tool.ShellMutationMayBePartial {
|
||||
t.Fatalf("replayed output changed classification: %+v", ex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//go:build windows
|
||||
|
||||
package winaclresidue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"reasonix/internal/proc"
|
||||
)
|
||||
|
||||
const (
|
||||
allApplicationPackagesSID = "S-1-15-2-1"
|
||||
allRestrictedApplicationPackagesSID = "S-1-15-2-2"
|
||||
// stillActiveExitCode is STILL_ACTIVE from GetExitCodeProcess.
|
||||
stillActiveExitCode = 259
|
||||
// icacls can stall on antivirus-scanned volumes; the bound keeps a stuck
|
||||
// cleanup from pinning startup or a credential read.
|
||||
icaclsTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// markerDir is the directory the retired backend used for crash-residue
|
||||
// markers; the name is part of the on-disk contract with older builds.
|
||||
func markerDir() string {
|
||||
return filepath.Join(os.TempDir(), "windows-sandbox-denylocks")
|
||||
}
|
||||
|
||||
// SweepStaleMarkers removes the ACEs recorded by crashed runs of the retired
|
||||
// backend whose owning process is provably gone, then deletes their markers.
|
||||
// It is best-effort: errors keep the marker for a later sweep and never block
|
||||
// the caller.
|
||||
func SweepStaleMarkers() {
|
||||
dir := markerDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sids := residueSIDs()
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, ok := markerOwnerPID(entry.Name())
|
||||
if !ok || !processExited(pid) {
|
||||
continue
|
||||
}
|
||||
sweepMarkerFile(filepath.Join(dir, entry.Name()), sids)
|
||||
}
|
||||
}
|
||||
|
||||
// residueSIDs is the exact trustee set the retired backend applied; removing
|
||||
// only these cannot disturb a legitimate ACL.
|
||||
func residueSIDs() []string {
|
||||
userSID, _ := currentProcessUserSIDString()
|
||||
return dedupeSIDStrings([]string{allApplicationPackagesSID, allRestrictedApplicationPackagesSID, userSID})
|
||||
}
|
||||
|
||||
// sweepMarkerFile removes the recorded ACEs and deletes the marker only when
|
||||
// every removal succeeded, so a failed cleanup keeps its evidence for the next
|
||||
// sweep instead of orphaning the ACE on disk.
|
||||
func sweepMarkerFile(markerPath string, sids []string) {
|
||||
clean := true
|
||||
for _, e := range readResidueMarker(markerPath) {
|
||||
if isWindowsSystemRoot(e.path) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(e.path); err != nil {
|
||||
continue
|
||||
}
|
||||
flag := "/remove:g"
|
||||
if e.kind == residueDeny {
|
||||
flag = "/remove:d"
|
||||
}
|
||||
for _, sid := range sids {
|
||||
if err := icacls(e.path, flag, "*"+sid, "/C"); err != nil {
|
||||
clean = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if clean {
|
||||
_ = os.Remove(markerPath)
|
||||
}
|
||||
}
|
||||
|
||||
// processExited reports whether a marker owner is provably gone. This package
|
||||
// writes no markers, so one carrying our own PID was left by a dead
|
||||
// predecessor after PID reuse. Access denied does not prove death.
|
||||
func processExited(pidText string) bool {
|
||||
if pidText == strconv.Itoa(os.Getpid()) {
|
||||
return true
|
||||
}
|
||||
pid, err := strconv.ParseUint(pidText, 10, 32)
|
||||
if err != nil || pid == 0 {
|
||||
return false
|
||||
}
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return errors.Is(err, windows.ERROR_INVALID_PARAMETER)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
|
||||
return false
|
||||
}
|
||||
return code != stillActiveExitCode
|
||||
}
|
||||
|
||||
func currentProcessUserSIDString() (string, error) {
|
||||
user, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if user == nil || user.User.Sid == nil {
|
||||
return "", fmt.Errorf("current process token has no user SID")
|
||||
}
|
||||
return user.User.Sid.String(), nil
|
||||
}
|
||||
|
||||
// systemRootTool resolves a Windows system tool below %SystemRoot%\System32
|
||||
// so a PATH-shadowed binary can never run in its place.
|
||||
func systemRootTool(name string) string {
|
||||
root := os.Getenv("SystemRoot")
|
||||
if root == "" {
|
||||
root = os.Getenv("windir")
|
||||
}
|
||||
if root == "" {
|
||||
root = `C:\Windows`
|
||||
}
|
||||
full := filepath.Join(root, "System32", name)
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
return full
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func icacls(path string, args ...string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), icaclsTimeout)
|
||||
defer cancel()
|
||||
cmd := proc.CommandContext(ctx, systemRootTool("icacls.exe"), append([]string{path}, args...)...)
|
||||
proc.HideWindow(cmd)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("icacls %q %s: %w: %s", path, strings.Join(args, " "), err, strings.TrimSpace(out.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build windows
|
||||
|
||||
package winaclresidue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A dead run's marker is swept (deny removed, marker deleted); a marker whose
|
||||
// owner is still alive is left for that owner.
|
||||
func TestSweepStaleMarkersRemovesDeadRunResidueOnly(t *testing.T) {
|
||||
t.Setenv("TEMP", t.TempDir())
|
||||
userSID, err := currentProcessUserSIDString()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dead := filepath.Join(t.TempDir(), "dead.txt")
|
||||
live := filepath.Join(t.TempDir(), "live.txt")
|
||||
for _, path := range []string{dead, live} {
|
||||
if err := os.WriteFile(path, []byte("secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installLegacyDeny(t, path, userSID)
|
||||
if _, err := os.ReadFile(path); err == nil {
|
||||
t.Fatalf("deny did not block reads of %s", path)
|
||||
}
|
||||
}
|
||||
exited := exec.Command("cmd", "/c", "exit 0")
|
||||
if err := exited.Run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadMarker := writeMarker(t, exited.ProcessState.Pid(), dead)
|
||||
liveMarker := writeMarker(t, os.Getppid(), live)
|
||||
|
||||
SweepStaleMarkers()
|
||||
|
||||
if _, err := os.ReadFile(dead); err != nil {
|
||||
t.Fatalf("dead run's deny survived the sweep: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(deadMarker); !os.IsNotExist(err) {
|
||||
t.Fatalf("dead marker survived the sweep: %v", err)
|
||||
}
|
||||
if _, err := os.ReadFile(live); err == nil {
|
||||
t.Fatal("live owner's deny was removed")
|
||||
}
|
||||
if _, err := os.Stat(liveMarker); err != nil {
|
||||
t.Fatalf("live marker was deleted: %v", err)
|
||||
}
|
||||
}
|
||||
+16
-42
@@ -1,6 +1,6 @@
|
||||
//go:build windows
|
||||
|
||||
package winsandbox
|
||||
package winaclresidue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -8,9 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
@@ -18,10 +16,10 @@ import (
|
||||
|
||||
const legacyCredentialDenyMask = windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE)
|
||||
|
||||
// RepairLegacyCredentialDeny removes the exact current-user DENY RX ACE used
|
||||
// by older Reasonix forbid-read handling, provided a dead sandbox run recorded
|
||||
// this path. An ACE's mask alone cannot establish its origin. Ambiguous ACLs
|
||||
// and live or unverifiable marker owners are never modified.
|
||||
// RepairLegacyCredentialDeny removes the exact current-user DENY RX ACE that
|
||||
// the retired backend placed on the credential file, provided a dead run's
|
||||
// marker recorded this path. A mask alone cannot prove origin, so ambiguous
|
||||
// ACLs and markers with live or unverifiable owners are never modified.
|
||||
func RepairLegacyCredentialDeny(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
@@ -32,14 +30,6 @@ func RepairLegacyCredentialDeny(path string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
canonicalPath := canonicalWindowsPath(path)
|
||||
|
||||
lock, err := lockWindowsRoots([]string{path}, nil, "legacy credential ACL repair", 2*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock legacy credential ACL repair: %w", err)
|
||||
}
|
||||
defer lock.release()
|
||||
|
||||
userSID, err := currentProcessUserSIDString()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -54,7 +44,7 @@ func RepairLegacyCredentialDeny(path string) error {
|
||||
if other != 0 || legacy != 1 {
|
||||
return fmt.Errorf("refusing to alter non-legacy current-user deny ACL on %q", path)
|
||||
}
|
||||
markers := staleCredentialDenyMarkers(path, canonicalPath)
|
||||
markers := staleCredentialDenyMarkers(path, canonicalWindowsPath(path))
|
||||
if len(markers) == 0 {
|
||||
return fmt.Errorf("refusing to alter credential deny ACL without a stale sandbox record on %q", path)
|
||||
}
|
||||
@@ -62,8 +52,9 @@ func RepairLegacyCredentialDeny(path string) error {
|
||||
removeErr := icacls(path, "/remove:d", "*"+userSID, "/C")
|
||||
remainingLegacy, remainingOther, verifyErr := currentUserDenyACECounts(path, userSID)
|
||||
if verifyErr == nil && remainingLegacy == 0 && remainingOther == 0 {
|
||||
sids := residueSIDs()
|
||||
for _, marker := range markers {
|
||||
sweepResidueMarkerFile(marker, sandboxResidueSIDs())
|
||||
sweepMarkerFile(marker, sids)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -76,18 +67,20 @@ func RepairLegacyCredentialDeny(path string) error {
|
||||
return fmt.Errorf("legacy credential deny ACL remains on %q", path)
|
||||
}
|
||||
|
||||
// staleCredentialDenyMarkers returns the markers of exited runs that recorded
|
||||
// a deny on path. Any marker with a live or unverifiable owner vetoes repair.
|
||||
func staleCredentialDenyMarkers(path, canonicalPath string) []string {
|
||||
entries, err := os.ReadDir(windowsDenyMarkerDir())
|
||||
entries, err := os.ReadDir(markerDir())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var found []string
|
||||
for _, entry := range entries {
|
||||
pid, ok := windowsDenyMarkerOwnerPID(entry.Name())
|
||||
pid, ok := markerOwnerPID(entry.Name())
|
||||
if entry.IsDir() || !ok {
|
||||
continue
|
||||
}
|
||||
marker := filepath.Join(windowsDenyMarkerDir(), entry.Name())
|
||||
marker := filepath.Join(markerDir(), entry.Name())
|
||||
for _, residue := range readResidueMarker(marker) {
|
||||
if residue.kind != residueDeny {
|
||||
continue
|
||||
@@ -96,7 +89,7 @@ func staleCredentialDenyMarkers(path, canonicalPath string) []string {
|
||||
!strings.EqualFold(canonicalWindowsPath(residue.path), canonicalPath) {
|
||||
continue
|
||||
}
|
||||
if !credentialMarkerOwnerExited(marker, pid) {
|
||||
if !processExited(pid) {
|
||||
return nil
|
||||
}
|
||||
found = append(found, marker)
|
||||
@@ -107,8 +100,8 @@ func staleCredentialDenyMarkers(path, canonicalPath string) []string {
|
||||
}
|
||||
|
||||
// canonicalWindowsPath expands 8.3 aliases without opening the protected file.
|
||||
// Keep the original spelling on failure so the exact-path comparison remains
|
||||
// available when a volume does not support long-name expansion.
|
||||
// The original spelling is kept on failure so the exact-path comparison still
|
||||
// works on volumes without long-name expansion.
|
||||
func canonicalWindowsPath(path string) string {
|
||||
input, err := windows.UTF16PtrFromString(filepath.Clean(path))
|
||||
if err != nil {
|
||||
@@ -122,25 +115,6 @@ func canonicalWindowsPath(path string) string {
|
||||
return filepath.Clean(windows.UTF16ToString(buffer[:n]))
|
||||
}
|
||||
|
||||
func credentialMarkerOwnerExited(marker, pidText string) bool {
|
||||
if pidText == strconv.Itoa(os.Getpid()) {
|
||||
_, live := liveResidueMarkers.Load(marker)
|
||||
return !live
|
||||
}
|
||||
pid, err := strconv.ParseUint(pidText, 10, 32)
|
||||
if err != nil || pid == 0 {
|
||||
return false
|
||||
}
|
||||
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||
if err != nil {
|
||||
// Access denied does not prove the owner is dead.
|
||||
return errors.Is(err, windows.ERROR_INVALID_PARAMETER)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
event, err := windows.WaitForSingleObject(handle, 0)
|
||||
return err == nil && event == windows.WAIT_OBJECT_0
|
||||
}
|
||||
|
||||
func currentUserDenyACECounts(path, userSID string) (legacy, other int, err error) {
|
||||
sid, err := windows.StringToSid(userSID)
|
||||
if err != nil {
|
||||
+39
-43
@@ -1,6 +1,6 @@
|
||||
//go:build windows
|
||||
|
||||
package winsandbox
|
||||
package winaclresidue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -14,6 +14,29 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func installLegacyDeny(t *testing.T, path, userSID string) {
|
||||
t.Helper()
|
||||
if err := icacls(path, "/deny", "*"+userSID+":(RX)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = icacls(path, "/remove:d", "*"+userSID, "/C") })
|
||||
}
|
||||
|
||||
// writeMarker records a deny for path under a marker owned by pid. Our own PID
|
||||
// stands for a crashed predecessor after PID reuse; the parent test runner's
|
||||
// PID stands for a live owner.
|
||||
func writeMarker(t *testing.T, pid int, path string) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(markerDir(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(markerDir(), strconv.Itoa(pid)+"-credential-test.txt")
|
||||
if err := os.WriteFile(marker, []byte("deny\t"+path+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return marker
|
||||
}
|
||||
|
||||
func TestRepairLegacyCredentialDenyRemovesExactCurrentUserACE(t *testing.T) {
|
||||
t.Setenv("TEMP", t.TempDir())
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
@@ -24,14 +47,12 @@ func TestRepairLegacyCredentialDenyRemovesExactCurrentUserACE(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := denyAppContainerSIDsWithInheritance(path, []string{userSID}, "RX", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installLegacyDeny(t, path, userSID)
|
||||
legacy, other, err := currentUserDenyACECounts(path, userSID)
|
||||
if err != nil || legacy != 1 || other != 0 {
|
||||
t.Fatalf("deny counts before repair = legacy:%d other:%d err:%v", legacy, other, err)
|
||||
}
|
||||
marker := writeStaleCredentialMarker(t, path)
|
||||
marker := writeMarker(t, os.Getpid(), path)
|
||||
|
||||
if err := RepairLegacyCredentialDeny(path); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -46,29 +67,12 @@ func TestRepairLegacyCredentialDenyRemovesExactCurrentUserACE(t *testing.T) {
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale marker survived repair: %v", err)
|
||||
}
|
||||
if err := denyAppContainerSIDsWithInheritance(path, []string{userSID}, "RX", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { removeDeniedAppContainerSIDs(path, []string{userSID}) })
|
||||
installLegacyDeny(t, path, userSID)
|
||||
if err := RepairLegacyCredentialDeny(path); err == nil {
|
||||
t.Fatal("repair reused a consumed stale marker")
|
||||
}
|
||||
}
|
||||
|
||||
func writeStaleCredentialMarker(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(windowsDenyMarkerDir(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An unregistered self-PID marker represents a crashed predecessor after
|
||||
// PID reuse, matching the existing sandbox residue lifecycle.
|
||||
marker := filepath.Join(windowsDenyMarkerDir(), strconv.Itoa(os.Getpid())+"-credential-test.txt")
|
||||
if err := os.WriteFile(marker, []byte("deny\t"+path+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return marker
|
||||
}
|
||||
|
||||
func TestRepairLegacyCredentialDenyMatchesFileAcrossPathAliases(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMP", tmp)
|
||||
@@ -94,11 +98,8 @@ func TestRepairLegacyCredentialDenyMatchesFileAcrossPathAliases(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := denyAppContainerSIDsWithInheritance(path, []string{userSID}, "RX", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { removeDeniedAppContainerSIDs(path, []string{userSID}) })
|
||||
writeStaleCredentialMarker(t, alias)
|
||||
installLegacyDeny(t, path, userSID)
|
||||
writeMarker(t, os.Getpid(), alias)
|
||||
if err := RepairLegacyCredentialDeny(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -119,25 +120,21 @@ func TestRepairLegacyCredentialDenyPreservesUnattributedAndLiveACL(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := denyAppContainerSIDsWithInheritance(path, []string{userSID}, "RX", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installLegacyDeny(t, path, userSID)
|
||||
switch source {
|
||||
case "live marker":
|
||||
marker := writeStaleCredentialMarker(t, path)
|
||||
liveResidueMarkers.Store(marker, struct{}{})
|
||||
t.Cleanup(func() { liveResidueMarkers.Delete(marker) })
|
||||
writeMarker(t, os.Getppid(), path)
|
||||
case "wrong path":
|
||||
writeStaleCredentialMarker(t, path+"-other")
|
||||
writeMarker(t, os.Getpid(), path+"-other")
|
||||
}
|
||||
before, err := windowsPathDACLSDDL(path)
|
||||
before, err := pathDACLSDDL(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RepairLegacyCredentialDeny(path); err == nil {
|
||||
t.Fatal("repair accepted an unattributed or live deny")
|
||||
}
|
||||
after, err := windowsPathDACLSDDL(path)
|
||||
after, err := pathDACLSDDL(path)
|
||||
if err != nil || after != before {
|
||||
t.Fatalf("DACL changed: before %s, after %s, err %v", before, after, err)
|
||||
}
|
||||
@@ -150,14 +147,14 @@ func TestRepairLegacyCredentialDenyLeavesOrdinaryACLAlone(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte("KEY=value\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := windowsPathDACLSDDL(path)
|
||||
before, err := pathDACLSDDL(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RepairLegacyCredentialDeny(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := windowsPathDACLSDDL(path)
|
||||
after, err := pathDACLSDDL(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -194,15 +191,14 @@ func TestRepairLegacyCredentialDenyRefusesMixedCurrentUserDenyACL(t *testing.T)
|
||||
if err != nil || legacy != 1 || other == 0 {
|
||||
t.Fatalf("deny counts before repair = legacy:%d other:%d err:%v", legacy, other, err)
|
||||
}
|
||||
before, err := windowsPathDACLSDDL(path)
|
||||
before, err := pathDACLSDDL(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := RepairLegacyCredentialDeny(path); err == nil {
|
||||
t.Fatal("RepairLegacyCredentialDeny accepted mixed current-user deny ACL")
|
||||
}
|
||||
after, err := windowsPathDACLSDDL(path)
|
||||
after, err := pathDACLSDDL(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -211,7 +207,7 @@ func TestRepairLegacyCredentialDenyRefusesMixedCurrentUserDenyACL(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func windowsPathDACLSDDL(path string) (string, error) {
|
||||
func pathDACLSDDL(path string) (string, error) {
|
||||
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil || sd == nil {
|
||||
return "", err
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package winaclresidue cleans up ACL residue that the retired Windows sandbox
|
||||
// backend (v1.38.8 to v1.38.10) left on user files. That backend applied
|
||||
// temporary deny and grant ACEs and recorded each mutation in a per-process
|
||||
// marker under %TEMP%; a crash left both behind. This package removes only
|
||||
// those exact trustees from paths named in markers whose owner is provably
|
||||
// gone, and repairs the one legacy DENY RX entry on the credential store when
|
||||
// a dead-run marker proves its origin. It never creates markers or mutates
|
||||
// ACLs for any other reason. Non-Windows builds are no-ops.
|
||||
package winaclresidue
|
||||
@@ -0,0 +1,97 @@
|
||||
package winaclresidue
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type residueKind string
|
||||
|
||||
const (
|
||||
residueDeny residueKind = "deny"
|
||||
residueGrant residueKind = "grant"
|
||||
)
|
||||
|
||||
type residueEntry struct {
|
||||
kind residueKind
|
||||
path string
|
||||
}
|
||||
|
||||
// markerOwnerPID extracts the owning PID from a "<pid>[-nonce].txt" name.
|
||||
func markerOwnerPID(name string) (string, bool) {
|
||||
if !strings.HasSuffix(name, ".txt") {
|
||||
return "", false
|
||||
}
|
||||
pid, _, _ := strings.Cut(strings.TrimSuffix(name, ".txt"), "-")
|
||||
if pid == "" {
|
||||
return "", false
|
||||
}
|
||||
if _, err := strconv.ParseUint(pid, 10, 32); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return pid, true
|
||||
}
|
||||
|
||||
// readResidueMarker parses "<kind>\t<path>" lines. A tab separates the fields
|
||||
// so paths with spaces survive; unrecognized lines are skipped rather than
|
||||
// guessed at, so a corrupt marker cannot cause a wrong ACE removal.
|
||||
func readResidueMarker(path string) []residueEntry {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []residueEntry
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
kindStr, p, ok := strings.Cut(strings.TrimRight(scanner.Text(), "\r\n"), "\t")
|
||||
if !ok || p == "" {
|
||||
continue
|
||||
}
|
||||
switch kind := residueKind(kindStr); kind {
|
||||
case residueDeny, residueGrant:
|
||||
out = append(out, residueEntry{kind: kind, path: p})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isWindowsSystemRoot reports whether path lies under a shared system
|
||||
// directory. Markers are untrusted input under %TEMP%; stripping the built-in
|
||||
// package SIDs from System32 or Program Files would remove factory ACEs.
|
||||
// Paths are compared with backslash separators so the rule is the same on
|
||||
// every host that inspects a Windows marker.
|
||||
func isWindowsSystemRoot(path string) bool {
|
||||
clean := windowsPathKey(path)
|
||||
for _, envVar := range []string{"SystemRoot", "windir", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"} {
|
||||
root := os.Getenv(envVar)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
root = windowsPathKey(root)
|
||||
if clean == root || strings.HasPrefix(clean, root+`\`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func windowsPathKey(path string) string {
|
||||
return strings.ToLower(strings.TrimRight(strings.ReplaceAll(filepath.Clean(path), "/", `\`), `\`))
|
||||
}
|
||||
|
||||
func dedupeSIDStrings(sids []string) []string {
|
||||
out := make([]string, 0, len(sids))
|
||||
seen := map[string]bool{}
|
||||
for _, sid := range sids {
|
||||
if sid == "" || seen[sid] {
|
||||
continue
|
||||
}
|
||||
seen[sid] = true
|
||||
out = append(out, sid)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package winaclresidue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkerOwnerPIDParsesRetiredBackendNames(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"1234.txt": "1234",
|
||||
"1234-deadbeef.txt": "1234",
|
||||
"1234-1700000000-2.txt": "1234",
|
||||
"1234": "",
|
||||
"-abc.txt": "",
|
||||
"notapid-1.txt": "",
|
||||
"99999999999.txt": "",
|
||||
} {
|
||||
got, ok := markerOwnerPID(name)
|
||||
if (want == "") == ok || got != want {
|
||||
t.Fatalf("markerOwnerPID(%q) = %q,%v want %q", name, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadResidueMarkerSkipsCorruptLines(t *testing.T) {
|
||||
marker := filepath.Join(t.TempDir(), "42.txt")
|
||||
body := "deny\tC:\\Users\\me\\.env\r\n" +
|
||||
"grant\tC:\\Tools\\with space\\bin\n" +
|
||||
"deny C:\\no-tab\n" +
|
||||
"unknown\tC:\\kind\n" +
|
||||
"deny\t\n" +
|
||||
"\n"
|
||||
if err := os.WriteFile(marker, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readResidueMarker(marker)
|
||||
if len(got) != 2 || got[0] != (residueEntry{residueDeny, `C:\Users\me\.env`}) || got[1] != (residueEntry{residueGrant, `C:\Tools\with space\bin`}) {
|
||||
t.Fatalf("readResidueMarker = %+v", got)
|
||||
}
|
||||
if got := readResidueMarker(filepath.Join(t.TempDir(), "missing.txt")); got != nil {
|
||||
t.Fatalf("missing marker parsed as %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWindowsSystemRootUsesEnvironmentRoots(t *testing.T) {
|
||||
t.Setenv("SystemRoot", `C:\Windows`)
|
||||
t.Setenv("ProgramFiles", `C:\Program Files`)
|
||||
for path, want := range map[string]bool{
|
||||
`C:\Windows\System32`: true,
|
||||
`c:\windows`: true,
|
||||
`C:\Program Files\Git\bin`: true,
|
||||
`C:\WindowsOld\System32`: false,
|
||||
`C:\Users\me\project`: false,
|
||||
`C:\Program FilesX\tool`: false,
|
||||
} {
|
||||
if got := isWindowsSystemRoot(path); got != want {
|
||||
t.Fatalf("isWindowsSystemRoot(%q) = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeSIDStringsDropsEmptyAndRepeats(t *testing.T) {
|
||||
got := dedupeSIDStrings([]string{"S-1-15-2-1", "", "S-1-15-2-2", "S-1-15-2-1", "S-1-5-21-1"})
|
||||
if len(got) != 3 || got[0] != "S-1-15-2-1" || got[1] != "S-1-15-2-2" || got[2] != "S-1-5-21-1" {
|
||||
t.Fatalf("dedupeSIDStrings = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package winaclresidue
|
||||
|
||||
// SweepStaleMarkers is a no-op outside Windows.
|
||||
func SweepStaleMarkers() {}
|
||||
|
||||
// RepairLegacyCredentialDeny is a no-op outside Windows.
|
||||
func RepairLegacyCredentialDeny(string) error { return nil }
|
||||
@@ -1,22 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 windows-sandbox Contributors
|
||||
Copyright (c) 2026 DeepSeek
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,24 +0,0 @@
|
||||
This package is adapted from `github.com/SivanCola/windows-sandbox` at commit
|
||||
`6b29dd09f9cb5a85d7ac646dd8ade74d207bb47b`.
|
||||
|
||||
The WRITE_RESTRICTED token, deterministic workspace/temp capability SID, exact
|
||||
ACL grant, and restricted-token default-DACL design are adapted from DeepSeek
|
||||
Harness at commit `c291e7961a`, package
|
||||
`packages/sandbox/sandbox-windows-acl`:
|
||||
|
||||
https://github.com/deepseek-ai/DeepSeek-Harness
|
||||
|
||||
Both upstream projects are licensed under the MIT License. The license text is
|
||||
kept in `LICENSE` in this directory. DeepSeek Harness carries:
|
||||
|
||||
Copyright (c) 2026 DeepSeek
|
||||
|
||||
Local modifications since vendoring:
|
||||
|
||||
- `lockWindowsRoots`/`acquireNamedMutex` take an optional notice writer and
|
||||
wait in slices, emitting a one-line message when a run queues behind another
|
||||
sandboxed command's per-root lock instead of blocking silently.
|
||||
- Capability hashes are domain-separated for Reasonix and support multiple
|
||||
independently authorized writable roots.
|
||||
- Existing Reasonix AppContainer read restrictions, forbid-read handling,
|
||||
Job Object UI restrictions, and crash-residue recovery are retained.
|
||||
@@ -1,102 +0,0 @@
|
||||
# internal/winsandbox
|
||||
|
||||
Reasonix's bundled native Windows process sandbox helpers.
|
||||
|
||||
The package combines two Windows isolation lanes:
|
||||
|
||||
- AppContainer for direct read-only tools, including optional network removal.
|
||||
- `WRITE_RESTRICTED` primary tokens for shells and writer processes.
|
||||
- Deterministic, per-directory capability SIDs for workspace writes.
|
||||
- A distinct capability SID for the session-private temporary directory.
|
||||
- Temporary deny ACEs for `ForbidReadRoots`.
|
||||
- Kill-on-close Job Objects and UI restrictions for process-tree cleanup.
|
||||
- Strict, versioned helper payloads and fail-closed native API handling.
|
||||
|
||||
The package implements enforcement only. Product permission presets, approval
|
||||
prompts, and grant policy live above this layer.
|
||||
|
||||
## Write capability model
|
||||
|
||||
For a workspace-write shell, Reasonix derives a capability SID from each
|
||||
canonical writable root and adds a `Modify`-class inheritable ACE for that SID.
|
||||
The ACE intentionally excludes `WRITE_DAC` and `WRITE_OWNER`. It remains on the
|
||||
directory, so later commands hit an exact-ACE fast path instead of recursively
|
||||
rewriting a large workspace on every launch.
|
||||
|
||||
Each restricted token contains the logon SID, Everyone, and only the directory
|
||||
capabilities authorized for that call. Windows performs the ordinary access
|
||||
check and the restricting-SID check; a write succeeds only when both pass.
|
||||
Read-only shells carry no directory capability, so standing workspace ACEs are
|
||||
inert after a mode downgrade.
|
||||
|
||||
The session temp directory uses a different SID namespace. Two sessions can
|
||||
share workspace authority without receiving access to one another's private
|
||||
temporary files. Deleting the session temp tree removes that temporary grant.
|
||||
|
||||
`ProtectedWriteRoots` are checked before any grant is materialized. A writable
|
||||
root that contains protected state, an arbitrary protected-state descendant,
|
||||
or an overlapping session temp is rejected. The direct
|
||||
`<state>/global-workspace` child is the sole product-layout exception; its ACE
|
||||
is applied only to that exact directory.
|
||||
|
||||
Before a workspace ACE is applied, a versioned `preparing` record is written
|
||||
under the protected state root. It is atomically replaced with `active` only
|
||||
after the exact ACE and Windows file identity have been revalidated.
|
||||
|
||||
```go
|
||||
result, err := winsandbox.Run(winsandbox.Spec{
|
||||
WritableRoots: []string{workspace},
|
||||
ProtectedWriteRoots: []string{reasonixState},
|
||||
ForbidReadRoots: []string{secretDir},
|
||||
TempDir: sessionTemp,
|
||||
Network: true,
|
||||
Writable: true,
|
||||
}, argv, winsandbox.RunOptions{
|
||||
Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr,
|
||||
})
|
||||
```
|
||||
|
||||
Set `ReadOnly: true` for a shell that may read with the caller's normal access
|
||||
but must not receive workspace or temp write capabilities. Set `Writable:
|
||||
false` for the direct AppContainer lane.
|
||||
|
||||
## Concurrency and crash safety
|
||||
|
||||
Standing capability ACEs are materialized under short, per-root named mutexes.
|
||||
Commands sharing a workspace can then run concurrently without grant/restore
|
||||
races. `ForbidReadRoots` still use temporary current-user deny ACEs, so those
|
||||
paths remain locked for the full command. A crash marker lets the next run
|
||||
remove a deny left by a dead helper.
|
||||
|
||||
The legacy AppContainer compatibility path retains its existing ACL residue
|
||||
sweeper for direct tools. The `WRITE_RESTRICTED` lane never lowers the process
|
||||
integrity level and never recursively relabels the workspace Low/Medium.
|
||||
|
||||
## Network and read boundaries
|
||||
|
||||
AppContainer omits network capabilities when `Network` is false.
|
||||
`WRITE_RESTRICTED` controls write-class access only; it does not isolate reads,
|
||||
network, registry access, or process visibility. A restricted-token launch with
|
||||
`Network: false` therefore fails closed. Reasonix reports Windows enforcement as
|
||||
`partial` and retains `ForbidReadRoots` for selected sensitive paths.
|
||||
|
||||
Everyone remains in the restricting set because Windows shell, DLL, CNG, pipe,
|
||||
and desktop initialization depends on it. A location that grants ambient write
|
||||
access directly to Everyone can remain writable; this is a documented Windows
|
||||
boundary of the approach.
|
||||
|
||||
## Environment overrides
|
||||
|
||||
| Variable | Effect |
|
||||
| --- | --- |
|
||||
| `WINDOWS_SANDBOX_WAIT_MS` | Maximum runtime before the Job Object is terminated. |
|
||||
| `WINDOWS_SANDBOX_ICACLS_TIMEOUT_MS` | Timeout for legacy AppContainer ACL cleanup operations. |
|
||||
| `WINDOWS_SANDBOX_LOCK_MS` | Maximum wait for a capability or temporary-deny root lock. |
|
||||
|
||||
## Verification
|
||||
|
||||
Windows-native tests cover workspace and private-temp writes, outside and
|
||||
sibling-temp denial, read-only capability downgrades, protected-root overlap,
|
||||
forbid-read cleanup, AppContainer network denial, stdio/environment/cwd, timeouts,
|
||||
and Job Object process-tree termination. Cross-compilation on non-Windows hosts
|
||||
verifies all Win32 bindings and helper wiring.
|
||||
@@ -1,128 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const capabilityRecordVersion = 1
|
||||
|
||||
type capabilityRecordStore struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
type capabilityRecord struct {
|
||||
Version int `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Purpose capabilityPurpose `json:"purpose"`
|
||||
CanonicalPath string `json:"canonicalPath"`
|
||||
SID string `json:"sid"`
|
||||
AccessMask uint32 `json:"accessMask"`
|
||||
Inheritance uint32 `json:"inheritance"`
|
||||
VolumeSerial uint32 `json:"volumeSerial"`
|
||||
FileIndexHigh uint32 `json:"fileIndexHigh"`
|
||||
FileIndexLow uint32 `json:"fileIndexLow"`
|
||||
OwnerPID int `json:"ownerPid"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func newCapabilityRecordStore(protectedRoots, writableRoots []string) (*capabilityRecordStore, error) {
|
||||
for _, root := range protectedRoots {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve capability record root %q: %w", root, err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create capability record root %q: %w", abs, err)
|
||||
}
|
||||
canonical, err := canonicalWindowsDirectory(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canonicalize capability record root %q: %w", abs, err)
|
||||
}
|
||||
dir := filepath.Join(canonical, "windows-sandbox-capabilities-v1")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create capability record directory %q: %w", dir, err)
|
||||
}
|
||||
canonicalDir, err := canonicalWindowsDirectory(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate capability record directory %q: %w", dir, err)
|
||||
}
|
||||
if !windowsPathWithin(canonical, canonicalDir) {
|
||||
return nil, fmt.Errorf("capability record directory %q escapes protected state root %q", canonicalDir, canonical)
|
||||
}
|
||||
for _, writable := range writableRoots {
|
||||
if windowsPathsOverlap(canonicalDir, writable) {
|
||||
return nil, fmt.Errorf("capability record directory %q overlaps writable root %q", canonicalDir, writable)
|
||||
}
|
||||
}
|
||||
return &capabilityRecordStore{dir: canonicalDir}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *capabilityRecordStore) write(capability restrictedCapability, status string) error {
|
||||
if s == nil || capability.purpose != capabilityWorkspace {
|
||||
return nil
|
||||
}
|
||||
record := capabilityRecord{
|
||||
Version: capabilityRecordVersion,
|
||||
Status: status,
|
||||
Purpose: capability.purpose,
|
||||
CanonicalPath: capability.root,
|
||||
SID: capability.sidText,
|
||||
AccessMask: uint32(capabilityWriteGrantMask),
|
||||
Inheritance: uint32(windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT),
|
||||
VolumeSerial: capability.object.volumeSerial,
|
||||
FileIndexHigh: capability.object.indexHigh,
|
||||
FileIndexLow: capability.object.indexLow,
|
||||
OwnerPID: os.Getpid(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
nameHash := sha256.Sum256([]byte(capability.sidText))
|
||||
path := filepath.Join(s.dir, hex.EncodeToString(nameHash[:16])+".json")
|
||||
tmp, err := os.CreateTemp(s.dir, ".capability-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create capability record: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
return fmt.Errorf("protect capability record: %w", err)
|
||||
}
|
||||
encoder := json.NewEncoder(tmp)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(record); err != nil {
|
||||
return fmt.Errorf("encode capability record: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("flush capability record: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close capability record: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("publish capability record: %w", err)
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type capabilityPurpose string
|
||||
|
||||
const (
|
||||
capabilityWorkspace capabilityPurpose = "workspace-write"
|
||||
capabilitySessionTemp capabilityPurpose = "session-temp-write"
|
||||
capabilityHashDomain = "reasonix/windows-write-capability/v1"
|
||||
)
|
||||
|
||||
// deriveCapabilitySID returns an unforgeable-in-practice Windows capability
|
||||
// identity for one canonical directory and purpose. The SID is authority-local
|
||||
// (S-1-4) and gains power only where an ACL explicitly names it.
|
||||
func deriveCapabilitySID(purpose capabilityPurpose, canonicalPath string) string {
|
||||
first, second := capabilitySubauthorities(purpose, canonicalPath)
|
||||
return fmt.Sprintf("S-1-4-%d-%d", first, second)
|
||||
}
|
||||
|
||||
func capabilitySubauthorities(purpose capabilityPurpose, canonicalPath string) (uint32, uint32) {
|
||||
digest := sha256.Sum256([]byte(capabilityHashDomain + "\x00" + string(purpose) + "\x00" + canonicalPath))
|
||||
const modulus = uint32(1<<30 - 1)
|
||||
first := binary.LittleEndian.Uint32(digest[0:4])%modulus + 1
|
||||
second := binary.LittleEndian.Uint32(digest[4:8])%modulus + 1
|
||||
return first, second
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCapabilitySIDIsDeterministicAndValid(t *testing.T) {
|
||||
first := deriveCapabilitySID(capabilityWorkspace, `c:\work\reasonix`)
|
||||
second := deriveCapabilitySID(capabilityWorkspace, `c:\work\reasonix`)
|
||||
if first != second {
|
||||
t.Fatalf("capability SID is not deterministic: %q != %q", first, second)
|
||||
}
|
||||
if !regexp.MustCompile(`^S-1-4-[1-9][0-9]*-[1-9][0-9]*$`).MatchString(first) {
|
||||
t.Fatalf("capability SID has unexpected form: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitySIDSeparatesPurposeAndPath(t *testing.T) {
|
||||
workspace := deriveCapabilitySID(capabilityWorkspace, `c:\work\reasonix`)
|
||||
temp := deriveCapabilitySID(capabilitySessionTemp, `c:\work\reasonix`)
|
||||
otherWorkspace := deriveCapabilitySID(capabilityWorkspace, `c:\work\other`)
|
||||
|
||||
if workspace == temp {
|
||||
t.Fatalf("workspace and session-temp capabilities must be domain separated: %q", workspace)
|
||||
}
|
||||
if workspace == otherWorkspace {
|
||||
t.Fatalf("different roots must receive different capabilities: %q", workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitySIDSubauthoritiesStayInThirtyBitRange(t *testing.T) {
|
||||
first, second := capabilitySubauthorities(capabilityWorkspace, `c:\work\reasonix`)
|
||||
const max = uint32(1<<30 - 1)
|
||||
if first < 1 || first > max || second < 1 || second > max {
|
||||
t.Fatalf("subauthorities outside 1..2^30-1: %d, %d", first, second)
|
||||
}
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Reasonix launches every sandboxed command as its own helper process, so two
|
||||
// commands preparing the same directory are separate OS processes. Standing
|
||||
// capability ACEs use this lock only while the exact entry is materialized and
|
||||
// its directory identity is revalidated. Temporary forbid-read ACEs and the
|
||||
// legacy AppContainer lane still restore descriptor snapshots, so they keep the
|
||||
// lock for their whole mutation window. A shared path has no per-process ACL
|
||||
// view; serializing those mutations prevents one helper's cleanup from undoing
|
||||
// another helper's boundary.
|
||||
//
|
||||
// windowsRootLock takes a per-root named mutex for the lifetime of a run. The
|
||||
// mutex lives in the session-local namespace and the OS releases it
|
||||
// automatically if the holder dies, so a crashed command never deadlocks the
|
||||
// next one (WAIT_ABANDONED is treated as ownership). Multiple roots are locked
|
||||
// in a stable sorted order so two processes cannot deadlock by acquiring them
|
||||
// in opposite orders.
|
||||
//
|
||||
// Trade-off: a long-running command holds a root lock only when it has a
|
||||
// temporary forbid-read or legacy AppContainer mutation on that root. Ordinary
|
||||
// workspace capability setup is short and commands sharing the workspace run
|
||||
// concurrently afterward. Lock waits remain bounded and identify their holder.
|
||||
|
||||
// stillActiveExitCode is STILL_ACTIVE: GetExitCodeProcess reports it while a
|
||||
// process is running. Used to tell a live marker-owner from a dead one.
|
||||
const stillActiveExitCode = 259
|
||||
|
||||
// Windows mutexes have thread affinity: the thread that acquires a mutex is the
|
||||
// only one that can release it (ReleaseMutex from another thread fails with
|
||||
// ERROR_NOT_OWNER), and the owning thread re-acquiring is a no-op that would
|
||||
// break serialization within a process. A Go goroutine can migrate across OS
|
||||
// threads between the acquire and the deferred release, so the lock pins itself
|
||||
// to one OS thread for its whole lifetime with runtime.LockOSThread and unpins
|
||||
// on release. This keeps ReleaseMutex on the owning thread and prevents a
|
||||
// concurrent goroutine that happens to land on the owner thread from re-entering
|
||||
// the mutex.
|
||||
type windowsRootLock struct {
|
||||
handles []windows.Handle
|
||||
names []string
|
||||
pinned bool
|
||||
}
|
||||
|
||||
// lockWindowsRoots serializes access to every distinct root in roots. The
|
||||
// returned lock must be released once the run's grants have been cleaned up.
|
||||
// notice, when non-nil, receives a one-line message if acquisition has to
|
||||
// queue behind another run for more than windowsRootLockNoticeAfter.
|
||||
// holderLabel is this run's command preview, recorded so a queued command can
|
||||
// name what is blocking it; lockWait is the caller's wait budget (see
|
||||
// windowsRootLockTimeout).
|
||||
func lockWindowsRoots(roots []string, notice io.Writer, holderLabel string, lockWait time.Duration) (*windowsRootLock, error) {
|
||||
names := windowsRootLockNames(roots)
|
||||
if len(names) == 0 {
|
||||
return &windowsRootLock{}, nil
|
||||
}
|
||||
lock := &windowsRootLock{}
|
||||
// Pin before acquiring the first mutex and stay pinned until release so every
|
||||
// mutex is owned by, and released from, the same OS thread.
|
||||
runtime.LockOSThread()
|
||||
lock.pinned = true
|
||||
timeout := windowsRootLockTimeout(lockWait)
|
||||
for _, name := range names {
|
||||
h, err := acquireNamedMutex(name, timeout, notice)
|
||||
if err != nil {
|
||||
lock.release()
|
||||
return nil, err
|
||||
}
|
||||
lock.handles = append(lock.handles, h)
|
||||
lock.names = append(lock.names, name)
|
||||
writeWindowsLockHolder(name, holderLabel)
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
func (l *windowsRootLock) release() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
for i, v := range slices.Backward(l.handles) {
|
||||
h := v
|
||||
if h == 0 {
|
||||
continue
|
||||
}
|
||||
// Drop the holder record before releasing the mutex so the next owner
|
||||
// never races its own record against ours.
|
||||
removeWindowsLockHolder(l.names[i])
|
||||
_ = windows.ReleaseMutex(h)
|
||||
_ = windows.CloseHandle(h)
|
||||
}
|
||||
l.handles = nil
|
||||
l.names = nil
|
||||
if l.pinned {
|
||||
runtime.UnlockOSThread()
|
||||
l.pinned = false
|
||||
}
|
||||
}
|
||||
|
||||
// windowsRootLockNames maps roots to a deduplicated, sorted list of mutex
|
||||
// names. Sorting guarantees a global acquisition order across processes so a
|
||||
// multi-root run cannot deadlock against another acquiring the same roots in a
|
||||
// different order.
|
||||
func windowsRootLockNames(roots []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var names []string
|
||||
for _, root := range roots {
|
||||
for _, key := range windowsRootLockDomains(root) {
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
sum := sha1.Sum([]byte(key))
|
||||
names = append(names, `Local\windows-sandbox.`+hex.EncodeToString(sum[:16]))
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// windowsRootLockDomains includes each directory and its non-volume ancestors.
|
||||
// Consequently ACL mutations for a parent and child path share at least one
|
||||
// mutex even when callers spell different roots. This is deliberately
|
||||
// conservative: short standing-capability materialization may serialize with a
|
||||
// sibling under a common ancestor, while whole-run locks remain limited to the
|
||||
// comparatively rare forbid-read paths.
|
||||
func windowsRootLockDomains(root string) []string {
|
||||
root = strings.ToLower(filepath.Clean(strings.TrimSpace(root)))
|
||||
if root == "" || root == "." {
|
||||
return nil
|
||||
}
|
||||
volume := strings.ToLower(filepath.Clean(filepath.VolumeName(root) + string(filepath.Separator)))
|
||||
var domains []string
|
||||
for current := root; current != "" && current != "."; current = filepath.Dir(current) {
|
||||
if current == volume || filepath.Dir(current) == current {
|
||||
break
|
||||
}
|
||||
domains = append(domains, current)
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
// windowsRootLockNoticeAfter is how long a lock acquisition may block silently
|
||||
// before a one-line notice is emitted. Queueing behind another sandboxed
|
||||
// command's whole-run lock is by design (see the package comment above), but
|
||||
// before this notice existed a queued command was indistinguishable from a
|
||||
// hung one — users read the silence as "bash is broken" (#6067).
|
||||
const windowsRootLockNoticeAfter = 2 * time.Second
|
||||
|
||||
func acquireNamedMutex(name string, timeout time.Duration, notice io.Writer) (windows.Handle, error) {
|
||||
name16, err := windows.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// CreateMutex returns a valid handle even when the named mutex already
|
||||
// exists (err == ERROR_ALREADY_EXISTS); only a zero handle is a real error.
|
||||
h, err := windows.CreateMutex(nil, false, name16)
|
||||
if h == 0 {
|
||||
return 0, fmt.Errorf("create sandbox mutex %q: %w", name, err)
|
||||
}
|
||||
// Wait in slices so a long queue surfaces a notice instead of blocking
|
||||
// silently until the deadline.
|
||||
var waited time.Duration
|
||||
noticed := false
|
||||
for {
|
||||
slice := windowsRootLockNoticeAfter
|
||||
if timeout > 0 {
|
||||
if remaining := timeout - waited; remaining < slice {
|
||||
slice = remaining
|
||||
}
|
||||
}
|
||||
event, werr := windows.WaitForSingleObject(h, uint32(slice/time.Millisecond))
|
||||
switch event {
|
||||
case windows.WAIT_OBJECT_0, windows.WAIT_ABANDONED:
|
||||
// WAIT_ABANDONED means the previous holder died without releasing. We now
|
||||
// own the mutex; any filesystem residue that run left behind is cleared by
|
||||
// sweepWindowsDenyResidue before we re-apply, and the integrity-label reset
|
||||
// on cleanup returns the tree to Medium.
|
||||
return h, nil
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
waited += slice
|
||||
if timeout > 0 && waited >= timeout {
|
||||
_ = windows.CloseHandle(h)
|
||||
if holder := describeWindowsLockHolder(name); holder != "" {
|
||||
return 0, fmt.Errorf("timed out waiting for sandbox lock %q after %s (%s; stop that command or set WINDOWS_SANDBOX_LOCK_MS higher)", name, timeout, holder)
|
||||
}
|
||||
return 0, fmt.Errorf("timed out waiting for sandbox lock %q after %s (stop the earlier sandboxed command or set WINDOWS_SANDBOX_LOCK_MS higher)", name, timeout)
|
||||
}
|
||||
if !noticed && notice != nil {
|
||||
noticed = true
|
||||
limit := "no limit"
|
||||
if timeout > 0 {
|
||||
limit = timeout.String()
|
||||
}
|
||||
if holder := describeWindowsLockHolder(name); holder != "" {
|
||||
fmt.Fprintf(notice, "windows sandbox: waiting for another sandboxed command on this workspace to finish (%s; lock wait limit %s; stop that command or set WINDOWS_SANDBOX_LOCK_MS to adjust)\n", holder, limit)
|
||||
} else {
|
||||
fmt.Fprintf(notice, "windows sandbox: waiting for another sandboxed command on this workspace to finish (lock wait limit %s; stop the earlier background/long-running command or set WINDOWS_SANDBOX_LOCK_MS to adjust)\n", limit)
|
||||
}
|
||||
}
|
||||
default:
|
||||
_ = windows.CloseHandle(h)
|
||||
if werr != nil {
|
||||
return 0, fmt.Errorf("wait for sandbox lock %q: %w", name, werr)
|
||||
}
|
||||
return 0, fmt.Errorf("wait for sandbox lock %q returned %#x", name, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lock-holder records live under the same user-shared %TEMP% as the residue
|
||||
// markers, one file per root digest. All operations are best-effort: the record
|
||||
// is diagnostic, so a write or read failure must never affect the run.
|
||||
func windowsLockHolderDir() string {
|
||||
return filepath.Join(os.TempDir(), "windows-sandbox-lockholders")
|
||||
}
|
||||
|
||||
func windowsLockHolderPath(mutexName string) string {
|
||||
return filepath.Join(windowsLockHolderDir(), lockHolderFileName(mutexName))
|
||||
}
|
||||
|
||||
func writeWindowsLockHolder(mutexName, label string) {
|
||||
if err := os.MkdirAll(windowsLockHolderDir(), 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
rec := lockHolderRecord{pid: os.Getpid(), startUnixMS: time.Now().UnixMilli(), label: label}
|
||||
_ = os.WriteFile(windowsLockHolderPath(mutexName), []byte(formatLockHolderRecord(rec)), 0o600)
|
||||
}
|
||||
|
||||
func removeWindowsLockHolder(mutexName string) {
|
||||
_ = os.Remove(windowsLockHolderPath(mutexName))
|
||||
}
|
||||
|
||||
// describeWindowsLockHolder names the run currently holding mutexName, or ""
|
||||
// when no trustworthy record exists. A record whose PID is dead is a crash
|
||||
// leftover (the OS already released the abandoned mutex); it is skipped, not
|
||||
// deleted, because removal here could race the file the next live holder just
|
||||
// wrote — the next acquire overwrites it anyway.
|
||||
func describeWindowsLockHolder(mutexName string) string {
|
||||
data, err := os.ReadFile(windowsLockHolderPath(mutexName))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rec, ok := parseLockHolderRecord(string(data))
|
||||
if !ok || !windowsProcessAlive(strconv.Itoa(rec.pid)) {
|
||||
return ""
|
||||
}
|
||||
return describeLockHolder(rec, time.Now())
|
||||
}
|
||||
|
||||
// windowsMutatedRoots is the set of paths a run edits ACLs on: its writable
|
||||
// roots plus any forbid_read roots that exist. These are the paths that must be
|
||||
// serialized against concurrent runs and tracked for crash-residue cleanup.
|
||||
func windowsMutatedRoots(spec Spec) []string {
|
||||
roots := append([]string(nil), windowsWritableRoots(spec)...)
|
||||
for _, r := range normalizedWindowsRoots(spec.ForbidReadRoots) {
|
||||
if pathExists(r) {
|
||||
roots = append(roots, r)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// windowsMutatedRootsForRun extends windowsMutatedRoots with the executable
|
||||
// directories the run will actually mutate ACLs on: the non-system ones. A run
|
||||
// snapshots/grants/restores argv[0]'s directory (and a Git install root), so two
|
||||
// runs in different workspaces that share one tool directory would otherwise
|
||||
// interleave their ACL snapshots and corrupt each other. System tool directories
|
||||
// (System32, Program Files) are never mutated (grantAppContainerExecutable skips
|
||||
// them), so they must stay out of the lock too — both sets draw from the same
|
||||
// windowsMutableExecutableGrantRoots so the locked paths and the mutated paths
|
||||
// cannot drift apart, and every command sharing the system shell is spared a
|
||||
// needless serialization.
|
||||
func windowsMutatedRootsForRun(spec Spec, exe string) []string {
|
||||
roots := windowsMutatedRoots(spec)
|
||||
return append(roots, windowsMutableExecutableGrantRoots(exe)...)
|
||||
}
|
||||
|
||||
// isWindowsSystemRoot reports whether path is inside a Windows system location
|
||||
// (%SystemRoot%, the Program Files variants). Determined by path membership, not
|
||||
// by attempting a write, so the result is stable regardless of the process's
|
||||
// integrity level or admin rights. Backs windowsMutableExecutableGrantRoots,
|
||||
// which keeps shared system directories out of both the per-root lock set and
|
||||
// the executable grant/residue set.
|
||||
func isWindowsSystemRoot(path string) bool {
|
||||
clean := strings.ToLower(filepath.Clean(path))
|
||||
for _, envVar := range []string{"SystemRoot", "windir", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"} {
|
||||
root := os.Getenv(envVar)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
root = strings.ToLower(filepath.Clean(root))
|
||||
if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// forbid_read applies a deny ACE for the current user SID so a same-user
|
||||
// low-integrity child cannot bypass the deny through normal file ACLs, and
|
||||
// writable runs grant AppContainer/user read+execute on the tool executable's
|
||||
// directory. Both are removed on normal cleanup, but a crash (or force-kill)
|
||||
// skips cleanup and leaves residue: a deny ACE locks the user out of, e.g.,
|
||||
// ~/.ssh until they repair the ACL by hand; a stale grant silently widens read
|
||||
// access to a tool directory. The residue tracker records each mutated path in a
|
||||
// per-PID marker *before* the ACE is applied, so any crash point leaves a marker
|
||||
// the next run can sweep; the next run removes the residue for markers whose
|
||||
// owning process is gone. Only the stable, sandbox-applied trustees are removed,
|
||||
// so legitimate ACLs are left untouched.
|
||||
//
|
||||
// Each marker line is "<kind> <path>", where kind is "deny" or "grant". Lines
|
||||
// are appended and fsync'd one at a time, and a write failure aborts the run
|
||||
// before the corresponding ACE is applied, so the marker can never lag behind
|
||||
// the on-disk ACLs.
|
||||
|
||||
type residueKind string
|
||||
|
||||
const (
|
||||
residueDeny residueKind = "deny"
|
||||
residueGrant residueKind = "grant"
|
||||
)
|
||||
|
||||
type residueEntry struct {
|
||||
kind residueKind
|
||||
path string
|
||||
}
|
||||
|
||||
func windowsDenyMarkerDir() string {
|
||||
return filepath.Join(os.TempDir(), "windows-sandbox-denylocks")
|
||||
}
|
||||
|
||||
func windowsDenyMarkerPath() string {
|
||||
return filepath.Join(windowsDenyMarkerDir(), strconv.Itoa(os.Getpid())+".txt")
|
||||
}
|
||||
|
||||
func windowsNewDenyMarkerPath() string {
|
||||
pid := strconv.Itoa(os.Getpid())
|
||||
for range 8 {
|
||||
if nonce, ok := windowsResidueNonce(); ok {
|
||||
marker := filepath.Join(windowsDenyMarkerDir(), pid+"-"+nonce+".txt")
|
||||
if !pathExists(marker) {
|
||||
return marker
|
||||
}
|
||||
}
|
||||
}
|
||||
seq := residueMarkerSeq.Add(1)
|
||||
return filepath.Join(windowsDenyMarkerDir(), fmt.Sprintf("%s-%d-%d.txt", pid, time.Now().UnixNano(), seq))
|
||||
}
|
||||
|
||||
func windowsResidueNonce() (string, bool) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return hex.EncodeToString(b[:]), true
|
||||
}
|
||||
|
||||
func windowsDenyMarkerOwnerPID(name string) (string, bool) {
|
||||
if !strings.HasSuffix(name, ".txt") {
|
||||
return "", false
|
||||
}
|
||||
stem := strings.TrimSuffix(name, ".txt")
|
||||
pid, _, _ := strings.Cut(stem, "-")
|
||||
if pid == "" {
|
||||
return "", false
|
||||
}
|
||||
if _, err := strconv.ParseUint(pid, 10, 32); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return pid, true
|
||||
}
|
||||
|
||||
var (
|
||||
residueMarkerSeq atomic.Uint64
|
||||
liveResidueMarkers sync.Map
|
||||
)
|
||||
|
||||
// windowsResidueRun owns one run-local crash-residue marker. Marker filenames
|
||||
// include the process PID so a later process can tell whether the owner is gone,
|
||||
// plus a nonce so multiple concurrent Run calls in the same process never share
|
||||
// a marker. The live registry protects this process's own active markers from a
|
||||
// concurrent sweep; an unregistered marker with our PID is therefore a crashed
|
||||
// predecessor's residue after Windows reused the PID.
|
||||
type windowsResidueRun struct {
|
||||
marker string
|
||||
owned atomic.Bool
|
||||
}
|
||||
|
||||
func newWindowsDenyResidueRun() *windowsResidueRun {
|
||||
r := &windowsResidueRun{marker: windowsNewDenyMarkerPath()}
|
||||
liveResidueMarkers.Store(r.marker, struct{}{})
|
||||
return r
|
||||
}
|
||||
|
||||
// recordBeforeApply appends one "<kind>\t<path>" line to this run's marker and
|
||||
// flushes it to disk. It is called immediately before the matching ACE is
|
||||
// applied; a failure here must abort the run before the ACE is applied so the
|
||||
// marker never lags the on-disk ACLs. Returns an error so the caller can fail
|
||||
// closed. A tab separates the fields because Windows paths never contain one, so
|
||||
// the path is recovered unambiguously regardless of spaces in it.
|
||||
func (r *windowsResidueRun) recordBeforeApply(kind residueKind, path string) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("residue marker is required")
|
||||
}
|
||||
dir := windowsDenyMarkerDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create residue marker dir: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(r.marker, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open residue marker: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
r.owned.Store(true)
|
||||
if _, err := f.WriteString(string(kind) + "\t" + path + "\n"); err != nil {
|
||||
return fmt.Errorf("write residue marker: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return fmt.Errorf("sync residue marker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clear drops this run's marker after its own cleanup has removed every
|
||||
// recorded ACE. It only removes the run-local marker this object created; stale
|
||||
// predecessor markers that happen to carry this process's PID stay available for
|
||||
// the next sweep instead of being orphaned.
|
||||
func (r *windowsResidueRun) clear() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
liveResidueMarkers.Delete(r.marker)
|
||||
if !r.owned.Load() {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(r.marker)
|
||||
r.owned.Store(false)
|
||||
}
|
||||
|
||||
// sweepWindowsDenyResidue removes ACEs left by crashed runs. It only acts on
|
||||
// markers whose owning process is provably gone: a PID that is no longer
|
||||
// alive, or a file with this process's PID that is not in this process's live
|
||||
// marker registry. Windows reuses PIDs, so an unregistered self-PID marker
|
||||
// records a dead predecessor's residue, while registered self-PID markers belong
|
||||
// to concurrent live Run calls. Only the stable sandbox-applied trustees are
|
||||
// removed, so it never disturbs a live run or a legitimate ACL. Best-effort: any
|
||||
// error is ignored so it can never block a run.
|
||||
func sweepWindowsDenyResidue() {
|
||||
dir := windowsDenyMarkerDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sandboxSIDs := sandboxResidueSIDs()
|
||||
self := strconv.Itoa(os.Getpid())
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, ok := windowsDenyMarkerOwnerPID(entry.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
markerPath := filepath.Join(dir, entry.Name())
|
||||
if pid == self {
|
||||
// Our PID is live only for markers registered by this process. Any
|
||||
// unregistered self-PID marker was left by a dead predecessor after PID
|
||||
// reuse, so consume it like ordinary crash residue.
|
||||
if _, live := liveResidueMarkers.Load(markerPath); live {
|
||||
continue
|
||||
}
|
||||
} else if windowsProcessAlive(pid) {
|
||||
continue
|
||||
}
|
||||
sweepResidueMarkerFile(markerPath, sandboxSIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// sandboxResidueSIDs is the trustee set the sweep removes. The sandbox only
|
||||
// ever applies these trustees, for both grants and denies, so removing exactly
|
||||
// them cannot disturb a legitimate ACL.
|
||||
func sandboxResidueSIDs() []string {
|
||||
userSID, _ := currentProcessUserSIDString()
|
||||
return dedupeSIDStrings([]string{
|
||||
allApplicationPackagesSID,
|
||||
allRestrictedApplicationPackagesSID,
|
||||
userSID,
|
||||
})
|
||||
}
|
||||
|
||||
// sweepResidueMarkerFile removes the ACEs recorded in one dead run's marker,
|
||||
// then the marker itself.
|
||||
func sweepResidueMarkerFile(markerPath string, sandboxSIDs []string) {
|
||||
for _, e := range readResidueMarker(markerPath) {
|
||||
if !pathExists(e.path) || !sweepableResidue(e) {
|
||||
continue
|
||||
}
|
||||
switch e.kind {
|
||||
case residueDeny:
|
||||
removeDeniedAppContainerSIDs(e.path, sandboxSIDs)
|
||||
case residueGrant:
|
||||
removeGrantedAppContainerSIDs(e.path, sandboxSIDs)
|
||||
}
|
||||
}
|
||||
_ = os.Remove(markerPath)
|
||||
}
|
||||
|
||||
// sweepableResidue reports whether a marker entry names a path the sandbox
|
||||
// could actually have mutated. The current code never records a system
|
||||
// directory (windowsMutableExecutableGrantRoots filters them before any
|
||||
// snapshot, grant, or marker line), but the marker file itself is untrusted
|
||||
// input: an older sandbox binary may have written one before that filter
|
||||
// existed, and the marker directory lives under the user's %TEMP%, writable to
|
||||
// anything running as the same user. Removing the broad built-in package SIDs
|
||||
// (ALL APPLICATION PACKAGES / ALL RESTRICTED APPLICATION PACKAGES) from
|
||||
// System32 or Program Files would strip factory ACEs the OS itself relies on,
|
||||
// so the sweep re-checks the invariant instead of trusting the marker.
|
||||
func sweepableResidue(e residueEntry) bool {
|
||||
return !isWindowsSystemRoot(e.path)
|
||||
}
|
||||
|
||||
// readResidueMarker parses "<kind>\t<path>" lines. A tab splits the fields so a
|
||||
// path containing spaces is preserved intact. An unrecognized line is skipped
|
||||
// rather than guessed at, so a corrupt marker cannot cause a wrong ACE removal.
|
||||
func readResidueMarker(path string) []residueEntry {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []residueEntry
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r\n")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
kindStr, p, ok := strings.Cut(line, "\t")
|
||||
if !ok || p == "" {
|
||||
continue
|
||||
}
|
||||
switch residueKind(kindStr) {
|
||||
case residueDeny:
|
||||
out = append(out, residueEntry{kind: residueDeny, path: p})
|
||||
case residueGrant:
|
||||
out = append(out, residueEntry{kind: residueGrant, path: p})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// windowsProcessAlive reports whether the given PID is still running. A parse
|
||||
// failure or a process that cannot be opened is treated as not-alive so stale
|
||||
// markers get cleaned; PID reuse can only delay cleanup, never corrupt a live
|
||||
// run or lose residue: same-process live markers are protected by
|
||||
// liveResidueMarkers, and a stale marker whose PID is reused by an unrelated
|
||||
// process merely waits until that process exits — unless the reuser is this
|
||||
// process, in which case the marker is unregistered and swept at run start.
|
||||
func windowsProcessAlive(pidStr string) bool {
|
||||
pid, err := strconv.ParseUint(pidStr, 10, 32)
|
||||
if err != nil || pid == 0 {
|
||||
return false
|
||||
}
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
|
||||
return false
|
||||
}
|
||||
return code == stillActiveExitCode
|
||||
}
|
||||
@@ -1,841 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// lockNoticeBuffer is an io.Writer safe to read while the contender goroutine
|
||||
// writes the queue notice into it.
|
||||
type lockNoticeBuffer struct {
|
||||
mu sync.Mutex
|
||||
b bytes.Buffer
|
||||
}
|
||||
|
||||
func (s *lockNoticeBuffer) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.Write(p)
|
||||
}
|
||||
|
||||
func (s *lockNoticeBuffer) String() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.String()
|
||||
}
|
||||
|
||||
func TestWindowsRootLockQueueEmitsNotice(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Long enough for the contender to sit through one full silent slice
|
||||
// (windowsRootLockNoticeAfter) and emit the queue notice before timing out.
|
||||
t.Setenv("WINDOWS_SANDBOX_LOCK_MS", "5000")
|
||||
held, err := lockWindowsRoots([]string{root}, nil, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("hold lock: %v", err)
|
||||
}
|
||||
|
||||
var notice lockNoticeBuffer
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
lock, err := lockWindowsRoots([]string{root}, ¬ice, "", 0)
|
||||
if err == nil {
|
||||
lock.release()
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if strings.Contains(notice.String(), "waiting for another sandboxed command") {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(notice.String(), "waiting for another sandboxed command") {
|
||||
t.Fatalf("queued lock never emitted a notice; got %q", notice.String())
|
||||
}
|
||||
held.release()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("contender never finished after release")
|
||||
}
|
||||
}
|
||||
|
||||
// A held root lock records its holder (pid + command label); a queued
|
||||
// contender's notice names that command so the user knows what to stop, and
|
||||
// release removes the record again.
|
||||
func TestWindowsRootLockHolderRecordedAndCleared(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("WINDOWS_SANDBOX_LOCK_MS", "5000")
|
||||
held, err := lockWindowsRoots([]string{root}, nil, "npm run dev", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("hold lock: %v", err)
|
||||
}
|
||||
name := windowsRootLockNames([]string{root})[0]
|
||||
data, err := os.ReadFile(windowsLockHolderPath(name))
|
||||
if err != nil {
|
||||
t.Fatalf("holder record not written: %v", err)
|
||||
}
|
||||
rec, ok := parseLockHolderRecord(string(data))
|
||||
if !ok || rec.pid != os.Getpid() || rec.label != "npm run dev" {
|
||||
t.Fatalf("holder record = %+v ok=%v, want this process with label", rec, ok)
|
||||
}
|
||||
|
||||
var notice lockNoticeBuffer
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
lock, err := lockWindowsRoots([]string{root}, ¬ice, "", 0)
|
||||
if err == nil {
|
||||
lock.release()
|
||||
}
|
||||
}()
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if strings.Contains(notice.String(), `held by "npm run dev"`) {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(notice.String(), `held by "npm run dev"`) {
|
||||
t.Fatalf("queued notice never named the holder; got %q", notice.String())
|
||||
}
|
||||
held.release()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("contender never finished after release")
|
||||
}
|
||||
if pathExists(windowsLockHolderPath(name)) {
|
||||
t.Fatal("holder record not removed after release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsRootLockNamesAreSortedAndDeduped(t *testing.T) {
|
||||
names := windowsRootLockNames([]string{
|
||||
`C:\work\b`,
|
||||
`C:\work\a`,
|
||||
`C:\WORK\A`, // same as a, different case
|
||||
"",
|
||||
".",
|
||||
})
|
||||
if len(names) < 2 {
|
||||
t.Fatalf("lock names = %v, want roots and their ancestor domains", names)
|
||||
}
|
||||
if !sort.StringsAreSorted(names) {
|
||||
t.Fatalf("lock names must be sorted for deadlock-free acquisition: %v", names)
|
||||
}
|
||||
for _, n := range names {
|
||||
if !strings.HasPrefix(n, `Local\windows-sandbox.`) {
|
||||
t.Fatalf("unexpected lock name %q", n)
|
||||
}
|
||||
}
|
||||
// Case-insensitive dedup: A and a must collapse to one name.
|
||||
same := windowsRootLockNames([]string{`C:\work\a`})
|
||||
if len(same) == 0 {
|
||||
t.Fatal("single root produced no lock domains")
|
||||
}
|
||||
for _, name := range same {
|
||||
if !contains(names, name) {
|
||||
t.Fatalf("case-insensitive dedup broken: %v vs %v", names, same)
|
||||
}
|
||||
}
|
||||
if len(windowsRootLockNames([]string{`C:\work\a`, `C:\WORK\A`})) != len(same) {
|
||||
t.Fatalf("case-insensitive dedup broken: %v vs %v", names, same)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsRootLockParentAndChildShareDomain(t *testing.T) {
|
||||
parent := windowsRootLockNames([]string{`C:\work\repo`})
|
||||
child := windowsRootLockNames([]string{`C:\work\repo\secret`})
|
||||
shared := false
|
||||
for _, candidate := range parent {
|
||||
if contains(child, candidate) {
|
||||
shared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !shared {
|
||||
t.Fatalf("parent and child locks do not conflict: parent=%v child=%v", parent, child)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsRootLockSerializesSameRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// The two locks target the same root, so the second acquire must block until
|
||||
// the first releases. Use a short timeout so a regression fails fast.
|
||||
t.Setenv("WINDOWS_SANDBOX_LOCK_MS", "2000")
|
||||
|
||||
first, err := lockWindowsRoots([]string{root}, nil, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("first lock: %v", err)
|
||||
}
|
||||
|
||||
acquired := make(chan *windowsRootLock, 1)
|
||||
go func() {
|
||||
second, err := lockWindowsRoots([]string{root}, nil, "", 0)
|
||||
if err != nil {
|
||||
acquired <- nil
|
||||
return
|
||||
}
|
||||
acquired <- second
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
t.Fatal("second lock acquired while first was held; roots not serialized")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
// Expected: still blocked.
|
||||
}
|
||||
|
||||
first.release()
|
||||
select {
|
||||
case second := <-acquired:
|
||||
if second == nil {
|
||||
t.Fatal("second lock failed to acquire after first released")
|
||||
}
|
||||
second.release()
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("second lock never acquired after first released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsRootLockTimesOutWhenHeld(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("WINDOWS_SANDBOX_LOCK_MS", "300")
|
||||
held, err := lockWindowsRoots([]string{root}, nil, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("hold lock: %v", err)
|
||||
}
|
||||
defer held.release()
|
||||
|
||||
// The contender must run on its own goroutine, not the holder's: a Windows
|
||||
// mutex is recursive for its owning OS thread, so re-acquiring from the same
|
||||
// thread would succeed instead of timing out. The holder pins its thread, so
|
||||
// the contender goroutine lands on a different thread and genuinely blocks.
|
||||
type result struct {
|
||||
err error
|
||||
elapsed time.Duration
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
start := time.Now()
|
||||
lock, err := lockWindowsRoots([]string{root}, nil, "", 0)
|
||||
if lock != nil {
|
||||
lock.release()
|
||||
}
|
||||
done <- result{err: err, elapsed: time.Since(start)}
|
||||
}()
|
||||
|
||||
select {
|
||||
case r := <-done:
|
||||
if r.err == nil {
|
||||
t.Fatal("expected timeout acquiring a held lock")
|
||||
}
|
||||
if !strings.Contains(r.err.Error(), "timed out") {
|
||||
t.Fatalf("error = %v, want timeout", r.err)
|
||||
}
|
||||
if r.elapsed > 2*time.Second {
|
||||
t.Fatalf("lock timeout took too long: %s", r.elapsed)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("contender never returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsRootLockMultiRootNoSelfDeadlock(t *testing.T) {
|
||||
a := t.TempDir()
|
||||
b := t.TempDir()
|
||||
t.Setenv("WINDOWS_SANDBOX_LOCK_MS", "2000")
|
||||
// Acquiring multiple roots in one call must not deadlock regardless of the
|
||||
// order the caller passes them, because names are sorted internally.
|
||||
lock, err := lockWindowsRoots([]string{b, a, b}, nil, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("multi-root lock: %v", err)
|
||||
}
|
||||
lock.release()
|
||||
}
|
||||
|
||||
func TestWindowsResidueMarkerIncrementalRoundTrip(t *testing.T) {
|
||||
// Redirect the marker dir into a temp dir so the test never touches the real
|
||||
// %TEMP%. windowsDenyMarkerDir derives from os.TempDir, so overriding TMP is
|
||||
// enough on Windows.
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMP", tmp)
|
||||
t.Setenv("TEMP", tmp)
|
||||
|
||||
// Append entries one at a time, as the run does before applying each ACE.
|
||||
run := newWindowsDenyResidueRun()
|
||||
defer run.clear()
|
||||
if err := run.recordBeforeApply(residueDeny, `C:\Users\me\.ssh`); err != nil {
|
||||
t.Fatalf("record deny: %v", err)
|
||||
}
|
||||
if err := run.recordBeforeApply(residueGrant, `C:\Users\me\tools`); err != nil {
|
||||
t.Fatalf("record grant: %v", err)
|
||||
}
|
||||
|
||||
marker := run.marker
|
||||
if !pathExists(marker) {
|
||||
t.Fatalf("marker not written at %s", marker)
|
||||
}
|
||||
got := readResidueMarker(marker)
|
||||
want := []residueEntry{
|
||||
{kind: residueDeny, path: `C:\Users\me\.ssh`},
|
||||
{kind: residueGrant, path: `C:\Users\me\tools`},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("marker round-trip = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("marker[%d] = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
run.clear()
|
||||
if pathExists(marker) {
|
||||
t.Fatal("marker not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsResidueMarkerSkipsMalformedLines(t *testing.T) {
|
||||
// A corrupt or unrecognized marker line must be skipped, never guessed at, so
|
||||
// a damaged marker cannot drive a wrong ACE removal.
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMP", tmp)
|
||||
t.Setenv("TEMP", tmp)
|
||||
if err := os.MkdirAll(windowsDenyMarkerDir(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := windowsDenyMarkerPath()
|
||||
content := "deny\tC:\\Users\\me\\.ssh\n" + // valid
|
||||
"garbage-no-tab\n" + // no separator
|
||||
"boguskind\tC:\\x\n" + // unknown kind
|
||||
"grant\t\n" + // empty path
|
||||
"grant\tC:\\Users\\me\\my tools\n" // valid, path with a space
|
||||
if err := os.WriteFile(marker, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readResidueMarker(marker)
|
||||
want := []residueEntry{
|
||||
{kind: residueDeny, path: `C:\Users\me\.ssh`},
|
||||
{kind: residueGrant, path: `C:\Users\me\my tools`},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parsed = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("entry %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsResidueMarkerWriteFailurePropagates(t *testing.T) {
|
||||
// If the marker cannot be created, recordResidueBeforeApply must return an
|
||||
// error so the caller fails closed instead of applying an untracked ACE. Point
|
||||
// TMP at a path that is a file, so MkdirAll of the marker dir underneath it
|
||||
// fails.
|
||||
tmp := t.TempDir()
|
||||
fileAsTemp := filepath.Join(tmp, "not-a-dir")
|
||||
if err := os.WriteFile(fileAsTemp, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("TMP", fileAsTemp)
|
||||
t.Setenv("TEMP", fileAsTemp)
|
||||
run := newWindowsDenyResidueRun()
|
||||
defer run.clear()
|
||||
if err := run.recordBeforeApply(residueDeny, `C:\Users\me\.ssh`); err == nil {
|
||||
t.Fatal("expected error when the marker directory cannot be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsResidueMarkerPIDReuseLifecycle(t *testing.T) {
|
||||
// The marker path is keyed by PID alone and Windows reuses PIDs, so a file
|
||||
// at our own path is not necessarily ours. A run must not delete a
|
||||
// predecessor's marker as if it were its own (that orphans the recorded
|
||||
// residue forever), and the run-start sweep must consume it rather than
|
||||
// skip it as "self".
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMP", tmp)
|
||||
t.Setenv("TEMP", tmp)
|
||||
if err := os.MkdirAll(windowsDenyMarkerDir(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A marker at our PID's path that this process never wrote models the dead
|
||||
// predecessor. The recorded path deliberately does not exist so consuming
|
||||
// it exercises only the marker lifecycle, not icacls.
|
||||
stale := windowsDenyMarkerPath()
|
||||
if err := os.WriteFile(stale, []byte("grant\t"+filepath.Join(tmp, "gone-tool-dir")+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Cleanup must leave a marker this process did not write.
|
||||
run := newWindowsDenyResidueRun()
|
||||
run.clear()
|
||||
if !pathExists(stale) {
|
||||
t.Fatal("clear removed a marker this process never wrote, orphaning the predecessor's residue")
|
||||
}
|
||||
|
||||
// The sweep must consume it instead of skipping pid == self.
|
||||
sweepWindowsDenyResidue()
|
||||
if pathExists(stale) {
|
||||
t.Fatal("sweep skipped a dead predecessor's marker at our own PID path")
|
||||
}
|
||||
|
||||
// Once this process records, the marker is owned: the sweep must leave it
|
||||
// alone and cleanup must remove it.
|
||||
run = newWindowsDenyResidueRun()
|
||||
if err := run.recordBeforeApply(residueDeny, filepath.Join(tmp, "own-root")); err != nil {
|
||||
t.Fatalf("record own entry: %v", err)
|
||||
}
|
||||
sweepWindowsDenyResidue()
|
||||
if !pathExists(run.marker) {
|
||||
t.Fatal("sweep removed this process's live marker")
|
||||
}
|
||||
run.clear()
|
||||
if pathExists(run.marker) {
|
||||
t.Fatal("clear did not remove this process's own marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsResidueRecordDoesNotMixWithStalePredecessorMarker(t *testing.T) {
|
||||
// If recording starts while a dead predecessor's same-PID marker is still
|
||||
// present (a caller that records without sweeping first), this run must use a
|
||||
// distinct marker. Mixing the two runs' lines would let this run's cleanup
|
||||
// delete the predecessor's record while only this run's ACEs had been removed.
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMP", tmp)
|
||||
t.Setenv("TEMP", tmp)
|
||||
if err := os.MkdirAll(windowsDenyMarkerDir(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stale := windowsDenyMarkerPath()
|
||||
if err := os.WriteFile(stale, []byte("grant\t"+filepath.Join(tmp, "predecessor-tool-dir")+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := newWindowsDenyResidueRun()
|
||||
defer run.clear()
|
||||
ownRoot := filepath.Join(tmp, "own-root")
|
||||
if err := run.recordBeforeApply(residueDeny, ownRoot); err != nil {
|
||||
t.Fatalf("record own entry: %v", err)
|
||||
}
|
||||
got := readResidueMarker(run.marker)
|
||||
want := []residueEntry{{kind: residueDeny, path: ownRoot}}
|
||||
if len(got) != 1 || got[0] != want[0] {
|
||||
t.Fatalf("marker after first record = %v, want only this run's entry %v", got, want)
|
||||
}
|
||||
if !pathExists(stale) {
|
||||
t.Fatal("this run removed the predecessor's marker")
|
||||
}
|
||||
run.clear()
|
||||
if pathExists(run.marker) {
|
||||
t.Fatal("clear did not remove this process's own marker")
|
||||
}
|
||||
if !pathExists(stale) {
|
||||
t.Fatal("clear removed the predecessor's marker")
|
||||
}
|
||||
sweepWindowsDenyResidue()
|
||||
if pathExists(stale) {
|
||||
t.Fatal("sweep did not consume the stale predecessor marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsResidueConcurrentRunsOwnDistinctMarkers(t *testing.T) {
|
||||
// Concurrent Run calls in one Go process share a PID but must not share one
|
||||
// marker: the first run to clean up would otherwise delete the second run's
|
||||
// residue record while the second run is still live.
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMP", tmp)
|
||||
t.Setenv("TEMP", tmp)
|
||||
|
||||
a := newWindowsDenyResidueRun()
|
||||
b := newWindowsDenyResidueRun()
|
||||
defer a.clear()
|
||||
defer b.clear()
|
||||
if a.marker == b.marker {
|
||||
t.Fatalf("concurrent runs share marker %s", a.marker)
|
||||
}
|
||||
if err := a.recordBeforeApply(residueDeny, filepath.Join(tmp, "a-root")); err != nil {
|
||||
t.Fatalf("record run A: %v", err)
|
||||
}
|
||||
if err := b.recordBeforeApply(residueGrant, filepath.Join(tmp, "b-root")); err != nil {
|
||||
t.Fatalf("record run B: %v", err)
|
||||
}
|
||||
|
||||
a.clear()
|
||||
if pathExists(a.marker) {
|
||||
t.Fatal("run A marker was not cleared")
|
||||
}
|
||||
if !pathExists(b.marker) {
|
||||
t.Fatal("run A cleanup removed run B marker")
|
||||
}
|
||||
sweepWindowsDenyResidue()
|
||||
if !pathExists(b.marker) {
|
||||
t.Fatal("sweep removed a live same-process marker")
|
||||
}
|
||||
b.clear()
|
||||
if pathExists(b.marker) {
|
||||
t.Fatal("run B marker was not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepableResidueRefusesSystemPaths(t *testing.T) {
|
||||
// The sweep must never act on a marker entry that names a system directory,
|
||||
// even though the current recorder can no longer write one: a marker left by
|
||||
// an older binary, or planted in the same-user-writable %TEMP%, would
|
||||
// otherwise drive icacls /remove:g of the broad built-in package SIDs against
|
||||
// System32 / Program Files and strip factory ACEs. User paths must stay
|
||||
// sweepable or crash residue would never be cleaned.
|
||||
userDir := t.TempDir()
|
||||
for _, e := range []residueEntry{
|
||||
{kind: residueDeny, path: filepath.Join(userDir, ".ssh")},
|
||||
{kind: residueGrant, path: userDir},
|
||||
} {
|
||||
if !sweepableResidue(e) {
|
||||
t.Fatalf("user path %q must be sweepable", e.path)
|
||||
}
|
||||
}
|
||||
|
||||
sysExe := systemRootTool("icacls.exe")
|
||||
if !filepath.IsAbs(sysExe) {
|
||||
t.Skip("no absolute system tool to derive a system directory from")
|
||||
}
|
||||
sysDir := filepath.Dir(sysExe)
|
||||
for _, e := range []residueEntry{
|
||||
{kind: residueGrant, path: sysDir},
|
||||
{kind: residueDeny, path: sysDir},
|
||||
{kind: residueGrant, path: filepath.Join(sysDir, "sub")},
|
||||
} {
|
||||
if sweepableResidue(e) {
|
||||
t.Fatalf("system path %q must never be sweepable", e.path)
|
||||
}
|
||||
}
|
||||
if root := os.Getenv("ProgramFiles"); root != "" {
|
||||
if sweepableResidue(residueEntry{kind: residueGrant, path: root}) {
|
||||
t.Fatalf("Program Files root %q must never be sweepable", root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsMutatedRootsForRunLocksNonSystemExeDirOnly(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
// A non-system tool directory must join the lock set; a Windows system
|
||||
// directory must not, or commands sharing the system shell would needlessly
|
||||
// serialize. Membership is by path, not by a write probe, so this holds even
|
||||
// when the test process is elevated (as CI's runner is).
|
||||
toolDir := t.TempDir()
|
||||
toolExe := filepath.Join(toolDir, "mytool.exe")
|
||||
if err := os.WriteFile(toolExe, []byte("not really an exe"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := windowsMutatedRootsForRun(Spec{WritableRoots: []string{workspace}, Writable: true}, toolExe)
|
||||
if !containsWindowsPath(got, workspace) {
|
||||
t.Fatalf("mutated roots = %v, want workspace", got)
|
||||
}
|
||||
if !containsWindowsPath(got, toolDir) {
|
||||
t.Fatalf("mutated roots = %v, want tool dir %s", got, toolDir)
|
||||
}
|
||||
|
||||
// A system executable's directory (System32) must be excluded regardless of
|
||||
// the process's integrity level. Resolve a real system tool to avoid
|
||||
// depending on PATH layout.
|
||||
sysExe := systemRootTool("icacls.exe")
|
||||
if !filepath.IsAbs(sysExe) {
|
||||
t.Skip("no absolute system tool to test the system-root exclusion")
|
||||
}
|
||||
sysRoots := windowsMutatedRootsForRun(Spec{WritableRoots: []string{workspace}, Writable: true}, sysExe)
|
||||
if containsWindowsPath(sysRoots, filepath.Dir(sysExe)) {
|
||||
t.Fatalf("system exe dir %s must not join the lock set: %v", filepath.Dir(sysExe), sysRoots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsMutableExecutableGrantRootsExcludesSystemDirs(t *testing.T) {
|
||||
// The grant loop (grantAppContainerExecutable) and the per-root lock both draw
|
||||
// their executable roots from windowsMutableExecutableGrantRoots, so it is the
|
||||
// single guard that keeps system directories from being snapshotted, granted,
|
||||
// or recorded as crash residue. A residue entry on System32 would let a later
|
||||
// sweep run icacls /remove:g for the broad built-in package SIDs and strip the
|
||||
// directory's factory ACEs, so a system tool dir must never appear here.
|
||||
toolDir := t.TempDir()
|
||||
toolExe := filepath.Join(toolDir, "mytool.exe")
|
||||
if err := os.WriteFile(toolExe, []byte("not really an exe"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := windowsMutableExecutableGrantRoots(toolExe); !containsWindowsPath(got, toolDir) {
|
||||
t.Fatalf("grant roots = %v, want non-system tool dir %s", got, toolDir)
|
||||
}
|
||||
|
||||
sysExe := systemRootTool("icacls.exe")
|
||||
if !filepath.IsAbs(sysExe) {
|
||||
t.Skip("no absolute system tool to test the system-root exclusion")
|
||||
}
|
||||
sysDir := filepath.Dir(sysExe)
|
||||
got := windowsMutableExecutableGrantRoots(sysExe)
|
||||
if containsWindowsPath(got, sysDir) {
|
||||
t.Fatalf("system exe dir %s must never be a mutable grant root: %v", sysDir, got)
|
||||
}
|
||||
// The same executable must still resolve to a non-empty grant candidate before
|
||||
// the system filter, so the exclusion is what drops it — not a resolution miss.
|
||||
if len(windowsExecutableGrantRoots(sysExe)) == 0 {
|
||||
t.Fatalf("system exe %s should resolve to a grant candidate before filtering", sysExe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWindowsSystemRoot(t *testing.T) {
|
||||
// System locations are excluded by path, independent of writability.
|
||||
sysExe := systemRootTool("icacls.exe")
|
||||
if filepath.IsAbs(sysExe) && !isWindowsSystemRoot(filepath.Dir(sysExe)) {
|
||||
t.Fatalf("%s should be classified as a system root", filepath.Dir(sysExe))
|
||||
}
|
||||
if root := os.Getenv("ProgramFiles"); root != "" {
|
||||
if !isWindowsSystemRoot(filepath.Join(root, "SomeApp")) {
|
||||
t.Fatalf("a path under %s should be a system root", root)
|
||||
}
|
||||
}
|
||||
// A user temp directory is never a system root.
|
||||
if isWindowsSystemRoot(t.TempDir()) {
|
||||
t.Fatal("a user temp dir must not be classified as a system root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsProcessAliveDetectsSelfAndDead(t *testing.T) {
|
||||
if !windowsProcessAlive(strconv.Itoa(os.Getpid())) {
|
||||
t.Fatal("current process should be reported alive")
|
||||
}
|
||||
// PID 0 and garbage never map to a live user process.
|
||||
for _, dead := range []string{"0", "not-a-pid", ""} {
|
||||
if windowsProcessAlive(dead) {
|
||||
t.Fatalf("%q should not be reported alive", dead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsMutatedRootsIncludesForbidReadThatExists(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
secret := filepath.Join(workspace, "secret")
|
||||
if err := os.Mkdir(secret, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missing := filepath.Join(workspace, "does-not-exist")
|
||||
got := windowsMutatedRoots(Spec{
|
||||
WritableRoots: []string{workspace},
|
||||
ForbidReadRoots: []string{secret, missing},
|
||||
Writable: true,
|
||||
})
|
||||
if !containsWindowsPath(got, workspace) || !containsWindowsPath(got, secret) {
|
||||
t.Fatalf("mutated roots = %v, want workspace and existing secret", got)
|
||||
}
|
||||
if containsWindowsPath(got, missing) {
|
||||
t.Fatalf("mutated roots must skip missing forbid_read paths: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsICACLSTimeoutRecursiveVsFlat(t *testing.T) {
|
||||
os.Unsetenv("WINDOWS_SANDBOX_ICACLS_TIMEOUT_MS")
|
||||
if got := icaclsTimeoutForArgs([]string{"/setintegritylevel", "L", "/T", "/C"}); got != defaultICACLSRecursiveTimeout {
|
||||
t.Fatalf("recursive timeout = %s, want %s", got, defaultICACLSRecursiveTimeout)
|
||||
}
|
||||
if got := icaclsTimeoutForArgs([]string{"/setintegritylevel", "M", "/C"}); got != defaultICACLSTimeout {
|
||||
t.Fatalf("flat timeout = %s, want %s", got, defaultICACLSTimeout)
|
||||
}
|
||||
t.Setenv("WINDOWS_SANDBOX_ICACLS_TIMEOUT_MS", "1234")
|
||||
if got := icaclsTimeoutForArgs([]string{"/T"}); got != 1234*time.Millisecond {
|
||||
t.Fatalf("env override = %s, want 1.234s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemRootToolResolvesUnderSystem32(t *testing.T) {
|
||||
got := systemRootTool("icacls.exe")
|
||||
// On any real Windows host this resolves to an absolute System32 path; the
|
||||
// fallback (bare name) only happens if the file is genuinely missing.
|
||||
if got == "icacls.exe" {
|
||||
t.Skip("icacls.exe not found under System32 on this host")
|
||||
}
|
||||
if !filepath.IsAbs(got) || !strings.EqualFold(filepath.Base(got), "icacls.exe") {
|
||||
t.Fatalf("resolved tool = %q, want absolute System32 icacls.exe", got)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(got), `system32`) {
|
||||
t.Fatalf("resolved tool = %q, want a System32 path", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowsSandboxConcurrentWritesToSharedWorkspace exercises the concurrency
|
||||
// fix end-to-end: several sandboxed writable commands run at once against the
|
||||
// same non-empty workspace with nested directories. Before the root lock, their
|
||||
// ACL/label mutations and snapshot restores would interleave and one command's
|
||||
// cleanup could revoke another's write grant mid-run, surfacing as a spurious
|
||||
// failure. With serialization every command must succeed and its file must be
|
||||
// written. The empty-temp-dir CI coverage that shipped originally could not
|
||||
// catch this.
|
||||
func TestWindowsSandboxConcurrentWritesToSharedWorkspace(t *testing.T) {
|
||||
if !Available() {
|
||||
t.Skip("windows sandbox APIs unavailable")
|
||||
}
|
||||
sh := powershellArgvForTest(t, "")
|
||||
if sh == nil {
|
||||
t.Skip("PowerShell unavailable")
|
||||
}
|
||||
workspace := t.TempDir()
|
||||
// Pre-populate with nested content so the writable relabel actually walks a
|
||||
// subtree (the empty-dir case hid #1/#2/#3).
|
||||
nested := filepath.Join(workspace, "pkg", "sub")
|
||||
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"a.txt", filepath.Join("pkg", "b.txt"), filepath.Join("pkg", "sub", "c.txt")} {
|
||||
if err := os.WriteFile(filepath.Join(workspace, f), []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const n = 4
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
target := filepath.Join(workspace, "out"+strconv.Itoa(idx)+".txt")
|
||||
script := "$ErrorActionPreference='Stop'; Set-Content -LiteralPath " + psQuote(target) + " -Value ok"
|
||||
result, err := Run(
|
||||
Spec{WritableRoots: []string{workspace}, Network: true, Writable: true, TempPrefix: "windows-sandbox-test-"},
|
||||
append(sh, script),
|
||||
RunOptions{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr},
|
||||
)
|
||||
if err != nil {
|
||||
errs[idx] = err
|
||||
return
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
errs[idx] = errExitf(idx, result.ExitCode)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := range n {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("concurrent command %d failed: %v", i, errs[i])
|
||||
}
|
||||
target := filepath.Join(workspace, "out"+strconv.Itoa(i)+".txt")
|
||||
if got, err := os.ReadFile(target); err != nil || !strings.Contains(string(got), "ok") {
|
||||
t.Fatalf("concurrent command %d output missing: %q err=%v", i, got, err)
|
||||
}
|
||||
}
|
||||
// The deterministic capability ACE intentionally remains. Broad AppContainer
|
||||
// grants and temporary current-user deny ACEs must not remain.
|
||||
assertNoWindowsSandboxACEForTest(t, workspace)
|
||||
}
|
||||
|
||||
// TestWindowsSandboxConcurrentDistinctWorkspacesSharedToolDir is the P2
|
||||
// regression: two runs in *different* workspaces (so their workspace locks do
|
||||
// not collide) that invoke a tool from one shared user-writable directory. That
|
||||
// shared exe directory is snapshot/grant/restored by each run, so without
|
||||
// folding it into the lock the two runs would interleave their ACL snapshots and
|
||||
// one could fail or leave a stale grant. Both must succeed and the shared tool
|
||||
// directory must carry no leftover sandbox ACE.
|
||||
func TestWindowsSandboxConcurrentDistinctWorkspacesSharedToolDir(t *testing.T) {
|
||||
if !Available() {
|
||||
t.Skip("windows sandbox APIs unavailable")
|
||||
}
|
||||
// Stage a copy of cmd.exe in a shared user directory as the tool. cmd.exe is a
|
||||
// single self-contained binary with no side-by-side runtime dependencies, so a
|
||||
// copy runs standalone — unlike pwsh.exe, which needs its hostfxr/DLLs next to
|
||||
// it. Each concurrent run resolves argv[0] into this shared directory, so the
|
||||
// runs contend on it even though their workspaces differ.
|
||||
cmdExe := systemRootTool("cmd.exe")
|
||||
if !filepath.IsAbs(cmdExe) {
|
||||
t.Skip("cmd.exe not found under System32")
|
||||
}
|
||||
toolDir := t.TempDir()
|
||||
sharedTool := filepath.Join(toolDir, "sandbox-shared-tool.exe")
|
||||
if err := copyFileForTest(t, cmdExe, sharedTool); err != nil {
|
||||
t.Skipf("cannot stage shared tool: %v", err)
|
||||
}
|
||||
|
||||
const n = 4
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
workspace := t.TempDir() // distinct per run: workspace locks do not collide
|
||||
target := filepath.Join(workspace, "out.txt")
|
||||
// cmd.exe /c echo ok> "<target>". No spaces before '>' so the redirect
|
||||
// target is exactly the path.
|
||||
toolArgv := []string{sharedTool, "/c", "echo ok> " + target}
|
||||
result, err := Run(
|
||||
Spec{WritableRoots: []string{workspace}, Network: true, Writable: true, TempPrefix: "windows-sandbox-test-"},
|
||||
toolArgv,
|
||||
RunOptions{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr},
|
||||
)
|
||||
if err != nil {
|
||||
errs[idx] = err
|
||||
return
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
errs[idx] = errExitf(idx, result.ExitCode)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := range n {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("concurrent run %d sharing tool dir failed: %v", i, errs[i])
|
||||
}
|
||||
}
|
||||
// The shared tool directory must be left with no sandbox grant residue.
|
||||
assertNoWindowsSandboxACEForTest(t, toolDir)
|
||||
}
|
||||
|
||||
func copyFileForTest(t *testing.T, src, dst string) error {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, 0o755)
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
return slices.Contains(haystack, needle)
|
||||
}
|
||||
|
||||
func containsWindowsPath(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if sameWindowsPath(h, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func errExitf(idx, code int) error {
|
||||
return &exitError{idx: idx, code: code}
|
||||
}
|
||||
|
||||
type exitError struct {
|
||||
idx int
|
||||
code int
|
||||
}
|
||||
|
||||
func (e *exitError) Error() string {
|
||||
return "command " + strconv.Itoa(e.idx) + " exit code " + strconv.Itoa(e.code)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFailurePhaseOfUsesTypedRunnerStage(t *testing.T) {
|
||||
base := errors.New("same text for every stage")
|
||||
for _, want := range []FailurePhase{FailureAuthorization, FailureDependency, FailureLaunch} {
|
||||
got, ok := FailurePhaseOf(failureAt(want, base))
|
||||
if !ok || got != want {
|
||||
t.Fatalf("phase = %q, %v; want %q", got, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := FailurePhaseOf(base); ok {
|
||||
t.Fatal("an untyped post-start error must remain in the execution lane")
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// The per-root lock serializes whole sandboxed runs (see the package comment in
|
||||
// coordination_windows.go). An interactive command queued behind a long-running
|
||||
// holder should fail fast with the holder named rather than hang its turn, so
|
||||
// the default wait is short; a caller whose run nobody is blocked on (a
|
||||
// background job) passes a longer budget via Spec.LockWait.
|
||||
const defaultWindowsRootLockTimeout = time.Minute
|
||||
|
||||
// windowsRootLockTimeout resolves one run's lock wait budget: the explicit
|
||||
// WINDOWS_SANDBOX_LOCK_MS override wins, then the caller's Spec.LockWait, then
|
||||
// the short interactive default.
|
||||
func windowsRootLockTimeout(specWait time.Duration) time.Duration {
|
||||
if raw := os.Getenv("WINDOWS_SANDBOX_LOCK_MS"); raw != "" {
|
||||
if ms, err := strconv.ParseUint(raw, 10, 63); err == nil && ms > 0 {
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
}
|
||||
if specWait > 0 {
|
||||
return specWait
|
||||
}
|
||||
return defaultWindowsRootLockTimeout
|
||||
}
|
||||
|
||||
// A lock-holder record names the run currently holding one root lock so a
|
||||
// queued command can tell the user what to stop. It is diagnostic only: written
|
||||
// best-effort after the mutex is acquired, removed before it is released, and
|
||||
// validated against a live PID on read, so a stale file left by a crash (the OS
|
||||
// releases the abandoned mutex itself) is ignored and overwritten by the next
|
||||
// holder of the same root. One line: "<pid>\t<start-unix-ms>\t<label>" — tabs
|
||||
// separate the fields because the label is sanitized to never contain one.
|
||||
type lockHolderRecord struct {
|
||||
pid int
|
||||
startUnixMS int64
|
||||
label string
|
||||
}
|
||||
|
||||
func formatLockHolderRecord(rec lockHolderRecord) string {
|
||||
return strconv.Itoa(rec.pid) + "\t" + strconv.FormatInt(rec.startUnixMS, 10) + "\t" + rec.label + "\n"
|
||||
}
|
||||
|
||||
func parseLockHolderRecord(data string) (lockHolderRecord, bool) {
|
||||
line, _, _ := strings.Cut(data, "\n")
|
||||
line = strings.TrimRight(line, "\r")
|
||||
parts := strings.SplitN(line, "\t", 3)
|
||||
if len(parts) != 3 {
|
||||
return lockHolderRecord{}, false
|
||||
}
|
||||
pid, err := strconv.Atoi(parts[0])
|
||||
if err != nil || pid <= 0 {
|
||||
return lockHolderRecord{}, false
|
||||
}
|
||||
start, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || start <= 0 {
|
||||
return lockHolderRecord{}, false
|
||||
}
|
||||
return lockHolderRecord{pid: pid, startUnixMS: start, label: parts[2]}, true
|
||||
}
|
||||
|
||||
// lockHolderLabelMaxRunes keeps the record one readable error-message line
|
||||
// while leaving room for the shell's absolute path ahead of the user command.
|
||||
const lockHolderLabelMaxRunes = 120
|
||||
|
||||
// lockHolderLabel renders argv as a one-line command preview for the holder
|
||||
// record: control characters (including the record's tab separator) become
|
||||
// spaces and long commands are truncated.
|
||||
func lockHolderLabel(argv []string) string {
|
||||
label := strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, strings.Join(argv, " "))
|
||||
label = strings.TrimSpace(label)
|
||||
if r := []rune(label); len(r) > lockHolderLabelMaxRunes {
|
||||
return string(r[:lockHolderLabelMaxRunes]) + "…"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// describeLockHolder renders a record for the queue notice / timeout error,
|
||||
// e.g. `held by "npm run dev", pid 1234, running 25m`.
|
||||
func describeLockHolder(rec lockHolderRecord, now time.Time) string {
|
||||
running := lockHolderRunningFor(now.Sub(time.UnixMilli(rec.startUnixMS)))
|
||||
if rec.label == "" {
|
||||
return fmt.Sprintf("held by pid %d, running %s", rec.pid, running)
|
||||
}
|
||||
return fmt.Sprintf("held by %q, pid %d, running %s", rec.label, rec.pid, running)
|
||||
}
|
||||
|
||||
// lockHolderRunningFor formats an elapsed runtime at the precision a human
|
||||
// needs to pick what to stop ("25m", not "25m3.021s"). Clock skew between the
|
||||
// holder's and the waiter's reads clamps to 0s instead of going negative.
|
||||
func lockHolderRunningFor(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
return strconv.Itoa(int(d.Seconds())) + "s"
|
||||
case d < time.Hour:
|
||||
return strconv.Itoa(int(d.Minutes())) + "m"
|
||||
default:
|
||||
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
}
|
||||
|
||||
// lockHolderFileName maps a root's mutex name (`Local\windows-sandbox.<hex>`)
|
||||
// to its holder file, keyed by the same root digest so holder and mutex can
|
||||
// never pair up differently.
|
||||
func lockHolderFileName(mutexName string) string {
|
||||
digest := mutexName
|
||||
if i := strings.LastIndexByte(digest, '.'); i >= 0 {
|
||||
digest = digest[i+1:]
|
||||
}
|
||||
return digest + ".txt"
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWindowsRootLockTimeoutPrecedence(t *testing.T) {
|
||||
// The explicit env override wins, then the caller's per-run budget, then the
|
||||
// short interactive default.
|
||||
t.Setenv("WINDOWS_SANDBOX_LOCK_MS", "")
|
||||
if got := windowsRootLockTimeout(0); got != defaultWindowsRootLockTimeout {
|
||||
t.Fatalf("default = %s, want %s", got, defaultWindowsRootLockTimeout)
|
||||
}
|
||||
if got := windowsRootLockTimeout(10 * time.Minute); got != 10*time.Minute {
|
||||
t.Fatalf("spec budget = %s, want 10m", got)
|
||||
}
|
||||
t.Setenv("WINDOWS_SANDBOX_LOCK_MS", "1234")
|
||||
if got := windowsRootLockTimeout(10 * time.Minute); got != 1234*time.Millisecond {
|
||||
t.Fatalf("env override = %s, want 1.234s", got)
|
||||
}
|
||||
// A malformed override falls back instead of hanging or failing.
|
||||
t.Setenv("WINDOWS_SANDBOX_LOCK_MS", "not-a-number")
|
||||
if got := windowsRootLockTimeout(0); got != defaultWindowsRootLockTimeout {
|
||||
t.Fatalf("malformed env = %s, want %s", got, defaultWindowsRootLockTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockHolderRecordRoundTrip(t *testing.T) {
|
||||
rec := lockHolderRecord{pid: 1234, startUnixMS: 1_700_000_000_000, label: `npm run dev`}
|
||||
got, ok := parseLockHolderRecord(formatLockHolderRecord(rec))
|
||||
if !ok || got != rec {
|
||||
t.Fatalf("round trip = %+v ok=%v, want %+v", got, ok, rec)
|
||||
}
|
||||
// A label containing spaces survives; tabs never occur (sanitized on write).
|
||||
rec.label = "cmd /c echo ok"
|
||||
if got, ok := parseLockHolderRecord(formatLockHolderRecord(rec)); !ok || got != rec {
|
||||
t.Fatalf("spaced label round trip = %+v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockHolderRecordRejectsMalformed(t *testing.T) {
|
||||
for _, data := range []string{
|
||||
"",
|
||||
"garbage",
|
||||
"12\t34", // missing label field
|
||||
"0\t123\tcmd", // pid 0 is never a real holder
|
||||
"-1\t123\tcmd", // negative pid
|
||||
"nope\t123\tcmd", // non-numeric pid
|
||||
"12\tnope\tcmd", // non-numeric start
|
||||
"12\t0\tcmd", // zero start time
|
||||
"12 34 cmd", // wrong separator
|
||||
} {
|
||||
if rec, ok := parseLockHolderRecord(data); ok {
|
||||
t.Fatalf("parse(%q) = %+v, want rejection", data, rec)
|
||||
}
|
||||
}
|
||||
// Only the first line counts; a trailing partial write cannot corrupt it.
|
||||
rec, ok := parseLockHolderRecord("12\t34\tcmd\ntrailing garbage")
|
||||
if !ok || rec.label != "cmd" {
|
||||
t.Fatalf("multi-line parse = %+v ok=%v, want first line only", rec, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockHolderLabelSanitizesAndTruncates(t *testing.T) {
|
||||
got := lockHolderLabel([]string{"pwsh", "-Command", "npm\trun\ndev"})
|
||||
if strings.ContainsAny(got, "\t\n\r") {
|
||||
t.Fatalf("label %q still contains control characters", got)
|
||||
}
|
||||
if got != "pwsh -Command npm run dev" {
|
||||
t.Fatalf("label = %q", got)
|
||||
}
|
||||
long := lockHolderLabel([]string{strings.Repeat("x", 500)})
|
||||
if r := []rune(long); len(r) > lockHolderLabelMaxRunes+1 {
|
||||
t.Fatalf("label not truncated: %d runes", len(r))
|
||||
}
|
||||
if !strings.HasSuffix(long, "…") {
|
||||
t.Fatalf("truncated label %q missing ellipsis", long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeLockHolder(t *testing.T) {
|
||||
start := time.UnixMilli(1_700_000_000_000)
|
||||
rec := lockHolderRecord{pid: 1234, startUnixMS: start.UnixMilli(), label: "npm run dev"}
|
||||
if got := describeLockHolder(rec, start.Add(25*time.Minute)); got != `held by "npm run dev", pid 1234, running 25m` {
|
||||
t.Fatalf("describe = %q", got)
|
||||
}
|
||||
rec.label = ""
|
||||
if got := describeLockHolder(rec, start.Add(3*time.Second)); got != "held by pid 1234, running 3s" {
|
||||
t.Fatalf("describe without label = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockHolderRunningFor(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{-5 * time.Second, "0s"}, // clock skew clamps, never negative
|
||||
{12 * time.Second, "12s"},
|
||||
{90 * time.Second, "1m"},
|
||||
{25 * time.Minute, "25m"},
|
||||
{2*time.Hour + 5*time.Minute, "2h05m"},
|
||||
} {
|
||||
if got := lockHolderRunningFor(tc.d); got != tc.want {
|
||||
t.Fatalf("runningFor(%s) = %q, want %q", tc.d, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockHolderFileNameMatchesMutexDigest(t *testing.T) {
|
||||
// The holder file is keyed by the same root digest as the mutex name, so the
|
||||
// two can never pair up differently.
|
||||
if got := lockHolderFileName(`Local\windows-sandbox.deadbeef00112233`); got != "deadbeef00112233.txt" {
|
||||
t.Fatalf("holder file = %q, want digest-keyed name", got)
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
disableMaxPrivilege = 0x1
|
||||
luaToken = 0x4
|
||||
writeRestricted = 0x8
|
||||
fileDeleteChild = 0x40
|
||||
capabilityWriteGrantMask = (windows.FILE_GENERIC_WRITE | windows.DELETE | fileDeleteChild) &^ windows.STANDARD_RIGHTS_WRITE
|
||||
fileAllAccess = 0x1F01FF
|
||||
)
|
||||
|
||||
type restrictedCapability struct {
|
||||
root string
|
||||
purpose capabilityPurpose
|
||||
sidText string
|
||||
sid *windows.SID
|
||||
object windowsFileIdentity
|
||||
}
|
||||
|
||||
type windowsFileIdentity struct {
|
||||
volumeSerial uint32
|
||||
indexHigh uint32
|
||||
indexLow uint32
|
||||
}
|
||||
|
||||
func prepareRestrictedCapabilities(spec Spec, tempRoot string, notice io.Writer, holderLabel string) ([]restrictedCapability, error) {
|
||||
if spec.ReadOnly {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
writeRoots, err := canonicalWindowsDirectories(spec.WritableRoots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tempRoot, err = canonicalWindowsDirectory(tempRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canonicalize session temp: %w", err)
|
||||
}
|
||||
if err := validateCapabilityBoundaries(writeRoots, tempRoot, spec.ProtectedWriteRoots); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
capabilities := make([]restrictedCapability, 0, len(writeRoots)+1)
|
||||
for _, root := range writeRoots {
|
||||
capability, err := newRestrictedCapability(capabilityWorkspace, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
capabilities = append(capabilities, capability)
|
||||
}
|
||||
tempCapability, err := newRestrictedCapability(capabilitySessionTemp, tempRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
capabilities = append(capabilities, tempCapability)
|
||||
|
||||
roots := make([]string, 0, len(capabilities))
|
||||
for _, capability := range capabilities {
|
||||
roots = append(roots, capability.root)
|
||||
}
|
||||
lock, err := lockWindowsRoots(roots, notice, holderLabel, spec.LockWait)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer lock.release()
|
||||
records, err := newCapabilityRecordStore(spec.ProtectedWriteRoots, roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, capability := range capabilities {
|
||||
if err := records.write(capability, "preparing"); err != nil {
|
||||
return nil, fmt.Errorf("record %s capability for %q before DACL update: %w", capability.purpose, capability.root, err)
|
||||
}
|
||||
if err := grantWriteCapability(capability.root, capability.sid); err != nil {
|
||||
return nil, fmt.Errorf("materialize %s capability for %q: %w", capability.purpose, capability.root, err)
|
||||
}
|
||||
current, err := windowsDirectoryIdentity(capability.root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("revalidate %s capability root %q: %w", capability.purpose, capability.root, err)
|
||||
}
|
||||
if current != capability.object {
|
||||
return nil, fmt.Errorf("%s capability root %q changed identity during sandbox preparation", capability.purpose, capability.root)
|
||||
}
|
||||
if err := records.write(capability, "active"); err != nil {
|
||||
return nil, fmt.Errorf("finalize %s capability record for %q: %w", capability.purpose, capability.root, err)
|
||||
}
|
||||
}
|
||||
return capabilities, nil
|
||||
}
|
||||
|
||||
func newRestrictedCapability(purpose capabilityPurpose, root string) (restrictedCapability, error) {
|
||||
identity := strings.ToLower(filepath.Clean(root))
|
||||
sidText := deriveCapabilitySID(purpose, identity)
|
||||
sid, err := windows.StringToSid(sidText)
|
||||
if err != nil {
|
||||
return restrictedCapability{}, fmt.Errorf("parse derived Windows capability SID %q: %w", sidText, err)
|
||||
}
|
||||
object, err := windowsDirectoryIdentity(root)
|
||||
if err != nil {
|
||||
return restrictedCapability{}, fmt.Errorf("read Windows directory identity for %q: %w", root, err)
|
||||
}
|
||||
return restrictedCapability{root: root, purpose: purpose, sidText: sidText, sid: sid, object: object}, nil
|
||||
}
|
||||
|
||||
func windowsDirectoryIdentity(root string) (windowsFileIdentity, error) {
|
||||
path16, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
return windowsFileIdentity{}, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
path16,
|
||||
windows.FILE_READ_ATTRIBUTES,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return windowsFileIdentity{}, err
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return windowsFileIdentity{}, err
|
||||
}
|
||||
return windowsFileIdentity{
|
||||
volumeSerial: info.VolumeSerialNumber,
|
||||
indexHigh: info.FileIndexHigh,
|
||||
indexLow: info.FileIndexLow,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func canonicalWindowsDirectories(roots []string) ([]string, error) {
|
||||
out := make([]string, 0, len(roots))
|
||||
seen := make(map[string]bool, len(roots))
|
||||
for _, root := range roots {
|
||||
canonical, err := canonicalWindowsDirectory(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canonicalize writable root %q: %w", root, err)
|
||||
}
|
||||
key := strings.ToLower(filepath.Clean(canonical))
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, canonical)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func canonicalWindowsDirectory(root string) (string, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return "", fmt.Errorf("empty directory")
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(real)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("not a directory")
|
||||
}
|
||||
path16, err := windows.UTF16PtrFromString(real)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
path16,
|
||||
windows.FILE_READ_ATTRIBUTES,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open directory for final-path validation: %w", err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
needed, err := windows.GetFinalPathNameByHandle(handle, nil, 0, 0)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("query final directory path: %w", err)
|
||||
}
|
||||
if needed == 0 {
|
||||
return "", fmt.Errorf("query final directory path returned an empty path")
|
||||
}
|
||||
buffer := make([]uint16, needed+1)
|
||||
written, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), 0)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read final directory path: %w", err)
|
||||
}
|
||||
if written == 0 || written >= uint32(len(buffer)) {
|
||||
return "", fmt.Errorf("read final directory path returned invalid length %d", written)
|
||||
}
|
||||
return filepath.Clean(normalizeWindowsFinalPath(windows.UTF16ToString(buffer[:written]))), nil
|
||||
}
|
||||
|
||||
func normalizeWindowsFinalPath(path string) string {
|
||||
const (
|
||||
extendedPrefix = `\\?\`
|
||||
uncPrefix = `\\?\UNC\`
|
||||
)
|
||||
if strings.HasPrefix(strings.ToUpper(path), strings.ToUpper(uncPrefix)) {
|
||||
return `\\` + path[len(uncPrefix):]
|
||||
}
|
||||
if strings.HasPrefix(path, extendedPrefix) {
|
||||
return path[len(extendedPrefix):]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func validateCapabilityBoundaries(writeRoots []string, tempRoot string, protectedRoots []string) error {
|
||||
for _, root := range writeRoots {
|
||||
if windowsPathsOverlap(root, tempRoot) {
|
||||
return fmt.Errorf("session temp %q overlaps writable root %q", tempRoot, root)
|
||||
}
|
||||
}
|
||||
for _, protected := range protectedRoots {
|
||||
protected = strings.TrimSpace(protected)
|
||||
if protected == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := canonicalWindowsDirectory(protected)
|
||||
if err != nil {
|
||||
return fmt.Errorf("canonicalize protected root %q: %w", protected, err)
|
||||
}
|
||||
if windowsPathsOverlap(tempRoot, abs) {
|
||||
return fmt.Errorf("session temp %q overlaps protected state root %q", tempRoot, abs)
|
||||
}
|
||||
for _, root := range writeRoots {
|
||||
// Capabilities apply to exact directories, so global-workspace may live
|
||||
// below protected state. A workspace containing protected state would
|
||||
// make that state inherit the write ACE and remains forbidden.
|
||||
if windowsPathWithin(abs, root) {
|
||||
if isWindowsGlobalWorkspaceException(abs, root) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("writable root %q is inside protected state root %q", root, abs)
|
||||
}
|
||||
if windowsPathWithin(root, abs) {
|
||||
return fmt.Errorf("writable root %q contains protected state root %q", root, abs)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isWindowsGlobalWorkspaceException(protectedRoot, writableRoot string) bool {
|
||||
return strings.EqualFold(filepath.Base(writableRoot), "global-workspace") &&
|
||||
strings.EqualFold(filepath.Clean(filepath.Dir(writableRoot)), filepath.Clean(protectedRoot))
|
||||
}
|
||||
|
||||
func windowsPathsOverlap(a, b string) bool {
|
||||
a = strings.ToLower(filepath.Clean(a))
|
||||
b = strings.ToLower(filepath.Clean(b))
|
||||
return windowsPathWithin(a, b) || windowsPathWithin(b, a)
|
||||
}
|
||||
|
||||
func windowsPathWithin(root, candidate string) bool {
|
||||
root = strings.ToLower(filepath.Clean(root))
|
||||
candidate = strings.ToLower(filepath.Clean(candidate))
|
||||
rel, err := filepath.Rel(root, candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
func grantWriteCapability(root string, sid *windows.SID) error {
|
||||
present, err := hasExactWriteCapability(root, sid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if present {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A directory capability is represented by one inheritable ACE. Do not add
|
||||
// the second, non-inheritable entry used by the legacy AppContainer helper:
|
||||
// the restricted token needs the same precise descriptor on the root and its
|
||||
// descendants so the exact-ACE check remains stable across launches.
|
||||
var pinner runtime.Pinner
|
||||
pinner.Pin(sid)
|
||||
defer pinner.Unpin()
|
||||
entry := windows.EXPLICIT_ACCESS{
|
||||
AccessPermissions: windows.ACCESS_MASK(capabilityWriteGrantMask),
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_UNKNOWN,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(sid),
|
||||
},
|
||||
}
|
||||
sd, err := windows.GetNamedSecurityInfo(root, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get DACL %q: %w", root, err)
|
||||
}
|
||||
var oldDACL *windows.ACL
|
||||
if sd != nil {
|
||||
oldDACL, _, err = sd.DACL()
|
||||
if err != nil && !errors.Is(err, windows.ERROR_OBJECT_NOT_FOUND) {
|
||||
return fmt.Errorf("read DACL %q: %w", root, err)
|
||||
}
|
||||
}
|
||||
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{entry}, oldDACL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build capability DACL %q: %w", root, err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(root, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, acl, nil); err != nil {
|
||||
return fmt.Errorf("set capability DACL %q: %w", root, err)
|
||||
}
|
||||
runtime.KeepAlive(sd)
|
||||
runtime.KeepAlive(acl)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasExactWriteCapability(root string, sid *windows.SID) (bool, error) {
|
||||
sd, err := windows.GetNamedSecurityInfo(root, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get DACL %q: %w", root, err)
|
||||
}
|
||||
if sd == nil {
|
||||
return false, nil
|
||||
}
|
||||
acl, _, err := sd.DACL()
|
||||
if errors.Is(err, windows.ERROR_OBJECT_NOT_FOUND) || acl == nil {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read DACL %q: %w", root, err)
|
||||
}
|
||||
for index := range uint32(acl.AceCount) {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(acl, index, &ace); err != nil {
|
||||
return false, fmt.Errorf("read DACL ACE %d for %q: %w", index, root, err)
|
||||
}
|
||||
if ace == nil || ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE ||
|
||||
ace.Header.AceFlags != windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT ||
|
||||
ace.Mask != windows.ACCESS_MASK(capabilityWriteGrantMask) {
|
||||
continue
|
||||
}
|
||||
aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
if windows.EqualSid(aceSID, sid) {
|
||||
runtime.KeepAlive(sd)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
runtime.KeepAlive(sd)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func createWriteRestrictedPrimaryToken(readOnly bool, capabilities []restrictedCapability) (windows.Token, error) {
|
||||
if readOnly && len(capabilities) != 0 {
|
||||
return 0, fmt.Errorf("read-only restricted token must not carry write capabilities")
|
||||
}
|
||||
if !readOnly && len(capabilities) == 0 {
|
||||
return 0, fmt.Errorf("workspace-write restricted token requires a directory capability")
|
||||
}
|
||||
|
||||
var current windows.Token
|
||||
access := uint32(windows.TOKEN_QUERY | windows.TOKEN_DUPLICATE | windows.TOKEN_ASSIGN_PRIMARY | windows.TOKEN_ADJUST_DEFAULT)
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(), access, ¤t); err != nil {
|
||||
return 0, fmt.Errorf("open current process token: %w", err)
|
||||
}
|
||||
defer current.Close()
|
||||
logonSID, err := currentProcessLogonSID(current)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create Everyone SID: %w", err)
|
||||
}
|
||||
|
||||
restricting := []windows.SIDAndAttributes{{Sid: logonSID}, {Sid: worldSID}}
|
||||
if !readOnly {
|
||||
for _, capability := range capabilities {
|
||||
restricting = append(restricting, windows.SIDAndAttributes{Sid: capability.sid})
|
||||
}
|
||||
}
|
||||
var token windows.Token
|
||||
r1, _, callErr := procCreateRestrictedToken.Call(
|
||||
uintptr(current),
|
||||
disableMaxPrivilege|luaToken|writeRestricted,
|
||||
0, 0,
|
||||
0, 0,
|
||||
uintptr(len(restricting)), uintptr(unsafe.Pointer(&restricting[0])),
|
||||
uintptr(unsafe.Pointer(&token)),
|
||||
)
|
||||
runtime.KeepAlive(restricting)
|
||||
runtime.KeepAlive(capabilities)
|
||||
if r1 == 0 {
|
||||
if errors.Is(callErr, windows.ERROR_SUCCESS) {
|
||||
return 0, fmt.Errorf("CreateRestrictedToken failed without a Windows error code")
|
||||
}
|
||||
return 0, fmt.Errorf("CreateRestrictedToken: %w", callErr)
|
||||
}
|
||||
if token == 0 {
|
||||
return 0, fmt.Errorf("CreateRestrictedToken returned a null token")
|
||||
}
|
||||
|
||||
defaultSID := worldSID
|
||||
if !readOnly {
|
||||
defaultSID = capabilities[0].sid
|
||||
for _, capability := range capabilities {
|
||||
if capability.purpose == capabilitySessionTemp {
|
||||
defaultSID = capability.sid
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := setRestrictedTokenDefaultDACL(token, defaultSID); err != nil {
|
||||
token.Close()
|
||||
return 0, err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func currentProcessLogonSID(token windows.Token) (*windows.SID, error) {
|
||||
groups, err := token.GetTokenGroups()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read process token groups: %w", err)
|
||||
}
|
||||
for _, group := range groups.AllGroups() {
|
||||
if group.Sid == nil || group.Attributes&windows.SE_GROUP_LOGON_ID != windows.SE_GROUP_LOGON_ID {
|
||||
continue
|
||||
}
|
||||
copy, err := group.Sid.Copy()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy process logon SID: %w", err)
|
||||
}
|
||||
return copy, nil
|
||||
}
|
||||
return nil, fmt.Errorf("CreateRestrictedToken prerequisite failed: process token has no logon SID")
|
||||
}
|
||||
|
||||
type tokenDefaultDACL struct {
|
||||
DefaultDACL *windows.ACL
|
||||
}
|
||||
|
||||
func setRestrictedTokenDefaultDACL(token windows.Token, sid *windows.SID) error {
|
||||
var needed uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, nil, 0, &needed)
|
||||
if err != nil && !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
|
||||
return fmt.Errorf("query restricted token default DACL size: %w", err)
|
||||
}
|
||||
if needed < uint32(unsafe.Sizeof(tokenDefaultDACL{})) {
|
||||
return fmt.Errorf("restricted token default DACL has invalid size %d", needed)
|
||||
}
|
||||
buffer := make([]byte, needed)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, &buffer[0], uint32(len(buffer)), &needed); err != nil {
|
||||
return fmt.Errorf("read restricted token default DACL: %w", err)
|
||||
}
|
||||
current := (*tokenDefaultDACL)(unsafe.Pointer(&buffer[0])).DefaultDACL
|
||||
if current == nil {
|
||||
return fmt.Errorf("restricted token has no default DACL")
|
||||
}
|
||||
|
||||
var pin runtime.Pinner
|
||||
pin.Pin(sid)
|
||||
defer pin.Unpin()
|
||||
entry := windows.EXPLICIT_ACCESS{
|
||||
AccessPermissions: windows.ACCESS_MASK(fileAllAccess),
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_UNKNOWN,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(sid),
|
||||
},
|
||||
}
|
||||
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{entry}, current)
|
||||
if err != nil {
|
||||
return fmt.Errorf("merge restricted token default DACL: %w", err)
|
||||
}
|
||||
info := tokenDefaultDACL{DefaultDACL: acl}
|
||||
if err := windows.SetTokenInformation(token, windows.TokenDefaultDacl, (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
|
||||
return fmt.Errorf("set restricted token default DACL: %w", err)
|
||||
}
|
||||
runtime.KeepAlive(buffer)
|
||||
runtime.KeepAlive(acl)
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingWindowsForbidReadRoots(spec Spec) []string {
|
||||
var roots []string
|
||||
for _, root := range normalizedWindowsRoots(spec.ForbidReadRoots) {
|
||||
if pathExists(root) {
|
||||
roots = append(roots, root)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func applyRestrictedForbidRead(residueRun *windowsResidueRun, roots []string) (func(), error) {
|
||||
var cleanup []func()
|
||||
for _, root := range normalizedWindowsRoots(roots) {
|
||||
if !pathExists(root) {
|
||||
continue
|
||||
}
|
||||
restore, _, err := snapshotPathSecurity(root, false)
|
||||
if err != nil {
|
||||
runCleanup(cleanup)()
|
||||
return func() {}, err
|
||||
}
|
||||
cleanup = append(cleanup, restore)
|
||||
restoreIndex := len(cleanup) - 1
|
||||
denySIDs := forbidReadDenySIDStrings(nil)
|
||||
if len(denySIDs) == 0 {
|
||||
runCleanup(cleanup)()
|
||||
return func() {}, fmt.Errorf("resolve current user SID for forbid_read %q", root)
|
||||
}
|
||||
if err := residueRun.recordBeforeApply(residueDeny, root); err != nil {
|
||||
runCleanup(cleanup)()
|
||||
return func() {}, err
|
||||
}
|
||||
if err := denyAppContainerSIDsWithInheritance(root, denySIDs, "RX", dirExists(root)); err != nil {
|
||||
runCleanup(cleanup)()
|
||||
return func() {}, err
|
||||
}
|
||||
removeAdded := func() { removeDeniedAppContainerSIDs(root, denySIDs) }
|
||||
cleanup[restoreIndex] = cleanupPathSecurity(restore, removeAdded, nil)
|
||||
}
|
||||
return runCleanup(cleanup), nil
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package winsandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrUnsupported is returned when the package is used on a non-Windows host or
|
||||
// when required native Windows sandbox APIs are unavailable.
|
||||
var ErrUnsupported = errors.New("windows sandbox is unavailable")
|
||||
|
||||
// FailurePhase identifies failures owned by the sandbox runner before the
|
||||
// requested process begins executing. Errors after ResumeThread intentionally
|
||||
// remain untyped because the command may already have mutated state.
|
||||
type FailurePhase string
|
||||
|
||||
const (
|
||||
FailureAuthorization FailurePhase = "authorization"
|
||||
FailureDependency FailurePhase = "dependency"
|
||||
FailureLaunch FailurePhase = "launch"
|
||||
)
|
||||
|
||||
type phasedFailure struct {
|
||||
phase FailurePhase
|
||||
err error
|
||||
}
|
||||
|
||||
func (e phasedFailure) Error() string { return e.err.Error() }
|
||||
func (e phasedFailure) Unwrap() error { return e.err }
|
||||
|
||||
func failureAt(phase FailurePhase, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return phasedFailure{phase: phase, err: err}
|
||||
}
|
||||
|
||||
// FailurePhaseOf reports the typed pre-command stage attached by the native
|
||||
// runner. Callers must not infer a stage from an exit code alone.
|
||||
func FailurePhaseOf(err error) (FailurePhase, bool) {
|
||||
var failure phasedFailure
|
||||
if !errors.As(err, &failure) {
|
||||
return "", false
|
||||
}
|
||||
switch failure.phase {
|
||||
case FailureAuthorization, FailureDependency, FailureLaunch:
|
||||
return failure.phase, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
var _ error = phasedFailure{}
|
||||
|
||||
// Spec describes one native Windows sandbox launch.
|
||||
//
|
||||
// Direct read-only launches use AppContainer. Shell and writer launches use a
|
||||
// WRITE_RESTRICTED token whose restricting SIDs carry only the selected
|
||||
// directory capabilities. ForbidReadRoots are denied with temporary deny ACEs.
|
||||
// Network=false is supported for AppContainer launches; restricted-token
|
||||
// launches fail closed because WRITE_RESTRICTED does not isolate network.
|
||||
type Spec struct {
|
||||
ReadableRoots []string
|
||||
WritableRoots []string
|
||||
ForbidReadRoots []string
|
||||
ProtectedWriteRoots []string
|
||||
Network bool
|
||||
Writable bool
|
||||
ReadOnly bool
|
||||
TempDir string
|
||||
TempPrefix string
|
||||
// LockWait bounds how long this run may queue behind another sandboxed
|
||||
// command holding the same per-root lock before failing with a clear
|
||||
// error. Zero uses the short interactive default; callers whose run
|
||||
// nobody is blocked on (background jobs) pass a longer budget.
|
||||
// WINDOWS_SANDBOX_LOCK_MS overrides both.
|
||||
LockWait time.Duration
|
||||
}
|
||||
|
||||
// RunOptions carries process IO and environment overrides.
|
||||
type RunOptions struct {
|
||||
Stdin *os.File
|
||||
Stdout *os.File
|
||||
Stderr *os.File
|
||||
Env []string
|
||||
Dir string
|
||||
}
|
||||
|
||||
// Result is the completed sandboxed process result.
|
||||
type Result struct {
|
||||
ExitCode int
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package winsandbox
|
||||
|
||||
// Available reports whether the native Windows sandbox backend is available.
|
||||
func Available() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Run executes argv in a native Windows sandbox.
|
||||
func Run(_ Spec, _ []string, _ RunOptions) (Result, error) {
|
||||
return Result{}, failureAt(FailureDependency, ErrUnsupported)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -28,7 +28,7 @@ const promote = workflow("release-promote");
|
||||
const appMemory = workflow("app-memory");
|
||||
|
||||
test("Windows PR verifies credential aliases before full push CI", () => {
|
||||
assert.match(ci, /name: test \(Windows credential ACL identity\)[\s\S]*?runner\.os == 'Windows' && github\.event_name == 'pull_request'[\s\S]*?go test -timeout=2m -run '\^TestCredentialAccessRepairsLegacyCredentialDeny\|\^TestRepairLegacyCredentialDenyMatchesFileAcrossPathAliases\$' \.\/internal\/config \.\/internal\/winsandbox/);
|
||||
assert.match(ci, /name: test \(Windows credential ACL identity\)[\s\S]*?runner\.os == 'Windows' && github\.event_name == 'pull_request'[\s\S]*?go test -timeout=2m -run '\^TestCredentialAccessRepairsLegacyCredentialDeny\|\^TestRepairLegacyCredentialDenyMatchesFileAcrossPathAliases\$' \.\/internal\/config \.\/internal\/winaclresidue/);
|
||||
});
|
||||
|
||||
test("release candidate verification cannot mutate repository contents before approval", () => {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
param(
|
||||
[string]$OutputDirectory = ".codex-build/windows-sandbox-native"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$output = Join-Path $repoRoot $OutputDirectory
|
||||
New-Item -ItemType Directory -Force -Path $output | Out-Null
|
||||
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$metadataPath = Join-Path $output "$stamp-metadata.txt"
|
||||
$testPath = Join-Path $output "$stamp-tests.log"
|
||||
$benchmarkPath = Join-Path $output "$stamp-benchmark.log"
|
||||
|
||||
@(
|
||||
"timestamp=$([DateTimeOffset]::Now.ToString('o'))"
|
||||
"computer=$env:COMPUTERNAME"
|
||||
"os=$([Environment]::OSVersion.VersionString)"
|
||||
"architecture=$env:PROCESSOR_ARCHITECTURE"
|
||||
"go=$(go version)"
|
||||
"commit=$(git -C $repoRoot rev-parse HEAD)"
|
||||
) | Set-Content -Encoding utf8 $metadataPath
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
go test -count=1 -timeout=8m ./internal/winsandbox ./internal/sandbox 2>&1 |
|
||||
Tee-Object -FilePath $testPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "native Windows sandbox tests failed" }
|
||||
|
||||
go test -run '^$' -bench '^BenchmarkWindowsRestrictedWorkspaceWrite$' `
|
||||
-benchtime=100x -count=1 ./internal/winsandbox 2>&1 |
|
||||
Tee-Object -FilePath $benchmarkPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "native Windows sandbox benchmark failed" }
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host "Windows sandbox evidence written to $output"
|
||||
@@ -12,7 +12,7 @@ const smokeRoots = internalRoots(
|
||||
// platform exercises, so Windows is the only lane that can prove it.
|
||||
"persistentshell", "proc",
|
||||
"lsp", "pathidentity", "projectiondb", "remote", "repair", "sandbox", "sessioncatalog", "sqliteuri", "sysproxy",
|
||||
"topicstate", "winsandbox", "workspacelease",
|
||||
"topicstate", "winaclresidue", "workspacelease",
|
||||
).concat("reasonix/cmd");
|
||||
|
||||
export function selectPackages(packages, group) {
|
||||
|
||||
@@ -9,7 +9,7 @@ const packages = ["reasonix/cmd/reasonix", "reasonix/internal/agent", "reasonix/
|
||||
"reasonix/internal/serve", "reasonix/internal/session", "reasonix/internal/worktree",
|
||||
"reasonix/internal/lsp", "reasonix/internal/fileops", "reasonix/internal/newpackage", "reasonix/internal/projectiondb",
|
||||
"reasonix/internal/sessioncatalog", "reasonix/internal/sqliteuri", "reasonix/internal/topicstate",
|
||||
"reasonix/internal/winsandbox", "reasonix/tools/repolint"];
|
||||
"reasonix/internal/winaclresidue", "reasonix/tools/repolint"];
|
||||
|
||||
test("the full Windows groups cover every package exactly once, including new packages", () => {
|
||||
const grouped = ["full", ...isolatedGroups].flatMap(group => selectPackages(packages, group));
|
||||
@@ -23,7 +23,7 @@ test("PR smoke keeps platform coverage without duplicating isolated suites", ()
|
||||
"reasonix/cmd/reasonix", "reasonix/internal/extension/sidecar", "reasonix/internal/proc", "reasonix/internal/lsp",
|
||||
"reasonix/internal/fileops",
|
||||
"reasonix/internal/projectiondb", "reasonix/internal/sessioncatalog", "reasonix/internal/sqliteuri",
|
||||
"reasonix/internal/topicstate", "reasonix/internal/winsandbox",
|
||||
"reasonix/internal/topicstate", "reasonix/internal/winaclresidue",
|
||||
]);
|
||||
for (const group of isolatedGroups) {
|
||||
assert.deepEqual(testArgs(packages, group).slice(0, 4), ["test", "-p", "1", "-timeout=8m"]);
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
"limits": {
|
||||
"banner": 13,
|
||||
"commented-code": 0,
|
||||
"complexity": 1878,
|
||||
"essay": 1915,
|
||||
"file-size": 100466,
|
||||
"function-size": 8031,
|
||||
"complexity": 1866,
|
||||
"essay": 1832,
|
||||
"file-size": 98851,
|
||||
"function-size": 8005,
|
||||
"layering": 1,
|
||||
"marker": 0,
|
||||
"narrative": 63,
|
||||
"struct-state": 106,
|
||||
"test-file-size": 64102
|
||||
"test-file-size": 63496
|
||||
},
|
||||
"files": {
|
||||
"cmd/e2ebench/main.go": {
|
||||
@@ -23,10 +23,10 @@
|
||||
"essay": 19
|
||||
},
|
||||
"desktop/app.go": {
|
||||
"complexity": 56,
|
||||
"essay": 60,
|
||||
"file-size": 11111,
|
||||
"function-size": 368,
|
||||
"complexity": 54,
|
||||
"essay": 58,
|
||||
"file-size": 10898,
|
||||
"function-size": 365,
|
||||
"struct-state": 11
|
||||
},
|
||||
"desktop/app_test.go": {
|
||||
@@ -72,13 +72,13 @@
|
||||
"essay": 1
|
||||
},
|
||||
"desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx": {
|
||||
"test-file-size": 1730
|
||||
"test-file-size": 1729
|
||||
},
|
||||
"desktop/frontend/src/__tests__/composer-run-strip.test.tsx": {
|
||||
"test-file-size": 26
|
||||
},
|
||||
"desktop/frontend/src/__tests__/composer-session-draft.test.tsx": {
|
||||
"test-file-size": 532
|
||||
"test-file-size": 531
|
||||
},
|
||||
"desktop/frontend/src/__tests__/math-golden.test.ts": {
|
||||
"test-file-size": 106
|
||||
@@ -90,19 +90,19 @@
|
||||
"test-file-size": 193
|
||||
},
|
||||
"desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx": {
|
||||
"test-file-size": 125
|
||||
"test-file-size": 124
|
||||
},
|
||||
"desktop/frontend/src/app-runtime/useSessionDraftSurface.ts": {
|
||||
"file-size": 281
|
||||
},
|
||||
"desktop/frontend/src/components/ApprovalModal.tsx": {
|
||||
"file-size": 233
|
||||
"file-size": 212
|
||||
},
|
||||
"desktop/frontend/src/components/CapabilitiesPanel.tsx": {
|
||||
"file-size": 2643
|
||||
},
|
||||
"desktop/frontend/src/components/Composer.tsx": {
|
||||
"file-size": 4086
|
||||
"file-size": 4063
|
||||
},
|
||||
"desktop/frontend/src/components/ContextPanel.tsx": {
|
||||
"file-size": 36
|
||||
@@ -120,7 +120,7 @@
|
||||
"file-size": 141
|
||||
},
|
||||
"desktop/frontend/src/components/SettingsPanel.tsx": {
|
||||
"file-size": 6496
|
||||
"file-size": 6483
|
||||
},
|
||||
"desktop/frontend/src/components/StatusBar.tsx": {
|
||||
"file-size": 20
|
||||
@@ -141,19 +141,19 @@
|
||||
"file-size": 177
|
||||
},
|
||||
"desktop/frontend/src/lib/bridge.ts": {
|
||||
"file-size": 5219
|
||||
"file-size": 5191
|
||||
},
|
||||
"desktop/frontend/src/lib/crash.ts": {
|
||||
"file-size": 98
|
||||
},
|
||||
"desktop/frontend/src/lib/transcriptStore.ts": {
|
||||
"file-size": 205
|
||||
"file-size": 196
|
||||
},
|
||||
"desktop/frontend/src/lib/types.ts": {
|
||||
"file-size": 1576
|
||||
"file-size": 1570
|
||||
},
|
||||
"desktop/frontend/src/lib/useController.ts": {
|
||||
"file-size": 4085
|
||||
"file-size": 4070
|
||||
},
|
||||
"desktop/heartbeat.go": {
|
||||
"essay": 7
|
||||
@@ -228,7 +228,7 @@
|
||||
},
|
||||
"desktop/session_draft.go": {
|
||||
"complexity": 12,
|
||||
"file-size": 532,
|
||||
"file-size": 505,
|
||||
"function-size": 5
|
||||
},
|
||||
"desktop/session_key_test.go": {
|
||||
@@ -259,7 +259,7 @@
|
||||
"desktop/settings_app.go": {
|
||||
"complexity": 29,
|
||||
"essay": 6,
|
||||
"file-size": 2666
|
||||
"file-size": 2646
|
||||
},
|
||||
"desktop/settings_app_test.go": {
|
||||
"test-file-size": 1808
|
||||
@@ -274,18 +274,15 @@
|
||||
"essay": 6
|
||||
},
|
||||
"desktop/subagents_app_test.go": {
|
||||
"test-file-size": 151
|
||||
"test-file-size": 149
|
||||
},
|
||||
"desktop/tabs.go": {
|
||||
"complexity": 54,
|
||||
"essay": 58,
|
||||
"file-size": 7199,
|
||||
"essay": 57,
|
||||
"file-size": 7154,
|
||||
"function-size": 382,
|
||||
"struct-state": 25
|
||||
},
|
||||
"desktop/tabs_order_test.go": {
|
||||
"test-file-size": 10
|
||||
},
|
||||
"desktop/tabs_topic_test.go": {
|
||||
"essay": 1,
|
||||
"test-file-size": 2832
|
||||
@@ -349,9 +346,6 @@
|
||||
"desktop/updater_windows.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"desktop/windows_sandbox_helper_test.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"desktop/workspace.go": {
|
||||
"essay": 3
|
||||
},
|
||||
@@ -382,12 +376,12 @@
|
||||
},
|
||||
"internal/acp/server_test.go": {
|
||||
"essay": 1,
|
||||
"test-file-size": 1364
|
||||
"test-file-size": 1325
|
||||
},
|
||||
"internal/acp/service.go": {
|
||||
"complexity": 1,
|
||||
"essay": 37,
|
||||
"file-size": 2297,
|
||||
"file-size": 2219,
|
||||
"function-size": 104,
|
||||
"struct-state": 2
|
||||
},
|
||||
@@ -397,7 +391,7 @@
|
||||
"internal/agent/agent.go": {
|
||||
"complexity": 26,
|
||||
"essay": 22,
|
||||
"file-size": 1332,
|
||||
"file-size": 1298,
|
||||
"function-size": 88
|
||||
},
|
||||
"internal/agent/branch.go": {
|
||||
@@ -423,8 +417,8 @@
|
||||
"essay": 5
|
||||
},
|
||||
"internal/agent/execute_batch.go": {
|
||||
"complexity": 4,
|
||||
"function-size": 44
|
||||
"complexity": 1,
|
||||
"function-size": 31
|
||||
},
|
||||
"internal/agent/execute_one.go": {
|
||||
"essay": 2
|
||||
@@ -479,7 +473,7 @@
|
||||
"internal/agent/save.go": {
|
||||
"complexity": 30,
|
||||
"essay": 81,
|
||||
"file-size": 1426,
|
||||
"file-size": 1355,
|
||||
"function-size": 103
|
||||
},
|
||||
"internal/agent/save_test.go": {
|
||||
@@ -524,12 +518,12 @@
|
||||
"internal/agent/task.go": {
|
||||
"complexity": 3,
|
||||
"essay": 9,
|
||||
"file-size": 1071,
|
||||
"file-size": 1058,
|
||||
"function-size": 18
|
||||
},
|
||||
"internal/agent/task_test.go": {
|
||||
"essay": 1,
|
||||
"test-file-size": 389
|
||||
"test-file-size": 362
|
||||
},
|
||||
"internal/agent/textsink.go": {
|
||||
"complexity": 9,
|
||||
@@ -543,15 +537,15 @@
|
||||
"test-file-size": 828
|
||||
},
|
||||
"internal/boot/boot.go": {
|
||||
"complexity": 258,
|
||||
"essay": 78,
|
||||
"file-size": 2042,
|
||||
"function-size": 1719,
|
||||
"complexity": 255,
|
||||
"essay": 77,
|
||||
"file-size": 2025,
|
||||
"function-size": 1713,
|
||||
"narrative": 3
|
||||
},
|
||||
"internal/boot/boot_test.go": {
|
||||
"essay": 1,
|
||||
"test-file-size": 3720
|
||||
"test-file-size": 3582
|
||||
},
|
||||
"internal/boot/extension_dispatch_test.go": {
|
||||
"essay": 3,
|
||||
@@ -572,7 +566,7 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/boot/golden_baseline_test.go": {
|
||||
"essay": 3
|
||||
"essay": 2
|
||||
},
|
||||
"internal/boot/rebuild_subgraph.go": {
|
||||
"function-size": 7
|
||||
@@ -678,10 +672,10 @@
|
||||
"test-file-size": 3533
|
||||
},
|
||||
"internal/cli/cli.go": {
|
||||
"complexity": 90,
|
||||
"complexity": 86,
|
||||
"essay": 22,
|
||||
"file-size": 1974,
|
||||
"function-size": 559
|
||||
"file-size": 1972,
|
||||
"function-size": 555
|
||||
},
|
||||
"internal/cli/cli_test.go": {
|
||||
"essay": 1,
|
||||
@@ -807,7 +801,7 @@
|
||||
},
|
||||
"internal/config/config.go": {
|
||||
"essay": 21,
|
||||
"file-size": 1516,
|
||||
"file-size": 1514,
|
||||
"narrative": 2
|
||||
},
|
||||
"internal/config/credential_diagnostics.go": {
|
||||
@@ -821,7 +815,7 @@
|
||||
},
|
||||
"internal/config/edit.go": {
|
||||
"essay": 5,
|
||||
"file-size": 1615
|
||||
"file-size": 1597
|
||||
},
|
||||
"internal/config/edit_test.go": {
|
||||
"test-file-size": 2417
|
||||
@@ -831,7 +825,7 @@
|
||||
},
|
||||
"internal/config/load.go": {
|
||||
"essay": 3,
|
||||
"file-size": 1755,
|
||||
"file-size": 1752,
|
||||
"function-size": 45
|
||||
},
|
||||
"internal/config/main_test.go": {
|
||||
@@ -853,7 +847,7 @@
|
||||
"essay": 7
|
||||
},
|
||||
"internal/config/provider_presets.go": {
|
||||
"file-size": 325
|
||||
"file-size": 316
|
||||
},
|
||||
"internal/config/provider_presets_test.go": {
|
||||
"test-file-size": 112
|
||||
@@ -861,7 +855,7 @@
|
||||
"internal/config/render.go": {
|
||||
"complexity": 228,
|
||||
"essay": 2,
|
||||
"file-size": 961,
|
||||
"file-size": 913,
|
||||
"function-size": 912
|
||||
},
|
||||
"internal/config/render_test.go": {
|
||||
@@ -873,15 +867,15 @@
|
||||
},
|
||||
"internal/control/controller.go": {
|
||||
"complexity": 13,
|
||||
"essay": 97,
|
||||
"file-size": 5182,
|
||||
"essay": 96,
|
||||
"file-size": 5000,
|
||||
"function-size": 88,
|
||||
"narrative": 4,
|
||||
"struct-state": 22
|
||||
},
|
||||
"internal/control/controller_test.go": {
|
||||
"essay": 1,
|
||||
"test-file-size": 4607
|
||||
"test-file-size": 4560
|
||||
},
|
||||
"internal/control/extension_ui.go": {
|
||||
"essay": 4,
|
||||
@@ -900,9 +894,6 @@
|
||||
"internal/control/in_flight_turn.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/control/inbox.go": {
|
||||
"file-size": 16
|
||||
},
|
||||
"internal/control/input_test.go": {
|
||||
"test-file-size": 734
|
||||
},
|
||||
@@ -917,7 +908,7 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/control/refs.go": {
|
||||
"file-size": 392
|
||||
"file-size": 387
|
||||
},
|
||||
"internal/control/refs_test.go": {
|
||||
"test-file-size": 90
|
||||
@@ -1094,7 +1085,7 @@
|
||||
"internal/hook/hook.go": {
|
||||
"complexity": 13,
|
||||
"essay": 13,
|
||||
"file-size": 753
|
||||
"file-size": 738
|
||||
},
|
||||
"internal/hook/hook_test.go": {
|
||||
"test-file-size": 1079
|
||||
@@ -1135,7 +1126,7 @@
|
||||
},
|
||||
"internal/jobs/jobs.go": {
|
||||
"essay": 17,
|
||||
"file-size": 1134
|
||||
"file-size": 1133
|
||||
},
|
||||
"internal/jobs/jobs_extra_test.go": {
|
||||
"essay": 1
|
||||
@@ -1181,12 +1172,12 @@
|
||||
},
|
||||
"internal/plugin/plugin.go": {
|
||||
"essay": 14,
|
||||
"file-size": 1059,
|
||||
"file-size": 1057,
|
||||
"function-size": 19
|
||||
},
|
||||
"internal/plugin/plugin_test.go": {
|
||||
"essay": 4,
|
||||
"test-file-size": 905
|
||||
"test-file-size": 904
|
||||
},
|
||||
"internal/plugin/stats.go": {
|
||||
"essay": 4
|
||||
@@ -1239,7 +1230,7 @@
|
||||
},
|
||||
"internal/provider/provider.go": {
|
||||
"essay": 7,
|
||||
"file-size": 325
|
||||
"file-size": 295
|
||||
},
|
||||
"internal/provider/responses/responses.go": {
|
||||
"complexity": 49,
|
||||
@@ -1302,7 +1293,7 @@
|
||||
"file-size": 34
|
||||
},
|
||||
"internal/repair/plan_test.go": {
|
||||
"test-file-size": 869
|
||||
"test-file-size": 864
|
||||
},
|
||||
"internal/repair/snapshot.go": {
|
||||
"complexity": 9,
|
||||
@@ -1332,17 +1323,11 @@
|
||||
"test-file-size": 3092
|
||||
},
|
||||
"internal/sandbox/sandbox.go": {
|
||||
"essay": 10
|
||||
"essay": 4
|
||||
},
|
||||
"internal/sandbox/seatbelt_other.go": {
|
||||
"essay": 4
|
||||
},
|
||||
"internal/sandbox/seatbelt_windows.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/sandbox/seatbelt_windows_test.go": {
|
||||
"essay": 2
|
||||
},
|
||||
"internal/secrets/redact.go": {
|
||||
"complexity": 9
|
||||
},
|
||||
@@ -1357,18 +1342,18 @@
|
||||
},
|
||||
"internal/serve/serve.go": {
|
||||
"essay": 18,
|
||||
"file-size": 933,
|
||||
"file-size": 870,
|
||||
"function-size": 8
|
||||
},
|
||||
"internal/serve/serve_test.go": {
|
||||
"test-file-size": 396
|
||||
},
|
||||
"internal/serve/session_ownership.go": {
|
||||
"essay": 17,
|
||||
"file-size": 309
|
||||
"essay": 15,
|
||||
"file-size": 274
|
||||
},
|
||||
"internal/serve/session_ownership_test.go": {
|
||||
"test-file-size": 169
|
||||
"test-file-size": 166
|
||||
},
|
||||
"internal/session/history_index.go": {
|
||||
"essay": 4
|
||||
@@ -1497,26 +1482,6 @@
|
||||
"file-size": 381,
|
||||
"function-size": 1
|
||||
},
|
||||
"internal/winsandbox/coordination_windows.go": {
|
||||
"essay": 31
|
||||
},
|
||||
"internal/winsandbox/coordination_windows_test.go": {
|
||||
"essay": 13,
|
||||
"test-file-size": 41
|
||||
},
|
||||
"internal/winsandbox/restricted_windows.go": {
|
||||
"essay": 2
|
||||
},
|
||||
"internal/winsandbox/sandbox.go": {
|
||||
"essay": 2
|
||||
},
|
||||
"internal/winsandbox/sandbox_windows.go": {
|
||||
"essay": 17,
|
||||
"file-size": 550
|
||||
},
|
||||
"internal/winsandbox/sandbox_windows_test.go": {
|
||||
"test-file-size": 290
|
||||
},
|
||||
"sdk/go/examples/fullsidecar/main.go": {
|
||||
"essay": 25
|
||||
},
|
||||
@@ -1534,7 +1499,7 @@
|
||||
"function-size": 9
|
||||
},
|
||||
"workers/crash-report/src/index.ts": {
|
||||
"file-size": 874
|
||||
"file-size": 873
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user