Skip to content
Closed
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
16 changes: 14 additions & 2 deletions src/engine/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import { relative } from "node:path";

import type { ViolationDetail } from "../formats/rules";
import { logDebug } from "../helpers/log";
import { resolvedProjectPaths } from "../helpers/project-config";
import {
resolvedProjectPaths,
resolveRuleImportDirs,
} from "../helpers/project-config";
import { ensureRulesShim } from "../helpers/rules-shim";
import { UserError } from "../helpers/user-error";
import { scanRuleSource } from "./rule-scanner";
Expand Down Expand Up @@ -235,6 +238,12 @@ export async function loadRuleAdrs(
): Promise<LoadResult[]> {
const pp = resolvedProjectPaths(projectRoot);

// Opt-in directories (inside `.archgate/`) that rule files may import shared
// helpers from. Empty unless the project configured `ruleImports.allowedDirs`.
// Throws a UserError if a configured dir escapes `.archgate/` — a hard,
// fail-closed boundary enforced before any rule source is scanned.
const allowedImportDirs = resolveRuleImportDirs(projectRoot);

// Ensure rules.d.ts exists so .rules.ts files get type checking
// without requiring node_modules (supports non-JS projects).
// When ADRs live in a custom directory, also write the shim there.
Expand Down Expand Up @@ -320,7 +329,10 @@ export async function loadRuleAdrs(
// 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 scanViolations = scanRuleSource(ruleSource);
const scanViolations = scanRuleSource(ruleSource, {
filePath: rulesFile,
allowedImportDirs,
});
if (scanViolations.length > 0) {
return {
type: "blocked",
Expand Down
97 changes: 97 additions & 0 deletions src/engine/rule-import-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Archgate
/**
* Resolution for the opt-in "contained relative import" feature.
*
* `.rules.ts` files may, when a project opts in via `ruleImports.allowedDirs`,
* import shared helpers by relative path — but ONLY when the resolved target
* lands inside a configured directory (each of which is itself proven to live
* inside `.archgate/`; see `resolveRuleImportDirs`). Everything else stays
* blocked exactly as before.
*
* The crux of the security model here is `realpathSync`: a specifier is
* resolved to a concrete file, then canonicalized, then checked for
* containment. A `..` escape or a symlink whose target sits outside the
* allowed tree canonicalizes to its true location and fails the check, so the
* boundary cannot be tricked by either.
*/
import { existsSync, realpathSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";

import { isPathInside } from "../helpers/paths";

/**
* Extension and index candidates tried for an extensionless specifier, in the
* order a Node/Bun resolver would consult them. Kept intentionally small: rule
* files and their helpers are TypeScript/JavaScript source only.
*/
const RESOLVE_EXTENSIONS = [
".ts",
".tsx",
".mts",
".cts",
".js",
".jsx",
".mjs",
".cjs",
] as const;

/** A relative specifier is one that starts with `./` or `../`. */
export function isRelativeSpecifier(spec: string): boolean {
return spec.startsWith("./") || spec.startsWith("../");
}

function isFile(p: string): boolean {
try {
return statSync(p).isFile();
} catch {
return false;
}
}

/**
* Apply extension/index resolution to a bare (possibly extensionless) target
* path. Returns the first existing file, or null. Mirrors the runtime: an
* explicit path wins, then `<target><ext>`, then `<target>/index<ext>`.
*/
function resolveToFile(target: string): string | null {
if (isFile(target)) return target;
for (const ext of RESOLVE_EXTENSIONS) {
const candidate = target + ext;
if (isFile(candidate)) return candidate;
}
if (existsSync(target)) {
for (const ext of RESOLVE_EXTENSIONS) {
const candidate = resolve(target, `index${ext}`);
if (isFile(candidate)) return candidate;
}
}
return null;
}

/**
* Resolve a relative import `spec` from `fromFile` and return its canonical
* (realpath'd) absolute path IF AND ONLY IF the target exists and lands inside
* one of `allowedDirs` (absolute, already realpath'd, and — by construction —
* inside `.archgate/`). Returns null when the specifier does not exist or
* escapes containment; the caller then emits the normal blocked-import
* violation.
*/
export function resolveContainedImport(
spec: string,
fromFile: string,
allowedDirs: string[]
): string | null {
if (allowedDirs.length === 0) return null;
const target = resolve(dirname(fromFile), spec);
const file = resolveToFile(target);
if (file === null) return null;

let real: string;
try {
real = realpathSync(file);
} catch {
return null;
}
return allowedDirs.some((dir) => isPathInside(real, dir)) ? real : null;
}
129 changes: 125 additions & 4 deletions src/engine/rule-scanner.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Archgate
import { readFileSync, realpathSync } from "node:fs";
import { dirname, relative } from "node:path";

import { z } from "zod";

import { parseJsModule, type MeriyahProgram } from "./js-parser";
import {
isRelativeSpecifier,
resolveContainedImport,
} from "./rule-import-resolver";

/**
* Module specifiers a rule file is permitted to import.
Expand Down Expand Up @@ -185,7 +192,7 @@ interface AstNode {
name?: string;
value?: string | number | boolean | null | AstNode;
computed?: boolean;
source?: AstNode;
source?: AstNode | null;
object?: AstNode;
property?: AstNode;
callee?: AstNode;
Expand All @@ -207,7 +214,16 @@ const AstNodeSchema: z.ZodType<AstNode> = z
])
.optional(),
computed: z.boolean().optional(),
source: z.lazy(() => AstNodeSchema).optional(),
// ESTree sets `source: null` on an `export` declaration that has no `from`
// clause (`export function`, `export const`, `export { local }`). Without
// `.nullable()` the whole node fails validation and `parseNode` drops it —
// silently skipping every child, so anything dangerous inside a top-level
// `export`-declaration would go unscanned. Tolerating null keeps the node
// in the walk; `checkModuleSpecifier` still correctly no-ops on a null src.
source: z
.lazy(() => AstNodeSchema)
.nullable()
.optional(),
object: z.lazy(() => AstNodeSchema).optional(),
property: z.lazy(() => AstNodeSchema).optional(),
callee: z.lazy(() => AstNodeSchema).optional(),
Expand Down Expand Up @@ -247,10 +263,38 @@ import { remapViolations, type RawViolation } from "./source-positions";
/** Shared transpiler — stateless, safe to reuse across calls. */
const tsTranspiler = new Bun.Transpiler({ loader: "ts" });

/**
* Options for {@link scanRuleSource}. Every field is optional and absent ⇒
* the historical behavior (all relative imports blocked, no pre-transpile).
*/
export interface ScanRuleOptions {
/** Pre-transpiled JS, to skip the internal TypeScript transpile step. */
preTranspiled?: string;
/**
* Absolute path of the file being scanned. Required to resolve relative
* imports; without it every relative import is blocked.
*/
filePath?: string;
/**
* Absolute, realpath'd directories — guaranteed by the caller to live inside
* `.archgate/` (see `resolveRuleImportDirs`) — that a relative import may
* resolve into. Empty (the default) blocks every relative import.
*/
allowedImportDirs?: string[];
}

export function scanRuleSource(
source: string,
preTranspiled?: string
opts: ScanRuleOptions = {},
// Internal: canonical paths already scanned on this transitive walk, so an
// import cycle terminates. Not part of the public contract.
visited: Set<string> = new Set()
): ScanViolation[] {
const { preTranspiled, filePath, allowedImportDirs = [] } = opts;
// Relative imports found to be allowed (resolved, contained real paths).
// Scanned transitively after the walk with the SAME options.
const resolvedImports = new Set<string>();

// Runs first, on the untransformed source, and is carried through the parse
// failure paths below: a file that does not parse is exactly where a hidden
// character is most worth reporting.
Expand Down Expand Up @@ -350,6 +394,25 @@ export function scanRuleSource(
}
}

/**
* Opt-in escape valve for the module allowlist: a RELATIVE specifier is
* accepted only when the project configured `ruleImports.allowedDirs` and the
* specifier resolves (after realpath) to a file inside one of those dirs —
* which are themselves proven to be inside `.archgate/`. The resolved path is
* recorded for the transitive scan. Returns false (⇒ caller emits the normal
* violation) when the feature is off, the specifier is not relative, or the
* target is missing / escapes containment. Absent `filePath` or empty
* `allowedImportDirs` ⇒ always false, i.e. the historical behavior.
*/
function tryAllowRelativeImport(spec: string): boolean {
if (filePath === undefined || allowedImportDirs.length === 0) return false;
if (!isRelativeSpecifier(spec)) return false;
const real = resolveContainedImport(spec, filePath, allowedImportDirs);
if (real === null) return false;
resolvedImports.add(real);
return true;
}

/**
* Enforce the module allowlist for any construct that names a module and
* causes it to be evaluated. `import`, `export ... from`, and `export * from`
Expand All @@ -360,6 +423,7 @@ export function scanRuleSource(
const src =
typeof node.source?.value === "string" ? node.source.value : undefined;
if (src === undefined || ALLOWED_MODULES.has(src)) return;
if (tryAllowRelativeImport(src)) return;
// Anchor on `from "module"` — `from` is in code context, whereas the bare
// module string is inside a literal, which buildNonCodeRanges skips.
pushViolation(
Expand Down Expand Up @@ -466,6 +530,7 @@ export function scanRuleSource(
? node.source.value
: undefined;
if (src !== undefined && !ALLOWED_MODULES.has(src)) {
if (tryAllowRelativeImport(src)) break;
// Anchor on `import(`, not the specifier: the literal is non-code to
// the remapper, and `import(` survives arbitrary argument formatting.
pushViolation(
Expand Down Expand Up @@ -512,7 +577,58 @@ export function scanRuleSource(
if (root) walk(root);
// Text-pass violations already carry true positions — they are found in the
// original source, so they need no remapping back through the transpiler.
return textViolations.concat(remapViolations(source, rawViolations));
const violations = textViolations.concat(
remapViolations(source, rawViolations)
);

// Transitive scan: recurse into every allowed relative import with the SAME
// options, so a contained helper cannot become an escape hatch (importing
// `node:child_process`, calling `fetch`/`eval`, hiding an invisible char,
// etc.). Cycles terminate via the shared `visited` set. This runs only when
// the feature resolved at least one import; the default path is untouched.
if (filePath !== undefined && resolvedImports.size > 0) {
let selfReal = filePath;
try {
selfReal = realpathSync(filePath);
} catch {
// Non-canonicalizable self path — fall back to the given path; the
// per-target `visited` guard below still bounds the recursion.
}
visited.add(selfReal);
const fromDir = dirname(selfReal);
for (const childPath of resolvedImports) {
if (visited.has(childPath)) continue;
visited.add(childPath);
const rel = relative(fromDir, childPath);
let childSource: string;
try {
childSource = readFileSync(childPath, "utf8");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
violations.push({
message: `Imported file "${rel}" could not be read: ${msg}`,
line: 1,
column: 0,
endLine: 1,
endColumn: 0,
});
continue;
}
const childViolations = scanRuleSource(
childSource,
{ filePath: childPath, allowedImportDirs },
visited
);
for (const v of childViolations) {
violations.push({
...v,
message: `Imported file "${rel}": ${v.message}`,
});
}
}
}

return violations;
}

/**
Expand All @@ -528,6 +644,11 @@ export function scanRuleSource(
* and this delegates. It remains a distinct export so the `adr import` call
* site reads intentionally, and so the two can diverge again if a future
* imported-only restriction is ever needed.
*
* Note: no `ScanRuleOptions` are forwarded, so the opt-in contained-relative-
* import feature never applies to imported packs — they must stay
* self-contained. Relative imports would in any case be meaningless for a pack
* scanned before it is placed into a project's `.archgate/` tree.
*/
export function scanImportedRuleSource(source: string): ScanViolation[] {
return scanRuleSource(source);
Expand Down
16 changes: 16 additions & 0 deletions src/formats/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,27 @@ export const PathsConfigSchema = z.object({
rules: RelativePathSchema.optional(),
});

/**
* Opt-in allow-list of directories that `.rules.ts` files may import shared
* helpers from via relative paths. Entries are project-root-relative strings.
*
* The schema deliberately does NOT try to prove containment here: a path that
* merely reads as safe can still escape `.archgate/` via a symlink, so the
* authoritative HARD boundary (resolve → realpath → must be inside
* `.archgate/`) is enforced against the filesystem in `resolveRuleImportDirs`.
* Keeping the schema permissive lets that resolver surface a clear,
* per-entry error rather than silently dropping the whole config.
*/
const RuleImportsConfigSchema = z.object({
allowedDirs: z.array(z.string().min(1, "path must not be empty")).default([]),
});

export const ProjectConfigSchema = z
.object({
domains: z.record(DomainNameSchema, DomainPrefixSchema).default({}),
paths: PathsConfigSchema.optional(),
baseBranch: z.string().min(1).optional(),
ruleImports: RuleImportsConfigSchema.optional(),
})
.default({ domains: {} });

Expand Down
14 changes: 13 additions & 1 deletion src/helpers/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Copyright 2026 Archgate
import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, dirname, resolve } from "node:path";
import { join, dirname, resolve, sep } from "node:path";

import { logDebug } from "./log";
import { UserError } from "./user-error";
Expand Down Expand Up @@ -110,6 +110,18 @@ export function cursorUserDir(): string {

export const paths = { cacheFolder: internalPath("cache") } as const;

/**
* True when `child` is `parent` itself or a path nested inside it. Both must be
* absolute and normalized (e.g. the output of `realpathSync`/`resolve`), so
* that a prefix comparison is sound. Used to enforce the `.archgate/`
* containment boundary on opt-in rule-file imports.
*/
export function isPathInside(child: string, parent: string): boolean {
if (child === parent) return true;
const base = parent.endsWith(sep) ? parent : parent + sep;
return child.startsWith(base);
}

export function projectPath(projectRoot: string, ...path: string[]) {
return join(projectRoot, ".archgate", ...path);
}
Expand Down
Loading