kritya 0.8.56-beta → 0.8.57-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/README.md +28 -10
- package/dist/agent/toolExecutor.js +41 -1
- package/dist/config/config.js +3 -0
- package/dist/index.js +7 -0
- package/dist/permissions/later.js +136 -0
- package/dist/shell/sandbox.js +356 -39
- package/dist/ui/App.js +2 -2
- package/dist/ui/useAgent.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ are triaged.
|
|
|
13
13
|
|
|
14
14
|
Open source, [MIT licensed](LICENSE).
|
|
15
15
|
|
|
16
|
+
GitHub is the canonical repo; the [GitLab copy](https://gitlab.com/tech_venkat/kritya) is a read-only backup mirror.
|
|
17
|
+
|
|
16
18
|
Superintelligence at your command line
|
|
17
19
|
|
|
18
20
|
It works with any OpenAI-compatible endpoint: [build.nvidia.com](https://build.nvidia.com)
|
|
@@ -63,7 +65,7 @@ Requires Node.js >=22.19.0.
|
|
|
63
65
|
model → "Get API Key").
|
|
64
66
|
2. Set it (any one of these):
|
|
65
67
|
- Put `NVIDIA_API_KEY=nvapi-...` in a `.env` file — checked in the workspace
|
|
66
|
-
you launch in,
|
|
68
|
+
you launch in, and `~/.kritya/.env`
|
|
67
69
|
- Linux/macOS: `export NVIDIA_API_KEY=nvapi-...`
|
|
68
70
|
- Windows: `setx NVIDIA_API_KEY nvapi-...` (then open a new terminal)
|
|
69
71
|
|
|
@@ -97,6 +99,14 @@ cd ~/some-project
|
|
|
97
99
|
kritya # enter
|
|
98
100
|
```
|
|
99
101
|
|
|
102
|
+
### Desktop app
|
|
103
|
+
|
|
104
|
+
There is also an Electron desktop app wrapping the same agent (`electron/`,
|
|
105
|
+
`npm run electron:dev` / `npm run electron:build`). Automatic desktop builds
|
|
106
|
+
are currently paused; Windows installers are built manually via the
|
|
107
|
+
"Build Desktop App" workflow. See
|
|
108
|
+
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for how the app is put together.
|
|
109
|
+
|
|
100
110
|
## Screenshots
|
|
101
111
|
|
|
102
112
|
**One-time AI disclosure notice on first launch**
|
|
@@ -203,7 +213,9 @@ Custom `/commands` you define (see [docs/CONFIGURATION.md](docs/CONFIGURATION.md
|
|
|
203
213
|
`Esc` cancels a running request. `↑/↓` recalls input history. `Ctrl+O` toggles
|
|
204
214
|
full tool output. `Ctrl+E` opens the file the agent touched most recently in
|
|
205
215
|
`$EDITOR`. `Ctrl+P` opens the command palette (fuzzy-find commands and
|
|
206
|
-
checkpoints). `Ctrl+
|
|
216
|
+
checkpoints). `Ctrl+B` copies the agent's most recent code block. `Ctrl+R`
|
|
217
|
+
opens reverse history search. `Ctrl+K` is the kill switch (see below).
|
|
218
|
+
`Ctrl+C` exits.
|
|
207
219
|
|
|
208
220
|
### More features
|
|
209
221
|
|
|
@@ -288,14 +300,20 @@ checkpoints). `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
|
|
|
288
300
|
|
|
289
301
|
- **Trust levels** — `Shift+Tab` cycles **normal** (every write/edit asks
|
|
290
302
|
first) → **accept-edits** (file writes/edits auto-approve, no prompt) →
|
|
291
|
-
**
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
303
|
+
**dry-run** (read-only: the agent explores and proposes a plan; edits and
|
|
304
|
+
shell are blocked) → **auto** (EVERY tool call auto-approves, including
|
|
305
|
+
destructive shell commands — nothing asks first) → back to normal. Auto
|
|
306
|
+
mode is only offered when the sandbox is active, and the sandbox — not a
|
|
307
|
+
human — is what contains any damage it causes; with the sandbox off,
|
|
308
|
+
dry-run goes straight back to normal. In accept-edits mode, destructive
|
|
309
|
+
shell commands (`rm -rf`, force-push, etc.) still prompt — that guard only
|
|
310
|
+
turns off in auto mode. The statusline always shows which mode you're in,
|
|
311
|
+
with a running `(N auto-approved)` count in the auto-approve modes so you
|
|
312
|
+
know how much slipped through before checking `/diff`. The first time you
|
|
313
|
+
switch into accept-edits or auto mode each session, kritya asks you to
|
|
314
|
+
confirm first so it's a deliberate choice, not an accidental keypress.
|
|
315
|
+
`/plan on` and `/plan off` set plan mode explicitly; a bare `/plan`
|
|
316
|
+
toggles it. Plan mode is separate from this cycle. `/plan` never
|
|
299
317
|
touches the project workflow — that's `/flow-plan`.
|
|
300
318
|
- **Kill switch** — `Ctrl+K` (or `/kill [reason]`) is a hard stop for the whole
|
|
301
319
|
session. It aborts the in-flight model stream, any running tool, and every
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { classifyDanger } from "../permissions/danger.js";
|
|
2
|
+
import { classifyLaterExec, classifyLaterExecInShell, recordLaterExec, workspaceRelative, } from "../permissions/later.js";
|
|
2
3
|
import { acknowledgeUnsandboxedFallback, sandboxAvailable, sandboxFallbackWarning, } from "../shell/sandbox.js";
|
|
3
4
|
import { isPlanningDocWrite, loadProjectState } from "./workflow.js";
|
|
4
5
|
import { redactSecrets } from "../tools/secretScan.js";
|
|
@@ -48,6 +49,27 @@ export class ToolTimeoutError extends Error {
|
|
|
48
49
|
* applies regardless of any mode.
|
|
49
50
|
*/
|
|
50
51
|
const ACCEPT_EDITS_TOOL_NAMES = new Set(["write_file", "edit_file", "write_document"]);
|
|
52
|
+
/**
|
|
53
|
+
* A write to a path that can run code later (a git hook, a lifecycle script,
|
|
54
|
+
* a direnv config — see `permissions/later.ts`), or null for an ordinary file.
|
|
55
|
+
*
|
|
56
|
+
* Only the text being *added* is inspected, never `old_string`: removing a
|
|
57
|
+
* `postinstall` script is not the risky direction, and matching the removed
|
|
58
|
+
* text would prompt on the cleanup.
|
|
59
|
+
*/
|
|
60
|
+
function laterExecForWrite(args, workspace) {
|
|
61
|
+
const target = args.path;
|
|
62
|
+
if (typeof target !== "string" || !target.trim())
|
|
63
|
+
return null;
|
|
64
|
+
const rel = workspaceRelative(workspace, target);
|
|
65
|
+
if (!rel)
|
|
66
|
+
return null;
|
|
67
|
+
const added = [args.content, args.new_string, args.text]
|
|
68
|
+
.filter((v) => typeof v === "string")
|
|
69
|
+
.join("\n");
|
|
70
|
+
const label = classifyLaterExec(rel, added || undefined);
|
|
71
|
+
return label ? { path: rel, label } : null;
|
|
72
|
+
}
|
|
51
73
|
/**
|
|
52
74
|
* Wrap external (web/MCP) tool output in untrusted-content markers. Any copy
|
|
53
75
|
* of the marker text inside the content itself is neutralized first, so the
|
|
@@ -231,7 +253,21 @@ export class ToolExecutor {
|
|
|
231
253
|
const sandboxWarning = tool.name === "shell" && dangerLabel === null && host.interactive
|
|
232
254
|
? sandboxFallbackWarning(host.ctx.sandboxMode, shellCommand)
|
|
233
255
|
: null;
|
|
234
|
-
|
|
256
|
+
// The workspace is writable by design, so the sandbox cannot stop a command
|
|
257
|
+
// from *leaving* something behind that runs later — a git hook, a
|
|
258
|
+
// postinstall script, a direnv config. That is a different risk from
|
|
259
|
+
// anything classifyDanger covers (it is about what runs now), so it gets its
|
|
260
|
+
// own label and its own prompt.
|
|
261
|
+
//
|
|
262
|
+
// Prompted only where a human can answer: a write subagent's
|
|
263
|
+
// requestPermission auto-refuses anything carrying a danger label, which
|
|
264
|
+
// would turn every package.json edit into a refusal. The end-of-session
|
|
265
|
+
// list is unconditional, so nothing is lost silently.
|
|
266
|
+
const laterMatch = tool.name === "shell"
|
|
267
|
+
? classifyLaterExecInShell(shellCommand, host.ctx.workspace)
|
|
268
|
+
: laterExecForWrite(args, host.ctx.workspace);
|
|
269
|
+
const laterLabel = host.interactive ? (laterMatch?.label ?? null) : null;
|
|
270
|
+
const danger = dangerLabel ?? sandboxWarning ?? laterLabel;
|
|
235
271
|
const autoApproveEdit = host.acceptEdits &&
|
|
236
272
|
danger === null &&
|
|
237
273
|
tool.requiresPermission &&
|
|
@@ -347,6 +383,10 @@ export class ToolExecutor {
|
|
|
347
383
|
}
|
|
348
384
|
output = truncateToolOutput(output);
|
|
349
385
|
const failed = tool.failed?.(output) ?? false;
|
|
386
|
+
// Only a write that actually landed is worth putting on the sign-off
|
|
387
|
+
// list — a rejected or failed one left nothing behind.
|
|
388
|
+
if (!failed && laterMatch)
|
|
389
|
+
recordLaterExec(laterMatch);
|
|
350
390
|
logToolOutcome(failed ? "error" : "ok");
|
|
351
391
|
finishSpan(failed ? "ERROR" : "OK");
|
|
352
392
|
handlers.onToolEnd(id, name, summary, output.slice(0, PREVIEW_CHARS), failed, failed ? undefined : (tool.resultSummary?.(output, args) ?? undefined));
|
package/dist/config/config.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { createSwitchyardClient } from "./provider/switchyardClient.js";
|
|
|
13
13
|
import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel, staleSwitchyardModelWarning, } from "./provider/switchyardSidecar.js";
|
|
14
14
|
import { SessionStore } from "./session/store.js";
|
|
15
15
|
import { exitNotice } from "./session/exitNotice.js";
|
|
16
|
+
import { laterExecFiles, laterExecNotice } from "./permissions/later.js";
|
|
16
17
|
import { AuditLog } from "./audit/audit.js";
|
|
17
18
|
import { runAuditCli } from "./audit/cli.js";
|
|
18
19
|
import { runSkillsCli } from "./agent/skillsCli.js";
|
|
@@ -497,6 +498,12 @@ async function main() {
|
|
|
497
498
|
});
|
|
498
499
|
if (text)
|
|
499
500
|
writeExitNotice(text);
|
|
501
|
+
// Printed even when the session notice is not (privacy mode, an empty
|
|
502
|
+
// session): this is a warning about files left on disk, not about the
|
|
503
|
+
// transcript, so it does not belong to that decision.
|
|
504
|
+
const later = laterExecNotice(laterExecFiles(), Boolean(process.stdout.isTTY));
|
|
505
|
+
if (later)
|
|
506
|
+
writeExitNotice(later);
|
|
500
507
|
};
|
|
501
508
|
// Filled in once the app is mounted, below; the crash handler needs a way to
|
|
502
509
|
// tear the UI down and is installed before there is a UI to tear down.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Lifecycle scripts npm runs on its own: `npm install` fires preinstall,
|
|
4
|
+
* install, postinstall and prepare; `npm publish` fires prepublishOnly,
|
|
5
|
+
* prepack and postpack. Any of them is a shell command attached to an
|
|
6
|
+
* everyday action.
|
|
7
|
+
*/
|
|
8
|
+
const LIFECYCLE_RE = /"(pre|post)?(install|prepare|publish|pack)(Only)?"\s*:/i;
|
|
9
|
+
const RULES = [
|
|
10
|
+
{
|
|
11
|
+
re: /^\.git\/hooks\/[^/]+$/,
|
|
12
|
+
label: "a git hook (runs on your next git command)",
|
|
13
|
+
},
|
|
14
|
+
{ re: /^\.husky\/[^/]+$/, label: "a husky hook (runs on your next git command)" },
|
|
15
|
+
{
|
|
16
|
+
re: /^\.git\/config$/,
|
|
17
|
+
label: "git config (can redirect hooks or define shell aliases)",
|
|
18
|
+
},
|
|
19
|
+
{ re: /^\.envrc$/, label: "direnv config (runs when you enter this directory)" },
|
|
20
|
+
{
|
|
21
|
+
re: /^\.vscode\/(tasks|launch)\.json$/,
|
|
22
|
+
label: "a VS Code task (runs when you launch it)",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
re: /^\.pre-commit-config\.ya?ml$/,
|
|
26
|
+
label: "a pre-commit config (runs on your next commit)",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
re: /^package\.json$/,
|
|
30
|
+
label: "package.json scripts (npm runs them on install and publish)",
|
|
31
|
+
contentSensitive: true,
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
/** `.\git\hooks\x` and `./.git/hooks/x` both become `.git/hooks/x`. */
|
|
35
|
+
function normalizeRel(p) {
|
|
36
|
+
return p
|
|
37
|
+
.split(path.sep)
|
|
38
|
+
.join("/")
|
|
39
|
+
.replace(/\\/g, "/")
|
|
40
|
+
.replace(/^\.\//, "")
|
|
41
|
+
.replace(/\/+$/, "")
|
|
42
|
+
.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
/** A workspace-relative, `/`-separated form of `target`, or null if it is outside. */
|
|
45
|
+
export function workspaceRelative(workspace, target) {
|
|
46
|
+
const abs = path.resolve(workspace, target);
|
|
47
|
+
const rel = path.relative(workspace, abs);
|
|
48
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
|
|
49
|
+
return null;
|
|
50
|
+
return rel.split(path.sep).join("/");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The label for a path that runs code later, or null when it is an ordinary
|
|
54
|
+
* file.
|
|
55
|
+
*
|
|
56
|
+
* `text` is the content being written, when the caller has it — only the
|
|
57
|
+
* `contentSensitive` rules look at it, and only to decide whether the edit
|
|
58
|
+
* actually introduces a script.
|
|
59
|
+
*/
|
|
60
|
+
export function classifyLaterExec(relPath, text) {
|
|
61
|
+
const rel = normalizeRel(relPath);
|
|
62
|
+
// Git ships .git/hooks/*.sample as documentation; writing one of those runs
|
|
63
|
+
// nothing.
|
|
64
|
+
if (rel.endsWith(".sample"))
|
|
65
|
+
return null;
|
|
66
|
+
for (const rule of RULES) {
|
|
67
|
+
if (!rule.re.test(rel))
|
|
68
|
+
continue;
|
|
69
|
+
if (rule.contentSensitive && !(text && LIFECYCLE_RE.test(text)))
|
|
70
|
+
continue;
|
|
71
|
+
return rule.label;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The same check for a shell command, by looking at what it redirects into.
|
|
77
|
+
*
|
|
78
|
+
* Only redirects are inspected — `> file` and `>> file` — because that is the
|
|
79
|
+
* one way a shell command writes a specific path without going through the
|
|
80
|
+
* file tools. Anything cleverer (a script that writes the hook itself) has no
|
|
81
|
+
* path to match, and is what the end-of-session list is for.
|
|
82
|
+
*/
|
|
83
|
+
export function classifyLaterExecInShell(command, workspace) {
|
|
84
|
+
const re = />>?\s*(?:"([^"]+)"|'([^']+)'|([^\s;|&()<>]+))/g;
|
|
85
|
+
for (let m = re.exec(command); m !== null; m = re.exec(command)) {
|
|
86
|
+
const target = m[1] ?? m[2] ?? m[3];
|
|
87
|
+
if (!target || target === "/dev/null" || target.startsWith("&"))
|
|
88
|
+
continue;
|
|
89
|
+
const rel = workspaceRelative(workspace, target);
|
|
90
|
+
if (!rel)
|
|
91
|
+
continue;
|
|
92
|
+
const label = classifyLaterExec(rel, command);
|
|
93
|
+
if (label)
|
|
94
|
+
return { path: rel, label };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
// --- Session record ---------------------------------------------------------
|
|
99
|
+
/**
|
|
100
|
+
* Paths touched this session, keyed by relative path. A Map rather than a Set
|
|
101
|
+
* so the same file touched twice is listed once, with its label.
|
|
102
|
+
*/
|
|
103
|
+
const touched = new Map();
|
|
104
|
+
/** Remembers a file that can run code later. */
|
|
105
|
+
export function recordLaterExec(match) {
|
|
106
|
+
touched.set(match.path, match.label);
|
|
107
|
+
}
|
|
108
|
+
/** Everything recorded so far this session, sorted for a stable list. */
|
|
109
|
+
export function laterExecFiles() {
|
|
110
|
+
return [...touched.entries()]
|
|
111
|
+
.map(([p, label]) => ({ path: p, label }))
|
|
112
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
113
|
+
}
|
|
114
|
+
/** Test-only: forgets the session's record. */
|
|
115
|
+
export function resetLaterExec() {
|
|
116
|
+
touched.clear();
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The end-of-session list, or null when nothing was touched.
|
|
120
|
+
*
|
|
121
|
+
* Kept separate from the prompt on purpose: the prompt interrupts a write, and
|
|
122
|
+
* an interrupted write is easy to approve without reading. This is the last
|
|
123
|
+
* thing on screen, after the agent has stopped, which is when the question
|
|
124
|
+
* "what did I just let it leave here?" actually gets asked.
|
|
125
|
+
*/
|
|
126
|
+
export function laterExecNotice(files, isTTY = false) {
|
|
127
|
+
if (files.length === 0)
|
|
128
|
+
return null;
|
|
129
|
+
const dim = isTTY ? "\x1b[2m" : "";
|
|
130
|
+
const plain = isTTY ? "\x1b[22m" : "";
|
|
131
|
+
const n = files.length;
|
|
132
|
+
const lines = files.map((f) => ` ${f.path} ${dim}${f.label}${plain}`);
|
|
133
|
+
return (`${dim}${n} file${n === 1 ? "" : "s"} here can run code later, outside the sandbox:${plain}\n` +
|
|
134
|
+
lines.join("\n") +
|
|
135
|
+
`\n${dim} review before your next git or npm command${plain}`);
|
|
136
|
+
}
|
package/dist/shell/sandbox.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { CONFIG_DIR, loadDotEnv } from "../config/config.js";
|
|
6
|
+
import { CONFIG_DIR, loadConfig, loadDotEnv } from "../config/config.js";
|
|
7
7
|
import { classifyDanger } from "../permissions/danger.js";
|
|
8
8
|
let cachedTool;
|
|
9
9
|
let cachedMxcExec;
|
|
@@ -94,6 +94,7 @@ export function resetSandboxToolCache() {
|
|
|
94
94
|
cachedTool = undefined;
|
|
95
95
|
cachedMxcExec = undefined;
|
|
96
96
|
dotEnvLoaded = false;
|
|
97
|
+
bwrapMaskSupported = undefined;
|
|
97
98
|
}
|
|
98
99
|
/** Which sandbox backend (if any) is usable on this platform, cached after the first check. */
|
|
99
100
|
function sandboxTool() {
|
|
@@ -138,7 +139,7 @@ function windowsQuote(value) {
|
|
|
138
139
|
return `"${value.replace(/"/g, '""')}"`;
|
|
139
140
|
}
|
|
140
141
|
/**
|
|
141
|
-
* Turns the observed state of the canary's
|
|
142
|
+
* Turns the observed state of the canary's marker files into probes.
|
|
142
143
|
*
|
|
143
144
|
* Split out from `runSandboxCanary` so the decision — the part that can be
|
|
144
145
|
* wrong — is testable without a backend to spawn. `escaped` is "the file
|
|
@@ -149,9 +150,15 @@ function windowsQuote(value) {
|
|
|
149
150
|
* means the command never reached the shell at all, which must NOT be read as
|
|
150
151
|
* containment — that is why the inside write is a probe rather than a
|
|
151
152
|
* throwaway control.
|
|
153
|
+
*
|
|
154
|
+
* `credentialRead` is the result of trying to read one credential file from
|
|
155
|
+
* inside the sandbox, when there was one to try and this backend claims to
|
|
156
|
+
* deny it. Passing it makes the read probe a real claim; omitting it (or
|
|
157
|
+
* passing null) leaves the informational note that reads are open by design,
|
|
158
|
+
* which is the honest answer when there is nothing to probe.
|
|
152
159
|
*/
|
|
153
|
-
export function canaryProbes(escaped, wroteInside) {
|
|
154
|
-
|
|
160
|
+
export function canaryProbes(escaped, wroteInside, credentialRead) {
|
|
161
|
+
const probes = [
|
|
155
162
|
{
|
|
156
163
|
what: "a write outside the workspace (system temp dir) did not reach the host",
|
|
157
164
|
held: !escaped,
|
|
@@ -160,12 +167,37 @@ export function canaryProbes(escaped, wroteInside) {
|
|
|
160
167
|
what: "a write inside the workspace succeeded (the sandbox is not simply refusing everything)",
|
|
161
168
|
held: wroteInside,
|
|
162
169
|
},
|
|
163
|
-
|
|
164
|
-
|
|
170
|
+
];
|
|
171
|
+
probes.push(credentialRead
|
|
172
|
+
? {
|
|
173
|
+
what: `a credential file (${credentialRead.path}) could not be read`,
|
|
174
|
+
held: !credentialRead.readable,
|
|
175
|
+
}
|
|
176
|
+
: {
|
|
177
|
+
what: "credential reads were not probed (reads are otherwise open by design)",
|
|
165
178
|
held: false,
|
|
166
179
|
informational: true,
|
|
167
|
-
}
|
|
168
|
-
|
|
180
|
+
});
|
|
181
|
+
return probes;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* A credential file that exists on this host and is non-empty, so the canary
|
|
185
|
+
* has something whose content it can look for. Empty files are skipped: reading
|
|
186
|
+
* one yields nothing whether or not the deny is working, so a probe against it
|
|
187
|
+
* would pass for the wrong reason.
|
|
188
|
+
*/
|
|
189
|
+
function canaryCredentialFile() {
|
|
190
|
+
for (const p of sandboxReadDenyPaths()) {
|
|
191
|
+
try {
|
|
192
|
+
const st = fs.statSync(p);
|
|
193
|
+
if (st.isFile() && st.size > 0)
|
|
194
|
+
return p;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Not present on this host — try the next one.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
169
201
|
}
|
|
170
202
|
/**
|
|
171
203
|
* A live containment check: run a canary *inside* the sandbox that tries to
|
|
@@ -177,12 +209,14 @@ export function canaryProbes(escaped, wroteInside) {
|
|
|
177
209
|
* `processcontainer`, a stale `wxc-exec.exe`, or a host missing the one-time
|
|
178
210
|
* privilege prep all change the answer without changing a line of code here).
|
|
179
211
|
* This closes that gap the only honest way — by trying to escape and looking
|
|
180
|
-
* at the result. It writes at most
|
|
181
|
-
* and removes
|
|
212
|
+
* at the result. It writes at most three marker files — one in the system temp
|
|
213
|
+
* dir, two in the workspace — and removes all three either way.
|
|
182
214
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
215
|
+
* The write probes are always containment claims. The credential probe is one
|
|
216
|
+
* only when there is a credential file to read and the backend claims to deny
|
|
217
|
+
* it (Linux and macOS); otherwise it degrades to the informational note that
|
|
218
|
+
* reads are open by design, so a host with no `~/.aws` or `~/.ssh` key is not
|
|
219
|
+
* reported as a sandbox failure.
|
|
186
220
|
*/
|
|
187
221
|
export function runSandboxCanary(workspace) {
|
|
188
222
|
const reason = sandboxUnavailableReason();
|
|
@@ -194,8 +228,11 @@ export function runSandboxCanary(workspace) {
|
|
|
194
228
|
// blocked it. A path *inside* the workspace would prove nothing.
|
|
195
229
|
const escapeTarget = path.join(os.tmpdir(), `kritya-canary-escape-${process.pid}.txt`);
|
|
196
230
|
const insideTarget = path.join(workspace, `.kritya-canary-inside-${process.pid}.txt`);
|
|
231
|
+
// Where the credential probe's output lands. Its *size* is the answer: a
|
|
232
|
+
// masked file reads as zero bytes, an unmasked one gives up its content.
|
|
233
|
+
const credTarget = path.join(workspace, `.kritya-canary-cred-${process.pid}.txt`);
|
|
197
234
|
const cleanup = () => {
|
|
198
|
-
for (const f of [escapeTarget, insideTarget]) {
|
|
235
|
+
for (const f of [escapeTarget, insideTarget, credTarget]) {
|
|
199
236
|
try {
|
|
200
237
|
fs.rmSync(f, { force: true });
|
|
201
238
|
}
|
|
@@ -206,17 +243,28 @@ export function runSandboxCanary(workspace) {
|
|
|
206
243
|
};
|
|
207
244
|
cleanup(); // in case a previous run left a marker behind
|
|
208
245
|
const isWindows = os.platform() === "win32";
|
|
246
|
+
// MXC cannot deny a read (see mxcPolicyJson), so there is nothing to probe
|
|
247
|
+
// there — reporting the gap as a failure would blame the user for a schema
|
|
248
|
+
// limitation, and reporting it as a pass would be a lie.
|
|
249
|
+
const credential = isWindows ? null : canaryCredentialFile();
|
|
209
250
|
const writeOutside = isWindows
|
|
210
251
|
? `echo canary> ${windowsQuote(escapeTarget)}`
|
|
211
252
|
: `printf canary > ${JSON.stringify(escapeTarget)}`;
|
|
212
253
|
const writeInside = isWindows
|
|
213
254
|
? `echo canary> ${windowsQuote(insideTarget)}`
|
|
214
255
|
: `printf canary > ${JSON.stringify(insideTarget)}`;
|
|
256
|
+
// `head -c 64` rather than `cat`: enough to tell content from no content,
|
|
257
|
+
// and it keeps a large credential file from being copied into the workspace.
|
|
258
|
+
// The redirection creates the marker even when the read is refused, which is
|
|
259
|
+
// exactly the signal — empty marker means the deny held.
|
|
260
|
+
const readCredential = credential
|
|
261
|
+
? `; head -c 64 ${JSON.stringify(credential)} > ${JSON.stringify(credTarget)} 2>/dev/null`
|
|
262
|
+
: "";
|
|
215
263
|
// The canary exits 0 regardless: reaching the shell at all is the point, and
|
|
216
264
|
// a nonzero exit from a *blocked* write is not something the caller needs.
|
|
217
265
|
const script = isWindows
|
|
218
266
|
? `${writeOutside} & ${writeInside} & exit /b 0`
|
|
219
|
-
: `${writeOutside}; ${writeInside}; true`;
|
|
267
|
+
: `${writeOutside}; ${writeInside}${readCredential}; true`;
|
|
220
268
|
const wrapped = buildSandboxedCommand(script, workspace);
|
|
221
269
|
if (!wrapped) {
|
|
222
270
|
return { ran: false, reason, probes: [] };
|
|
@@ -241,8 +289,20 @@ export function runSandboxCanary(workspace) {
|
|
|
241
289
|
wrapped.cleanup?.();
|
|
242
290
|
const escaped = fs.existsSync(escapeTarget);
|
|
243
291
|
const wroteInside = fs.existsSync(insideTarget);
|
|
292
|
+
const credentialRead = credential
|
|
293
|
+
? { path: credential, readable: markerSize(credTarget) > 0 }
|
|
294
|
+
: null;
|
|
244
295
|
cleanup();
|
|
245
|
-
return { ran: true, probes: canaryProbes(escaped, wroteInside) };
|
|
296
|
+
return { ran: true, probes: canaryProbes(escaped, wroteInside, credentialRead) };
|
|
297
|
+
}
|
|
298
|
+
/** Byte size of a marker file, 0 when it is missing (the expected case). */
|
|
299
|
+
function markerSize(p) {
|
|
300
|
+
try {
|
|
301
|
+
return fs.statSync(p).size;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
246
306
|
}
|
|
247
307
|
/** One-line reason sandboxing can't run here, for a fallback warning. */
|
|
248
308
|
export function sandboxUnavailableReason() {
|
|
@@ -363,6 +423,106 @@ function extraWritablePaths() {
|
|
|
363
423
|
// don't exist, and the macOS profile tolerates nonexistent subpaths.
|
|
364
424
|
.concat([path.join(home, "Library", "Caches")]));
|
|
365
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* Entries inside `~/.ssh` that are *not* secret, and which SSH therefore has to
|
|
428
|
+
* read for anything to work: `config` supplies host aliases and identity files,
|
|
429
|
+
* `known_hosts` is what makes host-key verification possible, and `.pub` files
|
|
430
|
+
* are public by definition. Everything else in that directory is treated as a
|
|
431
|
+
* private key — including names this list has never heard of, which is the
|
|
432
|
+
* point: `id_rsa` and `id_ed25519` are the common spellings, not the only ones.
|
|
433
|
+
*/
|
|
434
|
+
const SSH_READABLE_NAMES = new Set([
|
|
435
|
+
"config",
|
|
436
|
+
"known_hosts",
|
|
437
|
+
"known_hosts.old",
|
|
438
|
+
"authorized_keys",
|
|
439
|
+
"environment",
|
|
440
|
+
"rc",
|
|
441
|
+
]);
|
|
442
|
+
/** `~/.ssh` entries that hold a private key, or a directory that holds them. */
|
|
443
|
+
function sshPrivateKeyPaths(home) {
|
|
444
|
+
const dir = path.join(home, ".ssh");
|
|
445
|
+
let entries;
|
|
446
|
+
try {
|
|
447
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return []; // no ~/.ssh at all — nothing to hide
|
|
451
|
+
}
|
|
452
|
+
return entries
|
|
453
|
+
.filter((e) => !SSH_READABLE_NAMES.has(e.name) && !e.name.endsWith(".pub"))
|
|
454
|
+
.map((e) => path.join(dir, e.name));
|
|
455
|
+
}
|
|
456
|
+
/** `~/x` and `~` expanded against the real home; anything else resolved as given. */
|
|
457
|
+
function expandHome(p) {
|
|
458
|
+
if (p === "~")
|
|
459
|
+
return os.homedir();
|
|
460
|
+
if (p.startsWith("~/") || p.startsWith("~\\"))
|
|
461
|
+
return path.join(os.homedir(), p.slice(2));
|
|
462
|
+
return path.resolve(p);
|
|
463
|
+
}
|
|
464
|
+
/** True when `parent` is `child` or contains it. */
|
|
465
|
+
function pathContains(parent, child) {
|
|
466
|
+
const rel = path.relative(parent, child);
|
|
467
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* `"none"` only when the user asked for it; anything else is `"open"`.
|
|
471
|
+
*
|
|
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.
|
|
476
|
+
*/
|
|
477
|
+
export function sandboxNetworkMode(config = loadConfig()) {
|
|
478
|
+
return config.sandboxNetwork === "none" ? "none" : "open";
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* The credential paths a sandboxed command must not read, as absolute paths.
|
|
482
|
+
*
|
|
483
|
+
* The list is short on purpose, and it is a *deny* list rather than a narrowing
|
|
484
|
+
* of the read-everything default because reads have to stay open generally:
|
|
485
|
+
* dynamic linking, package manager caches and every toolchain's own config live
|
|
486
|
+
* outside the workspace, and closing those off breaks ordinary work rather than
|
|
487
|
+
* containing anything. What is left is the handful of files whose only value to
|
|
488
|
+
* a hostile command is the secret inside them, and which almost no build or
|
|
489
|
+
* test run touches:
|
|
490
|
+
*
|
|
491
|
+
* - `~/.ssh` private keys (not `config`, `known_hosts`, or `.pub` files) —
|
|
492
|
+
* SSH through an agent never reads the key file, so the everyday
|
|
493
|
+
* `git push` keeps working while `cat ~/.ssh/id_rsa` stops working.
|
|
494
|
+
* - `~/.aws`, `~/.config/gcloud`, `~/.kube` — cloud and cluster credentials.
|
|
495
|
+
* A project's own build does not need *your* AWS keys.
|
|
496
|
+
* - `~/.docker/config.json` — registry auth tokens. The rest of `~/.docker`
|
|
497
|
+
* stays readable so contexts keep resolving.
|
|
498
|
+
* - `~/.netrc` — plaintext credentials for whatever `machine` entries it has.
|
|
499
|
+
*
|
|
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.
|
|
506
|
+
*
|
|
507
|
+
* Exported for tests.
|
|
508
|
+
*/
|
|
509
|
+
export function sandboxReadDenyPaths(config = loadConfig()) {
|
|
510
|
+
if (config.sandboxReadDeny === false)
|
|
511
|
+
return [];
|
|
512
|
+
const home = os.homedir();
|
|
513
|
+
const deny = [
|
|
514
|
+
path.join(home, ".aws"),
|
|
515
|
+
path.join(home, ".config", "gcloud"),
|
|
516
|
+
path.join(home, ".kube"),
|
|
517
|
+
path.join(home, ".docker", "config.json"),
|
|
518
|
+
path.join(home, ".netrc"),
|
|
519
|
+
...sshPrivateKeyPaths(home),
|
|
520
|
+
];
|
|
521
|
+
const allow = (config.sandboxReadAllow ?? [])
|
|
522
|
+
.filter((p) => typeof p === "string" && p.length > 0)
|
|
523
|
+
.map(expandHome);
|
|
524
|
+
return [...new Set(deny.filter((p) => !allow.some((a) => pathContains(a, p))))];
|
|
525
|
+
}
|
|
366
526
|
/**
|
|
367
527
|
* The one directory under the real system temp dir that sandboxed commands can
|
|
368
528
|
* write to and see again on the next invocation. The rest of /tmp is a fresh
|
|
@@ -522,8 +682,33 @@ export function sandboxPathVariants(p) {
|
|
|
522
682
|
function macTmpRoots() {
|
|
523
683
|
return [...new Set(["/tmp", "/private/tmp", os.tmpdir()].flatMap(sandboxPathVariants))];
|
|
524
684
|
}
|
|
525
|
-
|
|
526
|
-
|
|
685
|
+
/**
|
|
686
|
+
* Escapes a filesystem path for a string literal in a sandbox-exec profile.
|
|
687
|
+
* Backslashes first, so the quotes escaped second aren't double-escaped.
|
|
688
|
+
*/
|
|
689
|
+
function escapeProfilePath(p) {
|
|
690
|
+
return p.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* The `(deny file-read* …)` lines that make the credential list unreadable
|
|
694
|
+
* under sandbox-exec. Exported for tests — the profile itself is only ever
|
|
695
|
+
* built on macOS, so this is the only way CI can check what it would contain.
|
|
696
|
+
*
|
|
697
|
+
* Seatbelt resolves the last matching rule, which is what makes a deny here
|
|
698
|
+
* work at all: `(allow default)` comes first in the profile, so these later
|
|
699
|
+
* rules win over it. Both spellings of each path are emitted (see
|
|
700
|
+
* `sandboxPathVariants`) because the kernel matches against the canonical path,
|
|
701
|
+
* and on macOS `$HOME` can itself be a symlink (`/Users/x` → `/System/Volumes/
|
|
702
|
+
* Data/Users/x`) — a rule written only against the `/Users` spelling would
|
|
703
|
+
* match nothing.
|
|
704
|
+
*/
|
|
705
|
+
export function macReadDenyRules(paths) {
|
|
706
|
+
return [...new Set(paths.flatMap(sandboxPathVariants))]
|
|
707
|
+
.map((p) => `(deny file-read* (subpath "${escapeProfilePath(p)}"))`)
|
|
708
|
+
.join("\n");
|
|
709
|
+
}
|
|
710
|
+
function macSandboxProfile(workspace, extraDirs, shared, runDir, readDeny, network) {
|
|
711
|
+
const esc = escapeProfilePath;
|
|
527
712
|
// Every allow rule is emitted for both the literal and the symlink-resolved
|
|
528
713
|
// spelling of its path — see `sandboxPathVariants`. Without that, a workspace
|
|
529
714
|
// under `os.tmpdir()` (i.e. `/var/folders/...`) is silently unwritable,
|
|
@@ -540,11 +725,11 @@ function macSandboxProfile(workspace, extraDirs, shared, runDir) {
|
|
|
540
725
|
.map((p) => `(allow file-write* (subpath "${esc(p)}"))`)
|
|
541
726
|
.join("\n");
|
|
542
727
|
// Reads and process exec/fork stay open by default (matches the Linux
|
|
543
|
-
// ro-bind-everything posture below) EXCEPT under the real temp-dir roots
|
|
544
|
-
// which are denied and then selectively
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
// anything outside the project.
|
|
728
|
+
// ro-bind-everything posture below) EXCEPT under the real temp-dir roots and
|
|
729
|
+
// the credential list, both of which are denied and then selectively
|
|
730
|
+
// re-opened — see the tmp-root rules and `credentialDeny`. Writes are denied
|
|
731
|
+
// everywhere except the workspace, the per-run scratch dir, and a short
|
|
732
|
+
// explicit allowlist, so a command can't damage anything outside the project.
|
|
548
733
|
const readAllowDirs = variants([workspace, runDir, ...extraDirs, ...(shared ? [shared] : [])]);
|
|
549
734
|
const readExtra = readAllowDirs.map((p) => `(allow file-read* (subpath "${esc(p)}"))`).join("\n");
|
|
550
735
|
// The world-writable /tmp (and its real path /private/tmp), plus the
|
|
@@ -559,11 +744,25 @@ function macSandboxProfile(workspace, extraDirs, shared, runDir) {
|
|
|
559
744
|
const tmpDeny = macTmpRoots()
|
|
560
745
|
.map((p) => `(deny file-read* (subpath "${esc(p)}"))\n(deny file-write* (subpath "${esc(p)}"))`)
|
|
561
746
|
.join("\n");
|
|
747
|
+
// The credential list, denied for reads only. It sits after `(allow default)`
|
|
748
|
+
// so it wins, and before `readExtra` — but nothing in `readExtra` is on this
|
|
749
|
+
// list, so the two cannot fight. Writes to these paths are already blocked by
|
|
750
|
+
// the whole-filesystem write deny above.
|
|
751
|
+
const credentialDeny = macReadDenyRules(readDeny);
|
|
752
|
+
// `network-outbound` and not `network*`: denying the whole operation family
|
|
753
|
+
// would also block binding a listening socket, so `npm run dev` could not
|
|
754
|
+
// serve anything at all. Denying only outbound connections stops a command
|
|
755
|
+
// from *sending* anything while leaving a dev server reachable from the
|
|
756
|
+
// browser — the same intent as bwrap's `--unshare-net`, minus the loopback
|
|
757
|
+
// loss that flag brings with it.
|
|
758
|
+
const networkDeny = network === "none" ? "(deny network-outbound)" : "";
|
|
562
759
|
return `(version 1)
|
|
563
760
|
(allow default)
|
|
564
761
|
(deny file-write* (subpath "/"))
|
|
565
762
|
(allow file-write* (subpath "/dev"))
|
|
566
763
|
${tmpDeny}
|
|
764
|
+
${credentialDeny}
|
|
765
|
+
${networkDeny}
|
|
567
766
|
${writeExtra}
|
|
568
767
|
${readExtra}
|
|
569
768
|
`;
|
|
@@ -666,9 +865,17 @@ function windowsToolPaths(workspace) {
|
|
|
666
865
|
*
|
|
667
866
|
* Deliberate choices, all to keep Windows consistent with the other platforms
|
|
668
867
|
* rather than stricter by accident:
|
|
669
|
-
* - The network is left open
|
|
670
|
-
*
|
|
671
|
-
*
|
|
868
|
+
* - The network is left open unless `sandboxNetwork` is `"none"`, which turns
|
|
869
|
+
* both directions into a deny-by-default policy here. It is not restricted by
|
|
870
|
+
* default because a contained command that cannot reach the network cannot
|
|
871
|
+
* `npm install`, `git push`, or run a dev server.
|
|
872
|
+
* - Credential reads are NOT denied here, unlike the other two backends (see
|
|
873
|
+
* `sandboxReadDenyPaths`). MXC's filesystem policy is a grant list, and the
|
|
874
|
+
* drive root has to appear in `readwritePaths` for the container to start at
|
|
875
|
+
* all (see below) — which grants every path beneath it, with no deny entry
|
|
876
|
+
* to carve one back out. That is a limitation of the policy schema, not a
|
|
877
|
+
* choice about how much to enforce, so it is stated plainly in SECURITY.md
|
|
878
|
+
* rather than papered over.
|
|
672
879
|
* - The temp dir is not isolated. Windows has no tmpfs equivalent, so scratch
|
|
673
880
|
* state already persists across calls without a shared-dir bind. MXC hands
|
|
674
881
|
* the container its own writable temp dir regardless (it rewrites `TEMP` to
|
|
@@ -708,7 +915,7 @@ function windowsToolPaths(workspace) {
|
|
|
708
915
|
* sent as `[]` when there is nothing to add, so a policy built without it
|
|
709
916
|
* is byte-identical to what this function produced before.
|
|
710
917
|
*/
|
|
711
|
-
export function mxcPolicyJson(command, workspace, writable, readable = []) {
|
|
918
|
+
export function mxcPolicyJson(command, workspace, writable, readable = [], network = "open") {
|
|
712
919
|
return JSON.stringify({
|
|
713
920
|
version: "1.0.0",
|
|
714
921
|
containment: "processcontainer",
|
|
@@ -724,28 +931,124 @@ export function mxcPolicyJson(command, workspace, writable, readable = []) {
|
|
|
724
931
|
// are fatal under `readonlyPaths`.
|
|
725
932
|
...(readable.length > 0 ? { readonlyPaths: [...new Set(readable)] } : {}),
|
|
726
933
|
},
|
|
727
|
-
|
|
934
|
+
// MXC is the one backend that can express an allowlist here; `deny` with
|
|
935
|
+
// nothing listed is a plain "no network", which is what `sandboxNetwork:
|
|
936
|
+
// "none"` asks for.
|
|
937
|
+
network: network === "none"
|
|
938
|
+
? { egress: { default: "deny" }, ingress: { default: "deny" } }
|
|
939
|
+
: { egress: { default: "allow" }, ingress: { default: "allow" } },
|
|
728
940
|
ui: { disable: false },
|
|
729
941
|
// MXC telemetry is off unless the run opts in, the user consents, and
|
|
730
942
|
// administrative policy permits it. Kritya opts out on the user's behalf.
|
|
731
943
|
telemetry: { enabled: false },
|
|
732
944
|
});
|
|
733
945
|
}
|
|
946
|
+
/**
|
|
947
|
+
* The bwrap operations that make the credential list unreadable: an empty
|
|
948
|
+
* read-only tmpfs over a denied directory, and `/dev/null` bound over a denied
|
|
949
|
+
* file.
|
|
950
|
+
*
|
|
951
|
+
* Both hide the *contents* while leaving the path present, which matters: a
|
|
952
|
+
* path that vanished entirely would make ordinary code take its "not
|
|
953
|
+
* configured yet" branch, and some tools respond to that by creating the file —
|
|
954
|
+
* so a deny that reads as ENOENT is a worse lie than one that reads as an empty
|
|
955
|
+
* file. `~/.aws` as an empty directory and `~/.netrc` as a zero-byte file are
|
|
956
|
+
* both what "nothing set up here" looks like.
|
|
957
|
+
*
|
|
958
|
+
* `--dir` comes before `--tmpfs` because bwrap will not create a tmpfs mount
|
|
959
|
+
* point: mounting on a path that is absent from the new root fails the *whole*
|
|
960
|
+
* command with `Can't mount tmpfs on /newroot/…: No such file or directory`,
|
|
961
|
+
* which turns a read deny into "nothing works at all". `--dir` is a no-op when
|
|
962
|
+
* the path is already there and creates it when it isn't.
|
|
963
|
+
*
|
|
964
|
+
* Paths that don't exist are skipped, because a missing file is already
|
|
965
|
+
* unreadable and there is nothing to mask. `statSync` (not `lstatSync`) is used
|
|
966
|
+
* so a symlink to a directory takes the tmpfs branch — `mount(2)` resolves the
|
|
967
|
+
* link either way, and binding `/dev/null` over a directory fails the command.
|
|
968
|
+
*
|
|
969
|
+
* Exported for tests.
|
|
970
|
+
*/
|
|
971
|
+
export function bwrapReadDenyArgs(paths) {
|
|
972
|
+
const args = [];
|
|
973
|
+
// The *resolved* path, not the literal one: a credential directory that is a
|
|
974
|
+
// symlink (`~/.aws -> ~/dotfiles/aws`) has to be masked where it really
|
|
975
|
+
// lives, because `mount(2)` follows the link and a link whose target the
|
|
976
|
+
// kernel cannot resolve is one of the ways this mount fails.
|
|
977
|
+
for (const raw of new Set(paths)) {
|
|
978
|
+
const p = realpathBestEffort(raw);
|
|
979
|
+
let st;
|
|
980
|
+
try {
|
|
981
|
+
st = fs.statSync(p);
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (st.isDirectory())
|
|
987
|
+
args.push("--dir", p, "--tmpfs", p, "--remount-ro", p);
|
|
988
|
+
else
|
|
989
|
+
args.push("--ro-bind", "/dev/null", p);
|
|
990
|
+
}
|
|
991
|
+
return args;
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Whether this host's bwrap actually accepts the mask operations, asked once
|
|
995
|
+
* per process by running them for real.
|
|
996
|
+
*
|
|
997
|
+
* This exists because of a failure mode that has no compile-time signal: a mask
|
|
998
|
+
* bwrap refuses (`Can't mount tmpfs on /newroot/…: No such file or directory`)
|
|
999
|
+
* does not just skip that one deny — it fails the *entire* command, so every
|
|
1000
|
+
* shell call in the session dies and the sandbox looks broken rather than
|
|
1001
|
+
* narrow. A security feature that can brick the tool is worse than one that is
|
|
1002
|
+
* off, so the mask is used only if bwrap demonstrably accepts it here.
|
|
1003
|
+
*
|
|
1004
|
+
* The probe is the real argument list, not a synthetic one, so it exercises the
|
|
1005
|
+
* exact paths the session will use. If it fails, the read deny is silently
|
|
1006
|
+
* dropped and `kritya doctor`'s canary is what surfaces it: its credential
|
|
1007
|
+
* probe reads a real file and reports the miss.
|
|
1008
|
+
*/
|
|
1009
|
+
let bwrapMaskSupported;
|
|
1010
|
+
function bwrapAcceptsMasks(maskArgs) {
|
|
1011
|
+
if (maskArgs.length === 0)
|
|
1012
|
+
return true;
|
|
1013
|
+
if (bwrapMaskSupported !== undefined)
|
|
1014
|
+
return bwrapMaskSupported;
|
|
1015
|
+
try {
|
|
1016
|
+
const probe = spawnSync("bwrap", ["--ro-bind", "/", "/", ...maskArgs, "true"], {
|
|
1017
|
+
stdio: "ignore",
|
|
1018
|
+
timeout: 15_000,
|
|
1019
|
+
windowsHide: true,
|
|
1020
|
+
});
|
|
1021
|
+
bwrapMaskSupported = probe.status === 0;
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
bwrapMaskSupported = false;
|
|
1025
|
+
}
|
|
1026
|
+
return bwrapMaskSupported;
|
|
1027
|
+
}
|
|
734
1028
|
/**
|
|
735
1029
|
* Wraps `command` (run via `sh -c`) so it's confined to `workspace`: writes
|
|
736
|
-
* are blocked everywhere else,
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
* can read
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
1030
|
+
* are blocked everywhere else, reads are left open outside the workspace
|
|
1031
|
+
* *except* the credential list from `sandboxReadDenyPaths` (SSH private keys,
|
|
1032
|
+
* cloud and cluster credentials) and the real temp-dir roots, which are hidden
|
|
1033
|
+
* so one sandboxed command can't read another's scratch files, and the network
|
|
1034
|
+
* stays open unless `sandboxNetwork` is `"none"`.
|
|
1035
|
+
*
|
|
1036
|
+
* Read confinement stops there deliberately. Closing reads generally breaks
|
|
1037
|
+
* most ordinary tooling — dynamic linking, package manager caches, every
|
|
1038
|
+
* toolchain's own config live outside the workspace — so the sandbox hides the
|
|
1039
|
+
* few files whose only value to a hostile command is the secret inside them,
|
|
1040
|
+
* and leaves `cat` on everything else working. Returns null if no sandbox
|
|
1041
|
+
* binary is available on this platform.
|
|
744
1042
|
*/
|
|
745
|
-
export function buildSandboxedCommand(command, workspace) {
|
|
1043
|
+
export function buildSandboxedCommand(command, workspace, config = loadConfig()) {
|
|
746
1044
|
const tool = sandboxTool();
|
|
747
1045
|
if (!tool)
|
|
748
1046
|
return null;
|
|
1047
|
+
// Read once: both settings are consulted by every branch below, and the
|
|
1048
|
+
// third argument exists so tests can exercise a policy without writing to the
|
|
1049
|
+
// user's real config.json.
|
|
1050
|
+
const network = sandboxNetworkMode(config);
|
|
1051
|
+
const readDeny = sandboxReadDenyPaths(config);
|
|
749
1052
|
// Linked worktrees / submodules keep their real git dir outside the workspace.
|
|
750
1053
|
const gitDir = externalGitCommonDir(workspace);
|
|
751
1054
|
if (tool === "mxc") {
|
|
@@ -771,7 +1074,7 @@ export function buildSandboxedCommand(command, workspace) {
|
|
|
771
1074
|
cmd: exe,
|
|
772
1075
|
args: [
|
|
773
1076
|
"--config-base64",
|
|
774
|
-
Buffer.from(mxcPolicyJson(windowsShellCommand(command), workspace, writable, windowsToolPaths(workspace)), "utf8").toString("base64"),
|
|
1077
|
+
Buffer.from(mxcPolicyJson(windowsShellCommand(command), workspace, writable, windowsToolPaths(workspace), network), "utf8").toString("base64"),
|
|
775
1078
|
],
|
|
776
1079
|
};
|
|
777
1080
|
}
|
|
@@ -785,6 +1088,20 @@ export function buildSandboxedCommand(command, workspace) {
|
|
|
785
1088
|
// the host's real /tmp — which holds other agents' isolated worktrees and
|
|
786
1089
|
// is the easiest place to hardlink a host file and write through the link.
|
|
787
1090
|
const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp"];
|
|
1091
|
+
// A fresh, empty network namespace: nothing routable, not even loopback.
|
|
1092
|
+
// That is stricter than the macOS profile (which only denies outbound), and
|
|
1093
|
+
// it is the honest limit of what bwrap can express — it has no per-host
|
|
1094
|
+
// filter, so "no network" is the only useful setting it can enforce.
|
|
1095
|
+
if (network === "none")
|
|
1096
|
+
args.push("--unshare-net");
|
|
1097
|
+
// Masked before the writable binds below rather than after, so a workspace
|
|
1098
|
+
// that happens to contain one of these paths still wins — the same
|
|
1099
|
+
// precedence the macOS profile gives the workspace. Used only when bwrap
|
|
1100
|
+
// accepts the operations on this host; see `bwrapAcceptsMasks` for why a
|
|
1101
|
+
// mask that bwrap refuses must not take the whole command with it.
|
|
1102
|
+
const maskArgs = bwrapReadDenyArgs(readDeny);
|
|
1103
|
+
if (bwrapAcceptsMasks(maskArgs))
|
|
1104
|
+
args.push(...maskArgs);
|
|
788
1105
|
seedSshKnownHosts();
|
|
789
1106
|
for (const p of [
|
|
790
1107
|
...extraWritablePaths(),
|
|
@@ -815,7 +1132,7 @@ export function buildSandboxedCommand(command, workspace) {
|
|
|
815
1132
|
// it's about to create.
|
|
816
1133
|
const runDir = path.join(os.tmpdir(), `kritya-sandbox-run-${runSuffix}`);
|
|
817
1134
|
fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
|
|
818
|
-
fs.writeFileSync(profilePath, macSandboxProfile(workspace, gitDir ? [gitDir] : [], shared, runDir), { mode: 0o600, flag: "wx" });
|
|
1135
|
+
fs.writeFileSync(profilePath, macSandboxProfile(workspace, gitDir ? [gitDir] : [], shared, runDir, readDeny, network), { mode: 0o600, flag: "wx" });
|
|
819
1136
|
return {
|
|
820
1137
|
cmd: "sandbox-exec",
|
|
821
1138
|
args: ["-f", profilePath, "sh", "-c", command],
|
package/dist/ui/App.js
CHANGED
|
@@ -424,7 +424,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
424
424
|
void copyLastCodeBlock();
|
|
425
425
|
return;
|
|
426
426
|
}
|
|
427
|
-
// Shift+Tab cycles normal → accept-edits →
|
|
427
|
+
// Shift+Tab cycles normal → accept-edits → dry-run → auto → normal. Only from the
|
|
428
428
|
// plain input state — not mid-permission-prompt, mid-resume-picker, etc.
|
|
429
429
|
if (key.tab && key.shift && phase === "input") {
|
|
430
430
|
cycleMode();
|
|
@@ -731,7 +731,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
731
731
|
}
|
|
732
732
|
addItem({ kind: "user", text: `${text} (queued)` });
|
|
733
733
|
void expandMentions(text).then((t) => agent.queueSteer(t));
|
|
734
|
-
}, placeholder: "steer the agent\u2026 (Enter to queue)" })] })] })), phase === "permission" && permission && (_jsx(PermissionPrompt, { toolName: permission.toolName, summary: permission.summary, diff: permission.diff, warning: permission.warning, onDecision: onPermissionDecision })), phase === "elicitation" && elicitation && (_jsx(ElicitationPrompt, { message: elicitation.message, fields: elicitation.fields, onDecision: onElicitationDecision })), phase === "confirmMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "Switch to accept-edits mode?" }), _jsx(Text, { children: "File writes and edits will auto-approve without asking." }), _jsx(Text, { dimColor: true, children: "Destructive shell commands (rm -rf, force-push, etc.) always still ask
|
|
734
|
+
}, placeholder: "steer the agent\u2026 (Enter to queue)" })] })] })), phase === "permission" && permission && (_jsx(PermissionPrompt, { toolName: permission.toolName, summary: permission.summary, diff: permission.diff, warning: permission.warning, onDecision: onPermissionDecision })), phase === "elicitation" && elicitation && (_jsx(ElicitationPrompt, { message: elicitation.message, fields: elicitation.fields, onDecision: onElicitationDecision })), phase === "confirmMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "Switch to accept-edits mode?" }), _jsx(Text, { children: "File writes and edits will auto-approve without asking." }), _jsx(Text, { dimColor: true, children: "Destructive shell commands (rm -rf, force-push, etc.) always still ask in this mode." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again moves to dry-run mode; once more to auto mode, then back to normal." }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "green", children: "Yes (y)" }), " \u00B7 No (n/Esc)"] }) })] })), phase === "confirmBypassMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Switch to auto mode? (sandbox-gated)" }), _jsx(Text, { children: "EVERY tool call will auto-approve, including destructive shell commands (rm -rf, force-push, etc.) \u2014 nothing will ask first." }), _jsx(Text, { dimColor: true, children: "The sandbox \u2014 not a human \u2014 is what contains any damage this mode causes." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again goes back to normal." }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "red", children: "Yes (y)" }), " \u00B7 No (n/Esc)"] }) })] })), phase === "model" && (_jsx(ModelPicker, { current: model, provider: provider, customModels: config.customModels ?? [], onSelect: (id) => {
|
|
735
735
|
setPhase("input");
|
|
736
736
|
setModelEverywhere(id);
|
|
737
737
|
}, onCancel: () => setPhase("input") })), phase === "resume" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "magenta", children: ["Resume a session", " ", _jsx(Text, { dimColor: true, children: "(type to search \u00B7 Esc for a fresh one \u00B7 -r <name> to skip this)" })] }), resumeFilter ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), resumeFilter] })) : null, _jsx(SelectList, { items: (resumeSessions ?? [])
|
package/dist/ui/useAgent.js
CHANGED
|
@@ -252,7 +252,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
|
|
|
252
252
|
kind: "info",
|
|
253
253
|
text: "Accept-edits mode ON — file writes/edits auto-approve without asking. Destructive shell " +
|
|
254
254
|
"commands (rm -rf, force-push, etc.) still always ask. Shift+Tab again for dry-run mode, once " +
|
|
255
|
-
"more for normal.",
|
|
255
|
+
"more for auto mode, then back to normal.",
|
|
256
256
|
});
|
|
257
257
|
};
|
|
258
258
|
const enterBypassMode = () => {
|