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,97 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CONFIG_DIR } from "../config/config.js";
4
+ import { debugLog } from "../config/debug.js";
5
+ import { hardenWindowsDir } from "../config/winAcl.js";
6
+ /**
7
+ * Persistent OAuth state for remote MCP servers, in `~/.kritya/mcp-auth.json`.
8
+ *
9
+ * This file holds live bearer tokens for third-party accounts (Linear, Notion,
10
+ * Sentry, …), so it is the single most sensitive file kritya writes — more so
11
+ * than config.json, whose provider keys the user can at least rotate from one
12
+ * place. It gets the same 0600 + Windows-ACL treatment as the rest of
13
+ * CONFIG_DIR, and never travels with the workspace.
14
+ *
15
+ * Records are keyed by the server's canonical URL rather than its configured
16
+ * name: the same endpoint is often called different things in a user's global
17
+ * config and a repo's .mcp.json, and renaming a server in config shouldn't
18
+ * silently orphan a token and force a fresh browser round-trip.
19
+ */
20
+ /**
21
+ * Where the store lives. Read per call rather than captured at import time so
22
+ * KRITYA_MCP_AUTH_FILE can be set after this module loads — the test suite must
23
+ * never touch a developer's real tokens, and ESM import hoisting makes
24
+ * "set the env first" unreliable otherwise.
25
+ */
26
+ export function authFilePath() {
27
+ return process.env.KRITYA_MCP_AUTH_FILE || path.join(CONFIG_DIR, "mcp-auth.json");
28
+ }
29
+ /** Normalize a server URL so trivial spelling differences map to one record. */
30
+ export function authKey(url) {
31
+ try {
32
+ const u = new URL(url);
33
+ u.hash = "";
34
+ u.search = "";
35
+ // A trailing slash on the path is not a different resource.
36
+ if (u.pathname.length > 1 && u.pathname.endsWith("/"))
37
+ u.pathname = u.pathname.slice(0, -1);
38
+ return u.toString();
39
+ }
40
+ catch {
41
+ return url;
42
+ }
43
+ }
44
+ function loadStore(file) {
45
+ try {
46
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
47
+ return parsed && typeof parsed === "object" ? parsed : {};
48
+ }
49
+ catch (err) {
50
+ debugLog(`mcp tokens loadStore(${file})`, err);
51
+ return {};
52
+ }
53
+ }
54
+ function saveStore(file, store) {
55
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
56
+ hardenWindowsDir(path.dirname(file));
57
+ fs.writeFileSync(file, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
58
+ // `mode` only applies when writeFileSync creates the file; enforce it even if
59
+ // mcp-auth.json already existed with looser permissions (e.g. restored from a
60
+ // backup, or written by an older build).
61
+ try {
62
+ fs.chmodSync(file, 0o600);
63
+ }
64
+ catch (err) {
65
+ debugLog(`mcp tokens chmod(${file})`, err);
66
+ }
67
+ }
68
+ export function loadAuth(url, file = authFilePath()) {
69
+ return loadStore(file)[authKey(url)];
70
+ }
71
+ export function saveAuth(url, auth, file = authFilePath()) {
72
+ const store = loadStore(file);
73
+ store[authKey(url)] = auth;
74
+ saveStore(file, store);
75
+ }
76
+ /** Forget a server's grant locally. Returns what was removed, for revocation. */
77
+ export function deleteAuth(url, file = authFilePath()) {
78
+ const store = loadStore(file);
79
+ const key = authKey(url);
80
+ const existing = store[key];
81
+ if (!existing)
82
+ return undefined;
83
+ delete store[key];
84
+ saveStore(file, store);
85
+ return existing;
86
+ }
87
+ /** Every stored grant, for `/mcp` status display. */
88
+ export function listAuth(file = authFilePath()) {
89
+ return Object.entries(loadStore(file)).map(([url, auth]) => ({ url, auth }));
90
+ }
91
+ /**
92
+ * Whether an access token is close enough to expiry to refresh proactively.
93
+ * The 60s skew keeps a token from dying mid-request after passing this check.
94
+ */
95
+ export function isExpired(auth, now = Date.now()) {
96
+ return auth.expiresAt !== undefined && auth.expiresAt - 60_000 <= now;
97
+ }
@@ -0,0 +1,295 @@
1
+ import { spawn } from "node:child_process";
2
+ import { McpAuthRequiredError, OAuthSession, parseWwwAuthenticate } from "./oauth.js";
3
+ import { planSpawn } from "./spawnWin.js";
4
+ /** Same-origin redirects we'll follow before calling it a loop. */
5
+ const MAX_REDIRECTS = 5;
6
+ function isRedirect(status) {
7
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
8
+ }
9
+ /** How much of a server's stderr to keep for diagnostics, and how much to report. */
10
+ const STDERR_KEEP_BYTES = 8_192;
11
+ const STDERR_REPORT_LINES = 5;
12
+ /** Env vars an MCP server's own OS/runtime needs to start up at all, without inheriting API keys. */
13
+ const PASSTHROUGH_ENV_VARS = [
14
+ "PATH",
15
+ "HOME",
16
+ "USERPROFILE",
17
+ "SystemRoot",
18
+ "windir",
19
+ "TEMP",
20
+ "TMP",
21
+ "APPDATA",
22
+ "LOCALAPPDATA",
23
+ "NODE_PATH",
24
+ ];
25
+ function minimalEnv() {
26
+ const env = {};
27
+ for (const key of PASSTHROUGH_ENV_VARS) {
28
+ const value = process.env[key];
29
+ if (value !== undefined)
30
+ env[key] = value;
31
+ }
32
+ return env;
33
+ }
34
+ /**
35
+ * A signal that fires on either the per-request timeout or the user's cancel.
36
+ * `AbortSignal.any` only landed in Node 20 and we support 18, hence the manual
37
+ * bridge.
38
+ */
39
+ function withTimeout(timeoutMs, signal) {
40
+ const timeout = AbortSignal.timeout(timeoutMs);
41
+ if (!signal)
42
+ return timeout;
43
+ const any = AbortSignal.any;
44
+ if (any)
45
+ return any.call(AbortSignal, [signal, timeout]);
46
+ const ctrl = new AbortController();
47
+ const abort = () => ctrl.abort();
48
+ if (signal.aborted || timeout.aborted)
49
+ ctrl.abort();
50
+ else {
51
+ signal.addEventListener("abort", abort, { once: true });
52
+ timeout.addEventListener("abort", abort, { once: true });
53
+ }
54
+ return ctrl.signal;
55
+ }
56
+ export class StdioTransport {
57
+ onMessage = () => { };
58
+ onError = () => { };
59
+ proc;
60
+ buffer = "";
61
+ /** Tail of the server's stderr, kept for the exit message (see below). */
62
+ stderrTail = "";
63
+ constructor(command, args, env, cwd) {
64
+ const plan = planSpawn(command, args);
65
+ this.proc = spawn(plan.command, plan.args, {
66
+ // Minimal env, not the full process env: every provider API key lives
67
+ // in process.env, and an MCP server is third-party code the user opted
68
+ // into but shouldn't automatically receive credentials it never asked
69
+ // for. Servers that need extra vars declare them in their own `env`.
70
+ env: { ...minimalEnv(), ...env },
71
+ // Always explicit. Inheriting kritya's cwd makes a server's scope depend
72
+ // on which directory the user launched from — see McpServerConfig.cwd.
73
+ cwd,
74
+ stdio: ["pipe", "pipe", "pipe"],
75
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
76
+ });
77
+ this.proc.stdout?.setEncoding("utf8");
78
+ this.proc.stdout?.on("data", (chunk) => this.onData(chunk));
79
+ // Draining stderr is not optional: we asked for a pipe, and a server that
80
+ // logs more than the ~64KB pipe buffer blocks in write() forever if nobody
81
+ // reads it. Keeping the tail also means a server that dies on a bad token
82
+ // can say why, instead of just "exited".
83
+ this.proc.stderr?.setEncoding("utf8");
84
+ this.proc.stderr?.on("data", (chunk) => {
85
+ this.stderrTail = (this.stderrTail + chunk).slice(-STDERR_KEEP_BYTES);
86
+ });
87
+ // Report on "close" (all stdio flushed), not "exit", so the stderr that
88
+ // explains the exit has actually arrived. "close" can be held open by a
89
+ // grandchild inheriting the pipes, so "exit" still arms a short fallback —
90
+ // a slightly thinner message beats never reporting at all.
91
+ let reported = false;
92
+ const report = (code, signal) => {
93
+ if (reported)
94
+ return;
95
+ reported = true;
96
+ const how = signal ? `killed by ${signal}` : `exited with code ${code ?? 0}`;
97
+ this.onError(new Error(`server process ${how}${this.stderrDetail()}`));
98
+ };
99
+ this.proc.on("close", report);
100
+ this.proc.on("exit", (code, signal) => {
101
+ setTimeout(() => report(code, signal), 200).unref();
102
+ });
103
+ this.proc.on("error", (err) => this.onError(new Error(`${err.message}${this.stderrDetail()}`)));
104
+ }
105
+ /** The last few non-blank stderr lines, formatted for an error message. */
106
+ stderrDetail() {
107
+ const lines = this.stderrTail
108
+ .split(/\r?\n/)
109
+ .map((l) => l.trim())
110
+ .filter(Boolean)
111
+ .slice(-STDERR_REPORT_LINES);
112
+ return lines.length ? ` — stderr: ${lines.join(" | ")}` : "";
113
+ }
114
+ onData(chunk) {
115
+ this.buffer += chunk;
116
+ let idx;
117
+ while ((idx = this.buffer.indexOf("\n")) >= 0) {
118
+ const line = this.buffer.slice(0, idx).trim();
119
+ this.buffer = this.buffer.slice(idx + 1);
120
+ if (!line)
121
+ continue;
122
+ try {
123
+ this.onMessage(JSON.parse(line));
124
+ }
125
+ catch {
126
+ // ignore non-JSON (some servers log to stdout)
127
+ }
128
+ }
129
+ }
130
+ send(msg) {
131
+ this.proc.stdin?.write(JSON.stringify(msg) + "\n");
132
+ return Promise.resolve();
133
+ }
134
+ close() {
135
+ this.proc.kill();
136
+ }
137
+ }
138
+ export class HttpTransport {
139
+ url;
140
+ headers;
141
+ onMessage = () => { };
142
+ onError = () => { };
143
+ /** Assigned by the server on initialize (Mcp-Session-Id header), echoed thereafter. */
144
+ sessionId;
145
+ /** Negotiated on initialize; sent as MCP-Protocol-Version on later requests. */
146
+ protocolVersion;
147
+ /** OAuth state for this endpoint, when the user has logged in (see oauth.ts). */
148
+ oauth;
149
+ constructor(url, headers) {
150
+ this.url = url;
151
+ this.headers = headers;
152
+ this.oauth = new OAuthSession(url);
153
+ }
154
+ async buildHeaders() {
155
+ const headers = {
156
+ "content-type": "application/json",
157
+ accept: "application/json, text/event-stream",
158
+ ...this.headers,
159
+ };
160
+ if (this.sessionId)
161
+ headers["mcp-session-id"] = this.sessionId;
162
+ if (this.protocolVersion)
163
+ headers["mcp-protocol-version"] = this.protocolVersion;
164
+ // A configured Authorization header wins: someone who pasted a PAT into
165
+ // config meant to use it, and shouldn't be overridden by a stale grant.
166
+ const hasExplicitAuth = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
167
+ if (!hasExplicitAuth) {
168
+ const token = await this.oauth.accessToken();
169
+ if (token)
170
+ headers.authorization = `Bearer ${token}`;
171
+ }
172
+ return headers;
173
+ }
174
+ async send(msg, timeoutMs, signal) {
175
+ let res = await this.post(msg, timeoutMs, signal);
176
+ // 401 has two meanings: an access token that just aged out (refresh and
177
+ // retry once — silent, and the common case an hour into a session), or no
178
+ // usable grant at all, which only a browser login can fix.
179
+ if (res.status === 401) {
180
+ const refreshed = await this.oauth.handleUnauthorized();
181
+ if (refreshed) {
182
+ await res.body?.cancel().catch(() => { });
183
+ res = await this.post(msg, timeoutMs, signal);
184
+ }
185
+ if (res.status === 401) {
186
+ const challenge = parseWwwAuthenticate(res.headers.get("www-authenticate"));
187
+ await res.body?.cancel().catch(() => { });
188
+ throw new McpAuthRequiredError(this.url, challenge);
189
+ }
190
+ }
191
+ const sid = res.headers.get("mcp-session-id");
192
+ if (sid)
193
+ this.sessionId = sid;
194
+ if (!res.ok) {
195
+ // Drain to avoid leaking the connection, then surface the status.
196
+ const body = await res.text().catch(() => "");
197
+ throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
198
+ }
199
+ const contentType = res.headers.get("content-type") ?? "";
200
+ if (contentType.includes("text/event-stream")) {
201
+ await this.readSse(res);
202
+ }
203
+ else if (contentType.includes("application/json")) {
204
+ const parsed = (await res.json());
205
+ for (const m of Array.isArray(parsed) ? parsed : [parsed])
206
+ this.onMessage(m);
207
+ }
208
+ // Anything else (e.g. a 202 Accepted for a notification) carries no messages.
209
+ }
210
+ async post(msg, timeoutMs, signal) {
211
+ const body = JSON.stringify(msg);
212
+ let url = this.url;
213
+ // Bounded, because a redirect chain is otherwise a free loop.
214
+ for (let hop = 0;; hop++) {
215
+ const res = await fetch(url, {
216
+ method: "POST",
217
+ headers: await this.buildHeaders(),
218
+ body,
219
+ // Following redirects automatically re-sends every header — including
220
+ // Authorization — to whatever Location names. A compromised server, or
221
+ // anyone able to rewrite a plaintext hop, could point that at their own
222
+ // origin and collect the token. Handle them here so we can look first.
223
+ redirect: "manual",
224
+ // Cancelling has to tear the socket down too, or the request stays in
225
+ // flight for the full timeout after the user has walked away.
226
+ signal: withTimeout(timeoutMs, signal),
227
+ });
228
+ if (!isRedirect(res.status))
229
+ return res;
230
+ const location = res.headers.get("location");
231
+ await res.body?.cancel().catch(() => { });
232
+ if (!location)
233
+ throw new Error(`HTTP ${res.status} redirect with no Location header`);
234
+ if (hop >= MAX_REDIRECTS)
235
+ throw new Error(`too many redirects from ${this.url}`);
236
+ const target = new URL(location, url);
237
+ // Same-origin only. A cross-origin redirect may be entirely legitimate
238
+ // (a vendor moving endpoints), but we cannot tell that from an attack,
239
+ // and the cost of guessing wrong is the user's bearer token. Refuse, and
240
+ // name the destination so a real migration is a one-line config edit.
241
+ if (target.origin !== new URL(url).origin) {
242
+ throw new Error(`refusing redirect to a different origin (${target.origin}) — ` +
243
+ `it would send this server's credentials there. ` +
244
+ `If that endpoint is genuine, point the server's "url" at it directly.`);
245
+ }
246
+ url = target.toString();
247
+ }
248
+ }
249
+ /** Parse an SSE body, feeding each `data:` event's JSON to onMessage. */
250
+ async readSse(res) {
251
+ if (!res.body)
252
+ return;
253
+ const decoder = new TextDecoder();
254
+ let buffer = "";
255
+ const dispatch = (rawEvent) => {
256
+ const data = rawEvent
257
+ .split(/\r?\n/)
258
+ .filter((l) => l.startsWith("data:"))
259
+ .map((l) => l.slice(5).trimStart())
260
+ .join("\n");
261
+ if (!data)
262
+ return;
263
+ try {
264
+ this.onMessage(JSON.parse(data));
265
+ }
266
+ catch {
267
+ // ignore non-JSON events (keep-alives, comments)
268
+ }
269
+ };
270
+ for await (const chunk of res.body) {
271
+ buffer += decoder.decode(chunk, { stream: true });
272
+ let idx;
273
+ while ((idx = buffer.search(/\r?\n\r?\n/)) >= 0) {
274
+ const rawEvent = buffer.slice(0, idx);
275
+ buffer = buffer.slice(idx).replace(/^\r?\n\r?\n/, "");
276
+ dispatch(rawEvent);
277
+ }
278
+ }
279
+ if (buffer.trim())
280
+ dispatch(buffer);
281
+ }
282
+ close() {
283
+ // Best-effort explicit session termination, per the Streamable HTTP spec.
284
+ if (!this.sessionId)
285
+ return;
286
+ this.buildHeaders()
287
+ .then((headers) => fetch(this.url, {
288
+ method: "DELETE",
289
+ headers,
290
+ redirect: "manual",
291
+ signal: AbortSignal.timeout(3_000),
292
+ }))
293
+ .catch(() => { });
294
+ }
295
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Shared "is this host on a private/internal network" check, used by both
3
+ * fetch_url (src/tools/fetchUrl.ts) and the MCP HTTP transport guard
4
+ * (src/mcp/client.ts) so the two SSRF chokepoints can't silently drift apart.
5
+ */
6
+ function isPrivateIPv4Parts(a, b) {
7
+ return (a === 10 ||
8
+ a === 127 ||
9
+ (a === 192 && b === 168) ||
10
+ (a === 172 && b >= 16 && b <= 31) ||
11
+ (a === 169 && b === 254) ||
12
+ a === 0 ||
13
+ // 100.64.0.0/10 — carrier-grade NAT / Tailscale range.
14
+ (a === 100 && b >= 64 && b <= 127) ||
15
+ // 198.18.0.0/15 — benchmarking range, sometimes used internally.
16
+ (a === 198 && (b === 18 || b === 19)) ||
17
+ // 192.0.0.0/24 — IETF protocol assignments (includes some cloud
18
+ // metadata-adjacent uses).
19
+ (a === 192 && b === 0));
20
+ }
21
+ function parseIPv4(host) {
22
+ const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
23
+ if (!m)
24
+ return null;
25
+ const parts = m.slice(1).map(Number);
26
+ if (parts.some((p) => p > 255))
27
+ return null;
28
+ return parts;
29
+ }
30
+ /**
31
+ * Whether `host` is a "plain" numeric IPv4 written as a single decimal, octal,
32
+ * or hexadecimal integer, or a dotted form with octal/hex octets (e.g.
33
+ * `2130706433`, `0x7f000001`, `017700000001`, `0177.0.0.1`) — forms that
34
+ * browsers and curl still resolve as IP addresses but that {@link parseIPv4}'s
35
+ * plain dotted-decimal-quad regex doesn't recognize, so they'd otherwise sail
36
+ * straight past the private/loopback check (e.g. `http://2130706433/` is
37
+ * `http://127.0.0.1/`). Rather than compute the exact address these forms
38
+ * resolve to, treat any host shaped like one as suspicious and block it
39
+ * outright — no ordinary public hostname is written this way, so this has no
40
+ * legitimate-use cost.
41
+ */
42
+ function isNonStandardNumericIPv4(host) {
43
+ if (parseIPv4(host))
44
+ return false; // ordinary dotted-decimal quad, handled separately
45
+ return /^(0x[0-9a-f]+|0[0-7]*|[1-9]\d*)(\.(0x[0-9a-f]+|0[0-7]+|[1-9]\d*|0))*$/i.test(host);
46
+ }
47
+ /**
48
+ * Expands an IPv6 literal (no brackets/zone id) into its 8 16-bit groups, or
49
+ * null if it isn't a valid IPv6 literal. Handles "::" compression and a
50
+ * trailing embedded IPv4 tail (e.g. "::ffff:169.254.169.254").
51
+ */
52
+ function parseIPv6Groups(host) {
53
+ if (!host.includes(":"))
54
+ return null;
55
+ // Normalize a trailing embedded IPv4 (e.g. "::ffff:169.254.169.254") into
56
+ // two plain hextets first, so the rest of the parser only ever deals with
57
+ // a pure hextet address — no separate bookkeeping for the IPv4 tail.
58
+ let normalized = host;
59
+ const ipv4Tail = host.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
60
+ if (ipv4Tail) {
61
+ const parts = parseIPv4(ipv4Tail[1]);
62
+ if (!parts)
63
+ return null;
64
+ const g1 = (((parts[0] << 8) | parts[1]) >>> 0).toString(16);
65
+ const g2 = (((parts[2] << 8) | parts[3]) >>> 0).toString(16);
66
+ normalized = host.slice(0, host.length - ipv4Tail[1].length) + g1 + ":" + g2;
67
+ }
68
+ const sides = normalized.split("::");
69
+ if (sides.length > 2)
70
+ return null;
71
+ const parseHextets = (s) => {
72
+ if (s === "")
73
+ return [];
74
+ const hextets = s.split(":");
75
+ const nums = [];
76
+ for (const h of hextets) {
77
+ if (!/^[0-9a-f]{1,4}$/.test(h))
78
+ return null;
79
+ nums.push(parseInt(h, 16));
80
+ }
81
+ return nums;
82
+ };
83
+ if (sides.length === 1) {
84
+ const groups = parseHextets(sides[0]);
85
+ return groups && groups.length === 8 ? groups : null;
86
+ }
87
+ const left = parseHextets(sides[0]);
88
+ const right = parseHextets(sides[1]);
89
+ if (!left || !right)
90
+ return null;
91
+ const missing = 8 - left.length - right.length;
92
+ if (missing < 0)
93
+ return null;
94
+ return [...left, ...Array(missing).fill(0), ...right];
95
+ }
96
+ function isPrivateOrLoopbackIPv6Groups(groups) {
97
+ const allZero = (from, to) => groups.slice(from, to).every((g) => g === 0);
98
+ // ::1 loopback and :: unspecified.
99
+ if (allZero(0, 7) && (groups[7] === 0 || groups[7] === 1))
100
+ return true;
101
+ // fc00::/7 unique-local.
102
+ if ((groups[0] & 0xfe00) === 0xfc00)
103
+ return true;
104
+ // fe80::/10 link-local.
105
+ if ((groups[0] & 0xffc0) === 0xfe80)
106
+ return true;
107
+ // IPv4-mapped (::ffff:a.b.c.d) and deprecated IPv4-compatible (::a.b.c.d) —
108
+ // both embed a real IPv4 address in the low 32 bits and must be judged by
109
+ // that address's range, not treated as "just IPv6" and allowed through.
110
+ if (allZero(0, 5) && (groups[5] === 0 || groups[5] === 0xffff)) {
111
+ const a = (groups[6] >> 8) & 0xff;
112
+ const b = groups[6] & 0xff;
113
+ return isPrivateIPv4Parts(a, b);
114
+ }
115
+ return false;
116
+ }
117
+ function isLoopbackIPv6Groups(groups) {
118
+ const allZero = (from, to) => groups.slice(from, to).every((g) => g === 0);
119
+ if (allZero(0, 7) && groups[7] === 1)
120
+ return true;
121
+ // IPv4-mapped/compatible loopback, e.g. ::ffff:127.0.0.1.
122
+ if (allZero(0, 5) && (groups[5] === 0 || groups[5] === 0xffff)) {
123
+ return groups[6] >> 8 === 127;
124
+ }
125
+ return false;
126
+ }
127
+ export function isPrivateOrLoopbackHost(rawHost) {
128
+ const host = rawHost.toLowerCase().replace(/^\[|\]$/g, "");
129
+ if (host === "localhost" || host.endsWith(".localhost"))
130
+ return true;
131
+ const v4 = parseIPv4(host);
132
+ if (v4)
133
+ return isPrivateIPv4Parts(v4[0], v4[1]);
134
+ if (isNonStandardNumericIPv4(host))
135
+ return true;
136
+ const v6 = parseIPv6Groups(host);
137
+ if (v6)
138
+ return isPrivateOrLoopbackIPv6Groups(v6);
139
+ return false;
140
+ }
141
+ /** Loopback only — used where private-but-routable (e.g. LAN) hosts are still acceptable. */
142
+ export function isLoopbackHost(rawHost) {
143
+ const host = rawHost.toLowerCase().replace(/^\[|\]$/g, "");
144
+ if (host === "localhost" || host.endsWith(".localhost"))
145
+ return true;
146
+ const v4 = parseIPv4(host);
147
+ if (v4)
148
+ return v4[0] === 127;
149
+ // A non-standard numeric form could resolve to anything, including a
150
+ // loopback address — refuse it here too rather than let it pass as "not
151
+ // loopback" and be treated as an acceptable LAN target.
152
+ if (isNonStandardNumericIPv4(host))
153
+ return true;
154
+ const v6 = parseIPv6Groups(host);
155
+ if (v6)
156
+ return isLoopbackIPv6Groups(v6);
157
+ return false;
158
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Flags shell commands that are destructive or irreversible. When a command
3
+ * matches, kritya forces a permission prompt with a warning even if the
4
+ * command would otherwise be covered by an allowlist rule or an "always allow"
5
+ * choice — so a blanket `shell(*)` allow can't silently run `rm -rf /`.
6
+ */
7
+ const PATTERNS = [
8
+ {
9
+ re: /\brm\s+.*(-[a-z]*[rf][a-z]*\b|--recursive\b|--force\b|--no-preserve-root\b)/i,
10
+ label: "recursive/forced file deletion (rm -rf)",
11
+ },
12
+ { re: /(?<!git\s)\brm\b\s+\S/i, label: "file deletion (rm)" },
13
+ { re: /\brmdir\s+\/s/i, label: "recursive directory deletion" },
14
+ {
15
+ re: /\bgit\s+push\b.*(--force(-with-lease)?|-f)\b/i,
16
+ label: "force push (rewrites remote history)",
17
+ },
18
+ { re: /\bgit\s+reset\s+--hard/i, label: "hard reset (discards local changes)" },
19
+ {
20
+ re: /\bgit\s+clean\s+.*(-[a-z]*f[a-z]*\b|--force\b)/i,
21
+ label: "git clean (deletes untracked files)",
22
+ },
23
+ {
24
+ // git restore discards working-tree changes — except the pure --staged
25
+ // form, which only unstages (safe) unless --worktree is also given.
26
+ re: /\bgit\s+checkout\s+--\s|\bgit\s+restore\b(?!\s+--staged\b(?!.*--worktree))/i,
27
+ label: "discards uncommitted changes",
28
+ },
29
+ { re: /\bgit\s+branch\s+(?:\S+\s+)*-D\b/, label: "force-deleting a branch" },
30
+ { re: /\bfind\b.*\s-delete\b/i, label: "bulk file deletion (find -delete)" },
31
+ { re: /\bshred\b/i, label: "irrecoverable file destruction (shred)" },
32
+ { re: /\btruncate\b.*\s-s\s*0\b/i, label: "truncating a file to zero size" },
33
+ { re: /\bxargs\b.*\brm\b/i, label: "bulk file deletion (xargs rm)" },
34
+ { re: /\bdel\b\s+\S*\/(s|q|f)\b/i, label: "bulk file deletion (del)" },
35
+ { re: /\brd\s+\/s/i, label: "recursive directory deletion (rd /s)" },
36
+ { re: /\bformat\s+[a-z]:/i, label: "disk format" },
37
+ {
38
+ re: /\bremove-item\b.*(-recurse\b|-force\b)/i,
39
+ label: "recursive/forced deletion (Remove-Item)",
40
+ },
41
+ { re: /\bmkfs\b/i, label: "filesystem format" },
42
+ { re: /\bdd\b.*\bof=\/dev\//i, label: "raw disk write (dd)" },
43
+ { re: />\s*\/dev\/(sd|nvme|hd|disk)/i, label: "writing to a raw disk device" },
44
+ {
45
+ re: /\bchmod\s+.*(-R\b|--recursive\b).*\b777\b|\bchmod\s+.*\b777\b.*(-R\b|--recursive\b)/i,
46
+ label: "world-writable recursive chmod",
47
+ },
48
+ { re: /\bchown\s+(-R\b|--recursive\b)/i, label: "recursive ownership change" },
49
+ { re: /:\(\)\s*\{.*\}\s*;/, label: "fork bomb" },
50
+ {
51
+ re: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i,
52
+ label: "piping a remote script into a shell",
53
+ },
54
+ { re: /\bsudo\b/i, label: "elevated privileges (sudo)" },
55
+ { re: /\bshutdown\b|\breboot\b|\bhalt\b|\bpoweroff\b/i, label: "system power state change" },
56
+ { re: /\bnpm\s+publish\b|\byarn\s+publish\b/i, label: "publishing a package" },
57
+ { re: /\bkillall\b|\bkill\s+-9\s+-1\b/i, label: "mass process kill" },
58
+ // Exfiltration: every prior pattern above is about local destruction —
59
+ // none of them catch a command whose actual purpose is sending local files
60
+ // (credentials, source, secrets) to a remote host. classifyDanger forces a
61
+ // warning prompt even under an allowlist, so these need the same coverage.
62
+ {
63
+ re: /\bcurl\b[^|]*(-d|--data(-raw|-binary|-urlencode)?|-F|--form|-T|--upload-file)\b/i,
64
+ label: "uploading data to a remote server (curl)",
65
+ },
66
+ {
67
+ re: /\bwget\b[^|]*(--post-data|--post-file)\b/i,
68
+ label: "uploading data to a remote server (wget)",
69
+ },
70
+ { re: /\bscp\b\s+\S+\s+\S+@/i, label: "copying files to a remote host (scp)" },
71
+ { re: /\brsync\b.*\S+@\S+:/i, label: "syncing files to a remote host (rsync)" },
72
+ { re: /\bnc\b|\bncat\b|\bnetcat\b/i, label: "raw network connection (nc/ncat)" },
73
+ {
74
+ re: /\bcurl\b[^|]*\|\s*\S|\bwget\b\s+-[^|]*-O\s*-[^|]*\|\s*\S/i,
75
+ label: "piping downloaded content into another command",
76
+ },
77
+ // Interpreter/eval evasion: a way to run arbitrary code that doesn't spell
78
+ // out a recognizable destructive command, so the patterns above can't see
79
+ // it — e.g. `eval "$(echo cm0gLXJmIC8=|base64 -d)"` or `python -c "..."`.
80
+ { re: /\beval\b/i, label: "dynamic code evaluation (eval)" },
81
+ { re: /\bbase64\b.*(-d|--decode)\b/i, label: "decoding base64 (often obfuscated payload)" },
82
+ {
83
+ re: /\b(python3?|node|ruby|perl)\b\s+(-c|-e)\b/i,
84
+ label: "running an inline script (bypasses command-text inspection)",
85
+ },
86
+ { re: /\bexec\s+\d*[<>]/i, label: "redirecting a shell's own file descriptors (exec)" },
87
+ ];
88
+ /** Returns a human-readable danger label if the command is destructive, else null. */
89
+ export function classifyDanger(command) {
90
+ const cmd = command.trim();
91
+ for (const { re, label } of PATTERNS) {
92
+ if (re.test(cmd))
93
+ return label;
94
+ }
95
+ return null;
96
+ }