From da7ee746f8c6fb78b524b9bc39d8ca0061e669cd Mon Sep 17 00:00:00 2001 From: "credence-the-bot[bot]" Date: Sat, 9 May 2026 21:15:52 +0000 Subject: [PATCH 1/2] feat(cli): advisory ESLint rule cli/no-daemon-internals --- .../__tests__/cli-no-daemon-internals.test.ts | 170 +++++++++++++ .../eslint-rules/cli-no-daemon-internals.ts | 232 ++++++++++++++++++ assistant/eslint.config.mjs | 8 + 3 files changed, 410 insertions(+) create mode 100644 assistant/eslint-rules/__tests__/cli-no-daemon-internals.test.ts create mode 100644 assistant/eslint-rules/cli-no-daemon-internals.ts diff --git a/assistant/eslint-rules/__tests__/cli-no-daemon-internals.test.ts b/assistant/eslint-rules/__tests__/cli-no-daemon-internals.test.ts new file mode 100644 index 00000000000..8ca234071d8 --- /dev/null +++ b/assistant/eslint-rules/__tests__/cli-no-daemon-internals.test.ts @@ -0,0 +1,170 @@ +import tsParser from "@typescript-eslint/parser"; +import { RuleTester } from "eslint"; + +import rule from "../cli-no-daemon-internals.js"; + +const tester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + parser: tsParser, + }, +}); + +tester.run("cli/no-daemon-internals", rule, { + valid: [ + // ipc-tagged file importing only allowed sources + { + code: ` + import type { Command } from "commander"; + import { cliIpcCall } from "../../ipc/cli-client.js"; + import { log } from "../logger.js"; + import { printTable } from "../output.js"; + + registerCommand(program, { + name: "example", + transport: "ipc", + build: () => {}, + }); + `, + }, + // local-tagged file importing allowed sources + { + code: ` + import type { Command } from "commander"; + import { loadRawConfig } from "../../config/loader.js"; + import { getWorkspaceDir } from "../../util/platform.js"; + + registerCommand(program, { + name: "local-example", + transport: "local", + build: () => {}, + }); + `, + }, + // bootstrap-tagged file importing allowed sources + { + code: ` + import type { Command } from "commander"; + import { AssistantConfigSchema } from "../../config/schema.js"; + + registerCommand(program, { + name: "bootstrap-example", + transport: "bootstrap", + build: () => {}, + }); + `, + }, + // ipc-tagged file importing from ../lib/ prefix (shared lib) + { + code: ` + import type { Command } from "commander"; + import { cliIpcCall } from "../../ipc/cli-client.js"; + import { readFileSync } from "../lib/daemon-credential-client.js"; + + registerCommand(program, { + name: "lib-example", + transport: "ipc", + build: () => {}, + }); + `, + }, + // File with zero imports and no registerCommand — utility file + { + code: ` + function utilHelper() { + return 42; + } + export { utilHelper }; + `, + }, + ], + + invalid: [ + // File with imports but no registerCommand call + { + code: ` + import type { Command } from "commander"; + import { cliIpcCall } from "../../ipc/cli-client.js"; + + export function registerMyCommand(program) { + // forgot to call registerCommand + } + `, + errors: [ + { + messageId: "missingTransport", + }, + ], + }, + // ipc-tagged file importing a forbidden runtime route + { + code: ` + import type { Command } from "commander"; + import { cliIpcCall } from "../../ipc/cli-client.js"; + import { healthRoutes } from "../../runtime/routes/health-routes.js"; + + registerCommand(program, { + name: "bad-ipc", + transport: "ipc", + build: () => {}, + }); + `, + errors: [ + { + messageId: "forbiddenImport", + data: { + transport: "ipc", + source: "../../runtime/routes/health-routes.js", + }, + }, + ], + }, + // ipc-tagged file importing a skills catalog module + { + code: ` + import type { Command } from "commander"; + import { cliIpcCall } from "../../ipc/cli-client.js"; + import { SkillsCatalog } from "../../skills/catalog.js"; + + registerCommand(program, { + name: "bad-ipc-skills", + transport: "ipc", + build: () => {}, + }); + `, + errors: [ + { + messageId: "forbiddenImport", + data: { + transport: "ipc", + source: "../../skills/catalog.js", + }, + }, + ], + }, + // local-tagged file importing a forbidden runtime route + { + code: ` + import type { Command } from "commander"; + import { loadRawConfig } from "../../config/loader.js"; + import { healthRoutes } from "../../runtime/routes/health-routes.js"; + + registerCommand(program, { + name: "bad-local", + transport: "local", + build: () => {}, + }); + `, + errors: [ + { + messageId: "forbiddenImport", + data: { + transport: "local", + source: "../../runtime/routes/health-routes.js", + }, + }, + ], + }, + ], +}); diff --git a/assistant/eslint-rules/cli-no-daemon-internals.ts b/assistant/eslint-rules/cli-no-daemon-internals.ts new file mode 100644 index 00000000000..4d1c60fe2f6 --- /dev/null +++ b/assistant/eslint-rules/cli-no-daemon-internals.ts @@ -0,0 +1,232 @@ +import type { Rule } from "eslint"; +import type { + CallExpression, + ImportDeclaration, + Node, + ObjectExpression, + Program, +} from "estree"; + +/** + * Allowed import prefixes per transport class. + * + * "ipc" commands run after the daemon is up and communicate over the Unix + * domain socket, so they may import the IPC client, logger, and output + * helpers plus anything in the shared lib/. + * + * "local" and "bootstrap" commands run without a live daemon (or during + * its bootstrap phase), so they must stay away from IPC internals and may + * only touch config, platform utilities, and the shared lib/. + */ +const ALLOWED_PREFIXES: Record = { + ipc: [ + "node:", + "bun:", + "commander", + "../../ipc/cli-client", + "../logger", + "../output", + "../lib/", + ], + local: [ + "node:", + "bun:", + "commander", + "../../config/loader", + "../../config/schema", + "../../util/platform", + "../lib/", + ], + bootstrap: [ + "node:", + "bun:", + "commander", + "../../config/loader", + "../../config/schema", + "../../util/platform", + "../lib/", + ], +}; + +/** + * Iteratively walk the AST body (top-level and nested expression statements) + * to find a `registerCommand(program, { transport: "..." })` call. + * + * We avoid generic deep recursion to prevent call-stack overflows on large + * TypeScript ASTs with circular parent/scope references. Instead, we only + * walk into the parts of the tree that could contain a top-level call or a + * call nested inside an export/function declaration. + */ +function findTransport(program: Program): string | null { + const worklist: Node[] = [...program.body]; + const seen = new WeakSet(); + + while (worklist.length > 0) { + const node = worklist.pop()!; + + if (!node || typeof node !== "object" || seen.has(node)) { + continue; + } + seen.add(node); + + // Check if this node is the target call expression. + if ( + node.type === "CallExpression" && + (node as CallExpression).callee.type === "Identifier" && + ((node as CallExpression).callee as { name: string }).name === + "registerCommand" + ) { + const call = node as CallExpression; + // Scan all arguments for an ObjectExpression with a transport property. + for (const arg of call.arguments) { + if (arg.type === "ObjectExpression") { + const objExpr = arg as ObjectExpression; + for (const prop of objExpr.properties) { + if ( + prop.type === "Property" && + prop.key.type === "Identifier" && + (prop.key as { name: string }).name === "transport" && + prop.value.type === "Literal" && + typeof (prop.value as { value: unknown }).value === "string" + ) { + return (prop.value as { value: string }).value; + } + } + } + } + } + + // Push children that may contain call expressions. + // We only drill into statement/expression wrappers to avoid cycles. + switch (node.type) { + case "ExpressionStatement": + worklist.push((node as { expression: Node }).expression); + break; + case "CallExpression": { + const call = node as CallExpression; + for (const arg of call.arguments) { + worklist.push(arg as Node); + } + break; + } + case "FunctionDeclaration": + case "FunctionExpression": + case "ArrowFunctionExpression": { + const fn = node as { + body: Node; + params?: Node[]; + }; + if (fn.body) worklist.push(fn.body); + break; + } + case "BlockStatement": { + for (const stmt of (node as { body: Node[] }).body) { + worklist.push(stmt); + } + break; + } + case "ReturnStatement": { + const ret = node as { argument?: Node }; + if (ret.argument) worklist.push(ret.argument); + break; + } + case "ExportNamedDeclaration": + case "ExportDefaultDeclaration": { + const exp = node as { declaration?: Node }; + if (exp.declaration) worklist.push(exp.declaration); + break; + } + case "VariableDeclaration": { + for (const decl of (node as { declarations: { init?: Node }[] }) + .declarations) { + if (decl.init) worklist.push(decl.init); + } + break; + } + case "ObjectExpression": { + for (const prop of ( + node as { properties: { value: Node; type: string }[] } + ).properties) { + if (prop.type === "Property") { + worklist.push(prop.value); + } + } + break; + } + default: + break; + } + } + + return null; +} + +const rule: Rule.RuleModule = { + meta: { + type: "suggestion", + docs: { + description: + "Enforce import allowlists for CLI commands by transport class", + }, + messages: { + missingTransport: + "CLI command file must call registerCommand({ transport: ... }) to declare its transport class.", + forbiddenImport: + "'{{transport}}'-tagged CLI command imports forbidden module '{{source}}'. See DESIGN.md §3.1 for allowed imports.", + }, + schema: [], + }, + + create(context: Rule.RuleContext) { + const importNodes: ImportDeclaration[] = []; + + return { + ImportDeclaration(node: ImportDeclaration) { + importNodes.push(node); + }, + + "Program:exit"(program: Program) { + // Skip files with zero imports — they may be pure utilities. + if (importNodes.length === 0) { + return; + } + + const transport = findTransport(program); + + if (transport === null) { + // No registerCommand with transport found — warn on Program node. + context.report({ + node: program, + messageId: "missingTransport", + }); + return; + } + + const allowedPrefixes = ALLOWED_PREFIXES[transport]; + if (!allowedPrefixes) { + // Unknown transport — no allowlist to enforce. + return; + } + + for (const importNode of importNodes) { + const source = importNode.source.value as string; + const allowed = allowedPrefixes.some((prefix) => + source.startsWith(prefix), + ); + if (!allowed) { + context.report({ + node: importNode, + messageId: "forbiddenImport", + data: { + transport, + source, + }, + }); + } + } + }, + }; + }, +}; + +export default rule; diff --git a/assistant/eslint.config.mjs b/assistant/eslint.config.mjs index fd5efa4cef8..5ed8b08912c 100644 --- a/assistant/eslint.config.mjs +++ b/assistant/eslint.config.mjs @@ -2,6 +2,8 @@ import { defineConfig, globalIgnores } from "eslint/config"; import simpleImportSort from "eslint-plugin-simple-import-sort"; import tseslint from "typescript-eslint"; +import cliNoDaemonInternals from "./eslint-rules/cli-no-daemon-internals.js"; + const eslintConfig = defineConfig([ ...tseslint.configs.recommended, globalIgnores(["dist/**", "drizzle/**"]), @@ -43,6 +45,12 @@ const eslintConfig = defineConfig([ "@typescript-eslint/no-explicit-any": "off", }, }, + { + files: ["src/cli/commands/**/*.ts"], + ignores: ["src/cli/commands/**/__tests__/**"], + plugins: { cli: { rules: { "no-daemon-internals": cliNoDaemonInternals } } }, + rules: { "cli/no-daemon-internals": "warn" }, + }, ]); export default eslintConfig; From 49f990b5f5e730b6a592ef6ba7facf73e2db18a7 Mon Sep 17 00:00:00 2001 From: "credence-the-bot[bot]" Date: Sat, 9 May 2026 21:22:11 +0000 Subject: [PATCH 2/2] fix(lint): convert ESLint rule to plain JS so Node.js ESM loader can find it Co-Authored-By: Claude Sonnet 4.6 --- .../eslint-rules/cli-no-daemon-internals.js | 176 +++++++++++++ .../eslint-rules/cli-no-daemon-internals.ts | 232 ------------------ 2 files changed, 176 insertions(+), 232 deletions(-) create mode 100644 assistant/eslint-rules/cli-no-daemon-internals.js delete mode 100644 assistant/eslint-rules/cli-no-daemon-internals.ts diff --git a/assistant/eslint-rules/cli-no-daemon-internals.js b/assistant/eslint-rules/cli-no-daemon-internals.js new file mode 100644 index 00000000000..60572ba9029 --- /dev/null +++ b/assistant/eslint-rules/cli-no-daemon-internals.js @@ -0,0 +1,176 @@ +const ALLOWED_PREFIXES = { + ipc: [ + "node:", + "bun:", + "commander", + "../../ipc/cli-client", + "../logger", + "../output", + "../lib/", + ], + local: [ + "node:", + "bun:", + "commander", + "../../config/loader", + "../../config/schema", + "../../util/platform", + "../lib/", + ], + bootstrap: [ + "node:", + "bun:", + "commander", + "../../config/loader", + "../../config/schema", + "../../util/platform", + "../lib/", + ], +}; + +function findTransport(program) { + const worklist = [...program.body]; + const seen = new WeakSet(); + + while (worklist.length > 0) { + const node = worklist.pop(); + + if (!node || typeof node !== "object" || seen.has(node)) { + continue; + } + seen.add(node); + + if ( + node.type === "CallExpression" && + node.callee.type === "Identifier" && + node.callee.name === "registerCommand" + ) { + for (const arg of node.arguments) { + if (arg.type === "ObjectExpression") { + for (const prop of arg.properties) { + if ( + prop.type === "Property" && + prop.key.type === "Identifier" && + prop.key.name === "transport" && + prop.value.type === "Literal" && + typeof prop.value.value === "string" + ) { + return prop.value.value; + } + } + } + } + } + + switch (node.type) { + case "ExpressionStatement": + worklist.push(node.expression); + break; + case "CallExpression": + for (const arg of node.arguments) { + worklist.push(arg); + } + break; + case "FunctionDeclaration": + case "FunctionExpression": + case "ArrowFunctionExpression": + if (node.body) worklist.push(node.body); + break; + case "BlockStatement": + for (const stmt of node.body) { + worklist.push(stmt); + } + break; + case "ReturnStatement": + if (node.argument) worklist.push(node.argument); + break; + case "ExportNamedDeclaration": + case "ExportDefaultDeclaration": + if (node.declaration) worklist.push(node.declaration); + break; + case "VariableDeclaration": + for (const decl of node.declarations) { + if (decl.init) worklist.push(decl.init); + } + break; + case "ObjectExpression": + for (const prop of node.properties) { + if (prop.type === "Property") { + worklist.push(prop.value); + } + } + break; + default: + break; + } + } + + return null; +} + +const rule = { + meta: { + type: "suggestion", + docs: { + description: + "Enforce import allowlists for CLI commands by transport class", + }, + messages: { + missingTransport: + "CLI command file must call registerCommand({ transport: ... }) to declare its transport class.", + forbiddenImport: + "'{{transport}}'-tagged CLI command imports forbidden module '{{source}}'. See DESIGN.md §3.1 for allowed imports.", + }, + schema: [], + }, + + create(context) { + const importNodes = []; + + return { + ImportDeclaration(node) { + importNodes.push(node); + }, + + "Program:exit"(program) { + if (importNodes.length === 0) { + return; + } + + const transport = findTransport(program); + + if (transport === null) { + context.report({ + node: program, + messageId: "missingTransport", + }); + return; + } + + const allowedPrefixes = ALLOWED_PREFIXES[transport]; + if (!allowedPrefixes) { + return; + } + + for (const importNode of importNodes) { + const source = importNode.source.value; + const allowed = allowedPrefixes.some((prefix) => + source.startsWith(prefix), + ); + if (!allowed) { + context.report({ + node: importNode, + messageId: "forbiddenImport", + data: { + transport, + source, + }, + }); + } + } + }, + }; + }, +}; + +export default rule; diff --git a/assistant/eslint-rules/cli-no-daemon-internals.ts b/assistant/eslint-rules/cli-no-daemon-internals.ts deleted file mode 100644 index 4d1c60fe2f6..00000000000 --- a/assistant/eslint-rules/cli-no-daemon-internals.ts +++ /dev/null @@ -1,232 +0,0 @@ -import type { Rule } from "eslint"; -import type { - CallExpression, - ImportDeclaration, - Node, - ObjectExpression, - Program, -} from "estree"; - -/** - * Allowed import prefixes per transport class. - * - * "ipc" commands run after the daemon is up and communicate over the Unix - * domain socket, so they may import the IPC client, logger, and output - * helpers plus anything in the shared lib/. - * - * "local" and "bootstrap" commands run without a live daemon (or during - * its bootstrap phase), so they must stay away from IPC internals and may - * only touch config, platform utilities, and the shared lib/. - */ -const ALLOWED_PREFIXES: Record = { - ipc: [ - "node:", - "bun:", - "commander", - "../../ipc/cli-client", - "../logger", - "../output", - "../lib/", - ], - local: [ - "node:", - "bun:", - "commander", - "../../config/loader", - "../../config/schema", - "../../util/platform", - "../lib/", - ], - bootstrap: [ - "node:", - "bun:", - "commander", - "../../config/loader", - "../../config/schema", - "../../util/platform", - "../lib/", - ], -}; - -/** - * Iteratively walk the AST body (top-level and nested expression statements) - * to find a `registerCommand(program, { transport: "..." })` call. - * - * We avoid generic deep recursion to prevent call-stack overflows on large - * TypeScript ASTs with circular parent/scope references. Instead, we only - * walk into the parts of the tree that could contain a top-level call or a - * call nested inside an export/function declaration. - */ -function findTransport(program: Program): string | null { - const worklist: Node[] = [...program.body]; - const seen = new WeakSet(); - - while (worklist.length > 0) { - const node = worklist.pop()!; - - if (!node || typeof node !== "object" || seen.has(node)) { - continue; - } - seen.add(node); - - // Check if this node is the target call expression. - if ( - node.type === "CallExpression" && - (node as CallExpression).callee.type === "Identifier" && - ((node as CallExpression).callee as { name: string }).name === - "registerCommand" - ) { - const call = node as CallExpression; - // Scan all arguments for an ObjectExpression with a transport property. - for (const arg of call.arguments) { - if (arg.type === "ObjectExpression") { - const objExpr = arg as ObjectExpression; - for (const prop of objExpr.properties) { - if ( - prop.type === "Property" && - prop.key.type === "Identifier" && - (prop.key as { name: string }).name === "transport" && - prop.value.type === "Literal" && - typeof (prop.value as { value: unknown }).value === "string" - ) { - return (prop.value as { value: string }).value; - } - } - } - } - } - - // Push children that may contain call expressions. - // We only drill into statement/expression wrappers to avoid cycles. - switch (node.type) { - case "ExpressionStatement": - worklist.push((node as { expression: Node }).expression); - break; - case "CallExpression": { - const call = node as CallExpression; - for (const arg of call.arguments) { - worklist.push(arg as Node); - } - break; - } - case "FunctionDeclaration": - case "FunctionExpression": - case "ArrowFunctionExpression": { - const fn = node as { - body: Node; - params?: Node[]; - }; - if (fn.body) worklist.push(fn.body); - break; - } - case "BlockStatement": { - for (const stmt of (node as { body: Node[] }).body) { - worklist.push(stmt); - } - break; - } - case "ReturnStatement": { - const ret = node as { argument?: Node }; - if (ret.argument) worklist.push(ret.argument); - break; - } - case "ExportNamedDeclaration": - case "ExportDefaultDeclaration": { - const exp = node as { declaration?: Node }; - if (exp.declaration) worklist.push(exp.declaration); - break; - } - case "VariableDeclaration": { - for (const decl of (node as { declarations: { init?: Node }[] }) - .declarations) { - if (decl.init) worklist.push(decl.init); - } - break; - } - case "ObjectExpression": { - for (const prop of ( - node as { properties: { value: Node; type: string }[] } - ).properties) { - if (prop.type === "Property") { - worklist.push(prop.value); - } - } - break; - } - default: - break; - } - } - - return null; -} - -const rule: Rule.RuleModule = { - meta: { - type: "suggestion", - docs: { - description: - "Enforce import allowlists for CLI commands by transport class", - }, - messages: { - missingTransport: - "CLI command file must call registerCommand({ transport: ... }) to declare its transport class.", - forbiddenImport: - "'{{transport}}'-tagged CLI command imports forbidden module '{{source}}'. See DESIGN.md §3.1 for allowed imports.", - }, - schema: [], - }, - - create(context: Rule.RuleContext) { - const importNodes: ImportDeclaration[] = []; - - return { - ImportDeclaration(node: ImportDeclaration) { - importNodes.push(node); - }, - - "Program:exit"(program: Program) { - // Skip files with zero imports — they may be pure utilities. - if (importNodes.length === 0) { - return; - } - - const transport = findTransport(program); - - if (transport === null) { - // No registerCommand with transport found — warn on Program node. - context.report({ - node: program, - messageId: "missingTransport", - }); - return; - } - - const allowedPrefixes = ALLOWED_PREFIXES[transport]; - if (!allowedPrefixes) { - // Unknown transport — no allowlist to enforce. - return; - } - - for (const importNode of importNodes) { - const source = importNode.source.value as string; - const allowed = allowedPrefixes.some((prefix) => - source.startsWith(prefix), - ); - if (!allowed) { - context.report({ - node: importNode, - messageId: "forbiddenImport", - data: { - transport, - source, - }, - }); - } - } - }, - }; - }, -}; - -export default rule;