deepdebug-local-agent 1.6.0 → 1.7.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.
Files changed (33) hide show
  1. package/docs/la-protocol-v2.md +33 -9
  2. package/env.example +3 -1
  3. package/package.json +1 -1
  4. package/src/la-protocol-v2/content-handlers/config.js +3 -1
  5. package/src/la-protocol-v2/content-handlers/content-index.js +54 -19
  6. package/src/la-protocol-v2/content-handlers/egress.js +22 -0
  7. package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
  8. package/src/la-protocol-v2/content-handlers/git-diff.js +43 -8
  9. package/src/la-protocol-v2/content-handlers/handler.js +28 -4
  10. package/src/la-protocol-v2/content-handlers/install.js +24 -19
  11. package/src/la-protocol-v2/content-handlers/path-guard.js +74 -5
  12. package/src/la-protocol-v2/content-handlers/ref-registry.js +18 -8
  13. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -8
  14. package/src/la-protocol-v2/contract/README.md +38 -0
  15. package/src/la-protocol-v2/contract/content.index.schema.json +1 -0
  16. package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
  17. package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
  18. package/src/la-protocol-v2/contract.js +9 -0
  19. package/src/la-protocol-v2/runtime.js +1 -1
  20. package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +32 -0
  21. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
  22. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
  23. package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
  24. package/test/la-protocol-v2/content-alignment.test.js +71 -10
  25. package/test/la-protocol-v2/content-deny-list.test.js +138 -0
  26. package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
  27. package/test/la-protocol-v2/content-file-list.test.js +385 -0
  28. package/test/la-protocol-v2/content-path-guard.test.js +1 -1
  29. package/test/la-protocol-v2/content-wiring.test.js +2 -2
  30. package/test/la-protocol-v2/contract.test.js +14 -4
  31. package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
  32. package/tools/interop/README.md +16 -0
  33. package/tools/interop/la-interop-list-client.mjs +93 -0
@@ -210,6 +210,8 @@ function fakeTimers() {
210
210
  return { pending, setTimeout: (fn, ms) => { const t = { fn, ms, off: false }; pending.push(t); return t; }, clearTimeout: (t) => { if (t) t.off = true; },
211
211
  async fire() { const due = pending.splice(0).filter((t) => !t.off); for (const t of due) await t.fn(); } };
212
212
  }
