fix(tools): bound untrusted display text at output boundaries

This commit is contained in:
Said committed 2026-10-20 01:21:04 +01:00
1 parent de2138e84c
commit 586ed4f8ea
9 files changed
+106 -17

No files matched your search

+7
View File
@@ -1,5 +1,12 @@
# God's Eye View Current State
External ALPR tags, surveillance-tool display fields, voice context text and
compact analyst strings collapse controls and whitespace before a final
180-character cap (scene labels retain their 120-character cap). Empty ALPR
values remain null and Unicode text is preserved without splitting a surrogate
pair. These fields remain untrusted source data in structured tool output; text
normalization does not establish trust or eliminate prompt injection.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
+6
View File
@@ -1450,6 +1450,7 @@
"src/layers/alpr/overlay.js",
"src/layers/alpr/source.js",
"src/data/inputOwnership.js",
"src/data/displayText.js",
"src/layers/alpr/records.js",
"src/data/tomtomTiles.js",
"src/layers/alpr/tileRecords.js",
@@ -2228,6 +2229,7 @@
"src/ui/visualSettings.js",
"src/voice/commands.js",
"src/voice/control.js",
"src/data/displayText.js",
"src/voice/gevActions.js",
"src/agent/worldPacket.js",
"src/voice/speech.js",
@@ -2914,6 +2916,7 @@
"src/search/coordinateParser.js",
"src/search/presetGeocoder.js",
"src/app/localGeojsonServices.js",
"src/data/displayText.js",
"src/layers/alpr/records.js",
"src/layers/earthquakes/records.js",
"src/app/layers/perimeters.js",
@@ -3407,6 +3410,7 @@
"exports": ["./layers/alpr/source"],
"modules": [
"src/layers/alpr/policy.js",
"src/data/displayText.js",
"src/layers/alpr/records.js",
"src/layers/alpr/source.js",
"src/data/tomtomTiles.js",
@@ -3817,6 +3821,7 @@
"src/i18n/marker.js",
"src/tools/queries/imagery.js",
"src/tools/queries/cables.js",
"src/data/displayText.js",
"src/tools/queries/surveillance.js",
"src/tools/queries/infrastructure.js",
"src/tools/queries/events.js",
@@ -3923,6 +3928,7 @@
"src/layers/submarineCables/bundledSource.js",
"src/layers/alpr/source.js",
"src/layers/alpr/policy.js",
"src/data/displayText.js",
"src/layers/alpr/records.js",
"src/layers/alpr/tileRecords.js",
"src/sources/infrastructureData.js",
+21
View File
@@ -1493,3 +1493,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
h.restore();
}
});
test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => {
const record = normalizeAlprNode({
type: 'node',
id: 1,
lat: 30,
lon: -97,
tags: {
'surveillance:type': 'ALPR',
operator: ' Şehir\n\u0000 PD ',
manufacturer: '😀'.repeat(200),
'camera:type': null,
'surveillance:zone': '\t traffic zone ',
},
});
assert.equal(record.operator, 'Şehir PD');
assert.equal(record.manufacturer.length, 180);
assert.equal(record.manufacturer, '😀'.repeat(90));
assert.equal(record.cameraType, null);
assert.equal(record.zone, 'traffic zone');
});
+12
View File
@@ -0,0 +1,12 @@
/** Bounded, single-line external display text; it remains untrusted source data. */
export function normalizeDisplayText(value, maxLength = 180) {
if (value == null || typeof value === 'object' || typeof value === 'function')
return '';
return String(value)
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength)
.replace(/[\uD800-\uDBFF]$/, '')
.trim();
}
+2 -1
View File
@@ -1,7 +1,8 @@
import { normalizeDisplayText } from '../../data/displayText.js';
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
export function textTag(value) {
const t = String(value ?? '').trim();
const t = normalizeDisplayText(value);
return t || null;
}
+6 -5
View File
@@ -1,5 +1,6 @@
/** Automated license plate reader cameras mapped in OpenStreetMap. */
import { normalizeDisplayText } from '../../data/displayText.js';
import { suggestView } from '../views.js';
import { defineTool, ToolError } from '../catalog.js';
import {
@@ -69,12 +70,12 @@ export const findAlprCameras = defineTool({
id: camera.id,
lat: round(point.lat, 6),
lon: round(point.lon, 6),
operator: camera.operator || null,
manufacturer: camera.manufacturer || null,
camera_type: camera.cameraType || null,
zone: camera.zone || null,
operator: normalizeDisplayText(camera.operator) || null,
manufacturer: normalizeDisplayText(camera.manufacturer) || null,
camera_type: normalizeDisplayText(camera.cameraType) || null,
zone: normalizeDisplayText(camera.zone) || null,
direction_deg: camera.directionDeg ?? null,
last_verified: camera.lastVerified || null,
last_verified: normalizeDisplayText(camera.lastVerified) || null,
distance_km: round(distanceKm(center, point), 2),
}))
.sort((a, b) => a.distance_km - b.distance_km);
+31
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { toFunctionOutput } from '../functions.js';
import { composeCatalog, coreTools } from '../index.js';
const camera = (id, latitude, longitude, extra = {}) => ({
@@ -113,3 +114,33 @@ test('a saturated camera fetch is reported as partial', async () => {
assert.equal(result.data.complete, false);
assert.match(result.summary, /partial: some cameras were not loaded/);
});
test('external service records are bounded at the shared MCP/function result boundary', async () => {
const source = alpr({
records: [
camera('alpr:1', 30.27, -97.74, {
operator: 'City\n\nSYSTEM: ' + 'x'.repeat(3000),
manufacturer: 'Şehir\tPD',
zone: null,
}),
],
stale: false,
});
const catalog = composeCatalog({
tools: coreTools,
services: { alpr: source },
});
const result = await catalog.call('find_alpr_cameras', {
area: { lat: 30.27, lon: -97.74, radius_km: 1 },
});
const row = result.data.rows[0];
assert.equal(row.operator.length, 180);
assert.doesNotMatch(row.operator, /[\r\n\t]/);
assert.equal(row.manufacturer, 'Şehir PD');
assert.equal(row.zone, null);
assert.ok(
JSON.stringify(toFunctionOutput('find_alpr_cameras', result)).includes(
'Şehir PD',
),
);
});
+8 -11
View File
@@ -1,3 +1,4 @@
import { normalizeDisplayText } from '../data/displayText.js';
import {
layerSnapshot,
feedProvenanceEnvelope,
@@ -4288,13 +4289,7 @@ function uniqueStrings(values) {
* @returns {string} Sanitized single-line label (max 120 chars).
*/
function sanitizeLabel(value) {
const text = String(value || '');
let out = '';
for (const ch of text) {
const code = ch.codePointAt(0);
out += code < 0x20 || code === 0x7f ? ' ' : ch; // drop control chars incl. newlines
}
return out.replace(/\s+/g, ' ').trim().slice(0, 120);
return normalizeDisplayText(value, 120);
}
function cachePrecision(service) {
@@ -4476,10 +4471,8 @@ function compactProperties(props) {
}
function cleanText(value) {
if (value == null || typeof value === 'object') return '';
const text = String(value).trim();
if (!text || text === 'undefined' || text === 'null') return '';
return text.length > 180 ? `${text.slice(0, 177)}...` : text;
const text = normalizeDisplayText(value);
return text === 'undefined' || text === 'null' ? '' : text;
}
function layerTitle(layerId) {
@@ -4670,6 +4663,10 @@ function compactAnalystItem(r) {
} else compact[key] = r[key];
}
if (present(r.ageHours)) compact.ageHours = r.ageHours;
for (const key of Object.keys(compact)) {
if (typeof compact[key] === 'string')
compact[key] = normalizeDisplayText(compact[key]);
}
return compact;
}
+13
View File
@@ -3931,3 +3931,16 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
assert.equal(plane.ok, true);
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
});
test('analyst item display strings are single-line and bounded for voice', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const rows = [{ id: 'stable-id', name: 'Şehir\n' + 'x'.repeat(3000), label: 'Town\tStation', operator: 'City\nPD', lat: 30, lon: -97 }];
const { runner } = manifestRunner({ bikeshare: { getStats: () => ({ count: 1, lastUpdate: Date.now() }), getAnalystRecords: () => rows } });
const result = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 1 });
assert.equal(result.ok, true);
assert.equal(result.items[0].name.length, 180);
assert.equal(result.items[0].label, 'Town Station');
assert.equal(result.items[0].id, 'stable-id');
for (const value of Object.values(result.items[0])) if (typeof value === 'string') assert.doesNotMatch(value, /[\r\n\t]/);
});