From 42d5847c60ed0fb805944ab06e903fa606161d4f Mon Sep 17 00:00:00 2001 From: d3cker Date: Thu, 17 Sep 2026 22:55:55 +0200 Subject: [PATCH] Add opt-in repository file approvals for bot sessions --- AGENTS.md | 4 ++- CHANGELOG.md | 6 ++++ README.md | 8 +++++ docs/advanced.md | 6 ++-- docs/architecture.md | 4 +++ docs/bot-workflow.md | 26 +++++++++++++-- docs/configuration.md | 45 +++++++++++++++++++++++++- docs/installation.md | 3 +- docs/runtime.md | 10 +++++- prompts/bot.md | 6 ++++ src/config.ts | 1 + src/easy.ts | 3 +- src/repository-permissions.ts | 41 +++++++++++++++++++++++ src/runtime.ts | 16 +++++++-- src/setup.ts | 4 ++- src/wizard.ts | 8 +++-- test/easy.test.ts | 4 ++- test/executor.test.ts | 4 +++ test/repository-permissions.test.ts | 33 +++++++++++++++++++ test/runtime.test.ts | 50 +++++++++++++++++++++++++++-- test/setup.test.ts | 6 ++-- test/wizard.test.ts | 11 ++++--- 22 files changed, 274 insertions(+), 25 deletions(-) create mode 100644 src/repository-permissions.ts create mode 100644 test/repository-permissions.test.ts diff --git a/AGENTS.md b/AGENTS.md index c9a2965..f81b32a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ steps, project setup, headless operation, and removal. | --- | --- | --- | | [docs/architecture.md](docs/architecture.md) | Component responsibilities, a short issue-to-PR overview, configuration ownership, scheduler ownership, and shared state. | Start here to understand how the system is divided before locating implementation code. | | [docs/bot-workflow.md](docs/bot-workflow.md) | Eight Mermaid diagrams and detailed implementation notes: startup and polling; discovery and routing; task phases; sessions and questions; media helpers; verification and publication; feedback, merging, and tab closure; status, retries, and recovery. Includes links to the source for each area. | Use for exact execution order, state transitions, checkpoint behavior, failure paths, and tracing a bot task from issue to merged PR. | -| [docs/configuration.md](docs/configuration.md) | The standard `.opencode/automation.json` format, defaults, setup flags, configuration tracking across Git branches, authors, triggers, checks, base branches, model capabilities, media helpers, custom prompts, signatures, and auto-merge settings. | Use when adding or changing user-facing configuration, defaults, or setup examples. | +| [docs/configuration.md](docs/configuration.md) | The standard `.opencode/automation.json` format, defaults, setup flags, configuration tracking across Git branches, repository file approvals, authors, triggers, checks, base branches, model capabilities, media helpers, custom prompts, signatures, and auto-merge settings. | Use when adding or changing user-facing configuration, defaults, or setup examples. | | [docs/runtime.md](docs/runtime.md) | User-visible behavior while the bot runs: GitHub questions and permission replies, branch selection, media inputs, prompt loading, follow-up comments, session tabs, runtime sidebar/status freshness, host repository inventory and discovery, local task closure, cancelling rounds while retaining tracking, and routine management commands. | Use when changing issue conversations, session continuation, runtime tools, or TUI behavior. | | [docs/advanced.md](docs/advanced.md) | Separate scheduler/dispatcher setup, multiple repositories, custom RPC jobs, full options, timeouts, management and retry commands, persistence, reconciliation, locks, and known limits. | Use for low-level configuration, operational troubleshooting, recovery, or ownership/concurrency changes. | | [docs/installation.md](docs/installation.md) | Loader registration, config-directory precedence, prerequisites, source installation, project-local installation, upgrade conflicts, testing on another machine, and migration limits. | Use when working on packaging, installers, registration, upgrades, or deployment troubleshooting. | @@ -72,6 +72,8 @@ the installation block without making remote writes. Keep its markers intact. - `src/runtime.ts`, `src/worker.ts`, and `src/bridge.ts` implement worker hooks, runtime installation, and communication with the owner. `src/prompt.ts` loads instructions; `prompts/bot.md` contains the bundled bot instructions. + `src/repository-permissions.ts` checks canonical path boundaries for opt-in + repository file approvals; `src/runtime.ts` applies them only to bot sessions. - `src/tui.ts`, `src/ui.ts`, and `src/activity.ts` implement terminal integration and task activity. `src/sidebar.ts` renders the runtime panel; `src/runtime-panel.ts` owns polling, freshness and presentation; diff --git a/CHANGELOG.md b/CHANGELOG.md index ae65d34..b41b0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,12 @@ include the full version, for example `## 0.7.0-beta.1`. ### Added +- Per-repository `autoApproveRepositoryFiles` configuration, an opt-in setup + prompt, and `init --auto-approve-repository-files`. Bot sessions and native + workers can automatically access files in the repository and assigned worktree + across rounds/restarts, without global permission changes. Explicit denials, + pending questions, media-helper limits, and shell permissions remain unchanged. + - Cancel a single bot round while retaining issue/PR tracking, with durable stop recovery, preserved draft worktrees and fresh worktrees for later feedback. Resume tracking a locally closed task without replaying its abandoned round diff --git a/README.md b/README.md index c4bd1de..ddb83ce 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,14 @@ and respects `XDG_CONFIG_HOME` and `OPENCODE_CONFIG_DIR`. 3. Load the project with the [headless command below](#run-without-the-tui), or open it with `opencode2 /absolute/path/to/your-project`. +The wizard can enable automatic file access within this repository and its bot +worktrees. Choose **yes** at the file-access prompt, or pass +`--auto-approve-repository-files` to `init`. Existing projects can set +`"autoApproveRepositoryFiles": true` in their configuration. This does not change +global permissions or approve arbitrary shell commands. See +[repository file approvals](docs/configuration.md#repository-file-approvals) for +scope, restart steps, and pending questions. + Settings are saved to `/absolute/path/to/your-project/.opencode/automation.json`. If it already exists, edit it directly and skip `init`. Repeat setup for each repository; the plugin is installed only once. After editing settings, restart diff --git a/docs/advanced.md b/docs/advanced.md index a2f6940..173e863 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -29,6 +29,7 @@ OpenCode service must be running for polling to work. | `ownerDirectory` | Absolute path of the checkout that owns automation. Worker worktrees do not activate another scheduler. | | `stateDirectory` | Shared location for queues, locks, and worktrees. Keep it consistent across components and restarts. | | `repositories` | Repositories with existing local checkouts, default base branches, allowed authors, and checks. A natural-language request can override the base before work starts. | +| `repositories[].autoApproveRepositoryFiles` | Opt-in file-access approval for that repository and the assigned task worktree, including worktrees outside the checkout. Defaults off; does not approve shell commands. | | `allowedAuthors` | GitHub users authorized to request work and approve merging. Merging also requires repository write access. | | `checks` | Arrays of executable arguments, e.g. `[["npm", "test"]]`. `[]` skips dispatcher test commands; the PR distinguishes this from agent-reported tests. No implicit shell. | | `routes` | Maps full mentions to agents and models available in OpenCode. | @@ -47,8 +48,9 @@ OpenCode service must be running for polling to work. Other plugins can expose idempotent RPC methods for custom scheduler jobs. A transport timeout does not prove the server never executed a request. -The executor uses the configured OpenCode permissions. Interactive permission -requests are posted to the issue and suspend the task. An authorized author must +The executor uses the configured OpenCode permissions. The optional repository +file policy handles eligible `ask` decisions first; see +[repository file approvals](configuration.md#repository-file-approvals). Remaining requests are posted to the issue and suspend the task. An authorized author must reply with the exact `/allow QUESTION_ID` or `/deny QUESTION_ID` command. Explicit OpenCode deny rules remain. Install project dependencies before running it or include suitable setup commands in your checks. diff --git a/docs/architecture.md b/docs/architecture.md index 11c723c..9f4c91e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,6 +63,10 @@ The easy setup writes a per-project `.opencode/automation.json`. Global plugin loaders remain inactive in projects without configuration. Account defaults come from GitHub authentication, while the wizard queries OpenCode for a model default. User-configured values are preserved rather than replaced during upgrades. +An opt-in repository file-access policy is stored in that project's configuration +and passed to its worktree runtime. Permission hooks apply it to associated bot +sessions and native workers using canonical paths, while retaining explicit +denials, shell rules, and the media helper's no-tools restriction. The primary checkout owns scheduling. Worker worktrees do not start additional schedulers. A shared Git-directory state folder holds the queue and locks; separate diff --git a/docs/bot-workflow.md b/docs/bot-workflow.md index 9fd8a8e..f6342de 100644 --- a/docs/bot-workflow.md +++ b/docs/bot-workflow.md @@ -22,7 +22,7 @@ flowchart TD Owner -->|Yes| Config[Use nonempty plugin options or read .opencode/automation.json] Config -->|No project config| Inactive Config --> Register[Register primary checkout in local user inventory without activating other owners] - Register --> Resolve[Validate settings and resolve GitHub auth, routes and defaults] + Register --> Resolve[Validate repository file policy and resolve GitHub auth, routes and defaults] Resolve --> Metadata[Register resolved repositories and base branches] Metadata --> GH[Acquire github lock and load queue.json] GH --> RPC[Register runtime bridge and dispatcher RPC] @@ -297,7 +297,20 @@ sequenceDiagram end end D->>S: Wait for completion - opt Clarification or permission required + opt OpenCode permission evaluation + S->>R: Permission action, resources and current effect + alt Explicit OpenCode allow or deny + R-->>S: Preserve effect + else Exact saved decision for this main session + R-->>S: Apply saved allow or deny + else Repo file policy enabled, eligible action and all paths inside repo or task worktree + Note over R: Resolve canonical paths, exclude media helpers and unanswered questions + R-->>S: Allow file access without an issue question + else Permission still needs approval + R-->>S: Deny this attempt and use the question flow below + end + end + opt Clarification or remaining permission question S->>R: ask_issue / intercepted question / permission ask R->>D: Register against main task session D->>D: Persist pending question @@ -357,6 +370,13 @@ sequenceDiagram phases or enforced review gates. The executor's `verifying` phase remains separate. A prose blocker in the final summary does not set `blocked` status; user-input blockers must go through `ask_issue`. +- `autoApproveRepositoryFiles` is a repository opt-in, carried in the generated + worktree runtime settings. It handles `external_directory`, `read`, and `edit` + requests for canonical paths within the configured checkout or assigned + worktree. Native workers inherit task association through parent lookup; new + rounds use the same repository policy. Explicit denials, pending questions, + media-helper restrictions, and shell rules are unchanged. See + [configuration and reload behavior](configuration.md#repository-file-approvals). - One unresolved question is retained at a time. Runtime hooks remove tools and reject non-question tool execution while a question is pending. Native subagent questions are attached to the main task; the reply resumes the main session. @@ -389,7 +409,7 @@ sequenceDiagram dispatcher advances to `verifying`. Sources: [executor.ts — runSession](../src/executor.ts), -[runtime.ts](../src/runtime.ts), [prompt.ts](../src/prompt.ts), +[runtime.ts](../src/runtime.ts), [repository-permissions.ts](../src/repository-permissions.ts), [prompt.ts](../src/prompt.ts), [dispatcher.ts — workOnce, question, publishQuestion, restartWorkflow](../src/dispatcher.ts). ## 5. Optional media inspection diff --git a/docs/configuration.md b/docs/configuration.md index 1488da3..9f9760d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,6 +53,7 @@ Use a model available in your own OpenCode 2 installation. Optional fields: | Field | Purpose | | --- | --- | +| `autoApproveRepositoryFiles` | Opt-in automatic `external_directory`, `read`, and `edit` approval within this repository and its assigned task worktree; omitted/`false` keeps existing permission behavior. Shell rules are unchanged. | | `baseBranch` | Base for new worktrees and PRs; defaults to the GitHub default branch. | | `capabilities` | Main model support: `text`, `vision`, `audio`; defaults to `["text"]`. | | `mediaModel` | Separate helper model and its capabilities; example below. | @@ -80,11 +81,53 @@ cd /absolute/path/to/your-project "$HOME/.local/bin/opencode2-automation" init --model provider/model --skip-tests --yes ``` -Optional flags: `--base-branch develop`, `--capabilities text`, +Optional flags: `--auto-approve-repository-files`, `--base-branch develop`, `--capabilities text`, `--media-model provider/vision-model`, `--media-capabilities text,vision`, `--system-prompt .opencode/bot.md`. With `--yes`, supply a helper explicitly if you want media support with a text-only main model. +## Repository file approvals + +During interactive `init`, choose **yes** for "Automatically approve file access +in this repository and its task worktrees". The default is **no**. Noninteractive +setup opts in with `--auto-approve-repository-files`; `--yes` alone does not enable it. +For an existing project, add this field to its existing `.opencode/automation.json` +without rerunning `init` or replacing the other settings: + +```json +{ + "model": "provider/model", + "autoApproveRepositoryFiles": true +} +``` + +This is a plugin setting for this repository, not a global OpenCode permission. +The runtime resolves the configured checkout path automatically, and also includes +the assigned task worktree when advanced state storage places it outside the +checkout. The policy follows new rounds and native subagents through their main +task; it is not tied to a previous session's `/allow` reply. Other repositories +and ordinary non-bot sessions are unaffected. No user-wide configuration is written. + +Only `ask` decisions for `external_directory`, `read`, and `edit` qualify. Every +resource must resolve inside the repository or assigned worktree. Symlink targets +are checked, including existing parents of new files. Paths to siblings, symlink +escapes, unknown patterns, and unresolvable boundaries use the normal approval +flow. Explicit OpenCode denials and exact saved `/deny` decisions remain effective. +Media helpers remain read-only and cannot use tools. Shell, network, subagent +launch, and other action permissions are unchanged; a shell command can affect +files outside its working directory, so its location does not grant blanket consent. + +After changing the setting, restart the idle service and activate the owner again. +The executor refreshes generated worktree runtime settings before continuing a +saved session. An already-pending permission question still needs its exact +`/allow QUESTION_ID` or `/deny QUESTION_ID` reply; enabling this option does not +answer it or bypass unrelated pending questions. Existing configs remain opt-out. +Set the field to `false` and reload to disable automatic file approval; previously +saved explicit approvals still have their original session scope. + +The model must still build, edit, and test in its assigned worktree. Permission to +access another checkout does not make that checkout the correct validation target. + ## Questions, branches, media, and bot instructions - **Questions:** reply in the issue as an account in `authors`; no repeated diff --git a/docs/installation.md b/docs/installation.md index efc1859..b063bdd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -78,7 +78,8 @@ GitHub user, not the repository owner. The model default is queried from the run OpenCode service; without it, the model is required. Command-line flags override prompts. `--skip-tests` explicitly disables tests; Enter otherwise accepts the shown test command or `skip`. -The wizard also asks for model capabilities, a vision helper if the main model +The wizard also asks whether to auto-approve repository file access (default no), +plus model capabilities, a vision helper if the main model lacks vision, and the base branch. Existing JSON files can be extended manually; see [runtime settings](runtime.md). diff --git a/docs/runtime.md b/docs/runtime.md index ca5a63f..30b3329 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -35,7 +35,15 @@ not by excluding the posting account's login. GitHub Bot accounts and unauthoriz authors are also excluded. A regular comment from the shared account can answer a question; the bot's own marked question, acknowledgement, or other post cannot. -For permission requests, use the exact `/allow QUESTION_ID` or `/deny QUESTION_ID` +When `autoApproveRepositoryFiles` is enabled for the repository, the runtime +approves eligible file-access requests within the checkout and assigned worktree, +including native subagents and later rounds. It still honors explicit denials and +does not grant shell permissions or tools to media helpers. A new session does not +need to repeat `/allow` for these eligible file requests. Existing unanswered +questions still require their explicit replies. See +[repository file approvals](configuration.md#repository-file-approvals). + +For permission requests that still require a reply, use the exact `/allow QUESTION_ID` or `/deny QUESTION_ID` shown in the question as your entire reply. Plain conversation does not grant permission. The decision is scoped to the operation and resource set in the current main session and its workers; explicit OpenCode deny rules still apply. diff --git a/prompts/bot.md b/prompts/bot.md index d6bdd16..0a39c3b 100644 --- a/prompts/bot.md +++ b/prompts/bot.md @@ -36,6 +36,9 @@ repository inspection in the implementation session. - Preserve existing work, including changes from earlier rounds. Inspect the current state before editing; do not assume a fresh checkout. - Work only in the assigned worktree and retain its branch and pinned base. + "Repository root" means that worktree's root for edits, builds, and tests. + Do not substitute the primary checkout's build or executable. Automatic file + permission within the repository does not change the assigned worktree. - Do not switch branches, push, merge, open PRs, or post directly to GitHub. The dispatcher owns publication and appends the configured message signature. - Do not change automation configuration, credentials, or permissions merely @@ -57,6 +60,9 @@ repository inspection in the implementation session. verification, or delegation while waiting. - A delegated worker that asks must return control to the main agent. The dispatcher delivers the reply to the main session. +- A repository may enable automatic file-access approval for its checkout and + assigned worktree. The runtime applies it; do not ask again for an operation + it already permits. This does not grant blanket shell approval or bypass denials. - If permission approval is pending, stop. Never bypass a denied operation. Approval requires an authorized user's exact `/allow QUESTION_ID` or `/deny QUESTION_ID` reply in the issue. Never supply that approval yourself. diff --git a/src/config.ts b/src/config.ts index 6ca5749..7baf7c6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ export type Route = z.infer; export const Repository = z.object({ repo: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), directory: absolute, + autoApproveRepositoryFiles: z.boolean().optional(), baseBranch: BranchName, allowedAuthors: z.array(name).min(1), checks: z.array(z.array(z.string().min(1)).min(1)), diff --git a/src/easy.ts b/src/easy.ts index ca8bd7d..58b09f6 100644 --- a/src/easy.ts +++ b/src/easy.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { GithubOptions, SchedulerOptions, MergeOptions, Capabilities, BranchName } from "./config.js"; export const EasyOptions = z.object({ + autoApproveRepositoryFiles: z.boolean().optional(), baseBranch: BranchName.optional(), capabilities: Capabilities.optional(), mediaModel: z.object({ model: z.string().regex(/^[^/\s]+\/\S+$/), capabilities: Capabilities }).strict().optional(), @@ -80,7 +81,7 @@ export async function resolveEasy(directory: string, raw: unknown, execute = run const slash = options.model.indexOf("/"); const stateDirectory = join(common, "opencode2-automation"); const github = GithubOptions.parse({ systemPromptFile: options.systemPromptFile, signature: options.signature ?? `${login}[OpenCode2]`, autoMerge: options.autoMerge, ownerDirectory: root, stateDirectory, - repositories: [{ repo, directory: root, baseBranch, allowedAuthors: options.authors ?? [login], checks: check === false ? [] : [check] }], + repositories: [{ repo, directory: root, autoApproveRepositoryFiles: options.autoApproveRepositoryFiles, baseBranch, allowedAuthors: options.authors ?? [login], checks: check === false ? [] : [check] }], routes: { [options.trigger]: { agent: "build", capabilities: options.capabilities, mediaModel: options.mediaModel ? { capabilities: options.mediaModel.capabilities, model: { providerID: options.mediaModel.model.split("/")[0], id: options.mediaModel.model.slice(options.mediaModel.model.indexOf("/") + 1) } } : undefined, model: { providerID: options.model.slice(0, slash), id: options.model.slice(slash + 1) } } }, }); const scheduler = SchedulerOptions.parse({ ownerDirectory: root, stateDirectory, jobs: [{ id: "github-issues", everySeconds: options.everySeconds }] }); diff --git a/src/repository-permissions.ts b/src/repository-permissions.ts new file mode 100644 index 0000000..2e0c142 --- /dev/null +++ b/src/repository-permissions.ts @@ -0,0 +1,41 @@ +import { lstat, realpath } from "node:fs/promises"; +import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; + +const inside = (root: string, path: string) => { + const rel = relative(root, path); + return rel === "" || (rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel)); +}; + +// Writes can target a new file or directory. Resolve its existing ancestor, +// without treating a dangling symlink as a missing ordinary path. +async function canonicalTarget(path: string): Promise { + try { return await realpath(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const exists = await lstat(path).then(() => true, error => { + if (error.code !== "ENOENT") throw error; + return false; + }); + if (exists || dirname(path) === path) throw error; + return resolve(await canonicalTarget(dirname(path)), basename(path)); + } +} + +/** Approve only file/directory resources wholly inside the selected repository. */ +export async function repositoryFileAccess(action: string, resources: string[], directory: string, roots: string[]): Promise { + if (!["external_directory", "read", "edit"].includes(action) || !resources.length) return false; + try { + const canonicalRoots = await Promise.all(roots.map(root => realpath(root))); + if (canonicalRoots.some(root => dirname(root) === root)) return false; + for (const resource of resources) { + const path = action === "external_directory" && resource.endsWith("/*") ? resource.slice(0, -2) : resource; + // OpenCode supplies concrete file paths and a single trailing directory + // wildcard. Unknown patterns and parent traversal keep ordinary approval. + if (!path || /[\0*?[\]{}]/.test(path) || path.split("/").includes("..")) return false; + if (action === "external_directory" && !isAbsolute(path)) return false; + const target = await canonicalTarget(resolve(directory, path)); + if (!canonicalRoots.some(root => inside(root, target))) return false; + } + return true; + } catch { return false; } // An unverifiable boundary never grants access. +} diff --git a/src/runtime.ts b/src/runtime.ts index 5eeaac9..c405d44 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -9,6 +9,7 @@ import { z } from "zod"; import { Task } from "./dispatcher.js"; import { GithubRpc } from "./rpc.js"; import { runtimeBridge } from "./bridge.js"; +import { repositoryFileAccess } from "./repository-permissions.js"; import { botPrompt } from "./prompt.js"; import type { GithubOptions } from "./config.js"; @@ -136,8 +137,19 @@ export async function setupRuntime(ctx: Plugin.Context, options: GithubOptions) const task = await lookup(event.sessionID); if (!task) return; const resources = [...event.resources].sort(); const decision = task.permissions?.find(p => p.sessionID === task.sessionID && p.action === event.action && JSON.stringify(p.resources) === JSON.stringify(resources)); - event.effect = decision?.allow ? "allow" : "deny"; - if (decision) return; + if (decision) { event.effect = decision.allow ? "allow" : "deny"; return; } + const repo = options.repositories.find(repo => repo.repo.toLowerCase() === task.repo.toLowerCase()); + // Do not bypass an unanswered question or the read-only media helper. + if (repo?.autoApproveRepositoryFiles && task.worktree && !(task.question && !task.question.delivered) + && !task.helpers?.some(h => h.id === event.sessionID) + && ["external_directory", "read", "edit"].includes(event.action)) { + const session = await ctx.session.get({ sessionID: event.sessionID }, { signal: request().signal }); + if (await repositoryFileAccess(event.action, resources, session.location.directory, [repo.directory, task.worktree])) { + event.effect = "allow"; + return; + } + } + event.effect = "deny"; await ask(event.sessionID, `permission:${event.action}:${JSON.stringify(resources)}`, `Permission required: ${event.action}\n\nResources:\n${JSON.stringify(resources, null, 2)}\n\nApprove only if you want this exact operation to run.`, { action: event.action, resources }); event.message = "Approval requested in the GitHub issue. Stop and wait for the reply."; })); diff --git a/src/setup.ts b/src/setup.ts index 7eb155e..78a9a9a 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -48,12 +48,13 @@ async function main() { "base-branch": { type: "string" }, capabilities: { type: "string" }, "media-model": { type: "string" }, "media-capabilities": { type: "string" }, "system-prompt": { type: "string" }, signature: { type: "string" }, authors: { type: "string", multiple: true }, model: { type: "string" }, check: { type: "string", multiple: true }, trigger: { type: "string" }, + "auto-approve-repository-files": { type: "boolean" }, "skip-tests": { type: "boolean", default: false }, yes: { type: "boolean", default: false }, local: { type: "boolean", default: false }, help: { type: "boolean", short: "h" }, } }); if (values.help || positionals[0] !== "init" || positionals.length !== 1) { - console.log("Usage: opencode2-automation install\n opencode2-automation init [--model provider/model] [--trigger @opencodebot] [--base-branch name] [--capabilities text,vision,audio] [--media-model provider/model] [--media-capabilities text,vision] [--system-prompt path.md] [--signature text] [--authors login (repeatable)] [--check executable --check argument | --skip-tests] [--local] [--yes]\n opencode2-automation \n opencode2-automation list [--json] [--discover /path/to/projects]\n opencode2-automation retry owner/repo#123 [--restart-session]\n opencode2-automation restartworkflow owner/repo#123\n opencode2-automation cancelround owner/repo#123\n opencode2-automation resumetracking owner/repo#123\ninstall registers the global plugin. list works from any directory. Run other commands inside your repository. --local enables an installation in .opencode/node_modules."); + console.log("Usage: opencode2-automation install\n opencode2-automation init [--model provider/model] [--trigger @opencodebot] [--base-branch name] [--capabilities text,vision,audio] [--media-model provider/model] [--media-capabilities text,vision] [--system-prompt path.md] [--signature text] [--authors login (repeatable)] [--check executable --check argument | --skip-tests] [--auto-approve-repository-files] [--local] [--yes]\n opencode2-automation \n opencode2-automation list [--json] [--discover /path/to/projects]\n opencode2-automation retry owner/repo#123 [--restart-session]\n opencode2-automation restartworkflow owner/repo#123\n opencode2-automation cancelround owner/repo#123\n opencode2-automation resumetracking owner/repo#123\ninstall registers the global plugin. list works from any directory. Run other commands inside your repository. --local enables an installation in .opencode/node_modules."); return; } const { root, primary } = await checkout(process.cwd()); @@ -62,6 +63,7 @@ async function main() { const detected = await detectCheck(root); const check = values["skip-tests"] ? false : values.check ?? detected; const extensions = { + ...(values["auto-approve-repository-files"] !== undefined ? { autoApproveRepositoryFiles: values["auto-approve-repository-files"] } : {}), ...(values["base-branch"] ? { baseBranch: values["base-branch"] } : {}), ...(values.capabilities ? { capabilities: values.capabilities.split(",").map(s => s.trim()) as ("text" | "vision" | "audio")[] } : {}), ...(values["media-model"] ? { mediaModel: { model: values["media-model"], capabilities: (values["media-capabilities"] ?? "text,vision").split(",").map(s => s.trim()) as ("text" | "vision" | "audio")[] } } : {}), diff --git a/src/wizard.ts b/src/wizard.ts index a154707..4fdeb30 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -3,7 +3,7 @@ import { Capabilities, BranchName } from "./config.js"; import { z } from "zod"; export type Question = (message: string) => Promise; -export type SetupValues = { baseBranch?: string; capabilities?: ("text" | "vision" | "audio")[]; mediaModel?: { model: string; capabilities: ("text" | "vision" | "audio")[] }; model?: string; trigger?: string; signature?: string; authors?: string[]; everySeconds?: number; check?: string[] | false; autoMerge?: { enabled: boolean; method: "merge" | "squash" | "rebase" } }; +export type SetupValues = { autoApproveRepositoryFiles?: boolean; baseBranch?: string; capabilities?: ("text" | "vision" | "audio")[]; mediaModel?: { model: string; capabilities: ("text" | "vision" | "audio")[] }; model?: string; trigger?: string; signature?: string; authors?: string[]; everySeconds?: number; check?: string[] | false; autoMerge?: { enabled: boolean; method: "merge" | "squash" | "rebase" } }; export async function configure(question: Question, defaults: { login: string; model?: string; check?: string[]; capabilities?: ("text" | "vision" | "audio")[] }, supplied: SetupValues = {}) { async function ask(label: string, fallback: string | undefined, parse: (value: string) => T): Promise { let error = ""; @@ -42,5 +42,9 @@ export async function configure(question: Question, defaults: { login: string; m if (/["'|;&<>`$\\]/.test(value)) throw new Error("Use a JSON argument array"); return z.array(z.string().min(1)).min(1).parse(value.split(/\s+/)); }); - return EasyOptions.parse({ model, capabilities, ...(mediaModel ? { mediaModel } : {}), ...(baseBranch ? { baseBranch } : {}), trigger, signature, authors, everySeconds, autoMerge: { enabled, method }, check }); + const autoApproveRepositoryFiles = supplied.autoApproveRepositoryFiles ?? await ask("Automatically approve file access in this repository and its task worktrees (yes/no; shell permissions unchanged)", "no", value => { + if (!["yes", "no", "y", "n"].includes(value.toLowerCase())) throw new Error("Expected yes or no"); + return ["yes", "y"].includes(value.toLowerCase()); + }); + return EasyOptions.parse({ autoApproveRepositoryFiles, model, capabilities, ...(mediaModel ? { mediaModel } : {}), ...(baseBranch ? { baseBranch } : {}), trigger, signature, authors, everySeconds, autoMerge: { enabled, method }, check }); } diff --git a/test/easy.test.ts b/test/easy.test.ts index fe6174b..198dd8a 100644 --- a/test/easy.test.ts +++ b/test/easy.test.ts @@ -39,13 +39,15 @@ test("minimal setup resolves repository, account, branch, private state and both await writeFile(join(directory, "package.json"), JSON.stringify({ scripts: { test: "node --test" } })); const result = await resolveEasy(directory, { model: "deepseek/model/variant" }, execute, fetcher); assert.equal(result.repo, "owner/project"); + assert.equal(result.github.repositories[0]?.autoApproveRepositoryFiles, undefined); assert.deepEqual(result.github.repositories[0]?.allowedAuthors, ["alice"]); assert.equal(result.github.repositories[0]?.baseBranch, "develop"); assert.equal(result.github.routes["@opencodebot"]?.model.id, "model/variant"); assert.equal(result.scheduler.jobs[0]?.everySeconds, 60); assert.equal(result.scheduler.stateDirectory, result.github.stateDirectory); assert.ok(result.github.stateDirectory.endsWith("/.git/opencode2-automation")); - const overridden = await resolveEasy(directory, { model: "provider/model", trigger: "@fix", everySeconds: 120, authors: ["bob"], check: ["pytest", "-q"] }, execute, fetcher); + const overridden = await resolveEasy(directory, { model: "provider/model", trigger: "@fix", autoApproveRepositoryFiles: true, everySeconds: 120, authors: ["bob"], check: ["pytest", "-q"] }, execute, fetcher); + assert.equal(overridden.github.repositories[0]?.autoApproveRepositoryFiles, true); assert.deepEqual(overridden.github.repositories[0]?.checks, [["pytest", "-q"]]); assert.deepEqual(overridden.github.repositories[0]?.allowedAuthors, ["bob"]); assert.equal(overridden.scheduler.jobs[0]?.everySeconds, 120); diff --git a/test/executor.test.ts b/test/executor.test.ts index 534b704..640058d 100644 --- a/test/executor.test.ts +++ b/test/executor.test.ts @@ -115,6 +115,10 @@ test("real git worktree isolates a fix, verifies, commits and pushes to a local assert.equal(await run(t.worktree!, ["git", "status", "--porcelain"]), ""); const runtimePath = join(t.worktree!, ".opencode/plugins/automation-runtime/index.js"); assert.match(await readFile(runtimePath, "utf8"), /workerPlugin/); + const optedIn = GithubOptions.parse({ ...options, repositories: [{ ...repo, autoApproveRepositoryFiles: true }] }); + await installWorkerPlugin(t.worktree!, optedIn, run); + assert.match(await readFile(runtimePath, "utf8"), /"autoApproveRepositoryFiles":true/); + assert.equal(await run(t.worktree!, ["git", "status", "--porcelain"]), ""); await assert.rejects(git.verify(t, repo), Blocked); await writeFile(join(t.worktree!, "counter.txt"), "fixed\n"); Object.assign(t, await git.verify(t, repo)); await git.push(t, repo); diff --git a/test/repository-permissions.test.ts b/test/repository-permissions.test.ts new file mode 100644 index 0000000..6b95b95 --- /dev/null +++ b/test/repository-permissions.test.ts @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repositoryFileAccess } from "../src/repository-permissions.js"; + +test("repository file boundaries include the primary checkout and external task worktree, but not neighboring paths or symlink escapes", async () => { + const temp = await mkdtemp(join(tmpdir(), "oc2-permissions-")); + const repo = join(temp, "repo"), worktree = join(temp, "worktree"), other = join(temp, "repo-other"); + try { + for (const path of [repo, worktree, other]) await mkdir(path); + await writeFile(join(repo, "file.txt"), "fixture"); + await symlink(other, join(repo, "escape")); + await symlink(join(other, "missing"), join(repo, "dangling")); + await symlink(repo, join(temp, "alias")); + const check = (action: string, resources: string[]) => repositoryFileAccess(action, resources, worktree, [repo, worktree]); + for (const path of [repo, worktree, join(repo, "new/subdir"), join(temp, "alias")]) { + assert.equal(await check("external_directory", [path + "/*"]), true, path); + } + assert.equal(await check("read", [join(repo, "file.txt")]), true); + assert.equal(await check("edit", ["new/subdir/file.txt", join(repo, "another.txt")]), true); + for (const resources of [[other + "/*"], [temp + "/*"], [repo + "-other/*"], [repo + "/**"], [repo + "/escape/*"], [repo + "/dangling/*"], [repo + "/*", other + "/*"], [], ["*"], ["relative/*"]]) { + assert.equal(await check("external_directory", resources), false, JSON.stringify(resources)); + } + for (const path of ["../repo-other/file", join(repo, "escape/new/file"), join(repo, "dangling/file"), "**/*.ts", "bad\0path"]) { + assert.equal(await check("edit", [path]), false, path); + } + for (const action of ["shell", "glob", "grep", "subagent", "execute", "question"]) { + assert.equal(await check(action, [repo + "/*"]), false, action); + } + } finally { await rm(temp, { recursive: true, force: true }); } +}); diff --git a/test/runtime.test.ts b/test/runtime.test.ts index cc213e5..672556b 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -20,7 +20,7 @@ async function fixture() { const registration = { dispose: async () => {} }; const ctx = { session: { hook: async (name: string, hook: any) => { hooks[name] = hook; return registration; }, - get: async ({ sessionID }: any) => { if (sessionID === "ses_child") return { parentID: "ses_main" }; if (sessionID === "normal") return {}; if (helperLookups++ === 0) throw { _tag: "SessionNotFoundError" }; return { outcome: "succeeded" }; }, + get: async ({ sessionID }: any) => { if (sessionID === task.sessionID) return { location: { directory: task.worktree } }; if (sessionID === "ses_child") return { parentID: task.sessionID, location: { directory: task.worktree } }; if (sessionID === "normal") return {}; if (helperLookups++ === 0) throw { _tag: "SessionNotFoundError" }; return { outcome: "succeeded" }; }, create: async (value: any) => { created.push(value); return value; }, prompt: async (value: any) => { prompted.push(value); }, wait: async () => {}, context: async () => [{ type: "assistant", text: "The button is red.", finish: "stop" }], interrupt: async () => {}, }, @@ -33,7 +33,7 @@ async function fixture() { helper: async ({ sessionID, capability }) => { task.helpers = [{ id: "ses_helper", parentID: sessionID, capability }]; return { id: "ses_helper" }; }, }); const stop = await setupRuntime(ctx, options); - return { directory, task, options, hooks, tools, created, prompted, normalQuestions: () => normalQuestions, close: async () => { await stop(); unbind(); await rm(directory, { recursive: true, force: true }); } }; + return { directory, task, options, hooks, tools, created, prompted, restart: async () => { await stop(); return setupRuntime(ctx, GithubOptions.parse(JSON.parse(JSON.stringify(options)))); }, normalQuestions: () => normalQuestions, close: async () => { await stop(); unbind(); await rm(directory, { recursive: true, force: true }); } }; } test("runtime replaces console questions only for bot sessions and blocks tools while waiting", async () => { const f = await fixture(); @@ -87,3 +87,49 @@ test("native subagent questions are routed to the owning issue session", async ( assert.equal(f.normalQuestions(), 0); } finally { await f.close(); } }); + +test("repository opt-in approves file access for new sessions and native workers after runtime reload", async () => { + const f = await fixture(); + let stopReloaded; + const worktree = await mkdtemp(join(tmpdir(), "oc2-policy-worktree-")); + try { + f.task.worktree = worktree; + f.options.repositories[0]!.autoApproveRepositoryFiles = true; + // Reconstruct settings as a worker loader does after installation/restart. + stopReloaded = await f.restart(); + for (const main of ["ses_main", "ses_next_round"]) { + f.task.sessionID = main; + for (const sessionID of [main, "ses_child"]) { + for (const [action, resources] of [["external_directory", [f.directory + "/*"]], ["read", ["README.md"]], ["edit", ["new/file.ts"]]] as const) { + const event = { sessionID, action, resources: [...resources], effect: "ask" }; + await f.hooks.evaluate!(event); + assert.equal(event.effect, "allow", `${main}/${sessionID}/${action}`); + assert.equal(f.task.question, undefined); + } + } + } + } finally { await stopReloaded?.(); await f.close(); await rm(worktree, { recursive: true, force: true }); } +}); + +test("repository approval preserves explicit denials, unrelated sessions, other repos and non-file permissions", async () => { + const f = await fixture(); + try { + const fileEvent = { sessionID: "ses_main", action: "external_directory", resources: [f.directory + "/*"], effect: "ask" }; + await f.hooks.evaluate!({ ...fileEvent }); assert.ok(f.task.question); // Existing configs remain opt-out. + f.task.question = undefined; + f.options.repositories[0]!.autoApproveRepositoryFiles = true; + const deny = { ...fileEvent, effect: "deny" }; await f.hooks.evaluate!(deny); assert.equal(deny.effect, "deny"); assert.equal(f.task.question, undefined); + f.task.permissions = [{ sessionID: "ses_main", action: fileEvent.action, resources: fileEvent.resources, allow: false }]; + const deniedInIssue = { ...fileEvent }; await f.hooks.evaluate!(deniedInIssue); assert.equal(deniedInIssue.effect, "deny"); assert.equal(f.task.question, undefined); + f.task.permissions = []; + const normal = { ...fileEvent, sessionID: "normal" }; await f.hooks.evaluate!(normal); assert.equal(normal.effect, "ask"); assert.equal(f.task.question, undefined); + f.task.helpers = [{ id: "ses_helper", parentID: "ses_main", capability: "vision" }]; + for (const change of [{ sessionID: "ses_helper" }, { action: "shell", resources: ["npm test"] }, { resources: ["/etc/*"] }]) { + const event = { ...fileEvent, ...change }; await f.hooks.evaluate!(event); assert.equal(event.effect, "deny"); assert.ok(f.task.question); f.task.question = undefined; + } + f.task.repo = "other/repository"; + const other = { ...fileEvent }; await f.hooks.evaluate!(other); assert.equal(other.effect, "deny"); assert.ok(f.task.question); + f.task.repo = "o/r"; + const pending = { ...fileEvent }; await f.hooks.evaluate!(pending); assert.equal(pending.effect, "deny"); + } finally { await f.close(); } +}); diff --git a/test/setup.test.ts b/test/setup.test.ts index 02b1f28..f82015c 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -24,9 +24,9 @@ test("configuration command writes one field and never overwrites existing setti assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { model: "provider/model" }); await rm(path); await rm(join(dir, "package.json")); - const skipped = await exec(process.execPath, [...args, "--skip-tests"], { cwd: dir, env: { ...process.env, XDG_STATE_HOME: join(dir, "state"), GITHUB_TOKEN: "fixture-secret" } }); + const skipped = await exec(process.execPath, [...args, "--skip-tests", "--auto-approve-repository-files"], { cwd: dir, env: { ...process.env, XDG_STATE_HOME: join(dir, "state"), GITHUB_TOKEN: "fixture-secret" } }); assert.match(skipped.stdout, /skipped/); - assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { model: "provider/model", check: false }); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { model: "provider/model", check: false, autoApproveRepositoryFiles: true }); await writeFile(path, JSON.stringify({ model: "provider/model" })); await writeFile(join(dir, "package.json"), JSON.stringify({ scripts: { test: "node --test" } })); await assert.rejects(exec(process.execPath, args, { cwd: dir, env: { ...process.env, XDG_STATE_HOME: join(dir, "state"), GITHUB_TOKEN: "fixture-secret" } })); @@ -58,6 +58,8 @@ test("interactive CLI saves account-derived defaults and displays English prompt assert.match(output, /Allowed GitHub users/); assert.match(output, /Ready: owner\/repo/); assert.ok(!output.includes("d3cker")); assert.ok(!output.includes("fixture-secret")); const saved = JSON.parse(await readFile(join(dir, ".opencode/automation.json"), "utf8")); + assert.equal(saved.autoApproveRepositoryFiles, false); + assert.match(output, /Automatically approve file access/); assert.equal(saved.trigger, "@opencodebot"); assert.equal(saved.signature, "alice[OpenCode2]"); assert.deepEqual(saved.authors, ["alice"]); assert.equal(saved.check, false); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/test/wizard.test.ts b/test/wizard.test.ts index 30844da..240f971 100644 --- a/test/wizard.test.ts +++ b/test/wizard.test.ts @@ -8,19 +8,20 @@ test("Enter accepts displayed defaults based on the authenticated account and de assert.equal(result.model, "provider/model"); assert.equal(result.trigger, "@opencodebot"); assert.equal(result.signature, "alice[OpenCode2]"); assert.deepEqual(result.authors, ["alice"]); assert.equal(result.everySeconds, 60); assert.equal(result.autoMerge?.enabled, true); - assert.deepEqual(result.check, ["npm", "test"]); assert.equal(prompts.length, 8); + assert.deepEqual(result.check, ["npm", "test"]); assert.equal(prompts.length, 9); assert.equal(result.autoApproveRepositoryFiles, false); assert.ok(prompts.every(p => p.includes("[") && !p.includes("d3cker"))); }); test("users can override every prompted default, including complex test arguments", async () => { - const answers = ["other/model", "@team-bot", "team[Agent]", "alice, bob", "120", "yes", "rebase", '["node","--test","file with spaces.js"]']; + const answers = ["other/model", "@team-bot", "team[Agent]", "alice, bob", "120", "yes", "rebase", '["node","--test","file with spaces.js"]', 'yes']; const result = await configure(async () => answers.shift()!, { login: "alice", model: "provider/model" }, { capabilities: ["text", "vision"], baseBranch: "main" }); + assert.equal(result.autoApproveRepositoryFiles, true); assert.equal(result.model, "other/model"); assert.equal(result.trigger, "@team-bot"); assert.equal(result.signature, "team[Agent]"); assert.deepEqual(result.authors, ["alice", "bob"]); assert.equal(result.everySeconds, 120); assert.equal(result.autoMerge?.method, "rebase"); assert.deepEqual(result.check, ["node", "--test", "file with spaces.js"]); }); test("missing models are required, invalid values retry, and no detected tests defaults to skip", async () => { - const answers = ["", "invalid", "provider/model", "", "", "", "zero", "", "no", ""]; + const answers = ["", "invalid", "provider/model", "", "", "", "zero", "", "no", "", "invalid", "no"]; const prompts: string[] = []; const result = await configure(async prompt => { prompts.push(prompt); assert.ok(answers.length); return answers.shift()!; }, { login: "bob" }, { capabilities: ["text", "vision"], baseBranch: "main" }); assert.ok(prompts[0]!.includes("required")); assert.ok(prompts.some(p => p.startsWith("Invalid value"))); @@ -30,14 +31,14 @@ test("missing models are required, invalid values retry, and no detected tests d test("explicit setup values skip prompts and preserve custom settings", async () => { const result = await configure(async () => { throw new Error("Unexpected prompt"); }, { login: "alice" }, { capabilities: ["text", "vision"], baseBranch: "main", model: "provider/model", trigger: "@legacy", signature: "custom", authors: ["bob"], everySeconds: 12, - autoMerge: { enabled: false, method: "merge" }, check: false, + autoMerge: { enabled: false, method: "merge" }, check: false, autoApproveRepositoryFiles: true, }); assert.equal(result.trigger, "@legacy"); assert.equal(result.signature, "custom"); assert.deepEqual(result.authors, ["bob"]); }); test("a text-only main model requires a separate vision helper and accepts a base branch", async () => { - const answers = ["provider/main", "text", "provider/vision", "text,vision,audio", "develop", "", "", "", "", "", "", ""]; + const answers = ["provider/main", "text", "provider/vision", "text,vision,audio", "develop", "", "", "", "", "", "", "", ""]; const result = await configure(async () => { assert.ok(answers.length); return answers.shift()!; }, { login: "alice" }); assert.deepEqual(result.capabilities, ["text"]); assert.deepEqual(result.mediaModel, { model: "provider/vision", capabilities: ["text", "vision", "audio"] });