deepdebug-local-agent 1.3.0 → 1.5.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 (123) hide show
  1. package/.github/workflows/deploy-gcp.yml +58 -26
  2. package/CHANGELOG.md +6 -0
  3. package/docs/la-protocol-v2.md +102 -0
  4. package/package.json +2 -1
  5. package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
  6. package/src/la-protocol-v2/chunks.js +45 -0
  7. package/src/la-protocol-v2/config.js +27 -0
  8. package/src/la-protocol-v2/content-handlers/audit.js +36 -0
  9. package/src/la-protocol-v2/content-handlers/config.js +48 -0
  10. package/src/la-protocol-v2/content-handlers/egress.js +65 -0
  11. package/src/la-protocol-v2/content-handlers/git-diff.js +92 -0
  12. package/src/la-protocol-v2/content-handlers/handler.js +170 -0
  13. package/src/la-protocol-v2/content-handlers/index.js +6 -0
  14. package/src/la-protocol-v2/content-handlers/install.js +50 -0
  15. package/src/la-protocol-v2/content-handlers/path-guard.js +142 -0
  16. package/src/la-protocol-v2/content-handlers/ref-registry.js +87 -0
  17. package/src/la-protocol-v2/content.js +63 -0
  18. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
  19. package/src/la-protocol-v2/contract/README.md +103 -0
  20. package/src/la-protocol-v2/contract/ack.schema.json +51 -0
  21. package/src/la-protocol-v2/contract/alert.schema.json +62 -0
  22. package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
  23. package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
  24. package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
  25. package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
  26. package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
  27. package/src/la-protocol-v2/contract/defs.schema.json +191 -0
  28. package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
  29. package/src/la-protocol-v2/contract/error.schema.json +59 -0
  30. package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
  31. package/src/la-protocol-v2/contract/hello.schema.json +111 -0
  32. package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
  33. package/src/la-protocol-v2/contract/index.json +166 -0
  34. package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
  35. package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
  36. package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
  37. package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
  38. package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
  39. package/src/la-protocol-v2/contract/ready.schema.json +106 -0
  40. package/src/la-protocol-v2/contract/register.schema.json +92 -0
  41. package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
  42. package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
  43. package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
  44. package/src/la-protocol-v2/contract.js +90 -0
  45. package/src/la-protocol-v2/errors.js +34 -0
  46. package/src/la-protocol-v2/index.js +8 -0
  47. package/src/la-protocol-v2/link.js +93 -0
  48. package/src/la-protocol-v2/runtime.js +81 -0
  49. package/src/la-protocol-v2/session.js +650 -0
  50. package/src/la-protocol-v2/strict-json.js +47 -0
  51. package/src/server.js +25 -4
  52. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
  53. package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
  54. package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
  55. package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
  56. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
  57. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
  58. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
  59. package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
  60. package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
  61. package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
  62. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
  63. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
  64. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
  65. package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
  66. package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
  67. package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
  68. package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
  69. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
  70. package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
  71. package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
  72. package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
  73. package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
  74. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
  75. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
  76. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
  77. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
  78. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
  79. package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
  80. package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
  81. package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
  82. package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
  83. package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
  84. package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
  85. package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
  86. package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
  87. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
  88. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
  89. package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
  90. package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
  91. package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
  92. package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
  93. package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
  94. package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
  95. package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
  96. package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
  97. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
  98. package/test/la-protocol-v2/content-handlers.test.js +633 -0
  99. package/test/la-protocol-v2/content-path-guard.test.js +214 -0
  100. package/test/la-protocol-v2/content-wiring.test.js +225 -0
  101. package/test/la-protocol-v2/contract.test.js +131 -0
  102. package/test/la-protocol-v2/helpers/content-rig.js +94 -0
  103. package/test/la-protocol-v2/helpers/la-rig.js +83 -0
  104. package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
  105. package/test/la-protocol-v2/helpers/spawn-agent.js +36 -0
  106. package/test/la-protocol-v2/link.test.js +141 -0
  107. package/test/la-protocol-v2/session-inbound.test.js +279 -0
  108. package/test/la-protocol-v2/session-outbound.test.js +382 -0
  109. package/test/la-protocol-v2/traces.test.js +270 -0
  110. package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
  111. package/tools/graphify-validation/extras/README.md +19 -0
  112. package/tools/graphify-validation/extras/bymethod.py +35 -0
  113. package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
  114. package/tools/graphify-validation/extras/docker/inner.sh +7 -0
  115. package/tools/graphify-validation/extras/g10.py +59 -0
  116. package/tools/graphify-validation/extras/g3aug.py +49 -0
  117. package/tools/graphify-validation/extras/hybrid.py +34 -0
  118. package/tools/graphify-validation/extras/truth2.py +44 -0
  119. package/tools/interop/LaV2NodeContentInteropTest.java +248 -0
  120. package/tools/interop/LaV2NodeInteropTest.java +266 -0
  121. package/tools/interop/README.md +38 -0
  122. package/tools/interop/la-interop-client.mjs +97 -0
  123. package/tools/interop/la-interop-content-client.mjs +91 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * handler.js - `content.request` handlers of the Local Agent (F3-07). Registered by runtime.js only when
