mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
224eb668a1
The web app kept helpers in two root folders (lib/ and utils/) and named route-local helper folders either utils/ or lib/. Shared helpers now live in lib/, route-only helpers in a lib/ next to their route (app/dashboard/lib, app/sessions/lib). Generated wire types, manifests, guardrails, error codes and the lastmod file move to lib/generated/, like the MCP server's src/generated/. The session-search parity cases sit next to sessionSearchQuery.ts. Pure move, no behaviour change. Imports, the generators (combine.js, generate-shared-manifest-types.js, sync-error-codes.js, generate-lastmod.js), the CI drift paths, the two backend tests and the MCP freshness test that read these files, and the guard tests' registry path and scan list follow the new locations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
13 KiB
JavaScript
303 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Combines individual rule JSON files into dist/ array files and generates
|
|
* TypeScript guardrails from rules/guardrails.json.
|
|
*
|
|
* Run: node rules/scripts/combine.js
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const rulesRoot = path.resolve(__dirname, '..');
|
|
|
|
// ── Rule combination ────────────────────────────────────────────────────────
|
|
|
|
// Shipped gather/analyze rules must stay inside the reserved built-in namespace:
|
|
// (ANALYZE|GATHER)-<CATEGORY>-<NNN> with CATEGORY !== CUSTOM. The CUSTOM category is
|
|
// the sanctioned tenant namespace (RuleIdPolicy.cs) — a built-in shipped there would
|
|
// silently shadow every tenant's same-ID custom rule at merge time (global wins).
|
|
const RESERVED_BUILTIN_ID = /^(ANALYZE|GATHER)-(?!CUSTOM-)[A-Z]+-\d{3}$/;
|
|
|
|
const configs = [
|
|
{
|
|
dir: path.join(rulesRoot, 'gather'),
|
|
output: path.join(rulesRoot, 'dist', 'gather-rules.json'),
|
|
schema: '../schema/gather-rule.schema.json',
|
|
idField: 'ruleId',
|
|
reservedNamespace: RESERVED_BUILTIN_ID
|
|
},
|
|
{
|
|
dir: path.join(rulesRoot, 'analyze'),
|
|
output: path.join(rulesRoot, 'dist', 'analyze-rules.json'),
|
|
schema: '../schema/analyze-rule.schema.json',
|
|
idField: 'ruleId',
|
|
reservedNamespace: RESERVED_BUILTIN_ID
|
|
},
|
|
{
|
|
dir: path.join(rulesRoot, 'ime-log-patterns'),
|
|
output: path.join(rulesRoot, 'dist', 'ime-log-patterns.json'),
|
|
schema: '../schema/ime-log-pattern.schema.json',
|
|
idField: 'patternId'
|
|
}
|
|
];
|
|
|
|
for (const config of configs) {
|
|
const files = fs.readdirSync(config.dir).filter(f => f.endsWith('.json')).sort();
|
|
const rules = [];
|
|
const seenIds = new Map(); // lowercased id -> filename (case variants collide for humans and RuleIdPolicy)
|
|
|
|
for (const file of files) {
|
|
const content = fs.readFileSync(path.join(config.dir, file), 'utf8');
|
|
const rule = JSON.parse(content);
|
|
// Remove $schema from individual entries (it's on the wrapper)
|
|
delete rule['$schema'];
|
|
|
|
const id = rule[config.idField] || '';
|
|
const idKey = id.toLowerCase();
|
|
if (seenIds.has(idKey)) {
|
|
console.error(`ERROR: duplicate ${config.idField} "${id}" in ${file} (already defined in ${seenIds.get(idKey)})`);
|
|
process.exit(1);
|
|
}
|
|
seenIds.set(idKey, file);
|
|
|
|
if (config.reservedNamespace && !config.reservedNamespace.test(id)) {
|
|
console.error(`ERROR: ${file}: ${config.idField} "${id}" is outside the reserved built-in namespace ${config.reservedNamespace} — built-ins must never use the tenant CUSTOM namespace`);
|
|
process.exit(1);
|
|
}
|
|
|
|
rules.push(rule);
|
|
}
|
|
|
|
// Sort by ID for deterministic output
|
|
rules.sort((a, b) => (a[config.idField] || '').localeCompare(b[config.idField] || ''));
|
|
|
|
const wrapper = {
|
|
$schema: config.schema,
|
|
rules: rules
|
|
};
|
|
|
|
fs.mkdirSync(path.dirname(config.output), { recursive: true });
|
|
fs.writeFileSync(config.output, JSON.stringify(wrapper, null, 2) + '\n', 'utf8');
|
|
|
|
console.log(`${path.basename(config.output)}: ${rules.length} rules combined`);
|
|
}
|
|
|
|
// ── Guardrails generation ───────────────────────────────────────────────────
|
|
|
|
const guardrailsPath = path.join(rulesRoot, 'guardrails.json');
|
|
// NOTE: consumers import from "@/lib/generated/guardrails.generated" (see guardValidation.ts
|
|
// and SectionGatherRules.tsx). The web re-org (commit a3e8e044) moved this file to
|
|
// utils/ but left this output path pointing at the now-orphaned lib/ copy, which
|
|
// silently froze the live allowlist. Keep this in sync with the import path.
|
|
const guardrailsOutput = path.resolve(
|
|
rulesRoot, '..', 'src', 'Web', 'autopilot-monitor-web', 'lib', 'generated', 'guardrails.generated.ts'
|
|
);
|
|
|
|
if (fs.existsSync(guardrailsPath)) {
|
|
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
|
|
|
|
// Every value below is emitted into a TypeScript module that ships in the
|
|
// portal bundle (and is auto-committed + auto-deployed from main). A string
|
|
// containing a raw newline / CR / U+2028 / U+2029 inside a naive "..." literal
|
|
// would terminate the literal and turn the remainder of the value into CODE.
|
|
// Two independent defences: (1) reject any control/line-terminator character
|
|
// outright — no legitimate prefix, command or channel name contains one —
|
|
// and (2) render every literal through a complete encoder (JSON.stringify),
|
|
// so even a value that slipped past (1) stays an inert string constant.
|
|
// rules/schema/guardrails.schema.json enforces the same constraints in CI
|
|
// before this script runs; this in-process check keeps local runs honest.
|
|
const UNSAFE_CHAR = /[\u0000-\u001F\u007F\u2028\u2029]/;
|
|
const fail = (msg) => {
|
|
console.error(`ERROR: guardrails.json ${msg}`);
|
|
process.exit(1);
|
|
};
|
|
const assertSafeString = (value, where) => {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
fail(`${where}: expected a non-empty string, got ${JSON.stringify(value)}`);
|
|
}
|
|
if (UNSAFE_CHAR.test(value)) {
|
|
fail(`${where}: control or line-terminator character in ${JSON.stringify(value)}`);
|
|
}
|
|
};
|
|
const assertStringList = (list, where) => {
|
|
if (!Array.isArray(list)) fail(`${where}: expected an array`);
|
|
list.forEach((s, i) => assertSafeString(s, `${where}[${i}]`));
|
|
};
|
|
const assertCategorized = (groups, itemsKey, where) => {
|
|
if (!Array.isArray(groups)) fail(`${where}: expected an array`);
|
|
groups.forEach((g, i) => {
|
|
assertSafeString(g && g.category, `${where}[${i}].category`);
|
|
assertStringList(g[itemsKey], `${where}[${i}].${itemsKey}`);
|
|
});
|
|
};
|
|
|
|
assertCategorized(guardrails.registryPrefixes, 'prefixes', 'registryPrefixes');
|
|
assertCategorized(guardrails.allowedCommands, 'commands', 'allowedCommands');
|
|
assertCategorized(guardrails.eventLogChannels, 'channels', 'eventLogChannels');
|
|
for (const key of [
|
|
'filePrefixes', 'wmiQueryPrefixes', 'diagnosticsPathPrefixes', 'blockedFilePrefixes',
|
|
'blockedEventLogChannels', 'blockedCommandPatterns'
|
|
]) {
|
|
assertStringList(guardrails[key], key);
|
|
}
|
|
assertStringList(guardrails.blockedInterimTriggerEventTypes ?? [], 'blockedInterimTriggerEventTypes');
|
|
assertStringList(guardrails.imeLogDerivedEventTypes ?? [], 'imeLogDerivedEventTypes');
|
|
if (!Number.isInteger(guardrails.maxCommandLength) || guardrails.maxCommandLength < 1) {
|
|
fail(`maxCommandLength: expected a positive integer, got ${JSON.stringify(guardrails.maxCommandLength)}`);
|
|
}
|
|
|
|
/**
|
|
* Render a string as a complete, self-contained TS double-quoted literal.
|
|
* JSON.stringify escapes backslash, quote and every C0 control character;
|
|
* U+2028/U+2029 are escaped on top because JSON does not require it.
|
|
*/
|
|
const lit = (s) => JSON.stringify(s).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
|
|
/** Render an array of strings as a TS readonly array. */
|
|
const flatArray = (items) =>
|
|
items.map((s) => ` ${lit(s)},`).join('\n');
|
|
|
|
/** Render a categorized list as a TS readonly array of { category, items }. */
|
|
const categorizedArray = (groups, itemsKey) =>
|
|
groups.map((g) => {
|
|
const items = g[itemsKey].map((s) => ` ${lit(s)},`).join('\n');
|
|
return ` {\n category: ${lit(g.category)},\n items: [\n${items}\n ],\n },`;
|
|
}).join('\n');
|
|
|
|
// Flatten categorized lists for validation
|
|
const flatRegistryPrefixes = guardrails.registryPrefixes.flatMap((g) => g.prefixes);
|
|
const flatCommands = guardrails.allowedCommands.flatMap((g) => g.commands);
|
|
const flatEventLogChannels = guardrails.eventLogChannels.flatMap((g) => g.channels);
|
|
|
|
const ts = `/**
|
|
* AUTO-GENERATED from rules/guardrails.json — DO NOT EDIT.
|
|
* Run: node rules/scripts/combine.js
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Categorized data (for documentation / UI display)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface GuardrailCategory {
|
|
readonly category: string;
|
|
readonly items: readonly string[];
|
|
}
|
|
|
|
export const REGISTRY_PREFIX_CATEGORIES: readonly GuardrailCategory[] = [
|
|
${categorizedArray(guardrails.registryPrefixes, 'prefixes')}
|
|
];
|
|
|
|
export const COMMAND_CATEGORIES: readonly GuardrailCategory[] = [
|
|
${categorizedArray(guardrails.allowedCommands, 'commands')}
|
|
];
|
|
|
|
export const EVENT_LOG_CHANNEL_CATEGORIES: readonly GuardrailCategory[] = [
|
|
${categorizedArray(guardrails.eventLogChannels, 'channels')}
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Flat arrays (for validation logic)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const ALLOWED_REGISTRY_PREFIXES: readonly string[] = [
|
|
${flatArray(flatRegistryPrefixes)}
|
|
];
|
|
|
|
export const ALLOWED_FILE_PREFIXES: readonly string[] = [
|
|
${flatArray(guardrails.filePrefixes)}
|
|
];
|
|
|
|
// Folders under the signed-in user's profile, RELATIVE to the profile root. The agent
|
|
// resolves %LOGGED_ON_USER_PROFILE% at runtime; the portal validates against a placeholder
|
|
// profile. These cannot live in ALLOWED_FILE_PREFIXES — that list is matched literally
|
|
// against the already-expanded path, where the user name is unknown at authoring time.
|
|
export const USER_PROFILE_FILE_PREFIXES: readonly string[] = [
|
|
${flatArray(guardrails.userProfileFilePrefixes ?? [])}
|
|
];
|
|
|
|
export const ALLOWED_WMI_QUERY_PREFIXES: readonly string[] = [
|
|
${flatArray(guardrails.wmiQueryPrefixes)}
|
|
];
|
|
|
|
export const ALLOWED_COMMANDS_LIST: readonly string[] = [
|
|
${flatArray(flatCommands)}
|
|
];
|
|
|
|
export const ALLOWED_DIAGNOSTICS_PATH_PREFIXES: readonly string[] = [
|
|
${flatArray(guardrails.diagnosticsPathPrefixes)}
|
|
];
|
|
|
|
export const BLOCKED_FILE_PREFIXES: readonly string[] = [
|
|
${flatArray(guardrails.blockedFilePrefixes)}
|
|
];
|
|
|
|
export const ALLOWED_EVENT_LOG_CHANNELS: readonly string[] = [
|
|
${flatArray(flatEventLogChannels)}
|
|
];
|
|
|
|
export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
|
|
${flatArray(guardrails.blockedEventLogChannels)}
|
|
];
|
|
|
|
export const BLOCKED_COMMAND_PATTERNS: readonly string[] = [
|
|
${flatArray(guardrails.blockedCommandPatterns)}
|
|
];
|
|
|
|
// High-frequency telemetry event types hard-blocked as analyze-rule evaluateOn
|
|
// on_event triggers (backend rejects on save, runtime ignores them).
|
|
export const BLOCKED_INTERIM_TRIGGER_EVENT_TYPES: readonly string[] = [
|
|
${flatArray(guardrails.blockedInterimTriggerEventTypes ?? [])}
|
|
];
|
|
|
|
export const MAX_COMMAND_LENGTH = ${guardrails.maxCommandLength};
|
|
`;
|
|
|
|
fs.writeFileSync(guardrailsOutput, ts, 'utf8');
|
|
console.log(`guardrails.generated.ts: ${flatRegistryPrefixes.length} registry, ${flatCommands.length} commands, ${guardrails.filePrefixes.length} file prefixes, ${flatEventLogChannels.length} event log channels`);
|
|
} else {
|
|
console.warn('guardrails.json not found — skipping guardrails generation');
|
|
}
|
|
|
|
// ── MCP rule-authoring module generation ────────────────────────────────────
|
|
// The MCP server exposes the rule schemas + guardrails as get_resource content
|
|
// so a customer's AI can author rules against the real contract instead of
|
|
// retrieval fragments. The server's tsconfig (rootDir: src, no resolveJsonModule)
|
|
// and the Docker build (rules/schema not copied) rule out importing the JSON
|
|
// directly — so we bake it into a TS module, exactly like the web mirror above.
|
|
// CI's guardrails-in-sync job re-runs this script and fails on any diff, which
|
|
// makes drift between rules/* and this generated module impossible to merge.
|
|
|
|
const mcpAuthoringOutput = path.resolve(
|
|
rulesRoot, '..', 'src', 'McpServer', 'autopilot-monitor-mcp', 'src', 'rule-authoring.generated.ts'
|
|
);
|
|
|
|
{
|
|
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
|
|
const gatherSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'gather-rule.schema.json'), 'utf8'));
|
|
const analyzeSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'analyze-rule.schema.json'), 'utf8'));
|
|
|
|
const ts = `/**
|
|
* AUTO-GENERATED from rules/guardrails.json + rules/schema/*.schema.json — DO NOT EDIT.
|
|
* Run: node rules/scripts/combine.js
|
|
*
|
|
* Consumed by the MCP server's rule-authoring surface (get_resource +
|
|
* validate_rule): the JSON Schemas are the validation contract, the guardrails
|
|
* are the agent-side collection allowlists. Single source of truth is rules/;
|
|
* the CI guardrails-in-sync job guards this file against drift.
|
|
*/
|
|
|
|
/** JSON Schema (2020-12) for gather rules — rules/schema/gather-rule.schema.json verbatim. */
|
|
export const GATHER_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(gatherSchema, null, 2)};
|
|
|
|
/** JSON Schema (2020-12) for analyze rules — rules/schema/analyze-rule.schema.json verbatim. */
|
|
export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(analyzeSchema, null, 2)};
|
|
|
|
/** Gather-rule collection guardrails — rules/guardrails.json verbatim. */
|
|
export const RULE_GUARDRAILS = ${JSON.stringify(guardrails, null, 2)} as const;
|
|
`;
|
|
|
|
fs.writeFileSync(mcpAuthoringOutput, ts, 'utf8');
|
|
console.log('rule-authoring.generated.ts: schemas + guardrails baked for MCP server');
|
|
}
|