fix: handle permissions=skip correctly in codex plugin (#337)

* fix: handle permissions=skip correctly in codex plugin

* refactor: rename permission mode to permissionless and align agent mappings

---------

Co-authored-by: Jayesh Sharma <wjayesh@outlook.com>
This commit is contained in:
prateek 2026-03-07 20:27:01 +05:30 committed by GitHub
parent 8801502871
commit ef40c529f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 151 additions and 33 deletions

View File

@ -45,7 +45,11 @@ projects:
# Agent-specific config
# agentConfig:
# permissions: skip # --dangerously-skip-permissions
# permissions: permissionless # modes: permissionless | default | auto-edit | suggest
# # - permissionless: no interactive permission prompts
# # - default: agent defaults
# # - auto-edit: auto-approve edits where supported
# # - suggest: conservative/untrusted-approval mode where supported
# model: opus
# Inline rules included in every agent prompt for this project

View File

@ -51,9 +51,14 @@ const NotifierConfigSchema = z
})
.passthrough();
const AgentPermissionSchema = z
.enum(["permissionless", "default", "auto-edit", "suggest", "skip"])
.default("permissionless")
.transform((value) => (value === "skip" ? "permissionless" : value));
const AgentSpecificConfigSchema = z
.object({
permissions: z.enum(["skip", "default"]).default("skip"),
permissions: AgentPermissionSchema,
model: z.string().optional(),
})
.passthrough();

View File

@ -718,11 +718,11 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
}
// Get agent launch config — uses systemPromptFile, no issue/tracker interaction.
// Orchestrator ALWAYS gets skip permissions — it must run ao CLI commands autonomously.
// Orchestrator ALWAYS gets permissionless mode — it must run ao CLI commands autonomously.
const agentLaunchConfig = {
sessionId,
projectConfig: project,
permissions: "skip" as const,
permissions: "permissionless" as const,
model: project.agentConfig?.model,
systemPromptFile,
};

View File

@ -329,7 +329,7 @@ export interface AgentLaunchConfig {
projectConfig: ProjectConfig;
issueId?: string;
prompt?: string;
permissions?: "skip" | "default";
permissions?: AgentPermissionMode;
model?: string;
/**
* System prompt to pass to the agent for orchestrator context.
@ -922,11 +922,43 @@ export interface NotifierConfig {
}
export interface AgentSpecificConfig {
permissions?: "skip" | "default";
permissions?: AgentPermissionMode;
model?: string;
[key: string]: unknown;
}
/**
* Canonical cross-agent permission policy mode.
*
* Semantics:
* - permissionless: run without interactive permission prompts (most permissive mode).
* - default: use the agent's normal/default permission model.
* - auto-edit: automatically approve edit actions where the agent supports granular approval policies.
* - suggest: conservative mode that asks for approval on higher-risk/untrusted actions where supported.
*
* Note: Not every agent exposes all granular policies; plugins map these modes to
* their closest supported behavior.
*/
export type AgentPermissionMode = "permissionless" | "default" | "auto-edit" | "suggest";
/** Backward-compatible legacy alias accepted in config parsing. */
export type LegacyAgentPermissionMode = "skip";
/** Raw permission input (supports legacy aliases). */
export type AgentPermissionInput = AgentPermissionMode | LegacyAgentPermissionMode;
/** Normalize legacy aliases to canonical permission modes. */
export function normalizeAgentPermissionMode(
mode: string | undefined,
): AgentPermissionMode | undefined {
if (!mode) return undefined;
if (mode !== "permissionless" && mode !== "default" && mode !== "auto-edit" && mode !== "suggest") {
if (mode === "skip") return "permissionless";
return undefined;
}
return mode;
}
// =============================================================================
// PLUGIN SYSTEM
// =============================================================================

View File

@ -114,8 +114,20 @@ describe("getLaunchCommand", () => {
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("aider");
});
it("includes --yes when permissions=skip", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" }));
it("includes --yes when permissions=permissionless", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "permissionless" }));
expect(cmd).toContain("--yes");
});
it("treats legacy permissions=skip as permissionless", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "skip" as unknown as AgentLaunchConfig["permissions"] }),
);
expect(cmd).toContain("--yes");
});
it("maps permissions=auto-edit to no-prompt mode on Aider", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "auto-edit" }));
expect(cmd).toContain("--yes");
});
@ -131,7 +143,7 @@ describe("getLaunchCommand", () => {
it("combines all options", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "skip", model: "sonnet", prompt: "Go" }),
makeLaunchConfig({ permissions: "permissionless", model: "sonnet", prompt: "Go" }),
);
expect(cmd).toBe("aider --yes --model 'sonnet' --message 'Go'");
});

