Browse Source

Merge pull request #748 from stephanschielke/feat/585-cherry-pick

Feat(585): Restore permission schema — cherry-pick from v2.1.0 + fix applyDefaultPermissions order
Alvin 4 weeks ago
parent
commit
fa8ff619f5
6 changed files with 1104 additions and 5 deletions
  1. 90 0
      docs/configuration.md
  2. 702 0
      oh-my-opencode-slim.schema.json
  3. 161 0
      src/agents/custom.test.ts
  4. 91 0
      src/agents/index.test.ts
  5. 19 5
      src/agents/index.ts
  6. 41 0
      src/config/schema.ts

+ 90 - 0
docs/configuration.md

@@ -122,6 +122,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `agents.<customAgent>.model` | string\|array | - | Required for custom agents inferred from unknown `agents` keys |
 | `agents.<customAgent>.prompt` | string | - | Full execution prompt for a custom agent |
 | `agents.<customAgent>.orchestratorPrompt` | string | - | Exact `@agent` block injected into the orchestrator prompt; must start with `@<agent-name>` |
+| `agents.<agent>.permission` | object \| string | - | Tool-level permission rules enforced by the SDK. See [Agent Permissions](#agent-permissions) |
 | `agents.<agent>.displayName` | string | - | Custom user-facing alias for the agent in the active config |
 | `acpAgents.<name>.command` | string | - | Command for an external ACP-compatible agent; creates a wrapper subagent named `<name>` |
 | `acpAgents.<name>.args` | string[] | `[]` | Arguments for the ACP agent command |
@@ -314,6 +315,95 @@ Notes:
 - Custom agents without a `model` are skipped with a warning
 - Disabled custom agents are not registered or injected into the orchestrator prompt
 
+### Agent Permissions
+
+The `permission` field provides deterministic, tool-level permission restrictions on custom agents, built-in agent overrides, and presets. Unlike prompt instructions ("do not edit files"), these rules are enforced by the OpenCode SDK at the tool-call level.
+
+The field accepts either:
+
+1. **Shorthand string** — `"ask"`, `"allow"`, or `"deny"` applied to all tools
+2. **Object** — keys are tool names, values are `"ask" | "allow" | "deny"` or (for rule keys) a pattern-to-action map
+
+**Example: read-only `planner` agent:**
+
+```jsonc
+{
+  "agents": {
+    "planner": {
+      "model": "openai/gpt-5.5",
+      "variant": "high",
+      "skills": [],
+      "mcps": ["context7", "websearch"],
+      "permission": {
+        "edit": "deny",
+        "bash": {
+          "*": "ask",
+          "git status*": "allow",
+          "git diff*": "allow",
+          "grep *": "allow"
+        },
+        "webfetch": "allow",
+        "websearch": "allow",
+        "task": "deny"
+      },
+      "prompt": "You are Planner. Create implementation plans only. Do not implement code."
+    }
+  }
+}
+```
+
+**Example: `security-reviewer` agent:**
+
+```jsonc
+{
+  "agents": {
+    "security-reviewer": {
+      "model": "anthropic/claude-sonnet-4-5",
+      "permission": {
+        "edit": "deny",
+        "bash": "deny",
+        "webfetch": "allow"
+      },
+      "prompt": "You are a security reviewer. Inspect code and report findings. Do not patch anything."
+    }
+  }
+}
+```
+
+#### Permission keys
+
+| Key | Value type | Description |
+|-----|------------|-------------|
+| `read` | string or object | File reading |
+| `edit` | string or object | File editing |
+| `glob` | string or object | File pattern matching |
+| `grep` | string or object | Content search |
+| `list` | string or object | Directory listing |
+| `bash` | string or object | Shell command execution |
+| `task` | string or object | Subagent task delegation |
+| `external_directory` | string or object | Access to directories outside the workspace |
+| `lsp` | string or object | Language server protocol operations |
+| `skill` | string or object | Skill execution |
+| `todowrite` | string only | Todo list writing |
+| `question` | string only | Asking the user questions |
+| `webfetch` | string only | Web content fetching |
+| `websearch` | string only | Web search |
+| `codesearch` | string only | Code search |
+| `doom_loop` | string only | Doom loop prevention |
+
+Keys marked "string or object" accept pattern-based rules (e.g. `bash: { "git status*": "allow", "*": "ask" }`). Keys marked "string only" accept a single `"ask"`, `"allow"`, or `"deny"` value. Unknown tool names (including MCP-derived keys) pass through without error.
+
+#### Merge semantics
+
+When a user supplies `permission` and also uses the `skills` or `mcps` arrays on the same agent, the plugin merges them:
+
+1. **User-supplied `permission` is the base layer.**
+2. **Plugin-generated rules from the `skills` array override `permission.skill`** — the `skills` array is authoritative for skill gating.
+3. **Plugin-generated rules from the `mcps` array set `permission.<mcp>_*` keys** — the `mcps` array is authoritative for MCP gating.
+4. **User-supplied keys for standard tools** (`edit`, `bash`, `webfetch`, `task`, etc.) survive the merge untouched.
+
+Use the `skills`/`mcps` arrays for skill and MCP gating. Use `permission` for everything else (file access, bash, web, task delegation).
+
 ### Desktop Companion App
 
 The desktop companion app provides a visual status overlay showing running and active agents. For quick installation instructions, binary paths, config defaults, and release information, see the full **[Desktop Companion Guide](companion.md)**.

+ 702 - 0
oh-my-opencode-slim.schema.json

@@ -103,6 +103,357 @@
             "displayName": {
               "type": "string",
               "minLength": 1
+            },
+            "permission": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "ask",
+                    "allow",
+                    "deny"
+                  ]
+                },
+                {
+                  "type": "object",
+                  "properties": {
+                    "read": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "edit": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "glob": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "grep": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "list": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "bash": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "task": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "external_directory": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "lsp": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "skill": {
+                      "anyOf": [
+                        {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        },
+                        {
+                          "type": "object",
+                          "propertyNames": {
+                            "type": "string"
+                          },
+                          "additionalProperties": {
+                            "type": "string",
+                            "enum": [
+                              "ask",
+                              "allow",
+                              "deny"
+                            ]
+                          }
+                        }
+                      ]
+                    },
+                    "todowrite": {
+                      "type": "string",
+                      "enum": [
+                        "ask",
+                        "allow",
+                        "deny"
+                      ]
+                    },
+                    "question": {
+                      "type": "string",
+                      "enum": [
+                        "ask",
+                        "allow",
+                        "deny"
+                      ]
+                    },
+                    "webfetch": {
+                      "type": "string",
+                      "enum": [
+                        "ask",
+                        "allow",
+                        "deny"
+                      ]
+                    },
+                    "websearch": {
+                      "type": "string",
+                      "enum": [
+                        "ask",
+                        "allow",
+                        "deny"
+                      ]
+                    },
+                    "codesearch": {
+                      "type": "string",
+                      "enum": [
+                        "ask",
+                        "allow",
+                        "deny"
+                      ]
+                    },
+                    "doom_loop": {
+                      "type": "string",
+                      "enum": [
+                        "ask",
+                        "allow",
+                        "deny"
+                      ]
+                    }
+                  },
+                  "additionalProperties": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  }
+                }
+              ]
             }
           },
           "additionalProperties": false
