feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+387
View File
@@ -0,0 +1,387 @@
# YCode sandbox, protocol 1
This directory contains YCode's Windows x64 execution backend. The implementation
uses public Windows APIs and the Microsoft SDK. WorkBuddy binaries, services,
profiles, hooks and IPC pipes are not runtime or build dependencies.
## Build and verify
Prerequisites: Windows x64, Node/npm, Visual Studio C++ Build Tools, and a Windows
10/11 SDK. The build discovers the installed compiler with `vswhere`; it does not
install or modify the toolchain.
```powershell
npm run sandbox:build
npm run test:sandbox
npm run sandbox:maintain -- status
npm run sandbox:maintain -- self-test
npm run test:sandbox:app
npm run test:sandbox:policy
npm run test:sandbox:toolchains
npm run test:sandbox:directories
npm run test:sandbox:write
npm run test:sandbox:workspace
npm run test:sandbox:feasibility
npm run test:sandbox:stdio
```
`sandbox:build` compiles `runner.cpp` with MSVC C++17, `/W4 /WX /MT /Brepro`.
Generated artifacts are `resources/sandbox/win32-x64/ycode-sandbox.exe` and
`manifest.json`. The manifest records the source/binary SHA-256, compiler, SDK,
architecture and protocol. Generated binaries are ignored by Git. `npm run build`
builds the runner before checking TypeScript and building Electron. Packaging
copies the runner, pinned adapters/manifests and bundled Git; test-only probe
executables are excluded.
`test:sandbox` executes real native processes in test-owned temporary directories.
`test:sandbox:app` builds and runs the actual production main/preload/renderer,
real OMP RPC, and a deterministic localhost model. It uses separate home/userData,
hides its own windows, and exercises approval, rejection and cancellation.
## Ownership and execution
- `runner.cpp`: AppContainer profile, SID/token verification, explicit stdio
handle inheritance, restricted child environment, Job, timeout/parent monitor,
temporary drive, lease and native result protocol.
- `src/main/sandbox/policy.ts`: workspace containment, sensitive names,
link/device/alternate stream checks.
- `src/main/sandbox/service.ts`: executable identity, bounded workspace snapshot,
native process lifecycle, diff, approval and audit.
- `workspace-state.h` / `workspace.ts` / `workspace-service.ts`: host-only ACL
sealing, persistent session drafts, immutable checkpoints, synchronization,
approval-based commit and explicit recovery.
- `src/main/sandbox/transactions.ts`: independent before/after backups, batch
conflict checks, compensation and explicit recovery.
- `src/main/sandbox/maintenance.ts`: lease validation, orphan cleanup and record
inventory.
- `policy-store.ts` / `authorizations.ts`: versioned global/workspace policy,
compare-and-swap updates, single-use approval and policy revocation.
- `toolchains.ts` / `dependency-cache.ts`: explicit content-verified runtime imports
and immutable workspace/lock/runtime-bound dependency layers.
- `execution.ts`: bounded admission and sequential execution per workspace.
- `src/main/agent/conversation-tools.ts`: common OMP and fallback execution path.
Default `execute_command` creates a fresh snapshot and unique
`YCode.Run.<32 hexadecimal characters>` AppContainer profile. No network
capabilities are granted. The runner verifies the suspended child's AppContainer
flag and exact SID, assigns the Job, then resumes it. Only stdio handles are
inherited. The Job forbids breakaway, limits active processes to 32 and aggregate
committed memory to 1 GiB, and kills remaining descendants when the root finishes.
The native runner also watches its launch parent and accepts `cancel\n` on stdin.
A free per-user DOS drive maps to this generated stage. This makes drive-root
queries by cmd and PowerShell operate on the scratch root. The allocator has a
named mutex and uses exact-target removal. The mapping and profile are removed
before a successful native result. The original workspace ACL is not changed.
The child can modify its scratch copy. Only a successful command can propose host
file changes. A real conversation approval precedes writeback. All targets are
checked against the initial hashes and rechecked while committing. Independent
backups are persisted before the first host write. Rejected, failed, cancelled,
conflicting or invalid changes are not committed. A failed compensation is
reported with its transaction ID and `recovery-required` state. Audit failure
after a successful commit preserves the actual committed outcome.
## State and maintenance
Production state is under `app.getPath('userData')/sandbox`. The actual app API
`window.ycode.app.sandboxStatus()` reports installed/unavailable backend identity;
`installed` means the binary manifest is valid. A native self-test or execution
is required to verify that the OS can start the isolation boundary.
Each `runs/<uuid>/` contains request metadata, native `lease.json`/`result.json`,
`audit.json` or `failure.json`, and `cleanup.json`. Raw stdout/stderr and environment
credentials are excluded from the audit. Scratch stages are removed after runs;
cleanup status records a failure if the OS still holds a file. The `transactions/`
directory retains the manifest and independent `.before`/`.after` contents.
Stateful maintenance requires an explicit absolute state directory. It does not
guess or switch to another application's storage.
```powershell
npm run sandbox:maintain -- audit --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- transactions --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- restore TRANSACTION_UUID --workspace 'C:\absolute\workspace' --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- cleanup --state-root 'C:\absolute\userData\sandbox'
```
Recovery checks the expected workspace, backup hashes, and current file hashes.
It refuses to overwrite a later external edit. Interrupted prepared/applying
transactions remain inspectable and recoverable. Restoring an already restored
transaction is harmless. `cleanup` checks a lease's run identity and PID, skips
live runners, and removes an orphaned profile/drive only for the recorded exact
target. It preserves transaction backups and audit history.
## Policies, runtimes and dependencies
Policy defaults preserve the original snapshot boundary. Maintenance can tighten
entry/file/byte limits, disable commands, deny writeback and add relative denied
paths. Workspace overrides cannot relax global restrictions. Updates require the
current revision and an exclusive policy lock. Corrupt/unknown policies fail
closed. Policy changes cancel active commands and pending conversation approvals;
an approved ticket is scoped to its exact change batch and can be consumed once.
Bindings include a content fingerprint as well as the revision, so manual edits
that keep a revision do not preserve old permissions or self-test observations.
```powershell
npm run sandbox:maintain -- policy --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- policy-set --expected-revision REVISION --rules 'C:\absolute\rules.json' --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- toolchain-add node --source 'C:\absolute\node.exe' --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- toolchain-add python --source 'C:\absolute\python.exe' --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- toolchain-add git --source 'C:\Program Files\Git\cmd\git.exe' --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- git-state --workspace 'C:\absolute\project' --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- git-reset --workspace 'C:\absolute\project' --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- dependency-add node --workspace 'C:\absolute\project' --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- dependency-add python --workspace 'C:\absolute\project' --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- operation RUN_UUID --state-root 'C:\absolute\userData\sandbox'
```
Import selects explicit local runtime bytes; it never executes the source
interpreter on the host. Node includes the installed sibling npm distribution
when present. Python includes its version DLL, standard library, extension DLLs
and installed pip when present. Sensitive configuration and test/cache files are
filtered; no global PATH or credentials are inherited. Imported runtimes have
independent file-count/size limits, manifests and pinned hashes. Registration is
metadata availability; every requested file is verified before actual execution.
Model commands select `runtimes: ['node']`, `['python']` or `['git']`. A dependency import
reads only that workspace's `node_modules` or `.venv/Lib/site-packages`, rejects
links and hard-linked files, and binds the layer to workspace, project/lockfile
hashes and runtime identity. Commands opt in with `dependencies: ['node']` or
`['python']`. Cached dependencies are copied to the private stage and protected by
native read/execute ACLs. Node keeps the actual node_modules layout for CommonJS
and ESM. Python uses a private PYTHONPATH. Dependency contents are never promoted
as ordinary generated changes; cache updates require explicit import. A changed
lockfile/runtime or a corrupted cache rejects execution. Original dependency
trees may change after import; the pinned layer changes only on explicit reimport.
The following paragraph records the original 0.4.0 Git-only snapshot scope;
host-Git import/writeback and transport were added in component 0.7.0 below.
Git is bundled by default; users do not install or register it. Maintainers run
`npm run sandbox:vendor-git` once when preparing a source checkout. The pinned
official MinGit archive and deterministic runtime manifest are checked before
an offline normal build; licenses and source-provenance notices ship with it.
An optional explicit import overrides the bundle, without silent fallback if
that override is damaged. The importer reads the real MinGW/UCRT x64 image and
its PE DLL dependency closure without executing source programs on the host.
In the original 0.4.0 scope, Git used a persistent private repository
whose initial commit contains the filtered workspace snapshot. Host `.git`,
history, index, credentials and hooks are never imported or written back. Thus
private status/diff/log do not describe the original host repository. Repository
changes require approval, bind the exact candidate and policy, and reject
approval-time workspace edits. Read-only status/diff/log do not save stat caches.
Git metadata uses its own versioned state outside the worktree; normal file
writeback retains its existing independent transaction and recovery record.
`git.persistence` and durable operation queries report private publication
separately: the two steps are not one crash-atomic transaction. All protocols are
disabled by default and the native Job retains zero network capabilities.
This historical snapshot path remains available as a separate mode. See
`docs/沙盒Git接入-2026-10-18.md` for the original subset.
Bundling, copied/packaged-resource and offline reproducibility checks use
`npm run test:sandbox:bundled-git`; details are recorded in
`docs/沙盒内置Git分发-2026-10-18.md`.
### Git host and remote integration (component 0.7.0)
The complete common Git path is documented in `docs/沙盒Git完整接入-2026-10-18.md`
and its acceptance matrix. `sandbox_git` can explicitly import a real host
repository (including an existing linked worktree), run the supported local Git
workflow in the AppContainer draft, and publish project files plus safe Git
metadata through coordinated native transactions. `sandbox_workspace` routes
commit/discard/recover through the same host-Git binding when one exists.
Import is approval-bound and preserves real objects, HEAD, index, refs, tags,
stash state and allowed configuration without executing host hooks, aliases,
filters, credential helpers or external object paths. New repositories remain
unborn until a real first commit. Clone/fetch/pull/push use the trusted host
transport control plane with fixed Git, explicit HTTPS/SSH credentials, TLS and
known-host verification, per-target approvals and durable unknown outcomes.
The ordinary command Job still has no network capability. `git-cleanup` removes
only abandoned control roots after checking both the host owner record and native
lease. This is a Windows x64 engineering acceptance, not a claim of complete
Git subcommand compatibility, crash-atomic multi-root publication, or WorkBuddy
1:1 equivalence.
### Persistent session drafts
Component 0.6.0 adds `workspaceState: 1`. Commands explicitly select
`workspace: 'persistent'` to retain a project/session-bound draft. A successful
command saves private files without writing the host. `sandbox_workspace`
supports `status`, `changes`, `commit`, `discard` and `recover`, with an optional
`expectedRevision`. Commit uses the existing conversation approval and native v3
host transaction. Explicit `snapshot` or `persistent` selection always confines
the command, even in a full-access tool context. Omitting the selector retains
the existing permission behavior.
Normal successful commands retain the same physical NTFS worktree identity by
moving it into and out of the run stage. The host-only `--seal-workspace` entry
uses relative native directory handles to remove former AppContainer access
before retention. Private hash-addressed blobs and revision manifests remain
host-only, including for later Jobs from the same session. Manifests are capped
at 8 MiB before publication. Root identity and policy changes make old drafts
stale. Three-way synchronization imports unrelated host edits and rejects file
or ancestor conflicts; file/directory type swaps are currently rejected.
Failed, cancelled and timed-out commands restore the saved checkpoint. Explicit
recovery checks the saved run owner, dead host lock and native lease. Commit
intents distinguish an interrupted private update from actual host writes:
metadata failure after host commit still reports `committed`. The read-only
status path returns `busy` during execution or approval instead of queueing.
Persistent Git uses its own project/session namespace. Local private Git commits
need no host approval and do not modify host `.git`. File checkpoint and private
Git publication are separate steps; publication failure is reported separately.
Discard archives both the draft and its active private Git reference. Scoped
stale Git locks are checked during explicit draft recovery. The existing
project-wide snapshot Git behavior is unchanged.
```powershell
npm run sandbox:maintain -- workspace-changes --workspace 'C:\absolute\project' --session SESSION_ID --state-root 'C:\absolute\userData\sandbox'
npm run sandbox:maintain -- workspace-commit --workspace 'C:\absolute\project' --session SESSION_ID --state-root 'C:\absolute\userData\sandbox' --draft-revision REVISION_UUID --approve
npm run sandbox:maintain -- workspace-recover --workspace 'C:\absolute\project' --session SESSION_ID --state-root 'C:\absolute\userData\sandbox'
```
The CLI also accepts `workspace-status` and `workspace-discard`. Preload exposes
read-only `app.sandboxWorkspace(sessionId)`; main resolves the root from the saved
session and requires the trusted top-level app page. Ordinary file tools and the
read bridge still access the host project; use persistent commands for draft
file access. Processes, home, temp, tools and absolute execution paths are not
persistent. Mutable dependencies and online installs remain pending; imported
read-only dependencies detach before checkpointing. See
`docs/沙盒长期工作区接入-2026-10-18.md` for verification and recovery limits.
Windows derives an additional private AppContainer TEMP directory from
LOCALAPPDATA. The runner provisions that profile-specific path before launch;
Node/Python temporary files remain inside the private stage and are cleaned with it.
Python 3.13+ gives mkdir(0o700) a user/admin-only DACL, excluding the package SID.
The pinned, read-only Python site adapter uses inherited native private ACLs for
that mode only within the owned stage. Outside paths retain Python's behavior and
OS denial. Isolated/no-site Python flags can bypass this compatibility adapter.
For synchronous Node subprocess capture, explicitly preload the owned adapter:
```text
node --require "%YCODE_SANDBOX_PROCESS%" build.cjs
```
The manifest-pinned `node-process.cjs` replaces public spawnSync/execFileSync/
execSync (including ESM named exports) with a file-descriptor capture adapter.
Subprocesses still use Node's own spawn and inherit the same token/Job. Inputs
and maxBuffer are capped at 16 MiB; maxBuffer defaults to 1 MiB. Buffer overflow
is detected after completion, not an OS hard disk quota or a claim of early
termination. Scratch handles/files are closed/removed after each call. Explicit
non-pipe stdio configurations pass through. Default Node synchronous pipes still
hang on the tested AppContainer without the adapter; async spawn/fork/IPC and
comprehensive API compatibility remain separate work. Run
`npm run test:sandbox:process` for the bounded Node/Python regression.
Native operation outcomes are saved independently of verbose audit and chat
messages. The operation query returns an unknown/incomplete outcome when no
durable host result exists; native process completion does not imply host commit.
Self-tests persist their timestamp and exact binary/policy identity. A new binary
or policy invalidates old observations. The current self-test verifies command
startup/isolation and cleanup, not every network/filesystem/registry capability.
The internal `stdioProbe` mode remains experimental and accepts bounded echo
requests only. It uses the already bound suspended/verified child and its Job's
inherited pipes, rejects caller-supplied identities, repeated IDs and excessive
frames/concurrency, and forwards replies as bounded base64 control records.
Model-facing tool arguments cannot enable it. It grants no host file/network
operation and is not individual descendant PID authentication. Normal commands
retain NUL stdin. Named-pipe connectivity remains unverified on this host; run
`npm run test:sandbox:feasibility -- --probe-pipe` to record the experimental
observation, or add `--require-pipe` to require actual success. An unavailable
transport is reported as unavailable, not as a completed broker capability.
### Native read-only workspace bridge
Commands may explicitly select `bridge: 'workspace-read'` together with
`runtimes: ['node']`. The manifest-pinned client is available at
`process.env.YCODE_SANDBOX_BRIDGE`; `require(...).connectSync()` supplies
`readFileSync`, `statSync`, `readdirSync`, `querySync` and `close`. `connect()`
supplies asynchronous equivalents and an eight-request dispatch queue. Close the
client after use. One Job should have one active stdin consumer. This is an
explicit JSON bridge, not a replacement for every Node fs or child_process API.
`filesystem-read.h` runs only on the host, requires a fixed local NTFS volume,
pins all ancestors/targets with read sharing only, rejects reparse/multiple-link
files, and compares root file identity and final normalized volume paths. The
host checks policy before and after reads and again for every cached chunk.
Reads are limited to 8 MiB or the tighter file policy; immutable snapshots are
served in 16 KiB chunks, with at most eight snapshots/16 MiB per Job, a 60-second
idle lifetime and a full client SHA-256 check. Directory listing is bounded to
128 raw entries. Native readers monitor their parent and have a four-second
deadline. No writable handle is returned to sandbox code.
The strict protocol caps requests at 8 KiB, replies at 24 KiB, eight concurrent
operations and 1024 total requests per Job. Replay, forged ownership, oversized
or incomplete frames cancel the channel/Job. Ordinary output is streamed. Final
operation metadata is flushed before replying; contents/tokens are not persisted.
Completed results remain queryable after a broken reply. Interrupted pending
records are unknown, never inferred successful:
```powershell
npm run test:sandbox:read-bridge
npm run test:sandbox:read-protocol
npm run sandbox:maintain -- broker-operation RUN_UUID REQUEST_UUID --state-root 'C:\absolute\userData\sandbox'
```
Preload also exposes `app.sandboxBrokerOperation(runId, requestId)` to the owned
top-level application page. Ownership and record IDs are checked on the host.
The bridge grants a read scope to the whole bound Job, not to individually
authenticated descendant PIDs. Full host write/file-descriptor shims, Python and
Shell adapters remain pending. See the implementation and test record in
`docs/沙盒只读文件桥接入-2026-10-18.md`.
To maintain a policy, edit the relevant module, add an adversarial case to
`scripts/sandbox-regression.ts`, run the native suite and application integration,
then rebuild. Keep protocol changes synchronized between runner, manifest,
service and tests. Do not enable ordinary host spawn as a default-mode fallback.
## Current scope and limits
- Windows x64 is implemented. Other platforms reject default-mode commands.
- The model command path is isolated. The OMP sidecar, manually operated terminal,
local MCP server processes and remote connectors retain their existing trust
and approval models; their process confinement is separate work.
- Input excludes credentials/protected names, `.git`, `.ycode`, `.tmp`,
`node_modules`, `.venv`, `venv`, `__pycache__` and symbolic links. Multi-link files are rejected. Limits are
10,000 entries, 128 MiB of copied/generated file data, 8 MiB per file, 120 seconds
by default, and 2 MiB retained per stdout/stderr stream.
- File-count/size limits are snapshot and writeback checks, not an OS disk quota.
Hard disk quotas and automatic audit/backup retention are pending work.
- Cmd and Windows PowerShell work in the mapped stage. Explicitly imported
Node/npm and Python/pip run offline with controlled optional dependency layers;
Git supports the private filtered repository described above.
Runtimes and package managers are not automatically copied or granted access.
Commands must use relative scratch paths; actual original workspace absolute
paths are not redirected. Persistent filtered drafts are opt-in as described
above; persistent processes/home/temp, mutable dependency layers, host Git
history/index integration and metadata writeback, remote Git, network installs,
pnpm link layouts and comprehensive native-addon compatibility remain pending.
- File creation/update/deletion, empty directories and directory tree deletion or
rename are supported through versioned transactions. Deletion preflight refuses
unknown/excluded host contents; recovery refuses later external additions.
Directory metadata, ACLs, timestamps, arbitrary reparse behavior and binary
compatibility with every program remain outside this first acceptance set.
- AppContainer can read shared Windows resources and other objects permitted by
Windows ACLs. It does not implement a universal read-path allowlist or DLP.
A workspace credential filter is an additional application policy.
- Registry/domain policies, configurable network egress, connector isolation,
security-center UI and WorkBuddy equivalence remain separate acceptance tasks.
- Read-bridge operations pin native handles. Host commit/compensation/recovery
now use the host-only NTFS transaction channel in `filesystem-write.h`, with
root identity, relative directory-handle operations and a v3 intent journal.
Replacement uses a no-overwrite two-rename handoff, with a brief absent-name
window rather than one atomic swap. Changed/unknown scratch content refuses
automatic cleanup. Full shims/metadata and power-loss acceptance remain pending;
see `docs/沙盒原生写回事务-2026-10-18.md`.
- The recovery journal is intended for process interruption. Power-loss durability
and independent security review require additional validation.
## Primary API references
- [AppContainer isolation](https://learn.microsoft.com/en-us/windows/win32/secauthz/appcontainer-isolation)
- [CreateAppContainerProfile](https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createappcontainerprofile)
- [Process attributes, including security capabilities and handle lists](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute)
- [Job objects and process trees](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects)
+174
View File
@@ -0,0 +1,174 @@
#pragma once
#include <memory>
#include <sstream>
#include <iomanip>
#include <cwctype>
// This host-only reader never grants a workspace handle to the sandbox.
namespace workspace_read {
struct Lifetime {
Handle parent{parentProcess()}, done{CreateEventW(nullptr, TRUE, FALSE, nullptr)};
std::thread monitor;
explicit Lifetime(DWORD timeout = 4000) {
require(done.value != nullptr, "Create reader lifecycle event");
monitor = std::thread([this, timeout]() {
HANDLE events[] = {parent.value, done.value};
const DWORD state = WaitForMultipleObjects(2, events, FALSE, timeout);
if (state != WAIT_OBJECT_0 + 1) TerminateProcess(GetCurrentProcess(), 125);
});
}
~Lifetime() { SetEvent(done.value); monitor.join(); }
};
static bool component(const std::wstring& name) {
if (name.empty() || name == L"." || name == L".." || name.back() == L'.' || name.back() == L' ') return false;
for (wchar_t ch : name) if (ch < 32 || std::wstring(L"<>:\"/\\|?*").find(ch) != std::wstring::npos) return false;
auto stem = name.substr(0, name.find(L'.'));
std::transform(stem.begin(), stem.end(), stem.begin(), [](wchar_t ch) { return static_cast<wchar_t>(towupper(ch)); });
if (stem == L"CON" || stem == L"PRN" || stem == L"AUX" || stem == L"NUL") return false;
return !(stem.size() == 4 && (stem.substr(0, 3) == L"COM" || stem.substr(0, 3) == L"LPT") && stem[3] >= L'1' && stem[3] <= L'9');
}
static std::vector<std::wstring> parts(std::wstring value) {
std::replace(value.begin(), value.end(), L'/', L'\\');
std::vector<std::wstring> result;
size_t start = 0;
for (;;) {
const auto end = value.find(L'\\', start);
const auto name = value.substr(start, end == std::wstring::npos ? end : end - start);
if (!component(name)) throw std::runtime_error("Invalid workspace path component");
result.push_back(name);
if (end == std::wstring::npos) return result;
start = end + 1;
}
}
static std::wstring finalPath(HANDLE handle) {
std::vector<wchar_t> buffer(32768);
const DWORD count = GetFinalPathNameByHandleW(handle, buffer.data(), static_cast<DWORD>(buffer.size()), FILE_NAME_NORMALIZED | VOLUME_NAME_GUID);
require(count > 0 && count < buffer.size(), "Resolve workspace handle");
std::wstring path(buffer.data(), count);
while (path.size() > 1 && path.back() == L'\\') path.pop_back();
return path;
}
static BY_HANDLE_FILE_INFORMATION information(HANDLE handle) {
BY_HANDLE_FILE_INFORMATION result = {};
require(GetFileInformationByHandle(handle, &result) != FALSE, "Inspect workspace handle");
if (result.dwFileAttributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DEVICE)) throw std::runtime_error("Workspace reparse point or device denied");
if (!(result.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && result.nNumberOfLinks != 1) throw std::runtime_error("Workspace hard link denied");
return result;
}
static std::string identity(HANDLE handle) {
FILE_ID_INFO id = {};
require(GetFileInformationByHandleEx(handle, FileIdInfo, &id, sizeof(id)) != FALSE, "Identify workspace handle");
std::ostringstream text;
text << std::hex << std::setfill('0') << std::setw(16) << id.VolumeSerialNumber << ':';
for (BYTE byte : id.FileId.Identifier) text << std::setw(2) << static_cast<unsigned int>(byte);
return text.str();
}
struct Target {
std::vector<std::unique_ptr<Handle>> held;
fs::path path;
std::wstring rootFinal;
std::string rootIdentity;
HANDLE open(bool directory) {
// Read sharing only pins names against rename/delete and excludes writable handles,
// including handles used to retarget a reparse point. All ancestors stay open.
auto handle = std::make_unique<Handle>(CreateFileW(path.c_str(), directory ? FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES : GENERIC_READ,
FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
require(handle->value != INVALID_HANDLE_VALUE, "Open pinned workspace target");
const auto info = information(handle->value);
if (directory && !(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) throw std::runtime_error("Workspace ancestor is not a directory");
const auto result = handle->value;
held.push_back(std::move(handle));
return result;
}
explicit Target(const std::wstring& root) {
if (root.size() < 3 || root.size() > 1024 || !iswalpha(root[0]) || root[1] != L':' || root[2] != L'\\') throw std::runtime_error("Workspace requires a local drive path");
path = root.substr(0, 3);
if (GetDriveTypeW(path.c_str()) != DRIVE_FIXED) throw std::runtime_error("Workspace requires a fixed local volume");
HANDLE last = open(true);
wchar_t filesystem[32] = {};
require(GetVolumeInformationByHandleW(last, nullptr, 0, nullptr, nullptr, nullptr, filesystem, 32) != FALSE, "Inspect workspace volume");
if (_wcsicmp(filesystem, L"NTFS") != 0) throw std::runtime_error("Workspace reader currently requires NTFS");
if (root.size() > 3) for (const auto& name : parts(root.substr(3))) { path /= name; last = open(true); }
rootFinal = finalPath(last);
rootIdentity = identity(last);
}
HANDLE resolve(const std::wstring& relative) {
if (relative == L".") return held.back()->value;
const auto names = parts(relative);
HANDLE last = nullptr;
for (size_t index = 0; index < names.size(); ++index) { path /= names[index]; last = open(index + 1 < names.size()); }
return last;
}
std::wstring relative(HANDLE handle) const {
const auto actual = finalPath(handle);
if (_wcsicmp(actual.c_str(), rootFinal.c_str()) == 0) return L".";
if (actual.size() <= rootFinal.size() || _wcsnicmp(actual.c_str(), rootFinal.c_str(), rootFinal.size()) != 0 || actual[rootFinal.size()] != L'\\') throw std::runtime_error("Workspace handle escaped root");
return actual.substr(rootFinal.size() + 1);
}
};
static std::string base64(const std::vector<BYTE>& bytes) {
static const char* alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string result; result.reserve((bytes.size() + 2) / 3 * 4);
for (size_t index = 0; index < bytes.size(); index += 3) {
const unsigned int value = (static_cast<unsigned int>(bytes[index]) << 16) |
(index + 1 < bytes.size() ? static_cast<unsigned int>(bytes[index + 1]) << 8 : 0) | (index + 2 < bytes.size() ? bytes[index + 2] : 0);
result += alphabet[value >> 18]; result += alphabet[(value >> 12) & 63];
result += index + 1 < bytes.size() ? alphabet[(value >> 6) & 63] : '=';
result += index + 2 < bytes.size() ? alphabet[value & 63] : '=';
}
return result;
}
static int run(const std::wstring& operation, const std::wstring& root, const std::wstring& relative, const std::string& expected, DWORD limit) {
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Workspace reader is host-only");
Lifetime lifetime;
if (operation != L"identity" && operation != L"read" && operation != L"stat" && operation != L"list") throw std::runtime_error("Invalid workspace reader operation");
if (limit == 0 || limit > 8 * 1024 * 1024) throw std::runtime_error("Invalid workspace reader limit");
Target target(root);
if (operation != L"identity" && target.rootIdentity != expected) throw std::runtime_error("Workspace root identity changed");
const HANDLE handle = target.resolve(relative);
const auto before = information(handle);
const bool directory = (before.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
const auto size = (static_cast<ULONGLONG>(before.nFileSizeHigh) << 32) | before.nFileSizeLow;
std::string result = "{\"protocol\":1,\"rootIdentity\":\"" + target.rootIdentity + "\",\"identity\":\"" + identity(handle) +
"\",\"path\":\"" + jsonText(target.relative(handle)) + "\",\"kind\":\"" + (directory ? "directory" : "file") + "\",\"size\":" + std::to_string(size);
if (operation == L"read") {
if (directory) throw std::runtime_error("Workspace target is a directory");
if (size > limit) throw std::runtime_error("Workspace file exceeds read limit");
std::vector<BYTE> bytes(static_cast<size_t>(size)); DWORD read = 0;
if (!bytes.empty()) require(ReadFile(handle, bytes.data(), static_cast<DWORD>(bytes.size()), &read, nullptr) && read == bytes.size(), "Read pinned workspace file");
const auto after = information(handle);
if (after.nNumberOfLinks != 1 || after.nFileSizeLow != before.nFileSizeLow || after.nFileSizeHigh != before.nFileSizeHigh || CompareFileTime(&before.ftLastWriteTime, &after.ftLastWriteTime) != 0) throw std::runtime_error("Workspace file changed while reading");
result += ",\"data\":\"" + base64(bytes) + "\"";
} else if (operation == L"list") {
if (!directory) throw std::runtime_error("Workspace target is not a directory");
result += ",\"entries\":[";
std::vector<BYTE> buffer(65536); bool first = true; DWORD count = 0;
for (;;) {
if (!GetFileInformationByHandleEx(handle, FileIdBothDirectoryInfo, buffer.data(), static_cast<DWORD>(buffer.size()))) {
if (GetLastError() == ERROR_NO_MORE_FILES) break;
require(false, "Enumerate pinned workspace directory");
}
size_t offset = 0;
for (;;) {
const auto entry = reinterpret_cast<FILE_ID_BOTH_DIR_INFO*>(buffer.data() + offset);
if (entry->FileNameLength > buffer.size() - offset - offsetof(FILE_ID_BOTH_DIR_INFO, FileName)) throw std::runtime_error("Invalid directory record");
const std::wstring name(entry->FileName, entry->FileNameLength / sizeof(wchar_t));
if (name != L"." && name != L"..") {
if (++count > limit) throw std::runtime_error("Workspace directory exceeds entry limit");
if (!first) result += ',';
first = false;
result += "{\"name\":\"" + jsonText(name) + "\",\"kind\":\"" + ((entry->FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) ? "link" : (entry->FileAttributes & FILE_ATTRIBUTE_DIRECTORY) ? "directory" : "file") + "\"}";
}
if (entry->NextEntryOffset == 0) break;
if (entry->NextEntryOffset < sizeof(FILE_ID_BOTH_DIR_INFO) || entry->NextEntryOffset > buffer.size() - offset - sizeof(FILE_ID_BOTH_DIR_INFO)) throw std::runtime_error("Invalid next directory record");
offset += entry->NextEntryOffset;
}
}
result += ']';
}
result += "}\n";
DWORD written = 0;
require(WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), result.data(), static_cast<DWORD>(result.size()), &written, nullptr) && written == result.size(), "Write workspace reader result");
return 0;
}
}
+332
View File
@@ -0,0 +1,332 @@
#pragma once
#include <bcrypt.h>
#include <map>
#include <winternl.h>
// Host-owned transaction channel. No workspace handles cross into an AppContainer.
namespace workspace_write {
static size_t fileLimit = 8 * 1024 * 1024;
using CreateFileFn = NTSTATUS(NTAPI*)(PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES, PIO_STATUS_BLOCK, PLARGE_INTEGER, ULONG, ULONG, ULONG, ULONG, PVOID, ULONG);
using SetFileFn = NTSTATUS(NTAPI*)(HANDLE, PIO_STATUS_BLOCK, PVOID, ULONG, FILE_INFORMATION_CLASS);
using ErrorFn = ULONG(NTAPI*)(NTSTATUS);
template<class Function> static Function nativeFunction(const char* name) {
const auto address = GetProcAddress(GetModuleHandleW(L"ntdll.dll"), name);
require(address != nullptr, "Resolve native transaction API");
Function result = nullptr;
static_assert(sizeof(result) == sizeof(address)); memcpy(&result, &address, sizeof(result));
return result;
}
static bool nativeOk(NTSTATUS status) {
if (status >= 0) return true;
SetLastError(nativeFunction<ErrorFn>("RtlNtStatusToDosError")(status));
return false;
}
static HANDLE openRelative(HANDLE parent, const std::wstring& name, ACCESS_MASK access, bool directory, bool create = false) {
UNICODE_STRING text = {}; text.Buffer = const_cast<PWSTR>(name.data());
text.Length = static_cast<USHORT>(name.size() * sizeof(wchar_t)); text.MaximumLength = text.Length;
OBJECT_ATTRIBUTES attributes = {}; attributes.Length = sizeof(attributes); attributes.RootDirectory = parent;
attributes.ObjectName = &text; attributes.Attributes = 0x1040; // OBJ_CASE_INSENSITIVE | OBJ_DONT_REPARSE
IO_STATUS_BLOCK status = {}; HANDLE handle = INVALID_HANDLE_VALUE;
const ULONG options = 0x00200000 | 0x20 | (directory ? 1 : 0); // OPEN_REPARSE_POINT, synchronous, optional directory
const auto result = nativeFunction<CreateFileFn>("NtCreateFile")(&handle, access | SYNCHRONIZE, &attributes, &status, nullptr,
FILE_ATTRIBUTE_NORMAL, directory ? FILE_SHARE_READ | FILE_SHARE_WRITE : FILE_SHARE_READ, create ? 2 : 1, options, nullptr, 0);
return nativeOk(result) ? handle : INVALID_HANDLE_VALUE;
}
struct Root {
std::vector<std::unique_ptr<Handle>> held;
fs::path path;
HANDLE handle = nullptr;
std::wstring rootFinal;
std::string rootIdentity;
explicit Root(const std::wstring& input) {
if (input.size() < 3 || input.size() > 1024 || !iswalpha(input[0]) || input[1] != L':' || input[2] != L'\\') throw std::runtime_error("Workspace requires a local drive path");
path = input.substr(0, 3);
if (GetDriveTypeW(path.c_str()) != DRIVE_FIXED) throw std::runtime_error("Workspace requires a fixed local volume");
auto first = std::make_unique<Handle>(CreateFileW(path.c_str(), FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
require(first->value != INVALID_HANDLE_VALUE, "Pin transaction volume");
wchar_t filesystem[32] = {};
require(GetVolumeInformationByHandleW(first->value, nullptr, 0, nullptr, nullptr, nullptr, filesystem, 32) != FALSE, "Inspect transaction volume");
if (_wcsicmp(filesystem, L"NTFS") != 0) throw std::runtime_error("Workspace transaction requires NTFS");
handle = first->value; held.push_back(std::move(first));
if (input.size() > 3) for (const auto& name : workspace_read::parts(input.substr(3))) {
auto next = std::make_unique<Handle>(openRelative(handle, name, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, true));
require(next->value != INVALID_HANDLE_VALUE, "Pin transaction root ancestor");
workspace_read::information(next->value);
handle = next->value; held.push_back(std::move(next)); path /= name;
}
rootFinal = workspace_read::finalPath(handle); rootIdentity = workspace_read::identity(handle);
}
std::wstring relative(HANDLE target) const {
const auto actual = workspace_read::finalPath(target);
if (actual.size() <= rootFinal.size() || _wcsnicmp(actual.c_str(), rootFinal.c_str(), rootFinal.size()) != 0 || actual[rootFinal.size()] != L'\\') throw std::runtime_error("Transaction handle escaped root");
return actual.substr(rootFinal.size() + 1);
}
};
static std::string hex(const BYTE* bytes, size_t size) {
static const char* digits = "0123456789abcdef";
std::string result; result.reserve(size * 2);
for (size_t index = 0; index < size; ++index) { result += digits[bytes[index] >> 4]; result += digits[bytes[index] & 15]; }
return result;
}
static std::vector<BYTE> unhex(const std::string& text, size_t limit) {
if (text.size() % 2 || text.size() > limit * 2) throw std::runtime_error("Invalid transaction field length");
std::vector<BYTE> result; result.reserve(text.size() / 2);
auto digit = [](char ch) -> BYTE {
if (ch >= '0' && ch <= '9') return static_cast<BYTE>(ch - '0');
if (ch >= 'a' && ch <= 'f') return static_cast<BYTE>(ch - 'a' + 10);
throw std::runtime_error("Invalid transaction hex field");
};
for (size_t index = 0; index < text.size(); index += 2) result.push_back(static_cast<BYTE>((digit(text[index]) << 4) | digit(text[index + 1])));
return result;
}
static std::wstring pathField(const std::string& text) {
const auto bytes = unhex(text, 4096);
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, reinterpret_cast<const char*>(bytes.data()), static_cast<int>(bytes.size()), nullptr, 0);
if (size <= 0 || size > 1024) throw std::runtime_error("Invalid transaction path encoding");
std::wstring result(size, 0);
require(MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, reinterpret_cast<const char*>(bytes.data()), static_cast<int>(bytes.size()), result.data(), size) == size, "Decode transaction path");
return result;
}
static std::wstring key(std::wstring value) {
std::transform(value.begin(), value.end(), value.begin(), [](wchar_t ch) { return static_cast<wchar_t>(towlower(ch)); });
return value;
}
static std::string hash(const std::vector<BYTE>& bytes) {
BCRYPT_ALG_HANDLE algorithm = nullptr;
if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0) throw std::runtime_error("Open transaction SHA256");
BYTE result[32] = {};
const auto status = BCryptHash(algorithm, nullptr, 0, const_cast<BYTE*>(bytes.data()), static_cast<ULONG>(bytes.size()), result, sizeof(result));
BCryptCloseAlgorithmProvider(algorithm, 0);
if (status < 0) throw std::runtime_error("Hash transaction contents");
return hex(result, sizeof(result));
}
static std::vector<BYTE> contents(HANDLE handle) {
const auto info = workspace_read::information(handle);
const auto size = (static_cast<ULONGLONG>(info.nFileSizeHigh) << 32) | info.nFileSizeLow;
if ((info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || size > fileLimit) throw std::runtime_error("Transaction file type or size denied");
std::vector<BYTE> bytes(static_cast<size_t>(size)); DWORD count = 0;
if (!bytes.empty()) require(ReadFile(handle, bytes.data(), static_cast<DWORD>(bytes.size()), &count, nullptr) && count == bytes.size(), "Read pinned transaction file");
return bytes;
}
static void reply(const std::string& text) {
const auto line = text + '\n'; DWORD count = 0;
require(WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), line.data(), static_cast<DWORD>(line.size()), &count, nullptr) && count == line.size(), "Write transaction response");
}
struct Session {
struct MissingParent : std::runtime_error { MissingParent() : std::runtime_error("Transaction parent missing") {} };
Root root;
std::map<std::wstring, std::unique_ptr<Handle>> directories;
std::map<std::wstring, std::string> known;
explicit Session(const std::wstring& path, const std::string& expected) : root(path) {
if (expected != "-" && expected != root.rootIdentity) throw std::runtime_error("Workspace root identity changed");
}
void validate(HANDLE handle, const std::wstring& relative, bool directory) {
for (const auto& ancestor : root.held) workspace_read::information(ancestor->value);
const auto info = workspace_read::information(handle);
if (directory != ((info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0)) throw std::runtime_error("Transaction target type changed");
if (_wcsicmp(root.relative(handle).c_str(), relative.c_str()) != 0) throw std::runtime_error("Transaction final name changed");
}
HANDLE parent(const std::vector<std::wstring>& names) {
HANDLE parentHandle = root.handle; std::wstring relative;
workspace_read::information(parentHandle);
for (size_t index = 0; index + 1 < names.size(); ++index) {
if (!relative.empty()) relative += L'\\'; relative += names[index];
const auto name = key(relative);
if (directories.count(name)) { parentHandle = directories[name]->value; workspace_read::information(parentHandle); continue; }
if (directories.size() >= 10000) throw std::runtime_error("Transaction directory pin limit");
auto handle = std::make_unique<Handle>(openRelative(parentHandle, names[index], FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, true));
if (handle->value == INVALID_HANDLE_VALUE && (GetLastError() == ERROR_FILE_NOT_FOUND || GetLastError() == ERROR_PATH_NOT_FOUND)) throw MissingParent();
require(handle->value != INVALID_HANDLE_VALUE, "Pin transaction ancestor");
validate(handle->value, relative, true);
parentHandle = handle->value;
directories.emplace(name, std::move(handle));
}
return parentHandle;
}
void unpin(const std::wstring& relative) {
const auto prefix = key(relative);
for (auto entry = directories.begin(); entry != directories.end();) {
if (entry->first == prefix || entry->first.compare(0, prefix.size() + 1, prefix + L'\\') == 0) entry = directories.erase(entry);
else ++entry;
}
}
std::string execute(const std::vector<std::string>& fields) {
if (fields.size() != 5) throw std::runtime_error("Invalid transaction frame");
const auto& operation = fields[0]; const auto relative = pathField(fields[1]);
const auto names = workspace_read::parts(relative);
const auto name = key(relative);
HANDLE directory = nullptr;
try { directory = parent(names); }
catch (const MissingParent&) {
if (operation == "cleanup") return "{\"ok\":true}";
if (operation == "read" || operation == "stat") return "{\"ok\":true,\"kind\":\"missing\"}";
throw;
}
const auto absolute = names.back();
const auto& nonce = fields[4];
if (operation == "write" || operation == "cleanup") {
if (nonce.size() != 32 || nonce.find_first_not_of("0123456789abcdef") != std::string::npos) throw std::runtime_error("Invalid transaction mutation identity");
}
const auto temporaryName = L".ycode-sandbox-" + std::wstring(nonce.begin(), nonce.end());
const auto temporary = temporaryName + L"-new.tmp";
const auto previous = temporaryName + L"-old.tmp";
if (operation == "cleanup") {
const auto separator = fields[2].find(':');
if (separator == std::string::npos) throw std::runtime_error("Invalid transaction cleanup hashes");
const std::pair<fs::path, std::string> candidates[] = {{previous, fields[2].substr(0, separator)}, {temporary, fields[2].substr(separator + 1)}};
for (const auto& candidate : candidates) {
Handle stale(openRelative(directory, candidate.first.wstring(), GENERIC_READ | DELETE, false));
if (stale.value == INVALID_HANDLE_VALUE && (GetLastError() == ERROR_FILE_NOT_FOUND || GetLastError() == ERROR_PATH_NOT_FOUND)) continue;
require(stale.value != INVALID_HANDLE_VALUE, "Open transaction recovery scratch");
validate(stale.value, (fs::path(relative).parent_path() / candidate.first.filename()).wstring(), false);
if (hash(contents(stale.value)) != candidate.second) throw std::runtime_error("Transaction scratch changed externally");
FILE_DISPOSITION_INFO disposition = {TRUE};
require(SetFileInformationByHandle(stale.value, FileDispositionInfo, &disposition, sizeof(disposition)) != FALSE, "Remove transaction recovery scratch");
}
return "{\"ok\":true}";
}
if (operation == "mkdir") {
Handle created(openRelative(directory, absolute, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, true, true));
require(created.value != INVALID_HANDLE_VALUE, "Create pinned transaction directory");
validate(created.value, relative, true);
const auto identity = workspace_read::identity(created.value);
directories[name] = std::make_unique<Handle>(created.value); created.value = nullptr;
known.erase(name);
return "{\"ok\":true,\"identity\":\"" + identity + "\"}";
}
if (operation == "rmdir") unpin(relative);
const bool mutation = operation == "write" || operation == "unlink" || operation == "rmdir";
Handle target(openRelative(directory, absolute, GENERIC_READ | (mutation ? DELETE : 0), operation == "rmdir"));
const DWORD error = target.value == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS;
const bool missing = error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND;
if (target.value == INVALID_HANDLE_VALUE && !missing) { SetLastError(error); require(false, "Open pinned transaction target"); }
if (operation == "read" || operation == "stat") {
if (missing) { known.erase(name); return "{\"ok\":true,\"kind\":\"missing\"}"; }
const bool isDirectory = (workspace_read::information(target.value).dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
validate(target.value, relative, isDirectory);
const auto identity = workspace_read::identity(target.value); known[name] = identity;
if (isDirectory && !directories.count(name)) {
if (directories.size() >= 10000) throw std::runtime_error("Transaction directory pin limit");
auto pinned = std::make_unique<Handle>(openRelative(directory, absolute, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, true));
require(pinned->value != INVALID_HANDLE_VALUE, "Pin inspected transaction directory");
validate(pinned->value, relative, true);
if (workspace_read::identity(pinned->value) != identity) throw std::runtime_error("Inspected transaction directory identity changed");
directories[name] = std::move(pinned);
}
if (operation == "read" && isDirectory) throw std::runtime_error("Transaction target is a directory");
std::string result = "{\"ok\":true,\"kind\":\"" + std::string(isDirectory ? "directory" : "file") + "\",\"identity\":\"" + identity + "\"";
if (operation == "read") result += ",\"data\":\"" + workspace_read::base64(contents(target.value)) + "\"";
return result + '}';
}
if (operation == "list") {
if (missing) throw std::runtime_error("Transaction directory missing");
validate(target.value, relative, true);
std::string result = "{\"ok\":true,\"entries\":["; bool first = true; size_t count = 0;
std::vector<BYTE> buffer(65536);
for (;;) {
if (!GetFileInformationByHandleEx(target.value, FileIdBothDirectoryInfo, buffer.data(), static_cast<DWORD>(buffer.size()))) {
if (GetLastError() == ERROR_NO_MORE_FILES) break;
require(false, "Enumerate transaction directory");
}
size_t offset = 0;
for (;;) {
const auto entry = reinterpret_cast<FILE_ID_BOTH_DIR_INFO*>(buffer.data() + offset);
if (entry->FileNameLength > buffer.size() - offset - offsetof(FILE_ID_BOTH_DIR_INFO, FileName)) throw std::runtime_error("Invalid transaction directory record");
const std::wstring child(entry->FileName, entry->FileNameLength / sizeof(wchar_t));
if (child != L"." && child != L"..") {
if (++count > 10000 || !workspace_read::component(child)) throw std::runtime_error("Transaction directory entry denied");
if (!first) result += ','; first = false;
result += "\"" + jsonText(child) + "\"";
}
if (entry->NextEntryOffset == 0) break;
if (entry->NextEntryOffset < sizeof(FILE_ID_BOTH_DIR_INFO) || entry->NextEntryOffset > buffer.size() - offset - sizeof(FILE_ID_BOTH_DIR_INFO)) throw std::runtime_error("Invalid transaction directory offset");
offset += entry->NextEntryOffset;
}
}
return result + "]}";
}
if (!mutation) throw std::runtime_error("Unknown transaction operation");
if (!missing) {
validate(target.value, relative, operation == "rmdir");
if (operation != "rmdir" && (workspace_read::information(target.value).dwFileAttributes & FILE_ATTRIBUTE_READONLY)) throw std::runtime_error("Transaction read-only file denied");
const auto identity = workspace_read::identity(target.value);
if (known.count(name) && known[name] != identity) throw std::runtime_error("Transaction target identity changed");
if (operation == "rmdir") {
if (fields[2] != identity) throw std::runtime_error("Transaction directory identity changed");
} else if (fields[2] != hash(contents(target.value))) throw std::runtime_error("Transaction contents changed");
} else if (operation != "write" || fields[2] != "-") throw std::runtime_error("Transaction target disappeared");
if (operation == "unlink" || operation == "rmdir") {
FILE_DISPOSITION_INFO disposition = {TRUE};
require(SetFileInformationByHandle(target.value, FileDispositionInfo, &disposition, sizeof(disposition)) != FALSE, "Delete pinned transaction target");
known.erase(name);
return "{\"ok\":true}";
}
const auto bytes = unhex(fields[3], fileLimit);
Handle output(openRelative(directory, temporary, GENERIC_WRITE | DELETE | FILE_READ_ATTRIBUTES, false, true));
require(output.value != INVALID_HANDLE_VALUE, "Create transaction replacement");
bool published = false, moved = false;
auto renameHandle = [directory](HANDLE handle, const fs::path& destination) {
const auto name = destination.wstring();
std::vector<BYTE> storage(offsetof(FILE_RENAME_INFO, FileName) + name.size() * sizeof(wchar_t));
auto rename = reinterpret_cast<FILE_RENAME_INFO*>(storage.data());
rename->ReplaceIfExists = FALSE;
rename->RootDirectory = directory; rename->FileNameLength = static_cast<DWORD>(name.size() * sizeof(wchar_t));
memcpy(rename->FileName, name.data(), rename->FileNameLength);
IO_STATUS_BLOCK status = {};
return nativeOk(nativeFunction<SetFileFn>("NtSetInformationFile")(handle, &status, rename, static_cast<ULONG>(storage.size()), static_cast<FILE_INFORMATION_CLASS>(10)));
};
try {
DWORD count = 0;
if (!bytes.empty()) require(WriteFile(output.value, bytes.data(), static_cast<DWORD>(bytes.size()), &count, nullptr) && count == bytes.size(), "Write transaction replacement");
require(FlushFileBuffers(output.value) != FALSE, "Flush transaction replacement");
validate(output.value, (fs::path(relative).parent_path() / fs::path(temporary).filename()).wstring(), false);
const auto replacementIdentity = workspace_read::identity(output.value);
// A no-overwrite handoff keeps the original handle exclusively pinned.
// A competing basename creation fails publication instead of losing data.
if (!missing) { require(renameHandle(target.value, previous), "Stage pinned transaction original"); moved = true; }
require(renameHandle(output.value, absolute), "Publish pinned transaction replacement");
published = true;
known[name] = replacementIdentity;
if (moved) {
FILE_DISPOSITION_INFO disposition = {TRUE};
require(SetFileInformationByHandle(target.value, FileDispositionInfo, &disposition, sizeof(disposition)) != FALSE, "Remove pinned transaction original");
}
} catch (...) {
FILE_DISPOSITION_INFO disposition = {TRUE};
if (!published) SetFileInformationByHandle(output.value, FileDispositionInfo, &disposition, sizeof(disposition));
if (moved && !published && !renameHandle(target.value, absolute)) throw std::runtime_error("Transaction handoff needs recovery; competing target preserved");
throw;
}
return "{\"ok\":true}";
}
};
static int run(const std::wstring& root, const std::string& expected, bool gitMetadata = false) {
if (gitMetadata) fileLimit = 128 * 1024 * 1024;
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Workspace transaction is host-only");
workspace_read::Lifetime lifetime(600000);
Session session(root, expected);
reply("{\"protocol\":1,\"rootIdentity\":\"" + session.root.rootIdentity + "\"}");
std::string line; size_t requests = 0; char buffer[16384]; DWORD count = 0;
while (ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer), &count, nullptr) && count) {
for (DWORD index = 0; index < count; ++index) {
if (buffer[index] != '\n') {
if (line.size() >= fileLimit * 2 + 16384) throw std::runtime_error("Transaction frame limit");
line += buffer[index]; continue;
}
if (++requests > 100000) throw std::runtime_error("Transaction request limit");
std::vector<std::string> fields; size_t start = 0;
for (;;) { const auto end = line.find('\t', start); fields.push_back(line.substr(start, end == std::string::npos ? end : end - start)); if (end == std::string::npos) break; start = end + 1; }
try { reply(session.execute(fields)); }
catch (const std::exception& error) {
std::string message;
for (char ch : std::string(error.what())) { if (ch == '\\' || ch == '"') message += '\\'; message += ch; }
reply("{\"ok\":false,\"error\":\"" + message + "\"}");
}
line.clear();
}
}
if (!line.empty()) throw std::runtime_error("Truncated transaction frame");
return 0;
}
}
+129
View File
@@ -0,0 +1,129 @@
#pragma once
#include <wincrypt.h>
#pragma comment(lib, "crypt32.lib")
namespace git_control {
static int ssh(int argc, wchar_t** argv) {
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Git SSH control is host-only");
BOOL inJob = FALSE; require(IsProcessInJob(GetCurrentProcess(), nullptr, &inJob) != FALSE && inJob, "Git SSH requires its control Job");
wchar_t module[32768] = {};
require(GetModuleFileNameW(nullptr, module, 32768) > 0, "Locate Git SSH control");
const auto tools = fs::path(module).parent_path(), root = tools.parent_path().parent_path(), executable = tools / L"ssh.exe";
for (const auto& path : {executable, root / L"ssh-config", root / L"ssh-key", root / L"known-hosts"}) nonReparse(path);
std::vector<std::wstring> args = {L"-F", (root / L"ssh-config").wstring(), L"-o", L"BatchMode=yes", L"-o", L"IdentitiesOnly=yes", L"-o", L"IdentityAgent=none",
L"-o", L"ProxyCommand=none", L"-o", L"ProxyJump=none", L"-o", L"CanonicalizeHostname=no", L"-o", L"ClearAllForwardings=yes", L"-o", L"PermitLocalCommand=no",
L"-o", L"StrictHostKeyChecking=yes", L"-o", L"UpdateHostKeys=no", L"-o", L"UserKnownHostsFile=" + (root / L"known-hosts").wstring(),
L"-o", L"GlobalKnownHostsFile=" + (root / L"ssh-config").wstring(), L"-i", (root / L"ssh-key").wstring()};
for (int index = 1; index < argc; ++index) args.push_back(argv[index]);
auto command = quote(executable.wstring()); for (const auto& arg : args) command += L" " + quote(arg);
if (command.size() > 32000) throw std::runtime_error("Git SSH argv exceeds limit");
Handle input, output, error;
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_INPUT_HANDLE), GetCurrentProcess(), &input.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Git SSH stdin");
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_OUTPUT_HANDLE), GetCurrentProcess(), &output.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Git SSH stdout");
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_ERROR_HANDLE), GetCurrentProcess(), &error.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Git SSH stderr");
AttributeList attributes; HANDLE handles[] = {input.value, output.value, error.value};
require(UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles, sizeof(handles), nullptr, nullptr) != FALSE, "Git SSH handle allowlist");
STARTUPINFOEXW startup = {}; startup.StartupInfo.cb = sizeof(startup); startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = input.value; startup.StartupInfo.hStdOutput = output.value; startup.StartupInfo.hStdError = error.value; startup.lpAttributeList = attributes.list;
PROCESS_INFORMATION process = {};
require(CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE, EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, nullptr, root.c_str(), &startup.StartupInfo, &process) != FALSE, "Create Git SSH process");
Handle child(process.hProcess), thread(process.hThread); WaitForSingleObject(child.value, INFINITE);
DWORD exit = 125; require(GetExitCodeProcess(child.value, &exit) != FALSE, "Read Git SSH exit"); return static_cast<int>(exit);
}
static int credential(bool protect) {
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Git credentials are host-only");
std::string input; char buffer[4096]; DWORD count = 0;
while (ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer), &count, nullptr) && count) {
input.append(buffer, count);
if (input.size() > 131072) throw std::runtime_error("Git credential exceeds limit");
}
auto bytes = workspace_write::unhex(input, 65536);
DATA_BLOB source = {static_cast<DWORD>(bytes.size()), bytes.data()}, output = {};
const BOOL result = protect ? CryptProtectData(&source, L"YCode Git credential", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &output)
: CryptUnprotectData(&source, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &output);
require(result != FALSE, "Protect/unprotect Git credential with user DPAPI");
const auto encoded = workspace_write::hex(output.pbData, output.cbData);
SecureZeroMemory(output.pbData, output.cbData); LocalFree(output.pbData);
SecureZeroMemory(bytes.data(), bytes.size());
workspace_write::reply("{\"data\":\"" + encoded + "\"}");
return 0;
}
static std::wstring text(const std::string& encoded) {
const auto bytes = workspace_write::unhex(encoded, 1024 * 1024);
if (bytes.empty()) return L"";
const int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, reinterpret_cast<const char*>(bytes.data()), static_cast<int>(bytes.size()), nullptr, 0);
if (count <= 0) throw std::runtime_error("Invalid Git control UTF-8");
std::wstring value(count, 0);
require(MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, reinterpret_cast<const char*>(bytes.data()), static_cast<int>(bytes.size()), value.data(), count) == count, "Decode Git control text");
if (value.find(L'\0') != std::wstring::npos) throw std::runtime_error("NUL in Git control text");
return value;
}
static std::vector<std::wstring> fields(const fs::path& path) {
nonReparse(path);
Handle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
require(file.value != INVALID_HANDLE_VALUE, "Open Git control fields");
const auto bytes = workspace_write::contents(file.value);
if (bytes.size() > 1024 * 1024) throw std::runtime_error("Git control fields exceed limit");
std::string input(bytes.begin(), bytes.end()); std::vector<std::wstring> result; size_t start = 0;
for (;;) {
const auto end = input.find('\n', start);
if (end == std::string::npos) break;
if (result.size() >= 256) throw std::runtime_error("Git control field count exceeds limit");
result.push_back(text(input.substr(start, end - start))); start = end + 1;
}
if (start != input.size()) throw std::runtime_error("Incomplete Git control fields");
return result;
}
static int run(const std::wstring& input, DWORD timeout) {
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Git control is host-only");
if (timeout < 100 || timeout > 600000) throw std::runtime_error("Invalid Git control timeout");
const fs::path stage = fs::absolute(input);
nonReparse(stage);
const auto executable = stage / L"tools/git/git.exe";
nonReparse(executable);
auto command = quote(executable.wstring());
for (const auto& arg : fields(stage / L"arguments")) command += L" " + quote(arg);
if (command.size() > 32000) throw std::runtime_error("Git control argv exceeds Windows limit");
auto environmentFields = fields(stage / L"environment");
std::sort(environmentFields.begin(), environmentFields.end());
std::vector<wchar_t> environment;
for (const auto& entry : environmentFields) {
const auto separator = entry.find(L'=');
if (!separator || separator == std::wstring::npos || entry.substr(0, separator).find_first_not_of(L"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") != std::wstring::npos) throw std::runtime_error("Invalid Git control environment");
environment.insert(environment.end(), entry.begin(), entry.end()); environment.push_back(0);
}
environment.push_back(0);
Handle parent(parentProcess()), job(CreateJobObjectW(nullptr, nullptr));
writeJson(stage / L"native-lease.json", "{\"version\":1,\"pid\":" + std::to_string(GetCurrentProcessId()) + ",\"root\":\"" + jsonText(stage.wstring()) + "\"}\n");
require(job.value != nullptr, "Create Git control Job");
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_JOB_MEMORY;
limits.BasicLimitInformation.ActiveProcessLimit = 32; limits.JobMemoryLimit = static_cast<SIZE_T>(1024) * 1024 * 1024;
require(SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) != FALSE, "Git control Job limits");
SECURITY_ATTRIBUTES inherited = {sizeof(inherited), nullptr, TRUE};
Handle stdinFile(CreateFileW((stage / L"input").c_str(), GENERIC_READ, FILE_SHARE_READ, &inherited, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
require(stdinFile.value != INVALID_HANDLE_VALUE, "Open Git control input");
Handle output, error;
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_OUTPUT_HANDLE), GetCurrentProcess(), &output.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Duplicate Git stdout");
require(DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_ERROR_HANDLE), GetCurrentProcess(), &error.value, 0, TRUE, DUPLICATE_SAME_ACCESS) != FALSE, "Duplicate Git stderr");
AttributeList attributes;
HANDLE handles[] = {stdinFile.value, output.value, error.value};
require(UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles, sizeof(handles), nullptr, nullptr) != FALSE, "Git control handle allowlist");
STARTUPINFOEXW startup = {}; startup.StartupInfo.cb = sizeof(startup); startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = stdinFile.value; startup.StartupInfo.hStdOutput = output.value; startup.StartupInfo.hStdError = error.value;
startup.lpAttributeList = attributes.list;
PROCESS_INFORMATION process = {};
require(CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE,
EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | CREATE_NO_WINDOW,
environment.data(), (stage / L"workspace").c_str(), &startup.StartupInfo, &process) != FALSE, "Create Git control process");
Handle child(process.hProcess), thread(process.hThread);
if (!AssignProcessToJobObject(job.value, child.value)) { TerminateProcess(child.value, 125); throw std::runtime_error("Assign Git control Job"); }
require(ResumeThread(thread.value) != static_cast<DWORD>(-1), "Resume Git control process");
HANDLE monitored[] = {child.value, parent.value};
const DWORD waited = WaitForMultipleObjects(2, monitored, FALSE, timeout);
DWORD exit = waited == WAIT_TIMEOUT ? 124 : 130;
if (waited == WAIT_OBJECT_0) require(GetExitCodeProcess(child.value, &exit) != FALSE, "Read Git control exit");
TerminateJobObject(job.value, exit); WaitForSingleObject(child.value, 5000);
return static_cast<int>(exit);
}
}
+134
View File
@@ -0,0 +1,134 @@
'use strict'
const fs = require('node:fs')
const { randomUUID, createHash } = require('node:crypto')
const prefix = '\x1eYCODE_WORKSPACE_READ_V1 '
let selected = false
function error(code, message) { return Object.assign(new Error(message), { code }) }
function frame(operation, args, id) { return '\n' + prefix + JSON.stringify({ version: 1, requestId: id, operation, args }) + '\n' }
function send(operation, args, id) {
const bytes = Buffer.from(frame(operation, args, id))
let offset = 0
while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset, bytes.length - offset)
}
function unpack(line, id) {
let response
try { response = JSON.parse(line) } catch { throw error('EPROTO', 'Invalid file bridge reply') }
if (response.version !== 1 || response.requestId !== id || typeof response.ok !== 'boolean') throw error('EPROTO', 'Unexpected file bridge reply')
if (!response.ok) throw error(response.error?.code || 'EACCES', response.error?.message || 'Workspace read denied')
return response.value
}
function claim() {
if (selected) throw error('EBUSY', 'Choose one bridge client and mode per process; stdin belongs to that client')
selected = true
}
function decode(part, initial, offset) {
if (!part || part.snapshotId !== initial.snapshotId || part.sha256 !== initial.sha256 || part.size !== initial.size || part.offset !== offset || typeof part.data !== 'string') throw error('EPROTO', 'Inconsistent read snapshot')
const bytes = Buffer.from(part.data, 'base64')
if (bytes.length > 16384 || !bytes.length && offset < initial.size || offset + bytes.length > initial.size || part.eof !== (offset + bytes.length === initial.size)) throw error('EPROTO', 'Invalid read chunk')
return bytes
}
function complete(chunks, initial, encoding) {
const bytes = Buffer.concat(chunks)
if (bytes.length !== initial.size || createHash('sha256').update(bytes).digest('hex') !== initial.sha256) throw error('EPROTO', 'Read snapshot digest mismatch')
return encoding ? bytes.toString(encoding) : bytes
}
function connectSync() {
claim()
let buffered = Buffer.alloc(0), closed = false
function call(operation, args, id = randomUUID()) {
if (closed) throw error('EPIPE', 'File bridge client closed')
send(operation, args, id)
for (;;) {
const end = buffered.indexOf(10)
if (end >= 0) {
const line = buffered.subarray(0, end).toString('utf8'); buffered = buffered.subarray(end + 1)
return unpack(line, id)
}
if (buffered.length > 32768) throw error('EPROTO', 'File bridge reply exceeds limit')
const bytes = Buffer.alloc(32768), length = fs.readSync(0, bytes, 0, bytes.length, null)
if (!length) { closed = true; throw error('EPIPE', 'File bridge disconnected') }
buffered = Buffer.concat([buffered, bytes.subarray(0, length)])
}
}
return {
call,
readFileSync(path, encoding) {
const initial = call('workspace.readFile', { path }), chunks = []
try {
let part = initial, offset = 0
for (;;) {
const bytes = decode(part, initial, offset); chunks.push(bytes); offset += bytes.length
if (part.eof) return complete(chunks, initial, encoding)
part = call('workspace.readChunk', { snapshotId: initial.snapshotId, offset })
}
} finally { call('workspace.closeRead', { snapshotId: initial.snapshotId }) }
},
statSync: path => call('workspace.stat', { path }),
readdirSync: path => call('workspace.readdir', { path }).entries,
querySync: requestId => call('operation.query', { requestId }),
close() { closed = true }
}
}
function connect() {
claim()
const waiting = new Map(), queue = []
let buffered = Buffer.alloc(0), closed = false, failure
function shutdown(reason) {
if (closed) return
closed = true; failure = reason
process.stdin.off('data', receive); process.stdin.off('end', ended); process.stdin.off('error', failed); process.stdin.pause()
process.stdin.destroy()
for (const item of [...waiting.values(), ...queue]) item.reject(reason)
waiting.clear(); queue.length = 0
}
function flush() {
while (!closed && waiting.size < 8 && queue.length) {
const item = queue.shift(); waiting.set(item.id, item)
try { send(item.operation, item.args, item.id) } catch (reason) { shutdown(reason) }
}
if (waiting.size) { process.stdin.ref?.(); process.stdin.resume() }
else { process.stdin.pause(); process.stdin.unref?.() }
}
function receive(bytes) {
buffered = Buffer.concat([buffered, bytes])
for (;;) {
const end = buffered.indexOf(10)
if (end < 0) { if (buffered.length > 32768) shutdown(error('EPROTO', 'File bridge reply exceeds limit')); return }
const line = buffered.subarray(0, end).toString('utf8'); buffered = buffered.subarray(end + 1)
let id
try { id = JSON.parse(line).requestId } catch { shutdown(error('EPROTO', 'Invalid file bridge reply')); return }
const item = waiting.get(id)
if (!item) { shutdown(error('EPROTO', 'Unexpected file bridge reply')); return }
waiting.delete(id)
try { item.resolve(unpack(line, id)) } catch (reason) { item.reject(reason) }
flush()
}
}
const ended = () => shutdown(error('EPIPE', 'File bridge disconnected'))
const failed = reason => shutdown(reason)
process.stdin.on('data', receive); process.stdin.on('end', ended); process.stdin.on('error', failed); process.stdin.pause()
function call(operation, args, id = randomUUID()) {
if (closed) return Promise.reject(failure || error('EPIPE', 'File bridge client closed'))
if (queue.length + waiting.size >= 32) return Promise.reject(error('EBUSY', 'File bridge client queue full'))
return new Promise((resolve, reject) => { queue.push({ id, operation, args, resolve, reject }); flush() })
}
return {
call,
async readFile(path, encoding) {
const initial = await call('workspace.readFile', { path }), chunks = []
try {
let part = initial, offset = 0
for (;;) {
const bytes = decode(part, initial, offset); chunks.push(bytes); offset += bytes.length
if (part.eof) return complete(chunks, initial, encoding)
part = await call('workspace.readChunk', { snapshotId: initial.snapshotId, offset })
}
} finally { await call('workspace.closeRead', { snapshotId: initial.snapshotId }) }
},
stat: path => call('workspace.stat', { path }),
readdir: async path => (await call('workspace.readdir', { path })).entries,
query: requestId => call('operation.query', { requestId }),
close() { shutdown(error('EPIPE', 'File bridge client closed')) }
}
}
module.exports = { connect, connectSync }
+70
View File
@@ -0,0 +1,70 @@
'use strict'
// Explicit preload for synchronous subprocess capture in Windows AppContainers.
// Every subprocess still uses Node's own spawn and inherits the existing token/Job.
const fs = require('node:fs')
const path = require('node:path')
const cp = require('node:child_process')
const { syncBuiltinESMExports } = require('node:module')
const originals = { spawnSync: cp.spawnSync, execFileSync: cp.execFileSync, execSync: cp.execSync }
const maximum = 16 * 1024 * 1024
function captured(method, file, args, options, spawn) {
if (options.stdio !== undefined && options.stdio !== 'pipe') return method()
const limit = options.maxBuffer === undefined ? 1024 * 1024 : options.maxBuffer
if (!Number.isSafeInteger(limit) || limit < 0 || limit > maximum) throw new RangeError('Sandbox process adapter maxBuffer must be between 0 and 16 MiB')
const input = options.input === undefined ? Buffer.alloc(0) : typeof options.input === 'string'
? Buffer.from(options.input, options.encoding && options.encoding !== 'buffer' ? options.encoding : 'utf8')
: ArrayBuffer.isView(options.input) ? Buffer.from(options.input.buffer, options.input.byteOffset, options.input.byteLength) : null
if (!input) throw new TypeError('Sandbox process adapter input must be a string, Buffer or ArrayBuffer view')
if (input.length > maximum) throw new RangeError('Sandbox process adapter input exceeds 16 MiB')
const directory = fs.mkdtempSync(path.join(process.env.TEMP, 'ycode-process-'))
const files = ['stdin', 'stdout', 'stderr'].map(name => path.join(directory, name)), handles = []
const encode = bytes => options.encoding && options.encoding !== 'buffer' ? bytes.toString(options.encoding) : bytes
let answer, failure
try {
fs.writeFileSync(files[0], input, { flag: 'wx', mode: 0o600 })
handles.push(fs.openSync(files[0], 'r'))
handles.push(fs.openSync(files[1], 'wx+'))
handles.push(fs.openSync(files[2], 'wx+'))
const forwarded = { ...options, stdio: handles }; delete forwarded.input
try { answer = method(forwarded) } catch (error) { failure = error }
const read = index => {
const buffer = Buffer.alloc(Math.min(fs.fstatSync(handles[index]).size, limit + 1))
let length = 0
while (length < buffer.length) { const count = fs.readSync(handles[index], buffer, length, buffer.length - length, length); if (!count) break; length += count }
return { bytes: buffer.subarray(0, Math.min(length, limit)), overflow: length > limit }
}
const out = read(1), err = read(2), stdout = encode(out.bytes), stderr = encode(err.bytes)
if (out.overflow || err.overflow) failure = Object.assign(new Error('Sandbox subprocess output exceeds maxBuffer'), { code: 'ENOBUFS', syscall: 'spawnSync', path: file, spawnargs: args })
if (spawn) {
if (failure && !answer) throw failure
return { ...answer, stdout, stderr, output: [null, stdout, stderr], ...(failure ? { error: failure } : {}) }
}
if (failure) {
failure.stdout = stdout; failure.stderr = stderr; failure.output = [null, stdout, stderr]
throw failure
}
return stdout
} finally {
for (const handle of handles) try { fs.closeSync(handle) } catch {}
for (const file of files) try { fs.unlinkSync(file) } catch {}
try { fs.rmdirSync(directory) } catch {}
}
}
function argumentsOf(args, options) {
return Array.isArray(args) ? { args, options: options || {} } : { args: [], options: args == null ? options || {} : args }
}
function spawnSync(file, args, options) {
const normalized = argumentsOf(args, options)
return captured(forwarded => originals.spawnSync(file, normalized.args, forwarded || normalized.options), file, normalized.args, normalized.options, true)
}
function execFileSync(file, args, options) {
const normalized = argumentsOf(args, options)
return captured(forwarded => originals.execFileSync(file, normalized.args, forwarded || normalized.options), file, normalized.args, normalized.options, false)
}
function execSync(command, options = {}) {
return captured(forwarded => originals.execSync(command, forwarded || options), command, [], options, false)
}
if (process.env.YCODE_SANDBOX !== 'appcontainer') throw new Error('The subprocess adapter is for the isolated YCode Node runtime')
Object.assign(cp, { spawnSync, execFileSync, execSync })
syncBuiltinESMExports()
module.exports = { spawnSync, execFileSync, execSync }
+247
View File
@@ -0,0 +1,247 @@
// Test-only executable. Never distributed as the sandbox runner.
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <winsock2.h>
#include <windows.h>
#include <userenv.h>
#include <sddl.h>
#include <cstdio>
#include <string>
#include <vector>
static bool registryId(const std::wstring& value) {
if (value.size() != 36 || value[8] != L'-' || value[13] != L'-' || value[18] != L'-' || value[23] != L'-') return false;
for (size_t index = 0; index < value.size(); ++index) {
if (index == 8 || index == 13 || index == 18 || index == 23) continue;
if ((value[index] < L'0' || value[index] > L'9') && (value[index] < L'a' || value[index] > L'f')) return false;
}
return true;
}
static std::wstring pipeName(const std::wstring& id) { return L"\\\\.\\pipe\\YCodeSandboxTest." + id; }
static int pipeServer(const std::wstring& id, const std::wstring& profile) {
if (!registryId(id) || profile.size() != 42 || profile.substr(0, 10) != L"YCode.Run." || profile.substr(10).find_first_not_of(L"0123456789abcdef") != std::wstring::npos) return 2;
PSID expected = nullptr;
if (FAILED(DeriveAppContainerSidFromAppContainerName(profile.c_str(), &expected))) return 29;
HANDLE token = nullptr; DWORD bytes = 0;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { FreeSid(expected); return 29; }
GetTokenInformation(token, TokenUser, nullptr, 0, &bytes); std::vector<BYTE> user(bytes);
const bool gotUser = GetTokenInformation(token, TokenUser, user.data(), bytes, &bytes) != FALSE; CloseHandle(token);
LPWSTR owner = nullptr, package = nullptr;
if (!gotUser || !ConvertSidToStringSidW(reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &owner) || !ConvertSidToStringSidW(expected, &package)) { if (owner) LocalFree(owner); FreeSid(expected); return 29; }
const std::wstring sddl = L"D:P(A;;GA;;;SY)(A;;GA;;;" + std::wstring(owner) + L")(A;;0x00120003;;;" + std::wstring(package) + L")S:(ML;;NW;;;LW)";
std::printf("PIPE_EXPECTED_SID %ls\n", package); std::fflush(stdout);
LocalFree(owner); LocalFree(package);
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr)) { FreeSid(expected); return 29; }
SECURITY_ATTRIBUTES security = {sizeof(security), descriptor, FALSE};
HANDLE pipe = CreateNamedPipeW(pipeName(id).c_str(), PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, 1, 4096, 4096, 5000, &security);
LocalFree(descriptor);
if (pipe == INVALID_HANDLE_VALUE) { FreeSid(expected); return 29; }
const bool connected = ConnectNamedPipe(pipe, nullptr) || GetLastError() == ERROR_PIPE_CONNECTED;
ULONG pid = 0; bool authenticated = false;
if (connected && GetNamedPipeClientProcessId(pipe, &pid)) {
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid), clientToken = nullptr;
if (process && OpenProcessToken(process, TOKEN_QUERY, &clientToken)) {
DWORD container = 0, needed = 0;
if (GetTokenInformation(clientToken, TokenIsAppContainer, &container, sizeof(container), &needed) && container) {
GetTokenInformation(clientToken, TokenAppContainerSid, nullptr, 0, &needed); std::vector<BYTE> data(needed);
if (GetTokenInformation(clientToken, TokenAppContainerSid, data.data(), needed, &needed)) authenticated = EqualSid(reinterpret_cast<TOKEN_APPCONTAINER_INFORMATION*>(data.data())->TokenAppContainer, expected) != FALSE;
}
CloseHandle(clientToken);
}
if (process) CloseHandle(process);
}
FreeSid(expected);
char input[8] = {}; DWORD read = 0, written = 0;
bool ok = authenticated && ReadFile(pipe, input, sizeof(input), &read, nullptr) && read == 4 && std::string(input, read) == "PING";
const char response[] = "BROKER_PONG";
if (ok) ok = WriteFile(pipe, response, sizeof(response) - 1, &written, nullptr) && written == sizeof(response) - 1;
if (ok) FlushFileBuffers(pipe);
DisconnectNamedPipe(pipe); CloseHandle(pipe);
std::printf("PIPE_%s\n", ok ? "AUTHENTICATED" : "REJECTED"); return ok ? 0 : 29;
}
static int pipeClient(const std::wstring& id) {
if (!registryId(id)) return 2;
HANDLE token = nullptr;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) {
DWORD bytes = 0; GetTokenInformation(token, TokenAppContainerSid, nullptr, 0, &bytes); std::vector<BYTE> data(bytes);
if (GetTokenInformation(token, TokenAppContainerSid, data.data(), bytes, &bytes)) {
const PSID sid = reinterpret_cast<TOKEN_APPCONTAINER_INFORMATION*>(data.data())->TokenAppContainer;
LPWSTR text = nullptr;
if (sid && ConvertSidToStringSidW(sid, &text)) { std::printf("PIPE_CLIENT_SID %ls\n", text); LocalFree(text); std::fflush(stdout); }
}
CloseHandle(token);
}
HANDLE pipe = INVALID_HANDLE_VALUE; const auto deadline = GetTickCount64() + 4000;
do {
pipe = CreateFileW(pipeName(id).c_str(), FILE_READ_DATA | FILE_WRITE_DATA | SYNCHRONIZE, 0, nullptr, OPEN_EXISTING, SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, nullptr);
if (pipe != INVALID_HANDLE_VALUE) break;
if (GetLastError() != ERROR_FILE_NOT_FOUND && GetLastError() != ERROR_PIPE_BUSY) { std::printf("PIPE_DENIED %lu\n", GetLastError()); return 29; }
Sleep(10);
} while (GetTickCount64() < deadline);
if (pipe == INVALID_HANDLE_VALUE) { std::printf("PIPE_UNAVAILABLE %lu\n", GetLastError()); return 29; }
DWORD written = 0, read = 0; char response[64] = {};
const bool ok = WriteFile(pipe, "PING", 4, &written, nullptr) && written == 4 && ReadFile(pipe, response, sizeof(response), &read, nullptr);
CloseHandle(pipe);
if (ok) std::fwrite(response, 1, read, stdout);
return ok && std::string(response, read) == "BROKER_PONG" ? 0 : 29;
}
static int stdioBroker(const std::wstring& mode, const std::wstring& wideId) {
if (!registryId(wideId)) return 2;
std::string id;
for (wchar_t ch : wideId) id += static_cast<char>(ch);
if (mode == L"--broker-oversize") {
const std::string frame = "\x1eYCODE_BROKER_V1 " + std::string(20000, 'x') + "\n";
std::fwrite(frame.data(), 1, frame.size(), stdout); std::fflush(stdout); Sleep(5000); return 30;
}
if (mode == L"--broker-flood") {
for (char index = '0'; index < '9'; ++index) {
auto current = id; current.back() = index;
std::printf("\x1eYCODE_BROKER_V1 {\"requestId\":\"%s\",\"operation\":\"echo\",\"payload\":\"probe-echo\"}\n", current.c_str());
}
std::fflush(stdout); Sleep(5000); return 30;
}
for (int attempt = 0; attempt < (mode == L"--broker-replay" ? 2 : 1); ++attempt) {
std::string frame = "\x1eYCODE_BROKER_V1 {\"requestId\":\"" + id + "\",\"operation\":\"" +
(mode == L"--broker-deny" ? "file_read" : "echo") + "\",\"payload\":\"probe-echo\"";
if (mode == L"--broker-spoof") frame += ",\"runId\":\"unrelated-run\"";
frame += "}\n"; std::fwrite(frame.data(), 1, frame.size(), stdout); std::fflush(stdout);
std::string reply; char buffer[1024] = {}; DWORD read = 0;
while (reply.find('\n') == std::string::npos && reply.size() < 16384 && ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer), &read, nullptr) && read) reply.append(buffer, read);
const bool rejected = mode == L"--broker-deny" || mode == L"--broker-spoof";
if (reply.find("\"requestId\":\"" + id + "\"") == std::string::npos || reply.find(rejected ? "\"ok\":false" : "\"ok\":true") == std::string::npos) return 30;
}
std::puts(mode == L"--broker-deny" || mode == L"--broker-spoof" ? "BROKER_DENIAL_RECEIVED" : "BROKER_ECHO_RECEIVED"); return 0;
}
static int registryProbe(const std::wstring& mode, const std::wstring& id) {
if (!registryId(id)) return 2;
const std::wstring path = L"Software\\YCodeSandboxTests\\" + id;
if (mode == L"--registry-delete") {
const LSTATUS result = RegDeleteTreeW(HKEY_CURRENT_USER, path.c_str());
std::printf("REG_DELETE %ld\n", result);
RegDeleteKeyW(HKEY_CURRENT_USER, L"Software\\YCodeSandboxTests");
return result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND ? 0 : 27;
}
HKEY key = nullptr;
const bool read = mode == L"--registry-read";
const LSTATUS opened = read ? RegOpenKeyExW(HKEY_CURRENT_USER, path.c_str(), 0, KEY_QUERY_VALUE, &key)
: RegCreateKeyExW(HKEY_CURRENT_USER, path.c_str(), 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key, nullptr);
if (opened != ERROR_SUCCESS) { std::printf("REG_OPEN_DENIED %ld\n", opened); return 27; }
DWORD value = mode == L"--registry-create" ? 0x123456 : 99, bytes = sizeof(value), type = 0;
const LSTATUS result = read ? RegQueryValueExW(key, L"fixture", nullptr, &type, reinterpret_cast<BYTE*>(&value), &bytes)
: RegSetValueExW(key, L"fixture", 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value));
RegCloseKey(key);
if (read) std::printf("REG_READ %ld %lu\n", result, value);
else std::printf("REG_WRITE %ld\n", result);
return result == ERROR_SUCCESS ? 0 : 27;
}
static std::wstring quoted(const std::wstring& value) { return L"\"" + value + L"\""; }
static int writeFile(const wchar_t* path) {
HANDLE file = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE) { std::printf("WRITE_DENIED %lu\n", GetLastError()); return 23; }
const char text[] = "native-probe"; DWORD written = 0;
const bool ok = WriteFile(file, text, sizeof(text) - 1, &written, nullptr) != FALSE;
CloseHandle(file); return ok ? 0 : 23;
}
static int spawnChild(const std::wstring& arguments, bool wait, DWORD flags = 0) {
wchar_t executable[32768] = {};
if (!GetModuleFileNameW(nullptr, executable, 32768)) return 25;
std::wstring command = quoted(executable) + L" " + arguments;
STARTUPINFOW startup = {}; startup.cb = sizeof(startup);
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE); startup.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); startup.hStdError = GetStdHandle(STD_ERROR_HANDLE);
PROCESS_INFORMATION process = {};
if (!CreateProcessW(executable, command.data(), nullptr, nullptr, TRUE, flags, nullptr, nullptr, &startup, &process)) {
std::printf("SPAWN_DENIED %lu\n", GetLastError()); return 25;
}
CloseHandle(process.hThread);
DWORD exit = 0;
if (wait) { WaitForSingleObject(process.hProcess, 10000); GetExitCodeProcess(process.hProcess, &exit); }
CloseHandle(process.hProcess); return static_cast<int>(exit);
}
int wmain(int argc, wchar_t** argv) {
if (argc < 2) return 2;
const std::wstring mode = argv[1];
if (mode.rfind(L"--broker-", 0) == 0 && argc == 3) return stdioBroker(mode, argv[2]);
if (mode == L"--pipe-server" && argc == 4) return pipeServer(argv[2], argv[3]);
if (mode == L"--pipe-client" && argc == 3) return pipeClient(argv[2]);
if (mode == L"--registry-create" || mode == L"--registry-read" || mode == L"--registry-write" || mode == L"--registry-delete") return argc == 3 ? registryProbe(mode, argv[2]) : 2;
if (mode == L"--hold-directory" && argc == 5) {
HANDLE directory = CreateFileW(argv[2], FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
if (directory == INVALID_HANDLE_VALUE) return 28;
FILE_ATTRIBUTE_TAG_INFO tag = {};
if (!GetFileInformationByHandleEx(directory, FileAttributeTagInfo, &tag, sizeof(tag)) || (tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { CloseHandle(directory); return 28; }
if (writeFile(argv[3])) { CloseHandle(directory); return 28; }
const ULONGLONG deadline = GetTickCount64() + 5000;
while (GetFileAttributesW(argv[4]) == INVALID_FILE_ATTRIBUTES && GetTickCount64() < deadline) Sleep(10);
CloseHandle(directory); return 0;
}
if (mode == L"--host-facts") {
HANDLE token = nullptr; TOKEN_ELEVATION elevation = {}; DWORD bytes = 0, container = 0;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return 3;
const bool elevated = GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &bytes) && elevation.TokenIsElevated;
GetTokenInformation(token, TokenIsAppContainer, &container, sizeof(container), &bytes); CloseHandle(token);
std::printf("{\"elevated\":%s,\"appContainer\":%s}\n", elevated ? "true" : "false", container ? "true" : "false"); return 0;
}
if (mode == L"--info") {
HANDLE token = nullptr; DWORD container = 0, bytes = 0; BOOL job = FALSE;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return 3;
GetTokenInformation(token, TokenIsAppContainer, &container, sizeof(container), &bytes); CloseHandle(token);
IsProcessInJob(GetCurrentProcess(), nullptr, &job);
wchar_t secret[128] = {};
const bool inherited = GetEnvironmentVariableW(L"YCODE_FAKE_SECRET", secret, 128) != 0;
std::printf("{\"appContainer\":%s,\"job\":%s,\"secretInherited\":%s}\n", container ? "true" : "false", job ? "true" : "false", inherited ? "true" : "false");
return container && job && !inherited ? 0 : 4;
}
if (mode == L"--write" && argc == 3) return writeFile(argv[2]);
if (mode == L"--delete" && argc == 3) {
const BOOL deleted = DeleteFileW(argv[2]);
std::printf("DELETE_%s %lu\n", deleted ? "OK" : "DENIED", deleted ? 0 : GetLastError());
return deleted ? 0 : 26;
}
if (mode == L"--paths") {
wchar_t cwd[32768] = {}, full[32768] = {};
GetCurrentDirectoryW(32768, cwd); GetFullPathNameW(L".", 32768, full, nullptr);
std::wprintf(L"CWD=%ls FULL=%ls\n", cwd, full);
const DWORD attributes = GetFileAttributesW(L".");
std::printf("ATTR=%lu ERROR=%lu\n", attributes, GetLastError());
WIN32_FIND_DATAW data = {};
HANDLE found = FindFirstFileW(L".\\*", &data);
std::printf("FIND=%s ERROR=%lu\n", found == INVALID_HANDLE_VALUE ? "DENIED" : "OK", GetLastError());
if (found != INVALID_HANDLE_VALUE) FindClose(found);
return 0;
}
if (mode == L"--read" && argc == 3) {
HANDLE file = CreateFileW(argv[2], GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
if (file == INVALID_HANDLE_VALUE) { std::printf("READ_DENIED %lu\n", GetLastError()); return 22; }
char buffer[256] = {}; DWORD bytes = 0;
const bool ok = ReadFile(file, buffer, sizeof(buffer), &bytes, nullptr) != FALSE; CloseHandle(file);
if (ok) std::fwrite(buffer, 1, bytes, stdout);
return ok ? 0 : 22;
}
if (mode == L"--tcp" && argc == 3) {
WSADATA data = {}; if (WSAStartup(MAKEWORD(2, 2), &data)) return 24;
SOCKET socket = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (socket == INVALID_SOCKET) { WSACleanup(); return 24; }
sockaddr_in target = {}; target.sin_family = AF_INET; target.sin_port = htons(static_cast<u_short>(_wtoi(argv[2]))); target.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
u_long nonblocking = 1; ioctlsocket(socket, FIONBIO, &nonblocking);
int connected = connect(socket, reinterpret_cast<sockaddr*>(&target), sizeof(target));
int error = connected == 0 ? 0 : WSAGetLastError();
if (connected != 0 && error == WSAEWOULDBLOCK) {
fd_set ready, failed; FD_ZERO(&ready); FD_ZERO(&failed); FD_SET(socket, &ready); FD_SET(socket, &failed);
timeval timeout = {1, 0};
if (select(0, nullptr, &ready, &failed, &timeout) > 0) {
int length = sizeof(error); getsockopt(socket, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&error), &length);
connected = error == 0 ? 0 : SOCKET_ERROR;
} else { connected = SOCKET_ERROR; error = WSAETIMEDOUT; }
}
std::printf("TCP_%s %d\n", connected == 0 ? "CONNECTED" : "DENIED", error);
closesocket(socket); WSACleanup(); return connected == 0 ? 0 : 24;
}
if (mode == L"--delay" && argc == 4) { Sleep(static_cast<DWORD>(_wtoi(argv[2]))); return writeFile(argv[3]); }
if (mode == L"--spawn-write" && argc == 3) return spawnChild(L"--write " + quoted(argv[2]), true);
if (mode == L"--spawn-delayed" && argc == 3) return spawnChild(L"--delay 1400 " + quoted(argv[2]), false);
if (mode == L"--breakaway" && argc == 3) return spawnChild(L"--write " + quoted(argv[2]), true, CREATE_BREAKAWAY_FROM_JOB);
return 2;
}
+23
View File
@@ -0,0 +1,23 @@
"""Keep private Windows temporary directories accessible to this AppContainer.
Python 3.13+ gives mode 0o700 an explicit user/admin DACL on Windows. That
omits the AppContainer SID. Inside our stage, inherit the native private parent
DACL instead. Kernel permissions and the Job remain the security boundary.
"""
import os
if os.name == "nt" and os.environ.get("YCODE_SANDBOX") == "appcontainer":
_mkdir = os.mkdir
_stage = os.path.normcase(os.path.abspath(os.environ["YCODE_SANDBOX_ROOT"]))
def _private_mkdir(path, mode=0o777, *, dir_fd=None):
if mode == 0o700 and dir_fd is None:
target = os.path.normcase(os.path.abspath(os.fsdecode(path)))
try:
if os.path.commonpath((_stage, target)) == _stage:
mode = 0o777
except ValueError:
pass
return _mkdir(path, mode, dir_fd=dir_fd)
os.mkdir = _private_mkdir
+444
View File
@@ -0,0 +1,444 @@
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#include <userenv.h>
#include <sddl.h>
#include <aclapi.h>
#include <tlhelp32.h>
#include <algorithm>
#include <cstdio>
#include <filesystem>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
namespace fs = std::filesystem;
struct Handle {
HANDLE value = nullptr;
Handle() = default;
explicit Handle(HANDLE handle) : value(handle) {}
~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); }
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
};
static void require(bool ok, const char* operation) {
if (!ok) throw std::runtime_error(std::string(operation) + ": win32=" + std::to_string(GetLastError()));
}
static void security(DWORD error, const char* operation) {
if (error != ERROR_SUCCESS) throw std::runtime_error(std::string(operation) + ": win32=" + std::to_string(error));
}
static bool inContainer(HANDLE process) {
Handle token;
require(OpenProcessToken(process, TOKEN_QUERY, &token.value) != FALSE, "OpenProcessToken");
DWORD value = 0, bytes = 0;
require(GetTokenInformation(token.value, TokenIsAppContainer, &value, sizeof(value), &bytes) != FALSE, "TokenIsAppContainer");
return value != 0;
}
static bool matchesContainer(HANDLE process, PSID expected) {
Handle token;
require(OpenProcessToken(process, TOKEN_QUERY, &token.value) != FALSE, "Open child token");
DWORD bytes = 0; GetTokenInformation(token.value, TokenAppContainerSid, nullptr, 0, &bytes);
std::vector<BYTE> buffer(bytes);
require(GetTokenInformation(token.value, TokenAppContainerSid, buffer.data(), bytes, &bytes) != FALSE, "Child AppContainer SID");
return EqualSid(reinterpret_cast<TOKEN_APPCONTAINER_INFORMATION*>(buffer.data())->TokenAppContainer, expected) != FALSE;
}
static std::wstring quote(const std::wstring& value) {
std::wstring result = L"\"";
size_t slashes = 0;
for (wchar_t ch : value) {
if (ch == L'\\') { ++slashes; continue; }
if (ch == L'\"') result.append(slashes * 2 + 1, L'\\');
else result.append(slashes, L'\\');
slashes = 0;
result += ch;
}
result.append(slashes * 2, L'\\');
return result + L"\"";
}
static std::string jsonText(const std::wstring& value) {
const int count = WideCharToMultiByte(CP_UTF8, 0, value.c_str(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
std::string bytes(count, 0), escaped;
WideCharToMultiByte(CP_UTF8, 0, value.c_str(), static_cast<int>(value.size()), bytes.data(), count, nullptr, nullptr);
for (char ch : bytes) { if (ch == '\\' || ch == '"') escaped += '\\'; escaped += ch; }
return escaped;
}
static void writeJson(const fs::path& target, const std::string& json) {
Handle output(CreateFileW(target.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr));
require(output.value != INVALID_HANDLE_VALUE, "Create control report");
DWORD written = 0;
require(WriteFile(output.value, json.data(), static_cast<DWORD>(json.size()), &written, nullptr) && written == json.size(), "Write control report");
require(FlushFileBuffers(output.value) != FALSE, "Flush control report");
}
static std::wstring windowsDirectory() {
wchar_t buffer[MAX_PATH] = {};
require(GetWindowsDirectoryW(buffer, MAX_PATH) != 0, "GetWindowsDirectory");
return buffer;
}
static void nonReparse(const fs::path& target) {
auto current = target;
for (;;) {
const DWORD attributes = GetFileAttributesW(current.c_str());
require(attributes != INVALID_FILE_ATTRIBUTES, "GetFileAttributes");
if (attributes & FILE_ATTRIBUTE_REPARSE_POINT) throw std::runtime_error("Reparse point in sandbox control path");
const auto parent = current.parent_path();
if (parent == current || parent.empty()) break;
current = parent;
}
}
static void privateDirectory(const fs::path& path) {
Handle token;
require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token.value) != FALSE, "OpenProcessToken");
DWORD bytes = 0;
GetTokenInformation(token.value, TokenUser, nullptr, 0, &bytes);
std::vector<BYTE> user(bytes);
require(GetTokenInformation(token.value, TokenUser, user.data(), bytes, &bytes) != FALSE, "TokenUser");
LPWSTR sid = nullptr;
require(ConvertSidToStringSidW(reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid) != FALSE, "ConvertSidToStringSid");
const std::wstring sddl = L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;" + std::wstring(sid) + L")";
LocalFree(sid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
require(ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr) != FALSE, "Private DACL");
PACL acl = nullptr; BOOL present = FALSE, defaulted = FALSE;
require(GetSecurityDescriptorDacl(descriptor, &present, &acl, &defaulted) != FALSE, "GetSecurityDescriptorDacl");
const DWORD error = SetNamedSecurityInfoW(const_cast<LPWSTR>(path.c_str()), SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, acl, nullptr);
LocalFree(descriptor);
security(error, "Set private control DACL");
}
static void grantStage(const fs::path& target, PSID sid, bool writable = true) {
if (!writable) privateDirectory(target);
PSECURITY_DESCRIPTOR old = nullptr;
PACL acl = nullptr, next = nullptr;
security(GetNamedSecurityInfoW(target.c_str(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, &acl, nullptr, &old), "Read stage DACL");
EXPLICIT_ACCESSW access = {};
access.grfAccessPermissions = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE;
if (writable) access.grfAccessPermissions |= FILE_GENERIC_WRITE | DELETE;
access.grfAccessMode = GRANT_ACCESS;
access.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
access.Trustee.TrusteeForm = TRUSTEE_IS_SID;
access.Trustee.TrusteeType = TRUSTEE_IS_UNKNOWN;
access.Trustee.ptstrName = static_cast<LPWSTR>(sid);
const DWORD combined = SetEntriesInAclW(1, &access, acl, &next);
if (combined != ERROR_SUCCESS) { LocalFree(old); security(combined, "Build stage DACL"); }
const DWORD applied = SetNamedSecurityInfoW(const_cast<LPWSTR>(target.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, next, nullptr);
LocalFree(next); LocalFree(old);
security(applied, "Grant stage DACL");
PSECURITY_DESCRIPTOR label = nullptr;
require(ConvertStringSecurityDescriptorToSecurityDescriptorW(L"S:(ML;OICI;NW;;;LW)", SDDL_REVISION_1, &label, nullptr) != FALSE, "Stage integrity label");
PACL sacl = nullptr; BOOL present = FALSE, defaulted = FALSE;
require(GetSecurityDescriptorSacl(label, &present, &sacl, &defaulted) != FALSE, "GetSecurityDescriptorSacl");
const DWORD labeled = SetNamedSecurityInfoW(const_cast<LPWSTR>(target.c_str()), SE_FILE_OBJECT, LABEL_SECURITY_INFORMATION, nullptr, nullptr, nullptr, sacl);
LocalFree(label);
security(labeled, "Set stage integrity label");
}
struct Profile {
std::wstring name;
PSID sid = nullptr;
explicit Profile(std::wstring value) : name(std::move(value)) {
if (name.size() != 42 || name.substr(0, 10) != L"YCode.Run." ||
name.substr(10).find_first_not_of(L"0123456789abcdef") != std::wstring::npos) throw std::runtime_error("Invalid profile identity");
const HRESULT hr = CreateAppContainerProfile(name.c_str(), L"YCode isolated command", L"YCode per-command workspace snapshot", nullptr, 0, &sid);
if (FAILED(hr)) throw std::runtime_error("CreateAppContainerProfile: hresult=" + std::to_string(static_cast<unsigned long>(hr)));
}
bool remove() {
if (!sid) return true;
FreeSid(sid); sid = nullptr;
return SUCCEEDED(DeleteAppContainerProfile(name.c_str()));
}
~Profile() { remove(); }
};
static bool validProfile(const std::wstring& name) {
return name.size() == 42 && name.substr(0, 10) == L"YCode.Run." && name.substr(10).find_first_not_of(L"0123456789abcdef") == std::wstring::npos;
}
static HANDLE parentProcess() {
Handle snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0));
require(snapshot.value != INVALID_HANDLE_VALUE, "CreateToolhelp32Snapshot");
PROCESSENTRY32W entry = {}; entry.dwSize = sizeof(entry);
require(Process32FirstW(snapshot.value, &entry) != FALSE, "Process32First");
do {
if (entry.th32ProcessID == GetCurrentProcessId()) {
HANDLE parent = OpenProcess(SYNCHRONIZE, FALSE, entry.th32ParentProcessID);
require(parent != nullptr, "Open parent monitor");
return parent;
}
} while (Process32NextW(snapshot.value, &entry));
throw std::runtime_error("Parent process identity unavailable");
}
struct Drive {
std::wstring name, target;
explicit Drive(const fs::path& stage) : target(L"\\??\\" + stage.wstring()) {
Handle mutex(CreateMutexW(nullptr, FALSE, L"Local\\YCode.Sandbox.DriveAllocator.v1"));
require(mutex.value != nullptr, "Create drive allocator mutex");
const DWORD lock = WaitForSingleObject(mutex.value, 5000);
if (lock != WAIT_OBJECT_0 && lock != WAIT_ABANDONED) throw std::runtime_error("Drive allocator busy");
for (wchar_t letter = L'Z'; letter >= L'D'; --letter) {
const std::wstring candidate = std::wstring(1, letter) + L":";
wchar_t existing[32768] = {};
if (QueryDosDeviceW(candidate.c_str(), existing, 32768)) continue;
if (DefineDosDeviceW(DDD_RAW_TARGET_PATH | DDD_NO_BROADCAST_SYSTEM, candidate.c_str(), target.c_str())) { name = candidate; break; }
}
ReleaseMutex(mutex.value);
if (name.empty()) throw std::runtime_error("No free drive for sandbox workspace");
}
bool release() {
if (name.empty()) return true;
const bool removed = DefineDosDeviceW(DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE | DDD_RAW_TARGET_PATH | DDD_NO_BROADCAST_SYSTEM, name.c_str(), target.c_str()) != FALSE;
if (removed) name.clear();
return removed;
}
~Drive() { release(); }
fs::path root() const { return fs::path(name + L"\\"); }
};
static std::vector<wchar_t> childEnvironment(const fs::path& stage) {
const auto win = windowsDirectory();
std::vector<std::wstring> entries = {
L"=" + stage.root_name().wstring() + L"=" + (stage / L"workspace").wstring(),
L"APPDATA=" + (stage / L"home/AppData/Roaming").wstring(), L"COMSPEC=" + win + L"\\System32\\cmd.exe",
L"HOME=" + (stage / L"home").wstring(), L"LOCALAPPDATA=" + (stage / L"home/AppData/Local").wstring(),
L"PATH=" + (stage / L"tools/node").wstring() + L";" + (stage / L"tools/python").wstring() + L";" + (stage / L"tools/git").wstring() + L";" + (stage / L"tools").wstring() + L";" + win + L"\\System32;" + win + L"\\System32\\WindowsPowerShell\\v1.0",
L"NODE_REPL_HISTORY=" + (stage / L"home/node_repl_history").wstring(),
L"NPM_CONFIG_CACHE=" + (stage / L"home/npm-cache").wstring(), L"NPM_CONFIG_OFFLINE=true",
L"NPM_CONFIG_USERCONFIG=" + (stage / L"home/npmrc").wstring(),
L"PYTHONHOME=" + (stage / L"tools/python").wstring(), L"PYTHONNOUSERSITE=1", L"PYTHONDONTWRITEBYTECODE=1",
L"PIP_CONFIG_FILE=NUL", L"PIP_NO_INPUT=1", L"PIP_NO_INDEX=1", L"PIP_DISABLE_PIP_VERSION_CHECK=1",
L"PIP_CACHE_DIR=" + (stage / L"home/pip-cache").wstring(),
L"PYTHONPATH=" + (stage / L"tools/python-adapter").wstring() + L";" + (stage / L"home/python-site").wstring(),
L"GIT_CONFIG_NOSYSTEM=1", L"GIT_CONFIG_GLOBAL=NUL", L"GIT_CONFIG_SYSTEM=NUL", L"GIT_TERMINAL_PROMPT=0",
L"GIT_OPTIONAL_LOCKS=0", L"GIT_PAGER=", L"GIT_EDITOR=" + (stage / L"tools/ycode-git-editor.exe").generic_wstring(), L"GIT_SEQUENCE_EDITOR=" + (stage / L"tools/ycode-git-editor.exe").generic_wstring(), L"GIT_ALLOW_PROTOCOL=",
L"GIT_EXEC_PATH=" + (stage / L"tools/git/empty-hooks").wstring(), L"GIT_TEMPLATE_DIR=" + (stage / L"tools/git/empty-template").wstring(),
L"GIT_CONFIG_COUNT=7", L"GIT_CONFIG_KEY_0=core.hooksPath", L"GIT_CONFIG_VALUE_0=" + (stage / L"tools/git/empty-hooks").wstring(),
L"GIT_CONFIG_KEY_1=core.fsmonitor", L"GIT_CONFIG_VALUE_1=false", L"GIT_CONFIG_KEY_2=credential.helper", L"GIT_CONFIG_VALUE_2=",
L"GIT_CONFIG_KEY_3=commit.gpgSign", L"GIT_CONFIG_VALUE_3=false", L"GIT_CONFIG_KEY_4=tag.gpgSign", L"GIT_CONFIG_VALUE_4=false",
L"GIT_CONFIG_KEY_5=maintenance.auto", L"GIT_CONFIG_VALUE_5=false",
L"GIT_CONFIG_KEY_6=diff.autoRefreshIndex", L"GIT_CONFIG_VALUE_6=false",
L"PATHEXT=.COM;.EXE;.BAT;.CMD", L"SystemRoot=" + win, L"TEMP=" + (stage / L"tmp").wstring(),
L"TMP=" + (stage / L"tmp").wstring(), L"USERPROFILE=" + (stage / L"home").wstring(),
L"WINDIR=" + win, L"YCODE_SANDBOX=appcontainer", L"YCODE_SANDBOX_BRIDGE=" + (stage / L"tools/ycode-bridge.cjs").wstring(),
L"YCODE_SANDBOX_PROCESS=" + (stage / L"tools/ycode-process.cjs").wstring(), L"YCODE_SANDBOX_ROOT=" + stage.wstring()
};
if (fs::is_directory(stage / L"repository")) {
entries.push_back(L"GIT_DIR=" + (stage / L"repository").wstring());
entries.push_back(L"GIT_WORK_TREE=" + (stage / L"workspace").wstring());
}
std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) { return _wcsicmp(a.c_str(), b.c_str()) < 0; });
std::vector<wchar_t> block;
for (const auto& entry : entries) { block.insert(block.end(), entry.begin(), entry.end()); block.push_back(0); }
block.push_back(0);
return block;
}
static void forward(HANDLE input, HANDLE output) {
char buffer[16384]; DWORD read = 0;
while (ReadFile(input, buffer, sizeof(buffer), &read, nullptr) && read) {
DWORD offset = 0;
while (offset < read) {
DWORD written = 0;
if (!WriteFile(output, buffer + offset, read - offset, &written, nullptr) || !written) return;
offset += written;
}
}
}
static bool brokerInput(const std::string& line, std::string& decoded) {
if (line.size() > 65536 || line.rfind("input:", 0) != 0) return false;
const std::string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const auto encoded = line.substr(6);
if (encoded.empty() || encoded.size() % 4) return false;
unsigned int accumulator = 0; int bits = 0; bool padding = false; size_t paddingCount = 0;
for (size_t index = 0; index < encoded.size(); ++index) {
const char ch = encoded[index];
if (ch == '=') { padding = true; if (++paddingCount > 2 || index < encoded.size() - 2) return false; continue; }
const auto value = alphabet.find(ch);
if (padding || value == std::string::npos) return false;
accumulator = (accumulator << 6) | static_cast<unsigned int>(value); bits += 6;
if (bits >= 8) { bits -= 8; decoded += static_cast<char>((accumulator >> bits) & 255); }
}
return decoded.size() <= 32768 && bits == static_cast<int>(paddingCount) * 2 && (bits == 0 || (accumulator & ((1U << bits) - 1)) == 0);
}
struct AttributeList {
std::vector<BYTE> bytes;
LPPROC_THREAD_ATTRIBUTE_LIST list = nullptr;
AttributeList() {
SIZE_T size = 0; InitializeProcThreadAttributeList(nullptr, 2, 0, &size);
bytes.resize(size); list = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(bytes.data());
require(InitializeProcThreadAttributeList(list, 2, 0, &size) != FALSE, "InitializeProcThreadAttributeList");
}
~AttributeList() { if (list) DeleteProcThreadAttributeList(list); }
};
namespace git_control { static std::vector<std::wstring> fields(const fs::path& path); }
static int run(const std::wstring& name, const fs::path& stage, const fs::path& report, DWORD timeout, bool broker = false) {
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Runner cannot broker from inside an AppContainer");
if (!stage.is_absolute() || report.parent_path() != stage.parent_path() || report.filename() != L"result.json" ||
stage.filename() != L"stage" || fs::exists(report)) throw std::runtime_error("Invalid sandbox control layout");
nonReparse(stage);
privateDirectory(stage.parent_path());
Profile profile(name);
// Windows derives the AppContainer TEMP path from LOCALAPPDATA even when TEMP
// was supplied explicitly. Provision that private path before applying ACLs.
fs::create_directories(stage / L"home/AppData/Local/Packages" / name / L"AC/Temp");
grantStage(stage, profile.sid);
for (const auto& entry : fs::recursive_directory_iterator(stage)) {
if (GetFileAttributesW(entry.path().c_str()) & FILE_ATTRIBUTE_REPARSE_POINT) throw std::runtime_error("Reparse point in stage");
const auto relative = entry.path().lexically_relative(stage).generic_wstring();
const bool tool = relative == L"tools" || relative.rfind(L"tools/", 0) == 0 ||
relative == L"workspace/node_modules" || relative.rfind(L"workspace/node_modules/", 0) == 0 ||
relative == L"home/python-site" || relative.rfind(L"home/python-site/", 0) == 0;
grantStage(entry.path(), profile.sid, !tool);
}
Drive drive(stage);
writeJson(stage.parent_path() / L"lease.json", "{\"version\":1,\"pid\":" + std::to_string(GetCurrentProcessId()) +
",\"profile\":\"" + jsonText(name) + "\",\"drive\":\"" + jsonText(drive.name) + "\",\"stage\":\"" + jsonText(stage.wstring()) + "\"}\n");
Handle parent(parentProcess());
Handle job(CreateJobObjectW(nullptr, nullptr)); require(job.value != nullptr, "CreateJobObject");
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_JOB_MEMORY;
limits.BasicLimitInformation.ActiveProcessLimit = 32;
limits.JobMemoryLimit = static_cast<SIZE_T>(1024) * 1024 * 1024;
require(SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) != FALSE, "Job limits");
JOBOBJECT_BASIC_UI_RESTRICTIONS ui = {}; ui.UIRestrictionsClass = JOB_OBJECT_UILIMIT_ALL;
require(SetInformationJobObject(job.value, JobObjectBasicUIRestrictions, &ui, sizeof(ui)) != FALSE, "Job UI limits");
SECURITY_ATTRIBUTES inherited = {sizeof(inherited), nullptr, TRUE};
Handle outRead, outWrite, errRead, errWrite;
require(CreatePipe(&outRead.value, &outWrite.value, &inherited, 0) != FALSE, "stdout pipe");
require(CreatePipe(&errRead.value, &errWrite.value, &inherited, 0) != FALSE, "stderr pipe");
require(SetHandleInformation(outRead.value, HANDLE_FLAG_INHERIT, 0) != FALSE, "stdout inheritance");
require(SetHandleInformation(errRead.value, HANDLE_FLAG_INHERIT, 0) != FALSE, "stderr inheritance");
Handle input, inputWrite;
if (broker) {
require(CreatePipe(&input.value, &inputWrite.value, &inherited, 65536) != FALSE, "broker child input");
require(SetHandleInformation(inputWrite.value, HANDLE_FLAG_INHERIT, 0) != FALSE, "broker input writer inheritance");
} else input.value = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &inherited, OPEN_EXISTING, 0, nullptr);
require(input.value != INVALID_HANDLE_VALUE, "NUL input");
AttributeList attributes;
SECURITY_CAPABILITIES caps = {}; caps.AppContainerSid = profile.sid;
require(UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &caps, sizeof(caps), nullptr, nullptr) != FALSE, "AppContainer capability attribute");
HANDLE handles[] = {input.value, outWrite.value, errWrite.value};
require(UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles, sizeof(handles), nullptr, nullptr) != FALSE, "Handle allowlist attribute");
STARTUPINFOEXW startup = {}; startup.StartupInfo.cb = sizeof(startup);
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = input.value; startup.StartupInfo.hStdOutput = outWrite.value; startup.StartupInfo.hStdError = errWrite.value;
startup.lpAttributeList = attributes.list;
auto executable = windowsDirectory() + L"\\System32\\cmd.exe";
std::wstring command = quote(executable) + L" /d /s /c \"\"" + (drive.root() / L"command.cmd").wstring() + L"\"\"";
if (fs::exists(stage / L"git-arguments")) {
executable = (drive.root() / L"tools/git/git.exe").wstring(); command = quote(executable);
for (const auto& arg : git_control::fields(stage / L"git-arguments")) command += L" " + quote(arg);
if (command.size() > 32000) throw std::runtime_error("Isolated Git argv exceeds Windows limit");
}
auto environment = childEnvironment(drive.root());
PROCESS_INFORMATION process = {};
require(CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE,
EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | CREATE_NO_WINDOW,
environment.data(), (drive.root() / L"workspace").c_str(), &startup.StartupInfo, &process) != FALSE, "Create AppContainer process");
Handle child(process.hProcess), thread(process.hThread);
if (!AssignProcessToJobObject(job.value, child.value) || !inContainer(child.value) || !matchesContainer(child.value, profile.sid)) {
TerminateProcess(child.value, 125); throw std::runtime_error("Child isolation verification failed");
}
CloseHandle(outWrite.value); outWrite.value = nullptr;
CloseHandle(errWrite.value); errWrite.value = nullptr;
const ULONGLONG started = GetTickCount64();
require(ResumeThread(thread.value) != static_cast<DWORD>(-1), "Resume isolated process");
std::thread stdoutThread(forward, outRead.value, GetStdHandle(STD_OUTPUT_HANDLE));
std::thread stderrThread(forward, errRead.value, GetStdHandle(STD_ERROR_HANDLE));
Handle cancelled(CreateEventW(nullptr, TRUE, FALSE, nullptr));
std::thread controlThread([&cancelled, &inputWrite, broker]() {
char buffer[4096] = {}; DWORD read = 0; std::string pending;
while (ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer), &read, nullptr) && read) {
pending.append(buffer, read);
if (pending.size() > 65536) { SetEvent(cancelled.value); return; }
size_t newline = 0;
while ((newline = pending.find('\n')) != std::string::npos) {
const auto line = pending.substr(0, newline); pending.erase(0, newline + 1);
if (line == "cancel") { SetEvent(cancelled.value); return; }
std::string decoded;
if (!broker || !brokerInput(line, decoded)) { SetEvent(cancelled.value); return; }
DWORD written = 0;
if (!WriteFile(inputWrite.value, decoded.data(), static_cast<DWORD>(decoded.size()), &written, nullptr) || written != decoded.size()) { SetEvent(cancelled.value); return; }
}
}
});
HANDLE monitored[] = {child.value, parent.value, cancelled.value};
const DWORD waited = WaitForMultipleObjects(3, monitored, FALSE, timeout);
DWORD exit = 125;
const char* status = "error";
if (waited == WAIT_OBJECT_0) { GetExitCodeProcess(child.value, &exit); status = "completed"; }
else if (waited == WAIT_TIMEOUT) { exit = 124; status = "timeout"; }
else if (waited == WAIT_OBJECT_0 + 1) { exit = 130; status = "parent-exited"; }
else if (waited == WAIT_OBJECT_0 + 2) { exit = 130; status = "cancelled"; }
// No breakaway flags: close/terminate the job also stops still-running descendants.
const bool terminated = TerminateJobObject(job.value, exit) != FALSE;
WaitForSingleObject(child.value, 5000);
bool quiescent = false;
for (int attempt = 0; terminated && attempt < 500; ++attempt) {
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting = {};
if (!QueryInformationJobObject(job.value, JobObjectBasicAccountingInformation, &accounting, sizeof(accounting), nullptr)) break;
if (accounting.ActiveProcesses == 0) { quiescent = true; break; }
Sleep(10);
}
if (!quiescent) { status = "error"; exit = 125; }
CancelSynchronousIo(controlThread.native_handle());
controlThread.join();
stdoutThread.join(); stderrThread.join();
const bool cleaned = profile.remove();
const bool unmapped = drive.release();
const auto elapsed = GetTickCount64() - started;
const std::string json = "{\"protocol\":1,\"backend\":\"windows-appcontainer\",\"isolated\":true,\"network\":\"deny\",\"status\":\"" +
std::string(status) + "\",\"exitCode\":" + std::to_string(exit) + ",\"durationMs\":" + std::to_string(elapsed) +
",\"profileRemoved\":" + (cleaned ? "true" : "false") + ",\"driveRemoved\":" + (unmapped ? "true" : "false") + ",\"broker\":" + (broker ? "\"bound-stdio-v1\"" : "null") + "}\n";
writeJson(report, json);
return 0;
}
#include "filesystem-read.h"
#include "filesystem-write.h"
#include "workspace-state.h"
#include "git-control.h"
int wmain(int argc, wchar_t** argv) {
try {
if (_wcsicmp(fs::path(argv[0]).filename().c_str(), L"ycode-git-editor.exe") == 0) return 0;
if (_wcsicmp(fs::path(argv[0]).filename().c_str(), L"git-ssh.exe") == 0) return git_control::ssh(argc, argv);
if (argc == 2 && (std::wstring(argv[1]) == L"--git-protect" || std::wstring(argv[1]) == L"--git-unprotect")) return git_control::credential(std::wstring(argv[1]) == L"--git-protect");
if (argc == 4 && std::wstring(argv[1]) == L"--git-control") {
size_t end = 0; const auto timeout = std::stoul(argv[3], &end);
if (end != wcslen(argv[3]) || timeout > 600000) throw std::runtime_error("Invalid Git control timeout");
return git_control::run(argv[2], static_cast<DWORD>(timeout));
}
if (argc == 3 && std::wstring(argv[1]) == L"--seal-workspace") return workspace_state::run(argv[2]);
if (argc == 4 && (std::wstring(argv[1]) == L"--workspace-write" || std::wstring(argv[1]) == L"--git-metadata-write")) {
const std::wstring expected(argv[3]);
if (expected.find_first_not_of(L"0123456789abcdef:-") != std::wstring::npos) throw std::runtime_error("Invalid workspace identity");
std::string expectedBytes;
for (wchar_t ch : expected) expectedBytes += static_cast<char>(ch);
return workspace_write::run(argv[2], expectedBytes, std::wstring(argv[1]) == L"--git-metadata-write");
}
if (argc == 7 && std::wstring(argv[1]) == L"--workspace-read") {
const std::wstring expected(argv[5]);
if (expected.find_first_not_of(L"0123456789abcdef:-") != std::wstring::npos) throw std::runtime_error("Invalid workspace identity");
size_t end = 0; const auto limit = std::stoul(argv[6], &end);
if (end != wcslen(argv[6]) || limit > 8 * 1024 * 1024) throw std::runtime_error("Invalid workspace limit");
std::string expectedBytes;
for (wchar_t ch : expected) expectedBytes += static_cast<char>(ch);
return workspace_read::run(argv[2], argv[3], argv[4], expectedBytes, static_cast<DWORD>(limit));
}
if (argc == 2 && std::wstring(argv[1]) == L"--version") {
std::puts("{\"protocol\":1,\"backend\":\"windows-appcontainer\",\"version\":\"0.7.0\",\"network\":\"deny\"}"); return 0;
}
if (argc == 3 && std::wstring(argv[1]) == L"--cleanup") {
if (inContainer(GetCurrentProcess()) || !validProfile(argv[2])) throw std::runtime_error("Invalid profile cleanup request");
const HRESULT cleaned = DeleteAppContainerProfile(argv[2]);
return SUCCEEDED(cleaned) || cleaned == HRESULT_FROM_WIN32(ERROR_NOT_FOUND) || cleaned == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) || cleaned == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND) ? 0 : 125;
}
if (argc == 4 && std::wstring(argv[1]) == L"--release-drive") {
const std::wstring drive = argv[2];
const fs::path stage(argv[3]);
if (inContainer(GetCurrentProcess()) || drive.size() != 2 || drive[0] < L'D' || drive[0] > L'Z' || drive[1] != L':' || !stage.is_absolute() || stage.filename() != L"stage") throw std::runtime_error("Invalid drive cleanup request");
const auto target = L"\\??\\" + stage.wstring();
const BOOL removed = DefineDosDeviceW(DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE | DDD_RAW_TARGET_PATH | DDD_NO_BROADCAST_SYSTEM, drive.c_str(), target.c_str());
return removed || GetLastError() == ERROR_FILE_NOT_FOUND ? 0 : 125;
}
const std::wstring mode = argc > 1 ? argv[1] : L"";
if (argc != 6 || (mode != L"--run" && mode != L"--run-stdio")) throw std::runtime_error("Usage: --run or --run-stdio profile stage result.json timeout-ms");
size_t end = 0; const auto timeout = std::stoul(argv[5], &end);
if (end != wcslen(argv[5]) || timeout < 100 || timeout > 600000) throw std::runtime_error("Invalid timeout");
return run(argv[2], fs::path(argv[3]), fs::path(argv[4]), static_cast<DWORD>(timeout), mode == L"--run-stdio");
} catch (const std::exception& error) { std::fprintf(stderr, "YCODE_SANDBOX_ERROR %s\n", error.what()); return 125; }
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
namespace workspace_state {
struct PrivateDacl {
PSECURITY_DESCRIPTOR descriptor = nullptr;
PACL acl = nullptr;
PrivateDacl() {
Handle token;
require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token.value) != FALSE, "Open workspace seal token");
DWORD bytes = 0; GetTokenInformation(token.value, TokenUser, nullptr, 0, &bytes);
std::vector<BYTE> user(bytes);
require(GetTokenInformation(token.value, TokenUser, user.data(), bytes, &bytes) != FALSE, "Read workspace seal owner");
LPWSTR sid = nullptr;
require(ConvertSidToStringSidW(reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid) != FALSE, "Encode workspace seal owner");
const std::wstring sddl = L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;" + std::wstring(sid) + L")";
LocalFree(sid);
require(ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr) != FALSE, "Build workspace private DACL");
BOOL present = FALSE, defaulted = FALSE;
require(GetSecurityDescriptorDacl(descriptor, &present, &acl, &defaulted) != FALSE && present && acl, "Read workspace private DACL");
}
~PrivateDacl() { if (descriptor) LocalFree(descriptor); }
void apply(HANDLE handle) const {
security(SetSecurityInfo(handle, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
nullptr, nullptr, acl, nullptr), "Seal workspace handle DACL");
}
};
static void seal(HANDLE handle, const PrivateDacl& dacl, size_t& count, unsigned int depth = 0) {
const auto info = workspace_read::information(handle);
if (++count > 10001 || depth > 64) throw std::runtime_error("Workspace seal entry or nesting limit");
dacl.apply(handle);
if (!(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) return;
std::vector<BYTE> buffer(65536);
for (;;) {
if (!GetFileInformationByHandleEx(handle, FileIdBothDirectoryInfo, buffer.data(), static_cast<DWORD>(buffer.size()))) {
if (GetLastError() == ERROR_NO_MORE_FILES) break;
require(false, "Enumerate workspace seal directory");
}
size_t offset = 0;
for (;;) {
const auto entry = reinterpret_cast<FILE_ID_BOTH_DIR_INFO*>(buffer.data() + offset);
if (entry->FileNameLength > buffer.size() - offset - offsetof(FILE_ID_BOTH_DIR_INFO, FileName)) throw std::runtime_error("Invalid workspace seal directory record");
const std::wstring name(entry->FileName, entry->FileNameLength / sizeof(wchar_t));
if (name != L"." && name != L"..") {
if (!workspace_read::component(name) || entry->FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) throw std::runtime_error("Workspace seal link or invalid name denied");
Handle child(workspace_write::openRelative(handle, name, GENERIC_READ | READ_CONTROL | WRITE_DAC, false));
require(child.value != INVALID_HANDLE_VALUE, "Open workspace seal child");
seal(child.value, dacl, count, depth + 1);
}
if (entry->NextEntryOffset == 0) break;
if (entry->NextEntryOffset < sizeof(FILE_ID_BOTH_DIR_INFO) || entry->NextEntryOffset > buffer.size() - offset - sizeof(FILE_ID_BOTH_DIR_INFO)) throw std::runtime_error("Invalid workspace seal directory offset");
offset += entry->NextEntryOffset;
}
}
}
static int run(const std::wstring& path) {
if (inContainer(GetCurrentProcess())) throw std::runtime_error("Workspace sealing is host-only");
workspace_read::Lifetime lifetime(120000);
workspace_write::Root root(path);
if (root.held.size() < 2) throw std::runtime_error("A volume root cannot be a managed workspace");
const auto names = workspace_read::parts(path.substr(3));
Handle target(workspace_write::openRelative(root.held[root.held.size() - 2]->value, names.back(), GENERIC_READ | READ_CONTROL | WRITE_DAC, true));
require(target.value != INVALID_HANDLE_VALUE, "Open workspace seal root");
if (workspace_read::identity(target.value) != root.rootIdentity) throw std::runtime_error("Workspace seal root changed");
PrivateDacl dacl; size_t count = 0;
seal(target.value, dacl, count);
workspace_write::reply("{\"protocol\":1,\"sealed\":true,\"rootIdentity\":\"" + root.rootIdentity + "\",\"entries\":" + std::to_string(count) + "}");
return 0;
}
}