deepdebug-local-agent 1.5.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 (44) hide show
  1. package/docs/la-protocol-v2.md +70 -16
  2. package/env.example +6 -0
  3. package/package.json +1 -1
  4. package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
  5. package/src/la-protocol-v2/content-handlers/config.js +10 -2
  6. package/src/la-protocol-v2/content-handlers/content-index.js +196 -0
  7. package/src/la-protocol-v2/content-handlers/egress.js +22 -0
  8. package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
  9. package/src/la-protocol-v2/content-handlers/git-diff.js +95 -10
  10. package/src/la-protocol-v2/content-handlers/handler.js +52 -16
  11. package/src/la-protocol-v2/content-handlers/install.js +35 -15
  12. package/src/la-protocol-v2/content-handlers/path-guard.js +120 -10
  13. package/src/la-protocol-v2/content-handlers/ref-registry.js +41 -13
  14. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
  15. package/src/la-protocol-v2/contract/README.md +87 -2
  16. package/src/la-protocol-v2/contract/content.index.schema.json +93 -0
  17. package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
  18. package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
  19. package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
  20. package/src/la-protocol-v2/contract/index.json +7 -0
  21. package/src/la-protocol-v2/contract.js +9 -0
  22. package/src/la-protocol-v2/runtime.js +12 -2
  23. package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3809 -0
  24. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
  25. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
  26. package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
  27. package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
  28. package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
  29. package/test/la-protocol-v2/content-alignment.test.js +669 -0
  30. package/test/la-protocol-v2/content-deny-list.test.js +138 -0
  31. package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
  32. package/test/la-protocol-v2/content-file-list.test.js +385 -0
  33. package/test/la-protocol-v2/content-handlers.test.js +55 -61
  34. package/test/la-protocol-v2/content-path-guard.test.js +10 -5
  35. package/test/la-protocol-v2/content-wiring.test.js +39 -14
  36. package/test/la-protocol-v2/contract.test.js +15 -5
  37. package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
  38. package/test/la-protocol-v2/traces.test.js +29 -4
  39. package/tools/interop/LaV2NodeContentInteropTest.java +18 -4
  40. package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
  41. package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
  42. package/tools/interop/README.md +16 -0
  43. package/tools/interop/la-interop-content-client.mjs +19 -8
  44. package/tools/interop/la-interop-list-client.mjs +93 -0
@@ -2,16 +2,34 @@
2
2
  * git-diff.js - `diff` kind: the work tree against HEAD, for the files that may be shown (F3-07).
3
3
  *
4
4
  * No shell. git runs with the agent's env minus secret-looking variables and minus every GIT_* variable (a stray
5
- * GIT_DIR must not point it at another repository), without external diff drivers, textconv filters or fsmonitor
6
- * hooks (a malicious repo config could name a command for each), with literal pathspecs. The file list is taken
7
- * first and filtered (sensitive and out-of-root names never reach the second call), then diffed with that explicit list
8
- * (at most `maxFiles` names and 128 KiB of arguments). Output is capped while it streams (the process is killed above the cap).
5
+ * GIT_DIR must not point it at another repository), without external diff drivers, textconv or fsmonitor hooks
6
+ * (a malicious repo config could name a command for each), with literal pathspecs. The file list is taken
7
+ * first and filtered (sensitive names, and hard-linked files unless allowed, never reach the second call), then diffed
8
+ * with that explicit list (at most `maxFiles` names and 128 KiB of arguments). Output is capped while it streams (the
9
+ * process is killed above the cap).
10
+ *
11
+ * Clean/smudge filters (F3-07b). `--no-ext-diff` and `--no-textconv` do NOT stop `git diff` from running the
12
+ * `filter.<name>.clean` command (and `process`, the long-running filter) of the repository config: to compare a work tree file with
13
+ * the index git first converts it with the clean filter its .gitattributes names, and that is a command line chosen
14
+ * by whoever wrote the repository. Before the two diff calls the filters the config defines are listed (`git config
15
+ * --get-regexp '^filter\.'`, which runs nothing) and every one of them is overridden on the command line with an empty
16
+ * clean, smudge and process and `required=false` (git treats an empty command as "no filter"; a still-required one
17
+ * would abort). Fail closed: if the list cannot be read, or has more than MAX_FILTERS names, there is no diff.
18
+ * Consequence: a work tree that depends on a filter (git-lfs, keyword expansion) is diffed as stored, unfiltered.
19
+ *
20
+ * Symlinks (F3-07c). The hunk of a tracked symlink ("new file mode 120000", "+/abs/target") prints the TARGET of the link, i.e. an
21
+ * absolute path on this machine. Entries whose mode is 120000 on either side (git diff --raw: a link added, changed, removed, or a file
22
+ * replaced by a link and the other way round) never reach the second call; the names are also lstat-ed (a file turned into a link in
23
+ * between) and, as a last net, `dropSymlinkBlocks()` removes from the text any per-file block whose header says 120000.
9
24
  */
10
25
  import { spawn } from "node:child_process";
11
26
  import fs from "node:fs";
12
27
  import { sanitizedChildEnv } from "../../utils/child-env.js";
28
+ import path from "node:path";
13
29
  import { isSensitivePath, normalizeRel } from "./path-guard.js";
14
30
 
31
+ const MAX_FILTERS = 64;
32
+
15
33
  const GIT_SAFE = ["--literal-pathspecs", "-c", "core.quotepath=off", "-c", "core.fsmonitor=false", "-c", "core.pager=cat", "-c", "diff.external="];
