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
25 changes: 25 additions & 0 deletions src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,31 @@ async function handleExec(
command: Command,
config: ResolvedAcpxConfig,
): Promise<void> {
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);
Expand Down
21 changes: 21 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type ConfigFileShape = {
format?: unknown;
agents?: unknown;
auth?: unknown;
disableExec?: unknown;
};

export type ResolvedAcpxConfig = {
Expand All @@ -37,6 +38,7 @@ export type ResolvedAcpxConfig = {
format: OutputFormat;
agents: Record<string, string>;
auth: Record<string, string>;
disableExec: boolean;
globalPath: string;
projectPath: string;
hasGlobalConfig: boolean;
Expand All @@ -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<PermissionMode>([
"approve-all",
"approve-reads",
Expand Down Expand Up @@ -216,6 +219,16 @@ function parseAuth(value: unknown, sourcePath: string): Record<string, string> |
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<ConfigFileLoadResult> {
try {
const payload = await fs.readFile(filePath, "utf8");
Expand Down Expand Up @@ -331,6 +344,11 @@ export async function loadResolvedConfig(cwd: string): Promise<ResolvedAcpxConfi
parseAuth(projectConfig?.auth, projectPath),
);

const disableExec =
parseDisableExec(projectConfig?.disableExec, projectPath) ??
parseDisableExec(globalConfig?.disableExec, globalPath) ??
DEFAULT_DISABLE_EXEC;

return {
defaultAgent,
defaultPermissions,
Expand All @@ -342,6 +360,7 @@ export async function loadResolvedConfig(cwd: string): Promise<ResolvedAcpxConfi
format,
agents,
auth,
disableExec,
globalPath,
projectPath,
hasGlobalConfig: globalResult.exists,
Expand All @@ -360,6 +379,7 @@ export function toConfigDisplay(config: ResolvedAcpxConfig): {
format: OutputFormat;
agents: Record<string, ConfigAgentEntry>;
authMethods: string[];
disableExec: boolean;
} {
const agents: Record<string, ConfigAgentEntry> = {};
for (const [name, command] of Object.entries(config.agents)) {
Expand All @@ -377,6 +397,7 @@ export function toConfigDisplay(config: ResolvedAcpxConfig): {
format: config.format,
agents,
authMethods: Object.keys(config.auth).toSorted(),
disableExec: config.disableExec,
};
}

Expand Down
94 changes: 94 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>): Promise<void> {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-cli-test-home-"));
try {
Expand Down
68 changes: 68 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>): Promise<void> {
const originalHome = process.env.HOME;

Expand Down