Compare commits

...
Author SHA1 Message Date
Said Turksever 11dd283712 Merge branch 'main' into codex/fix-tool-display-text 2026-10-24 23:43:46 +01:00
Said TurkseverandClaude Opus 5.5 cbca6a228d 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 <[email protected]>
2026-10-23 12:31:18 +01:00
Said Turksever 445af0875b Merge remote-tracking branch 'origin/main' into fix/1280
# Conflicts:
#	docs/CURRENT-STATE.md
#	src/layers/alpr/records.js
2026-10-23 12:09:46 +01:00
Said TurkseverandClaude Opus 5.5 d167cf0ccb Merge branch 'main' into codex/fix-tool-display-text
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:50 +01:00
Said 3685c416f3 docs: record text fix 2026-10-20 01:21:41 +01:00
Said 586ed4f8ea fix(tools): bound untrusted display text at output boundaries 2026-10-20 01:21:04 +01:00
10 changed files with 168 additions and 25 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
- Bound untrusted display text at output boundaries (asturksever, #1280).
+10 -7
View File
@@ -9,13 +9,16 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
lookups preserve the previous analyst cohort and boundary source. Navigation
search keeps a successful geocode, with no search or view-based region recovery.
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
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.
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
+6
View File
@@ -1500,6 +1500,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",
@@ -2290,6 +2291,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",
@@ -2974,6 +2976,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",
@@ -3471,6 +3474,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",
@@ -3886,6 +3890,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",
@@ -3992,6 +3997,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';
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',
),
);
});
+26 -12
View File
@@ -1,4 +1,4 @@
import { interruptGlobeRotation } from '../cameraVerbs.js';
import { normalizeDisplayText } from '../data/displayText.js';
import {
layerSnapshot,
feedProvenanceEnvelope,
@@ -4301,13 +4301,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) {
@@ -4424,10 +4418,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) {
@@ -4581,6 +4573,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
@@ -4618,6 +4625,13 @@ 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' && !ANALYST_CANONICAL_FIELDS.has(key))
compact[key] = normalizeDisplayText(compact[key]);
}
return compact;
}
+53
View File
@@ -3931,3 +3931,56 @@ 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]/);
});
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);
}
});