16
34
 
17
35
  function gitEnv() {
@@ -53,11 +71,36 @@ function runGit(args, { cwd, input, maxBytes, timeoutMs, signal }) {
53
71
  });
54
72
  }
55
73
 
74
+ /**
75
+ * `-c` overrides that switch off every filter the repository (or the user's global config) defines.
76
+ * @returns {Promise<{ok:true, args:string[]} | {ok:false, status:"error", errorCode:string}>}
77
+ */
78
+ export async function filterOverrides({ cwd, timeoutMs, signal }) {
79
+ const r = await runGit([...GIT_SAFE, "config", "--null", "--get-regexp", "^filter\\."], { cwd, maxBytes: 1024 * 1024, timeoutMs, signal });
80
+ if (r.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
81
+ if (r.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
82
+ if (r.code === 1 && !r.overflow) return { ok: true, args: [] }; // exit 1 = no such key: no filter is defined
83
+ if (r.code !== 0 || r.overflow) return { ok: false, status: "error", errorCode: "internal" };
84
+ const names = new Set();
85
+ for (const entry of r.out.toString("utf8").split("\0")) {
86
+ const key = entry.split("\n", 1)[0]; // "filter.<name>.<variable>" (name may contain dots)
87
+ if (!key.toLowerCase().startsWith("filter.")) continue;
88
+ const last = key.lastIndexOf(".");
89
+ if (last <= "filter.".length) continue; // "filter.x": a variable without a name, nothing to run
90
+ names.add(key.slice("filter.".length, last));
91
+ }
92
+ if (names.size > MAX_FILTERS) return { ok: false, status: "error", errorCode: "internal" };
93
+ const args = [];
94
+ for (const n of names) for (const v of ["clean", "smudge", "process"]) args.push("-c", `filter.${n}.${v}=`);
95
+ for (const n of names) args.push("-c", `filter.${n}.required=false`);
96
+ return { ok: true, args };
97
+ }
98
+
56
99
  /**
57
100
  * @param {{root:string, rel?:string|null, maxBytes:number, maxFiles:number, timeoutMs:number, denyGlobs?:string[], signal?:AbortSignal}} o
58
101
  * @returns {Promise<{ok:true, text:string} | {ok:false, status:"not_found"|"forbidden"|"error", errorCode?:string}>}
59
102
  */
60
- export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeoutMs, denyGlobs = [], signal }) {
103
+ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeoutMs, denyGlobs = [], allowHardlinks = false, signal }) {
61
104
  let cwd;
62
105
  try { cwd = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
63
106
  let only = null;
@@ -66,7 +109,10 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
66
109
  if (!only) return { ok: false, status: "not_found" };
67
110
  if (isSensitivePath(only, denyGlobs)) return { ok: false, status: "forbidden" };
68
111
  }
69
- const listArgs = [...GIT_SAFE, "diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", "--relative", "HEAD", "--", ...(only ? [only] : ["."])];
112
+ const filters = await filterOverrides({ cwd, timeoutMs, signal });
113
+ if (!filters.ok) return filters;
114
+ const safe = [...GIT_SAFE, ...filters.args];
115
+ const listArgs = [...safe, "diff", "--raw", "-z", "--no-abbrev", "--no-renames", "--no-ext-diff", "--relative", "HEAD", "--", ...(only ? [only] : ["."])];
70
116
  const listed = await runGit(listArgs, { cwd, maxBytes: 4 * 1024 * 1024, timeoutMs, signal });
71
117
  if (listed.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
72
118
  if (listed.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
@@ -74,19 +120,58 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
74
120
  if (listed.code !== 0) return { ok: false, status: "not_found" }; // not a repository, no HEAD yet, unsafe directory...
75
121
  const names = [];
76
122
  let argBytes = 0;
77
- for (const n of listed.out.toString("utf8").split("\0")) {
78
- const norm = n ? normalizeRel(n) : null;
123
+ for (const n of rawPaths(listed.out.toString("utf8"))) {
124
+ if (n.symlink) continue; // the hunk would print the target of the link
125
+ const norm = n.path ? normalizeRel(n.path) : null;
79
126
  if (!norm || isSensitivePath(norm, denyGlobs)) continue;
127
+ if (await isLinkOnDisk(cwd, norm, allowHardlinks)) continue; // a link (or a hard link) now, whatever git remembers
80
128
  argBytes += Buffer.byteLength(norm) + 1;
81
129
  if (names.length >= maxFiles || argBytes > 128 * 1024) break;
82
130
  names.push(norm);
83
131
  }
84
132
  if (names.length === 0) return { ok: true, text: "" };
85
- const diffArgs = [...GIT_SAFE, "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--relative", "-U3", "HEAD", "--", ...names];
133
+ const diffArgs = [...safe, "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--relative", "-U3", "HEAD", "--", ...names];
86
134
  const diff = await runGit(diffArgs, { cwd, maxBytes: maxBytes + 1, timeoutMs, signal });
87
135
  if (diff.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
88
136
  if (diff.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
89
137
  if (diff.overflow) return { ok: false, status: "error", errorCode: "payload_too_large" };
90
138
  if (diff.code !== 0) return { ok: false, status: "error", errorCode: "internal" };
91
- return { ok: true, text: diff.out.toString("utf8") };
139
+ return { ok: true, text: dropSymlinkBlocks(diff.out.toString("utf8")) };
140
+ }
141
+
142
+ /**
143
+ * `git diff --raw -z` -> [{path, symlink}]: ":<oldmode> <newmode> <oldsha> <newsha> <status>\0<path>\0" per entry (no renames: one path).
144
+ * @returns {{path:string, symlink:boolean}[]}
145
+ */
146
+ export function rawPaths(out) {
147
+ const parts = out.split("\0");
148
+ const res = [];
149
+ for (let i = 0; i < parts.length; i++) {
150
+ const meta = parts[i];
151
+ if (!meta.startsWith(":")) continue;
152
+ const f = meta.slice(1).split(" ");
153
+ const p = parts[i + 1];
154
+ i++;
155
+ if (typeof p !== "string") break;
156
+ res.push({ path: p, symlink: f[0] === "120000" || f[1] === "120000" });
157
+ }
158
+ return res;
159
+ }
160
+
161
+ /** Removes the per-file blocks of a diff whose header (up to the first hunk) names mode 120000 (a symlink): they print the link target. */
162
+ export function dropSymlinkBlocks(text) {
163
+ if (!text.includes("120000")) return text;
164
+ const blocks = text.split(/^(?=diff --git )/m);
165
+ return blocks.filter((b) => {
166
+ const head = b.split(/^@@/m, 1)[0];
167
+ return !/^(?:(?:new file|deleted file|old|new) mode 120000|index [0-9a-f]+\.\.[0-9a-f]+ 120000)\s*$/m.test(head);
168
+ }).join("");
169
+ }
170
+
171
+ /** True for a symlink, or (unless allowed) a regular file with more than one link. A name that no longer exists (a deletion) is neither. */
172
+ async function isLinkOnDisk(cwd, rel, allowHardlinks) {
173
+ try {
174
+ const st = await fs.promises.lstat(path.join(cwd, ...rel.split("/")));
175
+ return st.isSymbolicLink() || (!allowHardlinks && st.isFile() && st.nlink > 1);
176
+ } catch { return false; }
92
177
  }
@@ -6,7 +6,7 @@
6
6
  * 1. accept: the handler answers the session at once ({accept:true} = ack `accepted`); the work goes on in the
7
7
  * background (the first chunk may take seconds, the ack timeout is 5 s). A request without requestId is refused.
8
8
  * 2. admission: at most `maxConcurrent` requests and `maxInFlightBytes` in memory; above it -> `rate_limited`.
9
- * 3. kind: `file` and `diff` are served; every other kind of the allowlist -> status `error` + `protocol_unsupported`
9
+ * 3. kind: `file`, `diff` and `file.list` (F3-07c: a folder listing that mints the ids of what it shows) are served; every other kind of the allowlist -> status `error` + `protocol_unsupported`
10
10
  * (the contract has no "not supported" status; this is the code already used for "no handler").
11
11
  * 4. workspace: the envelope's workspace must be registered here (and belong to this tenant) -> else `not_found`.
12
12
  * 5. ref: an opaque id (ref-registry.js) valid for this tenant+workspace+root+kind -> else `not_found`, whatever the
@@ -22,15 +22,19 @@ import { ProtocolError } from "../errors.js";
22
22
  import { sendContent, sendContentFailure } from "../content.js";
23
23
  import { REF_KINDS } from "./ref-registry.js";
24
24
  import { normalizeRel, resolveInside, readRegular } from "./path-guard.js";
25
+ import { listFolder, ROOT_REF, clampLimit, MAX_LIST_BYTES } from "./file-list.js";
25
26
  import { worktreeDiff } from "./git-diff.js";
26
- import { prepareOutgoing, contentTypeOf, decodeText } from "./egress.js";
27
+ import { prepareOutgoing, prepareList, maskName, contentTypeOf, decodeText } from "./egress.js";
27
28
  import { createAudit, short } from "./audit.js";
29
+ import { dlpConfigFromEnv } from "../../dlp/index.js";
28
30
 
29
- export const SERVED_KINDS = Object.freeze(["file", "diff"]);
31
+ export const SERVED_KINDS = Object.freeze(["file", "diff", "file.list"]);
30
32
  /** Symbolic ref of kind `diff`: the whole work tree of the envelope's workspace (carries no client data). */
31
33
  export const WORKTREE_REF = "worktree";
32
34
 
33
- export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now }) {
35
+ export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now, mint = null }) {
36
+ // F3-07c: where the ids of a listing come from. install.js passes mintRef (registry + content.index); without it (tests) the registry alone.
37
+ const mintFor = (workspaceId, root) => (o) => (mint ? mint({ workspaceId, ...o }) : registry.mint({ workspaceId, root, ...o }));
34
38
  const inflight = new Map(); // requestId -> { ac, timer }
35
39
  let reservedBytes = 0;
36
40
  const auditor = audit || createAudit({ file: config.auditFile, now });
@@ -43,8 +47,12 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
43
47
  /** @returns {Promise<{status:string, errorCode?:string, buffer?:Buffer, contentType?:string, dlp?:object}>} */
44
48
  async function produce(ctx, p, signal) {
45
49
  if (!SERVED_KINDS.includes(p.kind)) return { status: "error", errorCode: "protocol_unsupported" };
50
+ // F3-07b: content never leaves unmasked. runtime.js does not even register this handler without DLP enforce; this is the
51
+ // same rule at the moment of the request (the env is read per request), so a changed mode can not open the door.
52
+ if (dlpConfigFromEnv(env).mode !== "enforce") return { status: "error", errorCode: "protocol_unsupported" };
46
53
  const ws = await resolveWorkspace(ctx.workspaceId);
47
54
  if (!ws || !ws.root) return { status: "not_found" };
55
+ if (p.kind === "file.list") return produceList(ctx, p, ws, signal);
48
56
  const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: p.kind });
49
57
  if (p.kind === "diff" && !target && p.ref === WORKTREE_REF) return produceDiff(p, ws, null, signal);
50
58
  if (!target) return { status: "not_found" };
@@ -55,18 +63,38 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
55
63
  async function produceFile(p, ws, rel, signal) {
56
64
  const norm = rel && normalizeRel(rel);
57
65
  if (!norm) return { status: "not_found" };
58
- const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs);
66
+ const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs, { allowHardlinks: config.allowHardlinks });
59
67
  if (!resolved.ok) return { status: resolved.status };
60
68
  if (signal.aborted) return { status: "error", errorCode: "agent_offline" };
61
- const read = await readRegular(resolved, config.maxBytes, signal);
69
+ const read = await readRegular(resolved, config.maxBytes, signal, { allowHardlinks: config.allowHardlinks });
62
70
  if (!read.ok) return read.status === "too_large" ? { status: "error", errorCode: "payload_too_large" } : { status: read.status };
63
71
  const isText = decodeText(read.buffer) !== null;
64
72
  return finish(p, read.buffer, contentTypeOf(norm, isText), "content.file");
65
73
  }
