kritya 0.8.56-beta → 0.8.58-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 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, the directory you run from, and `~/.kritya/.env`
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+K` is the kill switch (see below). `Ctrl+C` exits.
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
- **plan** (read-only, nothing executes) → back to normal. The statusline
292
- always shows which one you're in, with a running `(N auto-approved)` count
293
- in accept-edits mode so you know how much slipped through before checking
294
- `/diff`. Destructive shell commands (`rm -rf`, force-push, etc.) always
295
- still prompt, in every mode — that guard never turns off. The first time you
296
- switch into accept-edits mode each session, kritya asks you to confirm first
297
- so it's a deliberate choice, not an accidental keypress. `/plan on` and
298
- `/plan off` set the mode explicitly; a bare `/plan` toggles it. `/plan` never
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
- const danger = dangerLabel ?? sandboxWarning;
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));
@@ -126,6 +126,9 @@ const KNOWN_CONFIG_KEYS = new Set([
126
126
  "subagentWriteMaxSteps",
127
127
  "mcpServers",
128
128
  "sandboxExec",
129
+ "sandboxReadDeny",
130
+ "sandboxReadAllow",
131
+ "sandboxNetwork",
129
132
  "retentionDays",
130
133
  "audit",
131
134
  "otel",
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
+ }
@@ -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 two marker files into probes.
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
- return [
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
- what: "reads outside the workspace (incl. ~/.kritya/.env) are open by design",
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 one marker file, in the system temp dir,
181
- * and removes it either way.
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
- * Only the *write* probe is a containment claim: reads outside the workspace
184
- * are open by design (see `buildSandboxedCommand`), so the read probe is
185
- * reported but never counted as a failure.
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
- function macSandboxProfile(workspace, extraDirs, shared, runDir) {
526
- const esc = (p) => p.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
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 re-opened below — see the tmp-root
545
- // rules. Writes are denied everywhere except the workspace, the per-run
546
- // scratch dir, and a short explicit allowlist, so a command can't damage
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. Neither bwrap nor sandbox-exec restricts it, and
670
- * a contained command that cannot reach the network cannot `npm install`,
671
- * `git push`, or run a dev server. Tightening it is a cross-platform call.
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
- network: { egress: { default: "allow" }, ingress: { default: "allow" } },
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, network and (outside the real temp-dir roots)
737
- * reads are left open. This contains accidental or malicious damage outside
738
- * the project — it does not stop a command from reading files the real user
739
- * can read (e.g. `cat ~/.ssh/id_rsa`), since restricting reads generally
740
- * breaks most ordinary tooling (dynamic linking, package manager caches,
741
- * etc.); the temp-dir roots are the one exception, hidden to keep one
742
- * sandboxed command from reading another's scratch files. Returns null if no
743
- * sandbox binary is available on this platform.
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 → plan → normal. Only from the
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, in every mode." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again moves to dry-run mode; once more 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) => {
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 ?? [])
@@ -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 = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.56-beta",
3
+ "version": "0.8.58-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {