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.
- package/dist/agent/toolExecutor.js +33 -1
- package/dist/atomicWrite.js +16 -2
- package/dist/audit/audit.js +2 -2
- package/dist/config/config.js +118 -15
- 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 +14 -35
- package/dist/mcp/eraDetect.js +17 -12
- package/dist/mcp/oauth.js +29 -7
- 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 +89 -0
- package/dist/permissions/danger.js +41 -1
- package/dist/provider/client.js +5 -0
- package/dist/session/store.js +96 -13
- package/dist/shell/background.js +38 -3
- 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 +22 -16
- package/dist/tools/notebook.js +13 -0
- package/dist/tools/shell.js +6 -1
- package/package.json +3 -2
|
@@ -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/atomicWrite.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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) {
|
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,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
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
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
|
@@ -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 {
|
|
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
|
|
555
|
-
* refuses these, but that
|
|
556
|
-
* ~/.kritya/config.json or a
|
|
557
|
-
*
|
|
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
|
-
|
|
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
|
}
|
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,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
|
|
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
|
|
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
|
|
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
|
|
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) {
|