kritya 0.8.21-beta → 0.8.23-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.
@@ -1,8 +1,29 @@
1
1
  import { classifyDanger } from "../permissions/danger.js";
2
2
  import { acknowledgeUnsandboxedFallback, sandboxFallbackWarning } from "../shell/sandbox.js";
3
3
  import { isPlanningDocWrite, loadProjectState } from "./workflow.js";
4
+ import { redactSecrets } from "../tools/secretScan.js";
4
5
  /** How much tool output to hand the UI (it shows a preview and expands on toggle). */
5
6
  const PREVIEW_CHARS = 4000;
7
+ /**
8
+ * Upper bound on a raw tool-call argument payload, checked before JSON.parse.
9
+ * Individual tools cap their own inputs where it matters (e.g. write_file
10
+ * content), but this is a backstop against a malformed or adversarial model
11
+ * response ballooning memory before any tool-specific validation runs.
12
+ */
13
+ const MAX_ARGS_JSON_CHARS = 2_000_000;
14
+ /**
15
+ * Backstop cap on what a tool can return to the model, applied after every
16
+ * tool call regardless of whether that tool already truncates its own
17
+ * output (most do, via truncateResult/truncateTail — see src/tools/common.ts
18
+ * — but this catches the ones that don't, or a tool with a bug).
19
+ */
20
+ const MAX_TOOL_OUTPUT_CHARS = 200_000;
21
+ function truncateToolOutput(output) {
22
+ if (output.length <= MAX_TOOL_OUTPUT_CHARS)
23
+ return output;
24
+ return (output.slice(0, MAX_TOOL_OUTPUT_CHARS) +
25
+ `\n... [truncated, ${output.length - MAX_TOOL_OUTPUT_CHARS} more characters]`);
26
+ }
6
27
  /**
7
28
  * A tool outlived its deadline and was abandoned. Carries the tool's name so
8
29
  * the message handed back to the model names what to avoid retrying blindly.
@@ -96,6 +117,10 @@ export class ToolExecutor {
96
117
  const tool = this.tools.find((t) => t.name === name);
97
118
  if (!tool)
98
119
  return `Error: unknown tool "${name}"`;
120
+ if (argsJson.length > MAX_ARGS_JSON_CHARS) {
121
+ return (`Error: tool arguments for "${name}" are too large ` +
122
+ `(${argsJson.length} characters, max ${MAX_ARGS_JSON_CHARS}). Use a smaller input.`);
123
+ }
99
124
  let args;
100
125
  try {
101
126
  args = JSON.parse(argsJson);
@@ -105,7 +130,13 @@ export class ToolExecutor {
105
130
  }
106
131
  let summary;
107
132
  try {
108
- summary = tool.summarize(args);
133
+ // A tool's own summarize() may embed raw values (a fetch_url query
134
+ // string, a search query, arbitrary text) that happen to contain a
135
+ // secret the model saw earlier in the conversation. This is the one
136
+ // chokepoint every tool's summary passes through before it's written
137
+ // to the audit log and telemetry, so redact here rather than trusting
138
+ // every individual tool to have already done it.
139
+ summary = redactSecrets(tool.summarize(args)).redacted;
109
140
  }
110
141
  catch {
111
142
  summary = name;
@@ -291,6 +322,7 @@ export class ToolExecutor {
291
322
  if (post.output.trim())
292
323
  output += `\n[postToolUse hook]: ${post.output.trim()}`;
293
324
  }
325
+ output = truncateToolOutput(output);
294
326
  const failed = tool.failed?.(output) ?? false;
295
327
  logToolOutcome(failed ? "error" : "ok");
296
328
  finishSpan(failed ? "ERROR" : "OK");
@@ -59,8 +59,16 @@ export async function writeFileAtomic(filePath, data, options = {}) {
59
59
  const target = resolveTarget(filePath);
60
60
  const mode = options.mode ?? existingMode(target);
61
61
  const tmp = tempPathFor(target);
62
+ const encoding = options.encoding ?? (typeof data === "string" ? "utf8" : null);
62
63
  try {
63
- await fsp.writeFile(tmp, data, options.encoding ?? (typeof data === "string" ? "utf8" : null));
64
+ // Pass `mode` to the create itself rather than chmod-ing afterward: a
65
+ // separate chmod call leaves a window where the temp file sits at
66
+ // Node's default (umask-derived, typically world/group-readable) mode
67
+ // before being narrowed — for a sensitive target (e.g. config.json) that
68
+ // briefly exposes contents a caller explicitly asked to keep owner-only.
69
+ // The chmod is kept too, since umask can still trim bits `mode` set on
70
+ // some platforms.
71
+ await fsp.writeFile(tmp, data, { encoding, ...(mode !== undefined ? { mode } : {}) });
64
72
  if (mode !== undefined)
65
73
  await fsp.chmod(tmp, mode);
66
74
  await fsp.rename(tmp, target);
@@ -69,7 +77,9 @@ export async function writeFileAtomic(filePath, data, options = {}) {
69
77
  await fsp.rm(tmp, { force: true }).catch(() => { });
70
78
  if (!isRenameFallback(err))
71
79
  throw err;
72
- await fsp.writeFile(filePath, data, options.encoding ?? (typeof data === "string" ? "utf8" : null));
80
+ await fsp.writeFile(filePath, data, { encoding, ...(mode !== undefined ? { mode } : {}) });
81
+ if (mode !== undefined)
82
+ await fsp.chmod(filePath, mode);
73
83
  }
74
84
  }
75
85
  export function writeFileAtomicSync(filePath, data, options = {}) {
@@ -101,6 +111,10 @@ export function writeFileAtomicSync(filePath, data, options = {}) {
101
111
  ...(options.encoding ? { encoding: options.encoding } : {}),
102
112
  ...(mode !== undefined ? { mode } : {}),
103
113
  });
114
+ // Same umask concern as the primary path above: `mode` on writeFileSync
115
+ // can still be trimmed by umask, so enforce it explicitly.
116
+ if (mode !== undefined)
117
+ fs.chmodSync(filePath, mode);
104
118
  }
105
119
  }
106
120
  function isRenameFallback(err) {
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { CONFIG_DIR } from "../config/config.js";
5
5
  import { hardenWindowsDir } from "../config/winAcl.js";
6
- import { debugLog } from "../config/debug.js";
6
+ import { debugLog, warnUser } from "../config/debug.js";
7
7
  const GENESIS = "0".repeat(64);
8
8
  /**
9
9
  * Where all audit logs live, across every workspace (not scoped per-project).
@@ -108,7 +108,7 @@ export class AuditLog {
108
108
  }
109
109
  catch (err) {
110
110
  // best-effort
111
- debugLog(`AuditLog.write(${this.file})`, err);
111
+ warnUser(`AuditLog.write(${this.file})`, err);
112
112
  }
113
113
  }
114
114
  ensureDir() {
@@ -2,9 +2,105 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { hardenWindowsDir } from "./winAcl.js";
5
- import { debugLog } from "./debug.js";
5
+ import { debugLog, warnUser } from "./debug.js";
6
+ import { writeFileAtomicSync } from "../atomicWrite.js";
7
+ import { assertJsonWithinLimits } from "./jsonSafety.js";
8
+ /**
9
+ * Bounds on `mcpServers` entries (from config.json or a workspace's
10
+ * .mcp.json) — without these, an oversized or malicious server list could
11
+ * balloon memory, or a single field (a giant command string, thousands of
12
+ * headers) could be used to abuse whatever eventually consumes it (a shell
13
+ * spawn, an HTTP client). Real MCP server configs are tiny; these limits are
14
+ * generous multiples of anything legitimate.
15
+ */
16
+ const MAX_MCP_SERVERS = 50;
17
+ const MAX_MCP_COMMAND_LENGTH = 4096;
18
+ const MAX_MCP_ARGS = 200;
19
+ const MAX_MCP_ARG_LENGTH = 4096;
20
+ const MAX_MCP_URL_LENGTH = 8192;
21
+ const MAX_MCP_CWD_LENGTH = 4096;
22
+ const MAX_MCP_MAP_ENTRIES = 200;
23
+ const MAX_MCP_MAP_VALUE_LENGTH = 16384;
24
+ function sanitizeMcpStringMap(rec, label) {
25
+ if (!rec)
26
+ return undefined;
27
+ const entries = Object.entries(rec);
28
+ const out = {};
29
+ let kept = 0;
30
+ for (const [k, v] of entries) {
31
+ if (kept >= MAX_MCP_MAP_ENTRIES) {
32
+ warnUser(label, new Error(`more than ${MAX_MCP_MAP_ENTRIES} entries; extra entries dropped`));
33
+ break;
34
+ }
35
+ if (typeof v !== "string" || v.length > MAX_MCP_MAP_VALUE_LENGTH) {
36
+ warnUser(label, new Error(`entry "${k}" exceeds ${MAX_MCP_MAP_VALUE_LENGTH} char limit; dropped`));
37
+ continue;
38
+ }
39
+ out[k] = v;
40
+ kept++;
41
+ }
42
+ return out;
43
+ }
44
+ /** Validates one server entry against the bounds above; returns null to drop the whole server. */
45
+ export function sanitizeMcpServerConfig(name, cfg) {
46
+ if (typeof cfg.command === "string" && cfg.command.length > MAX_MCP_COMMAND_LENGTH) {
47
+ warnUser(`mcpServers.${name}.command`, new Error(`exceeds ${MAX_MCP_COMMAND_LENGTH} char limit; server dropped`));
48
+ return null;
49
+ }
50
+ if (typeof cfg.url === "string" && cfg.url.length > MAX_MCP_URL_LENGTH) {
51
+ warnUser(`mcpServers.${name}.url`, new Error(`exceeds ${MAX_MCP_URL_LENGTH} char limit; server dropped`));
52
+ return null;
53
+ }
54
+ if (typeof cfg.cwd === "string" && cfg.cwd.length > MAX_MCP_CWD_LENGTH) {
55
+ warnUser(`mcpServers.${name}.cwd`, new Error(`exceeds ${MAX_MCP_CWD_LENGTH} char limit; server dropped`));
56
+ return null;
57
+ }
58
+ let args = cfg.args;
59
+ if (args) {
60
+ if (args.length > MAX_MCP_ARGS) {
61
+ warnUser(`mcpServers.${name}.args`, new Error(`${args.length} args exceeds limit of ${MAX_MCP_ARGS}; extra args dropped`));
62
+ args = args.slice(0, MAX_MCP_ARGS);
63
+ }
64
+ if (args.some((a) => typeof a !== "string" || a.length > MAX_MCP_ARG_LENGTH)) {
65
+ warnUser(`mcpServers.${name}.args`, new Error(`an argument exceeds ${MAX_MCP_ARG_LENGTH} char limit; server dropped`));
66
+ return null;
67
+ }
68
+ }
69
+ return {
70
+ ...cfg,
71
+ args,
72
+ env: sanitizeMcpStringMap(cfg.env, `mcpServers.${name}.env`),
73
+ headers: sanitizeMcpStringMap(cfg.headers, `mcpServers.${name}.headers`),
74
+ };
75
+ }
76
+ /** Caps server count and validates each entry; oversized/malformed servers are dropped, not truncated. */
77
+ export function sanitizeMcpServersRecord(servers, label = "mcpServers") {
78
+ if (!servers)
79
+ return undefined;
80
+ const names = Object.keys(servers);
81
+ if (names.length > MAX_MCP_SERVERS) {
82
+ warnUser(label, new Error(`${names.length} servers exceeds limit of ${MAX_MCP_SERVERS}; extra servers dropped`));
83
+ }
84
+ const out = {};
85
+ let kept = 0;
86
+ for (const name of names) {
87
+ if (kept >= MAX_MCP_SERVERS)
88
+ break;
89
+ const sanitized = sanitizeMcpServerConfig(name, servers[name]);
90
+ if (sanitized) {
91
+ out[name] = sanitized;
92
+ kept++;
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ function sanitizeCliConfig(config) {
98
+ if (!config.mcpServers)
99
+ return config;
100
+ return { ...config, mcpServers: sanitizeMcpServersRecord(config.mcpServers) };
101
+ }
6
102
  export const CONFIG_DIR = path.join(os.homedir(), ".kritya");
7
- const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
103
+ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
8
104
  export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
9
105
  /**
10
106
  * Built-in OpenAI-compatible providers. Users select one with `provider` in
@@ -101,16 +197,25 @@ export function listProviders(config) {
101
197
  .map((name) => ({ name, hasKey: !!resolveProvider(config, name).apiKey }));
102
198
  }
103
199
  export function loadConfig() {
200
+ let raw;
104
201
  try {
105
- const raw = fs.readFileSync(CONFIG_FILE, "utf8");
106
- return JSON.parse(raw);
202
+ raw = fs.readFileSync(CONFIG_FILE, "utf8");
107
203
  }
108
204
  catch (err) {
109
- // A missing file is normal on first run; a malformed one is worth being
110
- // able to see when someone reports "my config isn't taking effect".
205
+ // A missing file is normal on first run.
111
206
  debugLog(`loadConfig(${CONFIG_FILE})`, err);
112
207
  return {};
113
208
  }
209
+ try {
210
+ assertJsonWithinLimits(raw, "config.json");
211
+ return sanitizeCliConfig(JSON.parse(raw));
212
+ }
213
+ catch (err) {
214
+ // Malformed or oversized — worth being able to see when someone reports
215
+ // "my config isn't taking effect".
216
+ warnUser(`loadConfig(${CONFIG_FILE})`, err);
217
+ return {};
218
+ }
114
219
  }
115
220
  /**
116
221
  * Persist `model` as the default for one provider, leaving every other
@@ -136,15 +241,13 @@ export function saveConfig(patch) {
136
241
  // config.json can hold a literal apiKey — keep it readable only by the owner.
137
242
  fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
138
243
  hardenWindowsDir(CONFIG_DIR);
139
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
140
- // `mode` on writeFileSync only applies when creating a new file; enforce it
141
- // even if config.json pre-existed with looser permissions.
142
- try {
143
- fs.chmodSync(CONFIG_FILE, 0o600);
144
- }
145
- catch (err) {
146
- debugLog(`saveConfig chmod(${CONFIG_FILE})`, err);
147
- }
244
+ // Atomic (write-to-temp-then-rename) rather than a direct write: config.json
245
+ // holds API keys, and a crash mid-write would otherwise leave it truncated
246
+ // and unreadable on next launch. `mode: 0o600` is forced on every save
247
+ // (rather than only applying on creation, like a plain writeFileSync would),
248
+ // so a config.json that pre-existed with looser permissions still ends up
249
+ // owner-only.
250
+ writeFileAtomicSync(CONFIG_FILE, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
148
251
  }
149
252
  /**
150
253
  * A copy of process.env with kritya-managed secrets removed. Every built-in
@@ -17,3 +17,33 @@ export function debugLog(context, err) {
17
17
  // stderr itself failing isn't something debug logging can do anything about
18
18
  }
19
19
  }
20
+ /**
21
+ * For best-effort persistence that a user actually needs to know failed
22
+ * (session/audit/telemetry writes) — unlike debugLog, this always prints a
23
+ * short one-line warning to stderr, not just under KRITYA_DEBUG. The full
24
+ * stack trace still only shows up under KRITYA_DEBUG, via the debugLog call
25
+ * this makes internally.
26
+ */
27
+ /**
28
+ * Contexts already warned about via warnUser() this process. Some of these
29
+ * (a telemetry sink retried every span, an append() called every turn) would
30
+ * otherwise print the same warning on every single failure — once per
31
+ * context is enough to tell the user something is wrong without flooding
32
+ * the terminal.
33
+ */
34
+ const warnedContexts = new Set();
35
+ export function warnUser(context, err) {
36
+ if (warnedContexts.has(context)) {
37
+ debugLog(context, err);
38
+ return;
39
+ }
40
+ warnedContexts.add(context);
41
+ const message = err instanceof Error ? err.message : String(err);
42
+ try {
43
+ process.stderr.write(`[kritya] warning: ${context} failed: ${message}\n`);
44
+ }
45
+ catch {
46
+ // stderr itself failing isn't something this can do anything about
47
+ }
48
+ debugLog(context, err);
49
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared bounds checks for JSON config files (config.json, .mcp.json) read
3
+ * from disk. These files are small by nature, so a huge file or pathological
4
+ * nesting is either corruption or a deliberately hostile input (e.g. a
5
+ * malicious .mcp.json checked into a repo someone was talked into trusting)
6
+ * — either way, better to refuse it than to hand an unbounded string or
7
+ * object graph to JSON.parse and whatever reads the result afterwards.
8
+ */
9
+ /** Config files this small in practice; anything past this is refused outright. */
10
+ export const MAX_CONFIG_JSON_BYTES = 5 * 1024 * 1024;
11
+ /**
12
+ * Deep nesting costs stack frames in every recursive consumer downstream
13
+ * (JSON.stringify on save, object spreads, etc.), not just JSON.parse
14
+ * itself. Real configs nest a handful of levels deep at most.
15
+ */
16
+ export const MAX_JSON_DEPTH = 64;
17
+ export class JsonSafetyError extends Error {
18
+ }
19
+ /** Throws if `raw` is larger than `maxBytes` (measured in UTF-8 bytes, not chars). */
20
+ export function assertJsonSizeWithinLimit(raw, maxBytes = MAX_CONFIG_JSON_BYTES, label = "JSON") {
21
+ if (Buffer.byteLength(raw, "utf8") > maxBytes) {
22
+ throw new JsonSafetyError(`${label} exceeds ${maxBytes} byte limit`);
23
+ }
24
+ }
25
+ /**
26
+ * Throws if the raw JSON text nests objects/arrays deeper than `maxDepth`.
27
+ * Scans the text directly rather than the parsed tree, so a hostile input is
28
+ * rejected before JSON.parse ever builds it. String contents (which may
29
+ * contain unbalanced-looking brace/bracket characters) are skipped rather
30
+ * than scanned, respecting escapes so an escaped quote doesn't end the
31
+ * string early.
32
+ */
33
+ export function assertJsonDepthWithinLimit(raw, maxDepth = MAX_JSON_DEPTH, label = "JSON") {
34
+ let depth = 0;
35
+ let inString = false;
36
+ let escaped = false;
37
+ for (let i = 0; i < raw.length; i++) {
38
+ const ch = raw[i];
39
+ if (inString) {
40
+ if (escaped) {
41
+ escaped = false;
42
+ }
43
+ else if (ch === "\\") {
44
+ escaped = true;
45
+ }
46
+ else if (ch === '"') {
47
+ inString = false;
48
+ }
49
+ continue;
50
+ }
51
+ if (ch === '"') {
52
+ inString = true;
53
+ }
54
+ else if (ch === "{" || ch === "[") {
55
+ depth++;
56
+ if (depth > maxDepth) {
57
+ throw new JsonSafetyError(`${label} nests deeper than ${maxDepth} levels`);
58
+ }
59
+ }
60
+ else if (ch === "}" || ch === "]") {
61
+ depth--;
62
+ }
63
+ }
64
+ }
65
+ /** Runs both the size and depth checks together — the usual entry point before JSON.parse. */
66
+ export function assertJsonWithinLimits(raw, label, maxBytes = MAX_CONFIG_JSON_BYTES, maxDepth = MAX_JSON_DEPTH) {
67
+ assertJsonSizeWithinLimit(raw, maxBytes, label);
68
+ assertJsonDepthWithinLimit(raw, maxDepth, label);
69
+ }
@@ -5,6 +5,7 @@ import { CONFIG_DIR, scrubbedShellEnv } from "../config/config.js";
5
5
  import { safeCompileRegex } from "../tools/common.js";
6
6
  import { NOOP_TRACER } from "../telemetry/tracer.js";
7
7
  import { debugLog } from "../config/debug.js";
8
+ import { redactSecrets } from "../tools/secretScan.js";
8
9
  const HOOK_TIMEOUT_MS = 30_000;
9
10
  export function loadHooks(workspace, trustWorkspace = true) {
10
11
  const merged = {};
@@ -85,7 +86,12 @@ export class HookRunner {
85
86
  parent,
86
87
  attributes: { "kritya.hook_command": def.command, "kritya.hook_tool": toolName },
87
88
  });
88
- const { ok, output } = await execHook(def.command, this.workspace, env);
89
+ // Hook stdout/stderr is arbitrary command output — it can echo back
90
+ // whatever the command saw (an env var, a file's contents), so it goes
91
+ // through the same secret redaction as shell output before it's kept
92
+ // in the span or handed back to the model.
93
+ const { ok, output: rawOutput } = await execHook(def.command, this.workspace, env);
94
+ const output = redactSecrets(rawOutput).redacted;
89
95
  if (output)
90
96
  outputs.push(output);
91
97
  if (!ok && event === "preToolUse" && def.blocking) {
@@ -107,7 +113,8 @@ export class HookRunner {
107
113
  attributes: { "kritya.hook_command": def.command },
108
114
  });
109
115
  // stop hooks are best-effort; failures are ignored beyond the span.
110
- const { ok, output } = await execHook(def.command, this.workspace, scrubbedShellEnv());
116
+ const { ok, output: rawOutput } = await execHook(def.command, this.workspace, scrubbedShellEnv());
117
+ const output = redactSecrets(rawOutput).redacted;
111
118
  span.setStatus(ok ? "OK" : "ERROR", ok ? undefined : output.slice(0, 500)).end();
112
119
  }
113
120
  }
package/dist/index.js CHANGED
@@ -360,9 +360,16 @@ async function main() {
360
360
  // hung collector can't stall shutdown) — cleanup()'s own sessionMeter.flush()
361
361
  // is fire-and-forget and would otherwise usually be discarded by the
362
362
  // process.exit() that follows it.
363
+ // SIGINT is included alongside SIGTERM/SIGHUP for the same reason: Ink's
364
+ // own Ctrl+C handling only fires when it can read a raw keypress from
365
+ // stdin (which itself falls through to "exit" → cleanup), but a SIGINT
366
+ // delivered directly to the process — `kill -INT`, a terminal that still
367
+ // sends a real signal instead of raw-mode bytes — bypasses that path
368
+ // entirely unless it's handled here too.
363
369
  for (const [sig, code] of [
364
370
  ["SIGTERM", 143],
365
371
  ["SIGHUP", 129],
372
+ ["SIGINT", 130],
366
373
  ]) {
367
374
  process.on(sig, () => {
368
375
  void (async () => {
@@ -6,10 +6,11 @@ import { NOOP_TRACER } from "../telemetry/tracer.js";
6
6
  import { McpAuthRequiredError } from "./oauth.js";
7
7
  import { missingVars } from "./servers.js";
8
8
  import { HttpTransport, StdioTransport } from "./transport.js";
9
- import { isPrivateOrLoopbackHost } from "../net/urlSafety.js";
9
+ import { assertSafeUrl as assertSafeUrlShared } from "../net/urlSafety.js";
10
10
  import { checkToolsShape, serverFingerprint } from "../trust/mcpTrust.js";
11
11
  import { probeStdioEra, probeHttpEra } from "./eraDetect.js";
12
12
  import { ModernMcpConnection } from "./clientModern.js";
13
+ import { redactSecrets } from "../tools/secretScan.js";
13
14
  import { ModernHttpTransport, ReusedProcessTransport, validateToolHeaders, } from "./transportModern.js";
14
15
  import { checkSchemaSafety } from "./schemaSafety.js";
15
16
  /**
@@ -545,42 +546,17 @@ export function toolAllowed(name, filter) {
545
546
  return true;
546
547
  return filter.allow.some((p) => matchesPattern(name, p));
547
548
  }
548
- /** Loopback is exempt from the https requirement: there's no network to sniff. */
549
- function isLoopback(hostname) {
550
- const h = hostname.replace(/^\[|\]$/g, "");
551
- return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
552
- }
553
549
  /**
554
- * Reject a remote server reachable only over plaintext. `/mcp add` already
555
- * refuses these, but that guards one entrance: a server hand-written into
556
- * ~/.kritya/config.json or a repo's .mcp.json never passes through it and
557
- * would happily POST a bearer token in the clear. This is the choke point all
558
- * three sources share.
550
+ * Reject a remote server reachable only over plaintext, or pointed at a
551
+ * private/internal address. `/mcp add` already refuses these, but that
552
+ * guards one entrance: a server hand-written into ~/.kritya/config.json or a
553
+ * repo's .mcp.json never passes through it and would happily POST a bearer
554
+ * token in the clear. This is the choke point all three sources share —
555
+ * delegates to the same check used for OAuth discovery/token endpoints
556
+ * (src/mcp/oauth.ts) so the two can't silently drift apart.
559
557
  */
560
558
  export function assertSafeUrl(name, url) {
561
- let parsed;
562
- try {
563
- parsed = new URL(url);
564
- }
565
- catch {
566
- throw new Error(`server "${name}" has an invalid url: ${url}`);
567
- }
568
- // https alone doesn't mean the traffic (incl. any bearer token in headers)
569
- // stays where the user intends — a config pointing at 169.254.169.254 or
570
- // another private/internal address would still ship credentials there.
571
- // Loopback is exempt: that's this app talking to itself, nothing to leak to.
572
- if (!isLoopback(parsed.hostname) && isPrivateOrLoopbackHost(parsed.hostname)) {
573
- throw new Error(`server "${name}" points at a private/internal address (${parsed.hostname}) — refusing to connect.`);
574
- }
575
- if (parsed.protocol === "https:")
576
- return parsed;
577
- if (parsed.protocol === "http:" && isLoopback(parsed.hostname))
578
- return parsed;
579
- if (parsed.protocol !== "http:") {
580
- throw new Error(`server "${name}" uses unsupported scheme "${parsed.protocol}" — use https://`);
581
- }
582
- throw new Error(`server "${name}" uses plain http:// (${parsed.host}) — an MCP session carries ` +
583
- `your credentials in cleartext over it. Use https:// (localhost is exempt).`);
559
+ return assertSafeUrlShared(`server "${name}"`, url);
584
560
  }
585
561
  function makeTransport(name, cfg, workspace) {
586
562
  if (cfg.url) {
@@ -878,9 +854,12 @@ export async function connectServer(name, cfg, trace) {
878
854
  status.error = err instanceof Error ? err.message : String(err);
879
855
  process.stderr.write(`kritya: MCP server "${name}" failed to start: ${status.error}\n`);
880
856
  span.setStatus("ERROR", status.error);
857
+ // status.error comes from the (untrusted) server process's own error
858
+ // output, which could echo back an env var or header value it was
859
+ // configured with.
881
860
  trace?.audit?.logTool({
882
861
  tool: "mcp_connect",
883
- summary: `server "${name}" failed to start: ${status.error}`,
862
+ summary: redactSecrets(`server "${name}" failed to start: ${status.error}`).redacted,
884
863
  outcome: "error",
885
864
  });
886
865
  }
@@ -44,6 +44,7 @@ import { spawn } from "node:child_process";
44
44
  import { planSpawn } from "./spawnWin.js";
45
45
  import { minimalEnv } from "./transport.js";
46
46
  import { OAuthSession } from "./oauth.js";
47
+ import { pinnedDispatcherAllowLoopback } from "../net/urlSafety.js";
47
48
  const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
48
49
  /**
49
50
  * Probe a stdio server with `server/discover`, per
@@ -160,18 +161,22 @@ export async function probeHttpEra(url, headers, timeoutMs = DEFAULT_HTTP_PROBE_
160
161
  }
161
162
  return built;
162
163
  };
163
- const post = async () => fetch(url, {
164
- method: "POST",
165
- headers: await buildHeaders(),
166
- body: JSON.stringify({
167
- jsonrpc: "2.0",
168
- id: "discover-probe",
169
- method: "server/discover",
170
- params: { _meta: modernMeta() },
171
- }),
172
- redirect: "manual",
173
- signal: AbortSignal.timeout(timeoutMs),
174
- });
164
+ const post = async () => {
165
+ const init = {
166
+ method: "POST",
167
+ headers: await buildHeaders(),
168
+ body: JSON.stringify({
169
+ jsonrpc: "2.0",
170
+ id: "discover-probe",
171
+ method: "server/discover",
172
+ params: { _meta: modernMeta() },
173
+ }),
174
+ redirect: "manual",
175
+ signal: AbortSignal.timeout(timeoutMs),
176
+ dispatcher: pinnedDispatcherAllowLoopback,
177
+ };
178
+ return fetch(url, init);
179
+ };
175
180
  let res;
176
181
  try {
177
182
  res = await post();
package/dist/mcp/oauth.js CHANGED
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import { VERSION } from "../version.js";
3
3
  import { debugLog } from "../config/debug.js";
4
4
  import { isExpired, loadAuth, saveAuth } from "./tokens.js";
5
+ import { assertSafeUrl, pinnedDispatcherAllowLoopback, } from "../net/urlSafety.js";
5
6
  /**
6
7
  * OAuth 2.1 for remote (Streamable HTTP) MCP servers.
7
8
  *
@@ -43,8 +44,23 @@ export class McpAuthRequiredError extends Error {
43
44
  function ua() {
44
45
  return { "user-agent": `kritya/${VERSION}`, accept: "application/json" };
45
46
  }
47
+ /**
48
+ * Fetches JSON from a URL that isn't necessarily one kritya's own config
49
+ * chose — the .well-known candidates are built from a `serverUrl`/`issuer`,
50
+ * but a caller can also pass a `resource_metadata` URL taken straight off a
51
+ * server's WWW-Authenticate header, or an `authorization_servers` entry
52
+ * from that server's own metadata document. Both are attacker-controlled if
53
+ * the MCP server is malicious or compromised, so validate here rather than
54
+ * trust every candidate this module constructs.
55
+ */
46
56
  async function getJson(url, timeoutMs = DISCOVERY_TIMEOUT_MS) {
47
- const res = await fetch(url, { headers: ua(), signal: AbortSignal.timeout(timeoutMs) });
57
+ const safe = assertSafeUrl("OAuth discovery endpoint", url);
58
+ const init = {
59
+ headers: ua(),
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ dispatcher: pinnedDispatcherAllowLoopback,
62
+ };
63
+ const res = await fetch(safe, init);
48
64
  if (!res.ok)
49
65
  throw new Error(`HTTP ${res.status} from ${url}`);
50
66
  return res.json();
@@ -156,7 +172,7 @@ export async function registerClient(meta, redirectUri, scope) {
156
172
  throw new Error(`authorization server ${meta.issuer} does not support dynamic client registration; ` +
157
173
  `register kritya manually and put the client_id in mcp-auth.json`);
158
174
  }
159
- const res = await fetch(meta.registrationEndpoint, {
175
+ const registrationInit = {
160
176
  method: "POST",
161
177
  headers: { ...ua(), "content-type": "application/json" },
162
178
  body: JSON.stringify({
@@ -169,7 +185,9 @@ export async function registerClient(meta, redirectUri, scope) {
169
185
  ...(scope ? { scope } : {}),
170
186
  }),
171
187
  signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
172
- });
188
+ dispatcher: pinnedDispatcherAllowLoopback,
189
+ };
190
+ const res = await fetch(assertSafeUrl("OAuth registration endpoint", meta.registrationEndpoint), registrationInit);
173
191
  if (!res.ok) {
174
192
  const body = await res.text().catch(() => "");
175
193
  throw new Error(`client registration failed: HTTP ${res.status}${body ? ` — ${body.slice(0, 200)}` : ""}`);
@@ -204,12 +222,14 @@ async function postToken(tokenEndpoint, params, clientSecret) {
204
222
  const basic = Buffer.from(`${params.client_id}:${clientSecret}`).toString("base64");
205
223
  headers.authorization = `Basic ${basic}`;
206
224
  }
207
- const res = await fetch(tokenEndpoint, {
225
+ const tokenInit = {
208
226
  method: "POST",
209
227
  headers,
210
228
  body: new URLSearchParams(params).toString(),
211
229
  signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
212
- });
230
+ dispatcher: pinnedDispatcherAllowLoopback,
231
+ };
232
+ const res = await fetch(assertSafeUrl("OAuth token endpoint", tokenEndpoint), tokenInit);
213
233
  const doc = (await res.json().catch(() => ({})));
214
234
  if (!res.ok || doc.error || !doc.access_token) {
215
235
  const detail = doc.error_description ?? doc.error ?? `HTTP ${res.status}`;
@@ -270,7 +290,7 @@ export async function revokeToken(auth) {
270
290
  const token = auth.refreshToken ?? auth.accessToken;
271
291
  const hint = auth.refreshToken ? "refresh_token" : "access_token";
272
292
  try {
273
- const res = await fetch(auth.revocationEndpoint, {
293
+ const revokeInit = {
274
294
  method: "POST",
275
295
  headers: { ...ua(), "content-type": "application/x-www-form-urlencoded" },
276
296
  body: new URLSearchParams({
@@ -279,7 +299,9 @@ export async function revokeToken(auth) {
279
299
  client_id: auth.clientId,
280
300
  }).toString(),
281
301
  signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
282
- });
302
+ dispatcher: pinnedDispatcherAllowLoopback,
303
+ };
304
+ const res = await fetch(assertSafeUrl("OAuth revocation endpoint", auth.revocationEndpoint), revokeInit);
283
305
  return res.ok;
284
306
  }
285
307
  catch (err) {