kritya 0.8.40-beta → 0.8.42-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createRequire } from "node:module";
5
- import { BUILTIN_PROVIDERS, CONFIG_DIR, CONFIG_FILE, legacyGlobalModel, loadConfig, privacyModeFor, resolveProvider, } from "../config/config.js";
5
+ import { BUILTIN_PROVIDERS, CONFIG_DIR, CONFIG_FILE, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "../config/config.js";
6
6
  import { DEFAULT_MODEL, contextWindowFor } from "../config/models.js";
7
7
  import { retentionDaysFor } from "../config/retention.js";
8
8
  import { assertJsonWithinLimits } from "../config/jsonSafety.js";
@@ -368,6 +368,13 @@ export async function runDoctorCli(argv, options = {}) {
368
368
  const offline = argv.includes("--offline");
369
369
  const dirArg = argv.find((a) => !a.startsWith("-"));
370
370
  const workspace = path.resolve(options.workspace ?? dirArg ?? ".");
371
+ // The user's own global .env is unconditionally trusted and is where the
372
+ // interactive and headless paths both pick up provider keys (see
373
+ // `runInteractive` in index.tsx and `runHeadless` in headless.ts). Without
374
+ // loading it here the provider check below sees only the ambient
375
+ // environment, so a key that lives solely in ~/.kritya/.env is reported as
376
+ // missing — a false "no API key" that also flips the exit code to 1.
377
+ loadDotEnv([path.join(CONFIG_DIR, ".env")]);
371
378
  const sections = await collectDiagnostics({ ...options, workspace, offline });
372
379
  const report = summarize(sections, workspace);
373
380
  if (json) {
package/dist/index.js CHANGED
@@ -221,17 +221,21 @@ if (args.version) {
221
221
  // pipe never pays for it and never sees it), cached for a day, bounded, and
222
222
  // written to stderr.
223
223
  console.log(VERSION);
224
+ // Awaited, not fired off behind a deferred process.exit(). This is
225
+ // top-level module code with no `return`, so a detached exit does not stop
226
+ // the statements below it: on a TTY the whole interactive path ran anyway
227
+ // (the TTY check, then main() and its AI-disclosure banner) and was then
228
+ // killed mid-frame when the notice resolved, which trips a libuv assertion
229
+ // on Windows — "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING),
230
+ // file src\win\async.c". Awaiting keeps the notice and makes the exit
231
+ // synchronous. checkForUpdate is capped by a 2s AbortSignal timeout, cached
232
+ // for a day, and never throws, so this cannot hang or fail.
224
233
  if (process.stdout.isTTY) {
225
- void updateNoticeForUser()
226
- .then((notice) => {
227
- if (notice)
228
- console.error(notice);
229
- })
230
- .finally(() => process.exit(0));
231
- }
232
- else {
233
- process.exit(0);
234
+ const notice = await updateNoticeForUser();
235
+ if (notice)
236
+ console.error(notice);
234
237
  }
238
+ process.exit(0);
235
239
  }
236
240
  const workspace = path.resolve(args.dir);
237
241
  if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) {
@@ -1,9 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import { createRequire } from "node:module";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { classifyDanger } from "../permissions/danger.js";
6
7
  let cachedTool;
8
+ let cachedMxcExec;
7
9
  function commandExists(bin) {
8
10
  const finder = os.platform() === "win32" ? "where" : "which";
9
11
  try {
@@ -13,7 +15,66 @@ function commandExists(bin) {
13
15
  return false;
14
16
  }
15
17
  }
16
- /** Which sandbox binary (if any) is usable on this platform, cached after the first check. */
18
+ /**
19
+ * MXC ships `wxc-exec.exe` per architecture (`bin/x64/`, `bin/arm64/`), and its
20
+ * own resolver picks the same way — see `sdk/node/src/v1/platform.ts::
21
+ * findWxcExecutable`, which this mirrors.
22
+ */
23
+ function mxcArch() {
24
+ return os.arch() === "arm64" ? "arm64" : "x64";
25
+ }
26
+ /** The `@microsoft/mxc-sdk` install directory, or null when it isn't installed. */
27
+ function mxcPackageRoot() {
28
+ try {
29
+ // `./package.json` is in the SDK's exports map, so this resolves whether or
30
+ // not the package's main entry is importable.
31
+ const require = createRequire(import.meta.url);
32
+ return path.dirname(require.resolve("@microsoft/mxc-sdk/package.json"));
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Locate `wxc-exec.exe`, in the order a user would expect to override it: an
40
+ * explicit path, then MXC's own `MXC_BIN_DIR`, then `PATH`, then an optional
41
+ * `@microsoft/mxc-sdk` install. Null means MXC isn't present, which leaves
42
+ * Windows behaving exactly as it did before MXC support existed.
43
+ */
44
+ function locateMxcExecutable() {
45
+ const explicit = process.env.KRITYA_MXC_EXEC;
46
+ if (explicit && fs.existsSync(explicit))
47
+ return explicit;
48
+ // MXC's own override: <MXC_BIN_DIR>/<arch>/wxc-exec.exe.
49
+ const binDir = process.env.MXC_BIN_DIR;
50
+ if (binDir) {
51
+ const candidate = path.join(binDir, mxcArch(), "wxc-exec.exe");
52
+ if (fs.existsSync(candidate))
53
+ return candidate;
54
+ }
55
+ // On PATH — kept as a bare name so the OS resolves it at spawn time.
56
+ if (commandExists("wxc-exec.exe"))
57
+ return "wxc-exec.exe";
58
+ const root = mxcPackageRoot();
59
+ if (root) {
60
+ const candidate = path.join(root, "bin", mxcArch(), "wxc-exec.exe");
61
+ if (fs.existsSync(candidate))
62
+ return candidate;
63
+ }
64
+ return null;
65
+ }
66
+ /** Cached after the first check — locating it can shell out to `where`. */
67
+ function mxcExecutable() {
68
+ if (cachedMxcExec === undefined)
69
+ cachedMxcExec = locateMxcExecutable();
70
+ return cachedMxcExec;
71
+ }
72
+ /** Test-only: drops the memoized backend and MXC path between cases. */
73
+ export function resetSandboxToolCache() {
74
+ cachedTool = undefined;
75
+ cachedMxcExec = undefined;
76
+ }
77
+ /** Which sandbox backend (if any) is usable on this platform, cached after the first check. */
17
78
  function sandboxTool() {
18
79
  if (cachedTool !== undefined)
19
80
  return cachedTool;
@@ -24,6 +85,12 @@ function sandboxTool() {
24
85
  else if (platform === "darwin") {
25
86
  cachedTool = commandExists("sandbox-exec") ? "sandbox-exec" : null;
26
87
  }
88
+ else if (platform === "win32") {
89
+ // MXC (Microsoft eXecution Containers) is Windows' only containment
90
+ // backend — AppContainer + DACLs, enforced by the kernel. It is an
91
+ // optional install, so "not there" is the common case and stays null.
92
+ cachedTool = mxcExecutable() ? "mxc" : null;
93
+ }
27
94
  else {
28
95
  cachedTool = null;
29
96
  }
@@ -35,10 +102,11 @@ export function sandboxAvailable() {
35
102
  /**
36
103
  * Default `sandboxMode` when the config leaves `sandboxExec` unset. "auto" on
37
104
  * Linux/macOS, where bwrap/sandbox-exec can actually confine writes to the
38
- * workspace. On Windows there's no sandbox backend at all (see
39
- * `sandboxUnavailableReason`), so "auto"/"always" would let a flagged command
40
- * run unprotected outside the workspace — "strict" refuses those instead of
41
- * silently falling back.
105
+ * workspace. On Windows "strict" is kept even though MXC can now back it,
106
+ * because "strict" is the mode that fails closed: with MXC installed every
107
+ * command runs *inside* the container instead of being refused, and on a host
108
+ * where MXC isn't present (or can't be served) commands are refused rather
109
+ * than silently falling back to an unconfined run.
42
110
  */
43
111
  export function defaultSandboxMode() {
44
112
  return os.platform() === "win32" ? "strict" : "auto";
@@ -46,8 +114,10 @@ export function defaultSandboxMode() {
46
114
  /** One-line reason sandboxing can't run here, for a fallback warning. */
47
115
  export function sandboxUnavailableReason() {
48
116
  const platform = os.platform();
49
- if (platform === "win32")
50
- return "sandboxed execution isn't supported on Windows yet";
117
+ if (platform === "win32") {
118
+ return ("wxc-exec.exe not found (install @microsoft/mxc-sdk, set MXC_BIN_DIR, " +
119
+ "or point KRITYA_MXC_EXEC at the binary)");
120
+ }
51
121
  if (platform === "linux")
52
122
  return "bwrap (bubblewrap) not found on PATH";
53
123
  if (platform === "darwin")
@@ -58,18 +128,18 @@ export function sandboxUnavailableReason() {
58
128
  export function shouldSandbox(mode, command) {
59
129
  if (!mode || mode === "off")
60
130
  return false;
61
- // "always"/"strict" mean always, on every platform — including Windows,
62
- // where there's no sandbox binary to back it, so every command falls back
63
- // to the "[sandbox unavailable]" note ("always") or is refused outright
64
- // ("strict"). That's deliberate: these are the modes for someone who wants
65
- // maximum enforcement/visibility even without a real sandbox backing it.
131
+ // "always"/"strict" mean always, on every platform. Where no backend can be
132
+ // found that falls back to the "[sandbox unavailable]" note ("always") or a
133
+ // refusal ("strict") — deliberate, since these are the modes for someone who
134
+ // wants maximum enforcement/visibility even without a sandbox behind them.
66
135
  if (mode === "always" || mode === "strict")
67
136
  return true;
68
- // "auto": Windows has no sandbox binary at all — falling back to "only
69
- // flagged commands" (today's behavior) avoids a spurious fallback note on
70
- // every single shell call, which "sandbox everything" would otherwise cause.
137
+ // "auto" on Windows without a backend: falling back to "only flagged
138
+ // commands" avoids a spurious fallback note on every single shell call,
139
+ // which "sandbox everything" would otherwise cause. With MXC installed
140
+ // there *is* a backend, so "auto" means here what it means elsewhere.
71
141
  if (os.platform() === "win32")
72
- return classifyDanger(command) !== null;
142
+ return sandboxAvailable() || classifyDanger(command) !== null;
73
143
  return true;
74
144
  }
75
145
  /**
@@ -365,6 +435,169 @@ ${writeExtra}
365
435
  ${readExtra}
366
436
  `;
367
437
  }
438
+ /**
439
+ * Windows' system-drive root, e.g. `C:\` — the read-everything analogue of
440
+ * bwrap's `--ro-bind / /`. It is granted through MXC's *readwrite* list rather
441
+ * than its readonly one; see `mxcPolicyJson` for why that is not a loosening.
442
+ */
443
+ function systemDriveRoot() {
444
+ const drive = process.env.SystemDrive || "C:";
445
+ return drive.endsWith("\\") ? drive : `${drive}\\`;
446
+ }
447
+ /**
448
+ * `command` as a `cmd.exe` command line, the way `buildSandboxedCommand`'s
449
+ * other two branches get theirs from `sh -c`.
450
+ *
451
+ * `wxc-exec` takes one command-line string and hands it to CreateProcess, which
452
+ * reads it as an image plus arguments — no shell, so `a && b` arrives at the
453
+ * first program as literal arguments and `|`, `>`, `cd …` and the like do
454
+ * nothing at all. This mirrors what Node's own `child_process.exec` passes on
455
+ * Windows (`cmd.exe /d /s /c "…"`), so a sandboxed command and an unsandboxed
456
+ * one behave the same: `/d` skips the registry AutoRun hooks, and `/s` makes
457
+ * cmd strip only the outermost quotes so a command carrying its own quoted
458
+ * arguments survives. Verified through wxc-exec: `&&`, `|`, `>` and nested
459
+ * quotes (`node -e "…"`) all work, and `dir`/`echo` resolve as cmd builtins.
460
+ */
461
+ function windowsShellCommand(command) {
462
+ return `cmd.exe /d /s /c "${command}"`;
463
+ }
464
+ /**
465
+ * `PATH` directories the container must be able to read for `cmd.exe` to
466
+ * resolve bare tool names such as `git`, `npm` and `node`.
467
+ *
468
+ * The container's `PATH` is the real, registry-derived one, so it lists
469
+ * directories like `C:\Program Files\nodejs` — but those are `EPERM` to the
470
+ * container until granted, so cmd.exe's lookup fails inside them and reports
471
+ * `'node' is not recognized as an internal or external command`. Granting them
472
+ * read-only fixes it. This is the same job MXC's own
473
+ * `policy.filesystem.getAvailableToolsPolicy` does, and the filters below
474
+ * follow it.
475
+ *
476
+ * Filtered rather than passed through wholesale:
477
+ * - only paths that exist and are directories,
478
+ * - nothing under `%SystemRoot%` — the container already has a baseline over
479
+ * the system directories, and MXC's helper excludes them for that reason,
480
+ * - never a drive root, and never a path that contains the home directory or
481
+ * the workspace: granting an ancestor of either aborts container creation
482
+ * outright (see `mxcPolicyJson`), and a `PATH` entry that contains the
483
+ * workspace would also contradict the read-write grant it already has.
484
+ */
485
+ function windowsToolPaths(workspace) {
486
+ const home = os.homedir();
487
+ const systemRoot = (process.env.SystemRoot || process.env.WINDIR || "C:\\Windows").replace(/\\+$/, "");
488
+ // True when `parent` is `child` or contains it.
489
+ const contains = (parent, child) => {
490
+ const rel = path.relative(parent, child);
491
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
492
+ };
493
+ const seen = new Set();
494
+ const out = [];
495
+ for (const raw of (process.env.PATH ?? "").split(path.delimiter)) {
496
+ const entry = raw.trim();
497
+ if (!entry || !path.isAbsolute(entry))
498
+ continue;
499
+ const abs = path.resolve(entry);
500
+ const key = abs.toLowerCase();
501
+ if (seen.has(key))
502
+ continue;
503
+ seen.add(key);
504
+ // `C:\` (and a bare `C:`) is the read-everything entry, handled separately.
505
+ if (abs === path.parse(abs).root)
506
+ continue;
507
+ if (abs.toLowerCase() === systemRoot.toLowerCase())
508
+ continue;
509
+ if (abs.toLowerCase().startsWith(systemRoot.toLowerCase() + "\\"))
510
+ continue;
511
+ if (contains(abs, home) || contains(abs, workspace))
512
+ continue;
513
+ try {
514
+ if (!fs.statSync(abs).isDirectory())
515
+ continue;
516
+ }
517
+ catch {
518
+ continue;
519
+ }
520
+ out.push(abs);
521
+ }
522
+ return out;
523
+ }
524
+ /**
525
+ * The MXC request for one command, as JSON. Mirrors the posture of the bwrap
526
+ * and sandbox-exec branches below: reads stay open, writes are confined to the
527
+ * workspace plus the same short allowlist of tool caches.
528
+ *
529
+ * MXC's schema is versioned and the version marker is part of the contract, so
530
+ * this targets 1.0.0 (`schemas/stable/mxc-config.schema.1.0.0.json`).
531
+ * `wxc-exec` validates natively and refuses a request it cannot serve, so a
532
+ * policy that is wrong here fails the command rather than running it unconfined.
533
+ *
534
+ * Deliberate choices, all to keep Windows consistent with the other platforms
535
+ * rather than stricter by accident:
536
+ * - The network is left open. Neither bwrap nor sandbox-exec restricts it, and
537
+ * a contained command that cannot reach the network cannot `npm install`,
538
+ * `git push`, or run a dev server. Tightening it is a cross-platform call.
539
+ * - The temp dir is not isolated. Windows has no tmpfs equivalent, so scratch
540
+ * state already persists across calls without a shared-dir bind. MXC hands
541
+ * the container its own writable temp dir regardless (it rewrites `TEMP` to
542
+ * `%LOCALAPPDATA%\Packages\sandbox.{…}\AC\Temp`), so nothing needs granting
543
+ * here either.
544
+ * - UI access stays enabled. MXC's default blocks the Win32k subsystem, and a
545
+ * contained process that cannot reach it dies before running a line of its
546
+ * own code, with STATUS_DLL_INIT_FAILED (0xC0000142) — wxc-exec names this
547
+ * exact setting when it reports that. bwrap and sandbox-exec do not restrict
548
+ * the UI either, so this is the consistent posture rather than a loosening.
549
+ * - The system drive root is granted as read-write, and `readonlyPaths` is
550
+ * omitted entirely. Both spellings are meant to say "reads stay open over
551
+ * the whole drive", and MXC's own `policy.filesystem.getPowerShellPolicy`
552
+ * emits the readonly one — but on Windows 11 24H2 that spelling aborts
553
+ * container creation. wxc-exec logs `process created (PID …)` and the child
554
+ * then exits 1 with no output, so the command silently never runs; it is
555
+ * reproducible with a bare `readonlyPaths: ["C:\\"]` and with the drive root
556
+ * added to a policy that already works. Every ancestor of the profile chain
557
+ * behaves the same way (`C:\Users`, `%USERPROFILE%`, `…\AppData`,
558
+ * `…\AppData\Local`), which is consistent with the drive root needing to be
559
+ * DACL-brokered for the contained token. Listing it under `readwritePaths`
560
+ * instead is harmless and grants what was actually intended: the command can
561
+ * stat `C:\` — which `node.exe`, `cmd.exe` and `pwsh.exe` all do at startup,
562
+ * and without which they die with `EPERM: lstat 'C:\'` — while writes to the
563
+ * root, to the profile and to `%USERPROFILE%` are still denied. Verified
564
+ * against `wxc-exec.exe` 1.0.0 on Windows 11 24H2 with a policy of exactly
565
+ * this shape: reads of `C:\`, `System32` and `Program Files` succeed, the
566
+ * workspace and the container's own temp dir are writable, and the profile
567
+ * root, the drive root and `C:\Windows\Temp` all stay unwritable.
568
+ * `readonlyPaths` carries the `PATH` tool directories from
569
+ * `windowsToolPaths`. MXC already gives the container a baseline over the
570
+ * system directories and over anything whose ACL carries an AppContainer
571
+ * capability SID, so most of `PATH` needs nothing said about it — but a
572
+ * directory outside that baseline (per-user installs such as
573
+ * `%APPDATA%\npm`) is `EPERM` until granted, and `cmd.exe` cannot resolve a
574
+ * bare `npm` or `node` that lives in one. The key is omitted rather than
575
+ * sent as `[]` when there is nothing to add, so a policy built without it
576
+ * is byte-identical to what this function produced before.
577
+ */
578
+ export function mxcPolicyJson(command, workspace, writable, readable = []) {
579
+ return JSON.stringify({
580
+ version: "1.0.0",
581
+ containment: "processcontainer",
582
+ process: { commandLine: command, cwd: workspace },
583
+ filesystem: {
584
+ // Existing paths only: the bwrap branch skips missing binds for the same
585
+ // reason, and naming a path that is not there is a needless way to have
586
+ // the whole request rejected. The drive root closes the list because it
587
+ // is the read-everything entry — see the note above for why it belongs
588
+ // here rather than in `readonlyPaths`.
589
+ readwritePaths: [...new Set([...writable, workspace, systemDriveRoot()])],
590
+ // Never the drive root or the profile chain — see the note above; both
591
+ // are fatal under `readonlyPaths`.
592
+ ...(readable.length > 0 ? { readonlyPaths: [...new Set(readable)] } : {}),
593
+ },
594
+ network: { egress: { default: "allow" }, ingress: { default: "allow" } },
595
+ ui: { disable: false },
596
+ // MXC telemetry is off unless the run opts in, the user consents, and
597
+ // administrative policy permits it. Kritya opts out on the user's behalf.
598
+ telemetry: { enabled: false },
599
+ });
600
+ }
368
601
  /**
369
602
  * Wraps `command` (run via `sh -c`) so it's confined to `workspace`: writes
370
603
  * are blocked everywhere else, network and (outside the real temp-dir roots)
@@ -382,6 +615,33 @@ export function buildSandboxedCommand(command, workspace) {
382
615
  return null;
383
616
  // Linked worktrees / submodules keep their real git dir outside the workspace.
384
617
  const gitDir = externalGitCommonDir(workspace);
618
+ if (tool === "mxc") {
619
+ // Windows: hand the whole request to wxc-exec, which creates the
620
+ // AppContainer and runs the command inside it. The policy travels as
621
+ // base64 rather than a file — the same way MXC's own SDK passes it — so
622
+ // there is no profile file to create, guard, or clean up. Compare the
623
+ // symlink/TOCTOU defences the macOS branch needs for its profile: never
624
+ // writing a policy to disk removes that whole class of problem.
625
+ //
626
+ // Deliberately ahead of the shared-temp-dir setup below, which creates a
627
+ // directory as a side effect: Windows has no tmpfs to carve it out of, so
628
+ // there is nothing for it to do here.
629
+ const exe = mxcExecutable();
630
+ if (!exe)
631
+ return null;
632
+ const writable = [...extraWritablePaths(), ...(gitDir ? [gitDir] : [])].filter((p) => fs.existsSync(p));
633
+ // Unlike the branches below there is no `sh -c` here to give the command a
634
+ // shell, so `windowsShellCommand` supplies the `cmd.exe` one — see its
635
+ // note. `windowsToolPaths` supplies the read-only grants cmd.exe needs to
636
+ // resolve bare tool names inside the container.
637
+ return {
638
+ cmd: exe,
639
+ args: [
640
+ "--config-base64",
641
+ Buffer.from(mxcPolicyJson(windowsShellCommand(command), workspace, writable, windowsToolPaths(workspace)), "utf8").toString("base64"),
642
+ ],
643
+ };
644
+ }
385
645
  // null if the path exists but isn't a real, self-owned directory (e.g.
386
646
  // another local user squatted it as a symlink) — see safeSandboxSharedTmpDir.
387
647
  const shared = safeSandboxSharedTmpDir();
@@ -125,7 +125,8 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
125
125
  const hasConfirmedAcceptEdits = useRef(false);
126
126
  const hasConfirmedBypassMode = useRef(false);
127
127
  /** Whether auto/bypass mode is even reachable — requires a real sandbox
128
- * backend on this platform (Windows never has one; see sandboxAvailable). */
128
+ * backend on this platform (on Windows that means MXC is installed; see
129
+ * sandboxAvailable). */
129
130
  const sandboxActive = (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable();
130
131
  const abortRef = useRef(null);
131
132
  /** Tool calls currently running, keyed by call id — more than one when a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.40-beta",
3
+ "version": "0.8.42-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {