@retinue/agentkit 0.1.0 → 0.3.0
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 +59 -277
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/embeddings/openai.d.ts +45 -0
- package/dist/adapters/embeddings/openai.js +109 -0
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +99 -1
- package/dist/agents/agent.js +215 -16
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +48 -0
- package/dist/agents/engine.js +370 -13
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/capabilities/index.d.ts +5 -1
- package/dist/capabilities/index.js +23 -0
- package/dist/capabilities/runtime.d.ts +8 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/budget.d.ts +55 -0
- package/dist/core/budget.js +56 -0
- package/dist/core/content-parts.d.ts +26 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +110 -2
- package/dist/core/events.js +33 -2
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/validation.js +19 -0
- package/dist/documents/index.d.ts +14 -0
- package/dist/documents/parsers/text.d.ts +16 -0
- package/dist/documents/parsers/text.js +54 -2
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/guardrails.d.ts +14 -0
- package/dist/entries/guardrails.js +14 -0
- package/dist/entries/knowledge.d.ts +17 -0
- package/dist/entries/knowledge.js +16 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +7 -1
- package/dist/graphql/resolvers.js +20 -0
- package/dist/graphql/schema.d.ts +1 -1
- package/dist/graphql/schema.js +44 -0
- package/dist/guardrails/index.d.ts +115 -0
- package/dist/guardrails/index.js +108 -0
- package/dist/guardrails/moderation.d.ts +53 -0
- package/dist/guardrails/moderation.js +75 -0
- package/dist/guardrails/pii.d.ts +75 -0
- package/dist/guardrails/pii.js +193 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +25 -0
- package/dist/knowledge/index.js +37 -1
- package/dist/knowledge/navigate.d.ts +89 -0
- package/dist/knowledge/navigate.js +107 -0
- package/dist/knowledge/retrieval.d.ts +98 -5
- package/dist/knowledge/retrieval.js +158 -28
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +111 -1
- package/dist/models/streaming.js +184 -3
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +55 -0
- package/dist/security/findings.js +18 -9
- package/dist/server/bin.d.ts +19 -0
- package/dist/server/bin.js +226 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +7 -4
- package/dist/server/doctor.d.ts +74 -0
- package/dist/server/doctor.js +280 -0
- package/dist/skills/catalogue.d.ts +49 -0
- package/dist/skills/catalogue.js +61 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +2 -0
- package/dist/skills/index.js +2 -0
- package/dist/telemetry/spans.js +17 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/files.d.ts +125 -0
- package/dist/toolkit/files.js +320 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +31 -1
- package/dist/toolkit/index.js +26 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/sandbox.d.ts +119 -0
- package/dist/toolkit/sandbox.js +239 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/toolkit/web.d.ts +13 -0
- package/dist/toolkit/web.js +7 -1
- package/dist/tools/budget.d.ts +28 -0
- package/dist/tools/budget.js +35 -0
- package/dist/tools/credentials.d.ts +316 -0
- package/dist/tools/credentials.js +369 -0
- package/dist/tools/define.d.ts +45 -0
- package/dist/tools/define.js +26 -0
- package/dist/tools/find.d.ts +109 -0
- package/dist/tools/find.js +210 -0
- package/dist/tools/index.d.ts +35 -2
- package/dist/tools/index.js +11 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/fs.d.ts +24 -0
- package/dist/tools/library/fs.js +102 -0
- package/dist/tools/library/index.d.ts +37 -2
- package/dist/tools/library/index.js +47 -0
- package/dist/tools/library/shell.d.ts +45 -0
- package/dist/tools/library/shell.js +70 -0
- package/dist/tools/meta-tools.js +8 -0
- package/dist/tools/registry.d.ts +146 -0
- package/dist/tools/registry.js +250 -4
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +33 -2
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 5322 messages, built once for every toolkit that sends mail — REQ-056 (#240), task #241.
|
|
3
|
+
*
|
|
4
|
+
* This started in `tools-google` for Gmail (#234) and moved here when `tools-email` needed the same thing. The
|
|
5
|
+
* reason is the one that moved `ssrf.ts`: the parts that are silent when wrong must have exactly one
|
|
6
|
+
* implementation, because a second copy is the one that ends up not encoding a header.
|
|
7
|
+
*
|
|
8
|
+
* Three things here are wrong *silently*, which is why they are here rather than per package:
|
|
9
|
+
*
|
|
10
|
+
* **A non-ASCII subject.** Headers are ASCII by the spec. Putting an umlaut in one raw does not throw — it
|
|
11
|
+
* arrives as mojibake and nobody tells you.
|
|
12
|
+
*
|
|
13
|
+
* **A header carrying a line break.** A subject of `Update` + CRLF + `Bcc: attacker@example.com` is two
|
|
14
|
+
* headers, and the second silently copies the message to somebody. Reachable from untrusted content: an agent
|
|
15
|
+
* composing a subject from a page it scraped or a mail it read is exactly the path.
|
|
16
|
+
*
|
|
17
|
+
* **Part ordering in `multipart/alternative`.** The spec orders parts least-faithful first, so `text/plain`
|
|
18
|
+
* precedes `text/html`. Reversed, a client showing the *last* part it understands displays the plain-text
|
|
19
|
+
* fallback and the HTML is never seen — mail that looks broken to the recipient and fine to the sender.
|
|
20
|
+
*
|
|
21
|
+
* ## Determinism, and why there is no `Date` or `Message-ID`
|
|
22
|
+
*
|
|
23
|
+
* `email_compose_preview` has to produce **byte-identical** output to what a send transmits, or the rehearsal
|
|
24
|
+
* is of a different message. A `Date` stamped at compose time makes that impossible by construction, and a
|
|
25
|
+
* `Message-ID` generated per call makes it impossible too. Both are added by the sending MTA or the provider's
|
|
26
|
+
* API, which is where they belong — they describe the act of sending, not the message the caller wrote. The
|
|
27
|
+
* multipart boundary is derived from a hash of the content for the same reason: a random boundary would make
|
|
28
|
+
* two composes of the same message differ.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
/** A header value, RFC 2047 encoded when it needs to be — and left alone when it does not. */
|
|
32
|
+
export const encodeHeader = (value) => {
|
|
33
|
+
// Only when needed: an ASCII subject must stay readable in the raw message, because half of debugging mail
|
|
34
|
+
// is reading it with your eyes. An encoded-word on everything would be correct and unreadable.
|
|
35
|
+
if (!/[^ -~]/.test(value))
|
|
36
|
+
return value;
|
|
37
|
+
// 75 is the RFC limit for an encoded-word *including* its wrapper, so the payload budget is smaller; base64
|
|
38
|
+
// expands by 4/3, hence a multiple of 3.
|
|
39
|
+
const budget = 45;
|
|
40
|
+
const bytes = Buffer.from(value, "utf8");
|
|
41
|
+
const words = [];
|
|
42
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
43
|
+
// Never split a multi-byte character across two encoded-words: each word must decode on its own.
|
|
44
|
+
let take = Math.min(budget, bytes.length - offset);
|
|
45
|
+
while (take > 0 && offset + take < bytes.length && (bytes[offset + take] & 0xc0) === 0x80)
|
|
46
|
+
take -= 1;
|
|
47
|
+
if (take === 0)
|
|
48
|
+
take = Math.min(budget, bytes.length - offset);
|
|
49
|
+
words.push(`=?UTF-8?B?${bytes.subarray(offset, offset + take).toString("base64")}?=`);
|
|
50
|
+
offset += take;
|
|
51
|
+
}
|
|
52
|
+
// Folded with a space between words, which is how a decoder is told they are one value.
|
|
53
|
+
return words.join(" ");
|
|
54
|
+
};
|
|
55
|
+
export class HeaderInjectionError extends Error {
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Refuses a header value that would inject another header.
|
|
59
|
+
*
|
|
60
|
+
* Refusing rather than stripping: no legitimate subject or address contains a newline, so nothing correct is
|
|
61
|
+
* lost, and silently removing it would hide an attempt somebody should see.
|
|
62
|
+
*/
|
|
63
|
+
export const assertHeaderSafe = (field, value) => {
|
|
64
|
+
if (/[\r\n]/.test(value)) {
|
|
65
|
+
throw new HeaderInjectionError(`The ${field} contains a line break, which would inject a new mail header. Refused rather than sent — ` +
|
|
66
|
+
"this is how a Bcc gets added to a message nobody meant to copy.");
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const CRLF = "\r\n";
|
|
70
|
+
/** Base64, folded at 76 characters — an unfolded base64 body is non-conformant and some servers reject it. */
|
|
71
|
+
const foldedBase64 = (value) => {
|
|
72
|
+
const encoded = Buffer.from(value, "utf8").toString("base64");
|
|
73
|
+
return encoded.match(/.{1,76}/g)?.join(CRLF) ?? "";
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* A boundary that depends only on the message.
|
|
77
|
+
*
|
|
78
|
+
* Deterministic so `email_compose_preview` and `email_send` produce the same bytes, and content-derived so two
|
|
79
|
+
* different messages do not share one. The loop rules out the remaining case where the chosen boundary appears
|
|
80
|
+
* inside a part — astronomically unlikely with a hash, and cheaper to exclude than to reason about.
|
|
81
|
+
*/
|
|
82
|
+
export const boundaryFor = (parts) => {
|
|
83
|
+
const digest = createHash("sha256").update(parts.join(" "), "utf8").digest("hex");
|
|
84
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
85
|
+
const candidate = `=_retinue_${digest.slice(attempt * 4, attempt * 4 + 32)}`;
|
|
86
|
+
if (!parts.some((part) => part.includes(candidate)))
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
|
89
|
+
return `=_retinue_${digest}`;
|
|
90
|
+
};
|
|
91
|
+
const renderPart = (headers, content) => `${headers.join(CRLF)}${CRLF}${CRLF}${content}`;
|
|
92
|
+
const textPart = (text) => renderPart(['Content-Type: text/plain; charset="UTF-8"', "Content-Transfer-Encoding: base64"], foldedBase64(text));
|
|
93
|
+
const htmlPart = (html) => renderPart(['Content-Type: text/html; charset="UTF-8"', "Content-Transfer-Encoding: base64"], foldedBase64(html));
|
|
94
|
+
const attachmentPart = (attachment) => {
|
|
95
|
+
assertHeaderSafe("attachment filename", attachment.filename);
|
|
96
|
+
assertHeaderSafe("attachment content type", attachment.contentType);
|
|
97
|
+
return renderPart([
|
|
98
|
+
`Content-Type: ${attachment.contentType}; name="${encodeHeader(attachment.filename)}"`,
|
|
99
|
+
"Content-Transfer-Encoding: base64",
|
|
100
|
+
`Content-Disposition: attachment; filename="${encodeHeader(attachment.filename)}"`,
|
|
101
|
+
], attachment.contentBase64.replace(/\s+/g, "").match(/.{1,76}/g)?.join(CRLF) ?? "");
|
|
102
|
+
};
|
|
103
|
+
const multipart = (subtype, parts) => {
|
|
104
|
+
const boundary = boundaryFor(parts);
|
|
105
|
+
return {
|
|
106
|
+
contentType: `multipart/${subtype}; boundary="${boundary}"`,
|
|
107
|
+
body: [
|
|
108
|
+
// A preamble, for clients that show nothing when they cannot parse the structure. Never displayed by one
|
|
109
|
+
// that can.
|
|
110
|
+
"This is a message in MIME format.",
|
|
111
|
+
...parts.map((body) => `--${boundary}${CRLF}${body}`),
|
|
112
|
+
`--${boundary}--`,
|
|
113
|
+
"",
|
|
114
|
+
].join(CRLF),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/** Splits a rendered part into its header lines and its content. */
|
|
118
|
+
const splitPart = (rendered) => {
|
|
119
|
+
const separator = rendered.indexOf(`${CRLF}${CRLF}`);
|
|
120
|
+
if (separator === -1)
|
|
121
|
+
return [[], rendered];
|
|
122
|
+
return [rendered.slice(0, separator).split(CRLF), rendered.slice(separator + 4)];
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* An RFC 5322 message.
|
|
126
|
+
*
|
|
127
|
+
* CRLF line endings, not bare newlines: the spec says CRLF and some servers are strict. Getting it wrong
|
|
128
|
+
* produces a message that works with most providers and is rejected by one, which is the worst kind of bug to
|
|
129
|
+
* find in production.
|
|
130
|
+
*/
|
|
131
|
+
export const buildMessage = (message) => {
|
|
132
|
+
const text = message.text ?? message.body;
|
|
133
|
+
const hasText = text !== undefined && text !== "";
|
|
134
|
+
const hasHtml = message.html !== undefined && message.html !== "";
|
|
135
|
+
if (!hasText && !hasHtml) {
|
|
136
|
+
throw new Error("A message needs a text body, an HTML body, or both.");
|
|
137
|
+
}
|
|
138
|
+
// Every header value, before any of them is written.
|
|
139
|
+
for (const [field, value] of [
|
|
140
|
+
["subject", message.subject],
|
|
141
|
+
["from", message.from ?? ""],
|
|
142
|
+
["reply-to", message.replyTo ?? ""],
|
|
143
|
+
...message.to.map((address) => ["recipient", address]),
|
|
144
|
+
...(message.cc ?? []).map((address) => ["cc recipient", address]),
|
|
145
|
+
...(message.bcc ?? []).map((address) => ["bcc recipient", address]),
|
|
146
|
+
...Object.entries(message.headers ?? {}).map(([name, value]) => [`${name} header`, value]),
|
|
147
|
+
]) {
|
|
148
|
+
assertHeaderSafe(field, value);
|
|
149
|
+
}
|
|
150
|
+
const headers = [];
|
|
151
|
+
if (message.from !== undefined)
|
|
152
|
+
headers.push(`From: ${message.from}`);
|
|
153
|
+
headers.push(`To: ${message.to.join(", ")}`);
|
|
154
|
+
if (message.cc !== undefined && message.cc.length > 0)
|
|
155
|
+
headers.push(`Cc: ${message.cc.join(", ")}`);
|
|
156
|
+
/**
|
|
157
|
+
* `Bcc` **is** written into the message this function returns.
|
|
158
|
+
*
|
|
159
|
+
* Correct for an API that takes a composed message and reads the recipients out of it, and wrong for SMTP,
|
|
160
|
+
* where the envelope carries the recipients and a `Bcc` header would show every blind recipient to all of
|
|
161
|
+
* them. The SMTP transport strips it — see `stripBcc` — rather than this function guessing which kind of
|
|
162
|
+
* caller it has. A preview shows it, because somebody inspecting a rehearsal should see who is on it.
|
|
163
|
+
*/
|
|
164
|
+
if (message.bcc !== undefined && message.bcc.length > 0)
|
|
165
|
+
headers.push(`Bcc: ${message.bcc.join(", ")}`);
|
|
166
|
+
if (message.replyTo !== undefined)
|
|
167
|
+
headers.push(`Reply-To: ${message.replyTo}`);
|
|
168
|
+
headers.push(`Subject: ${encodeHeader(message.subject)}`);
|
|
169
|
+
/**
|
|
170
|
+
* Both threading headers, from the original's `Message-ID`.
|
|
171
|
+
*
|
|
172
|
+
* `In-Reply-To` is what most clients thread on and `References` is what the rest use, so sending one without
|
|
173
|
+
* the other threads correctly in some inboxes and starts a new conversation in others.
|
|
174
|
+
*/
|
|
175
|
+
if (message.inReplyTo !== undefined) {
|
|
176
|
+
headers.push(`In-Reply-To: ${message.inReplyTo}`);
|
|
177
|
+
const chain = message.references === undefined || message.references.trim() === ""
|
|
178
|
+
? message.inReplyTo
|
|
179
|
+
: `${message.references.trim()} ${message.inReplyTo}`;
|
|
180
|
+
headers.push(`References: ${chain}`);
|
|
181
|
+
}
|
|
182
|
+
for (const [name, value] of Object.entries(message.headers ?? {}))
|
|
183
|
+
headers.push(`${name}: ${encodeHeader(value)}`);
|
|
184
|
+
headers.push("MIME-Version: 1.0");
|
|
185
|
+
const bodyParts = [];
|
|
186
|
+
if (hasText)
|
|
187
|
+
bodyParts.push(textPart(text));
|
|
188
|
+
// Plain text first. The spec orders parts least-faithful first, and a client showing the last part it
|
|
189
|
+
// understands would otherwise display the fallback and never the HTML.
|
|
190
|
+
if (hasHtml)
|
|
191
|
+
bodyParts.push(htmlPart(message.html));
|
|
192
|
+
const attachments = message.attachments ?? [];
|
|
193
|
+
if (bodyParts.length === 1 && attachments.length === 0) {
|
|
194
|
+
// The single-body case, byte-identical to what this produced before multipart existed.
|
|
195
|
+
const [partHeaders, content] = splitPart(bodyParts[0]);
|
|
196
|
+
return `${[...headers, ...partHeaders].join(CRLF)}${CRLF}${CRLF}${content}`;
|
|
197
|
+
}
|
|
198
|
+
const alternative = bodyParts.length > 1 ? multipart("alternative", bodyParts) : undefined;
|
|
199
|
+
const bodySection = alternative === undefined
|
|
200
|
+
? bodyParts[0]
|
|
201
|
+
: renderPart([`Content-Type: ${alternative.contentType}`], alternative.body);
|
|
202
|
+
if (attachments.length === 0) {
|
|
203
|
+
const only = alternative;
|
|
204
|
+
return `${headers.join(CRLF)}${CRLF}Content-Type: ${only.contentType}${CRLF}${CRLF}${only.body}`;
|
|
205
|
+
}
|
|
206
|
+
const mixed = multipart("mixed", [bodySection, ...attachments.map(attachmentPart)]);
|
|
207
|
+
return `${headers.join(CRLF)}${CRLF}Content-Type: ${mixed.contentType}${CRLF}${CRLF}${mixed.body}`;
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Removes the `Bcc` header from a composed message.
|
|
211
|
+
*
|
|
212
|
+
* For SMTP the envelope carries every recipient and the header must not, or each blind recipient can read the
|
|
213
|
+
* whole blind list — the single most embarrassing mail bug there is. The header is kept in the composed form
|
|
214
|
+
* so a preview can show it, and stripped at the transport that would otherwise leak it.
|
|
215
|
+
*/
|
|
216
|
+
export const stripBcc = (raw) => {
|
|
217
|
+
const separator = raw.indexOf(`${CRLF}${CRLF}`);
|
|
218
|
+
if (separator === -1)
|
|
219
|
+
return raw;
|
|
220
|
+
const kept = raw
|
|
221
|
+
.slice(0, separator)
|
|
222
|
+
.split(CRLF)
|
|
223
|
+
.filter((line) => !/^bcc:/i.test(line));
|
|
224
|
+
return `${kept.join(CRLF)}${raw.slice(separator)}`;
|
|
225
|
+
};
|
|
226
|
+
/** The value of a header in a composed message, for tests and for reading a provider's echo. */
|
|
227
|
+
export const headerOf = (raw, name) => {
|
|
228
|
+
const separator = raw.indexOf(`${CRLF}${CRLF}`);
|
|
229
|
+
const head = separator === -1 ? raw : raw.slice(0, separator);
|
|
230
|
+
const match = new RegExp(`^${name}:\\s*(.*)$`, "im").exec(head);
|
|
231
|
+
return match?.[1]?.trim();
|
|
232
|
+
};
|
|
233
|
+
//# sourceMappingURL=mime.js.map
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running a command somewhere it cannot hurt you — REQ-047 (#206), task #215.
|
|
3
|
+
*
|
|
4
|
+
* `shell_exec` is the only tool in this package whose blast radius is not described by its schema. Every other
|
|
5
|
+
* tool can do one thing to one kind of object; this one can do anything the process can do. And its trigger is
|
|
6
|
+
* natural language — including language the model merely *read*, in a document, in an issue body, in a Slack
|
|
7
|
+
* message. Without isolation, a shell tool is a remote code execution endpoint reachable by anyone who can get
|
|
8
|
+
* text in front of the agent.
|
|
9
|
+
*
|
|
10
|
+
* So the sandbox is not a hardening step applied afterwards. It is the thing that makes the tool defensible, and
|
|
11
|
+
* the tool does not exist without one wired.
|
|
12
|
+
*
|
|
13
|
+
* ## What the contract guarantees
|
|
14
|
+
*
|
|
15
|
+
* | Guarantee | Why it is not optional |
|
|
16
|
+
* |---|---|
|
|
17
|
+
* | No network | A command that can reach the network can exfiltrate anything it can read, and the egress policy does not apply inside a container |
|
|
18
|
+
* | Read-only root, one writable scratch mount | A command that can write to the image can install a persistent foothold |
|
|
19
|
+
* | Memory cap | An unbounded allocation takes the host down with it, and that is a denial of service anybody can trigger by asking |
|
|
20
|
+
* | Wall-clock timeout | `sleep 999` must end as a *timeout*, not as an empty success |
|
|
21
|
+
* | No TTY | An interactive prompt would hang forever waiting for a person who is not there |
|
|
22
|
+
* | Output cap, with the truncation reported | Silent truncation makes a model believe it saw the whole answer |
|
|
23
|
+
* | The exit code in the envelope | Inferring success from output text is guessing; a non-zero exit is a fact |
|
|
24
|
+
*
|
|
25
|
+
* ## Gating is by effect, never by reading the command
|
|
26
|
+
*
|
|
27
|
+
* `shell_exec` is classified `destructive` and routed through the approval gate. It is tempting to inspect the
|
|
28
|
+
* command instead — refuse `rm -rf`, allow `ls` — and that is a losing game: `find . -delete`, `>file`, `dd`,
|
|
29
|
+
* `python -c`, a base64 pipeline. Any list of dangerous shapes is a list somebody gets around, and worse, it
|
|
30
|
+
* *feels* like protection. A classification cannot be evaded by rephrasing.
|
|
31
|
+
*/
|
|
32
|
+
/** Bytes of stdout and of stderr returned. Beyond this the output is truncated and says so. */
|
|
33
|
+
export declare const MAX_OUTPUT_BYTES = 64000;
|
|
34
|
+
/** Wall clock. A model waiting on a hung command is a run holding a worker slot. */
|
|
35
|
+
export declare const DEFAULT_TIMEOUT_MS = 20000;
|
|
36
|
+
export declare const DEFAULT_MEMORY_MB = 256;
|
|
37
|
+
export type SandboxRequest = {
|
|
38
|
+
/** The command, run by a shell inside the sandbox. Never interpreted or inspected here. */
|
|
39
|
+
readonly command: string;
|
|
40
|
+
readonly timeoutMs?: number;
|
|
41
|
+
readonly memoryMb?: number;
|
|
42
|
+
/** Files to place in the scratch mount before running, by relative path. */
|
|
43
|
+
readonly files?: Readonly<Record<string, string>>;
|
|
44
|
+
};
|
|
45
|
+
export type SandboxResult = {
|
|
46
|
+
/** Whether the command *ran to completion*. A non-zero exit is `ok: true` with a non-zero code. */
|
|
47
|
+
readonly ok: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The process's exit code, or null when it never produced one.
|
|
50
|
+
*
|
|
51
|
+
* Null is the honest answer for a killed process, and it is why `reason` exists: "exit code 137" and "we killed
|
|
52
|
+
* it after 20 seconds" are the same event described at two levels, and the model needs the second one.
|
|
53
|
+
*/
|
|
54
|
+
readonly exitCode: number | null;
|
|
55
|
+
readonly stdout: string;
|
|
56
|
+
readonly stderr: string;
|
|
57
|
+
readonly truncated: boolean;
|
|
58
|
+
/** Set when the sandbox ended the command itself. */
|
|
59
|
+
readonly reason?: "timeout" | "memory" | "spawn-failed";
|
|
60
|
+
readonly durationMs: number;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* A place to run a command. A port, because where that place is — a container here, a microVM, E2B, Daytona — is
|
|
64
|
+
* a deployment's decision and not this package's.
|
|
65
|
+
*/
|
|
66
|
+
export interface Sandbox {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
run(request: SandboxRequest): Promise<SandboxResult>;
|
|
69
|
+
}
|
|
70
|
+
export type DockerSandboxConfig = {
|
|
71
|
+
/** The image. A deployment's choice, and it should be one with a shell and nothing else. */
|
|
72
|
+
readonly image: string;
|
|
73
|
+
readonly docker?: string;
|
|
74
|
+
readonly timeoutMs?: number;
|
|
75
|
+
readonly memoryMb?: number;
|
|
76
|
+
/** Extra flags, appended after the enforced ones so they cannot remove them. */
|
|
77
|
+
readonly extraArgs?: readonly string[];
|
|
78
|
+
readonly shell?: string;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The argv, built separately so it can be asserted on.
|
|
82
|
+
*
|
|
83
|
+
* Every security property of this adapter *is* this array. A test that ran a command and checked its output would
|
|
84
|
+
* pass just as well with `--network=none` missing, so the argv is what the tests read — and the flags come before
|
|
85
|
+
* `extraArgs`, so a deployment adding options cannot quietly drop one.
|
|
86
|
+
*/
|
|
87
|
+
export declare const dockerArgs: (config: DockerSandboxConfig, request: SandboxRequest) => readonly string[];
|
|
88
|
+
/**
|
|
89
|
+
* The real adapter: one container per command, destroyed after.
|
|
90
|
+
*
|
|
91
|
+
* Not a pool. A reused container is a container a previous command could have left something in, and the whole
|
|
92
|
+
* proposition here is that a command cannot affect anything outside itself.
|
|
93
|
+
*/
|
|
94
|
+
export declare const createDockerSandbox: (config: DockerSandboxConfig) => Sandbox;
|
|
95
|
+
export type LocalSandboxConfig = {
|
|
96
|
+
/**
|
|
97
|
+
* Required, and named to be uncomfortable to type.
|
|
98
|
+
*
|
|
99
|
+
* There is no safe default here. A shell on the runtime's own host has no isolation at all: the command runs as
|
|
100
|
+
* the runtime user, with its filesystem, its network and its credentials. That is a remote code execution
|
|
101
|
+
* endpoint reachable through content the model merely read.
|
|
102
|
+
*/
|
|
103
|
+
readonly allowUnsafeLocalExecution: true;
|
|
104
|
+
readonly timeoutMs?: number;
|
|
105
|
+
readonly shell?: string;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The development adapter, which **refuses to exist** unless a deployment says so in words.
|
|
109
|
+
*
|
|
110
|
+
* The refusal is at construction rather than at the call, so a misconfiguration is a boot failure rather than a
|
|
111
|
+
* surprise the first time somebody asks the agent to run something. And the message says what to do instead,
|
|
112
|
+
* because "not allowed" without a next step is how a flag gets set to make an error go away.
|
|
113
|
+
*
|
|
114
|
+
* It provides the timeout and the output cap. It provides **none** of the isolation: no network isolation, no
|
|
115
|
+
* memory cap, no read-only filesystem, no dropped capabilities. The contract's table describes what a sandbox
|
|
116
|
+
* guarantees; this adapter meets one row of it, and saying so is the point.
|
|
117
|
+
*/
|
|
118
|
+
export declare const createLocalSandbox: (config: LocalSandboxConfig) => Sandbox;
|
|
119
|
+
//# sourceMappingURL=sandbox.d.ts.map
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running a command somewhere it cannot hurt you — REQ-047 (#206), task #215.
|
|
3
|
+
*
|
|
4
|
+
* `shell_exec` is the only tool in this package whose blast radius is not described by its schema. Every other
|
|
5
|
+
* tool can do one thing to one kind of object; this one can do anything the process can do. And its trigger is
|
|
6
|
+
* natural language — including language the model merely *read*, in a document, in an issue body, in a Slack
|
|
7
|
+
* message. Without isolation, a shell tool is a remote code execution endpoint reachable by anyone who can get
|
|
8
|
+
* text in front of the agent.
|
|
9
|
+
*
|
|
10
|
+
* So the sandbox is not a hardening step applied afterwards. It is the thing that makes the tool defensible, and
|
|
11
|
+
* the tool does not exist without one wired.
|
|
12
|
+
*
|
|
13
|
+
* ## What the contract guarantees
|
|
14
|
+
*
|
|
15
|
+
* | Guarantee | Why it is not optional |
|
|
16
|
+
* |---|---|
|
|
17
|
+
* | No network | A command that can reach the network can exfiltrate anything it can read, and the egress policy does not apply inside a container |
|
|
18
|
+
* | Read-only root, one writable scratch mount | A command that can write to the image can install a persistent foothold |
|
|
19
|
+
* | Memory cap | An unbounded allocation takes the host down with it, and that is a denial of service anybody can trigger by asking |
|
|
20
|
+
* | Wall-clock timeout | `sleep 999` must end as a *timeout*, not as an empty success |
|
|
21
|
+
* | No TTY | An interactive prompt would hang forever waiting for a person who is not there |
|
|
22
|
+
* | Output cap, with the truncation reported | Silent truncation makes a model believe it saw the whole answer |
|
|
23
|
+
* | The exit code in the envelope | Inferring success from output text is guessing; a non-zero exit is a fact |
|
|
24
|
+
*
|
|
25
|
+
* ## Gating is by effect, never by reading the command
|
|
26
|
+
*
|
|
27
|
+
* `shell_exec` is classified `destructive` and routed through the approval gate. It is tempting to inspect the
|
|
28
|
+
* command instead — refuse `rm -rf`, allow `ls` — and that is a losing game: `find . -delete`, `>file`, `dd`,
|
|
29
|
+
* `python -c`, a base64 pipeline. Any list of dangerous shapes is a list somebody gets around, and worse, it
|
|
30
|
+
* *feels* like protection. A classification cannot be evaded by rephrasing.
|
|
31
|
+
*/
|
|
32
|
+
import { spawn } from "node:child_process";
|
|
33
|
+
import { AgentPlatformError } from "../core/errors.js";
|
|
34
|
+
/** Bytes of stdout and of stderr returned. Beyond this the output is truncated and says so. */
|
|
35
|
+
export const MAX_OUTPUT_BYTES = 64_000;
|
|
36
|
+
/** Wall clock. A model waiting on a hung command is a run holding a worker slot. */
|
|
37
|
+
export const DEFAULT_TIMEOUT_MS = 20_000;
|
|
38
|
+
export const DEFAULT_MEMORY_MB = 256;
|
|
39
|
+
const cap = (text, limit) => Buffer.byteLength(text, "utf8") <= limit
|
|
40
|
+
? { text, truncated: false }
|
|
41
|
+
: { text: Buffer.from(text, "utf8").subarray(0, limit).toString("utf8"), truncated: true };
|
|
42
|
+
/**
|
|
43
|
+
* Spawn a process and bound it, shared by both adapters.
|
|
44
|
+
*
|
|
45
|
+
* The output cap is applied **while reading**, not afterwards: a command that prints a gigabyte would otherwise
|
|
46
|
+
* be buffered in full before any limit could run, which is the same mistake `readBounded` exists to avoid in the
|
|
47
|
+
* HTTP client.
|
|
48
|
+
*
|
|
49
|
+
* ## Two decisions the first version got wrong, and CI found
|
|
50
|
+
*
|
|
51
|
+
* **The whole process group is killed, not the child.** `sh -c "sleep 999"` may exec or may fork, and when it
|
|
52
|
+
* forks the grandchild survives a `SIGKILL` aimed at its parent — so a timed-out command left a process running
|
|
53
|
+
* on the host. `detached: true` makes the child a group leader; `process.kill(-pid)` takes the group with it.
|
|
54
|
+
*
|
|
55
|
+
* **Resolution is on `exit`, not `close`.** `close` waits for every stdio stream to end, and an orphaned
|
|
56
|
+
* grandchild *holds the pipe open* — so `sh -c "sleep 5 | cat"` never resolved at all. The timeout fired, the
|
|
57
|
+
* process was killed, and the promise hung until the test runner gave up thirty seconds later. Reproduced
|
|
58
|
+
* locally in one line once CI had pointed at it.
|
|
59
|
+
*/
|
|
60
|
+
const boundedSpawn = async (file, args, input) => {
|
|
61
|
+
const startedAt = Date.now();
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
// A group leader, so a fork inside the shell can be killed with its parent.
|
|
64
|
+
const child = spawn(file, [...args], { stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
65
|
+
let out = "";
|
|
66
|
+
let err = "";
|
|
67
|
+
let truncated = false;
|
|
68
|
+
let reason;
|
|
69
|
+
const collect = (stream, append) => {
|
|
70
|
+
stream.on("data", (chunk) => {
|
|
71
|
+
const capped = cap(chunk.toString("utf8"), MAX_OUTPUT_BYTES);
|
|
72
|
+
if (capped.truncated)
|
|
73
|
+
truncated = true;
|
|
74
|
+
append(capped.text);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
collect(child.stdout, (chunk) => {
|
|
78
|
+
const capped = cap(out + chunk, MAX_OUTPUT_BYTES);
|
|
79
|
+
out = capped.text;
|
|
80
|
+
if (capped.truncated)
|
|
81
|
+
truncated = true;
|
|
82
|
+
});
|
|
83
|
+
collect(child.stderr, (chunk) => {
|
|
84
|
+
const capped = cap(err + chunk, MAX_OUTPUT_BYTES);
|
|
85
|
+
err = capped.text;
|
|
86
|
+
if (capped.truncated)
|
|
87
|
+
truncated = true;
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* The group, not the process.
|
|
91
|
+
*
|
|
92
|
+
* `-pid` addresses the group `detached` created. It throws if the group is already gone — a command that
|
|
93
|
+
* finished between the timer firing and this line — so the fallback is the plain kill, and neither is
|
|
94
|
+
* allowed to reject the promise.
|
|
95
|
+
*/
|
|
96
|
+
const killGroup = () => {
|
|
97
|
+
try {
|
|
98
|
+
if (child.pid !== undefined)
|
|
99
|
+
process.kill(-child.pid, "SIGKILL");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
child.kill("SIGKILL");
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
reason = "timeout";
|
|
107
|
+
input.onKill?.();
|
|
108
|
+
killGroup();
|
|
109
|
+
}, input.timeoutMs);
|
|
110
|
+
// `exit` and `close` can both fire, and after a group kill only one of them may. Whichever arrives first
|
|
111
|
+
// answers, and the guard is what makes listening to both safe rather than a double resolution.
|
|
112
|
+
let settled = false;
|
|
113
|
+
const settle = (result) => {
|
|
114
|
+
if (settled)
|
|
115
|
+
return;
|
|
116
|
+
settled = true;
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
resolve(result);
|
|
119
|
+
};
|
|
120
|
+
child.on("error", (error) => {
|
|
121
|
+
settle({
|
|
122
|
+
ok: false,
|
|
123
|
+
exitCode: null,
|
|
124
|
+
stdout: out,
|
|
125
|
+
stderr: `${err}${error.message}`,
|
|
126
|
+
truncated,
|
|
127
|
+
reason: "spawn-failed",
|
|
128
|
+
durationMs: Date.now() - startedAt,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
const finish = (code) => settle({
|
|
132
|
+
// A timeout is not a completed command, whatever it printed before it died.
|
|
133
|
+
ok: reason === undefined,
|
|
134
|
+
exitCode: code,
|
|
135
|
+
stdout: out,
|
|
136
|
+
stderr: err,
|
|
137
|
+
truncated,
|
|
138
|
+
...(reason === undefined ? {} : { reason }),
|
|
139
|
+
durationMs: Date.now() - startedAt,
|
|
140
|
+
});
|
|
141
|
+
/**
|
|
142
|
+
* `exit` fires when the process dies; `close` waits for its stdio to end.
|
|
143
|
+
*
|
|
144
|
+
* Preferring `exit` is the fix: a grandchild holding the pipe open means `close` may never come, and the
|
|
145
|
+
* output collected so far is still the output. `close` stays wired for the ordinary case, where it arrives
|
|
146
|
+
* a moment later with the last of the buffer flushed.
|
|
147
|
+
*/
|
|
148
|
+
child.on("exit", (code) => finish(code));
|
|
149
|
+
child.on("close", (code) => finish(code));
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* The argv, built separately so it can be asserted on.
|
|
154
|
+
*
|
|
155
|
+
* Every security property of this adapter *is* this array. A test that ran a command and checked its output would
|
|
156
|
+
* pass just as well with `--network=none` missing, so the argv is what the tests read — and the flags come before
|
|
157
|
+
* `extraArgs`, so a deployment adding options cannot quietly drop one.
|
|
158
|
+
*/
|
|
159
|
+
export const dockerArgs = (config, request) => [
|
|
160
|
+
"run",
|
|
161
|
+
"--rm",
|
|
162
|
+
// No network at all. Not a restricted network: a command that can resolve a name can exfiltrate through it.
|
|
163
|
+
"--network=none",
|
|
164
|
+
// The image is immutable; only the scratch mount is writable, and it is a tmpfs that dies with the container.
|
|
165
|
+
"--read-only",
|
|
166
|
+
"--tmpfs=/scratch:rw,size=16m,mode=1777",
|
|
167
|
+
`--memory=${config.memoryMb ?? DEFAULT_MEMORY_MB}m`,
|
|
168
|
+
// Without this a memory-capped container can still be pushed into swap and take the host's disk with it.
|
|
169
|
+
`--memory-swap=${config.memoryMb ?? DEFAULT_MEMORY_MB}m`,
|
|
170
|
+
"--pids-limit=128",
|
|
171
|
+
"--cap-drop=ALL",
|
|
172
|
+
"--security-opt=no-new-privileges",
|
|
173
|
+
// Not root inside the container either. `--read-only` makes most of that moot; this makes the rest of it moot.
|
|
174
|
+
"--user=65534:65534",
|
|
175
|
+
"--workdir=/scratch",
|
|
176
|
+
// No TTY, and stdin closed: an interactive prompt has nobody to answer it and would hold the slot until the
|
|
177
|
+
// timeout, reported as a timeout, which is a confusing way to learn that a command asked a question.
|
|
178
|
+
...(config.extraArgs ?? []),
|
|
179
|
+
config.image,
|
|
180
|
+
config.shell ?? "sh",
|
|
181
|
+
"-c",
|
|
182
|
+
request.command,
|
|
183
|
+
];
|
|
184
|
+
/**
|
|
185
|
+
* The real adapter: one container per command, destroyed after.
|
|
186
|
+
*
|
|
187
|
+
* Not a pool. A reused container is a container a previous command could have left something in, and the whole
|
|
188
|
+
* proposition here is that a command cannot affect anything outside itself.
|
|
189
|
+
*/
|
|
190
|
+
export const createDockerSandbox = (config) => ({
|
|
191
|
+
id: `docker:${config.image}`,
|
|
192
|
+
async run(request) {
|
|
193
|
+
const timeoutMs = request.timeoutMs ?? config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
194
|
+
const args = dockerArgs({ ...config, ...(request.memoryMb === undefined ? {} : { memoryMb: request.memoryMb }) }, request);
|
|
195
|
+
const result = await boundedSpawn(config.docker ?? "docker", args, { timeoutMs });
|
|
196
|
+
/**
|
|
197
|
+
* 137 is SIGKILL, and inside a memory-capped container that is almost always the OOM killer.
|
|
198
|
+
*
|
|
199
|
+
* Reported as `memory` rather than left as a bare exit code, because "exit 137" is a number a reader has to
|
|
200
|
+
* look up and "the command ran out of memory" is the finding. Not claimed when we did the killing ourselves:
|
|
201
|
+
* a timeout is also a SIGKILL, and it is already named.
|
|
202
|
+
*/
|
|
203
|
+
return result.exitCode === 137 && result.reason === undefined
|
|
204
|
+
? { ...result, ok: false, reason: "memory" }
|
|
205
|
+
: result;
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
/**
|
|
209
|
+
* The development adapter, which **refuses to exist** unless a deployment says so in words.
|
|
210
|
+
*
|
|
211
|
+
* The refusal is at construction rather than at the call, so a misconfiguration is a boot failure rather than a
|
|
212
|
+
* surprise the first time somebody asks the agent to run something. And the message says what to do instead,
|
|
213
|
+
* because "not allowed" without a next step is how a flag gets set to make an error go away.
|
|
214
|
+
*
|
|
215
|
+
* It provides the timeout and the output cap. It provides **none** of the isolation: no network isolation, no
|
|
216
|
+
* memory cap, no read-only filesystem, no dropped capabilities. The contract's table describes what a sandbox
|
|
217
|
+
* guarantees; this adapter meets one row of it, and saying so is the point.
|
|
218
|
+
*/
|
|
219
|
+
export const createLocalSandbox = (config) => {
|
|
220
|
+
if (config.allowUnsafeLocalExecution !== true) {
|
|
221
|
+
throw new AgentPlatformError({
|
|
222
|
+
code: "invalid_input",
|
|
223
|
+
message: "The local sandbox runs commands on this host with no isolation: no network isolation, no memory cap, " +
|
|
224
|
+
"no read-only filesystem. It is for development only, and it requires " +
|
|
225
|
+
"`allowUnsafeLocalExecution: true` in so many words. For anything else, use createDockerSandbox — or " +
|
|
226
|
+
"leave shell_exec unwired, which is the default and is a legitimate answer.",
|
|
227
|
+
retryable: false,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
id: "local:unsafe",
|
|
232
|
+
async run(request) {
|
|
233
|
+
return boundedSpawn(config.shell ?? "sh", ["-c", request.command], {
|
|
234
|
+
timeoutMs: request.timeoutMs ?? config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
//# sourceMappingURL=sandbox.js.map
|