66
74
 
75
+ /** F3-07c: `root` or a folder id (or the nextRef of a previous page) -> one page of names with the ids to ask for them. */
76
+ async function produceList(ctx, p, ws, signal) {
77
+ if (p.range) return { status: "error", errorCode: "protocol_unsupported" }; // a listing has no byte range
78
+ let rel = null;
79
+ let after = null;
80
+ if (p.ref !== ROOT_REF) {
81
+ const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: "file.list" });
82
+ if (!target) return { status: "not_found" };
83
+ rel = target.rel && normalizeRel(target.rel);
84
+ if (target.rel && !rel) return { status: "not_found" };
85
+ after = target.after;
86
+ }
87
+ const r = await listFolder({ root: ws.root, rel, after, limit: clampLimit(p.limit), maxBytes: config.maxBytes, maxDirEntries: config.maxDirEntries,
88
+ denyGlobs: config.extraDenyGlobs, allowHardlinks: config.allowHardlinks, signal, mint: mintFor(ctx.workspaceId, ws.root),
89
+ maskName: (n) => maskName(n, { env }) });
90
+ if (!r.ok) return r;
91
+ const out = prepareList({ buffer: r.body, maxBytes: Math.min(config.maxBytes, MAX_LIST_BYTES), dlp: { mode: "enforce", total: r.dlp.total, redactions: r.dlp.redactions } });
92
+ return out.ok ? { status: "ok", buffer: out.buffer, contentType: out.contentType, dlp: out.dlp } : { status: out.status, errorCode: out.errorCode, dlp: out.dlp };
93
+ }
94
+
67
95
  async function produceDiff(p, ws, rel, signal) {
68
96
  const d = await worktreeDiff({ root: ws.root, rel, maxBytes: config.maxBytes, maxFiles: config.maxDiffFiles,
69
- timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, signal });
97
+ timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, allowHardlinks: config.allowHardlinks, signal });
70
98
  if (!d.ok) return d;
