Skip to content

Commit a4715ad

Browse files
committed
feat(security): add lensEnforcer (Permanent fix v3.1) + Sentinel Auditor & self-test
1 parent f5cb4db commit a4715ad

4 files changed

Lines changed: 183 additions & 42 deletions

File tree

soulfield-v2-mvp/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"scripts": {
1111
"test": "jest",
1212
"test:canary": "jest -i __tests__/sse-contract.test.js",
13-
"start": "node -r dotenv/config src/index.js"
13+
"start": "node -r dotenv/config src/index.js",
14+
"audit": "node scripts/sentinel-audit.js",
15+
"selftest:lens": "node scripts/selftest-lens.js"
1416
},
1517
"devDependencies": {
1618
"@types/jest": "^30.0.0",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Self-test: hit a route that purposefully returns no lens to
3+
* confirm the enforcer quarantines the reply.
4+
* Exits 0 when quarantine detected, 1 otherwise.
5+
*/
6+
const http = require('http');
7+
const BASE = process.env.SF_BASE || 'http://localhost:3001'\;
8+
9+
function get(path) {
10+
return new Promise((resolve, reject) => {
11+
const req = http.get(BASE + path, (res) => {
12+
let buf = '';
13+
res.setEncoding('utf8');
14+
res.on('data', (c) => (buf += c));
15+
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
16+
});
17+
req.on('error', reject);
18+
});
19+
}
20+
21+
(async () => {
22+
try {
23+
const { status, body } = await get('/__selftest/lens'); // see step 5
24+
if (status !== 200) {
25+
console.error('selftest: bad HTTP', status);
26+
return process.exit(1);
27+
}
28+
const out = JSON.parse(body);
29+
const quarantined =
30+
out?.prefix === '[WARN]' &&
31+
out?.policy?.route === 'AureaReview' &&
32+
out?.meta?.lens?.error;
33+
if (!quarantined) {
34+
console.error('selftest: lensEnforcer DID NOT quarantine');
35+
return process.exit(1);
36+
}
37+
console.log('selftest: lensEnforcer OK');
38+
process.exit(0);
39+
} catch (e) {
40+
console.error('selftest error:', e?.message || e);
41+
process.exit(1);
42+
}
43+
})();
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Sentinel Auditor (CI-safe)
3+
* Checks that /aurea/consult-sentences and /council/consult-sentences
4+
* return bodies with valid lens metadata.
5+
* Exit code 0 => OK, 1 => contract violation.
6+
*/
7+
const http = require('http');
8+
9+
const BASE = process.env.SF_BASE || 'http://localhost:3001'\;
10+
const routes = [
11+
'/aurea/consult-sentences',
12+
'/council/consult-sentences'
13+
];
14+
15+
function postJSON(path, data) {
16+
return new Promise((resolve, reject) => {
17+
const payload = Buffer.from(JSON.stringify(data));
18+
const req = http.request(
19+
BASE + path,
20+
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length } },
21+
(res) => {
22+
let buf = '';
23+
res.setEncoding('utf8');
24+
res.on('data', (c) => (buf += c));
25+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: buf }));
26+
}
27+
);
28+
req.on('error', reject);
29+
req.write(payload);
30+
req.end();
31+
});
32+
}
33+
34+
function validLens(body) {
35+
try {
36+
const out = typeof body === 'string' ? JSON.parse(body) : body;
37+
return out?.meta?.lens && Array.isArray(out.meta.lens.passed) && !out.meta.lens.error;
38+
} catch { return false; }
39+
}
40+
41+
(async () => {
42+
try {
43+
for (const r of routes) {
44+
const res = await postJSON(r, { text: 'Test input for v3.1 smoke' });
45+
if (res.status !== 200) {
46+
console.error(`[Sentinel] ${r} -> HTTP ${res.status}`);
47+
process.exit(1);
48+
}
49+
if (!validLens(res.body)) {
50+
console.error(`[Sentinel] ${r} -> lens contract FAILED`);
51+
process.exit(1);
52+
}
53+
}
54+
console.log('[Sentinel] All consult routes passed lens checks');
55+
process.exit(0);
56+
} catch (e) {
57+
console.error('[Sentinel] error:', e?.message || e);
58+
process.exit(1);
59+
}
60+
})();
Lines changed: 77 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,93 @@
11
/**
2-
* LensEnforcer v3.1 – CI-safe response guard.
3-
* - If client requests SSE, ensure `text/event-stream` header.
4-
* - If client requests JSON, enforce:
5-
* { meta: { lens: ... }, sentences: [ { tags: [...] }, ... ] }
6-
* Allowed tags: 'fact' | 'intuition' | 'metaphor'
7-
* On invalid/missing, return a tiny safe stub so CI can proceed.
2+
* lensEnforcer (Permanent fix v3.1)
3+
* - Wraps res.send() and validates the output contract.
4+
* - If lens metadata is missing/failed, or content is “intuition-only” with no facts,
5+
* it quarantines the response (adds policy + meta.lens error) and logs a warning.
86
*/
7+
function isJsonLike(v) {
8+
if (v == null) return false;
9+
if (typeof v === 'object') return true;
10+
if (typeof v === 'string') {
11+
try { JSON.parse(v); return true; } catch { return false; }
12+
}
13+
return false;
14+
}
915

