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]/); +});