diff --git a/src/cli-core.ts b/src/cli-core.ts index 89812654..76610f9f 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -285,6 +285,31 @@ async function handleExec( command: Command, config: ResolvedAcpxConfig, ): Promise { + if (config.disableExec) { + const globalFlags = resolveGlobalFlags(command, config); + const outputPolicy = resolveOutputPolicy(globalFlags.format, globalFlags.jsonStrict === true); + if (outputPolicy.format === "json") { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "exec subcommand is disabled by configuration (disableExec: true)", + data: { + acpxCode: "EXEC_DISABLED", + }, + }, + })}\n`, + ); + } else { + process.stderr.write( + "Error: exec subcommand is disabled by configuration (disableExec: true)\n", + ); + } + process.exitCode = EXIT_CODES.ERROR; + return; + } + const globalFlags = resolveGlobalFlags(command, config); const outputPolicy = resolveOutputPolicy(globalFlags.format, globalFlags.jsonStrict === true); const permissionMode = resolvePermissionMode(globalFlags, config.defaultPermissions); diff --git a/src/config.ts b/src/config.ts index 49a16ab1..b0f6ca29 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,7 @@ type ConfigFileShape = { format?: unknown; agents?: unknown; auth?: unknown; + disableExec?: unknown; }; export type ResolvedAcpxConfig = { @@ -37,6 +38,7 @@ export type ResolvedAcpxConfig = { format: OutputFormat; agents: Record; auth: Record; + disableExec: boolean; globalPath: string; projectPath: string; hasGlobalConfig: boolean; @@ -55,6 +57,7 @@ const DEFAULT_NON_INTERACTIVE_PERMISSION_POLICY: NonInteractivePermissionPolicy const DEFAULT_AUTH_POLICY: AuthPolicy = "skip"; const DEFAULT_OUTPUT_FORMAT: OutputFormat = "text"; const DEFAULT_QUEUE_MAX_DEPTH = 16; +const DEFAULT_DISABLE_EXEC = false; const VALID_PERMISSION_MODES = new Set([ "approve-all", "approve-reads", @@ -216,6 +219,16 @@ function parseAuth(value: unknown, sourcePath: string): Record | return parsed; } +function parseDisableExec(value: unknown, sourcePath: string): boolean | undefined { + if (value == null) { + return undefined; + } + if (typeof value !== "boolean") { + throw new Error(`Invalid config disableExec in ${sourcePath}: expected boolean`); + } + return value; +} + async function readConfigFile(filePath: string): Promise { try { const payload = await fs.readFile(filePath, "utf8"); @@ -331,6 +344,11 @@ export async function loadResolvedConfig(cwd: string): Promise; authMethods: string[]; + disableExec: boolean; } { const agents: Record = {}; for (const [name, command] of Object.entries(config.agents)) { @@ -377,6 +397,7 @@ export function toConfigDisplay(config: ResolvedAcpxConfig): { format: config.format, agents, authMethods: Object.keys(config.auth).toSorted(), + disableExec: config.disableExec, }; } diff --git a/test/cli.test.ts b/test/cli.test.ts index 5dcde784..12e94dd7 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1360,6 +1360,100 @@ test("config defaults are loaded from global and project config files", async () }); }); +test("exec subcommand is blocked when disableExec is true", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify( + { + disableExec: true, + agents: { + codex: { command: MOCK_AGENT_COMMAND }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = await runCli(["--cwd", cwd, "codex", "exec", "hello"], homeDir); + + assert.equal(result.code, 1); + assert.match(result.stderr, /exec subcommand is disabled by configuration/); + }); +}); + +test("exec subcommand is blocked in json format when disableExec is true", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify( + { + disableExec: true, + agents: { + codex: { command: MOCK_AGENT_COMMAND }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = await runCli( + ["--cwd", cwd, "--format", "json", "codex", "exec", "hello"], + homeDir, + ); + + assert.equal(result.code, 1); + const payload = JSON.parse(result.stdout.trim()) as { + error?: { code?: number; data?: { acpxCode?: string } }; + }; + assert.equal(payload.error?.code, -32603); + assert.equal(payload.error?.data?.acpxCode, "EXEC_DISABLED"); + }); +}); + +test("exec subcommand works when disableExec is false", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify( + { + disableExec: false, + agents: { + codex: { command: MOCK_AGENT_COMMAND }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = await runCli( + ["--cwd", cwd, "--format", "json", "codex", "exec", "echo hello"], + homeDir, + ); + + // exec should work (exit code 0) since disableExec is false + assert.equal(result.code, 0, result.stderr); + }); +}); + async function withTempHome(run: (homeDir: string) => Promise): Promise { const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-cli-test-home-")); try { diff --git a/test/config.test.ts b/test/config.test.ts index 0c8697ec..5880d04b 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -109,6 +109,74 @@ test("initGlobalConfigFile creates the config once and then reports existing fil }); }); +test("loadResolvedConfig defaults disableExec to false", async () => { + await withTempEnv(async ({ homeDir }) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + + const config = await loadResolvedConfig(cwd); + assert.equal(config.disableExec, false); + }); +}); + +test("loadResolvedConfig parses disableExec from global config", async () => { + await withTempEnv(async ({ homeDir }) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify({ disableExec: true }, null, 2)}\n`, + "utf8", + ); + + const config = await loadResolvedConfig(cwd); + assert.equal(config.disableExec, true); + }); +}); + +test("loadResolvedConfig parses disableExec from project config with priority", async () => { + await withTempEnv(async ({ homeDir }) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify({ disableExec: true }, null, 2)}\n`, + "utf8", + ); + + await fs.writeFile( + path.join(cwd, ".acpxrc.json"), + `${JSON.stringify({ disableExec: false }, null, 2)}\n`, + "utf8", + ); + + const config = await loadResolvedConfig(cwd); + assert.equal(config.disableExec, false); + }); +}); + +test("loadResolvedConfig rejects invalid disableExec value", async () => { + await withTempEnv(async ({ homeDir }) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify({ disableExec: "yes" }, null, 2)}\n`, + "utf8", + ); + + await assert.rejects(async () => { + await loadResolvedConfig(cwd); + }, /Invalid config disableExec.*expected boolean/); + }); +}); + async function withTempEnv(run: (ctx: { homeDir: string }) => Promise): Promise { const originalHome = process.env.HOME;