diff --git a/src/cli.ts b/src/cli.ts index c4640ce..07581e9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -768,6 +768,12 @@ async function handleExec( command: Command, config: ResolvedAcpxConfig, ): Promise { + if (config.disableExec) { + throw new InvalidArgumentError( + "exec is disabled by configuration (disableExec: true). Use prompt with a persistent session instead.", + ); + } + const globalFlags = resolveGlobalFlags(command, config); const outputPolicy = resolveOutputPolicy( globalFlags.format, diff --git a/src/config.ts b/src/config.ts index c4034f0..b38af70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ type ConfigFileShape = { format?: unknown; agents?: unknown; auth?: unknown; + disableExec?: unknown; }; export type ResolvedAcpxConfig = { @@ -35,6 +36,7 @@ export type ResolvedAcpxConfig = { format: OutputFormat; agents: Record; auth: Record; + disableExec: boolean; globalPath: string; projectPath: string; hasGlobalConfig: boolean; @@ -164,6 +166,16 @@ function parseOutputFormat( return value as OutputFormat; } +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; +} + function parseDefaultAgent(value: unknown, sourcePath: string): string | undefined { if (value == null) { return undefined; @@ -347,6 +359,11 @@ export async function loadResolvedConfig(cwd: string): Promise; authMethods: string[]; } { @@ -388,6 +407,7 @@ export function toConfigDisplay(config: ResolvedAcpxConfig): { ttl: Math.round(config.ttlMs / 1_000), timeout: config.timeoutMs == null ? null : config.timeoutMs / 1_000, format: config.format, + disableExec: config.disableExec, agents, authMethods: Object.keys(config.auth).sort(), }; diff --git a/test/config.test.ts b/test/config.test.ts index 8260b7b..ae6c64e 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -104,6 +104,81 @@ test("initGlobalConfigFile creates the config once and then reports existing fil }); }); +test("disableExec defaults to false when not set", async () => { + await withTempEnv(async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-exec-")); + try { + const config = await loadResolvedConfig(cwd); + assert.equal(config.disableExec, false); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("disableExec is read 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("disableExec project config overrides 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", + ); + + 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("disableExec rejects non-boolean values", 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( + () => loadResolvedConfig(cwd), + (error: Error) => { + assert.match(error.message, /disableExec/); + assert.match(error.message, /boolean/); + return true; + }, + ); + }); +}); + async function withTempEnv( run: (ctx: { homeDir: string }) => Promise, ): Promise {