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.
- package/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- 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
|
+
}
|