← back to AbramsEgo
TK-10381: harden A2A extractText against malformed/hostile responses (non-array parts/artifacts + null entries ignored, not thrown) + 8 parser tests
0553051516c56832701de56d7e8b45c7c6a1c902 · 2026-08-09 12:37:24 -0700 · Steve
Files touched
M lib/a2a-client.jsM lib/a2a-client.test.js
Diff
commit 0553051516c56832701de56d7e8b45c7c6a1c902
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Aug 9 12:37:24 2026 -0700
TK-10381: harden A2A extractText against malformed/hostile responses (non-array parts/artifacts + null entries ignored, not thrown) + 8 parser tests
---
lib/a2a-client.js | 33 ++++++++++++++++++---------------
lib/a2a-client.test.js | 16 ++++++++++++++++
2 files changed, 34 insertions(+), 15 deletions(-)
diff --git a/lib/a2a-client.js b/lib/a2a-client.js
index 8825facf..2a4378f7 100644
--- a/lib/a2a-client.js
+++ b/lib/a2a-client.js
@@ -171,24 +171,27 @@ async function rpcCall(baseUrl, card, body, authHeader) {
return readJsonCapped(res);
}
-/** Extract text from a completed Task's artifacts, or from an inline message result. */
+/** Pull trimmed text from an array of A2A message Parts, ignoring malformed entries. */
+function collectTextParts(partsArray, out) {
+ if (!Array.isArray(partsArray)) return;
+ for (const p of partsArray) {
+ if (p && p.kind === 'text' && typeof p.text === 'string' && p.text.trim()) out.push(p.text.trim());
+ }
+}
+
+/**
+ * Extract text from a completed Task's artifacts, or from an inline message result.
+ * Hardened against malformed/hostile responses: non-array parts/artifacts and null
+ * entries are IGNORED rather than throwing — the RPC result is untrusted input.
+ */
function extractText(rpcResult) {
const parts = [];
// inline result (no task opened)
- const msg = rpcResult?.result?.message;
- if (msg?.parts) {
- for (const p of msg.parts) {
- if (p.kind === 'text' && p.text) parts.push(p.text.trim());
- }
- }
- // task result
+ collectTextParts(rpcResult?.result?.message?.parts, parts);
+ // task result — each artifact carries its own parts array
const task = rpcResult?.result?.task ?? rpcResult?.result;
- if (task?.artifacts) {
- for (const art of task.artifacts) {
- for (const p of (art.parts ?? [])) {
- if (p.kind === 'text' && p.text) parts.push(p.text.trim());
- }
- }
+ if (Array.isArray(task?.artifacts)) {
+ for (const art of task.artifacts) collectTextParts(art?.parts, parts);
}
return parts.join('\n').trim() || null;
}
@@ -261,4 +264,4 @@ function logConsult(entry) {
}
}
-module.exports = { fetchAgentCard, consult, loadAllowlist, resolveAgent, logConsult, lintPayload, assertSafeUrl, assertSameHost, readJsonCapped, MAX_RESPONSE_BYTES };
+module.exports = { fetchAgentCard, consult, loadAllowlist, resolveAgent, logConsult, lintPayload, assertSafeUrl, assertSameHost, readJsonCapped, MAX_RESPONSE_BYTES, extractText };
diff --git a/lib/a2a-client.test.js b/lib/a2a-client.test.js
index 34c0a085..99d29bbd 100644
--- a/lib/a2a-client.test.js
+++ b/lib/a2a-client.test.js
@@ -68,6 +68,22 @@ lintPayload('What is the best approach to rate limiting?'); // clean
lintPayload('How should I structure my agent for A2A?'); // clean
console.log(' lintPayload: 9 assertions pass');
+// --- extractText (untrusted-response parser robustness) ----------------------
+const { extractText } = require('./a2a-client');
+// valid inline message
+assert.strictEqual(extractText({ result: { message: { parts: [{ kind: 'text', text: 'hello' }] } } }), 'hello');
+// valid task artifacts
+assert.strictEqual(extractText({ result: { task: { artifacts: [{ parts: [{ kind: 'text', text: 'a' }, { kind: 'text', text: 'b' }] }] } } }), 'a\nb');
+// non-text parts ignored
+assert.strictEqual(extractText({ result: { message: { parts: [{ kind: 'file', uri: 'x' }] } } }), null);
+// MALFORMED / hostile shapes must NOT throw — they return null
+assert.strictEqual(extractText({ result: { message: { parts: 'not-an-array' } } }), null);
+assert.strictEqual(extractText({ result: { task: { artifacts: 'nope' } } }), null);
+assert.strictEqual(extractText({ result: { message: { parts: [null, { kind: 'text' }, { kind: 'text', text: ' ' }] } } }), null);
+assert.strictEqual(extractText({}), null);
+assert.strictEqual(extractText(null), null);
+console.log(' extractText: 8 assertions pass');
+
// --- loadAllowlist -----------------------------------------------------------
// The real file is data/a2a-agents.json (starts empty — [] by spec).
const list = loadAllowlist();
← c95c477d auto-data-snapshot: 2026-08-09T12:20:46 (1 data files) — dat
·
back to AbramsEgo
·
auto-data-snapshot: 2026-08-09T12:51:43 (1 data files) — dat e47013cb →