kritya 0.8.22-beta → 0.8.24-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/agent/loop.js +11 -0
- package/dist/agent/toolExecutor.js +49 -3
- package/dist/audit/audit.js +2 -2
- package/dist/config/config.js +116 -5
- package/dist/config/debug.js +63 -0
- package/dist/config/jsonSafety.js +69 -0
- package/dist/engine.js +10 -5
- package/dist/headless.js +11 -6
- package/dist/hooks/hooks.js +9 -2
- package/dist/index.js +26 -9
- 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/provider/switchyardClient.js +1 -0
- package/dist/session/store.js +60 -9
- package/dist/telemetry/metrics.js +7 -4
- 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/dist/ui/App.js +5 -4
- package/dist/ui/StatusLine.js +10 -5
- package/dist/ui/useAgent.js +37 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,7 +50,8 @@ review → fix` for building something new end-to-end
|
|
|
50
50
|
state
|
|
51
51
|
- **Office docs & notebooks** — read/write Word, Excel, PowerPoint, PDF,
|
|
52
52
|
Jupyter
|
|
53
|
-
- **Headless/CI mode** — scriptable, no-TTY runs for automation
|
|
53
|
+
- **Headless/CI mode** — scriptable, no-TTY runs for automation
|
|
54
|
+
- **Privacy mode** — `--privacy`, `KRITYA_PRIVACY=1`, or `"privacyMode": true` disables transcript, audit, and telemetry persistence
|
|
54
55
|
|
|
55
56
|
## Setup
|
|
56
57
|
|
package/dist/agent/loop.js
CHANGED
|
@@ -78,6 +78,14 @@ export class Agent {
|
|
|
78
78
|
audit;
|
|
79
79
|
/** OpenTelemetry-shaped tracer for the tool loop. No-op unless enabled. */
|
|
80
80
|
tracer = NOOP_TRACER;
|
|
81
|
+
/**
|
|
82
|
+
* Fired around compact() so the UI can show a live "compacting…" indicator
|
|
83
|
+
* for the summarization call, which otherwise runs silently until it
|
|
84
|
+
* either finishes or fails (see onToolEnd's "compact" record for the
|
|
85
|
+
* after-the-fact summary).
|
|
86
|
+
*/
|
|
87
|
+
onCompactStart;
|
|
88
|
+
onCompactEnd;
|
|
81
89
|
/** OTLP metrics recorder for tool/turn durations. No-op unless enabled. */
|
|
82
90
|
meter = NOOP_METER;
|
|
83
91
|
/**
|
|
@@ -266,6 +274,7 @@ export class Agent {
|
|
|
266
274
|
this.kill.assertLive();
|
|
267
275
|
const compactSpan = this.tracer.startSpan("agent.compact", { parent: this.currentTurnSpan });
|
|
268
276
|
const tokensBefore = this.lastPromptTokens;
|
|
277
|
+
this.onCompactStart?.();
|
|
269
278
|
try {
|
|
270
279
|
const note = await this.doCompact(signal, compactSpan);
|
|
271
280
|
compactSpan.setStatus("OK");
|
|
@@ -279,6 +288,7 @@ export class Agent {
|
|
|
279
288
|
compactSpan.setAttribute("kritya.prompt_tokens_before", tokensBefore);
|
|
280
289
|
compactSpan.setAttribute("kritya.prompt_tokens_after", this.lastPromptTokens);
|
|
281
290
|
compactSpan.end();
|
|
291
|
+
this.onCompactEnd?.();
|
|
282
292
|
}
|
|
283
293
|
}
|
|
284
294
|
async doCompact(signal, compactSpan) {
|
|
@@ -444,6 +454,7 @@ export class Agent {
|
|
|
444
454
|
onTextDelta: handlers.onTextDelta,
|
|
445
455
|
onReasoningDelta: handlers.onReasoningDelta,
|
|
446
456
|
onRetry: handlers.onRetry,
|
|
457
|
+
onFallback: handlers.onFallback,
|
|
447
458
|
}, signal, { tracer: this.tracer, parent: this.currentTurnSpan });
|
|
448
459
|
let result;
|
|
449
460
|
try {
|
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
import { classifyDanger } from "../permissions/danger.js";
|
|
2
|
-
import { acknowledgeUnsandboxedFallback, sandboxFallbackWarning } from "../shell/sandbox.js";
|
|
2
|
+
import { acknowledgeUnsandboxedFallback, sandboxAvailable, 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;
|
|
@@ -220,7 +251,21 @@ export class ToolExecutor {
|
|
|
220
251
|
diff = undefined;
|
|
221
252
|
}
|
|
222
253
|
}
|
|
223
|
-
const
|
|
254
|
+
const permissionDetails = [
|
|
255
|
+
summary,
|
|
256
|
+
`workspace: ${host.ctx.workspace}`,
|
|
257
|
+
typeof args.path === "string" ? `affected path: ${args.path}` : undefined,
|
|
258
|
+
tool.name === "shell"
|
|
259
|
+
? `command: ${shellCommand}\nsandbox: ${host.ctx.sandboxMode === "off"
|
|
260
|
+
? "off"
|
|
261
|
+
: sandboxAvailable()
|
|
262
|
+
? `${host.ctx.sandboxMode ?? "auto"} (available)`
|
|
263
|
+
: `${host.ctx.sandboxMode ?? "auto"} (unavailable; unsandboxed)`}`
|
|
264
|
+
: undefined,
|
|
265
|
+
]
|
|
266
|
+
.filter(Boolean)
|
|
267
|
+
.join("\n");
|
|
268
|
+
const decision = await handlers.requestPermission(tool.name, permissionDetails, diff, danger ?? undefined);
|
|
224
269
|
// A forced (danger) prompt does not grant a lasting allowance.
|
|
225
270
|
if (danger === null)
|
|
226
271
|
host.permissions.record(tool.name, decision, args);
|
|
@@ -291,6 +336,7 @@ export class ToolExecutor {
|
|
|
291
336
|
if (post.output.trim())
|
|
292
337
|
output += `\n[postToolUse hook]: ${post.output.trim()}`;
|
|
293
338
|
}
|
|
339
|
+
output = truncateToolOutput(output);
|
|
294
340
|
const failed = tool.failed?.(output) ?? false;
|
|
295
341
|
logToolOutcome(failed ? "error" : "ok");
|
|
296
342
|
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, warnPersistenceFailure } 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
|
+
warnPersistenceFailure(`AuditLog.write(${this.file})`, err);
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
ensureDir() {
|
package/dist/config/config.js
CHANGED
|
@@ -2,8 +2,110 @@ 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
|
+
/** KRITYA_PRIVACY wins over config.json; truthy values enable privacy mode. */
|
|
9
|
+
export function privacyModeFor(config) {
|
|
10
|
+
const value = process.env.KRITYA_PRIVACY;
|
|
11
|
+
if (value !== undefined)
|
|
12
|
+
return /^(1|true|yes|on)$/i.test(value.trim());
|
|
13
|
+
return config.privacyMode === true;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Bounds on `mcpServers` entries (from config.json or a workspace's
|
|
17
|
+
* .mcp.json) — without these, an oversized or malicious server list could
|
|
18
|
+
* balloon memory, or a single field (a giant command string, thousands of
|
|
19
|
+
* headers) could be used to abuse whatever eventually consumes it (a shell
|
|
20
|
+
* spawn, an HTTP client). Real MCP server configs are tiny; these limits are
|
|
21
|
+
* generous multiples of anything legitimate.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_MCP_SERVERS = 50;
|
|
24
|
+
const MAX_MCP_COMMAND_LENGTH = 4096;
|
|
25
|
+
const MAX_MCP_ARGS = 200;
|
|
26
|
+
const MAX_MCP_ARG_LENGTH = 4096;
|
|
27
|
+
const MAX_MCP_URL_LENGTH = 8192;
|
|
28
|
+
const MAX_MCP_CWD_LENGTH = 4096;
|
|
29
|
+
const MAX_MCP_MAP_ENTRIES = 200;
|
|
30
|
+
const MAX_MCP_MAP_VALUE_LENGTH = 16384;
|
|
31
|
+
function sanitizeMcpStringMap(rec, label) {
|
|
32
|
+
if (!rec)
|
|
33
|
+
return undefined;
|
|
34
|
+
const entries = Object.entries(rec);
|
|
35
|
+
const out = {};
|
|
36
|
+
let kept = 0;
|
|
37
|
+
for (const [k, v] of entries) {
|
|
38
|
+
if (kept >= MAX_MCP_MAP_ENTRIES) {
|
|
39
|
+
warnUser(label, new Error(`more than ${MAX_MCP_MAP_ENTRIES} entries; extra entries dropped`));
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (typeof v !== "string" || v.length > MAX_MCP_MAP_VALUE_LENGTH) {
|
|
43
|
+
warnUser(label, new Error(`entry "${k}" exceeds ${MAX_MCP_MAP_VALUE_LENGTH} char limit; dropped`));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
out[k] = v;
|
|
47
|
+
kept++;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** Validates one server entry against the bounds above; returns null to drop the whole server. */
|
|
52
|
+
export function sanitizeMcpServerConfig(name, cfg) {
|
|
53
|
+
if (typeof cfg.command === "string" && cfg.command.length > MAX_MCP_COMMAND_LENGTH) {
|
|
54
|
+
warnUser(`mcpServers.${name}.command`, new Error(`exceeds ${MAX_MCP_COMMAND_LENGTH} char limit; server dropped`));
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (typeof cfg.url === "string" && cfg.url.length > MAX_MCP_URL_LENGTH) {
|
|
58
|
+
warnUser(`mcpServers.${name}.url`, new Error(`exceeds ${MAX_MCP_URL_LENGTH} char limit; server dropped`));
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (typeof cfg.cwd === "string" && cfg.cwd.length > MAX_MCP_CWD_LENGTH) {
|
|
62
|
+
warnUser(`mcpServers.${name}.cwd`, new Error(`exceeds ${MAX_MCP_CWD_LENGTH} char limit; server dropped`));
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
let args = cfg.args;
|
|
66
|
+
if (args) {
|
|
67
|
+
if (args.length > MAX_MCP_ARGS) {
|
|
68
|
+
warnUser(`mcpServers.${name}.args`, new Error(`${args.length} args exceeds limit of ${MAX_MCP_ARGS}; extra args dropped`));
|
|
69
|
+
args = args.slice(0, MAX_MCP_ARGS);
|
|
70
|
+
}
|
|
71
|
+
if (args.some((a) => typeof a !== "string" || a.length > MAX_MCP_ARG_LENGTH)) {
|
|
72
|
+
warnUser(`mcpServers.${name}.args`, new Error(`an argument exceeds ${MAX_MCP_ARG_LENGTH} char limit; server dropped`));
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
...cfg,
|
|
78
|
+
args,
|
|
79
|
+
env: sanitizeMcpStringMap(cfg.env, `mcpServers.${name}.env`),
|
|
80
|
+
headers: sanitizeMcpStringMap(cfg.headers, `mcpServers.${name}.headers`),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Caps server count and validates each entry; oversized/malformed servers are dropped, not truncated. */
|
|
84
|
+
export function sanitizeMcpServersRecord(servers, label = "mcpServers") {
|
|
85
|
+
if (!servers)
|
|
86
|
+
return undefined;
|
|
87
|
+
const names = Object.keys(servers);
|
|
88
|
+
if (names.length > MAX_MCP_SERVERS) {
|
|
89
|
+
warnUser(label, new Error(`${names.length} servers exceeds limit of ${MAX_MCP_SERVERS}; extra servers dropped`));
|
|
90
|
+
}
|
|
91
|
+
const out = {};
|
|
92
|
+
let kept = 0;
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
if (kept >= MAX_MCP_SERVERS)
|
|
95
|
+
break;
|
|
96
|
+
const sanitized = sanitizeMcpServerConfig(name, servers[name]);
|
|
97
|
+
if (sanitized) {
|
|
98
|
+
out[name] = sanitized;
|
|
99
|
+
kept++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function sanitizeCliConfig(config) {
|
|
105
|
+
if (!config.mcpServers)
|
|
106
|
+
return config;
|
|
107
|
+
return { ...config, mcpServers: sanitizeMcpServersRecord(config.mcpServers) };
|
|
108
|
+
}
|
|
7
109
|
export const CONFIG_DIR = path.join(os.homedir(), ".kritya");
|
|
8
110
|
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
9
111
|
export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
|
|
@@ -102,16 +204,25 @@ export function listProviders(config) {
|
|
|
102
204
|
.map((name) => ({ name, hasKey: !!resolveProvider(config, name).apiKey }));
|
|
103
205
|
}
|
|
104
206
|
export function loadConfig() {
|
|
207
|
+
let raw;
|
|
105
208
|
try {
|
|
106
|
-
|
|
107
|
-
return JSON.parse(raw);
|
|
209
|
+
raw = fs.readFileSync(CONFIG_FILE, "utf8");
|
|
108
210
|
}
|
|
109
211
|
catch (err) {
|
|
110
|
-
// A missing file is normal on first run
|
|
111
|
-
// able to see when someone reports "my config isn't taking effect".
|
|
212
|
+
// A missing file is normal on first run.
|
|
112
213
|
debugLog(`loadConfig(${CONFIG_FILE})`, err);
|
|
113
214
|
return {};
|
|
114
215
|
}
|
|
216
|
+
try {
|
|
217
|
+
assertJsonWithinLimits(raw, "config.json");
|
|
218
|
+
return sanitizeCliConfig(JSON.parse(raw));
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
// Malformed or oversized — worth being able to see when someone reports
|
|
222
|
+
// "my config isn't taking effect".
|
|
223
|
+
warnUser(`loadConfig(${CONFIG_FILE})`, err);
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
115
226
|
}
|
|
116
227
|
/**
|
|
117
228
|
* Persist `model` as the default for one provider, leaving every other
|
package/dist/config/debug.js
CHANGED
|
@@ -17,3 +17,66 @@ 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
|
+
}
|
|
50
|
+
/** Contexts currently warning via warnPersistenceFailure, in the order first seen. */
|
|
51
|
+
const persistenceWarnings = [];
|
|
52
|
+
const persistenceListeners = new Set();
|
|
53
|
+
function notifyPersistenceListeners() {
|
|
54
|
+
const snapshot = [...persistenceWarnings];
|
|
55
|
+
for (const listener of persistenceListeners)
|
|
56
|
+
listener(snapshot);
|
|
57
|
+
}
|
|
58
|
+
/** Current persistence-failure warnings (session/audit/telemetry writes that failed this process). */
|
|
59
|
+
export function activePersistenceWarnings() {
|
|
60
|
+
return [...persistenceWarnings];
|
|
61
|
+
}
|
|
62
|
+
/** Subscribe to persistence-warning changes. Returns an unsubscribe function. */
|
|
63
|
+
export function onPersistenceWarning(listener) {
|
|
64
|
+
persistenceListeners.add(listener);
|
|
65
|
+
return () => persistenceListeners.delete(listener);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Like warnUser, but for the specific best-effort writes whose failure means
|
|
69
|
+
* silent data loss (session transcripts, audit log, telemetry) — a stderr
|
|
70
|
+
* line alone is easy to miss since Ink's full-screen redraws can immediately
|
|
71
|
+
* paint over it. Recorded once per context, same dedup as warnUser, so the
|
|
72
|
+
* UI badge doesn't grow without bound from a write that fails every turn.
|
|
73
|
+
*/
|
|
74
|
+
export function warnPersistenceFailure(context, err) {
|
|
75
|
+
const isNewContext = !warnedContexts.has(context);
|
|
76
|
+
warnUser(context, err);
|
|
77
|
+
if (isNewContext) {
|
|
78
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
79
|
+
persistenceWarnings.push({ context, message });
|
|
80
|
+
notifyPersistenceListeners();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -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/engine.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { Agent } from "./agent/loop.js";
|
|
3
|
-
import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
|
|
3
|
+
import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
|
|
4
4
|
import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
|
|
5
5
|
import { PermissionManager } from "./permissions/permissions.js";
|
|
6
6
|
import { loadRules } from "./permissions/rules.js";
|
|
@@ -35,6 +35,7 @@ export async function createEngineSession(dir, opts = {}) {
|
|
|
35
35
|
if (trustWorkspace)
|
|
36
36
|
loadDotEnv([path.join(workspace, ".env")]);
|
|
37
37
|
const config = loadConfig();
|
|
38
|
+
const privacyMode = privacyModeFor(config);
|
|
38
39
|
const provider = resolveProvider(config, opts.provider);
|
|
39
40
|
if (!provider.apiKey) {
|
|
40
41
|
throw new Error(`No API key found for provider "${provider.name}". Set it via env var, a .env file, or ~/.kritya/config.json.`);
|
|
@@ -49,9 +50,11 @@ export async function createEngineSession(dir, opts = {}) {
|
|
|
49
50
|
const client = provider.name === "switchyard"
|
|
50
51
|
? await createSwitchyardClient(provider.apiKey, sampling)
|
|
51
52
|
: new ProviderClient(provider.apiKey, provider.baseUrl, sampling);
|
|
52
|
-
const session = new SessionStore(workspace);
|
|
53
|
+
const session = new SessionStore(workspace, privacyMode);
|
|
53
54
|
session.start([]);
|
|
54
|
-
const sessionMeter =
|
|
55
|
+
const sessionMeter = privacyMode
|
|
56
|
+
? createMeter(session.id, "off")
|
|
57
|
+
: createMeter(session.id, config.otel);
|
|
55
58
|
// The crash path is fire-and-forget best-effort by Node's own constraints
|
|
56
59
|
// (a crash handler can't reliably await async work), so this uses the
|
|
57
60
|
// synchronous flush() rather than flushAndWait().
|
|
@@ -79,8 +82,10 @@ export async function createEngineSession(dir, opts = {}) {
|
|
|
79
82
|
if (trustWorkspace) {
|
|
80
83
|
agent.hooks = new HookRunner(loadHooks(workspace, trustWorkspace), workspace);
|
|
81
84
|
}
|
|
82
|
-
agent.audit = AuditLog.forSession(session.id, config.audit);
|
|
83
|
-
agent.tracer =
|
|
85
|
+
agent.audit = privacyMode ? undefined : AuditLog.forSession(session.id, config.audit);
|
|
86
|
+
agent.tracer = privacyMode
|
|
87
|
+
? createTracer(session.id, "off")
|
|
88
|
+
: createTracer(session.id, config.otel);
|
|
84
89
|
agent.meter = sessionMeter;
|
|
85
90
|
if (agent.hooks)
|
|
86
91
|
agent.hooks.tracer = agent.tracer;
|
package/dist/headless.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { Agent } from "./agent/loop.js";
|
|
3
3
|
import { KillSwitchError } from "./agent/killSwitch.js";
|
|
4
|
-
import { CONFIG_DIR, legacyGlobalModel, listProviders, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
|
|
4
|
+
import { CONFIG_DIR, legacyGlobalModel, listProviders, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
|
|
5
5
|
import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
|
|
6
6
|
import { PermissionManager } from "./permissions/permissions.js";
|
|
7
7
|
import { loadRules } from "./permissions/rules.js";
|
|
@@ -78,6 +78,7 @@ export async function runHeadless(args) {
|
|
|
78
78
|
if (trustWorkspace)
|
|
79
79
|
loadDotEnv([path.join(workspace, ".env")]);
|
|
80
80
|
const config = loadConfig();
|
|
81
|
+
const privacyMode = args.privacy === true || privacyModeFor(config);
|
|
81
82
|
const provider = resolveProvider(config, args.provider || undefined);
|
|
82
83
|
if (!provider.apiKey) {
|
|
83
84
|
return finish(args, startedAt, {
|
|
@@ -102,12 +103,16 @@ export async function runHeadless(args) {
|
|
|
102
103
|
const client = provider.name === "switchyard"
|
|
103
104
|
? await createSwitchyardClient(provider.apiKey, sampling)
|
|
104
105
|
: new ProviderClient(provider.apiKey, provider.baseUrl, sampling);
|
|
105
|
-
const session = new SessionStore(workspace);
|
|
106
|
-
const initialHistory = args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
|
|
106
|
+
const session = new SessionStore(workspace, privacyMode);
|
|
107
|
+
const initialHistory = !privacyMode && args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
|
|
107
108
|
session.start(initialHistory);
|
|
108
|
-
const sessionAudit = AuditLog.forSession(session.id, config.audit);
|
|
109
|
-
const sessionTracer =
|
|
110
|
-
|
|
109
|
+
const sessionAudit = privacyMode ? undefined : AuditLog.forSession(session.id, config.audit);
|
|
110
|
+
const sessionTracer = privacyMode
|
|
111
|
+
? createTracer(session.id, "off")
|
|
112
|
+
: createTracer(session.id, config.otel);
|
|
113
|
+
const sessionMeter = privacyMode
|
|
114
|
+
? createMeter(session.id, "off")
|
|
115
|
+
: createMeter(session.id, config.otel);
|
|
111
116
|
// A crash here orphans MCP children and background processes onto a CI
|
|
112
117
|
// runner, where nothing will ever reap them. No terminal to restore. The
|
|
113
118
|
// crash path is fire-and-forget best-effort by Node's own constraints (a
|
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
|
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { render } from "ink";
|
|
6
6
|
import { Agent } from "./agent/loop.js";
|
|
7
|
-
import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
|
|
7
|
+
import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
|
|
8
8
|
import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
|
|
9
9
|
import { PermissionManager } from "./permissions/permissions.js";
|
|
10
10
|
import { loadRules } from "./permissions/rules.js";
|
|
@@ -65,6 +65,7 @@ Headless / CI mode (no terminal UI, exits with 0 on success / 1 on failure):
|
|
|
65
65
|
since CI often checks out untrusted branches/PRs)
|
|
66
66
|
--timeout <seconds> hard wall-clock cap for the whole run (default 1800)
|
|
67
67
|
--non-interactive accepted for compatibility; implied by --prompt
|
|
68
|
+
--privacy do not persist transcripts, audit logs, or telemetry
|
|
68
69
|
|
|
69
70
|
Inspect the local audit log:
|
|
70
71
|
kritya audit --list | --verify [file] | --show [file]
|
|
@@ -93,6 +94,7 @@ function parseArgs(argv) {
|
|
|
93
94
|
allowAll: false,
|
|
94
95
|
trust: false,
|
|
95
96
|
timeoutSeconds: 1800,
|
|
97
|
+
privacy: false,
|
|
96
98
|
};
|
|
97
99
|
for (let i = 0; i < argv.length; i++) {
|
|
98
100
|
const a = argv[i];
|
|
@@ -124,6 +126,8 @@ function parseArgs(argv) {
|
|
|
124
126
|
args.trust = true;
|
|
125
127
|
else if (a === "--timeout")
|
|
126
128
|
args.timeoutSeconds = Number(argv[++i]) || args.timeoutSeconds;
|
|
129
|
+
else if (a === "--privacy")
|
|
130
|
+
args.privacy = true;
|
|
127
131
|
else if (a === "--non-interactive") {
|
|
128
132
|
// implied by --prompt; accepted so scripts can pass it explicitly
|
|
129
133
|
}
|
|
@@ -176,6 +180,7 @@ if (args.prompt) {
|
|
|
176
180
|
allowAll: args.allowAll,
|
|
177
181
|
trust: args.trust,
|
|
178
182
|
timeoutSeconds: args.timeoutSeconds,
|
|
183
|
+
privacy: args.privacy,
|
|
179
184
|
}).then((code) => process.exit(code));
|
|
180
185
|
}
|
|
181
186
|
else {
|
|
@@ -261,6 +266,7 @@ async function showAiDisclosureNotice() {
|
|
|
261
266
|
}
|
|
262
267
|
async function main() {
|
|
263
268
|
const config = loadConfig();
|
|
269
|
+
const privacyMode = args.privacy || privacyModeFor(config);
|
|
264
270
|
if (!isAiDisclosureShown(workspace)) {
|
|
265
271
|
await showAiDisclosureNotice();
|
|
266
272
|
markAiDisclosureShown(workspace);
|
|
@@ -312,18 +318,22 @@ async function main() {
|
|
|
312
318
|
let client = provider.name === "switchyard"
|
|
313
319
|
? await createSwitchyardClient(apiKey, sampling)
|
|
314
320
|
: new ProviderClient(apiKey, provider.baseUrl, sampling);
|
|
315
|
-
const session = new SessionStore(workspace);
|
|
321
|
+
const session = new SessionStore(workspace, privacyMode);
|
|
316
322
|
// Shared by the main agent and every subagent it spawns, so a write
|
|
317
323
|
// subagent's commits and a read-only subagent's tool calls land in the same
|
|
318
324
|
// audit trail and trace tree as the turn that spawned them — an agent that
|
|
319
325
|
// edits the repo should never do so off the record.
|
|
320
|
-
const sessionAudit = AuditLog.forSession(session.id, config.audit);
|
|
321
|
-
const sessionTracer =
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
326
|
+
const sessionAudit = privacyMode ? undefined : AuditLog.forSession(session.id, config.audit);
|
|
327
|
+
const sessionTracer = privacyMode
|
|
328
|
+
? createTracer(session.id, "off")
|
|
329
|
+
: createTracer(session.id, config.otel);
|
|
330
|
+
const sessionMeter = privacyMode
|
|
331
|
+
? createMeter(session.id, "off")
|
|
332
|
+
: createMeter(session.id, config.otel);
|
|
333
|
+
const initialHistory = !privacyMode && args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
|
|
334
|
+
const initialTasks = !privacyMode && args.continue ? SessionStore.loadLatestTasks(workspace) : [];
|
|
325
335
|
session.start(initialHistory);
|
|
326
|
-
const resumeSessions = args.resume ? SessionStore.listSessions(workspace) : [];
|
|
336
|
+
const resumeSessions = !privacyMode && args.resume ? SessionStore.listSessions(workspace) : [];
|
|
327
337
|
// Filled in once the app is mounted, below; the crash handler needs a way to
|
|
328
338
|
// tear the UI down and is installed before there is a UI to tear down.
|
|
329
339
|
const ui = {};
|
|
@@ -360,9 +370,16 @@ async function main() {
|
|
|
360
370
|
// hung collector can't stall shutdown) — cleanup()'s own sessionMeter.flush()
|
|
361
371
|
// is fire-and-forget and would otherwise usually be discarded by the
|
|
362
372
|
// process.exit() that follows it.
|
|
373
|
+
// SIGINT is included alongside SIGTERM/SIGHUP for the same reason: Ink's
|
|
374
|
+
// own Ctrl+C handling only fires when it can read a raw keypress from
|
|
375
|
+
// stdin (which itself falls through to "exit" → cleanup), but a SIGINT
|
|
376
|
+
// delivered directly to the process — `kill -INT`, a terminal that still
|
|
377
|
+
// sends a real signal instead of raw-mode bytes — bypasses that path
|
|
378
|
+
// entirely unless it's handled here too.
|
|
363
379
|
for (const [sig, code] of [
|
|
364
380
|
["SIGTERM", 143],
|
|
365
381
|
["SIGHUP", 129],
|
|
382
|
+
["SIGINT", 130],
|
|
366
383
|
]) {
|
|
367
384
|
process.on(sig, () => {
|
|
368
385
|
void (async () => {
|
|
@@ -670,5 +687,5 @@ async function main() {
|
|
|
670
687
|
permissionRef.current = fn;
|
|
671
688
|
}, onRequestElicitationReady: (fn) => {
|
|
672
689
|
elicitationRef.current = fn;
|
|
673
|
-
} }));
|
|
690
|
+
}, privacyMode: privacyMode }));
|
|
674
691
|
}
|