kritya 0.8.2-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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. package/package.json +122 -0
@@ -0,0 +1,396 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { classifyDanger } from "../permissions/danger.js";
6
+ let cachedTool;
7
+ function commandExists(bin) {
8
+ const finder = os.platform() === "win32" ? "where" : "which";
9
+ try {
10
+ return spawnSync(finder, [bin], { stdio: "ignore" }).status === 0;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /** Which sandbox binary (if any) is usable on this platform, cached after the first check. */
17
+ function sandboxTool() {
18
+ if (cachedTool !== undefined)
19
+ return cachedTool;
20
+ const platform = os.platform();
21
+ if (platform === "linux") {
22
+ cachedTool = commandExists("bwrap") ? "bwrap" : null;
23
+ }
24
+ else if (platform === "darwin") {
25
+ cachedTool = commandExists("sandbox-exec") ? "sandbox-exec" : null;
26
+ }
27
+ else {
28
+ cachedTool = null;
29
+ }
30
+ return cachedTool;
31
+ }
32
+ export function sandboxAvailable() {
33
+ return sandboxTool() !== null;
34
+ }
35
+ /**
36
+ * Default `sandboxMode` when the config leaves `sandboxExec` unset. "auto" on
37
+ * Linux/macOS, where bwrap/sandbox-exec can actually confine writes to the
38
+ * workspace. On Windows there's no sandbox backend at all (see
39
+ * `sandboxUnavailableReason`), so "auto"/"always" would let a flagged command
40
+ * run unprotected outside the workspace — "strict" refuses those instead of
41
+ * silently falling back.
42
+ */
43
+ export function defaultSandboxMode() {
44
+ return os.platform() === "win32" ? "strict" : "auto";
45
+ }
46
+ /** One-line reason sandboxing can't run here, for a fallback warning. */
47
+ export function sandboxUnavailableReason() {
48
+ const platform = os.platform();
49
+ if (platform === "win32")
50
+ return "sandboxed execution isn't supported on Windows yet";
51
+ if (platform === "linux")
52
+ return "bwrap (bubblewrap) not found on PATH";
53
+ if (platform === "darwin")
54
+ return "sandbox-exec not found on PATH";
55
+ return `sandboxed execution isn't supported on ${platform}`;
56
+ }
57
+ /** Whether `command` should run sandboxed under the given mode. */
58
+ export function shouldSandbox(mode, command) {
59
+ if (!mode || mode === "off")
60
+ return false;
61
+ // "always"/"strict" mean always, on every platform — including Windows,
62
+ // where there's no sandbox binary to back it, so every command falls back
63
+ // to the "[sandbox unavailable]" note ("always") or is refused outright
64
+ // ("strict"). That's deliberate: these are the modes for someone who wants
65
+ // maximum enforcement/visibility even without a real sandbox backing it.
66
+ if (mode === "always" || mode === "strict")
67
+ return true;
68
+ // "auto": Windows has no sandbox binary at all — falling back to "only
69
+ // flagged commands" (today's behavior) avoids a spurious fallback note on
70
+ // every single shell call, which "sandbox everything" would otherwise cause.
71
+ if (os.platform() === "win32")
72
+ return classifyDanger(command) !== null;
73
+ return true;
74
+ }
75
+ /**
76
+ * Whether a command that `shouldSandbox` flagged, but for which no sandbox
77
+ * binary is available on this platform, must be refused outright rather than
78
+ * falling back to an unsandboxed run. Only "strict" has this fail-closed
79
+ * behavior; "auto" and "always" fail open with a warning note instead.
80
+ */
81
+ export function requiresSandbox(mode) {
82
+ return mode === "strict";
83
+ }
84
+ /**
85
+ * Common tool-cache / global-install directories outside the workspace that
86
+ * legitimate commands need to write to (package manager caches, toolchain
87
+ * installs) even though sandboxing now applies to every command by default.
88
+ * Kept short and explicit rather than trying to infer "safe" paths.
89
+ */
90
+ function extraWritablePaths() {
91
+ const home = os.homedir();
92
+ return ([
93
+ // Package-manager / toolchain caches.
94
+ ".npm",
95
+ ".cache",
96
+ ".cargo",
97
+ ".rustup",
98
+ ".gem",
99
+ // Deliberately NARROW entries, not whole dotfile directories: a writable
100
+ // ~/.ssh lets a command plant a `ProxyCommand` in ~/.ssh/config, a
101
+ // writable ~/.config lets it plant a `!sh -c ...` git alias in
102
+ // ~/.config/git/config, and a writable ~/.local lets it shadow a real
103
+ // binary via ~/.local/bin — each of which is arbitrary code execution
104
+ // outside the sandbox on the next unrelated command.
105
+ //
106
+ // known_hosts (a *file*): git push/clone over SSH appends to it the
107
+ // first time a host is seen. ~/.config/gh: the GitHub CLI's config.
108
+ // ~/.local/share: the XDG data dir (pip --user site tracking, etc.).
109
+ path.join(".ssh", "known_hosts"),
110
+ path.join(".config", "gh"),
111
+ path.join(".local", "share"),
112
+ // ~/.gnupg stays whole: GPG-signed commits need to write the agent
113
+ // socket (S.gpg-agent), trustdb.gpg and pubring.kbx, which sit directly
114
+ // in that directory, so there's no single safe subpath to narrow to.
115
+ // Unlike .ssh/.config/.local it holds no "run this command" config that
116
+ // another tool executes, and GPG keeps it 0700 itself.
117
+ ".gnupg",
118
+ ]
119
+ .map((d) => path.join(home, d))
120
+ // macOS-only, but harmless elsewhere: the bwrap branch skips paths that
121
+ // don't exist, and the macOS profile tolerates nonexistent subpaths.
122
+ .concat([path.join(home, "Library", "Caches")]));
123
+ }
124
+ /**
125
+ * The one directory under the real system temp dir that sandboxed commands can
126
+ * write to and see again on the next invocation. The rest of /tmp is a fresh
127
+ * per-invocation tmpfs, so a sandboxed command can't reach other agents'
128
+ * isolated worktrees (`os.tmpdir()/kritya-worktrees`) or hardlink a host file
129
+ * into /tmp and write through the link to dodge the read-only bind.
130
+ */
131
+ export function sandboxSharedTmpDir() {
132
+ return path.join(os.tmpdir(), "kritya-sandbox-shared");
133
+ }
134
+ /**
135
+ * Creates (if needed) and validates the shared sandbox temp dir, returning its
136
+ * path only if it's safe to bind read-write into every sandboxed command —
137
+ * otherwise `null`, and callers must skip the bind rather than use it.
138
+ *
139
+ * On a multi-user host, `os.tmpdir()` (usually `/tmp`) is world-writable, so
140
+ * another local user could pre-create `kritya-sandbox-shared` as a symlink to
141
+ * somewhere sensitive (e.g. the real user's `~/.ssh`) before we ever get to
142
+ * it. Binding that in read-write would hand every sandboxed command a path
143
+ * back into the real filesystem — exactly what the dedicated-tmpfs isolation
144
+ * above exists to prevent. Guarding against it requires:
145
+ * - creating with `mode: 0o700` so a *freshly created* dir isn't itself
146
+ * squattable by another user afterwards, and
147
+ * - `fs.lstatSync` (never `fs.statSync`, which follows symlinks) to confirm
148
+ * the path is a real directory we own before trusting it.
149
+ */
150
+ function safeSandboxSharedTmpDir() {
151
+ const shared = sandboxSharedTmpDir();
152
+ try {
153
+ fs.mkdirSync(shared, { recursive: true, mode: 0o700 });
154
+ }
155
+ catch {
156
+ // May already exist (as a legitimate dir from an earlier run, or as
157
+ // something a hostile squatter left behind) — fall through to the lstat
158
+ // check below, which is the real safety gate either way.
159
+ }
160
+ let st;
161
+ try {
162
+ st = fs.lstatSync(shared);
163
+ }
164
+ catch {
165
+ // Doesn't exist and we couldn't create it — no bind, sandbox still runs.
166
+ return null;
167
+ }
168
+ if (!st.isDirectory())
169
+ return null; // symlink, file, or anything else squatted here
170
+ if (typeof process.getuid === "function" && st.uid !== process.getuid())
171
+ return null;
172
+ return shared;
173
+ }
174
+ /**
175
+ * bwrap can only bind paths that already exist, and `~/.ssh/known_hosts` is a
176
+ * *file* that doesn't exist until the first SSH connection — so without this,
177
+ * a first-time `git clone git@host:...` inside the sandbox could never create
178
+ * it. Seeding an empty 0600 file (only when ~/.ssh already exists, so we never
179
+ * create the directory ourselves) keeps first-use SSH working without falling
180
+ * back to binding the whole ~/.ssh directory.
181
+ */
182
+ function seedSshKnownHosts() {
183
+ try {
184
+ const sshDir = path.join(os.homedir(), ".ssh");
185
+ if (!fs.existsSync(sshDir))
186
+ return;
187
+ const knownHosts = path.join(sshDir, "known_hosts");
188
+ // O_CREAT|O_EXCL ("wx"): the create is atomic, so a concurrent seeder
189
+ // that wins the race gets EEXIST here (caught below and ignored) instead
190
+ // of this call truncating a file that gained content in the meantime.
191
+ fs.writeFileSync(knownHosts, "", { mode: 0o600, flag: "wx" });
192
+ }
193
+ catch {
194
+ // Either it already exists (including the race above) or we can't create
195
+ // it: best effort — if we can't seed it, the bind is skipped and SSH to a
196
+ // new host fails with a clear error instead of silently escaping the
197
+ // sandbox.
198
+ }
199
+ }
200
+ /**
201
+ * The git common directory for `workspace` when it lives OUTSIDE the workspace.
202
+ *
203
+ * In a linked worktree (or a submodule) `.git` is a *file* pointing at
204
+ * `<main-repo>/.git/worktrees/<name>` (or `<parent>/.git/modules/<name>`),
205
+ * which the sandbox would otherwise mount read-only — breaking `git add`,
206
+ * `git commit`, `git stash`, and friends with "Read-only file system".
207
+ * Returns null for a plain repo (whose common dir is `workspace/.git`, already
208
+ * writable), for a non-repo, or if git isn't available.
209
+ */
210
+ function externalGitCommonDir(workspace) {
211
+ try {
212
+ const res = spawnSync("git", ["rev-parse", "--git-common-dir"], {
213
+ cwd: workspace,
214
+ encoding: "utf8",
215
+ });
216
+ if (res.status !== 0 || !res.stdout)
217
+ return null;
218
+ const raw = res.stdout.trim();
219
+ if (!raw)
220
+ return null;
221
+ // Git may print this relative to the cwd we passed in.
222
+ const abs = path.resolve(workspace, raw);
223
+ const rel = path.relative(workspace, abs);
224
+ // Inside the workspace already (the plain-repo case) — no extra bind needed.
225
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)))
226
+ return null;
227
+ if (!fs.existsSync(abs))
228
+ return null;
229
+ return abs;
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
235
+ /**
236
+ * `fs.realpathSync`, but tolerant of paths that don't exist yet: resolves the
237
+ * deepest existing ancestor and re-appends the missing tail. Needed because
238
+ * several paths the profile opens up are named before they exist (a per-run
239
+ * scratch dir, `~/.ssh/known_hosts`, cache dirs on a fresh machine).
240
+ */
241
+ function realpathBestEffort(p) {
242
+ const abs = path.resolve(p);
243
+ let cur = abs;
244
+ const tail = [];
245
+ for (;;) {
246
+ try {
247
+ return path.join(fs.realpathSync(cur), ...tail);
248
+ }
249
+ catch {
250
+ const parent = path.dirname(cur);
251
+ if (parent === cur)
252
+ return abs; // reached the root with nothing resolvable
253
+ tail.unshift(path.basename(cur));
254
+ cur = parent;
255
+ }
256
+ }
257
+ }
258
+ /**
259
+ * Every spelling of `p` a sandbox rule may need to name: the literal path and,
260
+ * when it differs, its symlink-resolved real path.
261
+ *
262
+ * This matters on macOS, where `sandbox-exec` matches `subpath` against the
263
+ * kernel-canonicalized path. `os.tmpdir()` there is `/var/folders/.../T`, whose
264
+ * real path is `/private/var/folders/.../T` — so a rule written against the
265
+ * `/var` spelling never matches anything. The tmp-root DENY rules already
266
+ * resolve, which means an allow rule that doesn't resolve loses to them and the
267
+ * path stays blocked. Exported for tests.
268
+ */
269
+ export function sandboxPathVariants(p) {
270
+ const abs = path.resolve(p);
271
+ return [...new Set([abs, realpathBestEffort(abs)])];
272
+ }
273
+ /**
274
+ * Real filesystem locations that back "the system temp dir" on macOS: `/tmp`
275
+ * (a symlink to `/private/tmp`), that resolved target, and `os.tmpdir()`
276
+ * itself (usually `/private/var/folders/.../T`, but honors `$TMPDIR`).
277
+ * Deduped and resolved through symlinks so the profile's deny rules cover
278
+ * whichever spelling a command actually uses.
279
+ */
280
+ function macTmpRoots() {
281
+ return [...new Set(["/tmp", "/private/tmp", os.tmpdir()].flatMap(sandboxPathVariants))];
282
+ }
283
+ function macSandboxProfile(workspace, extraDirs, shared, runDir) {
284
+ const esc = (p) => p.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
285
+ // Every allow rule is emitted for both the literal and the symlink-resolved
286
+ // spelling of its path — see `sandboxPathVariants`. Without that, a workspace
287
+ // under `os.tmpdir()` (i.e. `/var/folders/...`) is silently unwritable,
288
+ // because the tmp-root deny rules below resolve and these allows would not.
289
+ const variants = (paths) => [...new Set(paths.flatMap(sandboxPathVariants))];
290
+ const writeAllowDirs = variants([
291
+ ...extraWritablePaths(),
292
+ ...extraDirs,
293
+ ...(shared ? [shared] : []),
294
+ workspace,
295
+ runDir,
296
+ ]);
297
+ const writeExtra = writeAllowDirs
298
+ .map((p) => `(allow file-write* (subpath "${esc(p)}"))`)
299
+ .join("\n");
300
+ // Reads and process exec/fork stay open by default (matches the Linux
301
+ // ro-bind-everything posture below) EXCEPT under the real temp-dir roots,
302
+ // which are denied and then selectively re-opened below — see the tmp-root
303
+ // rules. Writes are denied everywhere except the workspace, the per-run
304
+ // scratch dir, and a short explicit allowlist, so a command can't damage
305
+ // anything outside the project.
306
+ const readAllowDirs = variants([workspace, runDir, ...extraDirs, ...(shared ? [shared] : [])]);
307
+ const readExtra = readAllowDirs.map((p) => `(allow file-read* (subpath "${esc(p)}"))`).join("\n");
308
+ // The world-writable /tmp (and its real path /private/tmp), plus the
309
+ // per-user TMPDIR under /private/var/folders, are denied wholesale for both
310
+ // reads and writes — hiding any other process's files there, the same
311
+ // isolation the Linux branch gets for free from a fresh per-invocation
312
+ // tmpfs — then selectively reopened just above for the workspace, the
313
+ // shared persistent dir (only when `shared` passed validation; see
314
+ // `safeSandboxSharedTmpDir`), and `runDir`, a fresh directory created for
315
+ // this invocation alone and exposed to the command via $TMPDIR so ordinary
316
+ // scratch-file use keeps working without reaching the real host temp dirs.
317
+ const tmpDeny = macTmpRoots()
318
+ .map((p) => `(deny file-read* (subpath "${esc(p)}"))\n(deny file-write* (subpath "${esc(p)}"))`)
319
+ .join("\n");
320
+ return `(version 1)
321
+ (allow default)
322
+ (deny file-write* (subpath "/"))
323
+ (allow file-write* (subpath "/dev"))
324
+ ${tmpDeny}
325
+ ${writeExtra}
326
+ ${readExtra}
327
+ `;
328
+ }
329
+ /**
330
+ * Wraps `command` (run via `sh -c`) so it's confined to `workspace`: writes
331
+ * are blocked everywhere else, network and (outside the real temp-dir roots)
332
+ * reads are left open. This contains accidental or malicious damage outside
333
+ * the project — it does not stop a command from reading files the real user
334
+ * can read (e.g. `cat ~/.ssh/id_rsa`), since restricting reads generally
335
+ * breaks most ordinary tooling (dynamic linking, package manager caches,
336
+ * etc.); the temp-dir roots are the one exception, hidden to keep one
337
+ * sandboxed command from reading another's scratch files. Returns null if no
338
+ * sandbox binary is available on this platform.
339
+ */
340
+ export function buildSandboxedCommand(command, workspace) {
341
+ const tool = sandboxTool();
342
+ if (!tool)
343
+ return null;
344
+ // Linked worktrees / submodules keep their real git dir outside the workspace.
345
+ const gitDir = externalGitCommonDir(workspace);
346
+ // null if the path exists but isn't a real, self-owned directory (e.g.
347
+ // another local user squatted it as a symlink) — see safeSandboxSharedTmpDir.
348
+ const shared = safeSandboxSharedTmpDir();
349
+ if (tool === "bwrap") {
350
+ // /tmp is a fresh per-invocation tmpfs, EXCEPT for one dedicated shared
351
+ // subdirectory bound read-write over it. That keeps the "scratch state
352
+ // persists across sandboxed calls in a session" behavior without exposing
353
+ // the host's real /tmp — which holds other agents' isolated worktrees and
354
+ // is the easiest place to hardlink a host file and write through the link.
355
+ const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp"];
356
+ seedSshKnownHosts();
357
+ for (const p of [
358
+ ...extraWritablePaths(),
359
+ ...(shared ? [shared] : []),
360
+ ...(gitDir ? [gitDir] : []),
361
+ ]) {
362
+ if (fs.existsSync(p))
363
+ args.push("--bind", p, p);
364
+ }
365
+ args.push("--bind", workspace, workspace, "--chdir", workspace, "--unshare-pid", "--die-with-parent", "sh", "-c", command);
366
+ return { cmd: "bwrap", args };
367
+ }
368
+ // sandbox-exec (macOS) takes its policy as a profile file, not inline args.
369
+ // Named with the same pid+timestamp+random suffix as runDir below, so a
370
+ // local attacker can't pre-create/symlink the path before this call reaches
371
+ // it (the profile itself isn't secret, but a symlinked write target could
372
+ // otherwise redirect the profile write elsewhere via TOCTOU). `wx` makes
373
+ // the create atomic and fails loudly on any pre-existing path — including a
374
+ // symlink — instead of silently following it, and 0o600 keeps it
375
+ // unreadable/unwritable by other local users in the meantime.
376
+ const runSuffix = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
377
+ const profilePath = path.join(os.tmpdir(), `kritya-sandbox-${runSuffix}.sb`);
378
+ // A fresh, uniquely-named scratch dir for this invocation alone — exposed
379
+ // to the command via $TMPDIR so ordinary tools that write scratch files
380
+ // keep working even though the rest of the real temp dirs are now hidden
381
+ // (see macSandboxProfile). Read by sandbox-exec's own profile loader before
382
+ // confinement takes effect, so it isn't subject to the tmp-root read-deny
383
+ // it's about to create.
384
+ const runDir = path.join(os.tmpdir(), `kritya-sandbox-run-${runSuffix}`);
385
+ fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
386
+ fs.writeFileSync(profilePath, macSandboxProfile(workspace, gitDir ? [gitDir] : [], shared, runDir), { mode: 0o600, flag: "wx" });
387
+ return {
388
+ cmd: "sandbox-exec",
389
+ args: ["-f", profilePath, "sh", "-c", command],
390
+ env: { TMPDIR: runDir, TMP: runDir, TEMP: runDir },
391
+ cleanup: () => {
392
+ fs.rm(profilePath, { force: true }, () => { });
393
+ fs.rm(runDir, { recursive: true, force: true }, () => { });
394
+ },
395
+ };
396
+ }
@@ -0,0 +1,172 @@
1
+ import { VERSION } from "../version.js";
2
+ import { encodeMetricsSnapshot, postOtlp, postOtlpAndWait, } from "./otlp.js";
3
+ const NOOP_COUNTER = { add() { } };
4
+ const NOOP_HISTOGRAM = { record() { } };
5
+ export const NOOP_METER = {
6
+ counter: () => NOOP_COUNTER,
7
+ histogram: () => NOOP_HISTOGRAM,
8
+ flush() { },
9
+ async flushAndWait() { },
10
+ stop() { },
11
+ };
12
+ export const DEFAULT_LATENCY_BOUNDS_MS = [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000];
13
+ function nowUnixNano() {
14
+ return (BigInt(Date.now()) * 1000000n).toString();
15
+ }
16
+ function attrKey(attributes) {
17
+ return JSON.stringify(Object.entries(attributes).sort(([a], [b]) => a.localeCompare(b)));
18
+ }
19
+ class RealMeter {
20
+ endpoint;
21
+ resource;
22
+ headers;
23
+ startTimeUnixNano = nowUnixNano();
24
+ sums = new Map();
25
+ histograms = new Map();
26
+ namesByKey = new Map();
27
+ timer;
28
+ constructor(endpoint, resource, headers, flushIntervalMs) {
29
+ this.endpoint = endpoint;
30
+ this.resource = resource;
31
+ this.headers = headers;
32
+ this.timer = setInterval(() => this.flush(), flushIntervalMs);
33
+ this.timer.unref();
34
+ }
35
+ counter(name) {
36
+ return {
37
+ add: (value, attributes = {}) => {
38
+ const key = `${name}::${attrKey(attributes)}`;
39
+ const existing = this.sums.get(key);
40
+ if (existing)
41
+ existing.value += value;
42
+ else
43
+ this.sums.set(key, { kind: "sum", attributes, value });
44
+ this.namesByKey.set(key, name);
45
+ },
46
+ };
47
+ }
48
+ histogram(name, bounds = DEFAULT_LATENCY_BOUNDS_MS) {
49
+ return {
50
+ record: (value, attributes = {}) => {
51
+ const key = `${name}::${attrKey(attributes)}`;
52
+ let entry = this.histograms.get(key);
53
+ if (!entry) {
54
+ entry = {
55
+ kind: "histogram",
56
+ attributes,
57
+ bounds,
58
+ bucketCounts: new Array(bounds.length + 1).fill(0),
59
+ count: 0,
60
+ sum: 0,
61
+ };
62
+ this.histograms.set(key, entry);
63
+ }
64
+ entry.count += 1;
65
+ entry.sum += value;
66
+ const bucketIndex = bounds.findIndex((b) => value <= b);
67
+ entry.bucketCounts[bucketIndex === -1 ? bounds.length : bucketIndex] += 1;
68
+ this.namesByKey.set(key, name);
69
+ },
70
+ };
71
+ }
72
+ /** Build the current cumulative snapshot's OTLP-encoded body, or undefined if there's nothing new to send. */
73
+ snapshotBody() {
74
+ const now = nowUnixNano();
75
+ const points = [];
76
+ for (const [key, entry] of this.sums) {
77
+ points.push({
78
+ name: this.namesByKey.get(key),
79
+ kind: "sum",
80
+ attributes: entry.attributes,
81
+ startTimeUnixNano: this.startTimeUnixNano,
82
+ timeUnixNano: now,
83
+ sumValue: entry.value,
84
+ isMonotonic: true,
85
+ });
86
+ }
87
+ for (const [key, entry] of this.histograms) {
88
+ points.push({
89
+ name: this.namesByKey.get(key),
90
+ kind: "histogram",
91
+ attributes: entry.attributes,
92
+ startTimeUnixNano: this.startTimeUnixNano,
93
+ timeUnixNano: now,
94
+ histogram: {
95
+ count: entry.count,
96
+ sum: entry.sum,
97
+ bucketCounts: entry.bucketCounts,
98
+ explicitBounds: entry.bounds,
99
+ },
100
+ });
101
+ }
102
+ if (!points.length)
103
+ return undefined;
104
+ return encodeMetricsSnapshot(points, this.resource);
105
+ }
106
+ flush() {
107
+ // Best-effort like every other telemetry path: a bad snapshot or a down
108
+ // collector must never throw into the caller (setInterval callback here
109
+ // has no caller to catch it, so an uncaught error would crash the process).
110
+ try {
111
+ const body = this.snapshotBody();
112
+ if (body === undefined)
113
+ return;
114
+ postOtlp(this.endpoint, "/v1/metrics", body, this.headers);
115
+ }
116
+ catch {
117
+ // best-effort: metrics must never crash the process
118
+ }
119
+ }
120
+ async flushAndWait() {
121
+ try {
122
+ const body = this.snapshotBody();
123
+ if (body === undefined)
124
+ return;
125
+ await postOtlpAndWait(this.endpoint, "/v1/metrics", body, this.headers);
126
+ }
127
+ catch {
128
+ // best-effort: metrics must never crash the process
129
+ }
130
+ }
131
+ stop() {
132
+ if (this.timer)
133
+ clearInterval(this.timer);
134
+ }
135
+ }
136
+ function parseOtlpHeaders(raw) {
137
+ if (!raw)
138
+ return undefined;
139
+ const headers = {};
140
+ for (const pair of raw.split(",")) {
141
+ const eq = pair.indexOf("=");
142
+ if (eq === -1)
143
+ continue;
144
+ headers[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
145
+ }
146
+ return headers;
147
+ }
148
+ /**
149
+ * Build the meter configured by the environment for this session. Metrics
150
+ * only exist when KRITYA_OTEL_ENDPOINT is set — there's no local file/console
151
+ * mode for them (unlike traces), since raw counters aren't useful to eyeball
152
+ * on disk the way a span log is. Returns the shared no-op meter otherwise, so
153
+ * callers can always hold a Meter and call counter()/histogram() unconditionally.
154
+ */
155
+ export function createMeter(sessionId, _configDefault, flushIntervalMs = 10_000) {
156
+ const endpoint = process.env.KRITYA_OTEL_ENDPOINT;
157
+ if (!endpoint)
158
+ return NOOP_METER;
159
+ const resource = {
160
+ attributes: {
161
+ "service.name": "kritya",
162
+ "service.version": VERSION,
163
+ "os.type": process.platform,
164
+ // Distinguishes concurrent kritya sessions so the collector's
165
+ // Prometheus exporter doesn't merge their cumulative counters into one
166
+ // corrupted series (requires resource_to_telemetry_conversion.enabled
167
+ // on the collector's prometheus exporter to surface as a label).
168
+ "service.instance.id": sessionId,
169
+ },
170
+ };
171
+ return new RealMeter(endpoint, resource, parseOtlpHeaders(process.env.KRITYA_OTEL_HEADERS), flushIntervalMs);
172
+ }