deepdebug-local-agent 1.6.0 → 1.8.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 (56) hide show
  1. package/docs/la-protocol-v2.md +41 -11
  2. package/env.example +10 -1
  3. package/package.json +1 -1
  4. package/scripts/incident-parity/Gen.java +256 -0
  5. package/scripts/incident-parity/LogEntry.java +17 -0
  6. package/scripts/incident-parity/extract.py +51 -0
  7. package/scripts/incident-parity/gen.sh +32 -0
  8. package/src/incident-detector/README.md +51 -0
  9. package/src/incident-detector/aggregator.js +73 -0
  10. package/src/incident-detector/categorize.js +48 -0
  11. package/src/incident-detector/config.js +95 -0
  12. package/src/incident-detector/detector.js +92 -0
  13. package/src/incident-detector/emitter.js +154 -0
  14. package/src/incident-detector/index.js +44 -0
  15. package/src/incident-detector/java-compat.js +66 -0
  16. package/src/incident-detector/normalize.js +209 -0
  17. package/src/incident-detector/parse.js +114 -0
  18. package/src/incident-detector/sources/file-source.js +113 -0
  19. package/src/incident-detector/sources/loki-source.js +101 -0
  20. package/src/incident-detector/sources/stream-source.js +39 -0
  21. package/src/incident-detector/store.js +119 -0
  22. package/src/la-protocol-v2/content-handlers/config.js +3 -1
  23. package/src/la-protocol-v2/content-handlers/content-index.js +54 -19
  24. package/src/la-protocol-v2/content-handlers/egress.js +22 -0
  25. package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
  26. package/src/la-protocol-v2/content-handlers/git-diff.js +43 -8
  27. package/src/la-protocol-v2/content-handlers/handler.js +28 -4
  28. package/src/la-protocol-v2/content-handlers/install.js +24 -19
  29. package/src/la-protocol-v2/content-handlers/path-guard.js +74 -5
  30. package/src/la-protocol-v2/content-handlers/ref-registry.js +18 -8
  31. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -8
  32. package/src/la-protocol-v2/contract/README.md +38 -0
  33. package/src/la-protocol-v2/contract/content.index.schema.json +1 -0
  34. package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
  35. package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
  36. package/src/la-protocol-v2/contract.js +9 -0
  37. package/src/la-protocol-v2/runtime.js +11 -2
  38. package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +32 -0
  39. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
  40. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
  41. package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
  42. package/test/incident-detector/parity.test.js +66 -0
  43. package/test/incident-detector/parity.vectors.json +2619 -0
  44. package/test/incident-detector/sources.test.js +179 -0
  45. package/test/incident-detector/units.test.js +430 -0
  46. package/test/incident-detector/wiring.test.js +165 -0
  47. package/test/la-protocol-v2/content-alignment.test.js +71 -10
  48. package/test/la-protocol-v2/content-deny-list.test.js +138 -0
  49. package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
  50. package/test/la-protocol-v2/content-file-list.test.js +385 -0
  51. package/test/la-protocol-v2/content-path-guard.test.js +1 -1
  52. package/test/la-protocol-v2/content-wiring.test.js +2 -2
  53. package/test/la-protocol-v2/contract.test.js +14 -4
  54. package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
  55. package/tools/interop/README.md +16 -0
  56. package/tools/interop/la-interop-list-client.mjs +93 -0
@@ -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,16 +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";
28
29
  import { dlpConfigFromEnv } from "../../dlp/index.js";
29
30
 
30
- export const SERVED_KINDS = Object.freeze(["file", "diff"]);
31
+ export const SERVED_KINDS = Object.freeze(["file", "diff", "file.list"]);
31
32
  /** Symbolic ref of kind `diff`: the whole work tree of the envelope's workspace (carries no client data). */
32
33
  export const WORKTREE_REF = "worktree";
33
34
 
34
- 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 }));
35
38
  const inflight = new Map(); // requestId -> { ac, timer }
36
39
  let reservedBytes = 0;
37
40
  const auditor = audit || createAudit({ file: config.auditFile, now });
@@ -49,6 +52,7 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
49
52
  if (dlpConfigFromEnv(env).mode !== "enforce") return { status: "error", errorCode: "protocol_unsupported" };
50
53
  const ws = await resolveWorkspace(ctx.workspaceId);
