kritya 0.8.58-beta → 0.8.61-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.
@@ -7,9 +7,10 @@ import { mcpPrompts } from "../mcp/client.js";
7
7
  import { scanSkillsDetailed, skillsDir, userSkillsDir } from "../agent/skills.js";
8
8
  import { pluginsDir, pluginSkillsRoots, scanPlugins, scanPluginsDetailed, userPluginsDir, } from "../plugins/discover.js";
9
9
  import { scanPluginMcpServers } from "../plugins/mcp.js";
10
- import { listProviders } from "../config/config.js";
10
+ import { listProviders, loadConfig } from "../config/config.js";
11
11
  import { expandCommand } from "./custom.js";
12
12
  import { recordCommandUse } from "../ui/recentCommands.js";
13
+ import { headlessSandboxRestrictions, sandboxNetworkEscalated, sandboxNetworkMode, setSandboxNetworkEscalated, } from "../shell/sandbox.js";
13
14
  import { allTemplates, applyTemplate, isValidName, lastUserText, loadTemplates, saveTemplates, } from "./templates.js";
14
15
  import { gitBranch, gitCommitsAhead, gitDefaultBranch, gitHasUpstream, gitPush, gitRefExists, gitRemoteUrl, gitUnpushedCount, } from "../git/git.js";
15
16
  import { createPullRequest, findOpenPullRequest, parseGitHubRemote, parsePrFlags, prBodyFor, prTitleFor, resolveGitHubToken, } from "../git/pr.js";
@@ -157,6 +158,11 @@ export const BUILTIN_COMMANDS = [
157
158
  description: "show this session's permission decisions and verify the audit log's chain",
158
159
  category: SAFETY,
159
160
  },
