kritya 0.8.22-beta → 0.8.24-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.
@@ -10,6 +10,7 @@ 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
  /**
@@ -853,9 +854,12 @@ export async function connectServer(name, cfg, trace) {
853
854
  status.error = err instanceof Error ? err.message : String(err);
854
855
  process.stderr.write(`kritya: MCP server "${name}" failed to start: ${status.error}\n`);
855
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.
856
860
  trace?.audit?.logTool({
857
861
  tool: "mcp_connect",
858
- summary: `server "${name}" failed to start: ${status.error}`,
862
+ summary: redactSecrets(`server "${name}" failed to start: ${status.error}`).redacted,
859
863
  outcome: "error",
860
864
  });
861
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,7 +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 } from "../net/urlSafety.js";
5
+ import { assertSafeUrl, pinnedDispatcherAllowLoopback, } from "../net/urlSafety.js";
6
6
  /**
7
7
  * OAuth 2.1 for remote (Streamable HTTP) MCP servers.
8
8
  *
@@ -55,7 +55,12 @@ function ua() {
55
55
  */
56
56
  async function getJson(url, timeoutMs = DISCOVERY_TIMEOUT_MS) {
57
57
  const safe = assertSafeUrl("OAuth discovery endpoint", url);
58
- const res = await fetch(safe, { headers: ua(), signal: AbortSignal.timeout(timeoutMs) });
58
+ const init = {
59
+ headers: ua(),
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ dispatcher: pinnedDispatcherAllowLoopback,
62
+ };
63
+ const res = await fetch(safe, init);
59
64
  if (!res.ok)
60
65
  throw new Error(`HTTP ${res.status} from ${url}`);
61
66
  return res.json();
@@ -167,7 +172,7 @@ export async function registerClient(meta, redirectUri, scope) {
167
172
  throw new Error(`authorization server ${meta.issuer} does not support dynamic client registration; ` +
168
173
  `register kritya manually and put the client_id in mcp-auth.json`);
169
174
  }
170
- const res = await fetch(assertSafeUrl("OAuth registration endpoint", meta.registrationEndpoint), {
175
+ const registrationInit = {
171
176
  method: "POST",
172
177
  headers: { ...ua(), "content-type": "application/json" },
173
178
  body: JSON.stringify({
@@ -180,7 +185,9 @@ export async function registerClient(meta, redirectUri, scope) {
180
185
  ...(scope ? { scope } : {}),
181
186
  }),
182
187
  signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
183
- });
188
+ dispatcher: pinnedDispatcherAllowLoopback,
189
+ };
190
+ const res = await fetch(assertSafeUrl("OAuth registration endpoint", meta.registrationEndpoint), registrationInit);
184
191
  if (!res.ok) {
185
192
  const body = await res.text().catch(() => "");
186
193
  throw new Error(`client registration failed: HTTP ${res.status}${body ? ` — ${body.slice(0, 200)}` : ""}`);
@@ -215,12 +222,14 @@ async function postToken(tokenEndpoint, params, clientSecret) {
215
222
  const basic = Buffer.from(`${params.client_id}:${clientSecret}`).toString("base64");
216
223
  headers.authorization = `Basic ${basic}`;
217
224
  }
218
- const res = await fetch(assertSafeUrl("OAuth token endpoint", tokenEndpoint), {
225
+ const tokenInit = {
219
226
  method: "POST",
220
227
  headers,
221
228
  body: new URLSearchParams(params).toString(),
222
229
  signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
223
- });
230
+ dispatcher: pinnedDispatcherAllowLoopback,
231
+ };
232
+ const res = await fetch(assertSafeUrl("OAuth token endpoint", tokenEndpoint), tokenInit);
224
233
  const doc = (await res.json().catch(() => ({})));
225
234
  if (!res.ok || doc.error || !doc.access_token) {
226
235
  const detail = doc.error_description ?? doc.error ?? `HTTP ${res.status}`;
@@ -281,7 +290,7 @@ export async function revokeToken(auth) {
281
290
  const token = auth.refreshToken ?? auth.accessToken;
282
291
  const hint = auth.refreshToken ? "refresh_token" : "access_token";
283
292
  try {
284
- const res = await fetch(assertSafeUrl("OAuth revocation endpoint", auth.revocationEndpoint), {
293
+ const revokeInit = {
285
294
  method: "POST",
286
295
  headers: { ...ua(), "content-type": "application/x-www-form-urlencoded" },
287
296
  body: new URLSearchParams({
@@ -290,7 +299,9 @@ export async function revokeToken(auth) {
290
299
  client_id: auth.clientId,
291
300
  }).toString(),
292
301
  signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
293
- });
302
+ dispatcher: pinnedDispatcherAllowLoopback,
303
+ };
304
+ const res = await fetch(assertSafeUrl("OAuth revocation endpoint", auth.revocationEndpoint), revokeInit);
294
305
  return res.ok;
295
306
  }
296
307
  catch (err) {
@@ -1,5 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { sanitizeMcpServersRecord } from "../config/config.js";
4
+ import { assertJsonWithinLimits } from "../config/jsonSafety.js";
5
+ import { warnUser } from "../config/debug.js";
3
6
  /**
4
7
  * Where MCP server definitions come from and how they combine:
5
8
  *
@@ -83,13 +86,23 @@ function isServerConfig(v) {
83
86
  * degrade to "no project servers", not break startup.
84
87
  */
85
88
  export function loadProjectMcpServers(workspace) {
86
- let parsed;
89
+ const file = path.join(workspace, ".mcp.json");
90
+ let raw;
87
91
  try {
88
- parsed = JSON.parse(fs.readFileSync(path.join(workspace, ".mcp.json"), "utf8"));
92
+ raw = fs.readFileSync(file, "utf8");
89
93
  }
90
94
  catch {
91
95
  return undefined;
92
96
  }
97
+ let parsed;
98
+ try {
99
+ assertJsonWithinLimits(raw, ".mcp.json");
100
+ parsed = JSON.parse(raw);
101
+ }
102
+ catch (err) {
103
+ warnUser(`loadProjectMcpServers(${file})`, err);
104
+ return undefined;
105
+ }
93
106
  if (!parsed?.mcpServers || typeof parsed.mcpServers !== "object")
94
107
  return undefined;
95
108
  const servers = {};
@@ -97,7 +110,9 @@ export function loadProjectMcpServers(workspace) {
97
110
  if (isServerConfig(cfg))
98
111
  servers[name] = cfg;
99
112
  }
100
- return Object.keys(servers).length ? servers : undefined;
113
+ if (!Object.keys(servers).length)
114
+ return undefined;
115
+ return sanitizeMcpServersRecord(servers, ".mcp.json mcpServers");
101
116
  }
102
117
  /**
103
118
  * Combine plugin, project, and global server definitions, expanding ${VAR} in
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { McpAuthRequiredError, OAuthSession, parseWwwAuthenticate } from "./oauth.js";
3
3
  import { planSpawn } from "./spawnWin.js";
4
+ import { pinnedDispatcherAllowLoopback } from "../net/urlSafety.js";
4
5
  /** Same-origin redirects we'll follow before calling it a loop. */
5
6
  const MAX_REDIRECTS = 5;
6
7
  function isRedirect(status) {
@@ -212,7 +213,7 @@ export class HttpTransport {
212
213
  let url = this.url;
213
214
  // Bounded, because a redirect chain is otherwise a free loop.
214
215
  for (let hop = 0;; hop++) {
215
- const res = await fetch(url, {
216
+ const init = {
216
217
  method: "POST",
217
218
  headers: await this.buildHeaders(),
218
219
  body,
@@ -224,7 +225,12 @@ export class HttpTransport {
224
225
  // Cancelling has to tear the socket down too, or the request stays in
225
226
  // flight for the full timeout after the user has walked away.
226
227
  signal: withTimeout(timeoutMs, signal),
227
- });
228
+ // DNS-pin every connection, not just the URL kritya validated once at
229
+ // server-config time — closes the DNS-rebinding gap between that
230
+ // check and the actual connect.
231
+ dispatcher: pinnedDispatcherAllowLoopback,
232
+ };
233
+ const res = await fetch(url, init);
228
234
  if (!isRedirect(res.status))
229
235
  return res;
230
236
  const location = res.headers.get("location");
@@ -284,12 +290,16 @@ export class HttpTransport {
284
290
  if (!this.sessionId)
285
291
  return;
286
292
  this.buildHeaders()
287
- .then((headers) => fetch(this.url, {
288
- method: "DELETE",
289
- headers,
290
- redirect: "manual",
291
- signal: AbortSignal.timeout(3_000),
292
- }))
293
+ .then((headers) => {
294
+ const init = {
295
+ method: "DELETE",
296
+ headers,
297
+ redirect: "manual",
298
+ signal: AbortSignal.timeout(3_000),
299
+ dispatcher: pinnedDispatcherAllowLoopback,
300
+ };
301
+ return fetch(this.url, init);
302
+ })
293
303
  .catch(() => { });
294
304
  }
295
305
  }
@@ -1,6 +1,7 @@
1
1
  import { McpAuthRequiredError, OAuthSession, parseWwwAuthenticate } from "./oauth.js";
2
2
  import { withTimeout } from "./transport.js";
3
3
  import { modernMeta, MODERN_PROTOCOL_VERSION } from "./eraDetect.js";
4
+ import { pinnedDispatcherAllowLoopback } from "../net/urlSafety.js";
4
5
  /** Same-origin redirects we'll follow before calling it a loop. */
5
6
  const MAX_REDIRECTS = 5;
6
7
  /** How much of a server's stderr to keep for diagnostics, and how much to report. */
@@ -333,13 +334,17 @@ export class ModernHttpTransport {
333
334
  const body = JSON.stringify(msg);
334
335
  let url = this.url;
335
336
  for (let hop = 0;; hop++) {
336
- const res = await fetch(url, {
337
+ const init = {
337
338
  method: "POST",
338
339
  headers: await this.buildHeaders(msg),
339
340
  body,
340
341
  redirect: "manual",
341
342
  signal: withTimeout(timeoutMs, signal),
342
- });
343
+ // DNS-pin the connection so a rebind between server-config validation
344
+ // and this request can't redirect it to a private address.
345
+ dispatcher: pinnedDispatcherAllowLoopback,
346
+ };
347
+ const res = await fetch(url, init);
343
348
  if (!isRedirect(res.status))
344
349
  return res;
345
350
  const location = res.headers.get("location");
@@ -1,3 +1,5 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import { Agent } from "undici";
1
3
  /**
2
4
  * Shared "is this host on a private/internal network" check, used by both
3
5
  * fetch_url (src/tools/fetchUrl.ts) and the MCP HTTP transport guard
@@ -189,3 +191,57 @@ export function assertSafeUrl(label, url) {
189
191
  throw new Error(`${label} uses plain http:// (${parsed.host}), which would carry credentials in cleartext. ` +
190
192
  `Use https:// (localhost is exempt).`);
191
193
  }
194
+ /**
195
+ * The actual SSRF boundary, shared by every outbound fetch kritya's own
196
+ * process makes on the user's behalf (fetch_url, MCP HTTP/SSE transports,
197
+ * OAuth discovery/registration/token/revocation, OTLP export): a connect-time
198
+ * `lookup` on a dedicated undici Agent, so the address that gets validated is
199
+ * the exact address the socket connects to — one DNS answer, not two.
200
+ * Checking a hostname once up front (assertSafeUrl) and letting `fetch`
201
+ * re-resolve it independently leaves a TOCTOU window: attacker-controlled or
202
+ * short-TTL DNS can answer differently the second time (DNS rebinding).
203
+ * Pinning the lookup closes that regardless of hop, hostname, or TTL.
204
+ *
205
+ * `isBlockedAddress` is pluggable because the two callers disagree on
206
+ * loopback: fetch_url refuses it outright (assertPublicUrl), while MCP
207
+ * servers and OAuth endpoints are routinely run locally during development
208
+ * and assertSafeUrl deliberately exempts loopback there. Each dispatcher
209
+ * instance holds no per-host state beyond ordinary connection pooling, so
210
+ * it's safe to share across every call site that uses the same policy.
211
+ */
212
+ function createPinnedDispatcher(isBlockedAddress) {
213
+ return new Agent({
214
+ connect: {
215
+ lookup(hostname, options, callback) {
216
+ dnsLookup(hostname, { all: true })
217
+ .then((addresses) => {
218
+ if (addresses.length === 0) {
219
+ callback(new Error(`Could not resolve ${hostname}`), []);
220
+ return;
221
+ }
222
+ const bad = addresses.find((a) => isBlockedAddress(a.address));
223
+ if (bad) {
224
+ callback(new Error(`Refusing to connect to ${hostname}: resolves to private/internal address ${bad.address}`), []);
225
+ return;
226
+ }
227
+ if (options.all) {
228
+ callback(null, addresses);
229
+ }
230
+ else {
231
+ const chosen = addresses[0];
232
+ callback(null, chosen.address, chosen.family);
233
+ }
234
+ })
235
+ .catch((err) => callback(err, []));
236
+ },
237
+ },
238
+ });
239
+ }
240
+ /** For callers that never allow loopback either (fetch_url). */
241
+ export const pinnedDispatcher = createPinnedDispatcher(isPrivateOrLoopbackHost);
242
+ /**
243
+ * For callers that follow assertSafeUrl's policy: loopback is fine (a locally
244
+ * running MCP server or OAuth endpoint during development), other private/
245
+ * internal ranges are not.
246
+ */
247
+ export const pinnedDispatcherAllowLoopback = createPinnedDispatcher((address) => !isLoopbackHost(address) && isPrivateOrLoopbackHost(address));
@@ -3,7 +3,31 @@
3
3
  * matches, kritya forces a permission prompt with a warning even if the
4
4
  * command would otherwise be covered by an allowlist rule or an "always allow"
5
5
  * choice — so a blanket `shell(*)` allow can't silently run `rm -rf /`.
6
+ *
7
+ * This is pattern matching over command text, not a shell parser — it can
8
+ * only see danger words that actually appear in the string. It normalizes
9
+ * one specific evasion ($IFS in place of a space) and flags several ways of
10
+ * running an opaque payload (eval, base64 -d, an interpreter's -c/-e,
11
+ * PowerShell's -EncodedCommand), but a command that reassembles a dangerous
12
+ * word at runtime without those (e.g. `a=r;b=m;$a$b -rf /`, or piping
13
+ * through `tr`/`rev`) has no literal substring left to match and will not be
14
+ * caught. Sandboxing (src/shell/sandbox.ts) is the actual backstop for that
15
+ * gap, not a replacement for closing it here.
6
16
  */
17
+ /**
18
+ * `$IFS`/`${IFS}` in place of a literal space is a well-known filter-bypass
19
+ * trick (`rm${IFS}-rf${IFS}/` runs exactly like `rm -rf /`, but every
20
+ * pattern below that requires `\s+` between a command and its arguments
21
+ * never sees a whitespace character to match). Since this is purely a
22
+ * word-separator substitution — nothing about how the shell actually runs
23
+ * the command — normalizing it to a literal space before pattern matching
24
+ * catches the same commands the patterns already catch, without changing
25
+ * what any of them mean.
26
+ */
27
+ const IFS_RE = /\$\{IFS[^}]*\}|\$IFS\b/g;
28
+ function normalizeForDangerCheck(command) {
29
+ return command.replace(IFS_RE, " ");
30
+ }
7
31
  const PATTERNS = [
8
32
  {
9
33
  re: /\brm\s+.*(-[a-z]*[rf][a-z]*\b|--recursive\b|--force\b|--no-preserve-root\b)/i,
@@ -83,11 +107,27 @@ const PATTERNS = [
83
107
  re: /\b(python3?|node|ruby|perl)\b\s+(-c|-e)\b/i,
84
108
  label: "running an inline script (bypasses command-text inspection)",
85
109
  },
110
+ {
111
+ // Shells' inline-command flag is -c specifically; unlike the
112
+ // interpreters above, -e means something else for a shell (errexit) and
113
+ // would false-positive on an ordinary `bash -e build.sh`.
114
+ re: /\b(bash|sh|zsh|dash|ksh)\b\s+-c\b/i,
115
+ label: "running an inline shell command (bypasses command-text inspection)",
116
+ },
117
+ {
118
+ // PowerShell accepts any unambiguous prefix of -EncodedCommand (-enc,
119
+ // -encodedcommand, ...); "en" is enough to disambiguate it from every
120
+ // other powershell.exe flag (-ExecutionPolicy starts "ex", not "en").
121
+ // This is the same base64-obfuscation evasion the generic `base64 -d`
122
+ // pattern above catches for POSIX shells, just spelled differently.
123
+ re: /\b(powershell(\.exe)?|pwsh)\b.*\s-en[a-z]*\b/i,
124
+ label: "running a base64-encoded PowerShell command (-EncodedCommand)",
125
+ },
86
126
  { re: /\bexec\s+\d*[<>]/i, label: "redirecting a shell's own file descriptors (exec)" },
87
127
  ];
88
128
  /** Returns a human-readable danger label if the command is destructive, else null. */
89
129
  export function classifyDanger(command) {
90
- const cmd = command.trim();
130
+ const cmd = normalizeForDangerCheck(command.trim());
91
131
  for (const { re, label } of PATTERNS) {
92
132
  if (re.test(cmd))
93
133
  return label;
@@ -29,6 +29,7 @@ export class SwitchyardProviderClient extends ProviderClient {
29
29
  let lastErr = err;
30
30
  for (const fb of this.fallbacks) {
31
31
  try {
32
+ callbacks.onFallback?.("switchyard", fb.model);
32
33
  return await fb.client.chat(fb.model, messages, tools, callbacks, signal, trace);
33
34
  }
34
35
  catch (fbErr) {
@@ -4,7 +4,56 @@ import path from "node:path";
4
4
  import { writeFileAtomicSync } from "../atomicWrite.js";
5
5
  import { CONFIG_DIR } from "../config/config.js";
6
6
  import { hardenWindowsDir } from "../config/winAcl.js";
7
- import { debugLog } from "../config/debug.js";
7
+ import { debugLog, warnPersistenceFailure } from "../config/debug.js";
8
+ /**
9
+ * A session file loaded whole into memory has no upper bound otherwise —
10
+ * `--continue` on a pathologically large transcript (corruption, a runaway
11
+ * write loop, or an adversarial file dropped into the session directory)
12
+ * would try to allocate the entire thing at once. Above this size, only the
13
+ * most recent bytes are read; older history is dropped rather than the
14
+ * resume failing outright.
15
+ */
16
+ const MAX_SESSION_FILE_BYTES = 50 * 1024 * 1024;
17
+ /**
18
+ * Upper bound on a single message body persisted to a session file. Guards
19
+ * against a runaway model response or tool result ballooning the transcript
20
+ * — the live in-memory turn is unaffected, only what gets written to disk
21
+ * (and so what a future `loadFile` would have to hold in memory at once).
22
+ */
23
+ const MAX_MESSAGE_CONTENT_CHARS = 2_000_000;
24
+ /** Cap `message.content` in place when it's a plain string over the limit. */
25
+ function capMessageContent(message) {
26
+ if (typeof message.content !== "string" || message.content.length <= MAX_MESSAGE_CONTENT_CHARS) {
27
+ return message;
28
+ }
29
+ return {
30
+ ...message,
31
+ content: message.content.slice(0, MAX_MESSAGE_CONTENT_CHARS) +
32
+ `\n... [truncated, ${message.content.length - MAX_MESSAGE_CONTENT_CHARS} more characters]`,
33
+ };
34
+ }
35
+ /**
36
+ * Read `filePath`, capped to the last `maxBytes` bytes when it exceeds that
37
+ * size. The byte cut can land mid-line, so the leading partial line is
38
+ * dropped — callers parse one JSON message per line and would otherwise
39
+ * choke on (or silently misparse) a truncated first line.
40
+ */
41
+ export function readSessionFileCapped(filePath, maxBytes = MAX_SESSION_FILE_BYTES) {
42
+ const size = fs.statSync(filePath).size;
43
+ if (size <= maxBytes)
44
+ return fs.readFileSync(filePath, "utf8");
45
+ const fd = fs.openSync(filePath, "r");
46
+ try {
47
+ const buffer = Buffer.alloc(maxBytes);
48
+ fs.readSync(fd, buffer, 0, maxBytes, size - maxBytes);
49
+ const text = buffer.toString("utf8");
50
+ const firstNewline = text.indexOf("\n");
51
+ return firstNewline === -1 ? "" : text.slice(firstNewline + 1);
52
+ }
53
+ finally {
54
+ fs.closeSync(fd);
55
+ }
56
+ }
8
57
  function sessionDir(workspace) {
9
58
  const hash = crypto.createHash("sha1").update(workspace).digest("hex").slice(0, 12);
10
59
  return path.join(CONFIG_DIR, "sessions", hash);
@@ -78,7 +127,7 @@ export class SessionStore {
78
127
  writeSessionFile(this.tasksFilePath(), JSON.stringify(tasks));
79
128
  }
80
129
  catch (err) {
81
- debugLog(`SessionStore.saveTasks(${this.tasksFilePath()})`, err);
130
+ warnPersistenceFailure(`SessionStore.saveTasks(${this.tasksFilePath()})`, err);
82
131
  }
83
132
  }
84
133
  /** Loads the task checklist saved alongside a given session file, if any. */
@@ -106,7 +155,7 @@ export class SessionStore {
106
155
  fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
107
156
  hardenWindowsDir(CONFIG_DIR);
108
157
  if (seed.length) {
109
- writeSessionFile(this.file, seed.map((m) => JSON.stringify(m) + "\n").join(""));
158
+ writeSessionFile(this.file, seed.map((m) => JSON.stringify(capMessageContent(m)) + "\n").join(""));
110
159
  }
111
160
  }
112
161
  /**
@@ -123,11 +172,13 @@ export class SessionStore {
123
172
  try {
124
173
  fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
125
174
  hardenWindowsDir(CONFIG_DIR);
126
- fs.appendFileSync(this.file, JSON.stringify(message) + "\n", { mode: 0o600 });
175
+ fs.appendFileSync(this.file, JSON.stringify(capMessageContent(message)) + "\n", {
176
+ mode: 0o600,
177
+ });
127
178
  }
128
179
  catch (err) {
129
180
  // Persistence is best-effort; never crash the session over it.
130
- debugLog(`SessionStore.append(${this.file})`, err);
181
+ warnPersistenceFailure(`SessionStore.append(${this.file})`, err);
131
182
  }
132
183
  }
133
184
  /** Start over with a fresh session file (used by /clear). */
@@ -146,11 +197,11 @@ export class SessionStore {
146
197
  try {
147
198
  fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
148
199
  hardenWindowsDir(CONFIG_DIR);
149
- writeSessionFile(this.file, messages.map((m) => JSON.stringify(m) + "\n").join(""));
200
+ writeSessionFile(this.file, messages.map((m) => JSON.stringify(capMessageContent(m)) + "\n").join(""));
150
201
  }
151
202
  catch (err) {
152
203
  // Persistence is best-effort; never crash the session over it.
153
- debugLog(`SessionStore.overwrite(${this.file})`, err);
204
+ warnPersistenceFailure(`SessionStore.overwrite(${this.file})`, err);
154
205
  }
155
206
  }
156
207
  /**
@@ -205,7 +256,7 @@ export class SessionStore {
205
256
  const messages = [];
206
257
  let raw;
207
258
  try {
208
- raw = fs.readFileSync(filePath, "utf8");
259
+ raw = readSessionFileCapped(filePath);
209
260
  }
210
261
  catch {
211
262
  return messages;
@@ -226,7 +277,7 @@ export class SessionStore {
226
277
  static readLines(filePath) {
227
278
  let raw;
228
279
  try {
229
- raw = fs.readFileSync(filePath, "utf8");
280
+ raw = readSessionFileCapped(filePath);
230
281
  }
231
282
  catch {
232
283
  return [];
@@ -1,4 +1,5 @@
1
1
  import { VERSION } from "../version.js";
2
+ import { warnPersistenceFailure } from "../config/debug.js";
2
3
  import { encodeMetricsSnapshot, postOtlp, postOtlpAndWait, } from "./otlp.js";
3
4
  const NOOP_COUNTER = { add() { } };
4
5
  const NOOP_HISTOGRAM = { record() { } };
@@ -113,8 +114,10 @@ class RealMeter {
113
114
  return;
114
115
  postOtlp(this.endpoint, "/v1/metrics", body, this.headers);
115
116
  }
116
- catch {
117
- // best-effort: metrics must never crash the process
117
+ catch (err) {
118
+ // best-effort: metrics must never crash the process, but a dropped
119
+ // export should still be visible to the user.
120
+ warnPersistenceFailure("metrics.flush", err);
118
121
  }
119
122
  }
120
123
  async flushAndWait() {
@@ -124,8 +127,8 @@ class RealMeter {
124
127
  return;
125
128
  await postOtlpAndWait(this.endpoint, "/v1/metrics", body, this.headers);
126
129
  }
127
- catch {
128
- // best-effort: metrics must never crash the process
130
+ catch (err) {
131
+ warnPersistenceFailure("metrics.flushAndWait", err);
129
132
  }
130
133
  }
131
134
  stop() {
@@ -1,4 +1,5 @@
1
- import { debugLog } from "../config/debug.js";
1
+ import { warnPersistenceFailure } from "../config/debug.js";
2
+ import { assertSafeUrl, pinnedDispatcherAllowLoopback, } from "../net/urlSafety.js";
2
3
  function toAnyValue(value) {
3
4
  if (typeof value === "string")
4
5
  return { stringValue: value };
@@ -97,14 +98,21 @@ export function encodeMetricsSnapshot(points, resource) {
97
98
  */
98
99
  export function postOtlp(endpoint, path, body, headers) {
99
100
  try {
100
- fetch(`${endpoint.replace(/\/+$/, "")}${path}`, {
101
+ // Same DNS-pinning + private-address policy as MCP server URLs and OAuth
102
+ // endpoints (loopback ok, other private ranges refused): KRITYA_OTEL_ENDPOINT
103
+ // is user config, not attacker input, but there's no reason this one
104
+ // outbound path should be less protected than the others.
105
+ const url = assertSafeUrl("OTLP endpoint", `${endpoint.replace(/\/+$/, "")}${path}`);
106
+ const init = {
101
107
  method: "POST",
102
108
  headers: { "content-type": "application/json", ...(headers ?? {}) },
103
109
  body: JSON.stringify(body),
104
- }).catch((err) => debugLog(`postOtlp(${path})`, err));
110
+ dispatcher: pinnedDispatcherAllowLoopback,
111
+ };
112
+ fetch(url.href, init).catch((err) => warnPersistenceFailure(`postOtlp(${path})`, err));
105
113
  }
106
114
  catch (err) {
107
- debugLog(`postOtlp(${path})`, err);
115
+ warnPersistenceFailure(`postOtlp(${path})`, err);
108
116
  }
109
117
  }
110
118
  /**
@@ -116,13 +124,16 @@ export function postOtlp(endpoint, path, body, headers) {
116
124
  */
117
125
  export async function postOtlpAndWait(endpoint, path, body, headers) {
118
126
  try {
119
- await fetch(`${endpoint.replace(/\/+$/, "")}${path}`, {
127
+ const url = assertSafeUrl("OTLP endpoint", `${endpoint.replace(/\/+$/, "")}${path}`);
128
+ const init = {
120
129
  method: "POST",
121
130
  headers: { "content-type": "application/json", ...(headers ?? {}) },
122
131
  body: JSON.stringify(body),
123
- });
132
+ dispatcher: pinnedDispatcherAllowLoopback,
133
+ };
134
+ await fetch(url.href, init);
124
135
  }
125
136
  catch (err) {
126
- debugLog(`postOtlpAndWait(${path})`, err);
137
+ warnPersistenceFailure(`postOtlpAndWait(${path})`, err);
127
138
  }
128
139
  }
@@ -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, warnPersistenceFailure } from "../config/debug.js";
7
7
  import { VERSION } from "../version.js";
8
8
  import { encodeSpan, postOtlp } from "./otlp.js";
9
9
  function nowUnixNano() {
@@ -158,7 +158,7 @@ function fileSink(file) {
158
158
  }
159
159
  catch (err) {
160
160
  // best-effort: telemetry must never crash a turn
161
- debugLog(`tracer.fileSink(${file})`, err);
161
+ warnPersistenceFailure(`tracer.fileSink(${file})`, err);
162
162
  }
163
163
  };
164
164
  }
@@ -1,7 +1,13 @@
1
1
  import mammoth from "mammoth";
2
2
  import { Document, HeadingLevel, Packer, Paragraph, TextRun } from "docx";
3
3
  import { validateBlocks } from "./types.js";
4
+ import { loadSafeZip } from "./zipSafety.js";
4
5
  export async function readDocx(buf) {
6
+ // .docx is a zip container, and mammoth decompresses every entry with no
7
+ // size limit — same decompression-bomb exposure as .xlsx (see
8
+ // zipSafety.ts). Validate declared uncompressed size before handing the
9
+ // buffer to mammoth.
10
+ await loadSafeZip(buf);
5
11
  const result = await mammoth.extractRawText({ buffer: buf });
6
12
  return result.value;
7
13
  }