71
99
  return finish(p, Buffer.from(d.text, "utf8"), "text/x-diff", "content.diff");
72
100
  }
@@ -79,7 +107,9 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
79
107
 
80
108
  async function run(ctx, p, entry) {
81
109
  const t0 = now();
82
- let result;
110
+ // F3-07b: always defined. If produce() throws, the gateway still gets an answer (a code, never the message of the
111
+ // exception: it may carry a path) and the request is audited like any other.
112
+ let result = { status: "error", errorCode: "internal" };
83
113
  let sent = null;
84
114
  let errorCode;
85
115
  try {
@@ -88,7 +118,11 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
88
118
  } else {
89
119
  reservedBytes += reserveEach;
90
120
  entry.reserved = true;
91
- result = await produce(ctx, p, entry.ac.signal);
121
+ try {
122
+ result = await produce(ctx, p, entry.ac.signal);
123
+ } catch {
124
+ result = { status: "error", errorCode: "internal" };
125
+ }
92
126
  }
93
127
  if (entry.ac.signal.aborted) {
94
128
  errorCode = "cancelled";
@@ -122,12 +156,14 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
122
156
  else if (status === "cancelled" || status === "timeout") stats.cancelled++;
123
157
  else stats.error++;
124
158
  bump(errorCode || result.errorCode || status);
125
- auditor.record({
126
- requestId: ctx.requestId, workspaceId: ctx.workspaceId, kind: p.kind, status, errorCode: errorCode || result.errorCode || null,
127
- bytes: sent ? sent.bytes : 0, chunks: sent ? sent.chunks : 0, ms: now() - t0,
128
- dlpMode: result.dlp ? result.dlp.mode : null, dlpHits: result.dlp ? result.dlp.total : 0, dlpRules: result.dlp ? result.dlp.redactions : {},
129
- refSha: short(p.ref), actorRole: p.actor && p.actor.role, actorSha: short(p.actor && p.actor.userId),
130
- });
159
+ try {
160
+ auditor.record({
161
+ requestId: ctx.requestId, workspaceId: ctx.workspaceId, kind: p.kind, status, errorCode: errorCode || result.errorCode || null,
162
+ bytes: sent ? sent.bytes : 0, chunks: sent ? sent.chunks : 0, ms: now() - t0,
163
+ dlpMode: result.dlp ? result.dlp.mode : null, dlpHits: result.dlp ? result.dlp.total : 0, dlpRules: result.dlp ? result.dlp.redactions : {},
164
+ refSha: short(p.ref), actorRole: p.actor && p.actor.role, actorSha: short(p.actor && p.actor.userId),
165
+ });
166
+ } catch { /* the audit must never take the data path down (nor leave a request without its answer) */ }
131
167
  if (status === "send_failed") warn(`[la-content] ${p.kind} not delivered: ${errorCode}`);
132
168
  }
133
169
 
@@ -147,7 +183,7 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
147
183
  entry.timer = setTimeout(() => { entry.reason = "deadline"; entry.ac.abort(); }, config.totalMs);
148
184
  inflight.set(ctx.requestId, entry);
149
185
  // background: ack first, answer later
150
- queueMicrotask(() => { run(ctx, p, entry).catch(() => {}); });
186
+ queueMicrotask(() => { run(ctx, p, entry).catch(() => warn("[la-content] request failed internally")); });
151
187
  return { accept: true };
152
188
  },
