Files
orca/src/shared/orcad-artifacts.ts
T
OrcaWinandOrca Worker 4559c250b3 fix(windows): restrict secure files in-process instead of spawning icacls (#28135)
* fix(windows): restrict secure files in-process instead of spawning icacls

Every secure write on Windows spawned icacls.exe five times on the calling
thread (the Electron main thread for most stores): verify, /reset,
/inheritance:r /grant:r, verify, verify. Process creation under endpoint
security costs ~0.6 s each, so each write froze the UI for seconds (#26027).
The /reset + /inheritance:r pair is also what behavioural EDR scores as
ransomware tradecraft (#25583).

Add native/windows-security (@orca/windows-security): a small N-API addon
over advapi32 that reads and writes DACLs in-process. It exposes the token
user SID, a DACL read as SDDL, a DACL write (sync and on the thread pool),
a create-with-DACL file write, and an additive grant. Policy and
verification stay in TypeScript.

- writeSecureFile creates each temp file already carrying the protected
  user/SYSTEM/Administrators DACL (CreateFileW + SECURITY_ATTRIBUTES), reads
  it back once, and renames it. No ACL is modified on the write path and no
  process starts. Before: 5-7 spawns, 94-138 ms per write. After: 0 spawns,
  ~0.5 ms.
- A secure directory is verified once per process. It is rewritten only
  when verification fails, on the thread pool, because the write walks
  every descendant (~2 s on a 29k-entry userData).
- The verifier follows OpenSSH's rule: only the user, SYSTEM and
  Administrators may be granted anything, and the path must be protected
  (or, for a file, inherit from a secure parent). DACLs written by the
  icacls-era hardening pass unchanged, so there is no migration.
- If the addon cannot load, the write still happens and reports
  unrestricted, logged once. There is no icacls or PowerShell fallback.
- grantDirAcl, the startup userData grant, and the install-dir probe and
  package repair use the same addon, so whoami.exe and icacls.exe are no
  longer spawned anywhere at runtime.
- orcad's Windows slots ship the addon beside their bundles (built per
  arch, PE-machine checked). Its data-root privacy check fails closed, so
  without the addon a Windows orcad would not start.

Fixes #26027
Fixes #25583

* refactor(secure-file): harden existing Windows files through the sync lane

The read path called restrictWindowsPathSync directly and recorded success
itself; the existing sync lane already does both. One call site fewer.

* refactor(windows-security): one load, one rule, no fallthrough (review #28135)

- The descriptor parser is anchored, so a conditional ACE (its body holds
  parentheses) or anything else it cannot read fails closed instead of
  verifying the ACEs before it.
- findSecureDaclViolation is one flat rule: protected, allow rules only for
  the user, SYSTEM and Administrators (OpenSSH's ACE rule), and full
  control for the user. The inherits-from-a-secure-parent exception is gone:
  it accepted exactly what loosening the parent reaches.
- The addon and the user SID load once, behind one wrapper that adds the
  extended-length prefix to every path and runs async DACL writes to one
  path in order, so a startup grant cannot land after a re-lock and restore
  the broader DACL.
- With the addon loaded, a failed born-restricted create throws with the
  matching Node error code instead of falling back to a plain write.
- A grant leaves a NULL DACL untouched instead of turning 'everyone' into
  'only this SID'. WriteFile runs once and fails on a short write.
- The artifact create-intent store writes through writeSecureFile and drops
  its own temp writer, directory LRU and synchronous directory re-lock.
- Test seams the global fake makes redundant are gone.

* fix(packaging): fail a Windows build whose workspace addon binary is missing or foreign-arch

The @orca/windows-* workspace links exist on every host, so their presence
proved nothing: a missing or wrong-architecture .node only surfaced at
runtime, as unrestricted secure files or an orcad that refuses to start.
beforePack now reads each addon's PE machine against the target arch.

* refactor(windows-security): prune what the native ACL left dead (review #28135)

- Sync-spawn ratchet: hardenSecurePath and the sync-flag exemption it alone
  used are gone, and the advice and baseline header no longer say secure
  writers run icacls.
- The install-dir probe reads ACEs through parseSddlDacl, so an ACE it
  cannot parse fails the whole reading closed instead of being skipped; AC
  is a resolved alias there now.
- The packaging check reads the addon binary through node_modules, the copy
  that ships.
- No winCode-to-errno table: nothing reads .code off a failed secure write.
- The load memo is not mutated; secure-path keeps its own log-once flag.
- grantInstallDirPackageAcl is a plain function, and secure-file restricts
  a Windows directory and a POSIX path directly instead of through one
  two-platform helper.

* fix(windows-security): resolve LA in real-ACL tests; tear a file whose close fails

- The GitHub Windows runner is the built-in Administrator (RID 500), whose
  SID reads back as LA. Production already resolves it through
  localDomainSidOf; the win32 test helpers parsed without it. One shared
  readDaclOf resolves aliases for every real-ACL suite, and the verifier's
  unit test covers an LA-aliased directory and LA as a stranger.
- createFileWithDacl treats a failed CloseHandle like a failed write (lost
  data on a network volume) and deletes the torn file.
- Drop a duplicate test reset and a typo.

* fix(windows-security): load only from inside the app; read DACLs as structure (review #28135)

- Loader: orcad's staged binary first, then the workspace package only if
  its real path is inside the app (process.resourcesPath, or this
  checkout). A bare specifier otherwise walks every parent up to
  C:\node_modules, which any local user may create, and orcad holds
  credentials.
- The addon reads a DACL as { protected, aces: [{ type, flags, mask, sid }] }
  via GetAce, with a SID only for plain allow/deny ACEs and null for a NULL
  DACL. findSecureDaclViolation and the install-dir probe check that
  structure, so conditional, callback and object ACEs fail closed. The SDDL
  parser, its alias table, LA/localDomainSid resolution and getDaclSddl are
  gone; SDDL is only the write format.
- Install-dir tree pass: files flagless as before, but a directory only when
  its own DACL is protected (inheritably). Re-granting inheriting
  directories re-propagated each subtree (3.2 s for 1,093 directories). The
  root is no longer granted, or counted, twice. The two passes are plain
  functions behind a { root, tree } seam.
- One namespacing (path.win32.toNamespacedPath) at the addon boundary, also
  the per-path ordering key; the comment states the real sync-grant
  invariant.
- restrictWindowsDirectory(dir): Promise<boolean> replaces the callback
  lane; the reporter is guarded once, so the 'settle' stage is gone.
- Sync-spawn ratchet: the secure writers no longer spawn or block, so they
  leave the gate (109 -> 61 uses).
- Errors name the Win32 call and code only; a failed
  GetSecurityDescriptorControl fails the write instead of defaulting to
  unprotected.

* test(secure-file): assert the durable POSIX write's outcome, not the old chmod order

writeSecureFile now chmods the temp file to 0o600 before fsyncing it, so
the fsync also covers the mode change and the file is owner-writable when
opened. Assert what matters under umask 0o200: the write succeeds, the
temp file is fsynced read-only, and the published file is exactly 0o600.

* fix(windows-security): namespace paths with the host's toNamespacedPath

path.win32.toNamespacedPath rewrote POSIX temp paths (/tmp/x -> \tmp\x) in the
Linux runs of the Windows-logic unit tests, so the fake addon wrote to the wrong
place. The addon only runs on Windows, where the host function is the win32 one;
elsewhere it is the identity. The long-path assertion runs on Windows only.

* test(windows-security): assert the install-dir long-path prefix on Windows only

The prefix comes from the host's toNamespacedPath, which is the identity off
Windows; the Linux run of this suite expected the win32 escape.

* fix(windows-user-data-acl): grant the root even when its children cannot be listed (review #28135)

A readdir failure on userData aborted the whole grant, so the root grant
that could repair a DACL refusing the listing was never attempted. The
listing failure is now recorded, the root is still granted, and the
marker stays withheld so the next launch retries.

* test(windows-security): wait for relock propagation, accept Entra ID SIDs (review #28135)

- The large-relock test waited only for the parent to turn protected,
  which happens before propagation reaches the children, then read a child.
  It now waits on the parent and both ends of the child range.
- The token-user test rejected S-1-12-1 (Entra ID) accounts, failing the
  suite on Entra-joined dev machines.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-10-13 01:56:55 -07:00

236 lines
10 KiB
TypeScript

/**
* What a packaged `orcad` directory must contain, declared once: the build, the content hash
* and the remote install probe must not keep three lists that drift.
*
* Order is load-bearing: the hash concatenates these files in sequence.
*
* Keep this file erasable-only TypeScript — build-orcad.mjs imports it directly under
* Node's type stripping, which rejects enums, namespaces and parameter properties.
*/
// Bun-era slots only: kept so a client can still launch one for rollback (design D7.1 R5).
export const ORCAD_BUN_RUNTIME_FILENAME = 'bun-runtime'
export const ORCAD_WINDOWS_BUN_RUNTIME_FILENAME = 'bun-runtime.exe'
export const ORCAD_BUILD_TARGET_FILENAME = '.build-target'
export const ORCAD_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node'
/** native/windows-security: secure-file ACLs without spawning icacls. */
export const ORCAD_WINDOWS_SECURITY_FILENAME = 'windows-security.node'
/** native/windows-cli-launcher: cmd.exe and .ps1 shims cannot carry every argv intact. */
export const ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME = 'bin/orca.exe'
export function orcadBunRuntimeFilename(target: string): string {
return isWindowsTarget(target) ? ORCAD_WINDOWS_BUN_RUNTIME_FILENAME : ORCAD_BUN_RUNTIME_FILENAME
}
function isWindowsTarget(target: string): boolean {
return target === 'win32' || target.startsWith('win32-')
}
/** A Node slot's server target (e.g. linux-x64-musl); `.build-target` would mark it as Bun. */
export const ORCAD_SERVER_TARGET_FILENAME = '.server-target'
/**
* Marks a slot launched by the pinned Node runtime; its content is that runtime's
* executableSha256 (node-runtime-pin.ts), which is how the runtime enters the version hash.
* A Node slot must never carry `.build-target`: Bun-era selectors exit 78 on `.build-target`
* without `bun-runtime` (design D7.1 R5).
*/
export const ORCAD_NODE_RUNTIME_MARKER_FILENAME = '.runtime-node'
/** Beside the slot dirs and shared across Orca versions (design D2): `runtimes/node-<sha256>/bin/node`. */
export const ORCAD_RUNTIMES_DIRNAME = 'runtimes'
export const ORCAD_NODE_RUNTIME_DIR_PREFIX = 'node-'
export const ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE = 'bin/node'
// Upstream's own name and layout: renaming node.exe is the masquerading pattern EDR scores.
export const ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE = 'node.exe'
export function orcadNodeRuntimeExecutable(target: string): string {
return isWindowsTarget(target)
? ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE
: ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE
}
/** Slot-relative path segments of the runtime a slot's marker names. */
export function orcadNodeRuntimeRelativePath(target: string, executableSha256: string): string[] {
return [
'..',
ORCAD_RUNTIMES_DIRNAME,
`${ORCAD_NODE_RUNTIME_DIR_PREFIX}${executableSha256}`,
...orcadNodeRuntimeExecutable(target).split('/')
]
}
/** N-API level the slot addons are built for; equals SLOT_NAPI_VERSION in the prebuild script. */
export const ORCAD_ADDON_NAPI_VERSION = 8
export const ORCAD_NODE_PTY_DIR = 'node_modules/node-pty'
export const ORCAD_CLI_ENTRY_FILENAME = 'out/cli/index.js'
export const ORCAD_CLI_PACKAGE_FILENAME = 'out/package.json'
// Test files and sources stay out; these are every module the runtime path requires.
export const ORCAD_NODE_PTY_JS_ARTIFACTS = [
'package.json',
'lib/conpty_console_list_agent.js',
'lib/eventEmitter2.js',
'lib/index.js',
'lib/interfaces.js',
'lib/shared/conout.js',
'lib/terminal.js',
'lib/types.js',
'lib/unixTerminal.js',
'lib/utils.js',
'lib/windowsConoutConnection.js',
'lib/windowsPtyAgent.js',
'lib/windowsTerminal.js',
'lib/worker/conoutSocketWorker.js'
].map((file) => `${ORCAD_NODE_PTY_DIR}/${file}`)
/** The prebuild slot's files (config/scripts/orcad-prebuild-slot-contents.mjs), relative to build/Release. */
export function orcadNodePtySlotFiles(target: string): string[] {
if (isWindowsTarget(target)) {
return [
'conpty.node',
'conpty_console_list.node',
'conpty/conpty.dll',
'conpty/OpenConsole.exe'
]
}
return target.startsWith('darwin-') ? ['pty.node', 'spawn-helper'] : ['pty.node']
}
export function orcadNodePtyNativeArtifacts(target: string): string[] {
return orcadNodePtySlotFiles(target).map((file) => `${ORCAD_NODE_PTY_DIR}/build/Release/${file}`)
}
export const ORCAD_PARCEL_WATCHER_ENTRY = 'node_modules/@parcel/watcher/index.js'
export const ORCAD_PARCEL_WATCHER_NATIVE = 'node_modules/@parcel/watcher/watcher.node'
export const ORCAD_EMOJI_SHORTCODE_DATASET =
'node_modules/emojibase-data/en/shortcodes/emojibase.json'
export const ORCAD_VERSION = '0.1.0'
export const ORCAD_LAUNCHER_FILENAME = 'orcad.js'
export const ORCAD_SERVER_ENTRY_FILENAME = 'orcad-server.js'
// Equals FOREIGN_SQLITE_READER_ENTRY_FILENAME; that module is not loadable under type stripping.
export const ORCAD_FOREIGN_SQLITE_READER_ENTRY = 'foreign-sqlite-reader-entry.js'
/** Worker thread that runs workspace port detection's probe commands off the event loop. */
export const ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY = 'port-scan-command-worker-entry.js'
// Equals AI_VAULT_SERVICE_ENTRY_FILENAME: the forked child that lists agent sessions.
export const ORCAD_SESSION_SCANNER_SERVICE_ENTRY = 'session-scanner-service-entry.js'
// Kept here because build-orcad.mjs imports this manifest directly under Node type stripping.
export const ORCAD_RIPGREP_ARTIFACTS = [
'ripgrep/linux-x64/rg',
'ripgrep/linux-arm64/rg',
'ripgrep/darwin-x64/rg',
'ripgrep/darwin-arm64/rg',
'ripgrep/win32-x64/rg.exe',
'ripgrep/win32-arm64/rg.exe'
] as const
/** Only the target's own ripgrep ships (design D2); both libcs share one static build. */
export function orcadRipgrepArtifact(target: string): string {
const [platform, arch] = target.split('-')
const artifact = ORCAD_RIPGREP_ARTIFACTS.find((candidate) =>
candidate.startsWith(`ripgrep/${platform}-${arch}/`)
)
if (!artifact) {
throw new Error(`orcad ships no ripgrep for ${target}`)
}
return artifact
}
export const ORCAD_RIPGREP_LICENSE_ARTIFACTS = [
'ripgrep/licenses/JEMALLOC-COPYING',
'ripgrep/licenses/LICENSE-MIT',
'ripgrep/licenses/LLVM-LIBUNWIND-LICENSE.TXT',
'ripgrep/licenses/MUSL-COPYRIGHT',
'ripgrep/licenses/PCRE2-LICENCE.md',
'ripgrep/licenses/README.md',
'ripgrep/licenses/RUST-CRATE-NOTICES.txt',
'ripgrep/licenses/SLJIT-LICENSE',
'ripgrep/licenses/UNLICENSE'
] as const
/** The skill plugin native-chat agents load by path; mirrors resources/native-chat-visuals. */
export const ORCAD_NATIVE_CHAT_VISUALS_ARTIFACTS = [
'native-chat-visuals/.claude-plugin/plugin.json',
'native-chat-visuals/skills/orca-chat-visuals/SKILL.md'
] as const
export type OrcadArtifact = {
filename: string
/**
* Absence is a degradation, not a torn install, so the remote probe must not require it.
* The agent-browser binary is the only one: `resolveOrcadBrowserProvider` already answers
* "no headless browser" when it is missing, and it is named per platform-arch anyway.
*/
optional?: boolean
}
export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [
{ filename: ORCAD_CLI_ENTRY_FILENAME },
{ filename: ORCAD_CLI_PACKAGE_FILENAME },
{ filename: ORCAD_LAUNCHER_FILENAME },
{ filename: ORCAD_SERVER_ENTRY_FILENAME },
// Forked so a native @parcel/watcher fault kills the child, not the server.
{ filename: 'parcel-watcher-process-entry.js' },
// Forked so PTYs outlive the runtime process; its absence makes every restart destructive.
{ filename: 'daemon-entry.js' },
{ filename: 'profile-state-writer-worker-entry.js' },
{ filename: 'profile-state-backup-worker-entry.js' },
// Worker thread that reads other apps' SQLite (the OpenCode binder and history) off the event loop.
{ filename: ORCAD_FOREIGN_SQLITE_READER_ENTRY },
{ filename: ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY },
// Forked so transcript parsing stays off the server's event loop; session search stays in-process.
{ filename: ORCAD_SESSION_SCANNER_SERVICE_ENTRY },
// Target-specific even when the JavaScript bundle is shared across packaged slots.
{ filename: ORCAD_SERVER_TARGET_FILENAME },
// orcad never depends on a host runtime or host-installed native module.
{ filename: ORCAD_NODE_RUNTIME_MARKER_FILENAME },
{ filename: ORCAD_PARCEL_WATCHER_ENTRY },
{ filename: ORCAD_PARCEL_WATCHER_NATIVE },
{ filename: ORCAD_EMOJI_SHORTCODE_DATASET },
...ORCAD_NODE_PTY_JS_ARTIFACTS.map((filename) => ({ filename })),
...ORCAD_RIPGREP_LICENSE_ARTIFACTS.map((filename) => ({ filename })),
...ORCAD_NATIVE_CHAT_VISUALS_ARTIFACTS.map((filename) => ({ filename }))
]
/** Written after the artifacts, so it is never an input to its own hash. */
export const ORCAD_VERSION_FILENAME = '.version'
export const ORCAD_TEMPLATE_MANIFEST_FILENAME = 'orcad-template.json'
export const ORCAD_TEMPLATE_TARGETS_DIR = 'targets'
/** Written last by the installer; its absence means a torn install. */
export const ORCAD_INSTALL_COMPLETE_FILENAME = '.install-complete'
/** Every file a `target` slot ships, in hash order; `target` is `<os>-<arch>[-<libc>]`. */
export function orcadArtifactFilenames(target: string): string[] {
const filenames = ORCAD_ARTIFACTS.filter((artifact) => !artifact.optional).map(
(artifact) => artifact.filename
)
filenames.push(...orcadNodePtyNativeArtifacts(target), orcadRipgrepArtifact(target))
if (isWindowsTarget(target)) {
filenames.push(
ORCAD_WINDOWS_PROCESS_TREE_FILENAME,
ORCAD_WINDOWS_SECURITY_FILENAME,
ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME
)
}
return filenames
}
/** Files shared by every template target; the rest live under `targets/<target>/`. */
export function orcadTemplateCommonFilenames(): string[] {
return ORCAD_ARTIFACTS.filter(
(artifact) =>
!artifact.optional &&
artifact.filename !== ORCAD_SERVER_TARGET_FILENAME &&
artifact.filename !== ORCAD_NODE_RUNTIME_MARKER_FILENAME &&
artifact.filename !== ORCAD_PARCEL_WATCHER_NATIVE
).map((artifact) => artifact.filename)
}
/** Files stored under the template's `targets/<target>/`, at their slot-relative paths. */
export function orcadTemplateTargetFilenames(target: string): string[] {
const common = new Set(orcadTemplateCommonFilenames())
return orcadArtifactFilenames(target).filter((filename) => !common.has(filename))
}