kritya 0.8.22-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.
- package/dist/agent/toolExecutor.js +33 -1
- package/dist/audit/audit.js +2 -2
- package/dist/config/config.js +109 -5
- package/dist/config/debug.js +30 -0
- package/dist/config/jsonSafety.js +69 -0
- package/dist/hooks/hooks.js +9 -2
- package/dist/index.js +7 -0
- package/dist/mcp/client.js +5 -1
- package/dist/mcp/eraDetect.js +17 -12
- package/dist/mcp/oauth.js +19 -8
- package/dist/mcp/servers.js +18 -3
- package/dist/mcp/transport.js +18 -8
- package/dist/mcp/transportModern.js +7 -2
- package/dist/net/urlSafety.js +56 -0
- package/dist/permissions/danger.js +41 -1
- package/dist/session/store.js +60 -9
- package/dist/telemetry/otlp.js +18 -7
- package/dist/telemetry/tracer.js +2 -2
- package/dist/tools/document/docx.js +6 -0
- package/dist/tools/document/pdf.js +22 -2
- package/dist/tools/document/pptx.js +5 -2
- package/dist/tools/document/xlsx.js +5 -20
- package/dist/tools/document/zipSafety.js +34 -0
- package/dist/tools/document.js +12 -0
- package/dist/tools/fetchUrl.js +1 -37
- package/dist/tools/notebook.js +13 -0
- package/dist/tools/shell.js +6 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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");
|
package/dist/audit/audit.js
CHANGED
|
@@ -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
|
-
|
|
111
|
+
warnUser(`AuditLog.write(${this.file})`, err);
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
ensureDir() {
|
package/dist/config/config.js
CHANGED
|
@@ -2,8 +2,103 @@ 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
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
|
+
}
|
|
7
102
|
export const CONFIG_DIR = path.join(os.homedir(), ".kritya");
|
|
8
103
|
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
9
104
|
export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
|
|
@@ -102,16 +197,25 @@ export function listProviders(config) {
|
|
|
102
197
|
.map((name) => ({ name, hasKey: !!resolveProvider(config, name).apiKey }));
|
|
103
198
|
}
|
|
104
199
|
export function loadConfig() {
|
|
200
|
+
let raw;
|
|
105
201
|
try {
|
|
106
|
-
|
|
107
|
-
return JSON.parse(raw);
|
|
202
|
+
raw = fs.readFileSync(CONFIG_FILE, "utf8");
|
|
108
203
|
}
|
|
109
204
|
catch (err) {
|
|
110
|
-
// A missing file is normal on first run
|
|
111
|
-
// able to see when someone reports "my config isn't taking effect".
|
|
205
|
+
// A missing file is normal on first run.
|
|
112
206
|
debugLog(`loadConfig(${CONFIG_FILE})`, err);
|
|
113
207
|
return {};
|
|
114
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
|
+
}
|
|
115
219
|
}
|
|
116
220
|
/**
|
|
117
221
|
* Persist `model` as the default for one provider, leaving every other
|
package/dist/config/debug.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/hooks/hooks.js
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
package/dist/mcp/client.js
CHANGED
|
@@ -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
|
}
|
package/dist/mcp/eraDetect.js
CHANGED
|
@@ -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 () =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
package/dist/mcp/servers.js
CHANGED
|
@@ -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
|
-
|
|
89
|
+
const file = path.join(workspace, ".mcp.json");
|
|
90
|
+
let raw;
|
|
87
91
|
try {
|
|
88
|
-
|
|
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
|
-
|
|
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
|
package/dist/mcp/transport.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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");
|
package/dist/net/urlSafety.js
CHANGED
|
@@ -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;
|
package/dist/session/store.js
CHANGED
|
@@ -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, warnUser } 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
|
-
|
|
130
|
+
warnUser(`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", {
|
|
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
|
-
|
|
181
|
+
warnUser(`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
|
-
|
|
204
|
+
warnUser(`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 =
|
|
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 =
|
|
280
|
+
raw = readSessionFileCapped(filePath);
|
|
230
281
|
}
|
|
231
282
|
catch {
|
|
232
283
|
return [];
|
package/dist/telemetry/otlp.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { warnUser } 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
|
-
|
|
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
|
-
|
|
110
|
+
dispatcher: pinnedDispatcherAllowLoopback,
|
|
111
|
+
};
|
|
112
|
+
fetch(url.href, init).catch((err) => warnUser(`postOtlp(${path})`, err));
|
|
105
113
|
}
|
|
106
114
|
catch (err) {
|
|
107
|
-
|
|
115
|
+
warnUser(`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
|
-
|
|
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
|
-
|
|
137
|
+
warnUser(`postOtlpAndWait(${path})`, err);
|
|
127
138
|
}
|
|
128
139
|
}
|
package/dist/telemetry/tracer.js
CHANGED
|
@@ -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
|
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
|
-
|
|
161
|
+
warnUser(`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
|
}
|
|
@@ -9,6 +9,15 @@ import { validateBlocks } from "./types.js";
|
|
|
9
9
|
// fileURLToPath yields backslash-separated paths — normalize to forward
|
|
10
10
|
// slashes, which fs.readFile accepts on every platform.
|
|
11
11
|
const STANDARD_FONT_DATA_URL = fileURLToPath(new URL("../../../node_modules/pdfjs-dist/standard_fonts/", import.meta.url)).replace(/\\/g, "/");
|
|
12
|
+
/**
|
|
13
|
+
* A hostile or corrupt PDF can declare an enormous page count, or pack a
|
|
14
|
+
* huge amount of text into a single page; without a cap, extracting text
|
|
15
|
+
* page-by-page has no upper bound on how long it runs or how much memory the
|
|
16
|
+
* accumulated text consumes. Both caps stop the extraction early rather than
|
|
17
|
+
* failing outright — the caller gets whatever was read so far, plus a note.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_PDF_PAGES = 5_000;
|
|
20
|
+
const MAX_PDF_TEXT_CHARS = 5_000_000;
|
|
12
21
|
export async function readPdf(buf) {
|
|
13
22
|
const loadingTask = getDocument({
|
|
14
23
|
data: new Uint8Array(buf),
|
|
@@ -16,16 +25,27 @@ export async function readPdf(buf) {
|
|
|
16
25
|
});
|
|
17
26
|
const doc = await loadingTask.promise;
|
|
18
27
|
const pageTexts = [];
|
|
19
|
-
|
|
28
|
+
let totalChars = 0;
|
|
29
|
+
let truncated = false;
|
|
30
|
+
const pageCount = Math.min(doc.numPages, MAX_PDF_PAGES);
|
|
31
|
+
for (let i = 1; i <= pageCount; i++) {
|
|
20
32
|
const page = await doc.getPage(i);
|
|
21
33
|
const content = await page.getTextContent();
|
|
22
34
|
const text = content.items
|
|
23
35
|
.map((item) => ("str" in item ? item.str : ""))
|
|
24
36
|
.join(" ");
|
|
25
37
|
pageTexts.push(text);
|
|
38
|
+
totalChars += text.length;
|
|
39
|
+
if (totalChars > MAX_PDF_TEXT_CHARS) {
|
|
40
|
+
truncated = true;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
26
43
|
}
|
|
27
44
|
await loadingTask.destroy();
|
|
28
|
-
|
|
45
|
+
if (pageCount < doc.numPages)
|
|
46
|
+
truncated = true;
|
|
47
|
+
return (pageTexts.join("\n\n") +
|
|
48
|
+
(truncated ? `\n\n... [truncated: read ${pageTexts.length} of ${doc.numPages} page(s)]` : ""));
|
|
29
49
|
}
|
|
30
50
|
const PAGE_WIDTH = 612; // US Letter, points
|
|
31
51
|
const PAGE_HEIGHT = 792;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import
|
|
2
|
+
import { loadSafeZip } from "./zipSafety.js";
|
|
3
3
|
// pptxgenjs is CommonJS-only and its bundled types don't resolve cleanly
|
|
4
4
|
// under NodeNext + esModuleInterop (the default import type-checks as the
|
|
5
5
|
// module namespace, not the constructor). Load it via require and type the
|
|
@@ -9,7 +9,10 @@ const PptxGenJS = require("pptxgenjs");
|
|
|
9
9
|
// Matches text runs inside slide XML, e.g. <a:t>Hello</a:t>.
|
|
10
10
|
const TEXT_RUN_RE = /<a:t>([^<]*)<\/a:t>/g;
|
|
11
11
|
export async function readPptx(buf) {
|
|
12
|
-
|
|
12
|
+
// .pptx is a zip container; same decompression-bomb exposure as .xlsx/.docx
|
|
13
|
+
// (see zipSafety.ts) — validate declared uncompressed size before reading
|
|
14
|
+
// any entry's content.
|
|
15
|
+
const zip = await loadSafeZip(buf);
|
|
13
16
|
const slideFiles = Object.keys(zip.files)
|
|
14
17
|
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
|
15
18
|
.sort((a, b) => slideNumber(a) - slideNumber(b));
|
|
@@ -1,27 +1,12 @@
|
|
|
1
1
|
import ExcelJS from "exceljs";
|
|
2
|
-
import
|
|
2
|
+
import { loadSafeZip } from "./zipSafety.js";
|
|
3
3
|
const CELL_REF_RE = /^[A-Za-z]{1,3}[1-9][0-9]*$/;
|
|
4
4
|
// exceljs's xlsx loader decompresses every entry of the .xlsx zip with no
|
|
5
|
-
// size limit (CVE-2026-78206, unpatched upstream as of this writing) —
|
|
6
|
-
//
|
|
7
|
-
// before exceljs itself ever runs.
|
|
8
|
-
// directory (cheap, no inflation), so summing each entry's *declared*
|
|
9
|
-
// uncompressed size here rejects a bomb before handing the buffer to exceljs.
|
|
10
|
-
const MAX_XLSX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
|
|
5
|
+
// size limit (CVE-2026-78206, unpatched upstream as of this writing) — see
|
|
6
|
+
// zipSafety.ts for why checking declared sizes up front (via JSZip, which
|
|
7
|
+
// doesn't inflate) catches this before exceljs itself ever runs.
|
|
11
8
|
async function assertSafeXlsxSize(buf) {
|
|
12
|
-
|
|
13
|
-
let total = 0;
|
|
14
|
-
for (const entry of Object.values(zip.files)) {
|
|
15
|
-
// `_data` is JSZip's internal CompressedObject; there is no public API
|
|
16
|
-
// for a zip entry's declared (pre-inflation) uncompressed size.
|
|
17
|
-
total +=
|
|
18
|
-
entry._data?.uncompressedSize ?? 0;
|
|
19
|
-
if (total > MAX_XLSX_UNCOMPRESSED_BYTES) {
|
|
20
|
-
throw new Error(`This .xlsx file's declared uncompressed size exceeds ` +
|
|
21
|
-
`${MAX_XLSX_UNCOMPRESSED_BYTES / (1024 * 1024)}MB and was refused ` +
|
|
22
|
-
`as a likely decompression bomb rather than risk exhausting memory.`);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
9
|
+
await loadSafeZip(buf);
|
|
25
10
|
}
|
|
26
11
|
export async function readXlsx(buf) {
|
|
27
12
|
await assertSafeXlsxSize(buf);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import JSZip from "jszip";
|
|
2
|
+
/**
|
|
3
|
+
* .docx, .xlsx, and .pptx are all zip containers, and every library that
|
|
4
|
+
* reads them (exceljs, mammoth, and this codebase's own pptx reader via
|
|
5
|
+
* JSZip directly) decompresses each entry with no size limit — a small file
|
|
6
|
+
* whose declared uncompressed size is huge can exhaust memory before the
|
|
7
|
+
* library that actually parses the format ever runs (see CVE-2026-78206 for
|
|
8
|
+
* exceljs specifically; mammoth uses the same unbounded JSZip decompression
|
|
9
|
+
* path). Loading with JSZip first only reads the central directory (cheap,
|
|
10
|
+
* no inflation) so summing each entry's *declared* uncompressed size rejects
|
|
11
|
+
* a bomb before any real parsing starts.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_ZIP_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
|
|
14
|
+
/** Throws if `zip`'s entries declare more than `maxBytes` of uncompressed data combined. */
|
|
15
|
+
export function assertSafeZipEntries(zip, maxBytes = MAX_ZIP_UNCOMPRESSED_BYTES) {
|
|
16
|
+
let total = 0;
|
|
17
|
+
for (const entry of Object.values(zip.files)) {
|
|
18
|
+
// `_data` is JSZip's internal CompressedObject; there is no public API
|
|
19
|
+
// for a zip entry's declared (pre-inflation) uncompressed size.
|
|
20
|
+
total +=
|
|
21
|
+
entry._data?.uncompressedSize ?? 0;
|
|
22
|
+
if (total > maxBytes) {
|
|
23
|
+
throw new Error(`This file's declared uncompressed size exceeds ` +
|
|
24
|
+
`${maxBytes / (1024 * 1024)}MB and was refused as a likely decompression bomb ` +
|
|
25
|
+
`rather than risk exhausting memory.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Loads `buf` as a zip and validates its declared uncompressed size, returning the loaded zip for reuse. */
|
|
30
|
+
export async function loadSafeZip(buf, maxBytes = MAX_ZIP_UNCOMPRESSED_BYTES) {
|
|
31
|
+
const zip = await JSZip.loadAsync(buf);
|
|
32
|
+
assertSafeZipEntries(zip, maxBytes);
|
|
33
|
+
return zip;
|
|
34
|
+
}
|
package/dist/tools/document.js
CHANGED
|
@@ -7,6 +7,13 @@ import { readXlsx, writeXlsx, editXlsx } from "./document/xlsx.js";
|
|
|
7
7
|
import { readPptx, writePptx } from "./document/pptx.js";
|
|
8
8
|
import { readPdf, writePdf, editPdf } from "./document/pdf.js";
|
|
9
9
|
const READABLE_EXTENSIONS = [".docx", ".xlsx", ".pptx", ".pdf"];
|
|
10
|
+
/**
|
|
11
|
+
* Bound the raw file read before it's dispatched to any format-specific
|
|
12
|
+
* reader. Each reader has its own internal limits (zip decompression caps,
|
|
13
|
+
* PDF page/text caps), but those only kick in once parsing has started —
|
|
14
|
+
* this stops an absurdly large file from being buffered into memory at all.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_DOCUMENT_FILE_BYTES = 200 * 1024 * 1024;
|
|
10
17
|
const BLOCK_SCHEMA = {
|
|
11
18
|
type: "array",
|
|
12
19
|
description: "Flowed content blocks, in document order.",
|
|
@@ -39,6 +46,11 @@ export const readDocumentTool = {
|
|
|
39
46
|
const relPath = String(args.path);
|
|
40
47
|
const abs = resolveSafe(ctx.workspace, relPath);
|
|
41
48
|
const ext = path.extname(abs).toLowerCase();
|
|
49
|
+
const { size } = await fs.stat(abs);
|
|
50
|
+
if (size > MAX_DOCUMENT_FILE_BYTES) {
|
|
51
|
+
throw new Error(`${relPath} is ${Math.round(size / (1024 * 1024))}MB, over the ` +
|
|
52
|
+
`${MAX_DOCUMENT_FILE_BYTES / (1024 * 1024)}MB limit for read_document.`);
|
|
53
|
+
}
|
|
42
54
|
const buf = await fs.readFile(abs);
|
|
43
55
|
switch (ext) {
|
|
44
56
|
case ".docx":
|
package/dist/tools/fetchUrl.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
-
import { Agent } from "undici";
|
|
3
2
|
import { truncateResult } from "./common.js";
|
|
4
|
-
import { isPrivateOrLoopbackHost } from "../net/urlSafety.js";
|
|
3
|
+
import { isPrivateOrLoopbackHost, pinnedDispatcher, } from "../net/urlSafety.js";
|
|
5
4
|
/** Cap on how much text a single fetch returns, unless the caller asks for less. */
|
|
6
5
|
const DEFAULT_MAX_CHARS = 20_000;
|
|
7
6
|
/** Give up on a slow or hanging server rather than blocking the whole turn. */
|
|
@@ -67,41 +66,6 @@ export async function hostResolvesToPrivateAddress(hostname, lookupFn = dnsLooku
|
|
|
67
66
|
}
|
|
68
67
|
/** Refuse to keep chasing redirects forever. */
|
|
69
68
|
const MAX_REDIRECTS = 5;
|
|
70
|
-
/**
|
|
71
|
-
* The actual SSRF boundary: a connect-time `lookup` on a dedicated undici
|
|
72
|
-
* Agent, so the address that gets validated is the exact address the socket
|
|
73
|
-
* connects to — one DNS answer, not two. Checking the hostname up front and
|
|
74
|
-
* then letting `fetch` re-resolve it independently (the previous approach)
|
|
75
|
-
* left a TOCTOU window: attacker-controlled or short-TTL DNS can answer
|
|
76
|
-
* differently the second time (classic DNS rebinding). Pinning the lookup
|
|
77
|
-
* closes that regardless of hop, hostname, or TTL.
|
|
78
|
-
*/
|
|
79
|
-
const pinnedDispatcher = new Agent({
|
|
80
|
-
connect: {
|
|
81
|
-
lookup(hostname, options, callback) {
|
|
82
|
-
dnsLookup(hostname, { all: true })
|
|
83
|
-
.then((addresses) => {
|
|
84
|
-
if (addresses.length === 0) {
|
|
85
|
-
callback(new Error(`Could not resolve ${hostname}`), []);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
const bad = addresses.find((a) => isPrivateOrLoopbackHost(a.address));
|
|
89
|
-
if (bad) {
|
|
90
|
-
callback(new Error(`Refusing to connect to ${hostname}: resolves to private/internal address ${bad.address}`), []);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (options.all) {
|
|
94
|
-
callback(null, addresses);
|
|
95
|
-
}
|
|
96
|
-
else {
|
|
97
|
-
const chosen = addresses[0];
|
|
98
|
-
callback(null, chosen.address, chosen.family);
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
.catch((err) => callback(err, []));
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
69
|
/**
|
|
106
70
|
* Fetch `url`, following redirects manually so every hop — not just the
|
|
107
71
|
* original URL — is checked against assertPublicUrl before the (DNS-pinned)
|
package/dist/tools/notebook.js
CHANGED
|
@@ -56,7 +56,20 @@ function summarizeOutputs(outputs) {
|
|
|
56
56
|
return (text.slice(0, MAX_OUTPUT_CHARS) +
|
|
57
57
|
`\n... [output truncated, ${text.length - MAX_OUTPUT_CHARS} more characters]`);
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Cell *output* text is already capped post-parse (MAX_OUTPUT_CHARS above),
|
|
61
|
+
* but nothing bounded the input file itself — a pathologically large
|
|
62
|
+
* .ipynb (corrupt, or a runaway export) would otherwise be read and
|
|
63
|
+
* JSON.parse'd whole regardless of size. Real notebooks are nowhere near
|
|
64
|
+
* this large even with heavy output.
|
|
65
|
+
*/
|
|
66
|
+
const MAX_NOTEBOOK_FILE_BYTES = 50 * 1024 * 1024;
|
|
59
67
|
async function loadNotebook(abs) {
|
|
68
|
+
const { size } = await fs.stat(abs);
|
|
69
|
+
if (size > MAX_NOTEBOOK_FILE_BYTES) {
|
|
70
|
+
throw new Error(`Notebook file is ${Math.round(size / (1024 * 1024))}MB, over the ` +
|
|
71
|
+
`${MAX_NOTEBOOK_FILE_BYTES / (1024 * 1024)}MB limit for reading a .ipynb file.`);
|
|
72
|
+
}
|
|
60
73
|
const text = await fs.readFile(abs, "utf8");
|
|
61
74
|
let nb;
|
|
62
75
|
try {
|
package/dist/tools/shell.js
CHANGED
|
@@ -38,7 +38,12 @@ export const shellTool = {
|
|
|
38
38
|
// background command returns immediately. A second, shorter cap from the
|
|
39
39
|
// agent loop would cut off commands the user explicitly asked to run longer.
|
|
40
40
|
timeoutMs: 0,
|
|
41
|
-
|
|
41
|
+
// The summary is what lands in the audit log and telemetry spans (see
|
|
42
|
+
// toolExecutor's `kritya.summary` attribute), so it needs the same
|
|
43
|
+
// redaction as the background-start echo below — otherwise a credential
|
|
44
|
+
// embedded in the command (e.g. a curl Authorization header) gets
|
|
45
|
+
// persisted in plain text on every ordinary foreground call.
|
|
46
|
+
summarize: (args) => `Run${args.background ? " in background" : ""}: ${redactSecrets(String(args.command ?? "")).redacted}`,
|
|
42
47
|
// A command that printed nothing needs no preview to say so — but every
|
|
43
48
|
// other command's output is the answer, so keep it (null = show the preview).
|
|
44
49
|
resultSummary: (output) => (output.trim() === "(no output)" ? "no output" : null),
|
package/package.json
CHANGED