51
54
  if (!ws || !ws.root) return { status: "not_found" };
55
+ if (p.kind === "file.list") return produceList(ctx, p, ws, signal);
52
56
  const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: p.kind });
53
57
  if (p.kind === "diff" && !target && p.ref === WORKTREE_REF) return produceDiff(p, ws, null, signal);
54
58
  if (!target) return { status: "not_found" };
@@ -68,6 +72,26 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
68
72
  return finish(p, read.buffer, contentTypeOf(norm, isText), "content.file");
69
73
  }
70
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
+
71
95
  async function produceDiff(p, ws, rel, signal) {
72
96
  const d = await worktreeDiff({ root: ws.root, rel, maxBytes: config.maxBytes, maxFiles: config.maxDiffFiles,
73
97
  timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, allowHardlinks: config.allowHardlinks, signal });
@@ -2,11 +2,11 @@
2
2
  * install.js - puts the content handlers on a v2 link (F3-07, F3-07b). Called by runtime.js only when
3
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, size?}) the HOOK for whoever reports metadata to the control plane (file lists, diff
6
- * summaries): returns {id, expiresAt} for that item (id = 43 base64url chars), or null
5
+ * mintRef({workspaceId, kind, rel, size?, after?}) returns {id, expiresAt} for that item (id = 43 base64url chars), or null
7
6
  * (sensitive path, unknown workspace). The id is also announced to the control plane in a
8
- * `content.index` frame ({kind, ref, size}: no name, no path). Nothing in the agent calls
9
- * it yet (the inventory producer is a later increment); `announce()` re-sends what is live.
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.
10
10
  */
11
11
  import { dlpConfigFromEnv } from "../../dlp/index.js";
12
12
  import { ContentRefRegistry } from "./ref-registry.js";
@@ -34,9 +34,26 @@ export function installContentHandlers({ link, tenantId, wsManager, getWorkspace
34
34
  return null;
35
35
  };
36
36
 
37
- 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 });
38
55
  link.session.setHandler("content.request", (ctx) => handler.handle(ctx));
39
- const indexer = createContentIndexer({ session: link.session, registry, config, log });
56
+ indexer = createContentIndexer({ session: link.session, registry, config, log });
40
57
 
41
58
  const closeLink = link.close.bind(link);
42
59
  link.close = () => { handler.cancelAll(); indexer.close(); registry.clear(); closeLink(); };
