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,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
|
+
}
|