153
189
 
@@ -1,19 +1,24 @@
1
1
  /**
2
- * install.js - puts the content handlers on a v2 link (F3-07). Called by runtime.js only when
3
- * LOCAL_AGENT_CONTENT_HANDLERS=true (and v2 is on). Returns what the rest of the agent needs:
2
+ * install.js - puts the content handlers on a v2 link (F3-07, F3-07b). Called by runtime.js only when
3
+ * LOCAL_AGENT_CONTENT_HANDLERS=true, v2 is on AND LOCAL_AGENT_DLP_MODE=enforce. Returns what the rest of the agent needs:
4
4
  *
5
- * mintRef({workspaceId, kind, rel}) the HOOK for whoever reports metadata to the control plane (file lists,
6
- * diff summaries): returns an opaque id for that item, or null (sensitive
7
- * path, unknown workspace). Nothing calls it yet: the contract has no
8
- * LA->CP frame that carries an inventory (see the F3-07 note, open questions).
5
+ * mintRef({workspaceId, kind, rel, size?, after?}) returns {id, expiresAt} for that item (id = 43 base64url chars), or null
6
+ * (sensitive path, unknown workspace). The id is also announced to the control plane in a
7
+ * `content.index` frame ({kind, ref, size}: no name, no path). F3-07c: the real caller is the
8
+ * `file.list` handler (handler.js / file-list.js), which mints one id per entry it lists;
9
+ * `announce()` re-sends what is live.
9
10
  */
10
11
  import { dlpConfigFromEnv } from "../../dlp/index.js";
11
12
  import { ContentRefRegistry } from "./ref-registry.js";
12
13
  import { createContentHandler, SERVED_KINDS } from "./handler.js";
14
+ import { createContentIndexer } from "./content-index.js";
15
+ import { sizeInsideSync } from "./path-guard.js";
13
16
 
14
17
  export const CONTENT_CAPABILITIES = Object.freeze(SERVED_KINDS.map((k) => `content.${k}`));
15
18
 
16
19
  export function installContentHandlers({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console }) {
20
+ // F3-07b, defence in depth: runtime.js already refuses; nobody can install the handlers without DLP enforce
21
+ if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("content handlers need LOCAL_AGENT_DLP_MODE=enforce");
17
22
  const tenant = String(tenantId).toLowerCase();
18
23
  const registry = new ContentRefRegistry({ tenantId: tenant, ttlMs: config.refTtlMs, maxEntries: config.maxRefs, denyGlobs: config.extraDenyGlobs });
19
24
 
@@ -29,22 +34,37 @@ export function installContentHandlers({ link, tenantId, wsManager, getWorkspace
29
34
  return null;
30
35
  };
31
36
 
32
- const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log });
37
+ // `indexer` is created below (it needs the session only); mintRef and the handler use it through this binding
38
+ let indexer = null;
39
+ const mintRef = ({ workspaceId, kind, rel, size, after }) => {
40
+ try {
41
+ const ws = resolveWorkspace(workspaceId);
42
+ if (!ws) return null;
43
+ const bytes = Number.isSafeInteger(size) ? size
44
+ : kind === "file" && typeof rel === "string" ? sizeInsideSync(ws.root, rel.replace(/\\/g, "/"), { allowHardlinks: config.allowHardlinks }) : 0;
45
+ const minted = registry.mint({ workspaceId, root: ws.root, kind, rel, size: bytes, after });
46
+ if (minted && indexer) indexer.add({ workspaceId, kind, ref: minted.id, size: bytes, expiresAt: minted.expiresAt });
47
+ return minted;
48
+ } catch {
49
+ return null; // a failing workspace manager means "no id", never an exception with a path in it
50
+ }
51
+ };
52
+
53
+ // F3-07c: the listing handler mints through mintRef, so every id it hands out is also announced with content.index
54
+ const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log, mint: mintRef });
33
55
  link.session.setHandler("content.request", (ctx) => handler.handle(ctx));
56
+ indexer = createContentIndexer({ session: link.session, registry, config, log });
34
57
 
35
58
  const closeLink = link.close.bind(link);
36
- link.close = () => { handler.cancelAll(); registry.clear(); closeLink(); };
59
+ link.close = () => { handler.cancelAll(); indexer.close(); registry.clear(); closeLink(); };
37
60
 
