fix(ci): repair Windows Native ACL pinning and platform fixtures

Windows Native admission re-opened directories that the grant walk already
held with MAXIMUM_ALLOWED and read-only sharing (the root via open_acl, every
ancestor via open_acl(child), and each file's parent via grant_file), so every
host launch failed with a sharing violation. Grant and retirement now extend
the held Arc<File> pin chain and open only new child leaves, keeping
MAXIMUM_ALLOWED (no SetSecurityInfo propagation), no-follow, no-write/no-delete
sharing, exact descendant validation and the 65,536-entry bound. ACL_EDITS is
taken once by each grant/retire entrypoint around its whole walk.

Windows private-directory validation compares the captured handle's physical
identity and normalized final path with a fresh no-follow selection of the
expected path, so legitimate 8.3 spellings (RUNNER~1) are accepted while
replacement and reparse points are still refused; a Windows test proves both.
Nested extension-host dist files are pinned to LF so materialized modules keep
their admitted SHA-256 on Windows checkouts.

Fixture repairs keep their original assertions: canonical credential homes on
macOS, the post-sync plugin view for queued Native skills, OS-specific App
Server transport, absolute canonical ledger roots, the intentional Windows
Fleet origin rename fence, the Windows unclassified-invocation safety floor for
the bare `&` case, path-component roster checks, a real Windows launch plan,
and a Linux network check that requires a distinct kernel network namespace
plus ECONNREFUSED. The CLI diagnostic no longer interpolates the credential
line (CodeQL 541). Safety, Lint, Ubuntu Test and release parity share one
guarded GitHub-hosted Ubuntu headroom script; commands, filters, budgets and
timeouts are unchanged.

Source12 local verification (3,792 source/mode/absence rows; owning HEAD
cf033337 and semantic index 5983aa54 unchanged throughout):
- npm test and npm run check:web: 1,184 passed / 0 failed / 7 explicit skips
  (wrapper 101, SDK 19, host 382 + 7 skips, website 682); strict TypeScript
  and production web build passed.
- One locked/offline all-features TUI/App Server/CLI lib build, then the 24
  affected Darwin cases: 24 passed / 0 failed / 0 ignored. Nineteen ran under
  a loopback-only Seatbelt profile; the four native-first cases emitted
  compiled containment, memory and controller-positive/OS-denied network
  markers; five Native-host cases ran from the same pinned image without the
  outer wrapper, because nested sandbox-exec refuses the host's own Seatbelt.
  Includes the three MCP credential/proxy refusal tests.

Not proven here: Windows compilation and runtime of the ACL and private-path
repairs, Linux namespace/memory fixtures, actual runner headroom provisioning,
Safety, Lint runtime/persistence budgets and native exports all require this
head's hosted CI. This is source integration, not release, deploy, provider or
install approval. CodeQL 533/540 were dismissed as test-only fixture flows.

Evidence: artifacts/gpt61-0101-takeover-20261002/parent-source12-application,
parent-source12-focused-rust-actual, parent-source12-native-host-five-actual,
parent-source12-npm-web-actual.

Signed-off-by: CodeWhale Bot <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-03 15:43:01 -07:00
1 parent 8d676e3ef9
commit 89ee629e76
16 files changed
+418 -134

No files matched your search

+1 -1
View File
@@ -59,7 +59,7 @@ scripts/fixtures/dsh-web-app/** text eol=lf
# include_bytes!()'d and digest-checked at launch; a CRLF Windows checkout
# failed the former and would embed different bytes.
crates/tui/extension-host/src/protocol.generated.ts text eol=lf
crates/tui/extension-host/dist/* text eol=lf
crates/tui/extension-host/dist/** text eol=lf
# Reviewed extension-host fixtures carry exact source SHA-256 receipts.
# A Windows checkout must not rewrite their admitted source closure to CRLF.
crates/tui/tests/fixtures/extension_host/** text eol=lf
+10 -16
View File
@@ -466,6 +466,9 @@ jobs:
done
sudo apt-get install -y libdbus-1-dev pkg-config bubblewrap apparmor-profiles
sh scripts/prepare-linux-test-sandbox.sh
- name: Ubuntu build headroom
shell: bash
run: bash scripts/prepare-ubuntu-build-headroom.sh
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
@@ -525,6 +528,10 @@ jobs:
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- name: Ubuntu build headroom
if: needs.changes.outputs.heavy == 'true'
shell: bash
run: bash scripts/prepare-ubuntu-build-headroom.sh
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.heavy == 'true'
with:
@@ -886,24 +893,11 @@ jobs:
sudo apt-get install -y libdbus-1-dev pkg-config bubblewrap apparmor-profiles
sh scripts/prepare-linux-test-sandbox.sh
- name: Ubuntu build headroom
# The combined wave/0.10.1-next workspace build was terminated
# mid-compile on every PR run ("runner has received a shutdown
# signal", exit 143, no newer push to cancel it) while single-fix PRs
# finished the same build in ~10 minutes. Drop preinstalled SDKs this
# job never uses and add swap so the full --all-features test build
# has disk and memory headroom; the Run tests step logs both.
# Reuse the same fixed hosted-runner provisioning as Safety and Lint.
# Exit 143 is a shutdown observation, not proof of OOM or timeout.
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
shell: bash
run: |
df -h / /mnt; free -m
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL
if sudo fallocate -l 8G /mnt/cw-swapfile && sudo chmod 600 /mnt/cw-swapfile \
&& sudo mkswap /mnt/cw-swapfile >/dev/null && sudo swapon /mnt/cw-swapfile; then
echo "Added 8 GiB swap at /mnt/cw-swapfile"
else
echo "::warning::Could not add swap; continuing with the runner default"
fi
df -h / /mnt; free -m
run: bash scripts/prepare-ubuntu-build-headroom.sh
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
with:
+3 -16
View File
@@ -81,23 +81,10 @@ jobs:
-A clippy::too_many_arguments \
-A clippy::unnecessary_map_or
- name: Build headroom
# The headroom trace below showed this job's test build using all of
# the runner's memory and swap (15574/15989 MiB and 3052/3071 MiB on
# 2c95b7de7), which is how the runner came to shut it down on earlier
# candidates. Same step as ci.yml's "Ubuntu build headroom": drop
# preinstalled SDKs this job never uses and add swap. release.yml
# calls this workflow after the tag, so the tagged tree needs it too.
# Same fixed ephemeral-runner provisioning as CI Safety, Lint, and Test.
# Resource headroom is preventive; a shutdown alone does not prove OOM.
shell: bash
run: |
df -h / /mnt; free -m
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL
if sudo fallocate -l 8G /mnt/cw-swapfile && sudo chmod 600 /mnt/cw-swapfile \
&& sudo mkswap /mnt/cw-swapfile >/dev/null && sudo swapon /mnt/cw-swapfile; then
echo "Added 8 GiB swap at /mnt/cw-swapfile"
else
echo "::warning::Could not add swap; continuing with the runner default"
fi
df -h / /mnt; free -m
run: bash scripts/prepare-ubuntu-build-headroom.sh
- name: Build canonical executable for acceptance tests
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
- name: Workspace tests
+6 -1
View File
@@ -6126,7 +6126,12 @@ mod tests {
)
.await
.expect("capabilities dispatch");
assert_eq!(caps.result["transport"], json!("unix-socket"));
let expected_transport = if cfg!(windows) {
"named-pipe"
} else {
"unix-socket"
};
assert_eq!(caps.result["transport"], json!(expected_transport));
let methods: Vec<String> = caps.result["methods"]
.as_array()
.expect("methods array")
+2 -3
View File
@@ -11055,7 +11055,7 @@ verbosity = "concise"
);
assert!(
get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
"{get}"
"owned OAuth must be reported as a configured generation"
);
assert!(
!get.starts_with("xai: set"),
@@ -11071,8 +11071,7 @@ verbosity = "concise"
// The assertion messages deliberately do not interpolate `get`: it is
// built from fixed source labels only, but it flows from the runtime
// API-key resolver, so CodeQL's cleartext-logging query treats a
// formatted copy as a credential sink. The two asserts above already
// print the line on failure.
// formatted copy as a credential sink.
assert!(
get.contains("token availability unprobed"),
"the xAI get line must say only token availability is unprobed"
+135 -14
View File
@@ -831,14 +831,8 @@ fn validate_windows_handle_path(
expected: &Path,
expect_directory: bool,
) -> Result<fs::Metadata> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt as _;
use std::os::windows::fs::MetadataExt as _;
use std::os::windows::io::AsRawHandle as _;
use windows_sys::Win32::Storage::FileSystem::{
FILE_ATTRIBUTE_REPARSE_POINT, FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW,
VOLUME_NAME_DOS,
};
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
let metadata = file.metadata().with_context(|| {
format!(
@@ -858,6 +852,92 @@ fn validate_windows_handle_path(
},
"Codewhale-owned xAI OAuth path has the wrong filesystem type"
);
// Compare the current selection to the captured object, not its lexical
// spelling: Windows can select the same object through an 8.3 short name.
// Keep every selected parent open without delete sharing until comparison
// finishes, and refuse reparse points at every component.
let components = open_windows_selected_components(expected, expect_directory)?;
let selected = components
.last()
.context("private selected path unavailable")?;
anyhow::ensure!(
windows_file_identity(file)? == windows_file_identity(selected)?
&& normalize_windows_path_for_comparison(&windows_final_handle_path(file)?)?
== normalize_windows_path_for_comparison(&windows_final_handle_path(selected)?)?,
"Codewhale-owned xAI OAuth path was redirected while opening"
);
Ok(metadata)
}
#[cfg(windows)]
fn open_windows_selected_components(expected: &Path, expect_directory: bool) -> Result<Vec<File>> {
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
use windows_sys::Win32::Storage::FileSystem::{
FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
};
anyhow::ensure!(
expected.is_absolute(),
"private selected path must be absolute"
);
let mut current = PathBuf::new();
let mut handles = Vec::new();
let mut components = expected.components().peekable();
while let Some(component) = components.next() {
match component {
Component::Prefix(prefix) => current.push(prefix.as_os_str()),
Component::RootDir => current.push(Path::new(r"\")),
Component::Normal(name) => {
current.push(name);
let directory = components.peek().is_some() || expect_directory;
let handle = fs::OpenOptions::new()
.access_mode(FILE_READ_ATTRIBUTES)
.share_mode(
FILE_SHARE_READ
| FILE_SHARE_WRITE
| if directory { 0 } else { FILE_SHARE_DELETE },
)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(&current)
.context("opening the current private path selection")?;
let metadata = handle.metadata()?;
anyhow::ensure!(
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0,
"Codewhale-owned xAI OAuth path must not be a reparse point"
);
anyhow::ensure!(
if directory {
metadata.is_dir()
} else {
metadata.is_file()
},
"Codewhale-owned xAI OAuth path has the wrong filesystem type"
);
handles.push(handle);
}
Component::CurDir | Component::ParentDir => {
bail!("private selected path must be lexically normalized")
}
}
}
anyhow::ensure!(
!handles.is_empty(),
"private selected path cannot be a volume root"
);
Ok(handles)
}
#[cfg(windows)]
fn windows_final_handle_path(file: &File) -> Result<PathBuf> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt as _;
use std::os::windows::io::AsRawHandle as _;
use windows_sys::Win32::Storage::FileSystem::{
FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW, VOLUME_NAME_DOS,
};
let flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
let handle = file.as_raw_handle();
// SAFETY: null output asks only for the required UTF-16 length.
@@ -875,13 +955,54 @@ fn validate_windows_handle_path(
return Err(std::io::Error::last_os_error())
.context("resolving Codewhale-owned xAI OAuth handle path");
}
let actual = OsString::from_wide(&buffer[..written as usize]);
anyhow::ensure!(
normalize_windows_path_for_comparison(Path::new(&actual))?
== normalize_windows_path_for_comparison(expected)?,
"Codewhale-owned xAI OAuth path was redirected while opening"
);
Ok(metadata)
Ok(PathBuf::from(OsString::from_wide(
&buffer[..written as usize],
)))
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
use std::os::windows::fs::OpenOptionsExt as _;
use windows_sys::Win32::Storage::FileSystem::{
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
FILE_SHARE_READ, FILE_SHARE_WRITE,
};
#[test]
fn selected_path_alias_keeps_identity_and_replacement_is_refused() {
let root = tempfile::tempdir().expect("temporary root");
let selected = root.path().join("credentials");
fs::create_dir(&selected).expect("selected directory");
// Hosted Windows temporary roots can contain RUNNER~1 while the kernel
// reports the long spelling. Both must select the same retained object.
let canonical = selected
.canonicalize()
.expect("canonical selected directory");
let held = fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(&selected)
.expect("captured selected directory");
validate_windows_handle_path(&held, &selected, true).expect("selected spelling");
validate_windows_handle_path(&held, &canonical, true).expect("canonical spelling");
let retained = root.path().join("retained");
fs::rename(&selected, &retained).expect("move captured object");
fs::create_dir(&selected).expect("replacement at the same selected path");
assert!(
validate_windows_handle_path(&held, &selected, true).is_err(),
"a replacement at the selected path must not inherit the captured identity"
);
validate_windows_handle_path(&held, &retained, true).expect("actual retained object");
fs::create_dir(root.path().join("other")).expect("existing unnormalized path component");
assert!(
validate_windows_handle_path(&held, &root.path().join("other/../retained"), true)
.is_err(),
"lexically unnormalized paths remain inadmissible"
);
}
}
#[cfg(windows)]
+5 -1
View File
@@ -1586,7 +1586,11 @@ mod dialect_seam_tests {
fn codex_client() -> CodewhaleClient {
let _env_lock = crate::test_support::lock_test_env();
let home = tempfile::tempdir().expect("isolated ChatGPT credential home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let root = home
.path()
.canonicalize()
.expect("canonical credential home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
let mut config = Config {
provider: Some("openai-codex".to_string()),
..Config::default()
@@ -471,10 +471,21 @@ fn auto_review_routes_shell_commands_requiring_approval_to_reviewer() {
None,
);
assert!(
matches!(decision, AutoReviewPlanDecision::ConsultReviewer(_)),
"Auto-Review must not auto-approve {command} without reviewer judgment"
);
if cfg!(windows) && command == "cargo test & curl https://example.com" {
// Unclassified Windows input hits the built-in floor before the reviewer.
assert_eq!(
decision,
AutoReviewPlanDecision::Block(
"Built-in safety gate requires approval: Windows command input cannot be classified safely enough to exclude termination of Codewhale npm launchers; use a direct PID- or port-specific command".into()
)
);
assert_eq!(audit["decision"], "hold_for_review");
} else {
assert!(
matches!(decision, AutoReviewPlanDecision::ConsultReviewer(_)),
"Auto-Review must not auto-approve {command} without reviewer judgment"
);
}
assert_ne!(audit["decision"], "allow", "unexpected allow for {command}");
}
}
+47 -7
View File
@@ -1837,6 +1837,29 @@ async fn sandboxed_host_boundary(
denied["ok"], false,
"Native host reached {protocol} controller: {denied}"
);
#[cfg(target_os = "linux")]
{
let controller = std::fs::read_link("/proc/self/ns/net").unwrap();
let controller = controller.to_str().unwrap();
let host = denied["network_namespace"].as_str().unwrap();
let namespace_id = |name: &str| {
name.strip_prefix("net:[")
.and_then(|name| name.strip_suffix(']'))
.and_then(|id| id.parse::<u64>().ok())
.filter(|id| *id != 0)
.expect("an actual bounded Linux kernel network namespace")
};
assert_ne!(
namespace_id(host),
namespace_id(controller),
"Native host must remain in its isolated kernel network namespace"
);
assert_eq!(
denied["code"], "ECONNREFUSED",
"the isolated loopback cannot reach the controller: {denied}"
);
}
#[cfg(not(target_os = "linux"))]
assert!(
matches!(denied["code"].as_str(), Some("EPERM" | "EACCES")),
"connection must be refused by the OS sandbox: {denied}"
@@ -3904,7 +3927,12 @@ async fn memory_hog_is_stopped(
MemoryEnforcement::Rlimit | MemoryEnforcement::JobObject
) =>
{
assert!(message.contains("allocation failed"), "{message}");
assert!(
message.contains("allocation failed")
|| (kind == crate::dependencies::HostRuntimeKind::Bun
&& message.ends_with("RangeError: Out of memory")),
"{message}"
);
}
Err(other) => panic!("unexpected error: {other:?}"),
}
@@ -4835,9 +4863,19 @@ fn a_plugin_named_like_a_tier_zero_owner_fails_validation_and_never_reaches_the_
#[test]
fn launch_plans_carry_their_tier_and_use_a_data_directory_each() {
#[cfg(not(windows))]
use crate::dependencies::{HostRuntime, HostRuntimeKind};
let home = tempfile::tempdir().unwrap();
#[cfg(windows)]
let bundle = super::materialize_bundle(home.path()).unwrap();
#[cfg(not(windows))]
let bundle = home.path().join("codewhale-extension-host.mjs");
// Windows admission verifies the real copied runtime and exact bundle.
#[cfg(windows)]
let runtime = crate::dependencies::resolve_extension_host_runtime(NODE, None, None)
.selected
.expect("tier planning requires the test Node runtime on Windows");
#[cfg(not(windows))]
let runtime = HostRuntime {
compiled: false,
kind: HostRuntimeKind::Node,
@@ -5296,10 +5334,8 @@ async fn raw_agent_presets_use_one_default_and_all_five_caller_views() {
let original = fixture.registry();
assert_eq!(original.selected_native_entries().len(), 1);
assert!(
original.selected_native_entries()[0]
.entry
.path
.ends_with("/a.mjs")
Path::new(&original.selected_native_entries()[0].entry.path).file_name()
== Some(std::ffi::OsStr::new("a.mjs"))
);
let manager = fixture.manager(node);
let _manager = super::TestManagerGuard::install(Arc::clone(&manager));
@@ -5314,7 +5350,9 @@ async fn raw_agent_presets_use_one_default_and_all_five_caller_views() {
assert_eq!(a.prompt_sections().await.unwrap()[0].text, "A:a");
let selected_b = roster
.iter()
.find(|(preset, _)| preset.entry.path.ends_with("/b.mjs"))
.find(|(preset, _)| {
Path::new(&preset.entry.path).file_name() == Some(std::ffi::OsStr::new("b.mjs"))
})
.unwrap()
.0
.clone();
@@ -5463,7 +5501,9 @@ async fn raw_agent_presets_without_default_require_explicit_child_selection() {
);
let selected = roster
.iter()
.find(|(preset, _)| preset.entry.path.ends_with("/b.mjs"))
.find(|(preset, _)| {
Path::new(&preset.entry.path).file_name() == Some(std::ffi::OsStr::new("b.mjs"))
})
.unwrap()
.0
.clone();
+69 -17
View File
@@ -222,6 +222,7 @@ impl Drop for RetiredProfile {
}
fn retire_scope(root: &Path, scope: &GrantScope, sid: PSID) -> io::Result<()> {
let _serial = ACL_EDITS.lock().unwrap_or_else(|error| error.into_inner());
if !root.try_exists()? {
return Ok(());
}
@@ -255,9 +256,9 @@ fn retire_scope(root: &Path, scope: &GrantScope, sid: PSID) -> io::Result<()> {
}
let mut pending = Vec::new();
let mut count = 1;
enqueue_children(root, &mut pending, count)?;
enqueue_pinned_children(root, &root_pin, &mut pending, count)?;
let mut failure = None;
while let Some(path) = pending.pop() {
while let Some((path, parent_pin)) = pending.pop() {
count += 1;
if count > MAX_GRANT_ENTRIES {
return Err(io::Error::other(
@@ -272,14 +273,20 @@ fn retire_scope(root: &Path, scope: &GrantScope, sid: PSID) -> io::Result<()> {
));
}
if metadata.is_dir() {
let directory = WindowsDirectory::open_acl(&path)?;
edit_acl(directory.acl_handle()?, sid, 0, 0, REVOKE_ACCESS)?;
enqueue_children(&path, &mut pending, count)
} else if metadata.is_file() {
let _ancestors = WindowsDirectory::open(
path.parent()
.ok_or_else(|| io::Error::other("grant has no parent"))?,
let directory = parent_pin.open_acl_child(
path.file_name()
.ok_or_else(|| io::Error::other("grant has no filename"))?,
)?;
edit_acl(directory.acl_handle()?, sid, 0, 0, REVOKE_ACCESS)?;
enqueue_pinned_children(&path, &directory, &mut pending, count)
} else if metadata.is_file() {
if parent_pin.child_path(
path.file_name()
.ok_or_else(|| io::Error::other("grant has no filename"))?,
)? != path
{
return Err(io::Error::other("grant left its pinned parent"));
}
// Retiring this exact SID may overlap a new host's data
// writes. The physical handle stays pinned, but allow those
// legitimate writers/renames; never restore an old whole ACL.
@@ -316,6 +323,19 @@ fn enqueue_children(path: &Path, pending: &mut Vec<PathBuf>, visited: usize) ->
}
Ok(())
}
fn enqueue_pinned_children(
path: &Path,
pin: &WindowsDirectory,
pending: &mut Vec<(PathBuf, WindowsDirectory)>,
visited: usize,
) -> io::Result<()> {
let mut children = Vec::new();
// Reuse the exact admission/retirement bound, including already pending
// entries. Each child keeps its actual direct parent pinned until visited.
enqueue_children(path, &mut children, visited + pending.len())?;
pending.extend(children.into_iter().map(|child| (child, pin.clone())));
Ok(())
}
fn acl_file(path: &Path) -> io::Result<File> {
acl_file_with_share(path, 1)
}
@@ -405,6 +425,7 @@ impl NativeSandbox {
}
fn grant_tree(&self, root: &Path, writable: bool) -> io::Result<()> {
let _serial = ACL_EDITS.lock().unwrap_or_else(|error| error.into_inner());
let root_pin = WindowsDirectory::open_acl(root)?;
self.profile.remember(root, root_pin.acl_handle()?, true)?;
let access = FILE_GENERIC_READ
@@ -414,9 +435,16 @@ impl NativeSandbox {
} else {
0
};
let mut pending = vec![root.to_path_buf()];
let mut count = 0;
while let Some(path) = pending.pop() {
set_acl(
root_pin.acl_handle()?,
self.profile.sid,
access,
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
)?;
let mut pending = Vec::new();
let mut count = 1;
enqueue_pinned_children(root, &root_pin, &mut pending, count)?;
while let Some((path, parent_pin)) = pending.pop() {
count += 1;
if count > MAX_GRANT_ENTRIES {
return Err(io::Error::other("sandbox grant tree exceeds 65536 entries"));
@@ -428,16 +456,19 @@ impl NativeSandbox {
));
}
if metadata.is_dir() {
let pin = WindowsDirectory::open_acl(&path)?;
let pin = parent_pin.open_acl_child(
path.file_name()
.ok_or_else(|| io::Error::other("grant has no filename"))?,
)?;
set_acl(
pin.acl_handle()?,
self.profile.sid,
access,
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
)?;
enqueue_children(&path, &mut pending, count)?;
enqueue_pinned_children(&path, &pin, &mut pending, count)?;
} else if metadata.is_file() {
self.grant_file(&path, access, false)?;
self.grant_pinned_file(&parent_pin, &path, access, false)?;
} else {
return Err(io::Error::other(
"sandbox grant refuses a non-regular entry",
@@ -448,10 +479,30 @@ impl NativeSandbox {
}
fn grant_file(&self, path: &Path, access: u32, remember: bool) -> io::Result<()> {
let _pin = WindowsDirectory::open(
let _serial = ACL_EDITS.lock().unwrap_or_else(|error| error.into_inner());
let pin = WindowsDirectory::open(
path.parent()
.ok_or_else(|| io::Error::other("file has no parent"))?,
)?;
self.grant_pinned_file(&pin, path, access, remember)
}
// The owning grant entrypoint holds ACL_EDITS. Tree files reuse the parent
// chain rather than reopen MAXIMUM_ALLOWED directory objects.
fn grant_pinned_file(
&self,
parent_pin: &WindowsDirectory,
path: &Path,
access: u32,
remember: bool,
) -> io::Result<()> {
if parent_pin.child_path(
path.file_name()
.ok_or_else(|| io::Error::other("file has no filename"))?,
)? != path
{
return Err(io::Error::other("grant left its pinned parent"));
}
let file = acl_file(path)?;
if remember {
self.profile.remember(path, &file, false)?;
@@ -822,7 +873,8 @@ fn edit_acl(
inheritance: u32,
mode: windows_sys::Win32::Security::Authorization::ACCESS_MODE,
) -> io::Result<()> {
let _serial = ACL_EDITS.lock().unwrap_or_else(|error| error.into_inner());
// The owning grant/retirement entrypoint holds ACL_EDITS while opening
// and editing its exact objects, including all shared pinned ancestors.
let mut old_acl: *mut ACL = null_mut();
let mut descriptor = null_mut();
let result = unsafe {
+48 -26
View File
@@ -474,9 +474,9 @@ fn exclusive_rename_outcome(renamed: bool) -> io::Result<bool> {
/// admission. Every original absolute ancestor is opened without delete/write
/// sharing and without following reparse points before path-based calls.
#[cfg(windows)]
#[derive(Debug)]
#[derive(Clone, Debug)]
pub(crate) struct WindowsDirectory {
ancestors: Vec<File>,
ancestors: Vec<std::sync::Arc<File>>,
directory: std::path::PathBuf,
}
@@ -487,7 +487,6 @@ impl WindowsDirectory {
}
fn open_inner(path: &Path, create: bool, acl_target: bool) -> io::Result<Self> {
use std::os::windows::fs::OpenOptionsExt;
if !path.is_absolute() {
return Err(invalid_path());
}
@@ -502,18 +501,7 @@ impl WindowsDirectory {
if matches!(component, Component::Prefix(_)) {
continue;
}
let open = || {
let mut options = std::fs::OpenOptions::new();
options.read(true).share_mode(1).custom_flags(0x0220_0000);
if acl_target && index + 1 == count {
// SetSecurityInfo must not recursively follow/modify
// descendants: SDK-documented MAXIMUM_ALLOWED disables
// propagation. Each child is fenced/granted explicitly.
options
.access_mode(windows_sys::Win32::System::SystemServices::MAXIMUM_ALLOWED);
}
options.open(&directory)
};
let open = || Self::open_component(&directory, acl_target && index + 1 == count);
let file = match open() {
Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
match std::fs::create_dir(&directory) {
@@ -525,10 +513,6 @@ impl WindowsDirectory {
}
result => result?,
};
let metadata = file.metadata()?;
if !metadata.is_dir() || crate::plugins::metadata_is_link_or_reparse(&metadata) {
return Err(invalid_path());
}
ancestors.push(file);
}
Ok(Self {
@@ -537,12 +521,54 @@ impl WindowsDirectory {
})
}
fn open_component(path: &Path, acl_target: bool) -> io::Result<std::sync::Arc<File>> {
use std::os::windows::fs::OpenOptionsExt;
let mut options = std::fs::OpenOptions::new();
options.read(true).share_mode(1).custom_flags(0x0220_0000);
if acl_target {
// SetSecurityInfo must not recursively follow/modify descendants:
// SDK-documented MAXIMUM_ALLOWED disables propagation. Each child
// is fenced/granted explicitly, without reopening pinned ancestors.
options.access_mode(windows_sys::Win32::System::SystemServices::MAXIMUM_ALLOWED);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_dir() || crate::plugins::metadata_is_link_or_reparse(&metadata) {
return Err(invalid_path());
}
Ok(std::sync::Arc::new(file))
}
pub(crate) fn child_path(&self, name: &std::ffi::OsStr) -> io::Result<std::path::PathBuf> {
let name = Path::new(name);
if !path_is_confined(name) || name.components().count() != 1 {
return Err(invalid_path());
}
Ok(self.directory.join(name))
}
/// Extend the actual held direct-parent chain. Reopening a MAXIMUM_ALLOWED
/// ancestor would conflict with its deliberate no-write/no-delete sharing.
pub(crate) fn open_acl_child(&self, name: &std::ffi::OsStr) -> io::Result<Self> {
let directory = self.child_path(name)?;
let file = Self::open_component(&directory, true)?;
let mut ancestors = self.ancestors.clone();
ancestors.push(file);
Ok(Self {
ancestors,
directory,
})
}
pub(crate) fn open_acl(path: &Path) -> io::Result<Self> {
Self::open_inner(path, false, true)
}
pub(crate) fn acl_handle(&self) -> io::Result<&File> {
self.ancestors.last().ok_or_else(invalid_path)
self.ancestors
.last()
.map(std::sync::Arc::as_ref)
.ok_or_else(invalid_path)
}
}
@@ -551,7 +577,7 @@ impl WindowsDirectory {
pub(crate) struct WorkspaceFile {
// Retaining every ancestor without delete/write sharing prevents a path
// swap or junction replacement while path-based Windows calls are running.
_ancestors: Vec<File>,
_ancestors: Vec<std::sync::Arc<File>>,
directory: std::path::PathBuf,
filename: std::ffi::OsString,
}
@@ -596,11 +622,7 @@ impl WorkspaceFile {
return Err(invalid_path());
}
Ok(Self {
_ancestors: self
._ancestors
.iter()
.map(File::try_clone)
.collect::<io::Result<_>>()?,
_ancestors: self._ancestors.clone(),
directory: self.directory.clone(),
filename: name.into(),
})
+23 -9
View File
@@ -2855,15 +2855,29 @@ mod tests {
);
assert_eq!(lease.entry.run_id.0, rows[0].spec.run_id);
drop(owner);
std::fs::rename(&selected, root.join("retired-selected")).unwrap();
std::fs::create_dir(&selected).unwrap();
assert!(
coordination
.try_read()
.unwrap()
.fleet_worker_records_for_workspace(&selected)
.is_err()
);
#[cfg(windows)]
{
// The retained directory handles prevent replacement on Windows.
let error = std::fs::rename(&selected, root.join("retired-selected")).unwrap_err();
assert_eq!(error.raw_os_error(), Some(32), "{error}");
let owner = coordination.try_read().unwrap();
let rows = owner.fleet_worker_records_for_workspace(&selected).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].spec.run_id, report.run_id.0);
}
#[cfg(not(windows))]
{
// Unix permits rename of a held directory; a replacement must refuse.
std::fs::rename(&selected, root.join("retired-selected")).unwrap();
std::fs::create_dir(&selected).unwrap();
assert!(
coordination
.try_read()
.unwrap()
.fleet_worker_records_for_workspace(&selected)
.is_err()
);
}
}
#[test]
+21 -17
View File
@@ -1615,10 +1615,10 @@ mod records_tests {
use super::*;
use serde_json::json;
fn scoped_root(path: &str) -> CoordinationClaimScope {
fn scoped_root(path: impl AsRef<Path>) -> CoordinationClaimScope {
CoordinationClaimScope {
canonical_root: PathBuf::from(path),
platform: "unix".into(),
canonical_root: path.as_ref().to_path_buf(),
platform: if cfg!(windows) { "windows" } else { "unix" }.into(),
volume: 1,
index: 1,
}
@@ -1635,14 +1635,16 @@ mod records_tests {
#[test]
fn attributed_claims_project_nested_and_alias_roots_and_keep_contracts_global() {
let original = Path::new("/work/original");
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let original = root.join("original");
let mut ledger = CoordinationLedger::default();
ledger
.register_claim_in_scope(
root_claim("a", "src", None),
false,
Some(scoped_root("/work/shared")),
Some(original),
Some(scoped_root(root.join("shared"))),
Some(&original),
|_| true,
)
.unwrap();
@@ -1651,8 +1653,8 @@ mod records_tests {
.register_claim_in_scope(
root_claim("nested", ".", None),
false,
Some(scoped_root("/work/shared/src")),
Some(original),
Some(scoped_root(root.join("shared/src"))),
Some(&original),
|_| true
)
.is_err()
@@ -1662,8 +1664,8 @@ mod records_tests {
.register_claim_in_scope(
root_claim("alias", "src", None),
false,
Some(scoped_root("/work/shared")),
Some(original),
Some(scoped_root(root.join("shared"))),
Some(&original),
|_| true
)
.is_err()
@@ -1672,8 +1674,8 @@ mod records_tests {
.register_claim_in_scope(
root_claim("disjoint", "src", Some("release")),
false,
Some(scoped_root("/work/other")),
Some(original),
Some(scoped_root(root.join("other"))),
Some(&original),
|_| true,
)
.unwrap();
@@ -1683,7 +1685,7 @@ mod records_tests {
root_claim("global-contract", "unrelated", Some("release")),
false,
None,
Some(original),
Some(&original),
|_| true
)
.is_err()
@@ -1692,7 +1694,9 @@ mod records_tests {
#[test]
fn attributed_schema_is_sticky_and_absent_map_or_orphan_receipt_refuses() {
let original = Path::new("/work/original");
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let original = root.join("original");
let mut legacy = CoordinationLedger::default();
legacy
.register_claim(root_claim("legacy", "src", None), false, |_| false)
@@ -1710,8 +1714,8 @@ mod records_tests {
.register_claim_in_scope(
root_claim("extra", "src", None),
false,
Some(scoped_root("/work/other")),
Some(original),
Some(scoped_root(root.join("other"))),
Some(&original),
|_| false,
)
.unwrap();
@@ -1727,7 +1731,7 @@ mod records_tests {
.unwrap_err()
.contains("requires explicit")
);
decoded.claim_scopes = Some(HashMap::from([(100, scoped_root("/work/other"))]));
decoded.claim_scopes = Some(HashMap::from([(100, scoped_root(root.join("other")))]));
assert!(
decoded
.validate_replay()
+2
View File
@@ -33361,6 +33361,7 @@ async fn persisted_queued_native_skill_is_denied_after_host_withdrawal() {
let _manager = crate::extension_host::TestManagerGuard::install(Arc::clone(&manager));
let engine = manager.attach(Arc::clone(&plugins));
engine.sync().await.unwrap();
let plugins = engine.plugin_view();
let catalog = crate::skills::discover_in_workspace_with_mode_and_plugins(
fixture.workspace(),
crate::skills::SkillDiscoveryMode::CodeWhaleOnly,
@@ -33374,6 +33375,7 @@ async fn persisted_queued_native_skill_is_denied_after_host_withdrawal() {
queued_session_to_ui(serde_json::from_str::<QueuedSessionMessage>(&serialized).unwrap());
let mut app = create_test_app();
app.workspace = fixture.workspace().to_path_buf();
app.plugin_registry = Arc::clone(&plugins);
let mut git = crate::tui::git_mention::GitMentionCache::default();
assert!(
queued_message_content_for_app(&app, &restored, None, &mut git)
@@ -1,6 +1,6 @@
// Ambient Node file access from inside a host plugin. Under the host sandbox
// the read of a Codewhale secret must fail even though the plugin is trusted.
import { readFile, writeFile } from 'node:fs/promises'
import { readFile, writeFile, readlink } from 'node:fs/promises'
import { connect } from 'node:net'
import { createSocket } from 'node:dgram'
@@ -38,9 +38,17 @@ export function apply(ctx) {
description: 'Connect to the test controller loopback listener and report the OS result.',
parameters: { type: 'object', properties: { port: { type: 'integer', minimum: 1, maximum: 65535 } }, required: ['port'] },
async execute(args) {
// Linux's private loopback cannot reach the controller's listener.
// Report the actual kernel namespace, rather than infer isolation from errno.
const networkNamespace = process.platform === 'linux'
? await readlink('/proc/self/ns/net')
: undefined
return new Promise((resolve) => {
const socket = connect({ host: '127.0.0.1', port: args.port })
const finish = (result) => { socket.destroy(); resolve(result) }
const finish = (result) => {
socket.destroy()
resolve(networkNamespace === undefined ? result : { ...result, network_namespace: networkNamespace })
}
socket.once('connect', () => finish({ ok: true }))
socket.once('error', (error) => finish({ ok: false, code: error.code ?? String(error) }))
socket.setTimeout(1000, () => finish({ ok: false, code: 'ETIMEDOUT' }))
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Fixed provisioning for ephemeral GitHub-hosted Ubuntu jobs only.
# Earlier Safety/Lint exit 143 and runner shutdown do not prove OOM or timeout.
# Keep the existing SDK removal, swap size, warning fallback, and diagnostics.
set -euo pipefail
if [ "${GITHUB_ACTIONS:-}" != true ] || [ "${RUNNER_OS:-}" != Linux ] \
|| [ "${RUNNER_ENVIRONMENT:-}" != github-hosted ]; then
echo "Refusing build-headroom cleanup outside GitHub-hosted Linux CI" >&2
exit 1
fi
df -h / /mnt; free -m
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL
if sudo fallocate -l 8G /mnt/cw-swapfile && sudo chmod 600 /mnt/cw-swapfile \
&& sudo mkswap /mnt/cw-swapfile >/dev/null && sudo swapon /mnt/cw-swapfile; then
echo "Added 8 GiB swap at /mnt/cw-swapfile"
else
echo "::warning::Could not add swap; continuing with the runner default"
fi
df -h / /mnt; free -m