View File

@ -18,6 +18,15 @@ import { constants } from "node:fs";
const execFileAsync = promisify(execFile);
function normalizePermissionMode(mode: string | undefined): "permissionless" | "default" | "auto-edit" | "suggest" | undefined {
if (!mode) return undefined;
if (mode === "skip") return "permissionless";
if (mode === "permissionless" || mode === "default" || mode === "auto-edit" || mode === "suggest") {
return mode;
}
return undefined;
}
// =============================================================================
// Aider Activity Detection Helpers
// =============================================================================
@ -75,7 +84,8 @@ function createAiderAgent(): Agent {
getLaunchCommand(config: AgentLaunchConfig): string {
const parts: string[] = ["aider"];
if (config.permissions === "skip") {
const permissionMode = normalizePermissionMode(config.permissions);
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
parts.push("--yes");
}

View File

@ -149,8 +149,20 @@ describe("getLaunchCommand", () => {
expect(cmd).not.toContain("unset");
});
it("includes --dangerously-skip-permissions when permissions=skip", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" }));
it("includes --dangerously-skip-permissions when permissions=permissionless", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "permissionless" }));
expect(cmd).toContain("--dangerously-skip-permissions");
});
it("treats legacy permissions=skip as permissionless", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "skip" as unknown as AgentLaunchConfig["permissions"] }),
);
expect(cmd).toContain("--dangerously-skip-permissions");
});
it("maps permissions=auto-edit to no-prompt mode on Claude", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "auto-edit" }));
expect(cmd).toContain("--dangerously-skip-permissions");
});
@ -167,7 +179,7 @@ describe("getLaunchCommand", () => {
it("combines all options without prompt", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "skip", model: "opus", prompt: "Hello" }),
makeLaunchConfig({ permissions: "permissionless", model: "opus", prompt: "Hello" }),
);
expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus'");
});
@ -658,4 +670,3 @@ describe("getSessionInfo", () => {
});
});
});

View File

