deepdebug-local-agent 1.12.1 → 1.13.1
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/CHANGELOG.md +2 -0
- package/docs/la-chat.md +35 -0
- package/docs/la-jobs-and-llm.md +1 -1
- package/package.json +1 -1
- package/src/dlp/rules.js +2 -2
- package/src/la-protocol-v2/chat/config.js +60 -0
- package/src/la-protocol-v2/chat/cursors.js +57 -0
- package/src/la-protocol-v2/chat/dlp.js +92 -0
- package/src/la-protocol-v2/chat/index.js +28 -0
- package/src/la-protocol-v2/chat/store.js +337 -0
- package/src/la-protocol-v2/chat/stream-source.js +23 -0
- package/src/la-protocol-v2/content-handlers/chat-session.js +41 -0
- package/src/la-protocol-v2/content-handlers/handler.js +10 -2
- package/src/la-protocol-v2/content-handlers/install.js +2 -2
- package/src/la-protocol-v2/content-handlers/job-stream.js +62 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +42 -0
- package/src/la-protocol-v2/contract/chat.session.body.schema.json +47 -0
- package/src/la-protocol-v2/contract/index.json +1 -1
- package/src/la-protocol-v2/contract/job.assign.schema.json +3 -2
- package/src/la-protocol-v2/contract/job.stream.body.schema.json +49 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +78 -51
- package/src/la-protocol-v2/jobs/agent2/config.js +1 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +92 -11
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +2 -0
- package/src/la-protocol-v2/jobs/manager.js +5 -3
- package/src/la-protocol-v2/llm/client.js +21 -1
- package/src/la-protocol-v2/runtime.js +32 -3
- package/test/contract/la-protocol-v2/vectors/chat.session.body.vectors.json +10379 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +245 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +112 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +568 -3
- package/test/contract/la-protocol-v2/vectors/job.stream.body.vectors.json +4572 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +106 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-chat-job-and-stream.json +311 -0
- package/test/la-protocol-v2/agent2-runner.test.js +3 -1
- package/test/la-protocol-v2/chat-dlp-json.test.js +169 -0
- package/test/la-protocol-v2/chat-la.test.js +490 -0
- package/test/la-protocol-v2/chat-real-e2e.test.js +69 -0
- package/test/la-protocol-v2/chat-store.test.js +301 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +6 -6
- package/test/la-protocol-v2/helpers/agent2-rig.js +2 -2
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +1 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +80 -5
- package/test/la-protocol-v2/jobs-e2e.test.js +2 -1
- package/test/la-protocol-v2/llm-client.test.js +1 -1
- package/test/la-protocol-v2/llm-messages.test.js +2 -0
- package/test/la-protocol-v2/llm-scope-ids.test.js +129 -0
- package/test/la-protocol-v2/traces.test.js +3 -3
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// LA-3 review fix: the event lines of agent2 are JSON; the DLP must judge their DECODED strings, not the serialized line (inside JSON a quoted value is \" and the
|
|
2
|
+
// assignment patterns stopped matching). Unit (maskEventLine), disk (ChatStore) and fuzz of formats. The wire (job.stream body) is in chat-la.test.js.
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { maskEventLine, WITHHELD_LINE } from "../../src/la-protocol-v2/chat/dlp.js";
|
|
10
|
+
import { resolveChatConfig } from "../../src/la-protocol-v2/chat/config.js";
|
|
11
|
+
import { createChatStore } from "../../src/la-protocol-v2/chat/store.js";
|
|
12
|
+
|
|
13
|
+
const TENANT = "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f";
|
|
14
|
+
const ENV = { LOCAL_AGENT_DLP_MODE: "enforce", LOCAL_AGENT_DLP_BLOCK_CLASSES: "private-key" };
|
|
15
|
+
const PW = "Sup3rS3cretValue!99";
|
|
16
|
+
const KEY = "abcd1234efgh5678ijkl9999";
|
|
17
|
+
const SECRETS = [PW, KEY];
|
|
18
|
+
const mask = (o) => maskEventLine(typeof o === "string" ? o : JSON.stringify(o), WITHHELD_LINE, { env: ENV, source: "t" });
|
|
19
|
+
const leaks = (text) => SECRETS.filter((s) => text.includes(s));
|
|
20
|
+
|
|
21
|
+
const CASES = {
|
|
22
|
+
"object with secret-named keys (the reviewer's case)": { type: "tool_result", content: { db_password: PW, api_key: KEY } },
|
|
23
|
+
"token with a quoted assignment": { type: "token", content: `the config says password = "${PW}" ok` },
|
|
24
|
+
"summary answer with a quoted assignment": { type: "summary", content: { answer: `use password = "${PW}" and api_key: "${KEY}"`, conversationId: "x" } },
|
|
25
|
+
"tool output: a JSON document inside a string": { type: "tool_result", output: JSON.stringify({ db_password: PW, api_key: KEY }) },
|
|
26
|
+
"tool output: env file with single quotes": { type: "tool_result", output: `DB_PASSWORD='${PW}'\nAPI_KEY='${KEY}'\n` },
|
|
27
|
+
"nested quotes and escapes": { type: "token", content: `say \\"password = \\\\\\"${PW}\\\\\\"\\"` },
|
|
28
|
+
"array of objects": { type: "x", items: [{ client_secret: PW }, [{ apiKey: KEY }]] },
|
|
29
|
+
"secret after a line break": { type: "token", content: `line one\r\nline two\npassword = "${PW}"\n\napi_key=\t${KEY}` },
|
|
30
|
+
"unicode escapes written in the raw line": null, // below: the raw line carries \\u escapes
|
|
31
|
+
"key named __proto__": null,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (const [name, ev] of Object.entries(CASES)) {
|
|
35
|
+
if (!ev) continue;
|
|
36
|
+
test(`maskEventLine: ${name} - no secret survives and the line is still JSON`, () => {
|
|
37
|
+
const m = mask(ev);
|
|
38
|
+
assert.equal(m.withheld, false, m.text);
|
|
39
|
+
assert.deepEqual(leaks(m.text), []);
|
|
40
|
+
assert.ok(m.total > 0);
|
|
41
|
+
assert.doesNotThrow(() => JSON.parse(m.text));
|
|
42
|
+
assert.equal(JSON.parse(m.text).type, ev.type, "the structure is kept");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("maskEventLine: the raw line spells the secret with \\u escapes and \\/ : decoded before judging", () => {
|
|
47
|
+
const esc = (s) => [...s].map((c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`).join("");
|
|
48
|
+
const raw = `{"type":"token","content":"password \\u003d \\"${PW}\\"","d\\u0062_password":"${esc(PW)}"}`;
|
|
49
|
+
const m = mask(raw);
|
|
50
|
+
assert.equal(m.withheld, false);
|
|
51
|
+
assert.deepEqual(leaks(m.text), []);
|
|
52
|
+
assert.ok(!m.text.includes(PW) && !JSON.parse(m.text).content.includes(PW));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("maskEventLine: __proto__ key and a secret used as a KEY are handled, no prototype pollution", () => {
|
|
56
|
+
const raw = `{"type":"x","__proto__":{"db_password":"${PW}"},"sk-ant-${"a1".repeat(15)}":"v"}`;
|
|
57
|
+
const m = mask(raw);
|
|
58
|
+
assert.equal(m.withheld, false);
|
|
59
|
+
assert.deepEqual(leaks(m.text), []);
|
|
60
|
+
assert.ok(!m.text.includes("sk-ant-a1a1"), "a secret in a key is masked as well");
|
|
61
|
+
assert.equal({}.db_password, undefined);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("maskEventLine: a line that is not JSON is masked as text; a bare JSON scalar too", () => {
|
|
65
|
+
for (const raw of [`not json password = "${PW}"`, JSON.stringify(`password = "${PW}"`), "{\"type\":\"token\",\"content\":\"cut off password = \\\""]) {
|
|
66
|
+
const m = mask(raw);
|
|
67
|
+
assert.deepEqual(leaks(m.text), [], raw);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("maskEventLine: fail closed - DLP not enforce, a blocked class, oversize, too deep -> the whole line is withheld", () => {
|
|
72
|
+
const ok = JSON.stringify({ type: "token", content: "hello" });
|
|
73
|
+
assert.equal(maskEventLine(ok, WITHHELD_LINE, { env: { LOCAL_AGENT_DLP_MODE: "warn" } }).withheld, true);
|
|
74
|
+
assert.equal(maskEventLine(ok, WITHHELD_LINE, { env: {} }).withheld, true);
|
|
75
|
+
const pem = { type: "tool_result", output: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAxxxxxxxxxxxxxxxx\n-----END RSA PRIVATE KEY-----" };
|
|
76
|
+
const w = mask(pem);
|
|
77
|
+
assert.deepEqual([w.withheld, w.text], [true, WITHHELD_LINE]);
|
|
78
|
+
assert.equal(maskEventLine(ok, WITHHELD_LINE, { env: { ...ENV, LOCAL_AGENT_DLP_MAX_BYTES: "3" } }).withheld, true);
|
|
79
|
+
let deep = "x";
|
|
80
|
+
for (let i = 0; i < 200; i++) deep = { a: deep };
|
|
81
|
+
assert.equal(mask(deep).withheld, true);
|
|
82
|
+
assert.equal(mask("{\"a\":" + "[".repeat(5) + "]".repeat(5) + "}").withheld, false, "shallow nesting is fine");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("maskEventLine: harmless content is untouched (same JSON), a placeholder is not masked twice", () => {
|
|
86
|
+
const ev = { type: "token", content: "total = 42; the password policy is documented in README", n: 3, ok: true, z: null };
|
|
87
|
+
const m = mask(ev);
|
|
88
|
+
assert.deepEqual(JSON.parse(m.text), ev);
|
|
89
|
+
assert.equal(m.total, 0);
|
|
90
|
+
const once = mask({ type: "token", content: `password = "${PW}"` });
|
|
91
|
+
const twice = mask(once.text);
|
|
92
|
+
assert.equal(twice.text, once.text, "idempotent: the egress mask over the stored mask changes nothing");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("fuzz: random wrappers around secrets in strings, keys, arrays and line breaks never leak", () => {
|
|
96
|
+
let seed = 12345;
|
|
97
|
+
const rnd = (n) => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n; };
|
|
98
|
+
const names = ["password", "db_password", "api_key", "apiKey", "client_secret", "auth_token", "PASSWD", "secret", "access_token"];
|
|
99
|
+
const quotes = ['"', "'", "", '\\"', "`"];
|
|
100
|
+
const seps = [" = ", ": ", "=", ":", "=>", " : "];
|
|
101
|
+
const pre = ["", "\n", "\r\n", " ", "export ", "{ ", "foo bar\n\n"];
|
|
102
|
+
for (let i = 0; i < 400; i++) {
|
|
103
|
+
const sec = i % 2 ? PW : KEY;
|
|
104
|
+
const q = quotes[rnd(quotes.length)];
|
|
105
|
+
const text = `${pre[rnd(pre.length)]}${names[rnd(names.length)]}${seps[rnd(seps.length)]}${q}${sec}${q}${pre[rnd(pre.length)]}`;
|
|
106
|
+
const shape = rnd(4);
|
|
107
|
+
const ev = shape === 0 ? { type: "token", content: text }
|
|
108
|
+
: shape === 1 ? { type: "tool_result", output: { [names[rnd(names.length)]]: sec, other: [text] } }
|
|
109
|
+
: shape === 2 ? { type: "summary", content: { answer: text } }
|
|
110
|
+
: { type: "tool_result", output: JSON.stringify({ [names[rnd(names.length)]]: sec }) };
|
|
111
|
+
const m = mask(ev);
|
|
112
|
+
assert.deepEqual(leaks(m.text), [], `iteration ${i}: ${JSON.stringify(ev)}`);
|
|
113
|
+
JSON.parse(m.text);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function mkStore() {
|
|
118
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "la-chat-dlpjson-"));
|
|
119
|
+
const env = { ...ENV, LOCAL_AGENT_CHAT: "true", LOCAL_AGENT_CHAT_DIR: dir };
|
|
120
|
+
const config = resolveChatConfig(env, () => {});
|
|
121
|
+
const store = createChatStore({ dir, tenantId: TENANT, config, env, log: { info() {}, warn() {}, log() {} } });
|
|
122
|
+
return { dir, store, stop: () => { store.close(); fs.rmSync(dir, { recursive: true, force: true }); } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test("disk: quoted secrets in a token, a tool result and a summary are not in the .jsonl", async () => {
|
|
126
|
+
const t = mkStore();
|
|
127
|
+
try {
|
|
128
|
+
const id = crypto.randomUUID();
|
|
129
|
+
const rec = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: "hi" });
|
|
130
|
+
rec.append(JSON.stringify({ type: "token", content: `password = "${PW}"` }));
|
|
131
|
+
rec.append(JSON.stringify({ type: "tool_result", content: { db_password: PW, api_key: KEY } }));
|
|
132
|
+
rec.append(JSON.stringify({ type: "tool_result", output: JSON.stringify({ db_password: PW }) }));
|
|
133
|
+
rec.append(JSON.stringify({ type: "summary", content: { answer: `password = "${PW}"`, conversationId: id } }));
|
|
134
|
+
rec.finish("done");
|
|
135
|
+
await t.store.flush();
|
|
136
|
+
const disk = fs.readFileSync(path.join(t.dir, TENANT, `${id}.jsonl`), "utf8");
|
|
137
|
+
assert.deepEqual(leaks(disk), [], "no secret on the disk");
|
|
138
|
+
assert.match(disk, /REDACTED/);
|
|
139
|
+
const r = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 0, waitMs: 0 });
|
|
140
|
+
assert.equal(r.events.length, 4);
|
|
141
|
+
assert.deepEqual(leaks(JSON.stringify(r.events)), []);
|
|
142
|
+
const s = t.store.readSession({ conversationId: id, workspaceId: "ws-1" });
|
|
143
|
+
assert.deepEqual(leaks(JSON.stringify(s)), []);
|
|
144
|
+
} finally { t.stop(); }
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("begin on a conversation that already has its end mark does not truncate it: the old file is archived", async () => {
|
|
148
|
+
const t = mkStore();
|
|
149
|
+
try {
|
|
150
|
+
const id = crypto.randomUUID();
|
|
151
|
+
const a = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: "first run" });
|
|
152
|
+
a.append(JSON.stringify({ type: "token", content: "FIRST-RUN-EVENT" }));
|
|
153
|
+
a.finish("done");
|
|
154
|
+
await t.store.flush();
|
|
155
|
+
const file = path.join(t.dir, TENANT, `${id}.jsonl`);
|
|
156
|
+
const before = fs.readFileSync(file, "utf8");
|
|
157
|
+
assert.match(before, /"k":"end"/);
|
|
158
|
+
const b = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: "second run" });
|
|
159
|
+
b.append(JSON.stringify({ type: "token", content: "SECOND-RUN-EVENT" }));
|
|
160
|
+
b.finish("done");
|
|
161
|
+
await t.store.flush();
|
|
162
|
+
const now = fs.readFileSync(file, "utf8");
|
|
163
|
+
assert.ok(!now.includes("FIRST-RUN-EVENT") && now.includes("SECOND-RUN-EVENT"), "the live file is the new run's");
|
|
164
|
+
const archived = fs.readdirSync(path.join(t.dir, TENANT)).filter((n) => n.startsWith(`${id}.`) && n !== `${id}.jsonl`);
|
|
165
|
+
assert.equal(archived.length, 1, "the finished conversation was kept, not truncated");
|
|
166
|
+
assert.equal(fs.readFileSync(path.join(t.dir, TENANT, archived[0]), "utf8"), before);
|
|
167
|
+
assert.equal((fs.statSync(path.join(t.dir, TENANT, archived[0])).mode & 0o777), 0o600);
|
|
168
|
+
} finally { t.stop(); }
|
|
169
|
+
});
|