mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
fix(tools): bound untrusted display text at output boundaries
This commit is contained in:
1 parent
de2138e84c
commit
586ed4f8ea
9 files changed
+106
-17
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]/);
|
||||
});
|
||||
Reference in new issue
Block a user