@@ -187,6 +538,357 @@
           "displayName": {
             "type": "string",
             "minLength": 1
+          },
+          "permission": {
+            "anyOf": [
+              {
+                "type": "string",
+                "enum": [
+                  "ask",
+                  "allow",
+                  "deny"
+                ]
+              },
+              {
+                "type": "object",
+                "properties": {
+                  "read": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "edit": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "glob": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "grep": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "list": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "bash": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "task": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "external_directory": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "lsp": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "skill": {
+                    "anyOf": [
+                      {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      },
+                      {
+                        "type": "object",
+                        "propertyNames": {
+                          "type": "string"
+                        },
+                        "additionalProperties": {
+                          "type": "string",
+                          "enum": [
+                            "ask",
+                            "allow",
+                            "deny"
+                          ]
+                        }
+                      }
+                    ]
+                  },
+                  "todowrite": {
+                    "type": "string",
+                    "enum": [
+                      "ask",
+                      "allow",
+                      "deny"
+                    ]
+                  },
+                  "question": {
+                    "type": "string",
+                    "enum": [
+                      "ask",
+                      "allow",
+                      "deny"
+                    ]
+                  },
+                  "webfetch": {
+                    "type": "string",
+                    "enum": [
+                      "ask",
+                      "allow",
+                      "deny"
+                    ]
+                  },
+                  "websearch": {
+                    "type": "string",
+                    "enum": [
+                      "ask",
+                      "allow",
+                      "deny"
+                    ]
+                  },
+                  "codesearch": {
+                    "type": "string",
+                    "enum": [
+                      "ask",
+                      "allow",
+                      "deny"
+                    ]
+                  },
+                  "doom_loop": {
+                    "type": "string",
+                    "enum": [
+                      "ask",
+                      "allow",
+                      "deny"
+                    ]
+                  }
+                },
+                "additionalProperties": {
+                  "anyOf": [
+                    {
+                      "type": "string",
+                      "enum": [
+                        "ask",
+                        "allow",
+                        "deny"
+                      ]
+                    },
+                    {
+                      "type": "object",
+                      "propertyNames": {
+                        "type": "string"
+                      },
+                      "additionalProperties": {
+                        "type": "string",
+                        "enum": [
+                          "ask",
+                          "allow",
+                          "deny"
+                        ]
+                      }
+                    }
+                  ]
+                }
+              }
+            ]
           }
         },
         "additionalProperties": false