38
- if (dlpConfigFromEnv(env).mode !== "enforce") {
39
- (log.warn || log.log).call(log, "[la-content] content handlers are on but LOCAL_AGENT_DLP_MODE is not enforce: file content leaves this machine unmasked");
40
- }
41
61
  link.content = {
42
62
  registry,
43
63
  handler,
44
- mintRef: ({ workspaceId, kind, rel }) => {
45
- const ws = resolveWorkspace(workspaceId);
46
- return ws ? registry.mint({ workspaceId, root: ws.root, kind, rel }) : null;
47
- },
64
+ indexer,
65
+ mintRef,
66
+ /** Announces again the live ids (of one workspace or all) that the gateway does not know yet. */
67
+ announce: (opts) => indexer.announce(opts),
48
68
  };
49
69
  return link.content;
50
70
  }
@@ -3,15 +3,22 @@
3
3
  * The gateway lets anything through (it only sees an opaque id); every check that matters is here.
4
4
  *
5
5
  * normalizeRel a relative path as the agent itself minted it: no NUL/control chars, not absolute, no `..`.
6
- * isSensitivePath deny list (built-in + LOCAL_AGENT_CONTENT_DENY_GLOBS): .env*, private keys, .git/, .ssh/, ...
6
+ * isSensitivePath deny list (built-in globs + name rules + LOCAL_AGENT_CONTENT_DENY_GLOBS): .env*, private keys, .git/, .ssh/, ...
7
+ * F3-07c: names that merely MENTION a credential (credentials.service.ts, CredentialsController.java) are
8
+ * code and are served; files that ARE credentials (credentials, credentials.json, .credentials,
9
+ * application_default_credentials.json, ...) are not. See sensitiveByName(). The DLP stays the 2nd layer.
7
10
  * resolveInside realpath of root and target; the real target must stay inside the real root (a symlink that
8
11
  * leaves the workspace is `forbidden`), must not be sensitive under its REAL name either
9
12
  * (a harmless-looking link to .env), and must be a regular file.
10
13
  * readRegular opens with O_NOFOLLOW, checks it is the same inode that was checked, reads at most `maxBytes`.
11
14
  *
12
15
  * Failures are small codes ("not_found" | "forbidden" | "too_large"); no message ever carries a path.
13
- * Limits that remain (documented in the note): a directory swapped for a symlink between the check and the open
14
- * (TOCTOU on parents), hard links to files outside the root (not detectable cheaply).
16
+ * Hard links (F3-07b): a file with more than one link (st.nlink > 1) is `forbidden` by default, because a hard link
17
+ * is indistinguishable from the file itself and may be a second name of a file OUTSIDE the root (the realpath check
18
+ * cannot see it). LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS=true lifts it (pnpm stores and some build trees hard-link
19
+ * every file). It is checked twice: on the path (resolveInside) and again on the open descriptor (readRegular).
20
+ * Limit that remains (documented in the note): a directory swapped for a symlink between the check and the open
21
+ * (TOCTOU on parents).
15
22
  */
16
23
  import fs from "node:fs";
17
24
  import path from "node:path";
@@ -21,12 +28,35 @@ const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
21
28
  /** Built-in deny list. A pattern with no `/` matches any path segment; with `/` it matches a path (or its prefix). */
22
29
  export const DEFAULT_DENY_GLOBS = Object.freeze([
23
30
  // secrets by name
24
- ".env", ".env.*", "*.env",
31
+ ".env", ".env.*", "*.env", ".envs/",
25
32
  "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", "*.p8", "*.ppk", "*.kdbx", "*.gpg", "*.crt.key",
26
33
  "id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*",
27
34
  ".npmrc", ".pypirc", ".netrc", "_netrc", ".pgpass", ".my.cnf", ".git-credentials", ".htpasswd", ".vault-token",
28
- "credentials", "credentials.json", "service-account*.json", "serviceaccount*.json", "secrets.json", "secrets.yml", "secrets.yaml",
35
+ "service-account*.json", "serviceaccount*.json", "secrets.json", "secrets.yml", "secrets.yaml",
36
+ // F3-07c: directories named for secrets (everything under them), by Edu's decision of 2026-11-03
37
+ "secrets/", ".secrets/", ".credentials/",
29
38
  "*.secret", "*.secrets", "*.tfstate", "*.tfstate.*", "*.tfvars", "*.tfvars.json", "terraform.rc",
39
+ // F3-07b: editor backups / autosaves of the env file, kubeconfig files
40
+ ".env~", ".env#", ".#.env", "*.kubeconfig",
41
+ // (F3-07c: .envrc, .env_backup, .env-prod, env.production, kubeconfig-prod, credentials* ... are name RULES, sensitiveByName() below)
42
+ // F3-07b: Windows 8.3 aliases ("short names") of the files above: ".env" is also reachable as ENV~1, ".npmrc" as NPMRC~1,
43
+ // "credentials.json" as CREDEN~1.JSO, "id_ed25519" as ID_ED2~1 ... The real name is checked as well (resolveInside), this
44
+ // covers the alias form on its own. `~?*` = "~" + at least one more character.
45
+ "env~?*", "npmrc~?*", "pypirc~?*", "netrc~?*", "pgpass~?*", "htpass~?*", "git-cr~?*", "vault-~?*", "kubeco~?*", "creden~?*",
46
+ "servic~?*", "secret~?*", "id_rsa~?*", "id_dsa~?*", "id_ecd~?*", "id_ed2~?*", "terraf~?*",
47
+ // (an alias keeps the first 3 characters of the extension: `*.pem`, `*.key`, `*.p12`... already catch their aliases; these are the ones whose
48
+ // extension is longer than 3: .tfstate -> .TFS, .tfvars -> .TFV, .kdbx -> .KDB, .secret(s) -> .SEC)
49
+ "*~?*.tfs", "*~?*.tfv", "*~?*.kdb", "*~?*.sec",
50
+ // F3-07c review: credential files that no rule above names (the DLP is only the second layer: it masks known token shapes, not plain values)
51
+ ".dockercfg", ".yarnrc", ".yarnrc.yml", ".pnpmrc", ".s3cfg", ".boto", ".terraformrc", ".renviron", "auth.json",
52
+ "client_secret*.json", "firebase-adminsdk*.json", "gha-creds-*.json", "local.settings.json", "wp-config.php",
53
+ "application-prod.properties", "application-prod.yml", "application-prod.yaml",
54
+ "*_rsa", "*_dsa", "*_ecdsa", "*_ed25519", "ssh_host_*_key",
55
+ ".m2/settings.xml", ".m2/settings-security.xml",
56
+ // family names that are also directories: everything under them is blocked (a bare `.env_backup/a.txt` was readable)
57
+ ".envrc/", ".env_backup/", ".env-prod/", "env.production/", "kubeconfig-prod/",
58
+ // 8.3 aliases of .envrc / .envs
59
+ "envrc~?*", "envs~?*",
30
60
  // directories that hold credentials or the repository internals (.git/config may carry a token in a URL)
31
61
  ".git/", ".ssh/", ".aws/", ".gnupg/", ".kube/", ".docker/", ".azure/", ".terraform/", ".config/gcloud/",
32
62
  ]);
