kritya 0.8.58-beta → 0.8.60-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.
- package/dist/commands/registry.js +46 -1
- package/dist/config/config.js +1 -0
- package/dist/headless.js +7 -1
- package/dist/shell/sandbox.js +138 -14
- package/dist/tools/shell.js +8 -2
- package/package.json +1 -1
|
@@ -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) {
|
package/dist/config/config.js
CHANGED
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) {
|
package/dist/shell/sandbox.js
CHANGED
|
@@ -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"`
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
501
|
-
* `~/.npmrc` (`npm install` against a private registry,
|
|
502
|
-
* `~/.config/gh/hosts.yml` (the `gh` calls behind pull
|
|
503
|
-
* `~/.git-credentials` (HTTPS `git push`). Blocking those would
|
|
504
|
-
* ordinary user's day far more often than it would stop an attacker,
|
|
505
|
-
*
|
|
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
|
-
|
|
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 }, () => { });
|
package/dist/tools/shell.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|