+ 161 - 0
src/agents/custom.test.ts

@@ -343,3 +343,164 @@ describe('custom-agent creation', () => {
     );
   });
 });
+
+describe('custom-agent permission passthrough', () => {
+  test('passes user permission through to agent config', () => {
+    const config: PluginConfig = {
+      agents: {
+        planner: {
+          model: 'openai/gpt-5.5',
+          permission: { edit: 'deny', bash: 'ask' },
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const planner = agents.find((a) => a.name === 'planner');
+
+    expect(planner).toBeDefined();
+    expect(planner?.config.permission).toMatchObject({
+      edit: 'deny',
+      bash: 'ask',
+    });
+  });
+
+  test('applies permission to built-in agent overrides', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: {
+          model: 'openai/gpt-5.5',
+          permission: { edit: 'deny' },
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const explorer = agents.find((a) => a.name === 'explorer');
+
+    expect(explorer).toBeDefined();
+    expect(explorer?.config.permission).toMatchObject({
+      edit: 'deny',
+    });
+  });
+
+  test('user edit/bash survive merge with skills config', () => {
+    const config: PluginConfig = {
+      agents: {
+        planner: {
+          model: 'openai/gpt-5.5',
+          skills: ['my-skill'],
+          permission: { edit: 'deny', bash: 'ask' },
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const planner = agents.find((a) => a.name === 'planner');
+
+    expect(planner).toBeDefined();
+    // User-supplied keys survive
+    expect(planner?.config.permission).toMatchObject({
+      edit: 'deny',
+      bash: 'ask',
+    });
+    // Plugin generates skill rule (overrides any user skill key)
+    expect(
+      (planner?.config.permission as Record<string, unknown>)?.skill,
+    ).toBeDefined();
+  });
+
+  test('passes permission through unchanged without skills or mcps', () => {
+    const config: PluginConfig = {
+      agents: {
+        researcher: {
+          model: 'openai/gpt-5.5',
+          permission: { edit: 'deny', webfetch: 'allow' },
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const researcher = agents.find((a) => a.name === 'researcher');
+
+    expect(researcher).toBeDefined();
+    expect(researcher?.config.permission).toMatchObject({
+      edit: 'deny',
+      webfetch: 'allow',
+    });
+  });
+
+  test('no permission field means no regression', () => {
+    const config: PluginConfig = {
+      agents: {
+        reviewer: {
+          model: 'openai/gpt-5.5',
+          prompt: 'You are a reviewer.',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const reviewer = agents.find((a) => a.name === 'reviewer');
+
+    expect(reviewer).toBeDefined();
+    // Plugin still generates its own permission keys (question, etc.)
+    expect(reviewer?.config.permission).toBeDefined();
+    // But no edit/bash keys since user didn't set them
+    expect(
+      (reviewer?.config.permission as Record<string, unknown>)?.edit,
+    ).toBeUndefined();
+  });
+});
+
+describe('permission edge cases', () => {
+  test('shorthand string permission is not corrupted by applyDefaultPermissions', () => {
+    const config: PluginConfig = {
+      agents: {
+        planner: {
+          model: 'openai/gpt-5.5',
+          permission: 'ask',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const planner = agents.find((a) => a.name === 'planner');
+
+    expect(planner).toBeDefined();
+    // The shorthand string should be preserved as-is, not spread into
+    // character keys like { "0": "a", "1": "s", "2": "k" }
+    expect(planner?.config.permission).toBe('ask');
+  });
+
+  test('orchestrator permission override does not replace plugin gates', () => {
+    const config: PluginConfig = {
+      agents: {
+        orchestrator: {
+          model: 'openai/gpt-5.5',
+          permission: { edit: 'deny' },
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+
+    expect(orchestrator).toBeDefined();
+    // User-supplied key survives
+    expect(orchestrator?.config.permission).toMatchObject({
+      edit: 'deny',
+    });
+    // Plugin-generated gates are NOT dropped by the override
+    expect(
+      (orchestrator?.config.permission as Record<string, unknown>)?.question,
+    ).toBeDefined();
+    expect(
+      (orchestrator?.config.permission as Record<string, unknown>)
+        ?.council_session,
+    ).toBeDefined();
+    expect(
+      (orchestrator?.config.permission as Record<string, unknown>)?.cancel_task,
+    ).toBeDefined();
+  });
+});

+ 91 - 0
src/agents/index.test.ts

@@ -1027,3 +1027,94 @@ describe('observer agent', () => {
     expect(DEFAULT_DISABLED_AGENTS).toContain('observer');
   });
 });
+
+describe('AgentOverrideConfigSchema permission validation', () => {
+  test('accepts object-form permission', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.5',
+      permission: { edit: 'deny', bash: 'ask' },
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.permission).toEqual({
+        edit: 'deny',
+        bash: 'ask',
+      });
+    }
+  });
+
+  test('accepts shorthand string permission', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.5',
+      permission: 'ask',
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.permission).toBe('ask');
+    }
+  });
+
+  test('rejects invalid action value', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.5',
+      permission: { edit: 'alow' },
+    });
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects object value on action-only key', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.5',
+      permission: { webfetch: { '*': 'allow' } },
+    });
+    expect(result.success).toBe(false);
+  });
+
+  test('accepts unknown permission key (passthrough)', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.5',
+      permission: { custom_tool_name: 'ask' },
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(
+        (result.data.permission as Record<string, unknown>).custom_tool_name,
+      ).toBe('ask');
+    }
+  });
+
+  test('accepts pattern-based bash rule', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.5',
+      permission: {
+        bash: { 'git status*': 'allow', '*': 'ask' },
+      },
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.permission).toEqual({
+        bash: { 'git status*': 'allow', '*': 'ask' },
+      });
+    }
+  });
+
+  test('rejects invalid action in pattern map', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.5',
+      permission: {
+        bash: { 'git status*': 'alow' },
+      },
+    });
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects array value for unknown permission key', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.5',
+      permission: {
+        custom_tool: ['foo', 'bar'],
+      },
+    });
+    expect(result.success).toBe(false);
+  });
+});

