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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. 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
+ };
@@ -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
+ }