@@ -45,19 +62,7 @@ export function installContentHandlers({ link, tenantId, wsManager, getWorkspace
45
62
  registry,
46
63
  handler,
47
64
  indexer,
48
- mintRef: ({ workspaceId, kind, rel, size }) => {
49
- try {
50
- const ws = resolveWorkspace(workspaceId);
51
- if (!ws) return null;
52
- const bytes = Number.isSafeInteger(size) ? size
53
- : kind === "file" && typeof rel === "string" ? sizeInsideSync(ws.root, rel.replace(/\\/g, "/"), { allowHardlinks: config.allowHardlinks }) : 0;
54
- const minted = registry.mint({ workspaceId, root: ws.root, kind, rel, size: bytes });
55
- if (minted) indexer.add({ workspaceId, kind, ref: minted.id, size: bytes, expiresAt: minted.expiresAt });
56
- return minted;
57
- } catch {
58
- return null; // a failing workspace manager means "no id", never an exception with a path in it
59
- }
60
- },
65
+ mintRef,
61
66
  /** Announces again the live ids (of one workspace or all) that the gateway does not know yet. */
62
67
  announce: (opts) => indexer.announce(opts),
63
68
  };
@@ -3,7 +3,10 @@
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.
@@ -25,14 +28,17 @@ const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
25
28
  /** Built-in deny list. A pattern with no `/` matches any path segment; with `/` it matches a path (or its prefix). */
26
29
  export const DEFAULT_DENY_GLOBS = Object.freeze([
27
30
  // secrets by name
28
- ".env", ".env.*", "*.env",
31
+ ".env", ".env.*", "*.env", ".envs/",
29
32
  "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", "*.p8", "*.ppk", "*.kdbx", "*.gpg", "*.crt.key",
30
33
  "id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*",
31
34
  ".npmrc", ".pypirc", ".netrc", "_netrc", ".pgpass", ".my.cnf", ".git-credentials", ".htpasswd", ".vault-token",
32
- "credentials*", "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/",
33
38
  "*.secret", "*.secrets", "*.tfstate", "*.tfstate.*", "*.tfvars", "*.tfvars.json", "terraform.rc",
34
39
  // F3-07b: editor backups / autosaves of the env file, kubeconfig files
35
- ".env~", ".env#", ".#.env", "kubeconfig", "kubeconfig.*", "*.kubeconfig",
40
+ ".env~", ".env#", ".#.env", "*.kubeconfig",
41
+ // (F3-07c: .envrc, .env_backup, .env-prod, env.production, kubeconfig-prod, credentials* ... are name RULES, sensitiveByName() below)
36
42
  // F3-07b: Windows 8.3 aliases ("short names") of the files above: ".env" is also reachable as ENV~1, ".npmrc" as NPMRC~1,
37
43
  // "credentials.json" as CREDEN~1.JSO, "id_ed25519" as ID_ED2~1 ... The real name is checked as well (resolveInside), this
38
44
  // covers the alias form on its own. `~?*` = "~" + at least one more character.
@@ -41,6 +47,16 @@ export const DEFAULT_DENY_GLOBS = Object.freeze([
41
47
  // (an alias keeps the first 3 characters of the extension: `*.pem`, `*.key`, `*.p12`... already catch their aliases; these are the ones whose
42
48
  // extension is longer than 3: .tfstate -> .TFS, .tfvars -> .TFV, .kdbx -> .KDB, .secret(s) -> .SEC)
43
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~?*",
44
60
  // directories that hold credentials or the repository internals (.git/config may carry a token in a URL)
45
61
  ".git/", ".ssh/", ".aws/", ".gnupg/", ".kube/", ".docker/", ".azure/", ".terraform/", ".config/gcloud/",
46
62
  ]);
@@ -82,12 +98,65 @@ function withoutStreams(folded) {
82
98
  return folded.split("/").map((seg) => { const i = seg.indexOf(":"); return i > 0 ? seg.slice(0, i) : seg; }).join("/");
83
99
  }
84
100
 
85
- export function isSensitivePath(rel, extraGlobs = []) {
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 } = {}) {
86
154
  if (typeof rel !== "string") return true;
87
155
  const folded = foldForMatch(rel.replace(/\\/g, "/"));
88
156
  const stripped = withoutStreams(folded);
89
157
  const forms = stripped === folded ? [folded] : [folded, stripped];
90
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;
91
160
  for (const g of extraGlobs) if (typeof g === "string" && g) { const re = regexFor(g); if (forms.some((f) => re.test(f))) return true; }
92
161
  return false;
93
162
  }
@@ -12,6 +12,8 @@
12
12
  * In memory only: a restart of the agent forgets every id (also not_found), by design.
13
13
  * binding an id is valid for ONE tenant, ONE workspace (and the root that workspace had when it was minted),
14
14
  * ONE kind. Any other combination resolves to null, indistinguishable from "never existed".
15
+ * file.list (F3-07c) the id of a FOLDER (rel = the folder, null = the workspace root) or the continuation of a listing page
16
+ * (`after` = the position where the previous page stopped, kept here: the control plane only ever sees the id).
15
17
  * size at most `maxEntries`; the oldest go first.
16
18
  *
17
19
  * Nothing here logs, throws a message with a path, or exposes an id's target to anything but resolve().
@@ -20,7 +22,7 @@ import crypto from "node:crypto";
20
22
  import { normalizeRel, isSensitivePath } from "./path-guard.js";
21
23
 
22
24
  export const REF_PATTERN = /^[A-Za-z0-9_-]{43}$/;
23
- export const REF_KINDS = Object.freeze(["file", "diff"]);
25
+ export const REF_KINDS = Object.freeze(["file", "file.list", "diff"]);
24
26
 
25
27
  export class ContentRefRegistry {
26
28
  constructor({ tenantId, ttlMs = 15 * 60_000, maxEntries = 20_000, now = Date.now, denyGlobs = [] } = {}) {
@@ -44,32 +46,40 @@ export class ContentRefRegistry {
44
46
  }
45
47
 
46
48
  /**
47
- * @param {{workspaceId:string, root:string, kind:"file"|"diff", rel?:string|null, size?:number}} o
48
- * rel = path relative to the workspace root (file: required; diff: optional, null = the whole work tree)
49
+ * @param {{workspaceId:string, root:string, kind:"file"|"file.list"|"diff", rel?:string|null, size?:number, after?:{dir:boolean,name:string}|null}} o
50
+ * rel = path relative to the workspace root (file: required; diff: optional, null = the whole work tree;
51
+ * file.list: the folder, null = the root)
52
+ * after = file.list only: the last entry of the previous page (dirs first, then names by code point)
49
53
  * size = informative size in bytes that content.index announces (0 when unknown); never a name or a path
50
54
  * @returns {{id:string, expiresAt:number} | null} null = refused (bad kind/path or a sensitive file): no id exists
51
55
  */
52
- mint({ workspaceId, root, kind, rel = null, size = 0 }) {
56
+ mint({ workspaceId, root, kind, rel = null, size = 0, after = null }) {
53
57
  if (!REF_KINDS.includes(kind) || typeof workspaceId !== "string" || !workspaceId || typeof root !== "string" || !root) return null;
54
58
  let norm = null;
55
59
  if (rel !== null && rel !== undefined) {
56
60
  norm = normalizeRel(rel);
57
- if (!norm || isSensitivePath(norm, this.denyGlobs)) return null; // never hand out an id for a file we would refuse
61
+ // never hand out an id for a file we would refuse (for a folder id: the folder rules)
62
+ if (!norm || isSensitivePath(norm, this.denyGlobs, { dir: kind === "file.list" })) return null;
58
63
  } else if (kind === "file") {
59
64
  return null;
60
65
  }
66
+ let cursor = null;
67
+ if (after !== null && after !== undefined) {
68
+ if (kind !== "file.list" || typeof after !== "object" || typeof after.name !== "string" || !after.name || after.name.length > 1024) return null;
69
+ cursor = { dir: after.dir === true, name: after.name };
70
+ }
61
71
  this.#purge();
62
72
  let id;
63
73
  do { id = crypto.randomBytes(32).toString("base64url"); } while (this.entries.has(id)); // 43 chars; a repeat is astronomically unlikely, never reused
64
74
  const expiresAt = this.now() + this.ttlMs;
65
75
  const sz = Number.isSafeInteger(size) && size >= 0 && size <= 2 ** 40 ? size : 0;
66
- this.entries.set(id, { tenantId: this.tenantId, workspaceId, root, kind, rel: norm, size: sz, expiresAt });
76
+ this.entries.set(id, { tenantId: this.tenantId, workspaceId, root, kind, rel: norm, size: sz, after: cursor, expiresAt });
67
77
  this.#purge(); // capacity includes the new entry
68
78
  return { id, expiresAt };
69
79
  }
70
80
 
71
81
  /**
72
- * @returns {{rel:string|null}} | null null for: not an id, unknown, expired, other tenant/workspace/root/kind
82
+ * @returns {{rel:string|null, after:{dir:boolean,name:string}|null}} | null null for: not an id, unknown, expired, other tenant/workspace/root/kind
73
83
  */
74
84
  resolve(id, { workspaceId, root, kind }) {
75
85
  if (typeof id !== "string" || !REF_PATTERN.test(id)) return null;
@@ -77,7 +87,7 @@ export class ContentRefRegistry {
77
87
  if (!e) return null;
78
88
  if (e.expiresAt <= this.now()) { this.entries.delete(id); return null; }
79
89
  if (e.tenantId !== this.tenantId || e.workspaceId !== workspaceId || e.root !== root || e.kind !== kind) return null;
80
- return { rel: e.rel };
90
+ return { rel: e.rel, after: e.after };
81
91
  }
82
92
 
83
93
  /**
@@ -1,22 +1,23 @@
1
1
  {
2
2
  "repo": "VISIONHAMMER/vh-api-gateway",
3
3
  "path": "docs/contracts/la-protocol-v2",
4
- "branch": "feat/edu/f3-07b-gateway-contract",
5
- "commit": "55921963e461464b34daf8be604bae162a1c3289",
4
+ "branch": "release/qa",
5
+ "commit": "b9176054337eeabe416117bafd90ce484a71968e",
6
6
  "note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
7
7
  "files": {
8
- "README.md": "77e95e25b4fa0eb5eb82c1bb3d9676fed4d946d8ce0e02838d8c3f7f57f972a9",
8
+ "README.md": "93fe7d94f212c36d5402a20cd1397e5236012c6bf1f6bb81546a65033703a88f",
9
9
  "ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
10
10
  "alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
11
11
  "approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
12
12
  "audit.event.schema.json": "e9ceb58ea00077151373955957b8319324de488c80da4063f6d0dcb40a8b3ed0",
13
13
  "config.update.schema.json": "f5318f24230f542c76a3cb996d08f33210c0b2cc2fc55ad73c4f95648582033f",
14
- "content.index.schema.json": "ba770a0b429331cc801b7b402b74c4e73d229d3b72f3052781720b34c14fb777",
15
- "content.request.schema.json": "9966b55ec051832ac93ee43e9cd226e4f0efaeb49741947bfc48193e40e00a32",
14
+ "content.index.schema.json": "1abc24344435f0f392d5b626c3abb6879d8f9dd2c1d0463840a67634a5caa71e",
15
+ "content.request.schema.json": "440e3675246657e9aae23e51176168bc21c91283b5fcaf6782111a18110bf76e",
16
16
  "content.response.schema.json": "c9a66ac9cbbcaec7fc182b3e3fa23b34877bd342592521c7e393779230332231",
17
17
  "defs.schema.json": "f19fd27fd103d8da02de4506c086263c6236250c5c127aa3aab41833a90b743a",
18
18
  "envelope.schema.json": "92b3e5b31e6235689a1c22fae1d1eab3f93d377a19032809e47ee014c0ce67e1",
19
19
  "error.schema.json": "b1fd663d4a1ea73d88cb575422f15f8c7279001f9385ebb2d5e1889b5d5ef878",
20
+ "file.list.body.schema.json": "dd58ac2af91bbde53e0fd585dc55af0adb01380492d1604f21ed8e972d7ba8d1",
20
21
  "heartbeat.schema.json": "8aff84a1ef3ca56bdff8819abb3b0ab01a2547814c2ea2f9ee893cb08f7a08f1",
21
22
  "hello.schema.json": "bd500320f01ff10da49ded6f99c7e8081252019984afb3c178e27003df20b963",
22
23
  "incident.candidate.schema.json": "b292a85917b5958fc6de10a2895732e30a35420385623930c78e47341862dc62",
@@ -36,11 +37,12 @@
36
37
  "vectors/approval.decision.vectors.json": "338115973bf2c4390d2bb33ce8f4079f3132cd739cd5505e1e873fdc252d2271",
37
38
  "vectors/audit.event.vectors.json": "d05c1820103eef041a84eb8b13d9a67a8a5b54b9b54ee3b6e43fd4ef15ed7000",
38
39
  "vectors/config.update.vectors.json": "8a5088d547e3eb4e460b25a5e413caaa02d4402a601340e279166d8c17758e1c",
39
- "vectors/content.index.vectors.json": "0a59f6b633388d0d1dcb1ebfc6fa6b5e9ad5e399b5a9d8c60b625a9f318f99f9",
40
- "vectors/content.request.vectors.json": "6922748edfc630ce881a1b79d24827de03f849e05c441ef7eecc684bf2238e58",
41
- "vectors/content.response.vectors.json": "35ef18edbcec24c029781e97d60dcb0b9111697fa3a5a2a8dfd159744e7f8258",
40
+ "vectors/content.index.vectors.json": "f167db30910989c46f317313d651ca59f9cd9d881d2ea81b84912155d18227b7",
41
+ "vectors/content.request.vectors.json": "fdf0f4d9e19f6480e20e40f4652bf8205da2b113620f8461dfaeb0d61add36a0",
42
+ "vectors/content.response.vectors.json": "cbb47cbf8a9512aa5360eea425f0cdd9064334311d37834dc75bddff07dadc5d",
42
43
  "vectors/cross-type.vectors.json": "f7ed403d3e3f53150b715a5423fde1bed708e8c6245709ed4ad74389dc8c3307",
43
44
  "vectors/error.vectors.json": "f5fd7954f2c1736ee0943eb330c2f85b7fdd7a8959e4f043f9cc9e9bcda39aa5",
45
+ "vectors/file.list.body.vectors.json": "da1dcc5d4ec976853161bef4b3947572f36b5526cbe16b838ac4ef72ddded403",
44
46
  "vectors/heartbeat.vectors.json": "cfd2e048139c67aa925f1fa94c05326e75248839645c1a1e4d6d3bb60cafa624",
45
47
  "vectors/hello.vectors.json": "51067dc645a2e9d8116997b36e6f3fe36401d27f3ee3c18de32ec30f8a052c5f",
46
48
  "vectors/incident.candidate.vectors.json": "0dd75ef396bf7b7911d20b3ab220469701f07866bb032ee31af58523c3633ab6",
@@ -9,6 +9,8 @@ exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json`
9
9
  ## Files
10
10
  - `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (22):
11
11
  one per allowlisted type, each `$ref`s the envelope and closes `payload`. `defs.schema.json`: shared types.
12
+ - `file.list.body.schema.json` (F3-07c): not a frame type; the schema of the DECODED body of a `content.response` for kind `file.list`
13
+ (below). `vectors/file.list.body.vectors.json` holds its vectors (same format, `schema` names that file).
12
14
  - `index.json`: allowlist, direction, signed/control flags, content class per type, and the limits below.
13
15
  - `vectors/<type>.vectors.json` (+ `cross-type`): valid and invalid messages with `expect` (`valid`, error `code`,
14
16
  optional JSON Schema `keyword`, optional session `rule`). `vectors/traces/*.json`: whole sessions for the stateful rules.
@@ -148,3 +150,39 @@ It is metadata about client content, not client content (`contentClass: none`).
148
150
  and 2048 live ids per session, send `content.index` only when its content handlers are on and DLP is `enforce`, and treat the
149
151
  ids as expiring (it answers `not_found` for an id it forgot). It must never put a path or a file name in a frame.
150
152
 
153
+
154
+ ## `file.list` (CP -> LA content kind, F3-07c): how the front finds out what exists on the customer's side
155
+ **Decision (Edu, 2026-11-03; no new frame type, no new limit in `index.json`).** The control plane knows no file, no folder and no name,
156
+ and must not learn them: `content.index` carries ids only. So the answer to "what is there?" is a content kind like the others, served on
157
+ demand and relayed without being kept: `file.list` lists ONE folder of ONE workspace, and its body (names, sizes, times) travels only
158
+ inside `content.response.data` (client content in transit: DLP at the agent, never parsed, stored or logged by the CP, bounded). The
159
+ front then asks for an item by the id it was given (`file` for a file, `file.list` for a folder). Nothing else was needed: the
160
+ existing kinds, `ref` convention, ids, envelope and chunking are reused; the changes are the kind in two enums and the body schema.
161
+ - **Request** `content.request{kind:"file.list", ref, limit?, actor}` (the envelope carries the `workspaceId`, always required for this
162
+ kind). `ref` is `root` (symbolic, exact, lower-case: the root folder of that workspace) or a 43-character opaque id: a folder id taken
163
+ from a previous listing, or the `nextRef` of one. `range` is not used (the gateway refuses it); `limit` is the page size, 1..1000
164
+ (default chosen by the agent, 200 suggested). The gateway never accepts a path or a UUID here.
165
+ - **Response** `content.response`, `contentType:"application/json"` (anything else is refused by the gateway as `agent_error`), body =
166
+ `file.list.body.schema.json`: `{entries:[{id, name, type:"file"|"dir", size, mtime}], nextRef?}`. `name` is the BASE name, 1..255
167
+ characters, no `/`, no control character, never `.` or `..`; a path cannot be expressed. `id` (43 chars) is minted by the agent
168
+ for the item, scoped to the workspace like every id; `size` is bytes (0 for a dir); `mtime` UTC. `nextRef`, when present, means
169
+ more entries: ask `file.list` again with it as `ref` (it is a continuation id the agent minted, 43 chars, not a position the client
170
+ can build). Order is the agent's and stable within one listing: dirs first, then names in code point order. An empty folder is
171
+ `entries: []`.
172
+ - **Limits.** At most **1000 entries** per page. The whole body is at most **1048576 bytes (1 MiB)**, half of the generic 2 MiB: the
173
+ gateway answers `too_large` above it, so the agent must cut a page by size as well as by count (1000 entries of 255-character
174
+ names do not fit) and give a `nextRef`. A page never holds the whole tree: there is no recursive listing.
175
+ - **What the agent lists.** Only what it could serve with kind `file` / `file.list`: it applies the same deny list, the same
176
+ symlink/hard-link and root checks to the listing, so an entry the agent would refuse (a credential, a link out of the root, an
177
+ unreadable file) is simply absent, and the ids it hands out resolve inside the workspace only. Names are client content: the DLP
178
+ runs on the body like on any other, the front must treat them as text (escape, never HTML).
179
+ - **Ids and the index.** Ids minted for a listing may be announced with `content.index` (kind `file.list` for a folder id or a
180
+ continuation, `file` for a file), within its limits (256 per frame, 2048 per session). That matters only with
181
+ `deepdebug.content-relay.require-known-ref`, default off and single-instance: with it on, an id that was not announced is `not_found`
182
+ at the gateway; the agent stays the authority either way.
183
+ - **Capability.** An agent that serves this announces `content.file.list` in `register.capabilities` (and `hello`). The gateway sends
184
+ kind `file.list` ONLY to a session that announced it (a Local Agent built before F3-07c does not have the kind in its copy of this
185
+ contract and would answer the whole request with a schema violation); others are skipped, and if no agent of the tenant has it the
186
+ answer is HTTP 501 `not_supported`. That announcement is also how the front/CP discovers that listing exists on a given agent.
187
+ - **Compatibility.** Additive: the kind is added to the `content.request` and `content.index` enums, nothing else changes there. The
188
+ `content.request` payload gained no field. With `deepdebug.content-relay.enabled` off nothing is mapped, as before.
@@ -32,6 +32,7 @@
32
32
  "incident.detail",
33
33
  "incident.stack",
34
34
  "file",
35
+ "file.list",
35
36
  "diff",
36
37
  "log.window",
37
38
  "log.groups",
@@ -22,6 +22,7 @@
22
22
  "incident.detail",
23
23
  "incident.stack",
24
24
  "file",
25
+ "file.list",
25
26
  "diff",
26
27
  "log.window",
27
28
  "log.groups",
@@ -0,0 +1,69 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.visionhammer.io/la-protocol-v2/file.list.body.schema.json",
4
+ "title": "LA protocol v2: decoded body of a content.response for kind file.list (not a frame type)",
5
+ "description": "One page of the listing of ONE folder of a workspace: what the Local Agent puts, as JSON, in the base64 `data` of a content.response (contentType application/json). Client content in transit: the control plane relays it, never parses, stores or logs it. Entries carry the opaque id to ask for the item later (kind `file` for a file, kind `file.list` for a dir), the base name (never a path), size and mtime. Total size of the body: at most 1048576 bytes, which JSON Schema cannot state (README).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "entries": {
10
+ "type": "array",
11
+ "maxItems": 1000,
12
+ "items": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "id": {
17
+ "type": "string",
18
+ "minLength": 43,
19
+ "maxLength": 43,
20
+ "pattern": "^[A-Za-z0-9_-]{43}$"
21
+ },
22
+ "name": {
23
+ "type": "string",
24
+ "minLength": 1,
25
+ "maxLength": 255,
26
+ "pattern": "^[^\\u0000-\\u001f\\u007f/]{1,255}$",
27
+ "not": {
28
+ "enum": [
29
+ ".",
30
+ ".."
31
+ ]
32
+ }
33
+ },
34
+ "type": {
35
+ "type": "string",
36
+ "enum": [
37
+ "file",
38
+ "dir"
39
+ ]
40
+ },
41
+ "size": {
42
+ "type": "integer",
43
+ "minimum": 0,
44
+ "maximum": 1099511627776
45
+ },
46
+ "mtime": {
47
+ "$ref": "defs.schema.json#/$defs/ts"
48
+ }
49
+ },
50
+ "required": [
51
+ "id",
52
+ "name",
53
+ "type",
54
+ "size",
55
+ "mtime"
56
+ ]
57
+ }
58
+ },
59
+ "nextRef": {
60
+ "type": "string",
61
+ "minLength": 43,
62
+ "maxLength": 43,
63
+ "pattern": "^[A-Za-z0-9_-]{43}$"
64
+ }
65
+ },
66
+ "required": [
67
+ "entries"
68
+ ]
69
+ }
@@ -64,6 +64,15 @@ export function loadContract() {
64
64
  outboundTypes: Object.entries(index.types).filter(([, i]) => accepts(i, "la->cp")).map(([t]) => t),
65
65
  canReceive: (type) => { const i = typeInfo(type); return !!i && accepts(i, "cp->la"); },
66
66
  canSend: (type) => { const i = typeInfo(type); return !!i && accepts(i, "la->cp"); },
67
+ /**
68
+ * F3-07c: validates a DECODED body that is not a frame (the file.list listing carried in content.response.data) against its
69
+ * schema file (`*.body.schema.json`). @returns {boolean}
70
+ */
71
+ validateBody(schemaFile, value) {
72
+ const v = ajv.getSchema(index.baseId + schemaFile);
73
+ if (!v) throw new Error(`contract: no schema ${schemaFile}`);
74
+ return v(value) === true;
75
+ },
67
76
  validateFrame(frame) {
68
77
  if (frame === null || typeof frame !== "object" || typeof frame.type !== "string") {
69
78
  // no `type` at all (a v1 command frame, say): the envelope says what is missing
@@ -55,7 +55,12 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
55
55
  contentCfg = { enabled: false };
56
56
  }
57
57
  }
58
- const contentCaps = contentCfg.enabled ? ["content.diff", "content.file"] : [];
58
+ const contentCaps = contentCfg.enabled ? ["content.diff", "content.file", "content.file.list"] : []; // content.file.list: F3-07c, how the front finds out what exists (the gateway only sends file.list to agents that announce it)
59
+ // F3-11b: incident detector (logs -> incident.candidate, metadata only), behind LOCAL_AGENT_INCIDENT_DETECTOR (default off: nothing below changes anything)
60
+ const { resolveIncidentDetectorConfig } = await import("../incident-detector/config.js");
61
+ const incidentCfg = resolveIncidentDetectorConfig(env, (m) => log.warn(m));
62
+ const hasIncidentSource = incidentCfg.enabled && (incidentCfg.files.length > 0 || incidentCfg.stdin || !!incidentCfg.loki);
63
+ const incidentCaps = hasIncidentSource ? ["incident.candidate"] : [];
59
64
  const link = createV2Link({
60
65
  log,
61
66
  sessionOptions: {
@@ -65,7 +70,7 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
65
70
  signing,
66
71
  laVersion: agentVersion(),
67
72
  // only what this build implements: the transport, the handshake and the v1 commands on the same connection
68
- capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps],
73
+ capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps],
69
74
  getWorkspaces: () => {
70
75
  const open = (wsManager?.list?.() || []).map((w) => ({
71
76
  workspaceId: String(w.id || ""),
@@ -87,5 +92,9 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
87
92
  const { installContentHandlers } = await import("./content-handlers/install.js");
88
93
  installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log });
89
94
  }
95
+ if (incidentCfg.enabled) {
96
+ const { installIncidentDetector } = await import("../incident-detector/index.js");
97
+ installIncidentDetector({ link, workspaceId: WORKSPACE_ID.test(identityWs) ? identityWs : "", config: incidentCfg, env, log });
98
+ }
90
99
  return link;
91
100
  }
@@ -3772,6 +3772,38 @@
3772
3772
  "last": true
3773
3773
  }
3774
3774
  }
3775
+ },
3776
+ {
3777
+ "name": "file-list-kind",
3778
+ "why": "ids of folders (kind file.list) are announced like any other id",
3779
+ "expect": {
3780
+ "valid": true
3781
+ },
3782
+ "message": {
3783
+ "schemaVersion": 2,
3784
+ "type": "content.index",
3785
+ "msgId": "00000000-0000-4000-8000-000000000001",
3786
+ "seq": 1,
3787
+ "tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
3788
+ "workspaceId": "ws-1",
3789
+ "ts": "2026-11-03T10:00:00Z",
3790
+ "agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
3791
+ "payload": {
3792
+ "entries": [
3793
+ {
3794
+ "kind": "file.list",
3795
+ "ref": "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk",
3796
+ "size": 0
3797
+ },
3798
+ {
3799
+ "kind": "file",
3800
+ "ref": "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo",
3801
+ "size": 2048,
3802
+ "updatedAt": "2026-11-03T09:30:00Z"
3803
+ }
3804
+ ]
3805
+ }
3806
+ }
3775
3807
  }
3776
3808
  ]
3777
3809
  }