10-
const ALLOWED = new Set(['fact', 'intuition', 'metaphor']);
11-
12-
function hasLensMeta(payload) {
13-
return payload && typeof payload === 'object' && payload.meta && payload.meta.lens;
16+
function coerceBody(body) {
17+
if (!isJsonLike(body)) return { raw: body };
18+
return typeof body === 'string' ? JSON.parse(body) : body;
1419
}
1520

16-
function sentencesAreValid(payload) {
17-
const arr = Array.isArray(payload?.sentences) ? payload.sentences : [];
18-
for (const s of arr) {
19-
if (!Array.isArray(s?.tags)) return false;
20-
for (const t of s.tags) if (!ALLOWED.has(t)) return false;
21+
function collectTags(out) {
22+
// sentences[].tags or top-level tags
23+
if (Array.isArray(out?.sentences)) {
24+
return out.sentences.flatMap(s => Array.isArray(s?.tags) ? s.tags : []);
2125
}
26+
return Array.isArray(out?.tags) ? out.tags : [];
27+
}
28+
29+
function hasFacts(tags) {
30+
return tags.some(t => (t?.type || t) === 'fact');
31+
}
32+
33+
function onlyIntuition(tags) {
34+
const tset = tags.map(t => t?.type || t);
35+
// "intuition" present, but no *non*-intuition tag
36+
return tset.includes('intuition') && tset.every(t => t === 'intuition');
37+
}
38+
39+
function ensureLensShape(lens) {
40+
if (!lens || typeof lens !== 'object') return false;
41+
if (!Array.isArray(lens.passed)) return false;
2242
return true;
2343
}
2444

25-
function safeStub() {
26-
return {
27-
meta: { lens: { status: 'missing/invalid' } },
28-
sentences: [],
29-
};
45+
function markQuarantine(out, reason) {
46+
out.prefix = '[WARN]';
47+
out.policy = Object.assign({ route: 'AureaReview' }, out.policy);
48+
out.meta = out.meta || {};
49+
out.meta.lens = out.meta.lens || {};
50+
out.meta.lens.error = out.meta.lens.error || reason || 'lens-missing-or-invalid';
51+
return out;
3052
}
3153

32-
module.exports = function lensEnforcer() {
54+
function lensEnforcer() {
3355
return function (req, res, next) {
34-
const accept = String(req.headers?.accept || '');
35-
36-
// Canary: if client wants SSE, advertise the right type.
37-
if (accept.includes('text/event-stream')) {
38-
res.setHeader('Content-Type', 'text/event-stream');
39-
}
40-
41-
// Wrap res.json to validate JSON responses when client asked for JSON.
42-
const wantsJson = accept.includes('application/json');
43-
const originalJson = res.json.bind(res);
44-
45-
res.json = (body) => {
46-
if (wantsJson) {
47-
if (!hasLensMeta(body) || !sentencesAreValid(body)) {
48-
// return safe stub; using originalJson to avoid recursion.
49-
return originalJson(safeStub());
50-
}
56+
const _send = res.send.bind(res);
57+
res.send = (body) => {
58+
let out = coerceBody(body);
59+
60+
// If body isn't JSON‑ish, just forward
61+
if (!isJsonLike(body)) return _send(body);
62+
63+
const lensOk = ensureLensShape(out?.meta?.lens) && !out?.meta?.lens?.error;
64+
const tags = collectTags(out);
65+
const intuitionOnly = onlyIntuition(tags);
66+
const missingFactsWithIntuition = !hasFacts(tags) && tags.length > 0 && tags.includes('intuition');
67+
68+
let quarantined = false;
69+
if (!lensOk) {
70+
out = markQuarantine(out, 'lens-missing-or-invalid');
71+
quarantined = true;
72+
} else if (intuitionOnly || missingFactsWithIntuition) {
73+
out = markQuarantine(out, 'intuition-unlabeled-or-no-facts');
74+
quarantined = true;
5175
}
52-
return originalJson(body);
53-
};
5476

77+
if (quarantined) {
78+
// terse log line, safe for journald/CI
79+
try {
80+
const id = out?.id || req.id || '-';
81+
const route = req?.originalUrl || req?.url || '-';
82+
console.warn('[LensEnforcer] quarantine', JSON.stringify({
83+
id, route, reason: out?.meta?.lens?.error || 'unknown'
84+
}));
85+
} catch {}
86+
}
87+
return _send(typeof body === 'string' ? JSON.stringify(out) : out);
88+
};
5589
next();
5690
};
57-
};
91+
}
92+
93+
module.exports = { lensEnforcer };

0 commit comments

Comments
 (0)