161
+ {
162
+ name: "/network",
163
+ description: "sandbox network access: /network on|off for this session, or no argument to see it",
164
+ category: SAFETY,
165
+ },
160
166
  {
161
167
  name: "/budget",
162
168
  description: "show session token budget, /budget reset, or /budget <number> to set it",
@@ -739,6 +745,45 @@ const handlers = {
739
745
  "/status": (ctx) => {
740
746
  ctx.addItem({ kind: "info", text: ctx.statusReport() });
741
747
  },
748
+ "/network": (ctx) => {
749
+ const arg = ctx.arg.trim().toLowerCase();
750
+ const config = loadConfig();
751
+ if (arg === "on" || arg === "off") {
752
+ if (headlessSandboxRestrictions()) {
753
+ ctx.addItem({
754
+ kind: "info",
755
+ text: "This run is headless, so the sandbox keeps the network off regardless. " +
756
+ "A CI job has nobody to answer for the session, which is the whole point of the posture.",
757
+ });
758
+ return;
759
+ }
760
+ setSandboxNetworkEscalated(arg === "on");
761
+ ctx.addItem({
762
+ kind: "info",
763
+ text: arg === "on"
764
+ ? "Sandbox network: OPEN for the rest of this session. It is not per-host — every " +
765
+ "destination is reachable — and it resets when kritya restarts."
766
+ : "Sandbox network: CLOSED for the rest of this session. Commands can no longer reach the network.",
767
+ });
768
+ return;
769
+ }
770
+ const mode = sandboxNetworkMode(config);
771
+ const source = headlessSandboxRestrictions()
772
+ ? "forced by this headless run"
773
+ : sandboxNetworkEscalated()
774
+ ? "opened by /network on this session"
775
+ : config.sandboxNetwork === "open"
776
+ ? 'set by "sandboxNetwork": "open" in config'
777
+ : "the default";
778
+ ctx.addItem({
779
+ kind: "info",
780
+ text: `Sandbox network: ${mode.toUpperCase()} (${source}).\n` +
781
+ (mode === "none"
782
+ ? "Commands run without the network, so npm install, git push and docker pull fail. " +
783
+ "/network on opens it for this session."
784
+ : "Commands can reach the network. /network off closes it for this session."),
785
+ });
786
+ },
742
787
  "/audit": (ctx) => {
743
788
  const audit = ctx.agent.audit;
744
789
  if (!audit) {
@@ -128,6 +128,7 @@ const KNOWN_CONFIG_KEYS = new Set([
128
128
  "sandboxExec",
129
129
  "sandboxReadDeny",
130
130
  "sandboxReadAllow",
131
+ "sandboxReadDenyStrict",
131
132
  "sandboxNetwork",
132
133
  "retentionDays",
133
134
  "audit",
package/dist/headless.js CHANGED
@@ -24,7 +24,7 @@ import { loadHooks, HookRunner } from "./hooks/hooks.js";
24
24
  import { gatedContentHash, isTrusted } from "./trust/trust.js";
25
25
  import { partitionByTrust, serverFingerprint, trustServer } from "./trust/mcpTrust.js";
26
26
  import { installCrashHandlers } from "./crash.js";
27
- import { defaultSandboxMode } from "./shell/sandbox.js";
27
+ import { defaultSandboxMode, setHeadlessSandboxRestrictions } from "./shell/sandbox.js";
28
28
  /**
29
29
  * Whether a `--prompt` value looks like a slash command.
30
30
  *
@@ -112,6 +112,12 @@ export async function runHeadless(args) {
112
112
  if (trustWorkspace)
113
113
  loadDotEnv([path.join(workspace, ".env")]);
114
114
  const config = loadConfig();
115
+ // Nobody is at the terminal to answer "this looks untrusted" or "allow the
116
+ // network for this one", so a headless run takes the maximum-restriction
117
+ // posture instead of the interactive defaults: no egress, and the strict
118
+ // credential list. Set before any agent is built so every shell call in the
119
+ // run, including subagents, sees it.
120
+ setHeadlessSandboxRestrictions(true);
115
121
  const privacyMode = args.privacy === true || privacyModeFor(config);
116
122
  const provider = resolveProvider(config, args.provider || undefined);
117
123
  if (!provider.apiKey) {
@@ -8,6 +8,68 @@ import { classifyDanger } from "../permissions/danger.js";
8
8
  let cachedTool;
9
9
  let cachedMxcExec;
10
10
  let dotEnvLoaded = false;
11
+ /**
12
+ * True once a headless run has claimed maximum restriction.
13
+ *
14
+ * Module state rather than a config field, because the readers below are called
15
+ * from paths that pass no config and fall back to `loadConfig()` — a frontend
16
+ * that mutated its own copy of the config would not be seen by
17
+ * `buildSandboxedCommand`'s default argument. A process-wide flag is what makes
18
+ * the claim hold no matter which caller arrives first, and a headless run is
19
+ * one process, so there is nothing to scope it to.
20
+ *
21
+ * What it forces, and why each one:
22
+ * - network `"none"` — a CI job has nobody watching it, so the setting that
23
+ * depends on a human deciding "this looks untrusted" never gets chosen.
24
+ * - the strict credential list — `gh`, `npm publish` and HTTPS push are not
25
+ * things a headless run does on the user's behalf, so masking their tokens
26
+ * costs nothing there.
27
+ * - read denial even when `sandboxReadDeny` is false — the config was written
28
+ * for an interactive session; a headless run cannot show the prompt that
29
+ * would make an open read policy defensible.
30
+ *
31
+ * Deliberately not part of it: `sandboxExec`. A headless run with no backend
32
+ * already fails closed under "strict", and forcing the mode would silently
33
+ * override a host where the user deliberately chose "auto".
34
+ */
35
+ let headlessRestrictions = false;
36
+ /**
37
+ * Turns maximum restriction on or off for this process. Called by the headless
38
+ * entry point; exported so tests can drive both states without a CI runner.
39
+ */
40
+ export function setHeadlessSandboxRestrictions(on) {
41
+ headlessRestrictions = on;
42
+ }
43
+ /** Whether the headless maximum-restriction posture is in force. */
44
+ export function headlessSandboxRestrictions() {
45
+ return headlessRestrictions;
46
+ }
47
+ /**
48
+ * True once this session has been granted network access by hand.
49
+ *
50
+ * The counterpart to `headlessRestrictions`, and the reason the default can be
51
+ * `"none"` without making the tool unusable: a command that needs the network
52
+ * fails, says so, and the user opens it with `/network on` for the rest of the
53
+ * session. Resets on restart, deliberately — "I trusted this session" is not a
54
+ * fact about the next one, and the same posture as the unsandboxed-fallback
55
+ * acknowledgement above.
56
+ *
57
+ * Session-scoped rather than per-command because the sandbox backends cannot
58
+ * express "this host, not that one": bwrap's `--unshare-net` is a namespace,
59
+ * and sandbox-exec's rule is an operation family. A per-host allowlist would
60
+ * need a proxy in front of the sandbox, which is a different piece of work —
61
+ * so this grants the whole session and says so, rather than implying a
62
+ * precision that does not exist.
63
+ */
64
+ let networkEscalated = false;
65
+ /** Grants or revokes network access for the rest of this session. */
66
+ export function setSandboxNetworkEscalated(on) {
67
+ networkEscalated = on;
68
+ }
69
+ /** Whether this session has been granted network access by hand. */
70
+ export function sandboxNetworkEscalated() {
71
+ return networkEscalated;
72
+ }
11
73
  /**
12
74
  * Load `~/.kritya/.env` before the backend is resolved, once per process.
13
75
  *
@@ -467,15 +529,67 @@ function pathContains(parent, child) {
467
529
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
468
530
  }
469
531
  /**
470
- * `"none"` only when the user asked for it; anything else is `"open"`.
532
+ * `"none"` unless the user asked for `"open"`, this session was granted the
533
+ * network by hand, or a headless run is in force.
534
+ *
535
+ * Off by default because egress is the one thing that turns a command that read
536
+ * something it shouldn't into a command that sent it somewhere. The cost is
537
+ * that `npm install`, `git push` and `docker pull` fail until the network is
538
+ * opened, which is why a failure says how (see `networkBlockedHint`) and
539
+ * `/network on` grants it for the session.
471
540
  *
472
- * Deliberately not a default: cutting the network off stops `npm install`,
473
- * `git push` and `docker pull`, so it is the setting you switch on for a review
474
- * pass or for code you don't trust, not one that should surprise someone
475
- * mid-task. Exported for tests.
541
+ * Exported for tests.
476
542
  */
477
543
  export function sandboxNetworkMode(config = loadConfig()) {
478
- return config.sandboxNetwork === "none" ? "none" : "open";
544
+ if (headlessRestrictions)
545
+ return "none";
546
+ if (networkEscalated)
547
+ return "open";
548
+ return config.sandboxNetwork === "open" ? "open" : "none";
549
+ }
550
+ /**
551
+ * Whether a failed command's output looks like it died because the network was
552
+ * unreachable, rather than because the command itself was wrong.
553
+ *
554
+ * Deliberately loose: a false positive costs one extra line under an error that
555
+ * already happened, and a false negative leaves someone staring at
556
+ * `getaddrinfo ENOTFOUND` with no idea the sandbox caused it. Exported for
557
+ * tests.
558
+ */
559
+ export function looksLikeNetworkFailure(output) {
560
+ return /\b(ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH)\b|could not resolve host|network is unreachable|temporary failure in name resolution|getaddrinfo/i.test(output);
561
+ }
562
+ /**
563
+ * The line appended to a failed sandboxed command when the network was what
564
+ * stood in its way. Null when the network was open, or the failure looks
565
+ * unrelated, so nothing is said when nothing is wrong.
566
+ */
567
+ export function networkBlockedHint(network, output) {
568
+ if (network !== "none")
569
+ return null;
570
+ if (!looksLikeNetworkFailure(output))
571
+ return null;
572
+ return (`[network is off inside the sandbox, so this command could not reach the network. ` +
573
+ `Run /network on to allow it for the rest of the session, or set "sandboxNetwork": "open" ` +
574
+ `in ~/.kritya/config.json to make that the default.]`);
575
+ }
576
+ /**
577
+ * The credential files that are readable by default because a first-class
578
+ * workflow reads each one, and which `sandboxReadDenyStrict` (or a headless
579
+ * run) masks anyway.
580
+ *
581
+ * Kept separate from the base list so the two postures stay legible: the base
582
+ * list is "nobody needs this to build", and this one is "somebody needs this
583
+ * to work". Every entry here is a live token in a plain file, which is exactly
584
+ * why it is the interesting half of the deny list and why it cannot be the
585
+ * default half.
586
+ */
587
+ function strictReadDenyPaths(home) {
588
+ return [
589
+ path.join(home, ".npmrc"),
590
+ path.join(home, ".config", "gh", "hosts.yml"),
591
+ path.join(home, ".git-credentials"),
592
+ ];
479
593
  }
480
594
  /**
481
595
  * The credential paths a sandboxed command must not read, as absolute paths.
@@ -497,19 +611,26 @@ export function sandboxNetworkMode(config = loadConfig()) {
497
611
  * stays readable so contexts keep resolving.
498
612
  * - `~/.netrc` — plaintext credentials for whatever `machine` entries it has.
499
613
  *
500
- * Deliberately NOT denied, each because a first-class workflow reads it:
501
- * `~/.npmrc` (`npm install` against a private registry, `npm publish`),
502
- * `~/.config/gh/hosts.yml` (the `gh` calls behind pull requests), and
503
- * `~/.git-credentials` (HTTPS `git push`). Blocking those would break the
504
- * ordinary user's day far more often than it would stop an attacker, and the
505
- * deny list is intentionally fixed so the only knob is the allow side.
614
+ * Deliberately NOT denied by default, each because a first-class workflow
615
+ * reads it: `~/.npmrc` (`npm install` against a private registry,
616
+ * `npm publish`), `~/.config/gh/hosts.yml` (the `gh` calls behind pull
617
+ * requests), and `~/.git-credentials` (HTTPS `git push`). Blocking those would
618
+ * break the ordinary user's day far more often than it would stop an attacker,
619
+ * so they are the opt-in half of the list rather than an omission — see
620
+ * `sandboxReadDenyStrict` and `strictReadDenyPaths`.
621
+ *
622
+ * The deny list is fixed apart from that one switch, so the only per-path knob
623
+ * is the allow side.
506
624
  *
507
625
  * Exported for tests.
508
626
  */
509
627
  export function sandboxReadDenyPaths(config = loadConfig()) {
510
- if (config.sandboxReadDeny === false)
628
+ // A headless run does not get to honour `sandboxReadDeny: false`: there is
629
+ // nobody to ask whether an open read policy is acceptable here.
630
+ if (config.sandboxReadDeny === false && !headlessRestrictions)
511
631
  return [];
512
632
  const home = os.homedir();
633
+ const strict = config.sandboxReadDenyStrict === true || headlessRestrictions;
513
634
  const deny = [
514
635
  path.join(home, ".aws"),
515
636
  path.join(home, ".config", "gcloud"),
@@ -517,6 +638,7 @@ export function sandboxReadDenyPaths(config = loadConfig()) {
517
638
  path.join(home, ".docker", "config.json"),
518
639
  path.join(home, ".netrc"),
519
640
  ...sshPrivateKeyPaths(home),
641
+ ...(strict ? strictReadDenyPaths(home) : []),
520
642
  ];
521
643
  const allow = (config.sandboxReadAllow ?? [])
522
644
  .filter((p) => typeof p === "string" && p.length > 0)
@@ -1076,6 +1198,7 @@ export function buildSandboxedCommand(command, workspace, config = loadConfig())
1076
1198
  "--config-base64",
1077
1199
  Buffer.from(mxcPolicyJson(windowsShellCommand(command), workspace, writable, windowsToolPaths(workspace), network), "utf8").toString("base64"),
1078
1200
  ],
1201
+ network,
1079
1202
  };
1080
1203
  }
1081
1204
  // null if the path exists but isn't a real, self-owned directory (e.g.
@@ -1112,7 +1235,7 @@ export function buildSandboxedCommand(command, workspace, config = loadConfig())
1112
1235
  args.push("--bind", p, p);
1113
1236
  }
1114
1237
  args.push("--bind", workspace, workspace, "--chdir", workspace, "--unshare-pid", "--die-with-parent", "sh", "-c", command);
1115
- return { cmd: "bwrap", args };
1238
+ return { cmd: "bwrap", args, network };
1116
1239
  }
1117
1240
  // sandbox-exec (macOS) takes its policy as a profile file, not inline args.
1118
1241
  // Named with the same pid+timestamp+random suffix as runDir below, so a
@@ -1136,6 +1259,7 @@ export function buildSandboxedCommand(command, workspace, config = loadConfig())
1136
1259
  return {
1137
1260
  cmd: "sandbox-exec",
1138
1261
  args: ["-f", profilePath, "sh", "-c", command],
1262
+ network,
1139
1263
  env: { TMPDIR: runDir, TMP: runDir, TEMP: runDir },
1140
1264
  cleanup: () => {
1141
1265
  fs.rm(profilePath, { force: true }, () => { });
@@ -2,7 +2,7 @@ import { exec, execFile } from "node:child_process";
2
2
  import { scrubbedShellEnv } from "../config/config.js";
3
3
  import { gitDiffStat } from "../git/git.js";
4
4
  import { backgroundManager } from "../shell/background.js";
5
- import { buildSandboxedCommand, requiresSandbox, sandboxUnavailableReason, shouldSandbox, } from "../shell/sandbox.js";
5
+ import { buildSandboxedCommand, networkBlockedHint, requiresSandbox, sandboxUnavailableReason, shouldSandbox, } from "../shell/sandbox.js";
6
6
  import { commandTouchesSensitivePath, truncateTail } from "./common.js";
7
7
  import { redactSecrets } from "./secretScan.js";
8
8
  const DEFAULT_TIMEOUT_S = 120;
@@ -114,7 +114,13 @@ export const shellTool = {
114
114
  : runOpts;
115
115
  execFile(wrapped.cmd, wrapped.args, opts, (error, stdout, stderr) => {
116
116
  wrapped.cleanup?.();
117
- finish(resolve, error, stdout, stderr);
117
+ // Only on failure, and only when the sandbox was the reason: a
118
+ // command that fails with a name-resolution error inside a
119
+ // no-network sandbox otherwise looks like a broken project rather
120
+ // than a setting the user can change.
121
+ finish(resolve, error, stdout, stderr, error
122
+ ? (networkBlockedHint(wrapped.network, `${stdout}\n${stderr}`) ?? undefined)
123
+ : undefined);
118
124
  });
119
125
  return;
120
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.58-beta",
3
+ "version": "0.8.61-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {