Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 87 additions & 4 deletions src/engine/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,15 @@ export type LoadResult =
/** Convert a BlockedAdr into a RuleResult-shaped object for reporting. */
export function blockedToRuleResult(projectRoot: string, b: BlockedAdr) {
const id = b.adr.frontmatter.id;
const isSyntax = b.error.includes("syntax convention");
const ruleId = isSyntax ? "syntax-check" : "security-scan";
const description = isSyntax
? "Rule file syntax conventions"
: "Rule file security scan";
return {
ruleId: "security-scan",
ruleId,
adrId: id,
description: "Rule file security scan",
description,
violations: b.violations.map(
(v): ViolationDetail => ({
message: v.message,
Expand All @@ -68,14 +73,70 @@ export function blockedToRuleResult(projectRoot: string, b: BlockedAdr) {
endColumn: v.endColumn,
severity: "error",
adrId: id,
ruleId: "security-scan",
ruleId,
})
),
error: b.error,
durationMs: 0,
};
}

interface SyntaxViolation {
message: string;
line: number;
column: number;
endLine: number;
endColumn: number;
}

/**
* Check that a `.rules.ts` file follows the required syntax conventions:
* 1. Triple-slash reference directive: `/// <reference path="..." />`
* pointing to `rules.d.ts` (provides ambient types without imports).
* 2. `satisfies RuleSet` on the default export (compile-time validation).
*
* These are authoring conventions that ensure rule files get proper
* type-checking and remain self-documenting.
*/
function checkRuleSyntax(source: string): SyntaxViolation[] {
const violations: SyntaxViolation[] = [];

// Check for triple-slash reference to rules.d.ts
const hasTripleSlash =
/^\/\/\/\s*<reference\s+path=["'][^"']*rules\.d\.ts["']\s*\/>$/m.test(
source
);
if (!hasTripleSlash) {
violations.push({
message:
'Missing triple-slash reference directive. Add /// <reference path="../rules.d.ts" /> at the top of the file.',
line: 1,
column: 0,
endLine: 1,
endColumn:
source.indexOf("\n") === -1 ? source.length : source.indexOf("\n"),
});
}

// Check for `satisfies RuleSet` on the default export
const hasSatisfies = /\bsatisfies\s+RuleSet\b/.test(source);
if (!hasSatisfies) {
// Point to the last line as a reasonable location for the missing satisfies
const lines = source.split("\n");
const lastLine = lines.length;
violations.push({
message:
"Missing `satisfies RuleSet` on default export. The export must use `} satisfies RuleSet;` for compile-time type validation.",
line: lastLine,
column: 0,
endLine: lastLine,
endColumn: lines[lastLine - 1]?.length ?? 0,
});
}

return violations;
}

/**
* Discover ADRs with rules: true and dynamically import their companion .rules.ts files.
*/
Expand Down Expand Up @@ -163,11 +224,33 @@ export async function loadRuleAdrs(
};
}

const ruleSource = await Bun.file(rulesFile).text();

// Syntax gate: ensure rule files follow the required conventions
// (triple-slash reference directive + `satisfies RuleSet`).
const syntaxViolations = checkRuleSyntax(ruleSource);
if (syntaxViolations.length > 0) {
return {
type: "blocked",
value: {
adr,
error: `ADR ${adr.frontmatter.id}: rule file has syntax convention violations (${syntaxViolations.length} violation${syntaxViolations.length === 1 ? "" : "s"})`,
violations: syntaxViolations.map((v) => ({
message: v.message,
file: rulesFile,
line: v.line,
column: v.column,
endLine: v.endLine,
endColumn: v.endColumn,
})),
},
};
}

// Security gate: scan rule source for banned patterns before executing.
// This blocks dangerous imports (node:fs, child_process), Bun APIs
// (Bun.spawn, Bun.file), network access (fetch), eval, and obfuscation
// patterns (computed property access, dynamic imports).
const ruleSource = await Bun.file(rulesFile).text();
const scanViolations = scanRuleSource(ruleSource);
if (scanViolations.length > 0) {
return {
Expand Down
6 changes: 5 additions & 1 deletion tests/commands/check-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ describe("check command security", () => {

function writeAdrAndRule(id: string, ruleCode: string): void {
writeFileSync(join(adrsDir, `${id}-sec.md`), adrTemplate(id));
writeFileSync(join(adrsDir, `${id}-sec.rules.ts`), ruleCode);
// Wrap rule code with required syntax conventions
const wrapped =
`/// <reference path="../rules.d.ts" />\n\n` +
ruleCode.trimEnd().replace(/};\s*$/, "} satisfies RuleSet;\n");
writeFileSync(join(adrsDir, `${id}-sec.rules.ts`), wrapped);
}

test("blocks readFile path traversal via on-disk rule", async () => {
Expand Down
18 changes: 12 additions & 6 deletions tests/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@ rules: true
);
writeFileSync(
join(tempDir, ".archgate", "adrs", "TEST-001-passing.rules.ts"),
`export default {
`/// <reference path="../rules.d.ts" />

export default {
rules: {
"always-pass": {
description: "Always passes",
async check() {},
},
},
};
} satisfies RuleSet;
`
);

Expand All @@ -74,7 +76,9 @@ files: ["src/**/*.ts"]
);
writeFileSync(
join(tempDir, ".archgate", "adrs", "TEST-002-failing.rules.ts"),
`export default {
`/// <reference path="../rules.d.ts" />

export default {
rules: {
"no-console": {
description: "No console.log",
Expand All @@ -92,7 +96,7 @@ files: ["src/**/*.ts"]
},
},
},
};
} satisfies RuleSet;
`
);

Expand All @@ -117,14 +121,16 @@ rules: true
);
writeFileSync(
join(tempDir, ".archgate", "adrs", "TEST-003-filter.rules.ts"),
`export default {
`/// <reference path="../rules.d.ts" />

export default {
rules: {
"filtered": {
description: "Filtered rule",
async check() {},
},
},
};
} satisfies RuleSet;
`
);

Expand Down
30 changes: 18 additions & 12 deletions tests/engine/loader-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ Test decision.
writeAdrMd(adrsDir, "SEC-001", "SEC-001-malicious-fs");
writeFileSync(
join(adrsDir, "SEC-001-malicious-fs.rules.ts"),
`import { readFileSync } from "node:fs";
`/// <reference path="../rules.d.ts" />
import { readFileSync } from "node:fs";
export default {
rules: {
"steal-secrets": {
Expand All @@ -51,7 +52,7 @@ export default {
},
},
},
};
} satisfies RuleSet;
`
);

Expand All @@ -68,7 +69,8 @@ export default {
writeAdrMd(adrsDir, "SEC-002", "SEC-002-malicious-spawn");
writeFileSync(
join(adrsDir, "SEC-002-malicious-spawn.rules.ts"),
`export default {
`/// <reference path="../rules.d.ts" />
export default {
rules: {
"run-command": {
description: "Run arbitrary command",
Expand All @@ -77,7 +79,7 @@ export default {
},
},
},
};
} satisfies RuleSet;
`
);

Expand All @@ -94,7 +96,8 @@ export default {
writeAdrMd(adrsDir, "SEC-003", "SEC-003-malicious-fetch");
writeFileSync(
join(adrsDir, "SEC-003-malicious-fetch.rules.ts"),
`export default {
`/// <reference path="../rules.d.ts" />
export default {
rules: {
"exfiltrate": {
description: "Exfiltrate data",
Expand All @@ -103,7 +106,7 @@ export default {
},
},
},
};
} satisfies RuleSet;
`
);

Expand All @@ -120,7 +123,8 @@ export default {
writeAdrMd(adrsDir, "SEC-004", "SEC-004-malicious-eval");
writeFileSync(
join(adrsDir, "SEC-004-malicious-eval.rules.ts"),
`export default {
`/// <reference path="../rules.d.ts" />
export default {
rules: {
"eval-attack": {
description: "Execute arbitrary code",
Expand All @@ -129,7 +133,7 @@ export default {
},
},
},
};
} satisfies RuleSet;
`
);

Expand All @@ -146,7 +150,8 @@ export default {
writeAdrMd(adrsDir, "SEC-005", "SEC-005-clean-rule");
writeFileSync(
join(adrsDir, "SEC-005-clean-rule.rules.ts"),
`export default {
`/// <reference path="../rules.d.ts" />
export default {
rules: {
"safe-rule": {
description: "A well-behaved rule",
Expand All @@ -161,7 +166,7 @@ export default {
},
},
},
};
} satisfies RuleSet;
`
);

Expand All @@ -179,7 +184,8 @@ export default {
writeAdrMd(adrsDir, "SEC-006", "SEC-006-safe-imports");
writeFileSync(
join(adrsDir, "SEC-006-safe-imports.rules.ts"),
`import { join } from "node:path";
`/// <reference path="../rules.d.ts" />
import { join } from "node:path";

export default {
rules: {
Expand All @@ -190,7 +196,7 @@ export default {
},
},
},
};
} satisfies RuleSet;
`
);

Expand Down
Loading
Loading