akemon 0.3.4 → 0.3.5
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 +18 -0
- package/dist/cli.js +89 -40
- package/dist/engine-peripheral.js +5 -4
- package/dist/engine-routing.js +99 -0
- package/dist/event-bus.js +63 -17
- package/dist/privacy-filter.js +269 -0
- package/dist/redaction.js +159 -0
- package/dist/relay-client.js +39 -2
- package/dist/server.js +86 -101
- package/dist/software-agent-memory.js +9 -11
- package/dist/software-agent-peripheral.js +313 -20
- package/dist/software-agent-stream-cli.js +101 -0
- package/package.json +1 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { redactText } from "./redaction.js";
|
|
3
|
+
export class PrivacyFilterUnavailableError extends Error {
|
|
4
|
+
constructor(message, options = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "PrivacyFilterUnavailableError";
|
|
7
|
+
if (options.cause !== undefined) {
|
|
8
|
+
this.cause = options.cause;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_OPF_COMMAND = "opf";
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
14
|
+
const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
const DEFAULT_MAX_INPUT_CHARS = 32_000;
|
|
16
|
+
export async function sanitizeText(text, options = {}) {
|
|
17
|
+
const mode = normalizeMode(options.mode);
|
|
18
|
+
const env = { ...process.env, ...(options.env || {}) };
|
|
19
|
+
const backend = resolveBackend(mode, options.backend, env);
|
|
20
|
+
const fastText = redactText(text || "");
|
|
21
|
+
if (backend === "fast") {
|
|
22
|
+
return {
|
|
23
|
+
text: fastText,
|
|
24
|
+
mode,
|
|
25
|
+
backend,
|
|
26
|
+
opfApplied: false,
|
|
27
|
+
warnings: [],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const filteredText = await runOpfCli(fastText, options, env);
|
|
32
|
+
return {
|
|
33
|
+
text: filteredText,
|
|
34
|
+
mode,
|
|
35
|
+
backend,
|
|
36
|
+
opfApplied: true,
|
|
37
|
+
warnings: [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (mode === "strict") {
|
|
42
|
+
throw toPrivacyFilterUnavailableError(error);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
text: fastText,
|
|
46
|
+
mode,
|
|
47
|
+
backend,
|
|
48
|
+
opfApplied: false,
|
|
49
|
+
warnings: [`OPF unavailable, used built-in redaction: ${redactText(errorMessage(error))}`],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function normalizeMode(mode) {
|
|
54
|
+
if (mode === undefined)
|
|
55
|
+
return "fast";
|
|
56
|
+
if (mode === "fast" || mode === "pii" || mode === "strict")
|
|
57
|
+
return mode;
|
|
58
|
+
throw new TypeError(`Invalid privacy filter mode: ${String(mode)}`);
|
|
59
|
+
}
|
|
60
|
+
function resolveBackend(mode, backend, env) {
|
|
61
|
+
if (backend !== undefined)
|
|
62
|
+
return normalizeBackend(backend);
|
|
63
|
+
const envBackend = env.AKEMON_PRIVACY_FILTER?.trim().toLowerCase();
|
|
64
|
+
if (envBackend)
|
|
65
|
+
return normalizeBackend(envBackend);
|
|
66
|
+
return mode === "strict" ? "opf" : "fast";
|
|
67
|
+
}
|
|
68
|
+
function normalizeBackend(backend) {
|
|
69
|
+
if (backend === "fast" || backend === "opf")
|
|
70
|
+
return backend;
|
|
71
|
+
throw new TypeError(`Invalid privacy filter backend: ${backend}`);
|
|
72
|
+
}
|
|
73
|
+
async function runOpfCli(text, options, env) {
|
|
74
|
+
const maxInputChars = readPositiveInt(options.maxInputChars, env.AKEMON_OPF_MAX_INPUT_CHARS, DEFAULT_MAX_INPUT_CHARS);
|
|
75
|
+
if (text.length > maxInputChars) {
|
|
76
|
+
throw new PrivacyFilterUnavailableError(`OPF input length ${text.length} exceeds max ${maxInputChars} chars`);
|
|
77
|
+
}
|
|
78
|
+
const command = options.command || env.AKEMON_OPF_COMMAND || DEFAULT_OPF_COMMAND;
|
|
79
|
+
const timeoutMs = readPositiveInt(options.timeoutMs, env.AKEMON_OPF_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
|
|
80
|
+
const maxBufferBytes = readPositiveInt(options.maxBufferBytes, env.AKEMON_OPF_MAX_BUFFER_BYTES, DEFAULT_MAX_BUFFER_BYTES);
|
|
81
|
+
const device = options.device || env.AKEMON_OPF_DEVICE;
|
|
82
|
+
const checkpoint = options.checkpoint || env.AKEMON_OPF_CHECKPOINT;
|
|
83
|
+
const args = buildOpfArgs({ device, checkpoint });
|
|
84
|
+
const spawnImpl = options.spawnImpl || spawn;
|
|
85
|
+
const stdout = await collectChildOutput(spawnImpl(command, args, {
|
|
86
|
+
env,
|
|
87
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
88
|
+
}), text, timeoutMs, maxBufferBytes);
|
|
89
|
+
return parseOpfRedactedText(stdout, text);
|
|
90
|
+
}
|
|
91
|
+
function buildOpfArgs(options) {
|
|
92
|
+
const args = [
|
|
93
|
+
"redact",
|
|
94
|
+
"--format",
|
|
95
|
+
"json",
|
|
96
|
+
"--output-mode",
|
|
97
|
+
"redacted",
|
|
98
|
+
"--json-indent",
|
|
99
|
+
"0",
|
|
100
|
+
"--no-print-color-coded-text",
|
|
101
|
+
];
|
|
102
|
+
if (options.device)
|
|
103
|
+
args.push("--device", options.device);
|
|
104
|
+
if (options.checkpoint)
|
|
105
|
+
args.push("--checkpoint", options.checkpoint);
|
|
106
|
+
return args;
|
|
107
|
+
}
|
|
108
|
+
function collectChildOutput(child, inputText, timeoutMs, maxBufferBytes) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
let stdout = "";
|
|
111
|
+
let stderr = "";
|
|
112
|
+
let stdoutBytes = 0;
|
|
113
|
+
let stderrBytes = 0;
|
|
114
|
+
let settled = false;
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
fail(new PrivacyFilterUnavailableError(`OPF timed out after ${timeoutMs}ms`));
|
|
117
|
+
child.kill("SIGTERM");
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
const fail = (error) => {
|
|
120
|
+
if (settled)
|
|
121
|
+
return;
|
|
122
|
+
settled = true;
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
reject(toPrivacyFilterUnavailableError(error));
|
|
125
|
+
};
|
|
126
|
+
child.stdout?.on("data", (chunk) => {
|
|
127
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
128
|
+
stdoutBytes += buffer.byteLength;
|
|
129
|
+
if (stdoutBytes > maxBufferBytes) {
|
|
130
|
+
fail(new PrivacyFilterUnavailableError(`OPF stdout exceeded ${maxBufferBytes} bytes`));
|
|
131
|
+
child.kill("SIGTERM");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
stdout += buffer.toString("utf8");
|
|
135
|
+
});
|
|
136
|
+
child.stderr?.on("data", (chunk) => {
|
|
137
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
138
|
+
stderrBytes += buffer.byteLength;
|
|
139
|
+
if (stderrBytes > maxBufferBytes) {
|
|
140
|
+
fail(new PrivacyFilterUnavailableError(`OPF stderr exceeded ${maxBufferBytes} bytes`));
|
|
141
|
+
child.kill("SIGTERM");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
stderr += buffer.toString("utf8");
|
|
145
|
+
});
|
|
146
|
+
child.on("error", fail);
|
|
147
|
+
child.on("close", (code) => {
|
|
148
|
+
if (settled)
|
|
149
|
+
return;
|
|
150
|
+
settled = true;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
if (code !== 0) {
|
|
153
|
+
reject(new PrivacyFilterUnavailableError(`OPF exited with code ${code}: ${stderr.trim() || "no stderr"}`));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
resolve(stdout);
|
|
157
|
+
});
|
|
158
|
+
if (!child.stdin) {
|
|
159
|
+
fail(new PrivacyFilterUnavailableError("OPF stdin was not available"));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
child.stdin.on("error", fail);
|
|
163
|
+
child.stdin.end(inputText.endsWith("\n") ? inputText : `${inputText}\n`);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function parseOpfRedactedText(stdout, originalText) {
|
|
167
|
+
const records = parseOpfJsonRecords(stdout);
|
|
168
|
+
const redactedLines = [];
|
|
169
|
+
for (const record of records) {
|
|
170
|
+
const redacted = readOpfRedactedText(record);
|
|
171
|
+
redactedLines.push(redacted);
|
|
172
|
+
}
|
|
173
|
+
const lines = originalText.split(/\r?\n/);
|
|
174
|
+
if (lines.filter((line) => line.trim()).length > 1) {
|
|
175
|
+
let redactedIndex = 0;
|
|
176
|
+
return lines.map((line) => {
|
|
177
|
+
if (!line.trim())
|
|
178
|
+
return line;
|
|
179
|
+
return redactedLines[redactedIndex++] ?? line;
|
|
180
|
+
}).join("\n");
|
|
181
|
+
}
|
|
182
|
+
return redactedLines[0] ?? "";
|
|
183
|
+
}
|
|
184
|
+
function parseOpfJsonRecords(stdout) {
|
|
185
|
+
const trimmed = stdout.trim();
|
|
186
|
+
if (!trimmed) {
|
|
187
|
+
throw new PrivacyFilterUnavailableError("OPF returned empty output");
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
return [JSON.parse(trimmed)];
|
|
191
|
+
}
|
|
192
|
+
catch { }
|
|
193
|
+
const chunks = splitConcatenatedJsonObjects(trimmed);
|
|
194
|
+
if (!chunks.length) {
|
|
195
|
+
throw new PrivacyFilterUnavailableError("OPF did not return valid JSON");
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
return chunks.map((chunk) => JSON.parse(chunk));
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw new PrivacyFilterUnavailableError("OPF did not return valid JSON", { cause: error });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function splitConcatenatedJsonObjects(text) {
|
|
205
|
+
const chunks = [];
|
|
206
|
+
let depth = 0;
|
|
207
|
+
let start = -1;
|
|
208
|
+
let inString = false;
|
|
209
|
+
let escaped = false;
|
|
210
|
+
for (let i = 0; i < text.length; i++) {
|
|
211
|
+
const char = text[i];
|
|
212
|
+
if (inString) {
|
|
213
|
+
if (escaped) {
|
|
214
|
+
escaped = false;
|
|
215
|
+
}
|
|
216
|
+
else if (char === "\\") {
|
|
217
|
+
escaped = true;
|
|
218
|
+
}
|
|
219
|
+
else if (char === "\"") {
|
|
220
|
+
inString = false;
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (char === "\"") {
|
|
225
|
+
inString = true;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (char === "{") {
|
|
229
|
+
if (depth === 0)
|
|
230
|
+
start = i;
|
|
231
|
+
depth += 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (char === "}") {
|
|
235
|
+
depth -= 1;
|
|
236
|
+
if (depth === 0 && start >= 0) {
|
|
237
|
+
chunks.push(text.slice(start, i + 1));
|
|
238
|
+
start = -1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return depth === 0 && !inString ? chunks : [];
|
|
243
|
+
}
|
|
244
|
+
function readOpfRedactedText(parsed) {
|
|
245
|
+
if (!parsed || typeof parsed !== "object") {
|
|
246
|
+
throw new PrivacyFilterUnavailableError("OPF JSON output was not an object");
|
|
247
|
+
}
|
|
248
|
+
const redacted = parsed.redacted_text
|
|
249
|
+
?? parsed.redactedText;
|
|
250
|
+
if (typeof redacted !== "string") {
|
|
251
|
+
throw new PrivacyFilterUnavailableError("OPF JSON output did not include redacted_text");
|
|
252
|
+
}
|
|
253
|
+
return redacted;
|
|
254
|
+
}
|
|
255
|
+
function readPositiveInt(value, envValue, fallback) {
|
|
256
|
+
const raw = value !== undefined ? value : envValue;
|
|
257
|
+
const parsed = typeof raw === "number" ? raw : Number(raw);
|
|
258
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
259
|
+
}
|
|
260
|
+
function toPrivacyFilterUnavailableError(error) {
|
|
261
|
+
if (error instanceof PrivacyFilterUnavailableError)
|
|
262
|
+
return error;
|
|
263
|
+
return new PrivacyFilterUnavailableError(errorMessage(error), { cause: error });
|
|
264
|
+
}
|
|
265
|
+
function errorMessage(error) {
|
|
266
|
+
if (error instanceof Error)
|
|
267
|
+
return error.message;
|
|
268
|
+
return String(error);
|
|
269
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const REDACTION = "[REDACTED]";
|
|
2
|
+
const PRIVATE_KEY_BLOCK_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
3
|
+
const AUTH_HEADER_RE = /\b((?:Authorization\s*:\s*)?(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]{12,}/gi;
|
|
4
|
+
const URL_CREDENTIAL_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/gi;
|
|
5
|
+
const SECRET_ASSIGNMENT_RE = /((?:^|[\s{,])["']?[A-Za-z0-9_.-]*(?:secret|token|password|passwd|pwd|api[_-]?key|access[_-]?key|private[_-]?key|credential)s?["']?\s*[:=]\s*["']?)([^"',\s})]+)/gi;
|
|
6
|
+
const KNOWN_TOKEN_PATTERNS = [
|
|
7
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
8
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
9
|
+
/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/g,
|
|
10
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
11
|
+
/\bnpm_[A-Za-z0-9]{20,}\b/g,
|
|
12
|
+
/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g,
|
|
13
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
14
|
+
/\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g,
|
|
15
|
+
];
|
|
16
|
+
const SENSITIVE_KEY_NAMES = new Set([
|
|
17
|
+
"auth",
|
|
18
|
+
"authorization",
|
|
19
|
+
"bearer",
|
|
20
|
+
"credential",
|
|
21
|
+
"credentials",
|
|
22
|
+
"password",
|
|
23
|
+
"passwd",
|
|
24
|
+
"pwd",
|
|
25
|
+
"secret",
|
|
26
|
+
"secretkey",
|
|
27
|
+
"token",
|
|
28
|
+
"accesstoken",
|
|
29
|
+
"accesskey",
|
|
30
|
+
"apikey",
|
|
31
|
+
"rawapikey",
|
|
32
|
+
"privatekey",
|
|
33
|
+
]);
|
|
34
|
+
const DEFAULT_MAX_BUFFERED_CHARS = 8192;
|
|
35
|
+
const SECRET_TERMINATOR_RE = /[\s"',}\])>]$/;
|
|
36
|
+
const POTENTIAL_SECRET_TAIL_PATTERNS = [
|
|
37
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*$/i,
|
|
38
|
+
/(?:^|[\s{,])["']?[A-Za-z0-9_.-]*(?:secret|token|password|passwd|pwd|api[_-]?key|access[_-]?key|private[_-]?key|credential)s?["']?\s*[:=]\s*["']?[^"',\s})]*$/i,
|
|
39
|
+
/\b(?:Authorization\s*:\s*)?(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]*$/i,
|
|
40
|
+
/\b(?:sk-ant-|sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|npm_|xox[baprs]-)[A-Za-z0-9._~+/=-]*$/i,
|
|
41
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]*$/i,
|
|
42
|
+
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*$/i,
|
|
43
|
+
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*$/i,
|
|
44
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]*$/i,
|
|
45
|
+
];
|
|
46
|
+
export class StreamingRedactor {
|
|
47
|
+
pending = "";
|
|
48
|
+
maxBufferedChars;
|
|
49
|
+
constructor(options = {}) {
|
|
50
|
+
this.maxBufferedChars = normalizePositiveInt(options.maxBufferedChars, DEFAULT_MAX_BUFFERED_CHARS);
|
|
51
|
+
}
|
|
52
|
+
push(chunk) {
|
|
53
|
+
if (!chunk)
|
|
54
|
+
return "";
|
|
55
|
+
this.pending += chunk;
|
|
56
|
+
const redacted = redactText(this.pending);
|
|
57
|
+
if (redacted !== this.pending) {
|
|
58
|
+
if (SECRET_TERMINATOR_RE.test(this.pending) || this.pending.length > this.maxBufferedChars) {
|
|
59
|
+
this.pending = "";
|
|
60
|
+
return redacted;
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
const holdStart = findPotentialSecretTailStart(this.pending);
|
|
65
|
+
if (holdStart >= 0) {
|
|
66
|
+
const output = this.pending.slice(0, holdStart);
|
|
67
|
+
const held = this.pending.slice(holdStart);
|
|
68
|
+
if (held.length > this.maxBufferedChars) {
|
|
69
|
+
this.pending = "";
|
|
70
|
+
return `${redactText(output)}${REDACTION}`;
|
|
71
|
+
}
|
|
72
|
+
this.pending = held;
|
|
73
|
+
return redactText(output);
|
|
74
|
+
}
|
|
75
|
+
const output = this.pending;
|
|
76
|
+
this.pending = "";
|
|
77
|
+
return output;
|
|
78
|
+
}
|
|
79
|
+
flush() {
|
|
80
|
+
if (!this.pending)
|
|
81
|
+
return "";
|
|
82
|
+
const output = redactText(this.pending);
|
|
83
|
+
this.pending = "";
|
|
84
|
+
return output;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function redactText(text) {
|
|
88
|
+
let redacted = text;
|
|
89
|
+
redacted = redacted.replace(PRIVATE_KEY_BLOCK_RE, REDACTION);
|
|
90
|
+
redacted = redacted.replace(URL_CREDENTIAL_RE, `$1${REDACTION}@`);
|
|
91
|
+
redacted = redacted.replace(AUTH_HEADER_RE, `$1${REDACTION}`);
|
|
92
|
+
redacted = redacted.replace(SECRET_ASSIGNMENT_RE, `$1${REDACTION}`);
|
|
93
|
+
for (const pattern of KNOWN_TOKEN_PATTERNS) {
|
|
94
|
+
redacted = redacted.replace(pattern, REDACTION);
|
|
95
|
+
}
|
|
96
|
+
return redacted;
|
|
97
|
+
}
|
|
98
|
+
export function redactSecrets(value) {
|
|
99
|
+
return redactValue(value, new WeakMap(), "");
|
|
100
|
+
}
|
|
101
|
+
export function isSensitiveKey(key) {
|
|
102
|
+
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
103
|
+
return SENSITIVE_KEY_NAMES.has(normalized)
|
|
104
|
+
|| normalized.endsWith("secret")
|
|
105
|
+
|| normalized.endsWith("token")
|
|
106
|
+
|| normalized.endsWith("password")
|
|
107
|
+
|| normalized.endsWith("apikey")
|
|
108
|
+
|| normalized.endsWith("accesskey")
|
|
109
|
+
|| normalized.endsWith("privatekey")
|
|
110
|
+
|| normalized.includes("credential");
|
|
111
|
+
}
|
|
112
|
+
function redactValue(value, seen, key) {
|
|
113
|
+
if (isSensitiveKey(key) && value !== undefined && value !== null) {
|
|
114
|
+
return REDACTION;
|
|
115
|
+
}
|
|
116
|
+
if (typeof value === "string") {
|
|
117
|
+
return redactText(value);
|
|
118
|
+
}
|
|
119
|
+
if (!value || typeof value !== "object") {
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
if (seen.has(value)) {
|
|
123
|
+
return seen.get(value);
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(value)) {
|
|
126
|
+
const copy = [];
|
|
127
|
+
seen.set(value, copy);
|
|
128
|
+
for (const item of value)
|
|
129
|
+
copy.push(redactValue(item, seen, ""));
|
|
130
|
+
return copy;
|
|
131
|
+
}
|
|
132
|
+
if (!isPlainObject(value)) {
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
const copy = {};
|
|
136
|
+
seen.set(value, copy);
|
|
137
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
138
|
+
copy[childKey] = redactValue(childValue, seen, childKey);
|
|
139
|
+
}
|
|
140
|
+
return copy;
|
|
141
|
+
}
|
|
142
|
+
function isPlainObject(value) {
|
|
143
|
+
const proto = Object.getPrototypeOf(value);
|
|
144
|
+
return proto === Object.prototype || proto === null;
|
|
145
|
+
}
|
|
146
|
+
function findPotentialSecretTailStart(text) {
|
|
147
|
+
let start = -1;
|
|
148
|
+
for (const pattern of POTENTIAL_SECRET_TAIL_PATTERNS) {
|
|
149
|
+
pattern.lastIndex = 0;
|
|
150
|
+
const match = pattern.exec(text);
|
|
151
|
+
if (match?.index !== undefined && (start < 0 || match.index < start)) {
|
|
152
|
+
start = match.index;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return start;
|
|
156
|
+
}
|
|
157
|
+
function normalizePositiveInt(value, fallback) {
|
|
158
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
159
|
+
}
|
package/dist/relay-client.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import http from "http";
|
|
3
3
|
import { getMetrics, updateMetrics } from "./metrics.js";
|
|
4
|
+
import { redactText, StreamingRedactor } from "./redaction.js";
|
|
4
5
|
const DEFAULT_RELAY_URL = "wss://relay.akemon.dev";
|
|
5
6
|
// Pending agent_call results (callId → resolve function)
|
|
6
7
|
const pendingAgentCalls = new Map();
|
|
7
8
|
let relayWsRef = null;
|
|
9
|
+
const taskStreamRedactors = new Map();
|
|
8
10
|
function sendRelayMessage(msg) {
|
|
9
11
|
if (!relayWsRef || relayWsRef.readyState !== WebSocket.OPEN)
|
|
10
12
|
return;
|
|
@@ -87,6 +89,7 @@ export function callAgent(target, task) {
|
|
|
87
89
|
});
|
|
88
90
|
}
|
|
89
91
|
export function sendTaskStart(taskId, origin, cmd) {
|
|
92
|
+
clearTaskStreamRedactors(taskId);
|
|
90
93
|
sendRelayMessage({
|
|
91
94
|
type: "task_start",
|
|
92
95
|
task_id: taskId,
|
|
@@ -95,14 +98,19 @@ export function sendTaskStart(taskId, origin, cmd) {
|
|
|
95
98
|
});
|
|
96
99
|
}
|
|
97
100
|
export function sendTaskStream(taskId, stream, chunk) {
|
|
101
|
+
const safeChunk = taskStreamRedactor(taskId, stream).push(chunk);
|
|
102
|
+
if (!safeChunk)
|
|
103
|
+
return;
|
|
98
104
|
sendRelayMessage({
|
|
99
105
|
type: "task_stream",
|
|
100
106
|
task_id: taskId,
|
|
101
107
|
stream,
|
|
102
|
-
chunk,
|
|
108
|
+
chunk: safeChunk,
|
|
103
109
|
});
|
|
104
110
|
}
|
|
105
111
|
export function sendTaskEnd(taskId, exitCode, durationMs) {
|
|
112
|
+
flushTaskStreamRedactor(taskId, "stdout");
|
|
113
|
+
flushTaskStreamRedactor(taskId, "stderr");
|
|
106
114
|
sendRelayMessage({
|
|
107
115
|
type: "task_end",
|
|
108
116
|
task_id: taskId,
|
|
@@ -110,6 +118,35 @@ export function sendTaskEnd(taskId, exitCode, durationMs) {
|
|
|
110
118
|
duration_ms: durationMs,
|
|
111
119
|
});
|
|
112
120
|
}
|
|
121
|
+
function clearTaskStreamRedactors(taskId) {
|
|
122
|
+
taskStreamRedactors.delete(`${taskId}:stdout`);
|
|
123
|
+
taskStreamRedactors.delete(`${taskId}:stderr`);
|
|
124
|
+
}
|
|
125
|
+
function taskStreamRedactor(taskId, stream) {
|
|
126
|
+
const key = `${taskId}:${stream}`;
|
|
127
|
+
let redactor = taskStreamRedactors.get(key);
|
|
128
|
+
if (!redactor) {
|
|
129
|
+
redactor = new StreamingRedactor();
|
|
130
|
+
taskStreamRedactors.set(key, redactor);
|
|
131
|
+
}
|
|
132
|
+
return redactor;
|
|
133
|
+
}
|
|
134
|
+
function flushTaskStreamRedactor(taskId, stream) {
|
|
135
|
+
const key = `${taskId}:${stream}`;
|
|
136
|
+
const redactor = taskStreamRedactors.get(key);
|
|
137
|
+
if (!redactor)
|
|
138
|
+
return;
|
|
139
|
+
const safeChunk = redactor.flush();
|
|
140
|
+
taskStreamRedactors.delete(key);
|
|
141
|
+
if (!safeChunk)
|
|
142
|
+
return;
|
|
143
|
+
sendRelayMessage({
|
|
144
|
+
type: "task_stream",
|
|
145
|
+
task_id: taskId,
|
|
146
|
+
stream,
|
|
147
|
+
chunk: safeChunk,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
113
150
|
export function connectRelay(options) {
|
|
114
151
|
const relayUrl = options.relayUrl || DEFAULT_RELAY_URL;
|
|
115
152
|
let wsUrl = relayUrl.replace(/^http/, "ws");
|
|
@@ -485,5 +522,5 @@ function extractSSEData(sse) {
|
|
|
485
522
|
}
|
|
486
523
|
/** Send a failure event to the relay for observability storage. Fire-and-forget. */
|
|
487
524
|
export function sendFailureEvent(kind, label, message) {
|
|
488
|
-
sendRelayMessage({ type: "failure_event", kind, label, message });
|
|
525
|
+
sendRelayMessage({ type: "failure_event", kind, label, message: redactText(message) });
|
|
489
526
|
}
|