feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user