3
+ * LOCAL_AGENT_PROTOCOL_V2=true AND LOCAL_AGENT_CONTENT_HANDLERS=true.
4
+ *
5
+ * Order of every request (nothing is read before the checks, nothing leaves before the DLP):
6
+ * 1. accept: the handler answers the session at once ({accept:true} = ack `accepted`); the work goes on in the
7
+ * background (the first chunk may take seconds, the ack timeout is 5 s). A request without requestId is refused.
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`
10
+ * (the contract has no "not supported" status; this is the code already used for "no handler").
11
+ * 4. workspace: the envelope's workspace must be registered here (and belong to this tenant) -> else `not_found`.
12
+ * 5. ref: an opaque id (ref-registry.js) valid for this tenant+workspace+root+kind -> else `not_found`, whatever the
13
+ * reason (unknown, expired, other workspace, or "../etc/passwd": no oracle, nothing echoed). The ref is never
14
+ * interpreted as a path.
15
+ * 6. path (file): realpath inside the root, not sensitive, regular file, <= maxBytes (path-guard.js) -> `forbidden` /
16
+ * `not_found` / `payload_too_large`.
17
+ * 7. DLP + range + size (egress.js), then 8. sendContent: <= 32 KiB per chunk, <= 64 chunks, window + backpressure of the
18
+ * session, aborted by cancel()/deadline/connection loss.
19
+ * Answers carry a status and a wire code only. Logs, errors and the audit never contain a path, a ref or content.
20
+ */
21
+ import { ProtocolError } from "../errors.js";
22
+ import { sendContent, sendContentFailure } from "../content.js";
23
+ import { REF_KINDS } from "./ref-registry.js";
24
+ import { normalizeRel, resolveInside, readRegular } from "./path-guard.js";
25
+ import { worktreeDiff } from "./git-diff.js";
26
+ import { prepareOutgoing, contentTypeOf, decodeText } from "./egress.js";
27
+ import { createAudit, short } from "./audit.js";
28
+
29
+ export const SERVED_KINDS = Object.freeze(["file", "diff"]);
30
+ /** Symbolic ref of kind `diff`: the whole work tree of the envelope's workspace (carries no client data). */
31
+ export const WORKTREE_REF = "worktree";
32
+
33
+ export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now }) {
34
+ const inflight = new Map(); // requestId -> { ac, timer }
35
+ let reservedBytes = 0;
36
+ const auditor = audit || createAudit({ file: config.auditFile, now });
37
+ const reserveEach = 2 * config.maxBytes;
38
+ const warn = (m) => (log.warn || log.log).call(log, m);
39
+
40
+ const stats = { received: 0, ok: 0, notFound: 0, forbidden: 0, error: 0, cancelled: 0, byCode: {} };
41
+ const bump = (k) => { stats.byCode[k] = (stats.byCode[k] || 0) + 1; };
42
+
43
+ /** @returns {Promise<{status:string, errorCode?:string, buffer?:Buffer, contentType?:string, dlp?:object}>} */
44
+ async function produce(ctx, p, signal) {
45
+ if (!SERVED_KINDS.includes(p.kind)) return { status: "error", errorCode: "protocol_unsupported" };
46
+ const ws = await resolveWorkspace(ctx.workspaceId);
47
+ if (!ws || !ws.root) return { status: "not_found" };
48
+ const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: p.kind });
49
+ if (p.kind === "diff" && !target && p.ref === WORKTREE_REF) return produceDiff(p, ws, null, signal);
50
+ if (!target) return { status: "not_found" };
51
+ if (p.kind === "diff") return produceDiff(p, ws, target.rel, signal);
52
+ return produceFile(p, ws, target.rel, signal);
53
+ }
54
+
55
+ async function produceFile(p, ws, rel, signal) {
56
+ const norm = rel && normalizeRel(rel);
57
+ if (!norm) return { status: "not_found" };
58
+ const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs);
59
+ if (!resolved.ok) return { status: resolved.status };
60
+ if (signal.aborted) return { status: "error", errorCode: "agent_offline" };
61
+ const read = await readRegular(resolved, config.maxBytes, signal);
62
+ if (!read.ok) return read.status === "too_large" ? { status: "error", errorCode: "payload_too_large" } : { status: read.status };
63
+ const isText = decodeText(read.buffer) !== null;
64
+ return finish(p, read.buffer, contentTypeOf(norm, isText), "content.file");
65
+ }
66
+
67
+ async function produceDiff(p, ws, rel, signal) {
68
+ const d = await worktreeDiff({ root: ws.root, rel, maxBytes: config.maxBytes, maxFiles: config.maxDiffFiles,
69
+ timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, signal });
70
+ if (!d.ok) return d;
71
+ return finish(p, Buffer.from(d.text, "utf8"), "text/x-diff", "content.diff");
72
+ }
73
+
74
+ function finish(p, buffer, contentType, source) {
75
+ const out = prepareOutgoing({ buffer, contentType, range: p.range, maxBytes: config.maxBytes, env, source });
76
+ return out.ok ? { status: "ok", buffer: out.buffer, contentType: out.contentType, dlp: out.dlp }
77
+ : { status: out.status, errorCode: out.errorCode, dlp: out.dlp };
78
+ }
79
+
80
+ async function run(ctx, p, entry) {
81
+ const t0 = now();
82
+ let result;
83
+ let sent = null;
84
+ let errorCode;
85
+ try {
86
+ if (inflight.size > config.maxConcurrent || reservedBytes + reserveEach > config.maxInFlightBytes) {
87
+ result = { status: "error", errorCode: "rate_limited" };
88
+ } else {
89
+ reservedBytes += reserveEach;
90
+ entry.reserved = true;
91
+ result = await produce(ctx, p, entry.ac.signal);
92
+ }
93
+ if (entry.ac.signal.aborted) {
94
+ errorCode = "cancelled";
95
+ // out of time before the first chunk: the gateway still waits for an answer, give it one (a code only)
96
+ if (entry.reason === "deadline") {
97
+ await sendContentFailure(session, { requestId: ctx.requestId, status: "error", errorCode: "timeout", workspaceId: ctx.workspaceId }).catch(() => {});
98
+ errorCode = "timeout";
99
+ }
100
+ } else if (result.status === "ok") {
101
+ try {
102
+ sent = await sendContent(session, { requestId: ctx.requestId, source: result.buffer, contentType: result.contentType,
103
+ workspaceId: ctx.workspaceId, signal: entry.ac.signal });
104
+ } catch (e) {
105
+ errorCode = e instanceof ProtocolError ? (entry.ac.signal.aborted ? "cancelled" : e.code) : "internal";
106
+ }
107
+ } else {
108
+ await sendContentFailure(session, { requestId: ctx.requestId, status: result.status,
109
+ errorCode: result.errorCode || null, workspaceId: ctx.workspaceId });
110
+ }
111
+ } catch (e) {
112
+ errorCode = e instanceof ProtocolError ? e.code : "internal";
113
+ } finally {
114
+ clearTimeout(entry.timer);
115
+ if (entry.reserved) reservedBytes -= reserveEach;
116
+ inflight.delete(ctx.requestId);
117
+ }
118
+ const status = errorCode === "cancelled" || errorCode === "timeout" ? errorCode : errorCode ? "send_failed" : result.status;
119
+ if (status === "ok") stats.ok++;
120
+ else if (status === "not_found") stats.notFound++;
121
+ else if (status === "forbidden") stats.forbidden++;
122
+ else if (status === "cancelled" || status === "timeout") stats.cancelled++;
123
+ else stats.error++;
124
+ 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
+ });
131
+ if (status === "send_failed") warn(`[la-content] ${p.kind} not delivered: ${errorCode}`);
132
+ }
133
+
134
+ return {
135
+ stats,
136
+ audit: auditor,
137
+ get inflight() { return inflight.size; },
138
+ get reservedBytes() { return reservedBytes; },
139
+
140
+ /** The session handler for `content.request` (session.setHandler). */
141
+ handle(ctx) {
142
+ stats.received++;
143
+ const p = ctx.params;
144
+ if (!ctx.requestId) return { reject: "schema_violation" };
145
+ if (inflight.has(ctx.requestId)) return { accept: true }; // already working on it (a retransmission with another seq)
146
+ const entry = { ac: new AbortController(), timer: null, reserved: false, reason: null };
147
+ entry.timer = setTimeout(() => { entry.reason = "deadline"; entry.ac.abort(); }, config.totalMs);
148
+ inflight.set(ctx.requestId, entry);
149
+ // background: ack first, answer later
150
+ queueMicrotask(() => { run(ctx, p, entry).catch(() => {}); });
151
+ return { accept: true };
152
+ },
153
+
154
+ /** Stops one request (no further chunk is read or written). There is no content.cancel frame in the contract. */
155
+ cancel(requestId) {
156
+ const e = inflight.get(requestId);
157
+ if (!e) return false;
158
+ e.reason = e.reason || "cancel";
159
+ e.ac.abort();
160
+ session.cancelWaiting(requestId);
161
+ return true;
162
+ },
163
+
164
+ cancelAll() {
165
+ for (const id of [...inflight.keys()]) this.cancel(id);
166
+ },
167
+ };
168
+ }
169
+
170
+ export { REF_KINDS };
@@ -0,0 +1,6 @@
1
+ export { CONTENT_HANDLERS_FLAG, resolveContentHandlersConfig } from "./config.js";
2
+ export { ContentRefRegistry, REF_PATTERN } from "./ref-registry.js";
3
+ export { createContentHandler, SERVED_KINDS, WORKTREE_REF } from "./handler.js";
4
+ export { installContentHandlers, CONTENT_CAPABILITIES } from "./install.js";
5
+ export { isSensitivePath, normalizeRel, resolveInside, DEFAULT_DENY_GLOBS } from "./path-guard.js";
6
+ export { createAudit } from "./audit.js";
@@ -0,0 +1,50 @@
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:
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).
9
+ */
10
+ import { dlpConfigFromEnv } from "../../dlp/index.js";
11
+ import { ContentRefRegistry } from "./ref-registry.js";
12
+ import { createContentHandler, SERVED_KINDS } from "./handler.js";
13
+
14
+ export const CONTENT_CAPABILITIES = Object.freeze(SERVED_KINDS.map((k) => `content.${k}`));
15
+
16
+ export function installContentHandlers({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console }) {
17
+ const tenant = String(tenantId).toLowerCase();
18
+ const registry = new ContentRefRegistry({ tenantId: tenant, ttlMs: config.refTtlMs, maxEntries: config.maxRefs, denyGlobs: config.extraDenyGlobs });
19
+
20
+ /** A workspace "registered here": open in the manager (of this tenant, or of none yet) or the default root. */
21
+ const resolveWorkspace = (workspaceId) => {
22
+ if (typeof workspaceId !== "string" || !workspaceId) return null;
23
+ const listed = (wsManager?.list?.() || []).find((w) => w && w.id === workspaceId);
24
+ if (listed) {
25
+ if (listed.tenantId && String(listed.tenantId).toLowerCase() !== tenant) return null;
26
+ return listed.root ? { root: listed.root } : null;
27
+ }
28
+ if (workspaceId === "default") { const root = getWorkspaceRoot(); return root ? { root } : null; }
29
+ return null;
30
+ };
31
+
32
+ const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log });
33
+ link.session.setHandler("content.request", (ctx) => handler.handle(ctx));
34
+
35
+ const closeLink = link.close.bind(link);
36
+ link.close = () => { handler.cancelAll(); registry.clear(); closeLink(); };
37
+
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
+ link.content = {
42
+ registry,
43
+ handler,
44
+ mintRef: ({ workspaceId, kind, rel }) => {
45
+ const ws = resolveWorkspace(workspaceId);
46
+ return ws ? registry.mint({ workspaceId, root: ws.root, kind, rel }) : null;
47
+ },
48
+ };
49
+ return link.content;
50
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * path-guard.js - the Local Agent is the authority on what a content.request may read (F3-07).
3
+ * The gateway lets anything through (it only sees an opaque id); every check that matters is here.
4
+ *
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/, ...
7
+ * resolveInside realpath of root and target; the real target must stay inside the real root (a symlink that
8
+ * leaves the workspace is `forbidden`), must not be sensitive under its REAL name either
9
+ * (a harmless-looking link to .env), and must be a regular file.
10
+ * readRegular opens with O_NOFOLLOW, checks it is the same inode that was checked, reads at most `maxBytes`.
11
+ *
12
+ * 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).
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
20
+
21
+ /** Built-in deny list. A pattern with no `/` matches any path segment; with `/` it matches a path (or its prefix). */
22
+ export const DEFAULT_DENY_GLOBS = Object.freeze([
23
+ // secrets by name
24
+ ".env", ".env.*", "*.env",
25
+ "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", "*.p8", "*.ppk", "*.kdbx", "*.gpg", "*.crt.key",
26
+ "id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*",
27
+ ".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",
29
+ "*.secret", "*.secrets", "*.tfstate", "*.tfstate.*", "*.tfvars", "*.tfvars.json", "terraform.rc",
30
+ // directories that hold credentials or the repository internals (.git/config may carry a token in a URL)
31
+ ".git/", ".ssh/", ".aws/", ".gnupg/", ".kube/", ".docker/", ".azure/", ".terraform/", ".config/gcloud/",
32
+ ]);
33
+
34
+ const RE_CACHE = new Map();
35
+
36
+ function globToRegExp(glob) {
37
+ const g = glob.toLowerCase();
38
+ const dir = g.endsWith("/");
39
+ const body = dir ? g.slice(0, -1) : g;
40
+ let re = "";
41
+ for (let i = 0; i < body.length; i++) {
42
+ const c = body[i];
43
+ if (c === "*") {
44
+ if (body[i + 1] === "*") { re += ".*"; i++; } else re += "[^/]*";
45
+ } else if (c === "?") re += "[^/]";
46
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
47
+ }
48
+ // anchored at a segment boundary on the left; on the right it must end the path or be followed by `/` (a directory prefix)
49
+ return new RegExp(`(?:^|/)${re}(?:/|$)`);
50
+ }
51
+
52
+ function regexFor(glob) {
53
+ let r = RE_CACHE.get(glob);
54
+ if (!r) { r = globToRegExp(glob); RE_CACHE.set(glob, r); if (RE_CACHE.size > 1000) RE_CACHE.delete(RE_CACHE.keys().next().value); }
55
+ return r;
56
+ }
57
+
58
+ /** Lower-case, posix, and without the trailing dots/spaces that Windows ignores (".env." is ".env"). */
59
+ function foldForMatch(rel) {
60
+ return rel.split("/").map((seg) => seg.replace(/[. ]+$/, "") || seg).join("/").toLowerCase();
61
+ }
62
+
63
+ export function isSensitivePath(rel, extraGlobs = []) {
64
+ if (typeof rel !== "string") return true;
65
+ 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;
68
+ return false;
69
+ }
70
+
71
+ const CONTROL = /[\u0000-\u001f\u007f]/;
72
+
73
+ /** @returns {string | null} posix relative path, or null when it is not an acceptable relative path */
74
+ export function normalizeRel(rel) {
75
+ if (typeof rel !== "string" || rel.length === 0 || rel.length > 1024) return null;
76
+ if (CONTROL.test(rel)) return null;
77
+ const p = rel.replace(/\\/g, "/");
78
+ if (p.startsWith("/") || /^[A-Za-z]:/.test(p)) return null;
79
+ const parts = [];
80
+ for (const seg of p.split("/")) {
81
+ if (seg === "" || seg === ".") continue;
82
+ if (seg === "..") return null;
83
+ parts.push(seg);
84
+ }
85
+ return parts.length ? parts.join("/") : null;
86
+ }
87
+
88
+ const toPosix = (p) => p.split(path.sep).join("/");
89
+ const inside = (root, abs) => abs === root || abs.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
90
+
91
+ /**
92
+ * @param {string} root the workspace root as registered
93
+ * @param {string} rel relative path already normalized (normalizeRel)
94
+ * @returns {Promise<{ok:true, real:string, relReal:string, stat:fs.Stats} | {ok:false, status:"not_found"|"forbidden"}>}
95
+ */
96
+ export async function resolveInside(root, rel, extraGlobs = []) {
97
+ let rootReal;
98
+ try { rootReal = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
99
+ const abs = path.join(rootReal, ...rel.split("/"));
100
+ if (!inside(rootReal, abs)) return { ok: false, status: "forbidden" };
101
+ if (isSensitivePath(rel, extraGlobs)) return { ok: false, status: "forbidden" };
102
+ let real;
103
+ try { real = await fs.promises.realpath(abs); } catch (e) { return { ok: false, status: e && (e.code === "EACCES" || e.code === "EPERM") ? "forbidden" : "not_found" }; }
104
+ if (!inside(rootReal, real)) return { ok: false, status: "forbidden" }; // symlink that leaves the workspace
105
+ const relReal = toPosix(path.relative(rootReal, real));
106
+ if (!relReal || isSensitivePath(relReal, extraGlobs)) return { ok: false, status: "forbidden" }; // link to a sensitive file
107
+ let st;
108
+ try { st = await fs.promises.lstat(real); } catch { return { ok: false, status: "not_found" }; }
109
+ if (!st.isFile()) return { ok: false, status: "not_found" };
110
+ return { ok: true, real, relReal, stat: st };
111
+ }
112
+
113
+ /**
114
+ * Reads the whole file (<= maxBytes) through a descriptor opened without following links and verified to be the
115
+ * file that resolveInside() checked.
116
+ * @returns {Promise<{ok:true, buffer:Buffer} | {ok:false, status:"not_found"|"forbidden"|"too_large"}>}
117
+ */
118
+ export async function readRegular(resolved, maxBytes, signal) {
119
+ if (resolved.stat.size > maxBytes) return { ok: false, status: "too_large" };
120
+ let fh;
121
+ try {
122
+ fh = await fs.promises.open(resolved.real, fs.constants.O_RDONLY | O_NOFOLLOW);
123
+ const st = await fh.stat();
124
+ if (!st.isFile()) return { ok: false, status: "not_found" };
125
+ if (resolved.stat.ino !== 0 && (st.ino !== resolved.stat.ino || st.dev !== resolved.stat.dev)) return { ok: false, status: "forbidden" };
126
+ if (st.size > maxBytes) return { ok: false, status: "too_large" };
127
+ const buf = Buffer.allocUnsafe(Math.min(st.size, maxBytes) + 1);
128
+ let n = 0;
129
+ while (n < buf.length) {
130
+ if (signal && signal.aborted) return { ok: false, status: "not_found" };
131
+ const { bytesRead } = await fh.read(buf, n, buf.length - n, n);
132
+ if (bytesRead === 0) break;
133
+ n += bytesRead;
134
+ }
135
+ if (n > maxBytes) return { ok: false, status: "too_large" }; // grew while reading
136
+ return { ok: true, buffer: Buffer.from(buf.subarray(0, n)) };
137
+ } catch (e) {
138
+ return { ok: false, status: e && (e.code === "ELOOP" || e.code === "EACCES" || e.code === "EPERM") ? "forbidden" : "not_found" };
139
+ } finally {
140
+ if (fh) await fh.close().catch(() => {});
141
+ }
142
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * ref-registry.js - opaque content ids (F3-07).
3
+ *
4
+ * The control plane never sees a path: the `ref` of a `content.request` is an id that THIS agent minted when it
5
+ * reported metadata (a file list, a diff summary) and that only this agent can map back to (workspace, relative path).
6
+ *
7
+ * id "cr1_" + 22 base64url chars = 128 random bits from crypto.randomBytes. NOT derived from the path
8
+ * (no hash, no HMAC of it): knowing a path tells nothing about its id and the other way round.
9
+ * validity `ttlMs` from the mint (default 15 min); expired ids are gone (not_found), the CP asks for a new listing.
10
+ * In memory only: a restart of the agent forgets every id (also not_found), by design.
11
+ * binding an id is valid for ONE tenant, ONE workspace (and the root that workspace had when it was minted),
12
+ * ONE kind. Any other combination resolves to null, indistinguishable from "never existed".
13
+ * size at most `maxEntries`; the oldest go first.
14
+ *
15
+ * Nothing here logs, throws a message with a path, or exposes an id's target to anything but resolve().
16
+ */
17
+ import crypto from "node:crypto";
18
+ import { normalizeRel, isSensitivePath } from "./path-guard.js";
19
+
20
+ export const REF_PATTERN = /^cr1_[A-Za-z0-9_-]{22}$/;
21
+ export const REF_KINDS = Object.freeze(["file", "diff"]);
22
+
23
+ export class ContentRefRegistry {
24
+ constructor({ tenantId, ttlMs = 15 * 60_000, maxEntries = 20_000, now = Date.now, denyGlobs = [] } = {}) {
25
+ this.tenantId = tenantId;
26
+ this.ttlMs = ttlMs;
27
+ this.maxEntries = maxEntries;
28
+ this.now = now;
29
+ this.denyGlobs = denyGlobs;
30
+ this.entries = new Map(); // id -> { tenantId, workspaceId, root, kind, rel, expiresAt } (insertion order = age)
31
+ }
32
+
33
+ get size() { return this.entries.size; }
34
+
35
+ #purge() {
36
+ const t = this.now();
37
+ for (const [id, e] of this.entries) {
38
+ if (e.expiresAt > t) break; // insertion order is expiry order (one ttl for all)
39
+ this.entries.delete(id);
40
+ }
41
+ while (this.entries.size > this.maxEntries) this.entries.delete(this.entries.keys().next().value);
42
+ }
43
+
44
+ /**
45
+ * @param {{workspaceId:string, root:string, kind:"file"|"diff", rel?:string|null}} o
46
+ * rel = path relative to the workspace root (file: required; diff: optional, null = the whole work tree)
47
+ * @returns {{id:string, expiresAt:number} | null} null = refused (bad kind/path or a sensitive file): no id exists
48
+ */
49
+ mint({ workspaceId, root, kind, rel = null }) {
50
+ if (!REF_KINDS.includes(kind) || typeof workspaceId !== "string" || !workspaceId || typeof root !== "string" || !root) return null;
51
+ let norm = null;
52
+ if (rel !== null && rel !== undefined) {
53
+ norm = normalizeRel(rel);
54
+ if (!norm || isSensitivePath(norm, this.denyGlobs)) return null; // never hand out an id for a file we would refuse
55
+ } else if (kind === "file") {
56
+ return null;
57
+ }
58
+ this.#purge();
59
+ const id = `cr1_${crypto.randomBytes(16).toString("base64url")}`;
60
+ const expiresAt = this.now() + this.ttlMs;
61
+ this.entries.set(id, { tenantId: this.tenantId, workspaceId, root, kind, rel: norm, expiresAt });
62
+ this.#purge(); // capacity includes the new entry
63
+ return { id, expiresAt };
64
+ }
65
+
66
+ /**
67
+ * @returns {{rel:string|null}} | null null for: not an id, unknown, expired, other tenant/workspace/root/kind
68
+ */
69
+ resolve(id, { workspaceId, root, kind }) {
70
+ if (typeof id !== "string" || !REF_PATTERN.test(id)) return null;
71
+ const e = this.entries.get(id);
72
+ if (!e) return null;
73
+ if (e.expiresAt <= this.now()) { this.entries.delete(id); return null; }
74
+ if (e.tenantId !== this.tenantId || e.workspaceId !== workspaceId || e.root !== root || e.kind !== kind) return null;
75
+ return { rel: e.rel };
76
+ }
77
+
78
+ revoke(id) { return this.entries.delete(id); }
79
+
80
+ revokeWorkspace(workspaceId) {
81
+ let n = 0;
82
+ for (const [id, e] of this.entries) if (e.workspaceId === workspaceId) { this.entries.delete(id); n++; }
83
+ return n;
84
+ }
85
+
86
+ clear() { this.entries.clear(); }
87
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * content.js - sends content to the control plane as a stream of `content.response` chunks (the relay of
3
+ * the design: never persisted or logged by the CP). TRANSPORT ONLY: no handler of `content.request` lives
4
+ * here (F3-07) and nothing here decides what may leave the machine (DLP is F3-09, before this is called).
5
+ *
6
+ * Rules (contract): <= 32 KiB raw per chunk (43692 base64 chars, frame <= 48 KiB), <= 64 chunks (2 MiB per
7
+ * response), chunk.index from 0, total constant, last only on the final one, byteLength = decoded size.
8
+ * Each chunk is its own data frame: numbered, acked, retransmitted. At most `maxInFlight` chunks are in
9
+ * flight and the next one is not even read until a slot opens (bounded memory for any size).
10
+ */
11
+ import { ProtocolError } from "./errors.js";
12
+ import { chunkCount } from "./chunks.js";
13
+
14
+ /**
15
+ * @param {import("./session.js").V2Session} session
16
+ * @param {object} o
17
+ * @param {string} o.requestId the content.request being answered (lower-case UUID)
18
+ * @param {Buffer | {size:number, read:(offset:number, length:number)=>Promise<Buffer>}} o.source
19
+ * @param {string} [o.contentType] one of the contract's content types
20
+ * @param {string} [o.workspaceId]
21
+ * @param {AbortSignal} [o.signal] abort stops reading and drops the chunks still waiting for a slot
22
+ * @returns {Promise<{chunks:number, bytes:number}>} resolves when the last chunk was acked
23
+ */
24
+ export async function sendContent(session, { requestId, source, contentType = "application/octet-stream", workspaceId, signal }) {
25
+ const size = Buffer.isBuffer(source) ? source.length : source.size;
26
+ if (!Number.isSafeInteger(size) || size < 0) throw new ProtocolError("internal", "source size");
27
+ const { chunkRawBytes, maxChunks } = session.limits;
28
+ const total = chunkCount(size, chunkRawBytes);
29
+ if (total > maxChunks) throw new ProtocolError("payload_too_large", "response above the chunk limit");
30
+ const acks = [];
31
+ // an abort must also free a chunk that is waiting for a window slot (enqueue() does not look at the signal)
32
+ const onAbort = () => session.cancelWaiting(requestId);
33
+ if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }
34
+ try {
35
+ for (let index = 0; index < total; index++) {
36
+ if (signal && signal.aborted) throw new ProtocolError("agent_offline", "cancelled");
37
+ const offset = index * chunkRawBytes;
38
+ const length = Math.min(chunkRawBytes, size - offset);
39
+ const bytes = Buffer.isBuffer(source) ? source.subarray(offset, offset + length) : await source.read(offset, length);
40
+ if (!Buffer.isBuffer(bytes) || bytes.length !== length) throw new ProtocolError("internal", "source returned a wrong length");
41
+ const h = await session.enqueue("content.response", {
42
+ status: "ok", contentType, encoding: "base64", data: bytes.toString("base64"), byteLength: length, totalBytes: size,
43
+ }, { requestId, workspaceId, chunk: { index, total, last: index === total - 1 } });
44
+ acks.push(h.acked);
45
+ }
46
+ } catch (e) {
47
+ session.cancelWaiting(requestId);
48
+ for (const a of acks) a.catch(() => {});
49
+ throw e;
50
+ } finally {
51
+ if (signal) signal.removeEventListener("abort", onAbort);
52
+ }
53
+ await Promise.all(acks);
54
+ return { chunks: total, bytes: size };
55
+ }
56
+
57
+ /** A failed answer: one empty chunk with the status (error | not_found | forbidden) and a wire error code. */
58
+ export async function sendContentFailure(session, { requestId, status = "error", errorCode = "internal", workspaceId }) {
59
+ // errorCode: null = none on the wire (not_found / forbidden need no code); undefined = "internal" (as before F3-07)
60
+ await session.send("content.response", {
61
+ status, ...(errorCode === null ? {} : { errorCode }), encoding: "base64", data: "", byteLength: 0,
62
+ }, { requestId, workspaceId, chunk: { index: 0, total: 1, last: true } });
63
+ }
@@ -0,0 +1,81 @@
1
+ {
2
+ "repo": "VISIONHAMMER/vh-api-gateway",
3
+ "path": "docs/contracts/la-protocol-v2",
4
+ "branch": "release/qa",
5
+ "commit": "b090144e19b1da0182554553b18f321452f03bd6",
6
+ "branchTip": "7043f75e3ed1b201e2c958fd52f721b5a1177efe",
7
+ "note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
8
+ "files": {
9
+ "README.md": "41dd3cbceccb487ca30da5838841f04d8abffcc81c31424da833114ab646b291",
10
+ "ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
11
+ "alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
12
+ "approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
13
+ "audit.event.schema.json": "e9ceb58ea00077151373955957b8319324de488c80da4063f6d0dcb40a8b3ed0",
14
+ "config.update.schema.json": "f5318f24230f542c76a3cb996d08f33210c0b2cc2fc55ad73c4f95648582033f",
15
+ "content.request.schema.json": "9966b55ec051832ac93ee43e9cd226e4f0efaeb49741947bfc48193e40e00a32",
16
+ "content.response.schema.json": "c9a66ac9cbbcaec7fc182b3e3fa23b34877bd342592521c7e393779230332231",
17
+ "defs.schema.json": "f19fd27fd103d8da02de4506c086263c6236250c5c127aa3aab41833a90b743a",
18
+ "envelope.schema.json": "670fb26c7d532475da144185e5100b3a2ffe0d267451b1ef9debadf31b33ab79",
19
+ "error.schema.json": "b1fd663d4a1ea73d88cb575422f15f8c7279001f9385ebb2d5e1889b5d5ef878",
20
+ "heartbeat.schema.json": "8aff84a1ef3ca56bdff8819abb3b0ab01a2547814c2ea2f9ee893cb08f7a08f1",
21
+ "hello.schema.json": "bd500320f01ff10da49ded6f99c7e8081252019984afb3c178e27003df20b963",
22
+ "incident.candidate.schema.json": "b292a85917b5958fc6de10a2895732e30a35420385623930c78e47341862dc62",
23
+ "index.json": "55543ecf492b1ffe2b9a78075890ee05518d3539fbcaeb04e8b2da8ff37c9130",
24
+ "job.assign.schema.json": "fdebb4ae98bf38a2c723281c1cf9477f71d877171df9a55b0baeb0b65244f60c",
25
+ "job.cancel.schema.json": "40ef64373283e17306d857c9a40081689786015294b0b88ab8f68f063bd2cfb8",
26
+ "job.event.schema.json": "493d08f9db4f7ffd4a97403ee33ca4a73a65dc8f774d8f18ef0a137fdb5fc470",
27
+ "job.result.schema.json": "9cbc61211482557283b8f4df44e79011ead1abbacdb2d986138199c747edc043",
28
+ "log.stats.schema.json": "4b33b111946a19793a60756793294e90911b06f7050e48821759f2497ec17641",
29
+ "ready.schema.json": "6e8bd8e1a5ce1b840d35af911ae09b8f58ad9de59eb33cd2b7777859892813f4",
30
+ "register.schema.json": "e75a4aa9d5d750e1473f71ab2f37d8e9a82fcfab3e8ff1c35118d0c08916e795",
31
+ "scan.upload.schema.json": "b1bba86ee362f73afc94c8c216977c12f67f770490e81002d14789397692f23b",
32
+ "scan.uploaded.schema.json": "f4c6aed7b9363b3ce1a9e14f9c4f4fb003bcf104b60acf74421dd0c75fc5d229",
33
+ "webhook.forward.schema.json": "33d4ee238855339850ff198d1078087f3b2f48c8f290b71def93cc02dd32c985",
34
+ "vectors/ack.vectors.json": "242ec2b8ff0c748eaaedce63e0015cf5bd9a7092d1055b0670ce6b95403b54b2",
35
+ "vectors/alert.vectors.json": "0bf179ff390bbecf07a117b80237cb7ac8778864e5780f36d68c8c9a64db8f04",
36
+ "vectors/approval.decision.vectors.json": "338115973bf2c4390d2bb33ce8f4079f3132cd739cd5505e1e873fdc252d2271",
37
+ "vectors/audit.event.vectors.json": "d05c1820103eef041a84eb8b13d9a67a8a5b54b9b54ee3b6e43fd4ef15ed7000",
38
+ "vectors/config.update.vectors.json": "8a5088d547e3eb4e460b25a5e413caaa02d4402a601340e279166d8c17758e1c",
39
+ "vectors/content.request.vectors.json": "6922748edfc630ce881a1b79d24827de03f849e05c441ef7eecc684bf2238e58",
40
+ "vectors/content.response.vectors.json": "35ef18edbcec24c029781e97d60dcb0b9111697fa3a5a2a8dfd159744e7f8258",
41
+ "vectors/cross-type.vectors.json": "f7ed403d3e3f53150b715a5423fde1bed708e8c6245709ed4ad74389dc8c3307",
42
+ "vectors/error.vectors.json": "f5fd7954f2c1736ee0943eb330c2f85b7fdd7a8959e4f043f9cc9e9bcda39aa5",
43
+ "vectors/heartbeat.vectors.json": "cfd2e048139c67aa925f1fa94c05326e75248839645c1a1e4d6d3bb60cafa624",
44
+ "vectors/hello.vectors.json": "51067dc645a2e9d8116997b36e6f3fe36401d27f3ee3c18de32ec30f8a052c5f",
45
+ "vectors/incident.candidate.vectors.json": "0dd75ef396bf7b7911d20b3ab220469701f07866bb032ee31af58523c3633ab6",
46
+ "vectors/job.assign.vectors.json": "e510c2cc6d17238d477429c255d828c0339b504e39a3a50d4b6ca980e262d9ef",
47
+ "vectors/job.cancel.vectors.json": "0fc9f99c8662435e9425389068adf418273489dc73928a21eae1b283256b9bc5",
48
+ "vectors/job.event.vectors.json": "03a2a2435918ba561821916bf4e435f2780210900139ecf68091acac6abf200c",
49
+ "vectors/job.result.vectors.json": "05995fbe65e5357705d75e9b660b517f54c6902069d36b62190b1b79f283e7a5",
50
+ "vectors/log.stats.vectors.json": "a8a67e09ef0f87b81f4028b77427ed7afd809c3f8534b8423a712b2ceef9a27f",
51
+ "vectors/ready.vectors.json": "a584da17e714a13c3583256773ef0be007660c33c62a456e27568def391e7cef",
52
+ "vectors/register.vectors.json": "e2a696efc547aa2f7242128d1a860238954f9e9a1330f10b567122d7b3da0365",
53
+ "vectors/scan.upload.vectors.json": "e8a6f73fe91ac58a3222b68cf28124c1254aa4f00c3ef8f472deb7de31cab76e",
54
+ "vectors/scan.uploaded.vectors.json": "7314ea15b1afa7b9625048fb7e245492d8910bc0dae8a52ea62afe2a1e240382",
55
+ "vectors/traces/invalid-agent-mismatch.json": "5f17b867de71c1bcc6c42723acb53a50338c0e491193499ea25c2220ea4fa558",
56
+ "vectors/traces/invalid-chunk-after-last.json": "0dcc3f3cce605a490d65e96592668381fa9a191d908a26ede998717494eb2f2e",
57
+ "vectors/traces/invalid-chunk-byte-length-mismatch.json": "d328ad05b22d3abaf0497ee8482a07a08ac90a4ede94edf754e8962ca8ea201e",
58
+ "vectors/traces/invalid-chunk-last-flag.json": "6c59a361313e966999ec25c80733758d86befb5f3607528634a148291844d212",
59
+ "vectors/traces/invalid-chunk-out-of-order.json": "c6560290755d86b524316823d32fcbd1420c170b13e198813c3b7e7b7f32ecb6",
60
+ "vectors/traces/invalid-chunk-total-changes.json": "9b7ade197252275725bc0754e40eadc12a289c063fed13a2905c55583a27a767",
61
+ "vectors/traces/invalid-clock-skew.json": "de16e0bd72e08bb03d44d5f2f04a238dcd38447ff1690f8acfe712ca3fe2028b",
62
+ "vectors/traces/invalid-event-before-ready.json": "17081f197fab5f4490b15a088f2bce620921007b5fdd7ed63bd988899d674883",
63
+ "vectors/traces/invalid-first-frame-not-register.json": "cf41c2d0e67f6df382790dab13bb9e49db99a34a88c1f9d108214be5df1e4ea3",
64
+ "vectors/traces/invalid-frame-over-48kib.json": "912e37c9a326ad92284ba22af752269cb5dd52ed06cdc22b17fd3b74a8423734",
65
+ "vectors/traces/invalid-la-sends-job-assign.json": "4ce5313e125e2afa920e923c2b5b524f627f47d06961a250a8dc642b79225252",
66
+ "vectors/traces/invalid-params-raw-differs-from-payload.json": "1220630372c78a41d77b2653b8f9025cb87d601c199adb03edbf007e6e727ad1",
67
+ "vectors/traces/invalid-payload-above-limit.json": "99f3834cfea69bc6ffa169bd6a2ed915377331205f8e991ceacbf21b3525fe96",
68
+ "vectors/traces/invalid-resume-restarts-seq.json": "795964093051511c00538f88774956b471a35338b92a0a180bedb33d460f2d83",
69
+ "vectors/traces/invalid-seq-gap.json": "9c85719b07cb7309752ffaa33b6c6cb1b278c45cd72e21dbc788c56af5e4502f",
70
+ "vectors/traces/invalid-seq-repeated.json": "4a4dd18d5b9de61a6cec735229b7991d6de4f3598acd7b16541eaca44be4556d",
71
+ "vectors/traces/invalid-sig-tenant-mismatch.json": "5d8895cbc9c1b91be31a851d009d1f2b832f9735c3713b131ac9837c90b4a8e7",
72
+ "vectors/traces/invalid-signature-warn-with-enforce-required.json": "71b5b3dc5dce52417173934db1e0de6542ecae5798e87124d54a8017493312e1",
73
+ "vectors/traces/invalid-tenant-mismatch.json": "dcc04ea423bddef20524ead7bfd146a5d9c42fe474c9216dc38c90544d291151",
74
+ "vectors/traces/invalid-type-outside-allowlist.json": "e492d326cfa044d3fc9f7e48dee63a52b715a62ba4867fc5ce21f1d86e3c435f",
75
+ "vectors/traces/valid-content-chunks-in-order.json": "c5fc301527b062b30c625da2798c7cfab3e3debac29a36f6ec7c699705267ab3",
76
+ "vectors/traces/valid-handshake-and-job.json": "9aa3c423c496403ca77d63965f1805f0a57ea9f6f8a01351310515b265aeb9cc",
77
+ "vectors/traces/valid-resume-continues-seq.json": "8aa90f24598f8f2d87a5355ec58c3531d16fd11ad41c782f53eca063372b704a",
78
+ "vectors/traces/valid-retransmit-is-idempotent.json": "924e9d296a67f0006c289e7cc978b9529ae1aa03bec2cd331a62f31354a90257",
79
+ "vectors/webhook.forward.vectors.json": "38d5b53b8fb4ecdb051e2c594afc52eb8b2044e4e6aae6ffb0057fbda33bcd46"
80
+ }
81
+ }