@ -23,6 +23,15 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
function normalizePermissionMode(mode: string | undefined): "permissionless" | "default" | "auto-edit" | "suggest" | undefined {
if (!mode) return undefined;
if (mode === "skip") return "permissionless";
if (mode === "permissionless" || mode === "default" || mode === "auto-edit" || mode === "suggest") {
return mode;
}
return undefined;
}
// =============================================================================
// Metadata Updater Hook Script
// =============================================================================
@ -635,7 +644,8 @@ function createClaudeCodeAgent(): Agent {
// This command must be safe for both shell and execFile contexts.
const parts: string[] = ["claude"];
if (config.permissions === "skip") {
const permissionMode = normalizePermissionMode(config.permissions);
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
parts.push("--dangerously-skip-permissions");
}
@ -794,7 +804,8 @@ function createClaudeCodeAgent(): Agent {
// Build resume command
const parts: string[] = ["claude", "--resume", shellEscape(sessionUuid)];
if (project.agentConfig?.permissions === "skip") {
const permissionMode = normalizePermissionMode(project.agentConfig?.permissions);
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
parts.push("--dangerously-skip-permissions");
}

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-core";
import type { Session, RuntimeHandle, AgentLaunchConfig, AgentSpecificConfig } from "@composio/ao-core";
// ---------------------------------------------------------------------------
// Hoisted mocks — available inside vi.mock factories
@ -213,24 +213,27 @@ describe("getLaunchCommand", () => {
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'codex'");
});
it("includes --dangerously-bypass-approvals-and-sandbox when permissions=skip", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" }));
it("includes bypass flag when permissions=permissionless", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "permissionless" }));
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
expect(cmd).not.toContain("--ask-for-approval");
expect(cmd).not.toContain("--full-auto");
});
it("includes --ask-for-approval never when permissions=auto-edit", () => {
// Cast needed: "auto-edit" not yet in AgentLaunchConfig type union
it("treats legacy permissions=skip as permissionless", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "auto-edit" as AgentLaunchConfig["permissions"] }),
makeLaunchConfig({ permissions: "skip" as unknown as AgentLaunchConfig["permissions"] }),
);
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
});
it("includes --ask-for-approval never when permissions=auto-edit", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "auto-edit" }));
expect(cmd).toContain("--ask-for-approval never");
});
it("includes --ask-for-approval untrusted when permissions=suggest", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "suggest" as AgentLaunchConfig["permissions"] }),
);
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "suggest" }));
expect(cmd).toContain("--ask-for-approval untrusted");
});
@ -253,7 +256,7 @@ describe("getLaunchCommand", () => {
it("combines all options", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "skip", model: "o3", prompt: "Go" }),
makeLaunchConfig({ permissions: "permissionless", model: "o3", prompt: "Go" }),
);
expect(cmd).toBe("'codex' --dangerously-bypass-approvals-and-sandbox --model 'o3' -c model_reasoning_effort=high -- 'Go'");
});
@ -965,7 +968,7 @@ describe("getRestoreCommand", () => {
expect(cmd).toContain("thread-abc-123");
});
it("includes --dangerously-bypass-approvals-and-sandbox from project config", async () => {
it("includes bypass flag when project config permissions=permissionless", async () => {
const content = jsonl(
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
{ threadId: "thread-1" },
@ -978,7 +981,27 @@ describe("getRestoreCommand", () => {
const session = makeSession({ workspacePath: "/workspace/test" });
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
agentConfig: { permissions: "skip" },
agentConfig: { permissions: "permissionless" },
}));
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
expect(cmd).not.toContain("--ask-for-approval");
});
it("treats legacy project config permissions=skip as permissionless", async () => {
const content = jsonl(
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
{ threadId: "thread-1" },
);
mockReaddir.mockResolvedValue(["sess.jsonl"]);
setupMockOpen(content);
setupMockStream(content);
mockReadFile.mockResolvedValue(content);
mockStat.mockResolvedValue({ mtimeMs: 1000 });
const session = makeSession({ workspacePath: "/workspace/test" });
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
agentConfig: { permissions: "skip" as unknown as AgentSpecificConfig["permissions"] },
}));
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");

View File

@ -24,6 +24,15 @@ import { randomBytes } from "node:crypto";
const execFileAsync = promisify(execFile);
function normalizePermissionMode(mode: string | undefined): "permissionless" | "default" | "auto-edit" | "suggest" | undefined {
if (!mode) return undefined;
if (mode === "skip") return "permissionless";
if (mode === "permissionless" || mode === "default" || mode === "auto-edit" || mode === "suggest") {
return mode;
}
return undefined;
}
/** Shared bin directory for ao shell wrappers (prepended to PATH) */
const AO_BIN_DIR = join(homedir(), ".ao", "bin");
@ -516,11 +525,12 @@ export async function resolveCodexBinary(): Promise<string> {
/** Append approval-policy flags to a command parts array */
function appendApprovalFlags(parts: string[], permissions: string | undefined): void {
if (permissions === "skip") {
const mode = normalizePermissionMode(permissions);
if (mode === "permissionless") {
parts.push("--dangerously-bypass-approvals-and-sandbox");
} else if (permissions === "auto-edit") {
} else if (mode === "auto-edit") {
parts.push("--ask-for-approval", "never");
} else if (permissions === "suggest") {
} else if (mode === "suggest") {
parts.push("--ask-for-approval", "untrusted");
}
}
@ -571,7 +581,7 @@ function createCodexAgent(): Agent {
const binary = resolvedBinary ?? "codex";
const parts: string[] = [shellEscape(binary)];
appendApprovalFlags(parts, config.permissions as string | undefined);
appendApprovalFlags(parts, config.permissions);
appendModelFlags(parts, config.model);
if (config.systemPromptFile) {
@ -760,7 +770,7 @@ function createCodexAgent(): Agent {
const binary = resolvedBinary ?? "codex";
const parts: string[] = [shellEscape(binary), "resume"];
appendApprovalFlags(parts, project.agentConfig?.permissions as string | undefined);
appendApprovalFlags(parts, project.agentConfig?.permissions);
const effectiveModel = (project.agentConfig?.model ?? data.model) as string | undefined;
appendModelFlags(parts, effectiveModel ?? undefined);