kritya 0.8.2-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/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- package/package.json +122 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { debugLog } from "../config/debug.js";
|
|
2
|
+
function toAnyValue(value) {
|
|
3
|
+
if (typeof value === "string")
|
|
4
|
+
return { stringValue: value };
|
|
5
|
+
if (typeof value === "boolean")
|
|
6
|
+
return { boolValue: value };
|
|
7
|
+
return { doubleValue: value };
|
|
8
|
+
}
|
|
9
|
+
function toKeyValueList(attributes) {
|
|
10
|
+
return Object.entries(attributes).map(([key, value]) => ({ key, value: toAnyValue(value) }));
|
|
11
|
+
}
|
|
12
|
+
function toOtlpResource(resource) {
|
|
13
|
+
return { attributes: toKeyValueList(resource.attributes) };
|
|
14
|
+
}
|
|
15
|
+
const STATUS_CODE = { UNSET: 0, OK: 1, ERROR: 2 };
|
|
16
|
+
function toOtlpSpan(span) {
|
|
17
|
+
return {
|
|
18
|
+
traceId: span.traceId,
|
|
19
|
+
spanId: span.spanId,
|
|
20
|
+
...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}),
|
|
21
|
+
name: span.name,
|
|
22
|
+
kind: 1, // SPAN_KIND_INTERNAL — kritya's spans are all local tool-loop work
|
|
23
|
+
startTimeUnixNano: span.startTimeUnixNano,
|
|
24
|
+
endTimeUnixNano: span.endTimeUnixNano,
|
|
25
|
+
attributes: toKeyValueList(span.attributes),
|
|
26
|
+
status: {
|
|
27
|
+
code: STATUS_CODE[span.status.code],
|
|
28
|
+
...(span.status.message ? { message: span.status.message } : {}),
|
|
29
|
+
},
|
|
30
|
+
events: span.events.map((e) => ({
|
|
31
|
+
name: e.name,
|
|
32
|
+
timeUnixNano: e.timeUnixNano,
|
|
33
|
+
attributes: e.attributes ? toKeyValueList(e.attributes) : [],
|
|
34
|
+
})),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Wraps a single span in an ExportTraceServiceRequest — one HTTP call per span, no client-side batching (the collector batches downstream). */
|
|
38
|
+
export function encodeSpan(span, resource) {
|
|
39
|
+
return {
|
|
40
|
+
resourceSpans: [
|
|
41
|
+
{
|
|
42
|
+
resource: toOtlpResource(resource),
|
|
43
|
+
scopeSpans: [{ scope: { name: "kritya" }, spans: [toOtlpSpan(span)] }],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const CUMULATIVE = 2; // AGGREGATION_TEMPORALITY_CUMULATIVE
|
|
49
|
+
function toOtlpMetric(point) {
|
|
50
|
+
const dataPointBase = {
|
|
51
|
+
attributes: toKeyValueList(point.attributes),
|
|
52
|
+
startTimeUnixNano: point.startTimeUnixNano,
|
|
53
|
+
timeUnixNano: point.timeUnixNano,
|
|
54
|
+
};
|
|
55
|
+
if (point.kind === "sum") {
|
|
56
|
+
return {
|
|
57
|
+
name: point.name,
|
|
58
|
+
sum: {
|
|
59
|
+
dataPoints: [{ ...dataPointBase, asDouble: point.sumValue ?? 0 }],
|
|
60
|
+
aggregationTemporality: CUMULATIVE,
|
|
61
|
+
isMonotonic: point.isMonotonic ?? true,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const h = point.histogram;
|
|
66
|
+
return {
|
|
67
|
+
name: point.name,
|
|
68
|
+
histogram: {
|
|
69
|
+
dataPoints: [
|
|
70
|
+
{
|
|
71
|
+
...dataPointBase,
|
|
72
|
+
count: String(h.count),
|
|
73
|
+
sum: h.sum,
|
|
74
|
+
bucketCounts: h.bucketCounts.map(String),
|
|
75
|
+
explicitBounds: h.explicitBounds,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
aggregationTemporality: CUMULATIVE,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Wraps the current cumulative snapshot of all metric points in one ExportMetricsServiceRequest. */
|
|
83
|
+
export function encodeMetricsSnapshot(points, resource) {
|
|
84
|
+
return {
|
|
85
|
+
resourceMetrics: [
|
|
86
|
+
{
|
|
87
|
+
resource: toOtlpResource(resource),
|
|
88
|
+
scopeMetrics: [{ scope: { name: "kritya" }, metrics: points.map(toOtlpMetric) }],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Fire-and-forget POST to an OTLP/HTTP collector endpoint. Best-effort like
|
|
95
|
+
* every other telemetry sink in this codebase — a down collector must never
|
|
96
|
+
* slow down or break a turn.
|
|
97
|
+
*/
|
|
98
|
+
export function postOtlp(endpoint, path, body, headers) {
|
|
99
|
+
try {
|
|
100
|
+
fetch(`${endpoint.replace(/\/+$/, "")}${path}`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "content-type": "application/json", ...(headers ?? {}) },
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
}).catch((err) => debugLog(`postOtlp(${path})`, err));
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
debugLog(`postOtlp(${path})`, err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Same as postOtlp, but awaits the request instead of firing-and-forgetting.
|
|
112
|
+
* Used by shutdown paths that want to give the last export a chance to
|
|
113
|
+
* actually complete before the process exits — callers are expected to race
|
|
114
|
+
* this against a short timeout themselves (a hung network call must never
|
|
115
|
+
* block process shutdown indefinitely).
|
|
116
|
+
*/
|
|
117
|
+
export async function postOtlpAndWait(endpoint, path, body, headers) {
|
|
118
|
+
try {
|
|
119
|
+
await fetch(`${endpoint.replace(/\/+$/, "")}${path}`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "content-type": "application/json", ...(headers ?? {}) },
|
|
122
|
+
body: JSON.stringify(body),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
debugLog(`postOtlpAndWait(${path})`, err);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CONFIG_DIR } from "../config/config.js";
|
|
5
|
+
import { hardenWindowsDir } from "../config/winAcl.js";
|
|
6
|
+
import { debugLog } from "../config/debug.js";
|
|
7
|
+
import { VERSION } from "../version.js";
|
|
8
|
+
import { encodeSpan, postOtlp } from "./otlp.js";
|
|
9
|
+
function nowUnixNano() {
|
|
10
|
+
// ms resolution is all we can portably get; scale to nanoseconds so the
|
|
11
|
+
// field matches the OTel schema consumers expect.
|
|
12
|
+
return (BigInt(Date.now()) * 1000000n).toString();
|
|
13
|
+
}
|
|
14
|
+
const NOOP_SPAN = {
|
|
15
|
+
traceId: "",
|
|
16
|
+
spanId: "",
|
|
17
|
+
setAttribute() {
|
|
18
|
+
return this;
|
|
19
|
+
},
|
|
20
|
+
addEvent() {
|
|
21
|
+
return this;
|
|
22
|
+
},
|
|
23
|
+
setStatus() {
|
|
24
|
+
return this;
|
|
25
|
+
},
|
|
26
|
+
end() { },
|
|
27
|
+
};
|
|
28
|
+
/** A tracer that does nothing — used when telemetry is disabled. */
|
|
29
|
+
export const NOOP_TRACER = {
|
|
30
|
+
startSpan() {
|
|
31
|
+
return NOOP_SPAN;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
class RealSpan {
|
|
35
|
+
sink;
|
|
36
|
+
traceId;
|
|
37
|
+
spanId;
|
|
38
|
+
parentSpanId;
|
|
39
|
+
name;
|
|
40
|
+
start;
|
|
41
|
+
attributes;
|
|
42
|
+
events = [];
|
|
43
|
+
status = { code: "UNSET" };
|
|
44
|
+
ended = false;
|
|
45
|
+
constructor(name, traceId, parentSpanId, attributes, sink) {
|
|
46
|
+
this.sink = sink;
|
|
47
|
+
this.name = name;
|
|
48
|
+
this.traceId = traceId;
|
|
49
|
+
this.spanId = crypto.randomBytes(8).toString("hex");
|
|
50
|
+
this.parentSpanId = parentSpanId;
|
|
51
|
+
this.attributes = { ...attributes };
|
|
52
|
+
this.start = nowUnixNano();
|
|
53
|
+
}
|
|
54
|
+
setAttribute(key, value) {
|
|
55
|
+
this.attributes[key] = value;
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
addEvent(name, attributes) {
|
|
59
|
+
this.events.push({ name, timeUnixNano: nowUnixNano(), attributes });
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
setStatus(code, message) {
|
|
63
|
+
this.status = { code, message };
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
end() {
|
|
67
|
+
if (this.ended)
|
|
68
|
+
return; // ending twice would double-emit
|
|
69
|
+
this.ended = true;
|
|
70
|
+
this.sink({
|
|
71
|
+
traceId: this.traceId,
|
|
72
|
+
spanId: this.spanId,
|
|
73
|
+
parentSpanId: this.parentSpanId,
|
|
74
|
+
name: this.name,
|
|
75
|
+
startTimeUnixNano: this.start,
|
|
76
|
+
endTimeUnixNano: nowUnixNano(),
|
|
77
|
+
attributes: this.attributes,
|
|
78
|
+
status: this.status,
|
|
79
|
+
events: this.events,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
class RealTracer {
|
|
84
|
+
sink;
|
|
85
|
+
constructor(sink) {
|
|
86
|
+
this.sink = sink;
|
|
87
|
+
}
|
|
88
|
+
startSpan(name, opts) {
|
|
89
|
+
const parent = opts?.parent;
|
|
90
|
+
// Children inherit the parent's trace id; roots mint a new one.
|
|
91
|
+
const traceId = parent && parent.traceId ? parent.traceId : crypto.randomBytes(16).toString("hex");
|
|
92
|
+
const parentSpanId = parent && parent.spanId ? parent.spanId : undefined;
|
|
93
|
+
return new RealSpan(name, traceId, parentSpanId, opts?.attributes ?? {}, this.sink);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* KRITYA_TELEMETRY_DIR overrides it — used by tests so cleanupOldTelemetry's
|
|
98
|
+
* directory scan never touches the real ~/.kritya/telemetry on a dev machine.
|
|
99
|
+
*/
|
|
100
|
+
function telemetryDir() {
|
|
101
|
+
return process.env.KRITYA_TELEMETRY_DIR || path.join(CONFIG_DIR, "telemetry");
|
|
102
|
+
}
|
|
103
|
+
/** KRITYA_OTEL if set, else config.json's persisted `otel` default, else "off". */
|
|
104
|
+
function resolveOtelMode(configDefault) {
|
|
105
|
+
return (process.env.KRITYA_OTEL ?? configDefault ?? "off").toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Where this session's spans are written, or undefined when no file sink is
|
|
109
|
+
* active. Exported so callers that report results (headless JSON) can point
|
|
110
|
+
* the reader at the trace without duplicating the path logic.
|
|
111
|
+
*/
|
|
112
|
+
export function telemetryFileFor(sessionId, configDefault) {
|
|
113
|
+
const mode = resolveOtelMode(configDefault);
|
|
114
|
+
if (mode === "off" || mode === "" || mode === "false")
|
|
115
|
+
return undefined;
|
|
116
|
+
// "console" is the only enabled mode with no file behind it.
|
|
117
|
+
if (mode === "console")
|
|
118
|
+
return undefined;
|
|
119
|
+
return process.env.KRITYA_OTEL_FILE ?? path.join(telemetryDir(), `${sessionId}.otel.jsonl`);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Delete telemetry span files older than `retentionDays`. Best-effort, same
|
|
123
|
+
* pattern as SessionStore.cleanupOldSessions and AuditLog.cleanupOld. 0 or
|
|
124
|
+
* negative means "keep forever" — auto-delete disabled.
|
|
125
|
+
*/
|
|
126
|
+
export function cleanupOldTelemetry(retentionDays) {
|
|
127
|
+
if (retentionDays <= 0)
|
|
128
|
+
return;
|
|
129
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
130
|
+
let files;
|
|
131
|
+
try {
|
|
132
|
+
files = fs.readdirSync(telemetryDir()).filter((f) => f.endsWith(".otel.jsonl"));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
const file = path.join(telemetryDir(), f);
|
|
139
|
+
try {
|
|
140
|
+
if (fs.statSync(file).mtimeMs < cutoff)
|
|
141
|
+
fs.unlinkSync(file);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
debugLog(`cleanupOldTelemetry(${file})`, err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function fileSink(file) {
|
|
149
|
+
let ready = false;
|
|
150
|
+
return (span) => {
|
|
151
|
+
try {
|
|
152
|
+
if (!ready) {
|
|
153
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
154
|
+
hardenWindowsDir(telemetryDir());
|
|
155
|
+
ready = true;
|
|
156
|
+
}
|
|
157
|
+
fs.appendFileSync(file, JSON.stringify(span) + "\n", { mode: 0o600 });
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
// best-effort: telemetry must never crash a turn
|
|
161
|
+
debugLog(`tracer.fileSink(${file})`, err);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function consoleSink() {
|
|
166
|
+
return (span) => {
|
|
167
|
+
try {
|
|
168
|
+
process.stderr.write(`[otel] ${JSON.stringify(span)}\n`);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// best-effort
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function resourceFor(sessionId) {
|
|
176
|
+
return {
|
|
177
|
+
attributes: {
|
|
178
|
+
"service.name": "kritya",
|
|
179
|
+
"service.version": VERSION,
|
|
180
|
+
"os.type": process.platform,
|
|
181
|
+
// Same resource-identity fields as metrics.ts's createMeter, so traces
|
|
182
|
+
// and metrics from the same session agree on resource identity.
|
|
183
|
+
"service.instance.id": sessionId,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function parseOtlpHeaders(raw) {
|
|
188
|
+
if (!raw)
|
|
189
|
+
return undefined;
|
|
190
|
+
const headers = {};
|
|
191
|
+
for (const pair of raw.split(",")) {
|
|
192
|
+
const eq = pair.indexOf("=");
|
|
193
|
+
if (eq === -1)
|
|
194
|
+
continue;
|
|
195
|
+
headers[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
196
|
+
}
|
|
197
|
+
return headers;
|
|
198
|
+
}
|
|
199
|
+
function otlpSink(endpoint, sessionId) {
|
|
200
|
+
const headers = parseOtlpHeaders(process.env.KRITYA_OTEL_HEADERS);
|
|
201
|
+
const resource = resourceFor(sessionId);
|
|
202
|
+
return (span) => {
|
|
203
|
+
try {
|
|
204
|
+
postOtlp(endpoint, "/v1/traces", encodeSpan(span, resource), headers);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
// best-effort: telemetry must never crash a turn
|
|
208
|
+
debugLog(`tracer.otlpSink(${endpoint})`, err);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Build the tracer configured by the environment for this session. Returns the
|
|
214
|
+
* shared no-op tracer when telemetry is off, so callers can always hold a
|
|
215
|
+
* Tracer and call startSpan unconditionally.
|
|
216
|
+
*/
|
|
217
|
+
export function createTracer(sessionId, configDefault) {
|
|
218
|
+
const mode = resolveOtelMode(configDefault);
|
|
219
|
+
const endpoint = process.env.KRITYA_OTEL_ENDPOINT;
|
|
220
|
+
const modeOff = mode === "off" || mode === "" || mode === "false";
|
|
221
|
+
if (modeOff && !endpoint)
|
|
222
|
+
return NOOP_TRACER;
|
|
223
|
+
const sinks = [];
|
|
224
|
+
if (mode === "file" || mode === "both") {
|
|
225
|
+
sinks.push(fileSink(telemetryFileFor(sessionId, configDefault)));
|
|
226
|
+
}
|
|
227
|
+
if (mode === "console" || mode === "both") {
|
|
228
|
+
sinks.push(consoleSink());
|
|
229
|
+
}
|
|
230
|
+
if (endpoint) {
|
|
231
|
+
sinks.push(otlpSink(endpoint, sessionId));
|
|
232
|
+
}
|
|
233
|
+
if (!sinks.length) {
|
|
234
|
+
// Unrecognized value (e.g. "1", "on"): default to a file, which is the
|
|
235
|
+
// useful local-only behavior, rather than silently doing nothing.
|
|
236
|
+
sinks.push(fileSink(path.join(telemetryDir(), `${sessionId}.otel.jsonl`)));
|
|
237
|
+
}
|
|
238
|
+
const sink = sinks.length === 1 ? sinks[0] : (span) => sinks.forEach((s) => s(span));
|
|
239
|
+
return new RealTracer(sink);
|
|
240
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const MAX_OPTIONS = 6;
|
|
2
|
+
const OTHER = "Other (type my own answer)";
|
|
3
|
+
/**
|
|
4
|
+
* A clarifying question with pick-from-a-list options instead of open prose,
|
|
5
|
+
* so the user can answer with a keypress. There is always a way to answer
|
|
6
|
+
* outside the given options: picking "Other" opens a free-text field, so a
|
|
7
|
+
* narrow option list never traps the user into a wrong answer.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately not available to subagents — spawnAgents' isolated contexts
|
|
10
|
+
* don't carry a UI to ask through, and a subagent blocking on user input
|
|
11
|
+
* would stall the parallel batch it's part of.
|
|
12
|
+
*/
|
|
13
|
+
export const askUserTool = {
|
|
14
|
+
name: "ask_user",
|
|
15
|
+
description: "Ask the user one clarifying question with a short list of options to choose from, instead " +
|
|
16
|
+
"of open-ended prose. Use this for make-or-break questions where you'd otherwise have to " +
|
|
17
|
+
"guess — not for anything you can reasonably default and state your choice for. The user " +
|
|
18
|
+
'can always type a custom answer via the automatic "Other" option, so keep the given list ' +
|
|
19
|
+
`short (2-${MAX_OPTIONS - 1} options) rather than trying to enumerate every possibility.`,
|
|
20
|
+
parameters: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
question: { type: "string", description: "The question to ask, in one or two sentences" },
|
|
24
|
+
options: {
|
|
25
|
+
type: "array",
|
|
26
|
+
description: `2 to ${MAX_OPTIONS - 1} short choices, each a few words`,
|
|
27
|
+
items: { type: "string" },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ["question", "options"],
|
|
31
|
+
},
|
|
32
|
+
requiresPermission: false,
|
|
33
|
+
summarize: (args) => `Ask: ${String(args.question ?? "").slice(0, 80)}`,
|
|
34
|
+
async execute(args, ctx) {
|
|
35
|
+
const question = String(args.question ?? "").trim();
|
|
36
|
+
if (!question)
|
|
37
|
+
throw new Error("question must not be empty");
|
|
38
|
+
const options = Array.isArray(args.options)
|
|
39
|
+
? args.options.map((o) => String(o).trim()).filter(Boolean)
|
|
40
|
+
: [];
|
|
41
|
+
if (options.length < 2)
|
|
42
|
+
throw new Error("options must have at least 2 choices");
|
|
43
|
+
if (options.length > MAX_OPTIONS - 1) {
|
|
44
|
+
throw new Error(`options must have at most ${MAX_OPTIONS - 1} choices — keep it short`);
|
|
45
|
+
}
|
|
46
|
+
if (!ctx.requestElicitation) {
|
|
47
|
+
return "ask_user is not available in this session — proceed with your best judgment instead.";
|
|
48
|
+
}
|
|
49
|
+
const picked = await ctx.requestElicitation(question, [
|
|
50
|
+
{ name: "choice", kind: "enum", label: question, options: [...options, OTHER] },
|
|
51
|
+
]);
|
|
52
|
+
if (picked.action !== "accept")
|
|
53
|
+
return "The user declined to answer — proceed with your best judgment.";
|
|
54
|
+
const choice = picked.content.choice;
|
|
55
|
+
if (choice !== OTHER)
|
|
56
|
+
return `The user chose: ${choice}`;
|
|
57
|
+
const typed = await ctx.requestElicitation(question, [
|
|
58
|
+
{ name: "answer", kind: "string", label: "Your answer" },
|
|
59
|
+
]);
|
|
60
|
+
if (typed.action !== "accept")
|
|
61
|
+
return "The user declined to answer — proceed with your best judgment.";
|
|
62
|
+
return `The user answered: ${typed.content.answer}`;
|
|
63
|
+
},
|
|
64
|
+
};
|
package/dist/tools/bg.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { backgroundManager } from "../shell/background.js";
|
|
2
|
+
import { truncateTail } from "./common.js";
|
|
3
|
+
import { redactSecrets } from "./secretScan.js";
|
|
4
|
+
export const bgOutputTool = {
|
|
5
|
+
name: "bg_output",
|
|
6
|
+
description: "Read the recent output of a background process started with shell background:true. " +
|
|
7
|
+
"Reports whether it is still running and its exit code if finished. " +
|
|
8
|
+
"Call without an id to list all background processes.",
|
|
9
|
+
parameters: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
id: { type: "string", description: "Background process id, e.g. bg_1 (omit to list all)" },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
requiresPermission: false,
|
|
16
|
+
summarize: (args) => (args.id ? `Read output of ${args.id}` : "List background processes"),
|
|
17
|
+
execute(args) {
|
|
18
|
+
if (!args.id) {
|
|
19
|
+
const list = backgroundManager.list();
|
|
20
|
+
if (!list.length)
|
|
21
|
+
return Promise.resolve("No background processes.");
|
|
22
|
+
return Promise.resolve(list
|
|
23
|
+
.map((p) => `${p.id} [${p.running ? "running" : "exited"}]: ${redactSecrets(p.command).redacted}`)
|
|
24
|
+
.join("\n"));
|
|
25
|
+
}
|
|
26
|
+
const info = backgroundManager.read(String(args.id));
|
|
27
|
+
if (!info)
|
|
28
|
+
return Promise.resolve(`Error: no background process "${args.id}"`);
|
|
29
|
+
const status = info.running ? "still running" : `exited with code ${info.exitCode}`;
|
|
30
|
+
// The header is redacted (a secret can appear in the command itself) but
|
|
31
|
+
// kept OUT of truncateTail, which keeps the tail: folding it in meant a
|
|
32
|
+
// chatty process could truncate away which process this is and whether
|
|
33
|
+
// it's still running.
|
|
34
|
+
const head = redactSecrets(`Process ${args.id} (${info.command}) — ${status}`);
|
|
35
|
+
const body = redactSecrets(info.output || "(no output yet)");
|
|
36
|
+
const matches = [...head.matches, ...body.matches];
|
|
37
|
+
const note = matches.length > 0
|
|
38
|
+
? `[${matches.length} secret(s) redacted from output: ${matches.map((m) => m.kind).join(", ")}]\n`
|
|
39
|
+
: "";
|
|
40
|
+
return Promise.resolve(`${note}${head.redacted}\n\n${truncateTail(body.redacted, 10_000)}`);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
export const bgKillTool = {
|
|
44
|
+
name: "bg_kill",
|
|
45
|
+
description: "Stop a background process started with shell background:true.",
|
|
46
|
+
parameters: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
id: { type: "string", description: "Background process id, e.g. bg_1" },
|
|
50
|
+
},
|
|
51
|
+
required: ["id"],
|
|
52
|
+
},
|
|
53
|
+
requiresPermission: false,
|
|
54
|
+
summarize: (args) => `Kill background process ${args.id}`,
|
|
55
|
+
execute(args) {
|
|
56
|
+
const ok = backgroundManager.kill(String(args.id));
|
|
57
|
+
return Promise.resolve(ok ? `Sent SIGTERM to ${args.id}.` : `Error: "${args.id}" is not a running background process`);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** Resolve symlinks in `p`, walking up to the nearest existing ancestor if it doesn't exist yet. */
|
|
4
|
+
function realpathAllowMissing(p) {
|
|
5
|
+
try {
|
|
6
|
+
return fs.realpathSync(p);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
const parent = path.dirname(p);
|
|
10
|
+
if (parent === p)
|
|
11
|
+
return p;
|
|
12
|
+
return path.join(realpathAllowMissing(parent), path.basename(p));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Filename/path patterns that are treated as sensitive and blocked from being
|
|
17
|
+
* read or written by tools, regardless of allowlist rules. This is a
|
|
18
|
+
* defense-in-depth measure against prompt injection tricking the agent into
|
|
19
|
+
* exfiltrating secrets — it is not a substitute for keeping real secrets out
|
|
20
|
+
* of the workspace.
|
|
21
|
+
*/
|
|
22
|
+
const SENSITIVE_PATH_PATTERNS = [
|
|
23
|
+
/(^|[\\/])\.env(\..*)?$/i,
|
|
24
|
+
// The whole .git/ directory, not just .git/config: .git/hooks/* runs
|
|
25
|
+
// automatically on the next commit/checkout/push, so a write anywhere in
|
|
26
|
+
// there is arbitrary code execution disguised as a file edit.
|
|
27
|
+
/(^|[\\/])\.git[\\/]/i,
|
|
28
|
+
/(^|[\\/])\.gitconfig$/i,
|
|
29
|
+
/(^|[\\/])[^\\/]*credentials[^\\/]*$/i,
|
|
30
|
+
/(^|[\\/])[^\\/]*secret[^\\/]*$/i,
|
|
31
|
+
/(^|[\\/])id_rsa(\.[^\\/]*)?$/i,
|
|
32
|
+
/(^|[\\/])id_ed25519(\.[^\\/]*)?$/i,
|
|
33
|
+
/(^|[\\/])id_ecdsa(\.[^\\/]*)?$/i,
|
|
34
|
+
/(^|[\\/])id_dsa(\.[^\\/]*)?$/i,
|
|
35
|
+
/\.pem$/i,
|
|
36
|
+
/\.key$/i,
|
|
37
|
+
/\.ppk$/i,
|
|
38
|
+
/\.jks$/i,
|
|
39
|
+
/(^|[\\/])\.npmrc$/i,
|
|
40
|
+
/(^|[\\/])\.netrc$/i,
|
|
41
|
+
/(^|[\\/])\.pypirc$/i,
|
|
42
|
+
/(^|[\\/])\.git-credentials$/i,
|
|
43
|
+
/(^|[\\/])\.kube[\\/]config$/i,
|
|
44
|
+
/(^|[\\/])\.docker[\\/]config\.json$/i,
|
|
45
|
+
/\.p12$/i,
|
|
46
|
+
/\.pfx$/i,
|
|
47
|
+
];
|
|
48
|
+
function isSensitivePath(relPath) {
|
|
49
|
+
return SENSITIVE_PATH_PATTERNS.some((re) => re.test(relPath));
|
|
50
|
+
}
|
|
51
|
+
/** Splits a shell command into path-like tokens, stripping quotes/metacharacters. */
|
|
52
|
+
const COMMAND_TOKEN_SPLIT_RE = /[\s|;&<>()"'`$]+/;
|
|
53
|
+
/**
|
|
54
|
+
* Best-effort check for whether a shell command references a sensitive file
|
|
55
|
+
* (.env, credentials, private keys, etc.) by name — e.g. `cat .env` or
|
|
56
|
+
* `grep foo .env`. Mirrors {@link isSensitivePath}, which already gates
|
|
57
|
+
* read_file/write_file, so the shell tool gets the same filename-based
|
|
58
|
+
* defense instead of relying solely on post-hoc output redaction.
|
|
59
|
+
*
|
|
60
|
+
* This is necessarily heuristic (arbitrary shell quoting/expansion can't be
|
|
61
|
+
* fully parsed without a real shell), so it only catches the literal
|
|
62
|
+
* filename appearing in the command text.
|
|
63
|
+
*/
|
|
64
|
+
export function commandTouchesSensitivePath(command) {
|
|
65
|
+
const tokens = command.split(COMMAND_TOKEN_SPLIT_RE).filter(Boolean);
|
|
66
|
+
for (const token of tokens) {
|
|
67
|
+
if (isSensitivePath(token))
|
|
68
|
+
return token;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a user/model-supplied path against the workspace root and refuse
|
|
74
|
+
* anything that escapes it, including via a symlink inside the workspace
|
|
75
|
+
* that points outside of it. Also refuses paths that look like secrets
|
|
76
|
+
* (.env, credentials, private keys, etc.) — see SENSITIVE_PATH_PATTERNS.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveSafe(workspace, p) {
|
|
79
|
+
const abs = path.resolve(workspace, p);
|
|
80
|
+
const rel = path.relative(workspace, abs);
|
|
81
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
82
|
+
throw new Error(`Path "${p}" is outside the workspace (${workspace})`);
|
|
83
|
+
}
|
|
84
|
+
const realWorkspace = realpathAllowMissing(workspace);
|
|
85
|
+
const realAbs = realpathAllowMissing(abs);
|
|
86
|
+
const realRel = path.relative(realWorkspace, realAbs);
|
|
87
|
+
if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
|
|
88
|
+
throw new Error(`Path "${p}" is outside the workspace (${workspace})`);
|
|
89
|
+
}
|
|
90
|
+
if (isSensitivePath(realRel) || isSensitivePath(rel)) {
|
|
91
|
+
throw new Error(`Path "${p}" looks like a secret file and is blocked from tool access`);
|
|
92
|
+
}
|
|
93
|
+
return abs;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Like {@link resolveSafe} but never throws — returns false for any path
|
|
97
|
+
* that escapes the workspace (directly or via symlink) or looks like a
|
|
98
|
+
* secret file. Used by tools (grep) that scan many candidate paths and
|
|
99
|
+
* should silently skip unsafe ones rather than aborting the whole search.
|
|
100
|
+
*/
|
|
101
|
+
export function isPathSafe(workspace, p) {
|
|
102
|
+
try {
|
|
103
|
+
resolveSafe(workspace, p);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const MAX_RESULT_CHARS = 30_000;
|
|
111
|
+
export function truncateResult(s, max = MAX_RESULT_CHARS) {
|
|
112
|
+
if (s.length <= max)
|
|
113
|
+
return s;
|
|
114
|
+
return s.slice(0, max) + `\n... [truncated, ${s.length - max} more characters]`;
|
|
115
|
+
}
|
|
116
|
+
/** Truncate keeping the END — for command output, where errors and summaries come last. */
|
|
117
|
+
export function truncateTail(s, max = MAX_RESULT_CHARS) {
|
|
118
|
+
if (s.length <= max)
|
|
119
|
+
return s;
|
|
120
|
+
return `[... truncated, ${s.length - max} earlier characters]\n` + s.slice(-max);
|
|
121
|
+
}
|
|
122
|
+
const MAX_REGEX_PATTERN_LENGTH = 500;
|
|
123
|
+
/** Nested-quantifier shapes like (a+)+, (a*)*, (a+)*, (.*)+ — classic catastrophic backtracking. */
|
|
124
|
+
const CATASTROPHIC_BACKTRACKING_RE = /\([^()]*[+*][^()]*\)[+*]/;
|
|
125
|
+
/**
|
|
126
|
+
* Compile a regex supplied by the model or a settings file, rejecting
|
|
127
|
+
* patterns that are excessively long or match a known catastrophic-
|
|
128
|
+
* backtracking shape. This is a heuristic backstop, not a guarantee — Node
|
|
129
|
+
* has no built-in regex execution timeout — but it catches the common cases
|
|
130
|
+
* (e.g. a model emitting `(a+)+$` as a grep pattern) before they can hang the
|
|
131
|
+
* process on adversarial input.
|
|
132
|
+
*/
|
|
133
|
+
export function safeCompileRegex(pattern, flags) {
|
|
134
|
+
if (pattern.length > MAX_REGEX_PATTERN_LENGTH) {
|
|
135
|
+
throw new Error(`Pattern too long (max ${MAX_REGEX_PATTERN_LENGTH} characters)`);
|
|
136
|
+
}
|
|
137
|
+
if (CATASTROPHIC_BACKTRACKING_RE.test(pattern)) {
|
|
138
|
+
throw new Error("Pattern rejected: nested quantifiers can cause catastrophic backtracking");
|
|
139
|
+
}
|
|
140
|
+
return new RegExp(pattern, flags);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Lines of a tool's output that carry information — no leading banner, no
|
|
144
|
+
* "(empty)" placeholder, no trailing truncation note. What's left is what a
|
|
145
|
+
* result summary should count.
|
|
146
|
+
*/
|
|
147
|
+
export function meaningfulLines(output) {
|
|
148
|
+
const lines = output
|
|
149
|
+
.split("\n")
|
|
150
|
+
.filter((l) => l.trim() && !/^\((no |empty)/i.test(l.trim()) && !/^\[?\.\.\. /.test(l.trim()));
|
|
151
|
+
return lines.length ? lines : null;
|
|
152
|
+
}
|
|
153
|
+
/** "10 entries", "1 line" — the shape of a result, for the tool-call line. */
|
|
154
|
+
export function countLines(output, one, many) {
|
|
155
|
+
const lines = meaningfulLines(output);
|
|
156
|
+
const n = lines?.length ?? 0;
|
|
157
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
158
|
+
}
|