mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
342 lines
14 KiB
JavaScript
342 lines
14 KiB
JavaScript
/**
|
|
* Surface test for #2573: every GATEGUARD_* environment variable the hook
|
|
* reads must be documented in the GateGuard skill doc.
|
|
*
|
|
* `GATEGUARD_BASH_ROUTINE_DISABLED` shipped with no documentation at all and
|
|
* `GATEGUARD_EXEMPT_GLOBS` was mentioned only in a release note, so operators
|
|
* had no discoverable way to narrow the gate short of disabling it outright.
|
|
* This pins the surface: adding a knob to the hook without documenting it
|
|
* fails here.
|
|
*
|
|
* The env reads are extracted from *code only* — comments, string literals,
|
|
* template-literal text and regex literals are blanked out first, so a knob
|
|
* named in a comment or an error message is never mistaken for a read. And
|
|
* because a regex scanner cannot see every possible access form, the supported
|
|
* forms are enforced as a convention rather than assumed: any other way of
|
|
* reaching `process.env` fails the guard below with instructions, instead of
|
|
* silently letting an undocumented knob through.
|
|
*
|
|
* Supported (and enforced) read forms:
|
|
* process.env.GATEGUARD_X
|
|
* process.env['GATEGUARD_X'] // or "GATEGUARD_X"
|
|
*
|
|
* Run with: node tests/ci/gateguard-env-documented.test.js
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const repoRoot = path.join(__dirname, '..', '..');
|
|
const hookPath = path.join(repoRoot, 'scripts', 'hooks', 'gateguard-fact-force.js');
|
|
const skillPath = path.join(repoRoot, 'skills', 'gateguard', 'SKILL.md');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` \u2713 ${name}`);
|
|
return true;
|
|
} catch (err) {
|
|
console.log(` \u2717 ${name}`);
|
|
console.log(` Error: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** A `/` here starts a regex literal, not a division. */
|
|
const REGEX_CAN_FOLLOW = new Set([
|
|
'', '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '+', '-', '*', '%', '~', '^', '<', '>',
|
|
]);
|
|
const REGEX_CAN_FOLLOW_KEYWORD = new Set([
|
|
'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of',
|
|
'return', 'throw', 'typeof', 'void', 'yield',
|
|
]);
|
|
|
|
function regexFollowsKeyword(source, slashIndex) {
|
|
const match = source.slice(0, slashIndex).match(/([A-Za-z_$][\w$]*)\s*$/);
|
|
return Boolean(match && REGEX_CAN_FOLLOW_KEYWORD.has(match[1]));
|
|
}
|
|
|
|
/**
|
|
* Blank out comments and literal text, preserving length and line breaks so
|
|
* offsets stay comparable with the raw source.
|
|
*
|
|
* Code inside a template literal's `${...}` is preserved — it is real code and
|
|
* may contain an env read — while the surrounding literal text is blanked.
|
|
*/
|
|
function blankCommentsAndLiterals(source) {
|
|
const out = [];
|
|
const emit = (ch) => out.push(ch === '\n' ? '\n' : ' ');
|
|
const keep = (ch) => out.push(ch);
|
|
|
|
let i = 0;
|
|
let prev = '';
|
|
// Stack of open template literals. 0 = in literal text, >=1 = inside `${...}`
|
|
// (the number tracks brace nesting within the expression).
|
|
const templates = [];
|
|
const inTemplateText = () => templates.length > 0 && templates[templates.length - 1] === 0;
|
|
|
|
while (i < source.length) {
|
|
const ch = source[i];
|
|
const next = source[i + 1];
|
|
|
|
// Template-literal TEXT is handled first: inside it, `//`, quotes and `/`
|
|
// are literal characters, not comments, strings or regexes.
|
|
if (inTemplateText()) {
|
|
if (ch === '\\') { emit(ch); if (i + 1 < source.length) { emit(source[i + 1]); } i += 2; continue; }
|
|
if (ch === '`') { templates.pop(); emit(ch); prev = '`'; i += 1; continue; }
|
|
if (ch === '$' && next === '{') {
|
|
templates[templates.length - 1] = 1;
|
|
keep(ch); keep(next); prev = '{'; i += 2;
|
|
continue;
|
|
}
|
|
emit(ch); i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '/' && next === '/') {
|
|
while (i < source.length && source[i] !== '\n') { emit(source[i]); i += 1; }
|
|
continue;
|
|
}
|
|
|
|
if (ch === '/' && next === '*') {
|
|
emit(ch); emit(next); i += 2;
|
|
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { emit(source[i]); i += 1; }
|
|
if (i < source.length) { emit('*'); emit('/'); i += 2; }
|
|
continue;
|
|
}
|
|
|
|
if (ch === '/' && (REGEX_CAN_FOLLOW.has(prev) || regexFollowsKeyword(source, i))) {
|
|
emit(ch); i += 1;
|
|
let inClass = false;
|
|
while (i < source.length) {
|
|
const r = source[i];
|
|
if (r === '\\') { emit(r); if (i + 1 < source.length) { emit(source[i + 1]); } i += 2; continue; }
|
|
if (r === '[') { inClass = true; }
|
|
else if (r === ']') { inClass = false; }
|
|
else if (r === '/' && !inClass) { emit(r); i += 1; break; }
|
|
else if (r === '\n') { break; }
|
|
emit(r); i += 1;
|
|
}
|
|
prev = '/';
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"' || ch === "'") {
|
|
const quote = ch;
|
|
emit(ch); i += 1;
|
|
while (i < source.length) {
|
|
const s = source[i];
|
|
if (s === '\\') { emit(s); if (i + 1 < source.length) { emit(source[i + 1]); } i += 2; continue; }
|
|
if (s === quote) { emit(s); i += 1; break; }
|
|
if (s === '\n') { break; }
|
|
emit(s); i += 1;
|
|
}
|
|
prev = quote;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '`') {
|
|
templates.push(0);
|
|
emit(ch); i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (templates.length > 0 && ch === '}') {
|
|
const depth = templates[templates.length - 1];
|
|
if (depth === 1) { templates[templates.length - 1] = 0; keep(ch); i += 1; prev = '}'; continue; }
|
|
if (depth > 1) { templates[templates.length - 1] = depth - 1; }
|
|
}
|
|
if (templates.length > 0 && ch === '{' && templates[templates.length - 1] >= 1) {
|
|
templates[templates.length - 1] += 1;
|
|
}
|
|
|
|
keep(ch);
|
|
if (!/\s/.test(ch)) { prev = ch; }
|
|
i += 1;
|
|
}
|
|
|
|
return out.join('');
|
|
}
|
|
|
|
const DOTTED_READ = /process\.env\.(GATEGUARD_[A-Z0-9_]+)/g;
|
|
const QUOTED_KEY = /^(['"])(GATEGUARD_[A-Z0-9_]+)\1$/;
|
|
const PLAIN_QUOTED_KEY = /^(['"])[A-Za-z0-9_]+\1$/;
|
|
|
|
function matchAll(source, pattern) {
|
|
return [...source.matchAll(pattern)].map(m => m[1]);
|
|
}
|
|
|
|
/**
|
|
* Keys used in `process.env[...]`, located in code but read from the raw source.
|
|
*
|
|
* Blanking replaces literal *text* with spaces, which would erase the key
|
|
* itself — so the bracket positions are found in the blanked code (proving the
|
|
* access is real code, not a comment or a doc string) and the key is then read
|
|
* back out of the raw source at the same offset. `blankCommentsAndLiterals`
|
|
* preserves length, which is what makes the offsets interchangeable.
|
|
*/
|
|
function bracketedEnvKeys(source) {
|
|
const code = blankCommentsAndLiterals(source);
|
|
return [...code.matchAll(/process\.env\s*\[/g)]
|
|
.map((m) => {
|
|
const at = source.slice(m.index).match(/^process\.env\s*\[\s*([^\]]*?)\s*\]/);
|
|
return at ? at[1] : null;
|
|
})
|
|
.filter(key => key !== null);
|
|
}
|
|
|
|
/** GATEGUARD_* env reads present in real code (comments and literals excluded). */
|
|
function readGateguardEnvNames(source) {
|
|
const code = blankCommentsAndLiterals(source);
|
|
const bracketed = bracketedEnvKeys(source)
|
|
.map(key => (key.match(QUOTED_KEY) || [])[2])
|
|
.filter(Boolean);
|
|
return new Set([...matchAll(code, DOTTED_READ), ...bracketed]);
|
|
}
|
|
|
|
/**
|
|
* Access forms this parser cannot follow. Each would let a GATEGUARD_* read
|
|
* escape the documentation check, so they are rejected outright.
|
|
*/
|
|
const UNSUPPORTED_ACCESS = [
|
|
{ label: 'destructuring from process.env', pattern: /\}\s*=\s*process\.env\b/ },
|
|
{ label: 'process.env aliased to a binding', pattern: /(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*process\.env\s*(?:[;,)\]]|$)/m },
|
|
{ label: 'spread of process.env', pattern: /\.\.\.\s*process\.env\b/ },
|
|
{ label: 'enumeration of process.env', pattern: /Object\.(?:keys|values|entries|assign|fromEntries)\(\s*process\.env\b/ },
|
|
{ label: 'Reflect access on process.env', pattern: /Reflect\.(?:get|has|set|deleteProperty|defineProperty|getOwnPropertyDescriptor|ownKeys)\(\s*process\.env\b/ },
|
|
];
|
|
|
|
/** `process.env[...]` whose key is not a plain quoted string. */
|
|
function findComputedEnvAccess(source) {
|
|
return bracketedEnvKeys(source).filter(key => !PLAIN_QUOTED_KEY.test(key));
|
|
}
|
|
|
|
function findUnsupportedAccess(source) {
|
|
const code = blankCommentsAndLiterals(source);
|
|
const structural = UNSUPPORTED_ACCESS.filter(rule => rule.pattern.test(code)).map(rule => rule.label);
|
|
const computed = findComputedEnvAccess(source).map(key => `computed process.env[${key}]`);
|
|
return [...structural, ...computed];
|
|
}
|
|
|
|
console.log('\nGateGuard env-var documentation surface\n');
|
|
|
|
if (test('hook and skill doc both exist', () => {
|
|
assert.ok(fs.existsSync(hookPath), `missing ${hookPath}`);
|
|
assert.ok(fs.existsSync(skillPath), `missing ${skillPath}`);
|
|
})) passed++; else failed++;
|
|
|
|
const hookSource = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, 'utf8') : '';
|
|
const skillDoc = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : '';
|
|
const envNames = readGateguardEnvNames(hookSource);
|
|
|
|
if (test('hook reads at least one GATEGUARD_* variable', () => {
|
|
assert.ok(envNames.size > 0, 'no GATEGUARD_* env reads found - has the hook moved?');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('every GATEGUARD_* variable the hook reads is documented', () => {
|
|
const undocumented = [...envNames].filter(name => !skillDoc.includes(name)).sort();
|
|
assert.deepStrictEqual(
|
|
undocumented,
|
|
[],
|
|
`undocumented in skills/gateguard/SKILL.md: ${undocumented.join(', ')}`
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('the documented knobs are the ones the hook actually reads', () => {
|
|
// Guards the reverse drift: a doc naming a knob the hook no longer reads.
|
|
// Compared against the parsed env reads, not raw source — a name surviving
|
|
// only in a comment or error string must not satisfy this.
|
|
const documented = [...new Set(skillDoc.match(/GATEGUARD_[A-Z0-9_]+/g) || [])];
|
|
const stale = documented.filter(name => !envNames.has(name)).sort();
|
|
assert.deepStrictEqual(stale, [], `documented but unread by the hook: ${stale.join(', ')}`);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('the hook reaches process.env only through the supported literal forms', () => {
|
|
const unsupported = findUnsupportedAccess(hookSource).sort();
|
|
assert.deepStrictEqual(
|
|
unsupported,
|
|
[],
|
|
'the hook uses an env access form this test cannot follow, so an undocumented '
|
|
+ 'GATEGUARD_* knob could bypass the check. Either keep to '
|
|
+ "`process.env.GATEGUARD_X` / `process.env['GATEGUARD_X']`, or teach "
|
|
+ `readGateguardEnvNames the new form. Found: ${unsupported.join(', ')}`
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
// --- parser self-checks: the convention above is only worth as much as these ---
|
|
|
|
if (test('blanking preserves offsets and line count', () => {
|
|
const blanked = blankCommentsAndLiterals(hookSource);
|
|
assert.strictEqual(blanked.length, hookSource.length, 'blanking changed the source length');
|
|
assert.strictEqual(
|
|
blanked.split('\n').length,
|
|
hookSource.split('\n').length,
|
|
'blanking changed the line count'
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('env reads are read from code, not from comments, strings or regexes', () => {
|
|
const fixture = [
|
|
"const a = process.env.GATEGUARD_REAL_ONE;",
|
|
"const b = process.env['GATEGUARD_REAL_TWO'];",
|
|
'// process.env.GATEGUARD_IN_LINE_COMMENT is only mentioned here',
|
|
'/* process.env.GATEGUARD_IN_BLOCK_COMMENT */',
|
|
"const msg = 'process.env.GATEGUARD_IN_STRING';",
|
|
'const tpl = `process.env.GATEGUARD_IN_TEMPLATE ${process.env.GATEGUARD_REAL_THREE}`;',
|
|
'const re = /process\\.env\\.GATEGUARD_IN_REGEX\\/\\//;',
|
|
].join('\n');
|
|
const found = [...readGateguardEnvNames(fixture)].sort();
|
|
assert.deepStrictEqual(found, ['GATEGUARD_REAL_ONE', 'GATEGUARD_REAL_THREE', 'GATEGUARD_REAL_TWO']);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('a regex literal containing a slash does not swallow the code after it', () => {
|
|
const fixture = 'const re = /a\\/\\/b/;\nconst x = process.env.GATEGUARD_AFTER_REGEX;';
|
|
assert.deepStrictEqual([...readGateguardEnvNames(fixture)], ['GATEGUARD_AFTER_REGEX']);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('a regex literal after a statement keyword is ignored', () => {
|
|
const fixture = 'function matches() { return /process\\.env\\.GATEGUARD_IN_RETURN_REGEX/; }';
|
|
assert.deepStrictEqual([...readGateguardEnvNames(fixture)], []);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('the access guard rejects every form the parser cannot follow', () => {
|
|
const cases = [
|
|
['destructuring', 'const { GATEGUARD_HIDDEN } = process.env;'],
|
|
['alias', 'const env = process.env;\nconst v = env.GATEGUARD_HIDDEN;'],
|
|
['computed template', 'const v = process.env[`GATEGUARD_${suffix}`];'],
|
|
['computed variable', 'const v = process.env[name];'],
|
|
['spread', 'const all = { ...process.env };'],
|
|
['enumeration', 'const ks = Object.keys(process.env);'],
|
|
['Reflect.get', "const v = Reflect.get(process.env, 'GATEGUARD_HIDDEN');"],
|
|
['Reflect.has', "const v = Reflect.has(process.env, 'GATEGUARD_HIDDEN');"],
|
|
['Reflect.ownKeys', 'const ks = Reflect.ownKeys(process.env);'],
|
|
];
|
|
const missed = cases.filter(([, code]) => findUnsupportedAccess(code).length === 0).map(([label]) => label);
|
|
assert.deepStrictEqual(missed, [], `access guard missed: ${missed.join(', ')}`);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('the access guard accepts the supported forms and ignores commented ones', () => {
|
|
const ok = [
|
|
'const v = process.env.GATEGUARD_STATE_DIR;',
|
|
"const v = process.env['GATEGUARD_STATE_DIR'];",
|
|
'const v = process.env["GATEGUARD_STATE_DIR"];',
|
|
'// const { GATEGUARD_HIDDEN } = process.env;',
|
|
"const doc = 'const { GATEGUARD_HIDDEN } = process.env;';",
|
|
];
|
|
const wrong = ok.filter(code => findUnsupportedAccess(code).length > 0);
|
|
assert.deepStrictEqual(wrong, [], `false positives from the access guard: ${wrong.join(' | ')}`);
|
|
})) passed++; else failed++;
|
|
|
|
console.log(`\nPassed: ${passed}`);
|
|
console.log(`Failed: ${failed}\n`);
|
|
|
|
if (failed > 0) {
|
|
process.exit(1);
|
|
}
|