From 586ed4f8ea7f89ff24f82635419cee2e618b8856 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:04 +0100 Subject: [PATCH 1/7] fix(tools): bound untrusted display text at output boundaries --- docs/CURRENT-STATE.md | 7 ++++++ scripts/package-boundaries.json | 6 +++++ src/data/alprCameras.test.mjs | 21 +++++++++++++++++ src/data/displayText.js | 12 ++++++++++ src/layers/alpr/records.js | 3 ++- src/tools/queries/surveillance.js | 11 +++++---- src/tools/queries/surveillance.test.mjs | 31 +++++++++++++++++++++++++ src/voice/gevActions.js | 19 +++++++-------- src/voice/gevActions.test.mjs | 13 +++++++++++ 9 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 src/data/displayText.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..b3a48f66e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ca5dc0c7f..859b030e7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 5a50220d5..e68b11da3 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -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'); +}); diff --git a/src/data/displayText.js b/src/data/displayText.js new file mode 100644 index 000000000..1138c4cc0 --- /dev/null +++ b/src/data/displayText.js @@ -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(); +} diff --git a/src/layers/alpr/records.js b/src/layers/alpr/records.js index 0d751f12d..d8d2ac104 100644 --- a/src/layers/alpr/records.js +++ b/src/layers/alpr/records.js @@ -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; } diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js index 06723007a..4ddafabc2 100644 --- a/src/tools/queries/surveillance.js +++ b/src/tools/queries/surveillance.js @@ -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); diff --git a/src/tools/queries/surveillance.test.mjs b/src/tools/queries/surveillance.test.mjs index 5c76a04f2..a13b9fd9a 100644 --- a/src/tools/queries/surveillance.test.mjs +++ b/src/tools/queries/surveillance.test.mjs @@ -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', + ), + ); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index bf3e5b8b8..bae453c23 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -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; } diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 4a3a8ec72..2009d789e 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -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]/); +}); From 3685c416f3d0e13a2e19e9e70e777d3723b323cc Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:41 +0100 Subject: [PATCH 2/7] docs: record text fix --- changelog.d/1280-text-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1280-text-issue-fix.md diff --git a/changelog.d/1280-text-issue-fix.md b/changelog.d/1280-text-issue-fix.md new file mode 100644 index 000000000..d13f3f9cc --- /dev/null +++ b/changelog.d/1280-text-issue-fix.md @@ -0,0 +1 @@ +- Bound untrusted display text at output boundaries (asturksever, #1280). From cbca6a228d83ff13a19a9f20bd9003a4eefd3d3a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:31:18 +0100 Subject: [PATCH 3/7] fix(voice): keep canonical analyst IDs out of display-text normalization compactAnalystItem normalized every string field, including `id`. Bikeshare uses the raw station name as its ID, so a station with repeated spaces or a name over 180 code units was registered as a referent under a different key and a numbered fly_to_location reported it as no longer loaded. Normalize display fields only; layerKey, id, icao24, mmsi, noradId, registration and callsign pass through as the layer reports them. Adds an analyst_query -> referent -> fly_to_location test for repeated-space and long station names, and documents the rule in docs/CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 8 +++++-- src/voice/gevActions.js | 20 +++++++++++++++++- src/voice/gevActions.test.mjs | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 721caabd4..0e875c1ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -13,8 +13,12 @@ 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. +pair. Only display fields are normalized: canonical analyst identifiers +(`id`, `layerKey`, `icao24`, `mmsi`, `noradId`, `registration`, `callsign`) +pass through unchanged, because bikeshare uses the raw station name as its ID +and numbered voice references resolve by that key. These fields remain +untrusted source data in structured tool output; text normalization does not +establish trust or eliminate prompt injection. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 308736ed5..71610992c 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4570,6 +4570,21 @@ const ANALYST_IDENTITY_FIELDS = Object.freeze([ 'noradId', ]); +/** + * Analyst item keys that identify a record rather than describe it: the + * referent registry, track_entity and locateLayerEntity match on them, so + * display-text normalization must leave them byte-for-byte unchanged. + */ +const ANALYST_CANONICAL_FIELDS = new Set([ + 'layerKey', + 'id', + 'icao24', + 'mmsi', + 'noradId', + 'registration', + 'callsign', +]); + /** * Compact one analyst record for the voice payload: identity, position (so a * follow-up can fly to it), distance, and that layer's manifest fields with @@ -4607,8 +4622,11 @@ function compactAnalystItem(r) { } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; + // Display text only: canonical keys are lookup identities (bikeshare uses + // the raw station name as its id), so the referent registry and + // locateLayerEntity must see them exactly as the layer reports them. for (const key of Object.keys(compact)) { - if (typeof compact[key] === 'string') + if (typeof compact[key] === 'string' && !ANALYST_CANONICAL_FIELDS.has(key)) compact[key] = normalizeDisplayText(compact[key]); } return compact; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 2009d789e..5367bd2f3 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3944,3 +3944,43 @@ test('analyst item display strings are single-line and bounded for voice', async 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]/); }); + +test('analyst_query keeps canonical IDs raw so numbered references still fly to the station', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { viewer, styleManager } = createVoiceNavigationHarness(); + const referents = createReferentRegistry(); + const longName = 'Station ' + 'é'.repeat(200); + const spaced = 'Congress Ave & 2nd'; + const stations = [ + { id: spaced, name: spaced, lat: 30.2672, lon: -97.7431 }, + { id: longName, name: longName, lat: 30.2711, lon: -97.7437 }, + ]; + const bikeshare = { getStats: () => ({ count: stations.length, lastUpdate: Date.now() }), getAnalystRecords: () => stations }; + const runner = createActionRunner({ + viewer, styleManager, + dataManager: { + layers: new Map([['bikeshare', { module: bikeshare }]]), + isEnabled: () => true, + getAll: () => [{ id: 'bikeshare', name: 'bikeshare', enabled: true, stats: bikeshare.getStats() }], + }, + deixis: { referents }, + }); + const listed = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 2 }); + assert.equal(listed.ok, true); + const bySpaced = listed.items.find((item) => item.id === spaced); + const byLong = listed.items.find((item) => item.id === longName); + assert.ok(bySpaced && byLong, 'compact items keep the raw station-name IDs'); + assert.equal(bySpaced.name, 'Congress Ave & 2nd', 'display name is still normalized'); + assert.equal(byLong.name.length, 180, 'display name is still bounded'); + assert.equal(byLong.id.length, longName.length); + const registered = referents.list(); + assert.equal(registered.length, 2, 'the analyst result became the numbered list'); + for (const ref of registered) { + const station = stations.find((row) => row.id === ref.id); + assert.ok(station, `referent ${ref.n} keeps the canonical ID`); + const flown = await runner('fly_to_location', { referent: ref.n }); + assert.equal(flown.ok, true, `referent ${ref.n} resolves instead of "no longer loaded"`); + assert.equal(flown.latitude, station.lat); + assert.equal(flown.longitude, station.lon); + } +}); From 0af33707da5788a27c5ce77ba3dac6aa1911834a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 13:58:48 +0400 Subject: [PATCH 4/7] fix(voice): restore the globe-rotation import a main merge dropped The earlier main merge replaced `import { interruptGlobeRotation } from '../cameraVerbs.js'` with the displayText import while keeping the `interruptGlobeRotation('voice-zoom-intent')` call, so voice zoom actions threw ReferenceError. The same merge replaced main's Overpass reservation paragraph in docs/CURRENT-STATE.md; restore it next to the new text. --- docs/CURRENT-STATE.md | 8 ++++++++ src/voice/gevActions.js | 1 + 2 files changed, 9 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 28f1daa01..cf9ed5fb6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -129,6 +129,14 @@ search keeps a successful geocode, with no search or view-based region recovery. Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. +Overpass reserves each normalized query before its disk-cache lookup. The +initial disk lookup, upstream quota admission, fetching and memory publication +are coalesced under that reservation. A caller that joined a reservation +rejected by another client's quota retries admission with its own quota. +Stale fallback runs per response after the reservation is released, and disk +writes finish asynchronously. Settled and rejected reservations are removed +only while they still own their map entry. + 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 diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index af350eb1d..0ad5d8def 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { interruptGlobeRotation } from '../cameraVerbs.js'; import { normalizeDisplayText } from '../data/displayText.js'; import { layerSnapshot, From 1b010fe328e868c01d9f1f3d167bab319617b0ff Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 14:31:28 +0400 Subject: [PATCH 5/7] fix(voice): keep transit route IDs out of display-text normalization A follow-up analyst_query filters on the routeId it was handed back, so a route ID with repeated spaces or over 180 characters must stay byte-for-byte raw like the other canonical fields. --- src/voice/gevActions.js | 1 + src/voice/gevActions.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 0ad5d8def..617f51e02 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -5365,6 +5365,7 @@ const ANALYST_CANONICAL_FIELDS = new Set([ 'noradId', 'registration', 'callsign', + 'routeId', ]); /** diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index e879dab60..802f20126 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -6600,6 +6600,32 @@ test('analyst_query keeps canonical IDs raw so numbered references still fly to } }); +test('analyst_query keeps transit route IDs raw so a follow-up filter still matches', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + for (const routeId of ['Route 17', 'R'.repeat(181)]) { + const { viewer, styleManager } = createVoiceNavigationHarness(); + const vehicles = [{ id: 'bus-1', routeId, lat: 30, lon: -97 }]; + const transit = { getStats: () => ({ count: 1, lastUpdate: Date.now() }), getAnalystRecords: () => vehicles }; + const runner = createActionRunner({ + viewer, styleManager, + dataManager: { + layers: new Map([['transit', { module: transit }]]), + isEnabled: () => true, + getAll: () => [{ id: 'transit', name: 'transit', enabled: true, stats: transit.getStats() }], + }, + }); + const listed = await runner('analyst_query', { layers: ['transit'], scope: { kind: 'anywhere' }, limit: 1 }); + assert.equal(listed.ok, true); + assert.equal(listed.items[0].routeId, routeId); + const followUp = await runner('analyst_query', { + followUp: true, + filters: [{ field: 'routeId', op: 'eq', value: listed.items[0].routeId }], + }); + assert.equal(followUp.ok, true); + assert.equal(followUp.count, 1, 'the returned route ID matches the same vehicle'); + } +}); + test('set_panel_open accepts the Authoring name for the scene panel', async () => { const calls = []; const runner = createGevActionRunner({ From 12a56309919d07cf3ecb685a13358362980feda6 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 15:34:32 +0400 Subject: [PATCH 6/7] docs: tighten the display-text notes Name routeId among the canonical analyst IDs in CURRENT-STATE, say what the changelog entry changes for users, and drop the comment that repeated the canonical-field doc. --- changelog.d/1280-text-issue-fix.md | 4 +++- docs/CURRENT-STATE.md | 17 +++++++---------- src/data/displayText.js | 2 +- src/voice/gevActions.js | 10 ++++------ 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/changelog.d/1280-text-issue-fix.md b/changelog.d/1280-text-issue-fix.md index d13f3f9cc..5e5474967 100644 --- a/changelog.d/1280-text-issue-fix.md +++ b/changelog.d/1280-text-issue-fix.md @@ -1 +1,3 @@ -- Bound untrusted display text at output boundaries (asturksever, #1280). +- Keep outside text in voice and MCP tool results to one line of 180 + characters: ALPR tags, station names and other display fields (asturksever, + #1280). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 809048675..7e2470098 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -137,16 +137,13 @@ Stale fallback runs per response after the reservation is released, and disk writes finish asynchronously. Settled and rejected reservations are removed only while they still own their map entry. -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. Only display fields are normalized: canonical analyst identifiers -(`id`, `layerKey`, `icao24`, `mmsi`, `noradId`, `registration`, `callsign`) -pass through unchanged, because bikeshare uses the raw station name as its ID -and numbered voice references resolve by that key. These fields remain -untrusted source data in structured tool output; text normalization does not -establish trust or eliminate prompt injection. +External ALPR tags, surveillance-tool fields, voice context text and compact +analyst strings become one line of at most 180 characters (scene labels: 120). +Empty ALPR values stay null, and the cap never splits a surrogate pair. +Canonical analyst identifiers such as `id` and `routeId` stay unchanged, because +voice follow-ups match on them and bikeshare uses the raw station name as its +ID. The text is still untrusted: normalization does not prevent prompt +injection. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/data/displayText.js b/src/data/displayText.js index 1138c4cc0..fd0fe4eba 100644 --- a/src/data/displayText.js +++ b/src/data/displayText.js @@ -1,4 +1,4 @@ -/** Bounded, single-line external display text; it remains untrusted source data. */ +/** One-line text capped at `maxLength` without splitting a surrogate pair. Still untrusted. */ export function normalizeDisplayText(value, maxLength = 180) { if (value == null || typeof value === 'object' || typeof value === 'function') return ''; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 617f51e02..2d9334bc8 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -5353,9 +5353,9 @@ const ANALYST_IDENTITY_FIELDS = Object.freeze([ ]); /** - * Analyst item keys that identify a record rather than describe it: the - * referent registry, track_entity and locateLayerEntity match on them, so - * display-text normalization must leave them byte-for-byte unchanged. + * Keys that identify an analyst record. The referent registry, track_entity, + * locateLayerEntity and follow-up filters match on them, so they skip + * display-text normalization. */ const ANALYST_CANONICAL_FIELDS = new Set([ 'layerKey', @@ -5405,9 +5405,7 @@ function compactAnalystItem(r) { } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; - // Display text only: canonical keys are lookup identities (bikeshare uses - // the raw station name as its id), so the referent registry and - // locateLayerEntity must see them exactly as the layer reports them. + // Canonical keys stay raw: bikeshare's id is the raw station name. for (const key of Object.keys(compact)) { if (typeof compact[key] === 'string' && !ANALYST_CANONICAL_FIELDS.has(key)) compact[key] = normalizeDisplayText(compact[key]); From 353eebf224e13e216a2ed4c78e9d30214ad0a442 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 15:37:16 +0400 Subject: [PATCH 7/7] refactor(tools): normalize ALPR text once, in the records Every camera find_alpr_cameras returns comes from the tile source, which builds it through normalizeAlprNode, so the tags are already one line and capped. Drop the second pass in the tool, its duplicate test and the boundary entry, and the function check normalizeDisplayText never sees. --- scripts/package-boundaries.json | 1 - src/data/displayText.js | 3 +-- src/tools/queries/surveillance.js | 11 ++++----- src/tools/queries/surveillance.test.mjs | 31 ------------------------- 4 files changed, 6 insertions(+), 40 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2aee3df36..9b9f880e5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -5140,7 +5140,6 @@ "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", diff --git a/src/data/displayText.js b/src/data/displayText.js index fd0fe4eba..d151d8af7 100644 --- a/src/data/displayText.js +++ b/src/data/displayText.js @@ -1,7 +1,6 @@ /** One-line text capped at `maxLength` without splitting a surrogate pair. Still untrusted. */ export function normalizeDisplayText(value, maxLength = 180) { - if (value == null || typeof value === 'object' || typeof value === 'function') - return ''; + if (value == null || typeof value === 'object') return ''; return String(value) .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') .replace(/\s+/g, ' ') diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js index 4ddafabc2..06723007a 100644 --- a/src/tools/queries/surveillance.js +++ b/src/tools/queries/surveillance.js @@ -1,6 +1,5 @@ /** 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 { @@ -70,12 +69,12 @@ export const findAlprCameras = defineTool({ id: camera.id, lat: round(point.lat, 6), lon: round(point.lon, 6), - operator: normalizeDisplayText(camera.operator) || null, - manufacturer: normalizeDisplayText(camera.manufacturer) || null, - camera_type: normalizeDisplayText(camera.cameraType) || null, - zone: normalizeDisplayText(camera.zone) || null, + operator: camera.operator || null, + manufacturer: camera.manufacturer || null, + camera_type: camera.cameraType || null, + zone: camera.zone || null, direction_deg: camera.directionDeg ?? null, - last_verified: normalizeDisplayText(camera.lastVerified) || null, + last_verified: camera.lastVerified || null, distance_km: round(distanceKm(center, point), 2), })) .sort((a, b) => a.distance_km - b.distance_km); diff --git a/src/tools/queries/surveillance.test.mjs b/src/tools/queries/surveillance.test.mjs index a13b9fd9a..5c76a04f2 100644 --- a/src/tools/queries/surveillance.test.mjs +++ b/src/tools/queries/surveillance.test.mjs @@ -1,6 +1,5 @@ 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 = {}) => ({ @@ -114,33 +113,3 @@ 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', - ), - ); -});