213
+ /** flush() would send a batch that is waiting for its backoff (it is the "drain now" of tests/shutdown); after a timer fires we only let the microtasks settle. */
214
+ const settle = () => new Promise((res) => setImmediate(res));
213
215
  function indexerRig({ maxIds = 2048, ttl = 900_000 } = {}) {
214
216
  const session = fakeSession();
215
217
  const timers = fakeTimers();
@@ -234,11 +236,11 @@ test("indexer: rate_limited -> nothing stored, the ids come back after 30 s, the
234
236
  assert.deepEqual(r.session.sent.map((s) => s.code), ["rate_limited"]);
235
237
  assert.equal(r.indexer.announcedCount, 0);
236
238
  assert.equal(r.timers.pending.at(-1).ms, 30_000);
237
- await r.timers.fire(); // announce() -> schedules the batch (0 ms)
238
- await r.timers.fire(); // the batch: rejected again
239
- await r.indexer.flush();
239
+ r.advance(30_000);
240
+ await r.timers.fire(); await settle(); // F3-07c: the retry sends the failed batch itself (it never left the queue): rejected again
240
241
  assert.equal(r.timers.pending.at(-1).ms, 60_000);
241
- await r.timers.fire(); await r.timers.fire(); await r.indexer.flush();
242
+ r.advance(60_000);
243
+ await r.timers.fire(); await settle();
242
244
  assert.equal(r.session.sent.at(-1).code, null, "accepted at last");
243
245
  assert.deepEqual(r.session.sent.at(-1).payload.entries.map((e) => e.ref), [id]);
244
246
  assert.equal(r.indexer.announcedCount, 1);
@@ -246,19 +248,41 @@ test("indexer: rate_limited -> nothing stored, the ids come back after 30 s, the
246
248
  r.indexer.close();
247
249
  });
248
250
 
251
+ test("indexer: after rate_limited, ids minted inside the window do NOT restart the send; they go out together when the window ends", async () => {
252
+ const r = indexerRig();
253
+ r.session.script = ["rate_limited"];
254
+ const a = r.mint("a.txt");
255
+ await r.indexer.flush();
256
+ assert.equal(r.session.sent.length, 1, "the rate-limited send");
257
+ // a listing mints many ids right now: the batch timer fires, but the window (30 s) has not ended
258
+ const more = [r.mint("b.txt"), r.mint("c.txt")];
259
+ r.advance(5_000);
260
+ await r.timers.fire(); await settle();
261
+ await r.timers.fire(); await settle();
262
+ assert.equal(r.session.sent.length, 1, "nothing was sent inside the backoff window");
263
+ assert.ok(r.timers.pending.some((t) => !t.off), "a timer is armed for the end of the window");
264
+ r.advance(30_000);
265
+ await r.timers.fire(); await settle();
266
+ const last = r.session.sent.at(-1);
267
+ assert.equal(last.code, null, "accepted after the window");
268
+ assert.deepEqual(last.payload.entries.map((e) => e.ref).sort(), [a, ...more].sort());
269
+ r.indexer.close();
270
+ });
271
+
249
272
  test("indexer: protocol_unsupported stops for the connection (no timers, adds are dropped); the next non-resumed ready starts again", async () => {
250
273
  const r = indexerRig();
251
274
  r.session.script = ["protocol_unsupported"];
252
275
  r.mint();
253
276
  await r.indexer.flush();
254
277
  assert.equal(r.timers.pending.filter((t) => !t.off).length, 0, "no retry timer");
278
+ assert.equal(r.indexer.pending, 0, "F3-07c: a gateway that keeps no index: the queue is dropped, not kept for nothing");
255
279
  const before = r.session.sent.length;
256
280
  r.mint("g.txt");
257
281
  await r.indexer.flush();
258
282
  assert.equal(r.session.sent.length, before);
259
283
  assert.equal(r.indexer.stats.dropped >= 1, true);
260
284
  r.session.emit("ready", { resumed: false });
261
- await r.indexer.flush(); await r.timers.fire(); await r.indexer.flush();
285
+ await r.indexer.flush(); await r.timers.fire(); await settle();
262
286
  assert.equal(r.session.sent.at(-1).code, null);
263
287
  assert.equal(r.session.sent.at(-1).payload.entries.length, 2, "both live ids come back: the one that was refused and the one dropped while stopped (it is in the registry)");
264
288
  r.indexer.close();
@@ -270,14 +294,51 @@ test("indexer: internal and timeout are retried after 5 s; other failures wait f
270
294
  r.mint();
271
295
  await r.indexer.flush();
272
296
  assert.equal(r.timers.pending.at(-1).ms, 5_000);
273
- await r.timers.fire(); await r.timers.fire(); await r.indexer.flush();
297
+ await r.timers.fire(); await settle();
274
298
  assert.equal(r.timers.pending.at(-1).ms, 5_000);
275
- await r.timers.fire(); await r.timers.fire(); await r.indexer.flush();
299
+ console.log("DBG", JSON.stringify(r.session.sent.map((x)=>x.code)), r.timers.pending.map((t)=>[t.ms,t.off]));
300
+ await r.timers.fire(); await settle();
276
301
  assert.equal(r.session.sent.at(-1).code, "agent_offline");
277
302
  assert.equal(r.timers.pending.filter((t) => !t.off).length, 0, "agent_offline: no timer, the next ready announces again");
278
303
  r.session.emit("ready", { resumed: false });
279
- await r.indexer.flush(); await r.timers.fire(); await r.indexer.flush();
304
+ await r.indexer.flush(); await r.timers.fire(); await settle();
305
+ assert.equal(r.session.sent.at(-1).code, null);
306
+ r.indexer.close();
307
+ });
308
+
309
+ test("indexer (F3-07c): a failed frame puts back ONLY its own batch: other workspaces' batches survive, nothing is duplicated, order is kept", async () => {
310
+ const r = indexerRig();
311
+ r.session.script = ["rate_limited"];
312
+ const a = r.mint("a.txt", "ws-1"); const b = r.mint("b.txt", "ws-2"); const c = r.mint("c.txt", "ws-3");
313
+ await r.indexer.flush();
314
+ assert.deepEqual(r.session.sent.map((x) => [x.workspaceId, x.code]), [["ws-1", "rate_limited"]]);
315
+ assert.equal(r.indexer.pending, 3, "the failed batch and the two batches of the other workspaces are all still queued (it used to be 0)");
316
+ assert.equal(r.indexer.announce(), 0, "announce() does not queue what is already waiting");
317
+ assert.equal(r.indexer.pending, 3);
318
+ r.advance(30_000); // the backoff window of the rate_limited answer ends before the retry timer fires
319
+ await r.timers.fire(); await settle();
320
+ assert.deepEqual(r.session.sent.slice(1).map((x) => [x.workspaceId, x.code, x.payload.entries.map((e) => e.ref)]), [["ws-1", null, [a]], ["ws-2", null, [b]], ["ws-3", null, [c]]]);
321
+ assert.equal(r.indexer.announcedCount, 3);
322
+ assert.equal(r.indexer.pending, 0);
323
+ assert.ok(r.logs.every((l) => ![a, b, c].some((id) => l.includes(id))), "no id in a log line");
324
+ r.indexer.close();
325
+ });
326
+
327
+ test("indexer (F3-07c): ids added while a frame is in flight are not lost when it fails; a batch that keeps failing is dropped after 5 tries and the next ones still go", async () => {
328
+ const r = indexerRig();
329
+ r.session.script = ["internal", "internal", "internal", "internal", "internal"];
330
+ const a = r.mint("a.txt", "ws-1");
331
+ await r.indexer.flush(); // 1st try fails
332
+ const b = r.mint("b.txt", "ws-1"); // queued behind the failed one, same workspace
333
+ for (let i = 0; i < 4; i++) { await r.timers.fire(); await settle(); }
334
+ assert.equal(r.session.sent.filter((x) => x.code === "internal").length, 5);
335
+ assert.equal(r.indexer.stats.dropped >= 1, true, "5th failure: the batch is given up and counted");
336
+ assert.equal(r.indexer.pending, 0);
337
+ assert.ok(a && b);
338
+ const c = r.mint("c.txt", "ws-1");
339
+ await r.indexer.flush();
280
340
  assert.equal(r.session.sent.at(-1).code, null);
341
+ assert.deepEqual(r.session.sent.at(-1).payload.entries.map((e) => e.ref), [c], "a later id is announced normally");
281
342
  r.indexer.close();
282
343
  });
283
344
 
@@ -285,13 +346,13 @@ test("indexer: not ready -> nothing is sent; ready -> every live id goes out; ex
285
346
  const r = indexerRig({ ttl: 60_000 });
286
347
  r.session.state = "await_ready";
287
348
  const a = r.mint("a.txt"); const b = r.mint("b.txt");
288
- await r.timers.fire(); await r.indexer.flush();
349
+ await r.timers.fire(); await settle();
289
350
  assert.equal(r.session.sent.length, 0);
290
351
  r.advance(61_000);
291
352
  const c = r.mint("c.txt"); // a and b expired in the meantime
292
353
  r.session.state = "ready";
293
354
  r.session.emit("ready", { resumed: false });
294
- await r.indexer.flush(); await r.timers.fire(); await r.indexer.flush();
355
+ await r.indexer.flush(); await r.timers.fire(); await settle();
295
356
  const refs = r.session.sent.flatMap((s) => s.payload.entries.map((e) => e.ref));
296
357
  assert.deepEqual(refs, [c]);
297
358
  assert.equal(refs.includes(a) || refs.includes(b), false);
@@ -0,0 +1,138 @@
1
+ // F3-07c: the narrowed deny list (Edu, 2026-11-03): block the files that ARE credentials, serve the code that merely talks about them.
2
+ // `credentials*` used to block credentials.service.ts; now a name that mentions credential/kubeconfig is served only when its last
3
+ // extension is source code. The DLP (enforce, mandatory) stays the second layer for whatever is served.
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { isSensitivePath, resolveInside, CODE_EXTENSIONS } from "../../src/la-protocol-v2/content-handlers/path-guard.js";
9
+ import { ContentRefRegistry } from "../../src/la-protocol-v2/content-handlers/ref-registry.js";
10
+ import { setup, write, tmpDir } from "./helpers/content-rig.js";
11
+
12
+ // [path, sensitive?] Every row is one name the guard must classify; "dir" rows are listing entries that are folders.
13
+ const FILES = [
14
+ // ---- served: code and docs whose NAME mentions a credential (the reason for the change)
15
+ ["credentials.service.ts", false], ["src/auth/credentials.service.ts", false], ["CredentialsController.java", false],
16
+ ["src/main/java/io/x/CredentialsController.java", false], ["credentials_test.py", false], ["tests/test_credentials.py", false],
17
+ ["credentials.js", false], ["credentials.mjs", false], ["credentials.tsx", false], ["credentialsHelper.go", false],
18
+ ["credentials_store.rs", false], ["CredentialProvider.kt", false], ["credentials.rb", false], ["Credentials.cs", false],
19
+ ["credentials.php", false], ["credentials.vue", false], ["credentials.sql", false], ["credentials.md", false],
20
+ ["docs/credentials.md", false], ["credentials.spec.ts", false], ["credentials.d.ts", false], ["aws-credentials-loader.ts", false],
21
+ ["credential-store.js", false], ["CREDENTIALS.SERVICE.TS", false], ["credentials.service.ts.", false], ["credentials.html", false],
22
+ // ---- served: look-alikes that are not about secrets at all
23
+ ["env.ts", false], ["env.d.ts", false], ["env.mjs", false], ["env.config.js", false], ["src/env/index.js", false], ["environment.ts", false],
24
+ ["src/environment.js", false], ["kubeconfig.go", false], ["KubeconfigLoader.java", false], ["kubeconfig_loader.py", false],
25
+ ["secrets.service.ts", false], ["SecretsManager.java", false], ["README.md", false], ["package.json", false], ["src/keyboard.js", false],
26
+ ["docs/pem-format.md", false], ["tsconfig.json", false], ["src/secretary.js", false], [".gitignore", false], [".github/workflows/ci.yml", false],
27
+ ["src/credentials/index.ts", false], ["src/credentials/CredentialsService.java", false], ["env.ts.snap.ts", false], [".environment.ts", false],
28
+ // ---- blocked: files that ARE credentials (Edu's list)
29
+ ["credentials", true], [".credentials", true], ["credentials.json", true], ["credentials.yml", true], ["credentials.yaml", true],
30
+ ["credentials.xml", true], ["credentials.toml", true], ["credentials.ini", true], ["credentials.cfg", true], ["credentials.conf", true],
31
+ ["credentials.csv", true], ["credentials.txt", true], ["credentials.properties", true], ["credentials.db", true], ["credentials.sqlite", true],
32
+ ["credentials.enc", true], ["credentials.pem", true], ["credentials.p12", true], ["credentials.pfx", true], ["credentials.kdbx", true],
33
+ ["credentials.tfvars", true], ["credentials.env", true], ["application_default_credentials.json", true], ["x.credentials", true],
34
+ ["aws/credentials", true], [".aws/credentials", true], ["gcloud/credentials.db", true], [".config/gcloud/credentials.db", true],
35
+ ["CREDENTIALS.JSON", true], ["Credentials.Yml", true], ["deep/er/credentials.json", true], ["gcp-credentials.json", true],
36
+ ["credentials.prod.json", true], ["credentials.json.bak", true], ["credentials.bak", true], ["credentials~", true], ["credentials-prod", true],
37
+ ["credential.json", true], ["credentials.sh", true], ["credentials.service.json", true], ["credentials.json.", true],
38
+ ["credentials/prod.json", true], ["src/credentials/README", true], ["src/credentials/key.txt", true], [".credentials/token", true],
39
+ // ---- blocked: env files and their variants (the gaps the reviewer found)
40
+ [".env", true], [".env.local", true], [".env.production", true], [".env.production.local", true], [".envrc", true], [".envrc.local", true],
41
+ [".env_backup", true], [".env-prod", true], [".env.local~", true], [".env~", true], [".env#", true], ["env.production", true], ["env.local", true],
42
+ ["env_prod", true], ["env-staging", true], ["env", true], ["prod.env", true], ["app/.ENV", true], [".env2", true], [".envs/.production/.postgres", true],
43
+ // ---- blocked: kube, secrets, keys, npm/pip/net, docker, git, ssh
44
+ ["kubeconfig", true], ["kubeconfig-prod", true], ["kubeconfig_dev", true], ["kubeconfig.yaml", true], ["prod.kubeconfig", true], [".kube/config", true],
45
+ ["secrets/api.txt", true], ["config/secrets/prod.yml", true], ["secrets/manager.ts", true], [".secrets/x", true], ["secrets.json", true], ["secrets.yaml", true],
46
+ ["id_rsa", true], ["id_rsa.pub", true], ["id_rsa_github", true], ["id_ed25519", true], ["id_ed25519.pub", true], ["id_ed25519_work", true], ["id_ecdsa", true],
47
+ ["server.pem", true], ["private.key", true], ["release.keystore", true], ["debug.keystore", true], ["app.jks", true], ["cert.p12", true], ["vault.kdbx", true],
48
+ [".npmrc", true], ["pkg/.npmrc", true], [".pypirc", true], [".netrc", true], ["_netrc", true], [".pgpass", true], [".git-credentials", true],
49
+ [".docker/config.json", true], [".ssh/known_hosts", true], [".git/config", true], [".aws/config", true], [".gnupg/pubring.kbx", true],
50
+ ["terraform.tfstate", true], ["prod.tfvars", true], ["service-account-prod.json", true], [".htpasswd", true],
51
+ // ---- blocked: Windows aliases and streams of the above
52
+ ["CREDEN~1.JSO", true], ["CREDEN~1", true], ["ENV~1", true], ["KUBECO~1", true], [".env::$DATA", true], ["credentials.json:Zone.Identifier", true],
53
+ // ---- F3-07c review: credential files that no earlier rule named (blocked) and their look-alikes (served)
54
+ [".dockercfg", true], [".yarnrc", true], [".yarnrc.yml", true], [".pnpmrc", true], [".s3cfg", true], [".boto", true], [".terraformrc", true],
55
+ [".Renviron", true], ["auth.json", true], ["sub/auth.json", true], ["client_secret_123.json", true], ["firebase-adminsdk-abc.json", true],
56
+ ["gha-creds-1234.json", true], ["local.settings.json", true], ["wp-config.php", true], ["application-prod.properties", true],
57
+ ["application-prod.yml", true], ["application-prod.yaml", true], ["deploy_rsa", true], ["host_ed25519", true], ["ssh_host_rsa_key", true],
58
+ [".m2/settings.xml", true], [".m2/settings-security.xml", true], [".envrc/a.txt", true], [".env_backup/a.txt", true], [".env-prod/a.txt", true],
59
+ ["env.production/a.txt", true], ["kubeconfig-prod/a.txt", true], ["ENVRC~1", true], ["ENVS~1/x", true],
60
+ ["settings.xml", false], ["pom.xml", false], ["src/main/resources/application.yml", false], ["application-dev.yml", false],
61
+ ["application-test.properties", false], ["auth.ts", false], ["author.json", false], ["auth.service.ts", false], ["wp-config-sample.php", false],
62
+ ["rsa.ts", false], ["key_ed25519.ts", false], ["src/m2/settings.xml", false],
63
+ ];
64
+
65
+ test("deny list table (F3-07c): >= 80 names, served and blocked, each one on its own", () => {
66
+ assert.ok(FILES.length >= 80, `${FILES.length} rows`);
67
+ assert.ok(FILES.filter(([, s]) => !s).length >= 30 && FILES.filter(([, s]) => s).length >= 50, "both sides are well covered");
68
+ const wrong = FILES.filter(([p, s]) => isSensitivePath(p) !== s).map(([p, s]) => `${p} expected ${s ? "blocked" : "served"}`);
69
+ assert.deepEqual(wrong, []);
70
+ });
71
+
72
+ test("deny list: a folder entry (dir) is judged as a folder: src/credentials is a folder of code, .credentials and secrets are not", () => {
73
+ for (const [p, sens] of [["credentials", false], ["src/credentials", false], ["Credentials", false], ["credential", false], ["kubeconfig", false], ["env", false],
74
+ [".credentials", true], ["secrets", true], [".secrets", true], [".git", true], [".ssh", true], [".aws", true], [".envs", true], [".env", true], ["x/.env", true],
75
+ ["node_modules", false], ["src", false], ["docs", false], ["environment", false]]) {
76
+ assert.equal(isSensitivePath(p, [], { dir: true }), sens, `dir ${p}`);
77
+ }
78
+ // the same name as a FILE stays blocked: the guard never serves a file called `credentials`
79
+ assert.equal(isSensitivePath("src/credentials"), true);
80
+ });
81
+
82
+ test("deny list: code extensions are an allow list; data formats, scripts and backups never are", () => {
83
+ for (const e of ["ts", "java", "py", "go", "js", "md", "sql", "kt", "rb", "cs", "rs", "php", "vue", "svelte"]) assert.ok(CODE_EXTENSIONS.has(e), e);
84
+ for (const e of ["json", "yml", "yaml", "xml", "toml", "ini", "cfg", "conf", "csv", "txt", "properties", "db", "sqlite", "enc", "pem", "p12", "pfx", "kdbx", "tfvars", "env",
85
+ "sh", "bash", "ps1", "bat", "gradle", "bak", "old", "orig", "tmp", "swp", "map", "snap", "j2", "log", ""]) assert.equal(CODE_EXTENSIONS.has(e), false, e);
86
+ for (const e of ["json", "yml", "toml", "txt", "sh", "bak"]) assert.equal(isSensitivePath(`credentials.${e}`), true, e);
87
+ for (const e of ["ts", "java", "py", "go", "md"]) assert.equal(isSensitivePath(`credentials.${e}`), false, e);
88
+ });
89
+
90
+ test("deny list: LOCAL_AGENT_CONTENT_DENY_GLOBS still adds to it (credentials.service.ts can be put back by an operator)", () => {
91
+ assert.equal(isSensitivePath("credentials.service.ts", ["credentials*"]), true);
92
+ assert.equal(isSensitivePath("credentials.service.ts", []), false);
93
+ assert.equal(isSensitivePath("src/x.ts", ["src/"]), true);
94
+ });
95
+
96
+ test("deny list: the registry never mints an id for a blocked name, and mints for credentials.service.ts; a folder id for src/credentials", () => {
97
+ const reg = new ContentRefRegistry({ tenantId: "t1" });
98
+ const q = { workspaceId: "w", root: "/r" };
99
+ assert.ok(reg.mint({ ...q, kind: "file", rel: "src/credentials.service.ts" }));
100
+ for (const rel of ["credentials.json", ".envrc", "env.production", "secrets/x.ts", "credentials"]) assert.equal(reg.mint({ ...q, kind: "file", rel }), null, rel);
101
+ assert.ok(reg.mint({ ...q, kind: "file.list", rel: "src/credentials" }), "a folder named credentials may be listed");
102
+ assert.equal(reg.mint({ ...q, kind: "file.list", rel: "secrets" }), null);
103
+ assert.equal(reg.mint({ ...q, kind: "file.list", rel: ".credentials" }), null);
104
+ assert.ok(reg.mint({ ...q, kind: "file.list", rel: null }), "the root");
105
+ });
106
+
107
+ test("deny list at read time: credentials.service.ts is served (masked by the DLP), credentials.json and a link to it are not", async () => {
108
+ const rig = await setup({ dlp: "enforce" });
109
+ try {
110
+ const secret = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
111
+ write(rig.root(), "src/credentials.service.ts", `export const token = "${secret}";\nexport const ok = 1;\n`);
112
+ write(rig.root(), "credentials.json", `{"token":"${secret}"}`);
113
+ const good = rig.mint("file", "src/credentials.service.ts");
114
+ assert.ok(good, "an id exists for the code file");
115
+ const r = await rig.request({ ref: good });
116
+ assert.equal(r.status, "ok");
117
+ assert.equal(r.text.includes(secret), false, "the DLP is still the second layer");
118
+ assert.match(r.text, /export const ok = 1/);
119
+ assert.equal(rig.mint("file", "credentials.json"), null);
120
+ // swapped in after the id was minted: a harmless name that now links to credentials.json
121
+ write(rig.root(), "later.ts", "x");
122
+ const id = rig.mint("file", "later.ts");
123
+ fs.rmSync(path.join(rig.root(), "later.ts"));
124
+ fs.symlinkSync(path.join(rig.root(), "credentials.json"), path.join(rig.root(), "later.ts"));
125
+ assert.equal((await rig.request({ ref: id })).status, "forbidden");
126
+ assert.deepEqual(await resolveInside(rig.root(), "credentials.json"), { ok: false, status: "forbidden" });
127
+ assert.equal((await resolveInside(rig.root(), "src/credentials.service.ts")).ok, true);
128
+ } finally { await rig.stop(); }
129
+ });
130
+
131
+ test("deny list: nothing about the new rules is a regex over the whole path (no catastrophic backtracking on a long name)", () => {
132
+ const long = "credentials".repeat(5000) + ".json";
133
+ const t = Date.now();
134
+ assert.equal(isSensitivePath(long.slice(0, 1000)), true);
135
+ assert.equal(isSensitivePath("a/".repeat(500) + "x.ts"), false);
136
+ assert.ok(Date.now() - t < 500);
137
+ void tmpDir;
138
+ });
@@ -0,0 +1,83 @@
1
+ // F3-07c: `git diff` of a tracked symlink prints the TARGET of the link ("new file mode 120000", "+/abs/path"): an absolute path of this
2
+ // machine. Entries whose mode is 120000 on either side no longer reach the diff. Real repositories, real links.
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { setup, write, git, initRepo, tmpDir } from "./helpers/content-rig.js";
8
+ import { rawPaths, dropSymlinkBlocks, worktreeDiff } from "../../src/la-protocol-v2/content-handlers/git-diff.js";
9
+
10
+ async function withRig(opts, body) {
11
+ const rig = await setup(opts);
12
+ try { await body(rig); } finally { await rig.stop(); }
13
+ }
14
+
15
+ const OUTSIDE = "/very/private/absolute/target/of-the-link";
16
+
17
+ test("diff: a tracked symlink that is retargeted, deleted, replaced by a file or created by replacing a file never prints its target; regular files still diff", async () => {
18
+ await withRig({}, async (rig) => {
19
+ const root = rig.root();
20
+ write(root, "regular.txt", "one\n");
21
+ write(root, "was-file.txt", "content\n");
22
+ write(root, "was-link-target.txt", "t\n");
23
+ fs.symlinkSync("/old/absolute/target-a", path.join(root, "link-retarget"));
24
+ fs.symlinkSync("/old/absolute/target-b", path.join(root, "link-deleted"));
25
+ fs.symlinkSync("/old/absolute/target-c", path.join(root, "link-becomes-file"));
26
+ initRepo(root);
27
+ // work tree changes
28
+ write(root, "regular.txt", "two\n");
29
+ fs.rmSync(path.join(root, "link-retarget")); fs.symlinkSync(OUTSIDE, path.join(root, "link-retarget"));
30
+ fs.rmSync(path.join(root, "link-deleted"));
31
+ fs.rmSync(path.join(root, "link-becomes-file")); write(root, "link-becomes-file", "now a file\n");
32
+ fs.rmSync(path.join(root, "was-file.txt")); fs.symlinkSync(OUTSIDE + "-2", path.join(root, "was-file.txt"));
33
+ // sanity: plain git DOES print the targets (this is what the guard hides)
34
+ const raw = git(root, "diff", "HEAD");
35
+ assert.match(raw, /new file mode 120000|deleted file mode 120000|120000/);
36
+ assert.ok(raw.includes(OUTSIDE) || raw.includes("/old/absolute/target"), "plain git shows the target");
37
+ const d = await rig.request({ kind: "diff", ref: "worktree" });
38
+ assert.equal(d.status, "ok");
39
+ assert.match(d.text, /regular\.txt/);
40
+ assert.match(d.text, /\+two/);
41
+ for (const leak of ["120000", OUTSIDE, "/old/absolute", "link-retarget", "link-deleted", "link-becomes-file", "was-file.txt"]) assert.equal(d.text.includes(leak), false, `leaked ${leak}`);
42
+ // an id for one path that is a link: empty diff, not the target
43
+ const one = rig.mint("diff", "link-retarget");
44
+ const o = await rig.request({ kind: "diff", ref: one });
45
+ assert.deepEqual([o.status, o.text], ["ok", ""]);
46
+ });
47
+ });
48
+
49
+ test("diff: a file turned into a link AFTER the file list was taken is still not printed (lstat net); the text net removes any block with mode 120000", async () => {
50
+ const root = tmpDir();
51
+ write(root, "a.txt", "1\n"); write(root, "b.txt", "1\n");
52
+ initRepo(root);
53
+ write(root, "a.txt", "2\n"); write(root, "b.txt", "2\n");
54
+ fs.rmSync(path.join(root, "b.txt")); fs.symlinkSync(OUTSIDE, path.join(root, "b.txt"));
55
+ const r = await worktreeDiff({ root, maxBytes: 1 << 20, maxFiles: 100, timeoutMs: 8000 });
56
+ assert.equal(r.ok, true);
57
+ assert.match(r.text, /a\.txt/);
58
+ assert.equal(r.text.includes("b.txt"), false);
59
+ assert.equal(r.text.includes(OUTSIDE), false);
60
+ fs.rmSync(root, { recursive: true, force: true });
61
+
62
+ const fake = [
63
+ "diff --git a/l b/l\nnew file mode 120000\nindex 0000000..e69de29\n--- /dev/null\n+++ b/l\n@@ -0,0 +1 @@\n+/etc/hostname\n\\n",
64
+ "diff --git a/m b/m\ndeleted file mode 120000\nindex e69de29..0000000\n--- a/m\n+++ /dev/null\n@@ -1 +0,0 @@\n-/etc/hosts\n",
65
+ "diff --git a/n b/n\nold mode 100644\nnew mode 120000\n",
66
+ "diff --git a/o b/o\nindex 1111111..2222222 120000\n--- a/o\n+++ b/o\n@@ -1 +1 @@\n-/a\n+/b\n",
67
+ "diff --git a/ok b/ok\nindex 1111111..2222222 100644\n--- a/ok\n+++ b/ok\n@@ -1 +1 @@\n-mode 120000 is just text here\n+new file mode 120000 neither\n",
68
+ ];
69
+ const out = dropSymlinkBlocks(fake.join(""));
70
+ assert.equal(out, fake[4], "only the regular file's block survives, including a body that talks about 120000");
71
+ assert.equal(dropSymlinkBlocks(fake[4]), fake[4]);
72
+ assert.equal(dropSymlinkBlocks(""), "");
73
+ });
74
+
75
+ test("diff: git diff --raw -z parsing: modes on either side, paths with spaces/newlines/unicode, and no path is ever split", () => {
76
+ const out = ":100644 100644 aaa bbb M\0src/a b.txt\0:100644 120000 aaa 000 T\0link\0:120000 000000 aaa 000 D\0gone\0:000000 100644 000 bbb A\0não\nsplit.txt\0:100755 100755 aaa bbb M\0x\0";
77
+ assert.deepEqual(rawPaths(out), [
78
+ { path: "src/a b.txt", symlink: false }, { path: "link", symlink: true }, { path: "gone", symlink: true },
79
+ { path: "não\nsplit.txt", symlink: false }, { path: "x", symlink: false },
80
+ ]);
81
+ assert.deepEqual(rawPaths(""), []);
82
+ assert.deepEqual(rawPaths(":100644 100644 a b M"), [], "a truncated entry gives nothing");
83
+ });