@@ -60,11 +90,74 @@ function foldForMatch(rel) {
60
90
  return rel.split("/").map((seg) => seg.replace(/[. ]+$/, "") || seg).join("/").toLowerCase();
61
91
  }
62
92
 
63
- export function isSensitivePath(rel, extraGlobs = []) {
93
+ /**
94
+ * NTFS alternate data streams: ".env::$DATA" and ".env:stream" read the data of ".env". Matching also uses the name without
95
+ * the stream part of every segment (the original is matched too: on POSIX a colon is an ordinary character).
96
+ */
97
+ function withoutStreams(folded) {
98
+ return folded.split("/").map((seg) => { const i = seg.indexOf(":"); return i > 0 ? seg.slice(0, i) : seg; }).join("/");
99
+ }
100
+
101
+ /**
102
+ * Extensions of SOURCE / documentation files (F3-07c). A name that mentions a credential, a kubeconfig or an env file is
103
+ * served only when its last extension is one of these: credentials.service.ts, CredentialsController.java, credentials_test.py
104
+ * are code about credentials; credentials, credentials.json, .credentials, credentials.bak are credentials. Everything that is not
105
+ * known code fails closed (an unknown extension is NOT a reason to serve). Not in the list on purpose: data and config formats
106
+ * (json, yml, yaml, xml, toml, ini, cfg, conf, csv, txt, properties, db, sqlite, enc, env ...), shell/batch scripts and gradle
107
+ * (they export or hold values), key stores, backups (bak, old, orig, tmp, swp) and anything with no extension.
108
+ */
109
+ export const CODE_EXTENSIONS = Object.freeze(new Set([
110
+ "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "java", "kt", "kts", "py", "go", "rb", "cs", "rs", "php", "scala", "swift",
111
+ "vue", "svelte", "astro", "c", "cc", "cpp", "cxx", "h", "hh", "hpp", "m", "mm", "sql", "md", "markdown", "rst", "adoc", "html", "htm",
112
+ "css", "scss", "sass", "less", "dart", "ex", "exs", "erl", "hrl", "clj", "cljs", "lua", "pl", "pm", "r", "jl", "hs", "ml", "fs", "fsx",
113
+ "vb", "groovy", "proto", "graphql", "gql", "tf", "cshtml", "razor", "jsp", "ejs", "hbs", "pug", "zig", "nim", "sol",
114
+ ]));
115
+
116
+ /** Last extension of a (folded) file name; "" for no extension and for a name that only starts with a dot. */
117
+ function extOf(name) {
118
+ const i = name.lastIndexOf(".");
119
+ return i > 0 ? name.slice(i + 1) : "";
120
+ }
121
+ const isCodeName = (name) => CODE_EXTENSIONS.has(extOf(name));
122
+
123
+ /**
124
+ * Name rules (on top of the globs): the families whose names have to be told apart from source code. `segs` are the folded path
125
+ * segments; the last one is a directory when `dir` is true (a listing entry), a file otherwise.
126
+ * - .env family: `.env`, `.envrc`, then end or a separator (. _ - # ~): .env_backup, .env-prod, .env.local~, .envrc.local. Not .environment.
127
+ * - env.<x> / env_<x> / env-<x> and a bare `env` file: env.production, env_prod: unless code (env.ts, env.d.ts, env.config.mjs).
128
+ * - names with "credential" or "kubeconfig": unless code (credentials.service.ts) ... a DIRECTORY with such a name is fine
129
+ * (src/credentials/), but a file under one has to be code (credentials/prod.json is not), and `.credentials` is never fine.
130
+ */
131
+ function sensitiveByName(segs, dir) {
132
+ const last = segs.length - 1;
133
+ for (let i = 0; i < segs.length; i++) {
134
+ const seg = segs[i];
135
+ const asDir = i < last || dir;
136
+ if (asDir) {
137
+ if (seg === ".credentials") return true;
138
+ continue;
139
+ }
140
+ if (/^\.env(?:rc)?(?![a-z])/.test(seg)) return true;
141
+ if (seg === "env" || (/^env[._-]/.test(seg) && !isCodeName(seg))) return true;
142
+ if ((seg.includes("credential") || seg.includes("kubeconfig")) && !isCodeName(seg)) return true;
143
+ if (i > 0 && segs.slice(0, i).some((d) => d.includes("credential")) && !isCodeName(seg)) return true;
144
+ }
145
+ return false;
146
+ }
147
+
148
+ /**
149
+ * @param {string} rel
150
+ * @param {string[]} [extraGlobs]
151
+ * @param {{dir?: boolean}} [o] dir: the last segment is a directory (a listing entry, a folder id): file-only name rules do not apply to it
152
+ */
153
+ export function isSensitivePath(rel, extraGlobs = [], { dir = false } = {}) {
64
154
  if (typeof rel !== "string") return true;
65
155
  const folded = foldForMatch(rel.replace(/\\/g, "/"));
66
- for (const g of DEFAULT_DENY_GLOBS) if (regexFor(g).test(folded)) return true;
67
- for (const g of extraGlobs) if (typeof g === "string" && g && regexFor(g).test(folded)) return true;
156
+ const stripped = withoutStreams(folded);
157
+ const forms = stripped === folded ? [folded] : [folded, stripped];
158
+ for (const g of DEFAULT_DENY_GLOBS) { const re = regexFor(g); if (forms.some((f) => re.test(f))) return true; }
159
+ for (const f of forms) if (sensitiveByName(f.split("/"), dir)) return true;
160
+ for (const g of extraGlobs) if (typeof g === "string" && g) { const re = regexFor(g); if (forms.some((f) => re.test(f))) return true; }
68
161
  return false;
69
162
  }
70
163
 
@@ -93,7 +186,7 @@ const inside = (root, abs) => abs === root || abs.startsWith(root.endsWith(path.
93
186
  * @param {string} rel relative path already normalized (normalizeRel)
94
187
  * @returns {Promise<{ok:true, real:string, relReal:string, stat:fs.Stats} | {ok:false, status:"not_found"|"forbidden"}>}
95
188
  */
96
- export async function resolveInside(root, rel, extraGlobs = []) {
189
+ export async function resolveInside(root, rel, extraGlobs = [], { allowHardlinks = false } = {}) {
97
190
  let rootReal;
98
191
  try { rootReal = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
99
192
  const abs = path.join(rootReal, ...rel.split("/"));
@@ -107,21 +200,38 @@ export async function resolveInside(root, rel, extraGlobs = []) {
107
200
  let st;
108
201
  try { st = await fs.promises.lstat(real); } catch { return { ok: false, status: "not_found" }; }
109
202
  if (!st.isFile()) return { ok: false, status: "not_found" };
203
+ if (st.nlink > 1 && !allowHardlinks) return { ok: false, status: "forbidden" }; // a hard link may be a second name of a file outside the root
110
204
  return { ok: true, real, relReal, stat: st };
111
205
  }
112
206
 
207
+ /**
208
+ * Size of a regular file inside the root, for content.index (informative). 0 for anything else: a link that leaves the
209
+ * root, a missing file, a hard link (when refused). Nothing is read and nothing but a number comes back.
210
+ */
211
+ export function sizeInsideSync(root, rel, { allowHardlinks = false } = {}) {
212
+ try {
213
+ const rootReal = fs.realpathSync(root);
214
+ const real = fs.realpathSync(path.join(rootReal, ...rel.split("/")));
215
+ if (!inside(rootReal, real) || real === rootReal) return 0;
216
+ const st = fs.lstatSync(real);
217
+ if (!st.isFile() || (st.nlink > 1 && !allowHardlinks)) return 0;
218
+ return st.size;
219
+ } catch { return 0; }
220
+ }
221
+
113
222
  /**
114
223
  * Reads the whole file (<= maxBytes) through a descriptor opened without following links and verified to be the
115
224
  * file that resolveInside() checked.
116
225
  * @returns {Promise<{ok:true, buffer:Buffer} | {ok:false, status:"not_found"|"forbidden"|"too_large"}>}
117
226
  */
118
- export async function readRegular(resolved, maxBytes, signal) {
227
+ export async function readRegular(resolved, maxBytes, signal, { allowHardlinks = false } = {}) {
119
228
  if (resolved.stat.size > maxBytes) return { ok: false, status: "too_large" };
120
229
  let fh;
121
230
  try {
122
231
  fh = await fs.promises.open(resolved.real, fs.constants.O_RDONLY | O_NOFOLLOW);
123
232
  const st = await fh.stat();
124
233
  if (!st.isFile()) return { ok: false, status: "not_found" };
234
+ if (st.nlink > 1 && !allowHardlinks) return { ok: false, status: "forbidden" }; // linked again after the path check
125
235
  if (resolved.stat.ino !== 0 && (st.ino !== resolved.stat.ino || st.dev !== resolved.stat.dev)) return { ok: false, status: "forbidden" };
126
236
  if (st.size > maxBytes) return { ok: false, status: "too_large" };
127
237
  const buf = Buffer.allocUnsafe(Math.min(st.size, maxBytes) + 1);