+ 19 - 5
src/agents/index.ts

@@ -107,7 +107,9 @@ function buildAcpAgentDefinition(
       `You are the ${name} ACP wrapper agent.`,
       '',
       'Your only job is to send the user task to the configured external ACP agent using the acp_run tool, then return the ACP agent result.',
-      `Always call acp_run with agent: ${JSON.stringify(name)} and pass the full user task as prompt.`,
+      `Always call acp_run with agent: ${JSON.stringify(
+        name,
+      )} and pass the full user task as prompt.`,
       'Do not edit files yourself unless the ACP result explicitly asks you to report a local follow-up to the orchestrator.',
     ].join('\n');
 
@@ -194,6 +196,9 @@ function applyOverrides(
   if (override.displayName) {
     agent.displayName = override.displayName;
   }
+  if (override.permission) {
+    agent.config.permission = override.permission;
+  }
 }
 
 function isKnownAgentName(name: string): boolean {
@@ -270,6 +275,13 @@ function applyDefaultPermissions(
   configuredSkills?: string[],
   disabledSkills?: string[],
 ): void {
+  // If the user supplied a shorthand string permission (e.g. "ask"),
+  // it already applies to all tools — preserve it as-is and skip the
+  // object merge, which would corrupt it by spreading the string.
+  if (typeof agent.config.permission === 'string') {
+    return;
+  }
+
   const existing = (agent.config.permission ?? {}) as Record<
     string,
     'ask' | 'allow' | 'deny' | Record<string, 'ask' | 'allow' | 'deny'>
@@ -534,14 +546,14 @@ export function createAgents(
     orchestratorPrompts.appendPrompt,
   );
 
+  if (orchestratorOverride) {
+    applyOverrides(orchestrator, orchestratorOverride);
+  }
   applyDefaultPermissions(
     orchestrator,
     orchestratorOverride?.skills,
     config?.disabled_skills,
   );
-  if (orchestratorOverride) {
-    applyOverrides(orchestrator, orchestratorOverride);
-  }
 
   // Collect all display names from orchestrator and all subagents
   const displayNameMap = new Map<string, string>();
@@ -567,7 +579,9 @@ export function createAgents(
     if (acp?.orchestratorPrompt) return acp.orchestratorPrompt;
     return [
       `@${agent.name}`,
-      `- Lane: External ACP-connected agent (${acp?.command ?? 'unknown command'})`,
+      `- Lane: External ACP-connected agent (${
+        acp?.command ?? 'unknown command'
+      })`,
       `- Role: ${agent.description ?? `External ACP agent ${agent.name}`}`,
       '- **Delegate when:** The user explicitly asks for this ACP-backed agent, or the task matches its role and benefits from software/subscription-specific capabilities outside OpenCode.',
       '- **Do not delegate when:** The built-in specialists can handle the task more directly or local file ownership would conflict with another writer lane.',

+ 41 - 0
src/config/schema.ts

@@ -54,6 +54,46 @@ export type ManualAgentName = (typeof MANUAL_AGENT_NAMES)[number];
 export type ManualAgentPlan = z.infer<typeof ManualAgentPlanSchema>;
 export type ManualPlan = z.infer<typeof ManualPlanSchema>;
 
+// Permission schemas — mirror the SDK's PermissionConfig type with shallow
+// validation. Action values are validated; unknown tool keys pass through.
+const PermissionActionSchema = z.enum(['ask', 'allow', 'deny']);
+
+// A rule key accepts either a single action (whole-tool default) or a
+// pattern→action map (e.g. bash: { "git status*": "allow", "*": "ask" })
+const PermissionRuleSchema = z.union([
+  PermissionActionSchema,
+  z.record(z.string(), PermissionActionSchema),
+]);
+
+// Known keys are typed for typo protection; .catchall() types the index
+// signature to match the SDK's PermissionConfig, so no cast is needed at
+// the assignment site. Unknown tool keys are still validated as rules.
+const PermissionObjectSchema = z
+  .object({
+    read: PermissionRuleSchema.optional(),
+    edit: PermissionRuleSchema.optional(),
+    glob: PermissionRuleSchema.optional(),
+    grep: PermissionRuleSchema.optional(),
+    list: PermissionRuleSchema.optional(),
+    bash: PermissionRuleSchema.optional(),
+    task: PermissionRuleSchema.optional(),
+    external_directory: PermissionRuleSchema.optional(),
+    lsp: PermissionRuleSchema.optional(),
+    skill: PermissionRuleSchema.optional(),
+    todowrite: PermissionActionSchema.optional(),
+    question: PermissionActionSchema.optional(),
+    webfetch: PermissionActionSchema.optional(),
+    websearch: PermissionActionSchema.optional(),
+    codesearch: PermissionActionSchema.optional(),
+    doom_loop: PermissionActionSchema.optional(),
+  })
+  .catchall(PermissionRuleSchema);
+
+export const PermissionConfigSchema = z.union([
+  PermissionActionSchema,
+  PermissionObjectSchema,
+]);
+
 // Agent override configuration (distinct from SDK's AgentConfig)
 export const AgentOverrideConfigSchema = z
   .object({
@@ -81,6 +121,7 @@ export const AgentOverrideConfigSchema = z
     orchestratorPrompt: z.string().min(1).optional(),
     options: z.record(z.string(), z.unknown()).optional(), // provider-specific model options (e.g., textVerbosity, thinking budget)
     displayName: z.string().min(1).optional(),
+    permission: PermissionConfigSchema.optional(), // tool-level permission rules enforced by the SDK
   })
   .strict();