mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(windows): land the sealed Windows Native isolation and ACL fixes
Hosted Windows failed 92 tests on3a788990and14b4d2025. 88 came from the LPAC isolation probe: `childProcess.spawn(..., stdio: 'pipe')` asks libuv for named pipes the AppContainer cannot open (`spawn EPERM`). The probe now captures the fixed descendant receipt in an own-data file and passes inherited handles, with the same 4096-byte bound, timeout and exact receipt check. The rebuilt host bundle is included. Also from the sealed Windows candidate: read stored DACLs through the kernel descriptor so a normalized view is never written back, reject reserved DOS basenames (CON, PRN, AUX, NUL, COM1-9, LPT1-9) in atomic writes, release the test Fleet owner before workspace replacement, and add the missing win32-arm64 DSH fixture metadata. Only the Windows files of that candidate are taken. Its Computer Use consent routing and agent-shell receipt changes are left out of this slice: they change approval behavior and need their own review. Where the branch had moved sinceaf5fa5c, `windows.rs` keeps HEAD's validated `CapabilitySid::registry_read` (one-SID check at construction) and compiled-host.test.mjs keeps HEAD's equivalent execArgv assertion. Source: Astra's sealed 24-path candidate (artifacts/windows-vm-release-20261004/landing-final-review, all 24 snapshot hashes verified against SOURCE_EXPECTED.json), merged per file againstaf5fa5c. Validation: npm --prefix crates/tui/extension-host test: 405 tests, 398 passed, 0 failed, 7 skipped. Fresh `node build.mjs` main host bundle is byte-identical to the committed dist (the optional builtin/mcp bundle cannot build here: local node_modules lacks @modelcontextprotocol/client). cargo check -p codewhale-tui --lib --tests --all-features --locked --target x86_64-pc-windows-gnu: Finished, no errors. Native Windows tests are not claimed; hosted Windows CI is the proof. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
3e424a0720
commit
2680ddc663
10 files changed
+815
-245
No files matched your search
+70
-29
@@ -16931,40 +16931,81 @@ async function windowsSandboxProbe(env = process.env) {
|
||||
if (env.CODEWHALE_WINDOWS_PROBE_DESCENDANT !== "1") {
|
||||
const args = JSON.parse(env.CODEWHALE_WINDOWS_PROBE_CHILD_ARGS ?? "null");
|
||||
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) throw new Error("missing Core child probe argv");
|
||||
await new Promise((resolve5, reject) => {
|
||||
const child = childProcess.spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: {
|
||||
...env,
|
||||
CODEWHALE_WINDOWS_PROBE_DESCENDANT: "1",
|
||||
CODEWHALE_WINDOWS_PROBE_INSIDE: `${inside}.child`
|
||||
} });
|
||||
let output = "", bytes = 0;
|
||||
const finish = (error) => {
|
||||
clearTimeout(timer);
|
||||
if (error) {
|
||||
child.kill();
|
||||
reject(error);
|
||||
} else resolve5();
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error("sandbox descendant probe timed out")), 6e3);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 4096) finish(new Error("sandbox descendant output exceeds 4096 bytes"));
|
||||
else output += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 4096) finish(new Error("sandbox descendant output exceeds 4096 bytes"));
|
||||
});
|
||||
child.once("error", (error) => finish(error));
|
||||
child.once("close", (code, signal) => {
|
||||
const outputPath = `${inside}.receipt`;
|
||||
const fd = fs.openSync(outputPath, "wx+");
|
||||
try {
|
||||
await new Promise((resolve5, reject) => {
|
||||
let child, timer, poll, closeTimer, failure, settled = false;
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
clearInterval(poll);
|
||||
clearTimeout(closeTimer);
|
||||
if (error) reject(error);
|
||||
else resolve5();
|
||||
};
|
||||
const fail2 = (error) => {
|
||||
if (settled || failure) return;
|
||||
failure = error;
|
||||
clearTimeout(timer);
|
||||
clearInterval(poll);
|
||||
closeTimer = setTimeout(() => finish(failure), 1e3);
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (code !== 0 || signal || JSON.stringify(JSON.parse(output)) !== JSON.stringify(receipt)) throw new Error("sandbox descendant has no exact denial receipt");
|
||||
finish();
|
||||
child = childProcess.spawn(process.execPath, args, { stdio: [0, fd, fd], windowsHide: true, env: {
|
||||
...env,
|
||||
CODEWHALE_WINDOWS_PROBE_DESCENDANT: "1",
|
||||
CODEWHALE_WINDOWS_PROBE_INSIDE: `${inside}.child`
|
||||
} });
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => fail2(new Error("sandbox descendant probe timed out")), 6e3);
|
||||
poll = setInterval(() => {
|
||||
try {
|
||||
if (fs.fstatSync(fd).size > 4096) fail2(new Error("sandbox descendant output exceeds 4096 bytes"));
|
||||
} catch (error) {
|
||||
fail2(error);
|
||||
}
|
||||
}, 25);
|
||||
child.once("error", fail2);
|
||||
child.once("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
if (failure) {
|
||||
finish(failure);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (code !== 0 || signal) throw new Error("sandbox descendant has no exact denial receipt");
|
||||
if (fs.fstatSync(fd).size > 4096) throw new Error("sandbox descendant output exceeds 4096 bytes");
|
||||
const output = Buffer.alloc(4097);
|
||||
let bytes = 0;
|
||||
while (bytes < output.length) {
|
||||
const read = fs.readSync(fd, output, bytes, output.length - bytes, bytes);
|
||||
if (read === 0) break;
|
||||
bytes += read;
|
||||
}
|
||||
if (bytes > 4096) throw new Error("sandbox descendant output exceeds 4096 bytes");
|
||||
if (JSON.stringify(JSON.parse(output.subarray(0, bytes).toString())) !== JSON.stringify(receipt)) throw new Error("sandbox descendant has no exact denial receipt");
|
||||
finish();
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} finally {
|
||||
fs.unlinkSync(outputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
@@ -41,31 +41,68 @@ export async function windowsSandboxProbe(env = process.env) {
|
||||
if (env.CODEWHALE_WINDOWS_PROBE_DESCENDANT !== '1') {
|
||||
const args = JSON.parse(env.CODEWHALE_WINDOWS_PROBE_CHILD_ARGS ?? 'null')
|
||||
if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) throw new Error('missing Core child probe argv')
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = childProcess.spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, env: {
|
||||
...env, CODEWHALE_WINDOWS_PROBE_DESCENDANT: '1', CODEWHALE_WINDOWS_PROBE_INSIDE: `${inside}.child`,
|
||||
} })
|
||||
let output = '', bytes = 0
|
||||
const finish = (error) => { clearTimeout(timer); if (error) { child.kill(); reject(error) } else resolve() }
|
||||
const timer = setTimeout(() => finish(new Error('sandbox descendant probe timed out')), 6000)
|
||||
child.stdout.on('data', (chunk) => {
|
||||
bytes += chunk.length
|
||||
if (bytes > 4096) finish(new Error('sandbox descendant output exceeds 4096 bytes'))
|
||||
else output += chunk.toString()
|
||||
})
|
||||
// Drain stderr without retaining arbitrary output.
|
||||
child.stderr.on('data', (chunk) => {
|
||||
bytes += chunk.length
|
||||
if (bytes > 4096) finish(new Error('sandbox descendant output exceeds 4096 bytes'))
|
||||
})
|
||||
child.once('error', (error) => finish(error))
|
||||
child.once('close', (code, signal) => {
|
||||
// LPAC cannot open NUL or libuv's global named pipes. Core already gave
|
||||
// us an EOF stdin handle; capture the fixed child receipt in own-data and
|
||||
// inherit all three handles instead of asking the runtime to create any.
|
||||
const outputPath = `${inside}.receipt`
|
||||
const fd = fs.openSync(outputPath, 'wx+')
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
let child, timer, poll, closeTimer, failure, settled = false
|
||||
const finish = (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer); clearInterval(poll); clearTimeout(closeTimer)
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
const fail = (error) => {
|
||||
if (settled || failure) return
|
||||
failure = error
|
||||
clearTimeout(timer); clearInterval(poll)
|
||||
// Wait for inherited handles to close before cleanup. Core's outer
|
||||
// job deadline still kills the whole tree if termination stalls.
|
||||
closeTimer = setTimeout(() => finish(failure), 1000)
|
||||
try { child.kill() } catch {}
|
||||
}
|
||||
try {
|
||||
if (code !== 0 || signal || JSON.stringify(JSON.parse(output)) !== JSON.stringify(receipt)) throw new Error('sandbox descendant has no exact denial receipt')
|
||||
finish()
|
||||
} catch (error) { finish(error) }
|
||||
child = childProcess.spawn(process.execPath, args, { stdio: [0, fd, fd], windowsHide: true, env: {
|
||||
...env, CODEWHALE_WINDOWS_PROBE_DESCENDANT: '1', CODEWHALE_WINDOWS_PROBE_INSIDE: `${inside}.child`,
|
||||
} })
|
||||
} catch (error) { finish(error); return }
|
||||
timer = setTimeout(() => fail(new Error('sandbox descendant probe timed out')), 6000)
|
||||
// This stops excess output promptly; it bounds neither a single write
|
||||
// nor disk growth between polls. The fixed diagnostic has no plugin code.
|
||||
poll = setInterval(() => {
|
||||
try {
|
||||
if (fs.fstatSync(fd).size > 4096) fail(new Error('sandbox descendant output exceeds 4096 bytes'))
|
||||
} catch (error) { fail(error) }
|
||||
}, 25)
|
||||
child.once('error', fail)
|
||||
child.once('close', (code, signal) => {
|
||||
if (settled) return
|
||||
if (failure) { finish(failure); return }
|
||||
try {
|
||||
if (code !== 0 || signal) throw new Error('sandbox descendant has no exact denial receipt')
|
||||
if (fs.fstatSync(fd).size > 4096) throw new Error('sandbox descendant output exceeds 4096 bytes')
|
||||
const output = Buffer.alloc(4097)
|
||||
let bytes = 0
|
||||
while (bytes < output.length) {
|
||||
// Inherited file handles share their offset; always read from
|
||||
// the beginning explicitly, including after a short read.
|
||||
const read = fs.readSync(fd, output, bytes, output.length - bytes, bytes)
|
||||
if (read === 0) break
|
||||
bytes += read
|
||||
}
|
||||
if (bytes > 4096) throw new Error('sandbox descendant output exceeds 4096 bytes')
|
||||
if (JSON.stringify(JSON.parse(output.subarray(0, bytes).toString())) !== JSON.stringify(receipt)) throw new Error('sandbox descendant has no exact denial receipt')
|
||||
finish()
|
||||
} catch (error) { finish(error) }
|
||||
})
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
try { fs.closeSync(fd) } finally { fs.unlinkSync(outputPath) }
|
||||
}
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import cp from 'node:child_process'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { windowsSandboxProbe } from '../src/windows-sandbox-probe.mjs'
|
||||
@@ -18,8 +17,10 @@ const env = {
|
||||
}
|
||||
function controls(t, options = {}) {
|
||||
let value
|
||||
const originals = [fs.writeFileSync, fs.readFileSync, fs.unlinkSync, net.connect, cp.spawn]
|
||||
t.after(() => { [fs.writeFileSync, fs.readFileSync, fs.unlinkSync, net.connect, cp.spawn] = originals })
|
||||
const outputPath = `${env.CODEWHALE_WINDOWS_PROBE_INSIDE}.receipt`
|
||||
const state = { created: false, closed: false, removed: false, killed: 0, events: [], positions: [], output: Buffer.from(options.output ?? JSON.stringify(receipt)) }
|
||||
const originals = [fs.writeFileSync, fs.readFileSync, fs.unlinkSync, fs.openSync, fs.fstatSync, fs.readSync, fs.closeSync, net.connect, cp.spawn]
|
||||
t.after(() => { [fs.writeFileSync, fs.readFileSync, fs.unlinkSync, fs.openSync, fs.fstatSync, fs.readSync, fs.closeSync, net.connect, cp.spawn] = originals })
|
||||
const denied = (code) => Object.assign(new Error(code), { code })
|
||||
fs.writeFileSync = (path, content) => {
|
||||
if (path === env.CODEWHALE_WINDOWS_PROBE_INSIDE) { value = content; return }
|
||||
@@ -30,7 +31,32 @@ function controls(t, options = {}) {
|
||||
if (path === env.CODEWHALE_WINDOWS_PROBE_INSIDE) return value
|
||||
throw denied(options.readError ?? 'EACCES')
|
||||
}
|
||||
fs.unlinkSync = () => { value = undefined }
|
||||
fs.unlinkSync = (path) => {
|
||||
if (path !== outputPath) { value = undefined; return }
|
||||
assert.equal(state.closed, true, 'close the owned descriptor before removing its file')
|
||||
state.removed = true; state.events.push('unlink')
|
||||
}
|
||||
fs.openSync = (path, flags) => {
|
||||
assert.equal(path, outputPath)
|
||||
assert.equal(flags, 'wx+')
|
||||
if (options.openError) throw denied(options.openError)
|
||||
state.created = true
|
||||
return 71
|
||||
}
|
||||
fs.fstatSync = (fd) => {
|
||||
assert.equal(fd, 71); assert.equal(state.closed, false)
|
||||
return { size: options.statSize ?? state.output.length }
|
||||
}
|
||||
fs.readSync = (fd, buffer, offset, length, position) => {
|
||||
assert.equal(fd, 71); assert.equal(state.closed, false)
|
||||
assert.ok(length <= 4097)
|
||||
state.positions.push(position)
|
||||
return state.output.copy(buffer, offset, position, Math.min(position + length, position + (options.shortRead ?? length)))
|
||||
}
|
||||
fs.closeSync = (fd) => {
|
||||
assert.equal(fd, 71); assert.equal(state.created, true); assert.equal(state.closed, false)
|
||||
state.closed = true; state.events.push('file-close')
|
||||
}
|
||||
net.connect = () => {
|
||||
const socket = new EventEmitter()
|
||||
socket.destroy = () => {}
|
||||
@@ -40,20 +66,81 @@ function controls(t, options = {}) {
|
||||
})
|
||||
return socket
|
||||
}
|
||||
cp.spawn = (program, args, options) => {
|
||||
cp.spawn = (program, args, launch) => {
|
||||
assert.equal(program, process.execPath)
|
||||
assert.deepEqual(args, ['fixed-probe'])
|
||||
assert.equal(options.env.CODEWHALE_WINDOWS_PROBE_DESCENDANT, '1')
|
||||
assert.equal(launch.env.CODEWHALE_WINDOWS_PROBE_DESCENDANT, '1')
|
||||
assert.equal(launch.env.CODEWHALE_WINDOWS_PROBE_INSIDE, `${env.CODEWHALE_WINDOWS_PROBE_INSIDE}.child`)
|
||||
assert.deepEqual(launch.stdio, [0, 71, 71], 'no NUL open or runtime-created pipes')
|
||||
if (options.spawnThrow) throw denied('EPERM')
|
||||
const child = new EventEmitter()
|
||||
child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = () => true
|
||||
process.nextTick(() => { child.stdout.write(JSON.stringify(receipt)); child.emit('close', 0, null) })
|
||||
state.child = child
|
||||
const close = (code, signal) => { state.events.push('child-close'); child.emit('close', code, signal) }
|
||||
child.kill = () => {
|
||||
state.killed += 1; state.events.push('kill')
|
||||
if (!options.hangAfterKill) process.nextTick(() => close(null, 'SIGTERM'))
|
||||
return true
|
||||
}
|
||||
if (options.spawnError) process.nextTick(() => child.emit('error', denied('EPERM')))
|
||||
else if (!options.hang) process.nextTick(() => close(options.exitCode ?? 0, options.signal ?? null))
|
||||
return child
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
test('fixed probe requires exact own-data and parent/descendant denial receipt', async (t) => {
|
||||
controls(t)
|
||||
const state = controls(t, { shortRead: 7 })
|
||||
assert.deepEqual(await windowsSandboxProbe(env), receipt)
|
||||
assert.equal(state.positions[0], 0)
|
||||
assert.ok(state.positions.length > 2, 'short reads must be completed with explicit positions')
|
||||
assert.deepEqual(state.events, ['child-close', 'file-close', 'unlink'])
|
||||
})
|
||||
test('preexisting receipt is preserved when exclusive creation fails', async (t) => {
|
||||
const state = controls(t, { openError: 'EEXIST' })
|
||||
await assert.rejects(windowsSandboxProbe(env), /EEXIST/)
|
||||
assert.deepEqual(state.events, [])
|
||||
assert.equal(state.removed, false)
|
||||
})
|
||||
test('synchronous spawn failure cleans up only the newly created receipt', async (t) => {
|
||||
const state = controls(t, { spawnThrow: true })
|
||||
await assert.rejects(windowsSandboxProbe(env), /EPERM/)
|
||||
assert.deepEqual(state.events, ['file-close', 'unlink'])
|
||||
})
|
||||
test('asynchronous spawn failure waits for child closure before cleanup', async (t) => {
|
||||
const state = controls(t, { spawnError: true })
|
||||
await assert.rejects(windowsSandboxProbe(env), /EPERM/)
|
||||
assert.deepEqual(state.events, ['kill', 'child-close', 'file-close', 'unlink'])
|
||||
})
|
||||
for (const [name, options] of [
|
||||
['nonzero exit', { exitCode: 1 }],
|
||||
['signal exit', { signal: 'SIGTERM' }],
|
||||
['missing receipt', { output: '' }],
|
||||
['malformed receipt', { output: '{' }],
|
||||
['incomplete denial receipt', { output: JSON.stringify({ ...receipt, descendant_denied: false }) }],
|
||||
['stderr mixed into receipt', { output: `${JSON.stringify(receipt)}\nunexpected warning` }],
|
||||
['oversized receipt', { output: 'x'.repeat(4097) }],
|
||||
['receipt growth after stat', { output: 'x'.repeat(4097), statSize: 10 }],
|
||||
]) {
|
||||
test(`descendant ${name} fails closed and cleans up`, async (t) => {
|
||||
const state = controls(t, options)
|
||||
await assert.rejects(windowsSandboxProbe(env))
|
||||
assert.deepEqual(state.events, ['child-close', 'file-close', 'unlink'])
|
||||
})
|
||||
}
|
||||
test('in-flight excess output terminates the child before its deadline', async (t) => {
|
||||
const state = controls(t, { hang: true, output: 'x'.repeat(4097) })
|
||||
await assert.rejects(windowsSandboxProbe(env), /exceeds 4096 bytes/)
|
||||
assert.deepEqual(state.events, ['kill', 'child-close', 'file-close', 'unlink'])
|
||||
})
|
||||
test('descendant timeout and missing close stay bounded; late close cannot reread a cleaned descriptor', async (t) => {
|
||||
const state = controls(t, { hang: true, hangAfterKill: true })
|
||||
const timeout = globalThis.setTimeout
|
||||
t.after(() => { globalThis.setTimeout = timeout })
|
||||
globalThis.setTimeout = (callback, after, ...args) => timeout(callback, after === 6000 || after === 1000 ? 1 : after, ...args)
|
||||
await assert.rejects(windowsSandboxProbe(env), /descendant probe timed out/)
|
||||
assert.deepEqual(state.events, ['kill', 'file-close', 'unlink'])
|
||||
state.child.emit('close', 0, null)
|
||||
assert.deepEqual(state.positions, [])
|
||||
})
|
||||
test('outside write success is an admission failure', async (t) => {
|
||||
controls(t, { outsideWrite: true })
|
||||
@@ -85,6 +172,39 @@ test('network timeout fails admission instead of passing a denial', async (t) =>
|
||||
test('invalid Core projection fails before side effects', async () => {
|
||||
await assert.rejects(windowsSandboxProbe({}), /invalid Core sandbox probe projection/)
|
||||
})
|
||||
test('actual runtime inherits the receipt descriptor and leaves no file; transport evidence only', async (t) => {
|
||||
const root = fs.mkdtempSync(join(tmpdir(), 'cw-win-probe-stdio-'))
|
||||
const inside = join(root, 'inside'), outside = join(root, 'outside')
|
||||
const reads = ['codex', 'dsh', 'builtin'].map((name) => join(root, name))
|
||||
const writeFile = fs.writeFileSync, readFile = fs.readFileSync, connect = net.connect
|
||||
t.after(() => {
|
||||
fs.writeFileSync = writeFile; fs.readFileSync = readFile; net.connect = connect
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
const denied = () => Object.assign(new Error('synthetic access denial'), { code: 'EACCES' })
|
||||
fs.writeFileSync = (path, ...args) => {
|
||||
if (path === outside) throw denied()
|
||||
return writeFile(path, ...args)
|
||||
}
|
||||
fs.readFileSync = (path, ...args) => {
|
||||
if (reads.includes(path)) throw denied()
|
||||
return readFile(path, ...args)
|
||||
}
|
||||
net.connect = () => {
|
||||
const socket = new EventEmitter()
|
||||
socket.destroy = () => {}
|
||||
process.nextTick(() => socket.emit('error', denied()))
|
||||
return socket
|
||||
}
|
||||
// Only this transport fixture substitutes the child body. Production still
|
||||
// receives Core's same full probe argv; these synthetic denials are not LPAC proof.
|
||||
const args = ['-e', `process.stdout.write(${JSON.stringify(JSON.stringify(receipt) + '\n')})`]
|
||||
assert.deepEqual(await windowsSandboxProbe({ ...env,
|
||||
CODEWHALE_WINDOWS_PROBE_INSIDE: inside, CODEWHALE_WINDOWS_PROBE_OUTSIDE: outside,
|
||||
CODEWHALE_WINDOWS_PROBE_READS: JSON.stringify(reads), CODEWHALE_WINDOWS_PROBE_CHILD_ARGS: JSON.stringify(args),
|
||||
}), receipt)
|
||||
assert.deepEqual(fs.readdirSync(root), [])
|
||||
})
|
||||
test('actual unrestricted Node is rejected by the fixed write control', async () => {
|
||||
const root = fs.mkdtempSync(join(tmpdir(), 'cw-win-probe-negative-'))
|
||||
const inside = join(root, 'inside'), outside = join(root, 'outside')
|
||||
|
||||
@@ -23,9 +23,7 @@ use windows_sys::Win32::Foundation::{
|
||||
ERROR_PIPE_CONNECTED, GetLastError, INVALID_HANDLE_VALUE, LocalFree, WAIT_OBJECT_0,
|
||||
WAIT_TIMEOUT,
|
||||
};
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
GRANT_ACCESS, GetSecurityInfo, REVOKE_ACCESS, SE_FILE_OBJECT,
|
||||
};
|
||||
use windows_sys::Win32::Security::Authorization::{GRANT_ACCESS, REVOKE_ACCESS};
|
||||
use windows_sys::Win32::Security::Isolation::{
|
||||
CreateAppContainerProfile, DeleteAppContainerProfile,
|
||||
};
|
||||
@@ -63,7 +61,7 @@ const PROBE_DEADLINE: Duration = Duration::from_secs(15);
|
||||
const PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT: u32 = 0x1;
|
||||
const MAX_GRANT_ENTRIES: usize = 65_536;
|
||||
const MAX_GRANT_SCOPES: usize = 1024;
|
||||
/// Empty Bun config in the granted runtime-copy directory (see spawn_inner).
|
||||
/// Empty Bun config in the granted runtime-copy directory (see sandbox_args).
|
||||
const EMPTY_BUN_CONFIG: &str = "empty-bunfig.toml";
|
||||
// Serialize Core's read/merge/write ACL operations across old-profile cleanup
|
||||
// and a new host admission. Never overwrite a concurrently admitted profile.
|
||||
@@ -585,6 +583,8 @@ impl NativeSandbox {
|
||||
format!("{PROBE_SOURCE}\nconsole.log(JSON.stringify(await windowsSandboxProbe()))"),
|
||||
]);
|
||||
}
|
||||
// Serialize the same sandbox-safe argv used to launch the parent.
|
||||
let args = sandbox_args(&args, self._assets.path());
|
||||
let child_args = (
|
||||
"CODEWHALE_WINDOWS_PROBE_CHILD_ARGS".to_string(),
|
||||
serde_json::to_string(&args)?,
|
||||
@@ -640,7 +640,8 @@ impl NativeSandbox {
|
||||
env: &[(OsString, OsString)],
|
||||
memory_cap: u64,
|
||||
) -> io::Result<Spawned> {
|
||||
self.spawn_inner(args, env, memory_cap, true)
|
||||
let args = sandbox_args(args, self._assets.path());
|
||||
self.spawn_inner(&args, env, memory_cap, true)
|
||||
}
|
||||
|
||||
fn spawn_inner(
|
||||
@@ -655,8 +656,9 @@ impl NativeSandbox {
|
||||
let (stderr, child_stderr) = pipe(true, overlapped)?;
|
||||
let mut attrs = Attributes::new(4)?;
|
||||
let registry_read = CapabilitySid::registry_read()?;
|
||||
let capability_sid = registry_read.sid();
|
||||
let mut capability = SID_AND_ATTRIBUTES {
|
||||
Sid: registry_read.sid(),
|
||||
Sid: capability_sid,
|
||||
Attributes: windows_sys::Win32::System::SystemServices::SE_GROUP_ENABLED as u32,
|
||||
};
|
||||
let capabilities = SECURITY_CAPABILITIES {
|
||||
@@ -691,23 +693,7 @@ impl NativeSandbox {
|
||||
startup.StartupInfo.hStdError = child_stderr.as_raw_handle();
|
||||
startup.lpAttributeList = attrs.ptr();
|
||||
let application = wide(self.program.as_os_str())?;
|
||||
// An LPAC cannot open the NUL device, so a Bun host reads its empty
|
||||
// config from the granted runtime-copy directory instead.
|
||||
let empty_config = format!(
|
||||
"--config={}",
|
||||
self._assets.path().join(EMPTY_BUN_CONFIG).display()
|
||||
);
|
||||
let args: Vec<String> = args
|
||||
.iter()
|
||||
.map(|arg| {
|
||||
if arg == "--config=NUL" {
|
||||
empty_config.clone()
|
||||
} else {
|
||||
arg.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut command = command_line(self.program.as_os_str(), &args)?;
|
||||
let mut command = command_line(self.program.as_os_str(), args)?;
|
||||
let directory = wide(self.data.as_os_str())?;
|
||||
let mut env = env.to_vec();
|
||||
let temp = self.data.join("tmp").into_os_string();
|
||||
@@ -747,7 +733,7 @@ impl NativeSandbox {
|
||||
&process,
|
||||
)?);
|
||||
tree.limit_process_memory(memory_cap)?;
|
||||
verify_token(&process, self.profile.sid, registry_read.sid())?;
|
||||
verify_token(&process, self.profile.sid, capability_sid)?;
|
||||
// No Native byte executes until Job, memory and actual LPAC token
|
||||
// identity/capabilities have all been checked by Rust.
|
||||
if unsafe { ResumeThread(thread.as_raw_handle()) } != 1 {
|
||||
@@ -1162,14 +1148,11 @@ impl Drop for CapabilitySid {
|
||||
fn set_acl(file: &File, sid: PSID, access: u32, inheritance: u32) -> io::Result<()> {
|
||||
edit_acl(file, sid, access, inheritance, GRANT_ACCESS)
|
||||
}
|
||||
/// Edit exactly one SID's explicit allow entry and write the resulting DACL
|
||||
/// through the kernel on this pinned handle. `SetSecurityInfo` re-derives
|
||||
/// inheritance: it turned a file's inherited ACEs into explicit copies after a
|
||||
/// grant/retire cycle, and it would propagate to children. Writing the exact
|
||||
/// DACL with `SetKernelObjectSecurity` keeps every other ACE byte-for-byte,
|
||||
/// including inherited ones, and never touches a child or reparse target.
|
||||
/// Microsoft advises against this API for files precisely because it does not
|
||||
/// propagate; here that is the required property.
|
||||
/// Edit exactly one SID's allow entries on this pinned handle. Read the stored
|
||||
/// descriptor with GetKernelObjectSecurity: GetSecurityInfo can normalize a
|
||||
/// child's inherited ACEs/control while its parent has a grant. Writing that
|
||||
/// view back freezes the normalized state. The exact kernel reader/writer pair
|
||||
/// preserves every other ACE and never propagates changes to child objects.
|
||||
fn edit_acl(
|
||||
file: &File,
|
||||
sid: PSID,
|
||||
@@ -1179,9 +1162,9 @@ fn edit_acl(
|
||||
) -> io::Result<()> {
|
||||
use windows_sys::Win32::Security::{
|
||||
ACCESS_ALLOWED_ACE, ACE_HEADER, AddAccessAllowedAceEx, AddAce, GetAce, GetLengthSid,
|
||||
GetSecurityDescriptorControl, INHERITED_ACE, InitializeAcl, InitializeSecurityDescriptor,
|
||||
SE_DACL_AUTO_INHERIT_REQ, SE_DACL_AUTO_INHERITED, SE_DACL_PROTECTED, SECURITY_DESCRIPTOR,
|
||||
SetKernelObjectSecurity, SetSecurityDescriptorControl, SetSecurityDescriptorDacl,
|
||||
INHERITED_ACE, InitializeAcl, InitializeSecurityDescriptor, SE_DACL_AUTO_INHERIT_REQ,
|
||||
SE_DACL_AUTO_INHERITED, SE_DACL_PROTECTED, SECURITY_DESCRIPTOR, SetKernelObjectSecurity,
|
||||
SetSecurityDescriptorControl, SetSecurityDescriptorDacl,
|
||||
};
|
||||
use windows_sys::Win32::System::SystemServices::{
|
||||
ACCESS_ALLOWED_ACE_TYPE, SECURITY_DESCRIPTOR_REVISION,
|
||||
@@ -1192,43 +1175,13 @@ fn edit_acl(
|
||||
if !grant && mode != REVOKE_ACCESS {
|
||||
return Err(io::Error::other("unsupported ACL edit mode"));
|
||||
}
|
||||
let mut old_acl: *mut ACL = null_mut();
|
||||
let mut descriptor = null_mut();
|
||||
let result = unsafe {
|
||||
GetSecurityInfo(
|
||||
file.as_raw_handle(),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
null_mut(),
|
||||
null_mut(),
|
||||
&mut old_acl,
|
||||
null_mut(),
|
||||
&mut descriptor,
|
||||
)
|
||||
};
|
||||
if result != 0 {
|
||||
return Err(io::Error::from_raw_os_error(result as i32));
|
||||
}
|
||||
let descriptor = LocalAllocation(descriptor);
|
||||
if old_acl.is_null() {
|
||||
return Err(io::Error::other("refusing to replace an unrestricted DACL"));
|
||||
}
|
||||
let mut control = 0_u16;
|
||||
let mut revision = 0_u32;
|
||||
if unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) } == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let preserved = control & (SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED);
|
||||
// An unprotected file has no children, so SetSecurityInfo cannot
|
||||
// propagate from it; with UNPROTECTED it re-derives the inherited entries
|
||||
// from the parent. Writing a file's inherited entries back (either API)
|
||||
// turned them into explicit ones on hosted Windows. Directories and
|
||||
// protected DACLs keep the exact kernel write, which never propagates.
|
||||
let derive_inherited = preserved & SE_DACL_PROTECTED == 0 && !file.metadata()?.is_dir();
|
||||
let descriptor = RawDacl::read(file)?;
|
||||
let old_acl = descriptor.acl();
|
||||
let preserved = descriptor.control & (SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED);
|
||||
let old = std::ptr::NonNull::new(old_acl)
|
||||
.ok_or_else(|| io::Error::other("refusing to replace an unrestricted DACL"))?;
|
||||
let (acl_revision, acl_size, ace_count) = {
|
||||
// SAFETY: GetSecurityInfo returned this DACL inside `descriptor`.
|
||||
// SAFETY: RawDacl validated this DACL inside its owned descriptor.
|
||||
let acl = unsafe { old.as_ref() };
|
||||
(
|
||||
u32::from(acl.AclRevision),
|
||||
@@ -1286,10 +1239,6 @@ fn edit_acl(
|
||||
merged |= unsafe { ace.cast::<ACCESS_ALLOWED_ACE>().as_ref().Mask };
|
||||
continue;
|
||||
}
|
||||
if derive_inherited && inherited {
|
||||
// Windows re-derives these from the parent on write.
|
||||
continue;
|
||||
}
|
||||
if ours && !grant {
|
||||
// Retirement removes explicit entries and the stale inherited
|
||||
// copies left after the parent's grant was revoked first.
|
||||
@@ -1322,66 +1271,42 @@ fn edit_acl(
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if derive_inherited {
|
||||
use windows_sys::Win32::Security::Authorization::SetSecurityInfo;
|
||||
use windows_sys::Win32::Security::UNPROTECTED_DACL_SECURITY_INFORMATION;
|
||||
// SAFETY: the pinned handle and the explicit-only ACL outlive the call.
|
||||
let result = unsafe {
|
||||
SetSecurityInfo(
|
||||
file.as_raw_handle(),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION,
|
||||
null_mut(),
|
||||
null_mut(),
|
||||
new_acl,
|
||||
null(),
|
||||
)
|
||||
let mut security: SECURITY_DESCRIPTOR = unsafe { zeroed() };
|
||||
let security_ptr = (&mut security as *mut SECURITY_DESCRIPTOR).cast();
|
||||
// NTFS stores this descriptor as given, with no inheritance merge. The
|
||||
// kernel keeps SE_DACL_AUTO_INHERITED only when SE_DACL_AUTO_INHERIT_REQ
|
||||
// accompanies it, so request it exactly when the object already had it;
|
||||
// otherwise the object would silently become a legacy DACL.
|
||||
let requested = preserved
|
||||
| if preserved & SE_DACL_AUTO_INHERITED != 0 {
|
||||
SE_DACL_AUTO_INHERIT_REQ
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if result != 0 {
|
||||
return Err(io::Error::from_raw_os_error(result as i32));
|
||||
}
|
||||
} else {
|
||||
let mut security: SECURITY_DESCRIPTOR = unsafe { zeroed() };
|
||||
let security_ptr = (&mut security as *mut SECURITY_DESCRIPTOR).cast();
|
||||
// NTFS stores this descriptor as given, with no inheritance merge. The
|
||||
// kernel keeps SE_DACL_AUTO_INHERITED only when SE_DACL_AUTO_INHERIT_REQ
|
||||
// accompanies it, so request it exactly when the object already had it;
|
||||
// otherwise the object would silently become a legacy DACL.
|
||||
let requested = preserved
|
||||
| if preserved & SE_DACL_AUTO_INHERITED != 0 {
|
||||
SE_DACL_AUTO_INHERIT_REQ
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if unsafe { InitializeSecurityDescriptor(security_ptr, SECURITY_DESCRIPTOR_REVISION) } == 0
|
||||
|| unsafe { SetSecurityDescriptorDacl(security_ptr, 1, new_acl, 0) } == 0
|
||||
|| unsafe {
|
||||
SetSecurityDescriptorControl(
|
||||
security_ptr,
|
||||
SE_DACL_AUTO_INHERIT_REQ | SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED,
|
||||
requested,
|
||||
)
|
||||
} == 0
|
||||
|| unsafe {
|
||||
SetKernelObjectSecurity(
|
||||
file.as_raw_handle(),
|
||||
DACL_SECURITY_INFORMATION,
|
||||
security_ptr,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if unsafe { InitializeSecurityDescriptor(security_ptr, SECURITY_DESCRIPTOR_REVISION) } == 0
|
||||
|| unsafe { SetSecurityDescriptorDacl(security_ptr, 1, new_acl, 0) } == 0
|
||||
|| unsafe {
|
||||
SetSecurityDescriptorControl(
|
||||
security_ptr,
|
||||
SE_DACL_AUTO_INHERIT_REQ | SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED,
|
||||
requested,
|
||||
)
|
||||
} == 0
|
||||
|| unsafe {
|
||||
SetKernelObjectSecurity(
|
||||
file.as_raw_handle(),
|
||||
DACL_SECURITY_INFORMATION,
|
||||
security_ptr,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
drop(descriptor);
|
||||
let (still_granted, control) = read_back_dacl(file, sid)?;
|
||||
// A re-derived file DACL is auto-inherited by construction; it must stay
|
||||
// unprotected. A kernel-written DACL keeps both bits exactly.
|
||||
let checked = if derive_inherited {
|
||||
SE_DACL_PROTECTED
|
||||
} else {
|
||||
SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED
|
||||
};
|
||||
// The raw descriptor and exact kernel write preserve both bits on files
|
||||
// and directories, without re-deriving or propagating inherited entries.
|
||||
let checked = SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED;
|
||||
if control & checked != preserved & checked {
|
||||
return Err(io::Error::other(
|
||||
"edited DACL changed its inheritance control bits",
|
||||
@@ -1398,37 +1323,14 @@ fn edit_acl(
|
||||
/// Read back the object's DACL: whether any allow entry names `sid`, and the
|
||||
/// descriptor's control bits.
|
||||
fn read_back_dacl(file: &File, sid: PSID) -> io::Result<(bool, u16)> {
|
||||
use windows_sys::Win32::Security::{
|
||||
ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce, GetSecurityDescriptorControl,
|
||||
};
|
||||
use windows_sys::Win32::Security::{ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce};
|
||||
use windows_sys::Win32::System::SystemServices::ACCESS_ALLOWED_ACE_TYPE;
|
||||
let mut acl: *mut ACL = null_mut();
|
||||
let mut descriptor = null_mut();
|
||||
let result = unsafe {
|
||||
GetSecurityInfo(
|
||||
file.as_raw_handle(),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
null_mut(),
|
||||
null_mut(),
|
||||
&mut acl,
|
||||
null_mut(),
|
||||
&mut descriptor,
|
||||
)
|
||||
};
|
||||
if result != 0 {
|
||||
return Err(io::Error::from_raw_os_error(result as i32));
|
||||
}
|
||||
let descriptor = LocalAllocation(descriptor);
|
||||
let mut control = 0_u16;
|
||||
let mut revision = 0_u32;
|
||||
if unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) } == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let Some(dacl) = std::ptr::NonNull::new(acl) else {
|
||||
return Ok((false, control));
|
||||
};
|
||||
// SAFETY: GetSecurityInfo returned this DACL inside `descriptor`.
|
||||
let descriptor = RawDacl::read(file)?;
|
||||
let acl = descriptor.acl();
|
||||
let control = descriptor.control;
|
||||
let dacl = std::ptr::NonNull::new(acl)
|
||||
.ok_or_else(|| io::Error::other("refusing to inspect an unrestricted DACL"))?;
|
||||
// SAFETY: RawDacl validated this DACL inside its owned descriptor.
|
||||
for index in 0..u32::from(unsafe { dacl.as_ref().AceCount }) {
|
||||
let mut ace = null_mut();
|
||||
if unsafe { GetAce(acl, index, &mut ace) } == 0 {
|
||||
@@ -1454,7 +1356,205 @@ fn read_back_dacl(file: &File, sid: PSID) -> io::Result<(bool, u16)> {
|
||||
Ok((false, control))
|
||||
}
|
||||
|
||||
/// Owned DWORD-aligned storage for a bounded self-relative kernel descriptor.
|
||||
/// The ACL offset is validated once and never outlives this allocation.
|
||||
struct RawDacl {
|
||||
storage: Vec<u32>,
|
||||
acl_offset: usize,
|
||||
control: u16,
|
||||
}
|
||||
impl RawDacl {
|
||||
fn read(file: &File) -> io::Result<Self> {
|
||||
use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
|
||||
use windows_sys::Win32::Security::{GetKernelObjectSecurity, SECURITY_DESCRIPTOR_RELATIVE};
|
||||
let mut required = 0_u32;
|
||||
// SAFETY: a zero-length size query writes only `required`.
|
||||
if unsafe {
|
||||
GetKernelObjectSecurity(
|
||||
file.as_raw_handle(),
|
||||
DACL_SECURITY_INFORMATION,
|
||||
null_mut(),
|
||||
0,
|
||||
&mut required,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
let error = io::Error::last_os_error();
|
||||
if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
// An external ACL writer can change the required size between calls.
|
||||
// Bound both the allocation and the number of attempts; fail closed.
|
||||
for _ in 0..3 {
|
||||
if !(size_of::<SECURITY_DESCRIPTOR_RELATIVE>()..=1024 * 1024)
|
||||
.contains(&(required as usize))
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"kernel security descriptor exceeds size bounds",
|
||||
));
|
||||
}
|
||||
let capacity = required;
|
||||
let mut storage = vec![0_u32; (capacity as usize).div_ceil(size_of::<u32>())];
|
||||
// SAFETY: storage is aligned, owned, and at least capacity bytes.
|
||||
if unsafe {
|
||||
GetKernelObjectSecurity(
|
||||
file.as_raw_handle(),
|
||||
DACL_SECURITY_INFORMATION,
|
||||
storage.as_mut_ptr().cast(),
|
||||
capacity,
|
||||
&mut required,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
let error = io::Error::last_os_error();
|
||||
if error.raw_os_error() == Some(ERROR_INSUFFICIENT_BUFFER as i32)
|
||||
&& required > capacity
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if required > capacity {
|
||||
return Err(io::Error::other(
|
||||
"kernel security descriptor exceeds its buffer",
|
||||
));
|
||||
}
|
||||
return Self::from_storage(storage, required as usize);
|
||||
}
|
||||
Err(io::Error::other(
|
||||
"kernel security descriptor repeatedly changed size",
|
||||
))
|
||||
}
|
||||
|
||||
fn from_storage(mut storage: Vec<u32>, length: usize) -> io::Result<Self> {
|
||||
use windows_sys::Win32::Security::{
|
||||
ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce, GetSecurityDescriptorControl,
|
||||
GetSecurityDescriptorDacl, IsValidAcl, IsValidSid, SE_SELF_RELATIVE,
|
||||
SECURITY_DESCRIPTOR_RELATIVE, SID,
|
||||
};
|
||||
use windows_sys::Win32::System::SystemServices::ACCESS_ALLOWED_ACE_TYPE;
|
||||
if length < size_of::<SECURITY_DESCRIPTOR_RELATIVE>()
|
||||
|| length > storage.len() * size_of::<u32>()
|
||||
{
|
||||
return Err(io::Error::other("kernel security descriptor is truncated"));
|
||||
}
|
||||
let descriptor = storage.as_mut_ptr().cast();
|
||||
let mut control = 0_u16;
|
||||
let mut revision = 0_u32;
|
||||
// SAFETY: the aligned allocation contains the complete descriptor header.
|
||||
if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if control & SE_SELF_RELATIVE == 0 {
|
||||
return Err(io::Error::other(
|
||||
"kernel security descriptor is not self-relative",
|
||||
));
|
||||
}
|
||||
let mut present = 0;
|
||||
let mut defaulted = 0;
|
||||
let mut acl: *mut ACL = null_mut();
|
||||
// SAFETY: only the validated self-relative header is read here. Bound
|
||||
// the returned DACL pointer before dereferencing any of its bytes.
|
||||
if unsafe { GetSecurityDescriptorDacl(descriptor, &mut present, &mut acl, &mut defaulted) }
|
||||
== 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if present == 0 || acl.is_null() {
|
||||
return Err(io::Error::other("refusing an absent or unrestricted DACL"));
|
||||
}
|
||||
let acl_offset = (acl as usize)
|
||||
.checked_sub(descriptor as usize)
|
||||
.filter(|offset| {
|
||||
*offset >= size_of::<SECURITY_DESCRIPTOR_RELATIVE>()
|
||||
&& *offset % size_of::<u32>() == 0
|
||||
&& offset
|
||||
.checked_add(size_of::<ACL>())
|
||||
.is_some_and(|end| end <= length)
|
||||
})
|
||||
.ok_or_else(|| io::Error::other("DACL header is outside its descriptor"))?;
|
||||
// SAFETY: the aligned DACL header lies completely inside storage.
|
||||
let acl_size = usize::from(unsafe { (*acl).AclSize });
|
||||
if acl_size < size_of::<ACL>()
|
||||
|| acl_offset
|
||||
.checked_add(acl_size)
|
||||
.is_none_or(|end| end > length)
|
||||
{
|
||||
return Err(io::Error::other("DACL is outside its descriptor"));
|
||||
}
|
||||
// SAFETY: the entire claimed ACL extent is bounded by owned storage.
|
||||
// IsValidAcl checks revision and whether its ACEs fit that extent.
|
||||
if unsafe { IsValidAcl(acl) } == 0 {
|
||||
return Err(io::Error::other(
|
||||
"kernel descriptor contains an invalid ACL",
|
||||
));
|
||||
}
|
||||
for index in 0..u32::from(unsafe { (*acl).AceCount }) {
|
||||
let mut entry = null_mut();
|
||||
if unsafe { GetAce(acl, index, &mut entry) } == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let entry_offset = (entry as usize)
|
||||
.checked_sub(acl as usize)
|
||||
.filter(|offset| {
|
||||
*offset >= size_of::<ACL>()
|
||||
&& *offset % size_of::<u32>() == 0
|
||||
&& offset
|
||||
.checked_add(size_of::<ACE_HEADER>())
|
||||
.is_some_and(|end| end <= acl_size)
|
||||
})
|
||||
.ok_or_else(|| io::Error::other("ACE header is outside its DACL"))?;
|
||||
// SAFETY: the complete, aligned ACE header is inside the DACL.
|
||||
let header = unsafe { &*entry.cast::<ACE_HEADER>() };
|
||||
let entry_size = usize::from(header.AceSize);
|
||||
if entry_size < size_of::<ACE_HEADER>() || entry_offset + entry_size > acl_size {
|
||||
return Err(io::Error::other("ACE is outside its DACL"));
|
||||
}
|
||||
if u32::from(header.AceType) == ACCESS_ALLOWED_ACE_TYPE {
|
||||
// IsValidAcl does not validate SIDs. Bound the whole SID before
|
||||
// IsValidSid or either caller's EqualSid can inspect it.
|
||||
let sid_offset = std::mem::offset_of!(ACCESS_ALLOWED_ACE, SidStart);
|
||||
let sid_header = std::mem::offset_of!(SID, SubAuthority);
|
||||
if entry_size < sid_offset + sid_header {
|
||||
return Err(io::Error::other("allow ACE has a truncated SID header"));
|
||||
}
|
||||
// SAFETY: the complete SID header is inside this ACE.
|
||||
let sid = unsafe { entry.cast::<u8>().add(sid_offset) };
|
||||
let count = usize::from(unsafe { *sid.add(1) });
|
||||
if sid_header + count * size_of::<u32>() > entry_size - sid_offset
|
||||
|| unsafe { IsValidSid(sid.cast()) } == 0
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"allow ACE has an invalid or truncated SID",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
storage,
|
||||
acl_offset,
|
||||
control,
|
||||
})
|
||||
}
|
||||
|
||||
fn acl(&self) -> *mut ACL {
|
||||
// SAFETY: from_storage validated the offset, alignment, and ACL extent;
|
||||
// storage stays owned and unchanged while the returned pointer is used.
|
||||
unsafe {
|
||||
self.storage
|
||||
.as_ptr()
|
||||
.cast::<u8>()
|
||||
.add(self.acl_offset)
|
||||
.cast_mut()
|
||||
.cast()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct LocalAllocation(*mut core::ffi::c_void);
|
||||
#[cfg(test)]
|
||||
impl Drop for LocalAllocation {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
@@ -1604,6 +1704,21 @@ fn wide(value: &OsStr) -> io::Result<Vec<u16>> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn sandbox_args(args: &[String], runtime_dir: &Path) -> Vec<String> {
|
||||
// An LPAC cannot open the NUL device. Both the host and its probe
|
||||
// descendant read the empty config in the already-granted runtime copy.
|
||||
let empty_config = format!("--config={}", runtime_dir.join(EMPTY_BUN_CONFIG).display());
|
||||
args.iter()
|
||||
.map(|arg| {
|
||||
if arg == "--config=NUL" {
|
||||
empty_config.clone()
|
||||
} else {
|
||||
arg.clone()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn command_line(program: &OsStr, args: &[String]) -> io::Result<Vec<u16>> {
|
||||
// The documented CommandLineToArgvW/MS CRT quote+backslash rules. An
|
||||
// explicit application path means PATH/first-token parsing is never authority.
|
||||
|
||||
@@ -209,6 +209,36 @@ fn windows_native_profiles_are_distinct_and_acl_grant_refuses_junctions() {
|
||||
assert_no_profile_grant(&outside_file, sandbox.profile.sid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_sandbox_args_use_granted_bun_config_without_changing_other_values() {
|
||||
let runtime_dir = Path::new("runtime copy 鲸鱼");
|
||||
let args = vec![
|
||||
"--no-addons".into(),
|
||||
"--config=NUL".into(),
|
||||
"--input-type=module".into(),
|
||||
"-e".into(),
|
||||
"console.log('--config=NUL')".into(),
|
||||
String::new(),
|
||||
];
|
||||
let mapped = sandbox_args(&args, runtime_dir);
|
||||
let mut expected = args.clone();
|
||||
expected[1] = format!("--config={}", runtime_dir.join(EMPTY_BUN_CONFIG).display());
|
||||
assert_eq!(mapped, expected);
|
||||
assert_eq!(sandbox_args(&mapped, runtime_dir), mapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_sandbox_args_preserve_node_and_compiled_arguments() {
|
||||
for args in [
|
||||
vec!["--no-addons".into(), "--input-type=module".into()],
|
||||
vec!["--tier=plugin".into(), "--windows-sandbox-probe".into()],
|
||||
vec!["--config=NUL.toml".into(), "--config=custom.toml".into()],
|
||||
Vec::new(),
|
||||
] {
|
||||
assert_eq!(sandbox_args(&args, Path::new("runtime copy")), args);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_argv_and_environment_keep_exact_values_and_reject_nul() {
|
||||
let args = vec![
|
||||
@@ -248,13 +278,19 @@ fn assert_no_profile_grant(path: &Path, profile: PSID) {
|
||||
);
|
||||
}
|
||||
fn acl_snapshot(path: &Path, exclude: Option<PSID>) -> Vec<Vec<u8>> {
|
||||
use windows_sys::Win32::Security::{ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce};
|
||||
acl_snapshot_view(path, exclude, false).1
|
||||
}
|
||||
fn acl_snapshot_view(path: &Path, exclude: Option<PSID>, raw: bool) -> (u16, Vec<Vec<u8>>) {
|
||||
use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
|
||||
use windows_sys::Win32::Security::{
|
||||
ACCESS_ALLOWED_ACE, ACE_HEADER, GetAce, GetSecurityDescriptorControl,
|
||||
};
|
||||
use windows_sys::Win32::System::SystemServices::ACCESS_ALLOWED_ACE_TYPE;
|
||||
let directory;
|
||||
let file;
|
||||
let handle = if path.is_dir() {
|
||||
let opened = if path.is_dir() {
|
||||
directory = WindowsDirectory::open(path).unwrap();
|
||||
directory.acl_handle().unwrap().as_raw_handle()
|
||||
directory.acl_handle().unwrap()
|
||||
} else {
|
||||
file = fs::OpenOptions::new()
|
||||
.access_mode(READ_CONTROL | FILE_READ_ATTRIBUTES)
|
||||
@@ -262,24 +298,37 @@ fn acl_snapshot(path: &Path, exclude: Option<PSID>) -> Vec<Vec<u8>> {
|
||||
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(path)
|
||||
.unwrap();
|
||||
file.as_raw_handle()
|
||||
&file
|
||||
};
|
||||
let raw_descriptor = raw.then(|| RawDacl::read(opened).unwrap());
|
||||
let mut _descriptor = None;
|
||||
let mut control = 0_u16;
|
||||
let mut dacl = null_mut();
|
||||
let mut descriptor = null_mut();
|
||||
let error = unsafe {
|
||||
GetSecurityInfo(
|
||||
handle,
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
null_mut(),
|
||||
null_mut(),
|
||||
&mut dacl,
|
||||
null_mut(),
|
||||
&mut descriptor,
|
||||
)
|
||||
};
|
||||
assert_eq!(error, 0, "cannot inspect control ACL");
|
||||
let _descriptor = LocalAllocation(descriptor);
|
||||
if let Some(raw) = &raw_descriptor {
|
||||
dacl = raw.acl();
|
||||
control = raw.control;
|
||||
} else {
|
||||
let mut descriptor = null_mut();
|
||||
let error = unsafe {
|
||||
GetSecurityInfo(
|
||||
opened.as_raw_handle(),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
null_mut(),
|
||||
null_mut(),
|
||||
&mut dacl,
|
||||
null_mut(),
|
||||
&mut descriptor,
|
||||
)
|
||||
};
|
||||
assert_eq!(error, 0, "cannot inspect control ACL");
|
||||
_descriptor = Some(LocalAllocation(descriptor));
|
||||
let mut revision = 0;
|
||||
assert_ne!(
|
||||
unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) },
|
||||
0
|
||||
);
|
||||
}
|
||||
let dacl = std::ptr::NonNull::new(dacl).expect("control must have an actual DACL");
|
||||
let mut result = Vec::new();
|
||||
for index in 0..unsafe { dacl.as_ref().AceCount as u32 } {
|
||||
@@ -304,7 +353,118 @@ fn acl_snapshot(path: &Path, exclude: Option<PSID>) -> Vec<Vec<u8>> {
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
result
|
||||
(control, result)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_raw_dacl_rejects_truncated_unrestricted_and_malformed_descriptors() {
|
||||
use windows_sys::Win32::Security::{
|
||||
ACL_REVISION, InitializeAcl, SE_DACL_PRESENT, SE_SELF_RELATIVE,
|
||||
SECURITY_DESCRIPTOR_RELATIVE,
|
||||
};
|
||||
fn fixture() -> Vec<u32> {
|
||||
let length = size_of::<SECURITY_DESCRIPTOR_RELATIVE>() + size_of::<ACL>();
|
||||
let mut storage = vec![0_u32; length.div_ceil(size_of::<u32>())];
|
||||
let descriptor = storage.as_mut_ptr().cast::<SECURITY_DESCRIPTOR_RELATIVE>();
|
||||
// SAFETY: the aligned allocation contains both complete structures.
|
||||
unsafe {
|
||||
(*descriptor).Revision = 1;
|
||||
(*descriptor).Control = SE_SELF_RELATIVE | SE_DACL_PRESENT;
|
||||
(*descriptor).Dacl = size_of::<SECURITY_DESCRIPTOR_RELATIVE>() as u32;
|
||||
let acl = storage
|
||||
.as_mut_ptr()
|
||||
.cast::<u8>()
|
||||
.add(size_of::<SECURITY_DESCRIPTOR_RELATIVE>())
|
||||
.cast::<ACL>();
|
||||
assert_ne!(InitializeAcl(acl, size_of::<ACL>() as u32, ACL_REVISION), 0);
|
||||
}
|
||||
storage
|
||||
}
|
||||
let valid = fixture();
|
||||
let length = valid.len() * size_of::<u32>();
|
||||
let descriptor = RawDacl::from_storage(valid.clone(), length).unwrap();
|
||||
assert_eq!(descriptor.control, SE_SELF_RELATIVE | SE_DACL_PRESENT);
|
||||
assert_eq!(
|
||||
unsafe { (*descriptor.acl()).AceCount },
|
||||
0,
|
||||
"an empty restrictive ACL is valid"
|
||||
);
|
||||
assert!(RawDacl::from_storage(Vec::new(), 0).is_err());
|
||||
assert!(RawDacl::from_storage(valid.clone(), length + 1).is_err());
|
||||
assert!(
|
||||
RawDacl::from_storage(valid.clone(), size_of::<SECURITY_DESCRIPTOR_RELATIVE>() - 1)
|
||||
.is_err()
|
||||
);
|
||||
for (control, offset) in [
|
||||
(
|
||||
SE_DACL_PRESENT,
|
||||
size_of::<SECURITY_DESCRIPTOR_RELATIVE>() as u32,
|
||||
),
|
||||
(SE_SELF_RELATIVE, 0),
|
||||
(SE_SELF_RELATIVE | SE_DACL_PRESENT, 0),
|
||||
(SE_SELF_RELATIVE | SE_DACL_PRESENT, 4),
|
||||
(SE_SELF_RELATIVE | SE_DACL_PRESENT, 21),
|
||||
(SE_SELF_RELATIVE | SE_DACL_PRESENT, length as u32),
|
||||
] {
|
||||
let mut storage = valid.clone();
|
||||
let header = storage.as_mut_ptr().cast::<SECURITY_DESCRIPTOR_RELATIVE>();
|
||||
unsafe {
|
||||
(*header).Control = control;
|
||||
(*header).Dacl = offset;
|
||||
}
|
||||
assert!(
|
||||
RawDacl::from_storage(storage, length).is_err(),
|
||||
"control={control:#x}, offset={offset}"
|
||||
);
|
||||
}
|
||||
for (size, count) in [(0, 0), (u16::MAX, 0), (size_of::<ACL>() as u16, 1)] {
|
||||
let mut storage = valid.clone();
|
||||
let acl = unsafe {
|
||||
storage
|
||||
.as_mut_ptr()
|
||||
.cast::<u8>()
|
||||
.add(size_of::<SECURITY_DESCRIPTOR_RELATIVE>())
|
||||
.cast::<ACL>()
|
||||
};
|
||||
unsafe {
|
||||
(*acl).AclSize = size;
|
||||
(*acl).AceCount = count;
|
||||
}
|
||||
assert!(
|
||||
RawDacl::from_storage(storage, length).is_err(),
|
||||
"size={size}, count={count}"
|
||||
);
|
||||
}
|
||||
// A complete allow ACE with S-1-5-32 provides the positive control. A
|
||||
// bounded ACL alone must not authorize EqualSid to read past that ACE.
|
||||
let mut allowed = fixture();
|
||||
let entry_offset = length;
|
||||
allowed.resize(allowed.len() + 5, 0);
|
||||
let allowed_length = allowed.len() * size_of::<u32>();
|
||||
unsafe {
|
||||
let bytes =
|
||||
std::slice::from_raw_parts_mut(allowed.as_mut_ptr().cast::<u8>(), allowed_length);
|
||||
bytes[size_of::<SECURITY_DESCRIPTOR_RELATIVE>() + 2..][..2]
|
||||
.copy_from_slice(&((size_of::<ACL>() + 20) as u16).to_le_bytes());
|
||||
bytes[size_of::<SECURITY_DESCRIPTOR_RELATIVE>() + 4] = 1; // AceCount
|
||||
bytes[entry_offset + 2..][..2].copy_from_slice(&20_u16.to_le_bytes());
|
||||
bytes[entry_offset + 4] = 1; // Mask
|
||||
bytes[entry_offset + 8..][..12].copy_from_slice(&[1, 1, 0, 0, 0, 0, 0, 5, 32, 0, 0, 0]);
|
||||
}
|
||||
assert!(RawDacl::from_storage(allowed.clone(), allowed_length).is_ok());
|
||||
for (offset, value) in [(2, 12), (8, 2), (9, 2)] {
|
||||
let mut malformed = allowed.clone();
|
||||
unsafe {
|
||||
*malformed
|
||||
.as_mut_ptr()
|
||||
.cast::<u8>()
|
||||
.add(entry_offset + offset) = value;
|
||||
}
|
||||
assert!(
|
||||
RawDacl::from_storage(malformed, allowed_length).is_err(),
|
||||
"malformed allow ACE at byte {offset}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -315,6 +475,8 @@ fn windows_profile_retirement_removes_only_its_grants_and_inherited_data_on_rest
|
||||
fs::write(&original_file, b"retained user state").unwrap();
|
||||
let original_root_acl = acl_snapshot(data.path(), None);
|
||||
let original_file_acl = acl_snapshot(&original_file, None);
|
||||
let original_raw_root_acl = acl_snapshot_view(data.path(), None, true);
|
||||
let original_raw_file_acl = acl_snapshot_view(&original_file, None, true);
|
||||
let make = || NativeSandbox {
|
||||
profile: Arc::new(Profile::create().unwrap()),
|
||||
_assets: Arc::new(tempfile::tempdir().unwrap()),
|
||||
@@ -324,6 +486,19 @@ fn windows_profile_retirement_removes_only_its_grants_and_inherited_data_on_rest
|
||||
// A concurrent/new profile's exact grants must survive old-profile cleanup.
|
||||
let other = make();
|
||||
other.grant_tree(data.path(), true).unwrap();
|
||||
// Check stored ACE bytes and all control bits before taking a later
|
||||
// baseline. GetSecurityInfo can temporarily normalize the child's view
|
||||
// while its parent carries a grant; no flag masking is allowed here.
|
||||
assert_eq!(
|
||||
acl_snapshot_view(data.path(), Some(other.profile.sid), true),
|
||||
original_raw_root_acl,
|
||||
"first profile grant changed pre-existing directory ACEs"
|
||||
);
|
||||
assert_eq!(
|
||||
acl_snapshot_view(&original_file, Some(other.profile.sid), true),
|
||||
original_raw_file_acl,
|
||||
"first profile grant changed pre-existing file ACEs"
|
||||
);
|
||||
for turn in 0..8 {
|
||||
let current = make();
|
||||
current.grant_tree(data.path(), true).unwrap();
|
||||
@@ -366,8 +541,16 @@ fn windows_profile_retirement_removes_only_its_grants_and_inherited_data_on_rest
|
||||
assert_eq!(fs::read(&original_file).unwrap(), b"retained user state");
|
||||
}
|
||||
drop(other);
|
||||
assert_eq!(acl_snapshot(data.path(), None), original_root_acl);
|
||||
assert_eq!(acl_snapshot(&original_file, None), original_file_acl);
|
||||
assert_eq!(
|
||||
acl_snapshot(data.path(), None),
|
||||
original_root_acl,
|
||||
"final profile retirement changed pre-existing directory ACEs"
|
||||
);
|
||||
assert_eq!(
|
||||
acl_snapshot(&original_file, None),
|
||||
original_file_acl,
|
||||
"final profile retirement changed pre-existing file ACEs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1196,9 +1196,26 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
|
||||
&scopes.admit(selected_workspace.clone()).await?,
|
||||
&selected_scope
|
||||
));
|
||||
let retired_workspace = root.join("retired-workspace");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// This fixture still owns a Fleet ledger whose ancestor pins deny
|
||||
// deletion. Check that protection before releasing the test owner.
|
||||
let error = fs::rename(&selected_workspace, &retired_workspace)
|
||||
.expect_err("a live Fleet ledger must prevent workspace replacement");
|
||||
assert_eq!(error.raw_os_error(), Some(32), "{error}");
|
||||
assert!(!retired_workspace.exists());
|
||||
assert!(Arc::ptr_eq(
|
||||
&scopes.admit(selected_workspace.clone()).await?,
|
||||
&selected_scope
|
||||
));
|
||||
}
|
||||
drop(selected_fleet);
|
||||
// File identity, not a stable pathname, binds the cache. Replacing the
|
||||
// selected directory cannot borrow its admitted LSP/MCP authority.
|
||||
fs::rename(&selected_workspace, root.join("retired-workspace"))?;
|
||||
// selected directory after the Fleet pins close cannot borrow its admitted
|
||||
// LSP/MCP authority. Keep this check on Windows as well as Unix.
|
||||
fs::rename(&selected_workspace, &retired_workspace)
|
||||
.context("replace selected workspace after releasing the test Fleet manager")?;
|
||||
fs::create_dir(&selected_workspace)?;
|
||||
assert!(scopes.admit(selected_workspace.clone()).await.is_err());
|
||||
assert_eq!(
|
||||
|
||||
+46
-1
@@ -485,6 +485,28 @@ fn windows_atomic_target_name(path: &Path) -> std::io::Result<std::ffi::OsString
|
||||
)
|
||||
};
|
||||
let name = path.file_name().ok_or_else(invalid)?;
|
||||
// Path normalization alone can leave a reserved DOS basename unchanged.
|
||||
// Reject them explicitly, including extensions and the documented
|
||||
// superscript port digits, before the native rename can create one.
|
||||
let stem = name
|
||||
.to_string_lossy()
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_end_matches(' ')
|
||||
.to_ascii_uppercase();
|
||||
let reserved_port = stem
|
||||
.strip_prefix("COM")
|
||||
.or_else(|| stem.strip_prefix("LPT"))
|
||||
.is_some_and(|port| {
|
||||
matches!(
|
||||
port,
|
||||
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
|
||||
)
|
||||
});
|
||||
if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || reserved_port {
|
||||
return Err(invalid());
|
||||
}
|
||||
let absolute = std::path::absolute(path)?;
|
||||
if absolute.file_name() != Some(name)
|
||||
|| absolute.as_os_str().to_string_lossy().starts_with(r"\\.\")
|
||||
@@ -1290,12 +1312,35 @@ mod atomic_write_tests {
|
||||
assert_eq!(fs::read(&created).expect("read created"), b"new");
|
||||
write_atomic(&created, b"replaced").expect("replace beside pins");
|
||||
assert_eq!(fs::read(&created).expect("read replaced"), b"replaced");
|
||||
for name in ["trailing.", "trailing ", "CON"] {
|
||||
for name in [
|
||||
"trailing.",
|
||||
"trailing ",
|
||||
"CON",
|
||||
"con.txt",
|
||||
"PRN",
|
||||
"AUX.log",
|
||||
"nul.tar.gz",
|
||||
"COM1",
|
||||
"com9.cfg",
|
||||
"LPT1",
|
||||
"lpt9.log",
|
||||
"COM¹",
|
||||
"COM².log",
|
||||
"COM³",
|
||||
"LPT¹",
|
||||
"LPT².log",
|
||||
"LPT³",
|
||||
] {
|
||||
assert!(
|
||||
write_atomic(&workspace.path().join(name), b"x").is_err(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
for name in ["console.json", "CON-file", "COM10.txt", "LPT10.txt"] {
|
||||
let path = workspace.path().join(name);
|
||||
write_atomic(&path, b"ordinary").expect("write ordinary name");
|
||||
assert_eq!(fs::read(&path).expect("read ordinary name"), b"ordinary");
|
||||
}
|
||||
let strays: Vec<_> = fs::read_dir(workspace.path())
|
||||
.expect("read_dir")
|
||||
.filter_map(Result::ok)
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
fixture
|
||||
+1
@@ -0,0 +1 @@
|
||||
fixture interpreter placeholder
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"desktopVersion": "0.0.0-codewhale-fixture",
|
||||
"platform": "win32",
|
||||
"arch": "arm64",
|
||||
"python": "3.12.8",
|
||||
"pythonPackages": {
|
||||
"numpy": "2.1.0",
|
||||
"pandas": "2.2.3"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user