Skip to content

feat: support configurable loop permission overrides - #84

Merged
chriswritescode-dev merged 3 commits into
mainfrom
feat/configurable-loop-permissions
Aug 3, 2026
Merged

feat: support configurable loop permission overrides#84
chriswritescode-dev merged 3 commits into
mainfrom
feat/configurable-loop-permissions

Conversation

@chriswritescode-dev

@chriswritescode-devchriswritescode-dev commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Summary

Adds loop.permissions — per-tool allow/deny permission overrides for loop, audit, and post-action sessions, layered between the external-directory allow rules and Forge's structural denies.

Behavior

  • allow entries are processed first, then deny; on ties deny wins. Duplicate entries are dropped.
  • Each entry is a bare tool name (pattern *) or an object { permission, pattern }.
  • Rules are layered in this order: blanket allow-all → external_directory deny → external-directory allows → configured allow → configured deny → Forge structural denies.
  • Configured rules can grant or deny user tools but can never override a structural deny.

Forge-managed permissions are rejected

*, external_directory, and every structural deny (plan, plan_enter, plan_exit, plan-write, plan-edit, execute-plan, execute-goal, question, loop-cancel, loop-status, launch-group, group-status, group-cancel, review-write, review-delete, edit, write, multiedit, apply_patch) are rejected with a log warning and a one-time TUI toast.

Notes

  • Remote loop launches receive the configured rules but not loop.allowExternalDirectories (host-specific paths are not portable to a remote server).
  • Added loop.permissions to the single FORGE_MANAGED_PERMISSIONS list in src/constants/loop.ts; both permission rulesets derive their structural denies from the shared name lists.

Validation

pnpm typecheck && pnpm lint && pnpm test && pnpm build — all green (inclusive of new loop-permissions-config and updated ruleset/runtime tests).

Summary by CodeRabbit

  • New Features

    • Added configurable loop permission deny rules, including tool-specific and scoped patterns.
    • Added workspace-aware permission handling that preserves portable rules across local, remote, restart, and audit sessions.
    • Added standardized warning and toast notifications for invalid or ignored permission settings.
    • Added safeguards preventing configuration from overriding required or structural permissions.
  • Documentation

    • Documented loop permission configuration, rule behavior, restrictions, remote behavior, and updated API references.
    • Updated documented version to 0.7.9.
  • Tests

    • Expanded coverage for permission parsing, validation, persistence, warnings, remote launches, and session restarts.

@gitguardian

gitguardianBot commented Aug 2, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 2 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian idGitGuardian statusSecretCommitFilename
34538785TriggeredGeneric Password4781cfdtest/utils/tui-remote-launch.test.tsView secret
34538785TriggeredGeneric Passwordf0fddfftest/utils/tui-remote-launch.test.tsView secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d74aef41-4069-4831-ab71-6b9c8ba785a3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/loop/runtime.ts (1)

360-373: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Portable permission rules are resolved from a workspace id that can differ from the id the session binds to. Both sites read workspaceId from a snapshot taken before the authoritative workspace id is established, then bind the created session to the later id. When the two ids differ, the session receives the wrong portable rule set, which can widen permissions.

  • src/loop/runtime.ts#L360-L373: move the resolveLoopPermissionOptionsForLoop call after ensureWorkspaceForLoop and pass ensured.workspaceId ?? state.workspaceId; apply the same change at Line 539, Line 1337, and Line 1504.
  • src/services/execution.ts#L1760-L1761: move the resolveLoopPermissionOptionsForWorkspace and buildLoopPermissionRuleset calls inside the runExclusive callback, after the stoppedState refresh at Line 1821 and Line 1828.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/loop/runtime.ts` around lines 360 - 373, Resolve portable permission
rules only after the authoritative workspace ID is established. In
src/loop/runtime.ts at ranges 360-373, 539, 1337, and 1504, move
resolveLoopPermissionOptionsForLoop after ensureWorkspaceForLoop and pass
ensured.workspaceId ?? state.workspaceId; in src/services/execution.ts at ranges
1760-1761, move resolveLoopPermissionOptionsForWorkspace and
buildLoopPermissionRuleset inside the runExclusive callback, after the
stoppedState refresh.
🧹 Nitpick comments (4)
src/agents/auditor.ts (1)

6-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a regression test for the question exception.

AUDITOR_TOOL_EXCLUDES deliberately keeps question available by filtering it out of SHARED_STRUCTURAL_DENY_PERMISSIONS. test/agents.test.ts asserts several auditor exclusions but never asserts that question stays excluded from the exclude list. If someone later removes question from SHARED_STRUCTURAL_DENY_PERMISSIONS, the filter becomes dead code and no test fails. If someone removes the filter, the auditor silently loses the tool and no test fails either.

💚 Proposed test addition in test/agents.test.ts
 test('auditor agent has expected tool exclusions', () => {
expect(auditorAgent.tools?.exclude).toBeDefined()
expect(auditorAgent.tools?.exclude).toContain('apply_patch')
+ // The auditor keeps `question`; it is filtered out of the shared structural denies.+ expect(auditorAgent.tools?.exclude).not.toContain('question')+ expect(SHARED_STRUCTURAL_DENY_PERMISSIONS).toContain('question')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/agents/auditor.ts` around lines 6 - 8, Add a regression assertion in the
auditor exclusions tests in test/agents.test.ts verifying that question is not
present in AUDITOR_TOOL_EXCLUDES. Keep the assertion focused on the existing
AUDITOR_TOOL_EXCLUDES behavior and preserve the current checks for other auditor
exclusions.
test/constants/loop.test.ts (1)

192-192: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the structural-deny probe order-independent.

This line finds the first structural deny by testing for review-write or edit. It relies on LOOP_ONLY_STRUCTURAL_DENY_PERMISSIONS and AUDIT_ONLY_STRUCTURAL_DENY_PERMISSIONS each emitting that specific name first. Reordering either list makes the assertion probe the wrong rule while still passing. The later test at Line 228 already uses an order-independent form; reuse it here.

♻️ Proposed refactor
- const firstStructuralDenyIdx = rules.findIndex(r => r.permission === 'review-write' || r.permission === 'edit')+ const firstStructuralDenyIdx = rules.findIndex(+ r => r.action === 'deny' && FORGE_MANAGED_PERMISSIONS.has(r.permission) && r.permission !== 'external_directory',+ )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/constants/loop.test.ts` at line 192, Update the structural-deny lookup
assigned to firstStructuralDenyIdx to use the same order-independent predicate
as the later test near line 228, rather than relying on review-write or edit
appearing first. Reuse that existing predicate form while leaving the
surrounding assertion unchanged.
test/loop-permission-ruleset.test.ts (1)

601-633: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an explicit mockUpdate call assertion. This gives a direct failure before indexing mock.calls[0].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/loop-permission-ruleset.test.ts` around lines 601 - 633, Add an explicit
assertion that mockUpdate was called before reading mockUpdate.mock.calls[0] in
the fallback-path test, while preserving the existing permission assertion and
test behavior.
test/services/execution.start-loop.test.ts (1)

445-449: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an explicit assertion for the configured deny rule.

The expected value repeats the exact expression used in src/services/execution.ts at Line 1193. The assertion therefore passes even if resolveLoopPermissionOptions stops translating permissions.deny into a deny rule. Add a concrete assertion for the rule, matching the style used in test/loop-runtime-audit-permissions.test.ts at Line 276.

💚 Proposed addition
 expect(client.session.create).toHaveBeenCalledWith(
expect.objectContaining({
permission: buildLoopPermissionRuleset(resolveLoopPermissionOptions(configuredConfig as any)),
}),
)
+ const createArgs = (client.session.create as any).mock.calls[0][0]+ expect(createArgs.permission).toContainEqual({ permission: 'webfetch', pattern: '*', action: 'deny' })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/services/execution.start-loop.test.ts` around lines 445 - 449, Update
the expectation around client.session.create to assert the configured deny rule
explicitly, rather than deriving permission from
resolveLoopPermissionOptions(configuredConfig as any). Follow the concrete
deny-rule assertion style used in the loop runtime audit permissions test, while
preserving the existing permission expectation for the remaining rules.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/hooks/loop-permission.ts`:
- Line 95: Update applyRuleset around getPermissionOptions so resolver
rejections are caught instead of bypassing session.update. Log the failure, then
build the default ruleset from empty options and continue applying it, while
preserving the existing successful resolver path.
In `@src/services/execution.ts`:
- Around line 1863-1868: Update the workspace recreation flow around
createBuiltinWorktreeWorkspace so previousEntry.extra does not reuse stale
startRef, syncRef, or gitRemote values. Filter those fields out before passing
extra, or recompute them from the new launch context, while preserving unrelated
extra metadata.
In `@src/types.ts`:
- Around line 98-104: Update the doc comment above LoopPermissionsConfig to
state that only blanket `*` denies for Forge-required permissions are rejected,
while scoped denies such as `bash` with a command pattern remain honored.
Preserve the existing guidance about Forge-managed permissions and
`allowExternalDirectories`.
In `@src/utils/loop-permission-options.ts`:
- Around line 24-33: Update the lookup flow around getForgeWorkspaceEntry so
exceptions return the empty rules result without executing perClient.set. Only
cache rules after a successful workspace lookup, including the valid empty
result when no rules are configured, and preserve returning [] for lookup
failures.
In `@src/workspace/forge-worktree.ts`:
- Around line 64-74: Update getForgeWorkspacePermissionRules to accept only
canonical validated deny rules, rejecting any rule with action "allow" and
Forge-managed permissions such as external_directory. Preserve validation of the
rule shape and add a regression test covering { permission:
'external_directory', pattern: '*', action: 'allow' } to ensure it is excluded
before buildLoopPermissionRuleset consumes the rules.
In `@test/utils/tui-remote-launch.test.ts`:
- Around line 600-603: Fix the filter predicate used to build externalAllows by
removing the duplicate arrow function and ensuring the callback directly
evaluates each rule’s permission and action checks. Preserve the intended result
of retaining only external_directory rules with action allow.
---
Outside diff comments:
In `@src/loop/runtime.ts`:
- Around line 360-373: Resolve portable permission rules only after the
authoritative workspace ID is established. In src/loop/runtime.ts at ranges
360-373, 539, 1337, and 1504, move resolveLoopPermissionOptionsForLoop after
ensureWorkspaceForLoop and pass ensured.workspaceId ?? state.workspaceId; in
src/services/execution.ts at ranges 1760-1761, move
resolveLoopPermissionOptionsForWorkspace and buildLoopPermissionRuleset inside
the runExclusive callback, after the stoppedState refresh.
---
Nitpick comments:
In `@src/agents/auditor.ts`:
- Around line 6-8: Add a regression assertion in the auditor exclusions tests in
test/agents.test.ts verifying that question is not present in
AUDITOR_TOOL_EXCLUDES. Keep the assertion focused on the existing
AUDITOR_TOOL_EXCLUDES behavior and preserve the current checks for other auditor
exclusions.
In `@test/constants/loop.test.ts`:
- Line 192: Update the structural-deny lookup assigned to firstStructuralDenyIdx
to use the same order-independent predicate as the later test near line 228,
rather than relying on review-write or edit appearing first. Reuse that existing
predicate form while leaving the surrounding assertion unchanged.
In `@test/loop-permission-ruleset.test.ts`:
- Around line 601-633: Add an explicit assertion that mockUpdate was called
before reading mockUpdate.mock.calls[0] in the fallback-path test, while
preserving the existing permission assertion and test behavior.
In `@test/services/execution.start-loop.test.ts`:
- Around line 445-449: Update the expectation around client.session.create to
assert the configured deny rule explicitly, rather than deriving permission from
resolveLoopPermissionOptions(configuredConfig as any). Follow the concrete
deny-rule assertion style used in the loop runtime audit permissions test, while
preserving the existing permission expectation for the remaining rules.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d410cc6-e4c0-4f1e-bb11-f9857fa82904

📥 Commits

Reviewing files that changed from the base of the PR and between 0c7dbd6 and f0fddff.

📒 Files selected for processing (48)
  • AGENTS.md
  • docs/api/_media/architecture.md
  • docs/api/_media/configuration.md
  • docs/api/functions/createForgePlugin.md
  • docs/api/functions/createParentSessionLookup.md
  • docs/api/functions/createSessionDirectoryLookup.md
  • docs/api/interfaces/CompactionConfig.md
  • docs/api/interfaces/CreateParentSessionLookupOptions.md
  • docs/api/interfaces/CreateSessionDirectoryLookupOptions.md
  • docs/api/interfaces/DashboardConfig.md
  • docs/api/interfaces/PluginConfig.md
  • docs/api/variables/VERSION.md
  • docs/api/variables/default.md
  • docs/architecture.md
  • docs/configuration.md
  • docs/modules.md
  • forge-config.jsonc
  • src/agents/auditor.ts
  • src/agents/code.ts
  • src/agents/feature-splitter.ts
  • src/constants/loop.ts
  • src/hooks/loop-permission.ts
  • src/hooks/plan-approval.ts
  • src/index.ts
  • src/loop/runtime.ts
  • src/services/execution.ts
  • src/services/session-loop-resolver.ts
  • src/tui.tsx
  • src/types.ts
  • src/utils/audit-session.ts
  • src/utils/loop-permission-options.ts
  • src/utils/loop-permission-warnings.ts
  • src/utils/loop-session.ts
  • src/utils/toast.ts
  • src/utils/tui-client.ts
  • src/utils/tui-remote-launch.ts
  • src/workspace/forge-worktree.ts
  • test/agents.test.ts
  • test/constants/loop-permissions-config.test.ts
  • test/constants/loop.test.ts
  • test/loop-permission-ruleset.test.ts
  • test/loop-runtime-audit-permissions.test.ts
  • test/plugin.test.ts
  • test/services/execution-restart.test.ts
  • test/services/execution.start-loop.test.ts
  • test/utils/loop-permission-warnings.test.ts
  • test/utils/tui-client-loop-inline-plan.test.ts
  • test/utils/tui-remote-launch.test.ts

Comment threadsrc/hooks/loop-permission.ts Outdated
logger.error(`[loop-permission] failed to fetch parent ${parentID} for inheritance`, err)
}
if (!ruleset) ruleset = buildLoopPermissionRuleset({ allowDirectories: getAllowExternalDirectories?.() })
if (!ruleset) ruleset = buildLoopPermissionRuleset((await getPermissionOptions?.(workspaceId)) ?? {})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether the workspace-aware resolver can reject, or catches internally.set -euo pipefail
F=$(fd --type f 'loop-permission-options.ts' src/utils | head -1)
ast-grep outline "$F" --items all
echo"=== source ==="
cat -n "$F"

Repository: chriswritescode-dev/opencode-forge

Length of output: 3135


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"=== loop-permission outline ==="
ast-grep outline src/hooks/loop-permission.ts --items all
echo"=== loop-permission relevant source ==="
sed -n '1,145p' src/hooks/loop-permission.ts
echo"=== index resolver and call sites ==="
rg -n -A12 -B8 'resolveLoopPermissionOptionsForWorkspace|getPermissionOptions|applyRuleset|ensurePatched|onSessionCreated' src/index.ts src/hooks src
echo"=== logger and type availability ==="
rg -n -A4 -B4 'logger\.error|LoopPermissionRulesetOptions' src/hooks/loop-permission.ts src | head -160

Repository: chriswritescode-dev/opencode-forge

Length of output: 50390


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"=== workspace lookup and option construction ==="
sed -n '1,65p' src/utils/loop-permission-options.ts
rg -n -A25 -B8 'function resolveLoopPermissionOptions|export function resolveLoopPermissionOptions|function getForgeWorkspaceEntry' \
src/constants/loop.ts src/workspace/forge-worktree.ts
echo"=== focused rejection-path verifier ==="
python3 - <<'PY'from pathlib import Pathhook = Path("src/hooks/loop-permission.ts").read_text()resolver = Path("src/utils/loop-permission-options.ts").read_text()assert "const portable = await readWorkspacePortableRules(client, workspaceId)" in resolverassert " } catch {" in resolverassert "if (!ruleset) ruleset = buildLoopPermissionRuleset((await getPermissionOptions?.(workspaceId)) ?? {})" in hook# The workspace read is inside readWorkspacePortableRules' catch, while the# patcher's await is not inside the parent-fetch or update try blocks.read_start = resolver.index("async function readWorkspacePortableRules")read_end = resolver.index("export async function resolveLoopPermissionOptionsForWorkspace")read_body = resolver[read_start:read_end]assert " } catch {" in read_bodyawait_pos = hook.index("await getPermissionOptions?.(workspaceId)")parent_try = hook.index(" try {", hook.index("async function applyRuleset"))parent_catch = hook.index(" } catch (err) {", parent_try)update_try = hook.index(" try {", parent_catch)update_catch = hook.index(" } catch (err) {", update_try)assert not (parent_try < await_pos < parent_catch)assert not (update_try < await_pos < update_catch)print("workspace read failures are caught internally")print("permission-option await remains outside applyRuleset try/catch")PY

Repository: chriswritescode-dev/opencode-forge

Length of output: 7433


Handle failures from getPermissionOptions.

The workspace lookup catches read errors, but the awaited resolver remains outside a try block. If it rejects, applyRuleset skips session.update, so the session does not receive structural denies. Catch the rejection, log it, and build the default ruleset.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/hooks/loop-permission.ts` at line 95, Update applyRuleset around
getPermissionOptions so resolver rejections are caught instead of bypassing
session.update. Log the failure, then build the default ruleset from empty
options and continue applying it, while preserving the existing successful
resolver path.

Comment threadsrc/services/execution.ts Outdated
Comment on lines +1863 to +1868
const { createBuiltinWorktreeWorkspace, getForgeWorkspaceEntry } = await import('../workspace/forge-worktree')
const previousEntry = stoppedState.workspaceId ? await getForgeWorkspaceEntry(deps.client, stoppedState.workspaceId) : undefined
const wsResult = await createBuiltinWorktreeWorkspace(deps.client, {
loopName: stoppedState.loopName,
directory: stoppedState.projectDir || ctx.directory,
extra: previousEntry?.extra ?? undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Inspect how createBuiltinWorktreeWorkspace consumes the `extra` parameter.
rg -n -C25 'export async function createBuiltinWorktreeWorkspace' src/workspace/forge-worktree.ts
rg -n -C5 'startRef|syncRef|gitRemote' src/workspace/forge-worktree.ts

Repository: chriswritescode-dev/opencode-forge

Length of output: 2745


🏁 Script executed:

#!/bin/bashset -eu
printf'%s\n''--- workspace creation implementation ---'
sed -n '163,235p' src/workspace/forge-worktree.ts
printf'%s\n''--- workspace entry definitions and field usage ---'
rg -n -C8 'interface .*Workspace|type .*Workspace|startRef|syncRef|gitRemote|workspace\.create' src/workspace src/services src -g '*.ts' -g '*.tsx'| head -n 240
printf'%s\n''--- restart call sites ---'
sed -n '1845,1910p' src/services/execution.ts

Repository: chriswritescode-dev/opencode-forge

Length of output: 24665


🏁 Script executed:

#!/bin/bashset -eu
printf'%s\n''--- workspace entry retrieval and extra propagation ---'
rg -n -C18 'getForgeWorkspaceEntry|ForgeWorkspaceEntry|extra:' src/workspace/forge-worktree.ts src -g '*.ts'| head -n 260
printf'%s\n''--- adapter creation path ---'
sed -n '245,325p' src/workspace/forge-adapter.ts
printf'%s\n''--- adapter registration and workspace info mapping ---'
sed -n '1,215p' src/workspace/forge-adapter.ts
printf'%s\n''--- static verifier for the restart data flow ---'
python3 - <<'PY'from pathlib import Pathrestart = Path('src/services/execution.ts').read_text()creator = Path('src/workspace/forge-worktree.ts').read_text()adapter = Path('src/workspace/forge-adapter.ts').read_text()checks = { 'restart forwards previous extra': 'extra: previousEntry?.extra ?? undefined' in restart, 'creator spreads caller extra': '...options.extra' in creator, 'adapter reads startRef from extra': 'extra.startRef' in adapter, 'adapter reads syncRef from extra': 'extra.syncRef' in adapter, 'adapter reads gitRemote from extra': 'extra.gitRemote' in adapter, 'adapter uses startRef for branch creation': 'const startPoint = pin && !branchExists ? pin.startRef : undefined' in adapter,}for name, result in checks.items(): print(f'{name}: {result}')PY

Repository: chriswritescode-dev/opencode-forge

Length of output: 33262


Do not copy stale Git refs into the recreated workspace. Filter startRef, syncRef, and gitRemote from previousEntry.extra, or recompute them for the new launch. forge-adapter treats startRef as authoritative and can fetch the stale ref, use it as the branch base, or reject the existing branch when its tip differs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/services/execution.ts` around lines 1863 - 1868, Update the workspace
recreation flow around createBuiltinWorktreeWorkspace so previousEntry.extra
does not reuse stale startRef, syncRef, or gitRemote values. Filter those fields
out before passing extra, or recompute them from the new launch context, while
preserving unrelated extra metadata.

Comment threadsrc/types.ts
Comment on lines +98 to +104
/**
* Extra `deny` rules layered over Forge's structural denies for loop, audit, and post-action
* sessions. Entries are applied before Forge's structural denies, so a user rule for a permission
* that Forge manages or that the loop requires is rejected. Use `external_directory` allow entries
* via `allowExternalDirectories` instead, which Forge manages for every session.
*/
permissions?: LoopPermissionsConfig

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the doc comment for scoped denies of required permissions.

The comment states that any rule naming a permission the loop requires is rejected. parseLoopPermissionRules rejects only blanket denies (pattern *) of FORGE_REQUIRED_PERMISSIONS. A scoped deny such as { permission: 'bash', pattern: 'git push *' } is honoured, and test/constants/loop-permissions-config.test.ts asserts this.

📝 Proposed doc fix
 /**
* Extra `deny` rules layered over Forge's structural denies for loop, audit, and post-action
* sessions. Entries are applied before Forge's structural denies, so a user rule for a permission
- * that Forge manages or that the loop requires is rejected. Use `external_directory` allow entries- * via `allowExternalDirectories` instead, which Forge manages for every session.+ * that Forge manages is rejected, as is a blanket (`*`) deny of a permission the loop requires;+ * a scoped deny such as `{ permission: 'bash', pattern: 'git push *' }` is honoured. Use+ * `allowExternalDirectories` for `external_directory` grants, which Forge manages for every session.
*/
permissions?: LoopPermissionsConfig
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
*Extra`deny`ruleslayeredoverForge's structural denies for loop, audit, and post-action
*sessions.EntriesareappliedbeforeForge's structural denies, so a user rule for a permission
*thatForgemanagesorthatthelooprequiresisrejected.Use`external_directory`allowentries
*via`allowExternalDirectories`instead,whichForgemanagesforeverysession.
*/
permissions?: LoopPermissionsConfig
/**
*Extra`deny`ruleslayeredoverForge's structural denies for loop, audit, and post-action
*sessions.EntriesareappliedbeforeForge's structural denies, so a user rule for a permission
*thatForgemanagesisrejected,asisablanket(`*`)denyofapermissionthelooprequires;
*ascopeddenysuchas `{ permission: 'bash', pattern: 'git push *' }` ishonoured.Use
*`allowExternalDirectories`for`external_directory`grants,whichForgemanagesforeverysession.
*/
permissions?: LoopPermissionsConfig
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/types.ts` around lines 98 - 104, Update the doc comment above
LoopPermissionsConfig to state that only blanket `*` denies for Forge-required
permissions are rejected, while scoped denies such as `bash` with a command
pattern remain honored. Preserve the existing guidance about Forge-managed
permissions and `allowExternalDirectories`.

Comment threadsrc/utils/loop-permission-options.ts Outdated
Comment on lines +24 to +33
let rules: PermissionRule[]
try {
const entry = await getForgeWorkspaceEntry(client, workspaceId)
rules = entry ? getForgeWorkspacePermissionRules(entry) : []
} catch {
rules = []
}
perClient.set(workspaceId, rules)
return rules
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not cache the empty result when the workspace lookup throws.

The catch block sets rules = [] and line 31 stores that value in the cache. A transient failure of getForgeWorkspaceEntry (network error, server restart) therefore pins an empty portable-rule set for that client and workspace for the rest of the process. Every later loop, audit, and post-action session for that workspace then drops the persisted deny rules, which widens permissions silently. Cache only successful lookups.

🛡️ Proposed fix
- let rules: PermissionRule[]
try {
const entry = await getForgeWorkspaceEntry(client, workspaceId)
- rules = entry ? getForgeWorkspacePermissionRules(entry) : []+ const rules = entry ? getForgeWorkspacePermissionRules(entry) : []+ perClient.set(workspaceId, rules)+ return rules
} catch {
- rules = []+ // Do not cache a lookup failure: a transient error must not permanently+ // drop the workspace's portable deny rules.+ return []
}
- perClient.set(workspaceId, rules)- return rules
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
letrules: PermissionRule[]
try{
constentry=awaitgetForgeWorkspaceEntry(client,workspaceId)
rules=entry ? getForgeWorkspacePermissionRules(entry) : []
}catch{
rules=[]
}
perClient.set(workspaceId,rules)
returnrules
}
try{
constentry=awaitgetForgeWorkspaceEntry(client,workspaceId)
construles=entry ? getForgeWorkspacePermissionRules(entry) : []
perClient.set(workspaceId,rules)
returnrules
}catch{
// Do not cache a lookup failure: a transient error must not permanently
// drop the workspace's portable deny rules.
return[]
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/utils/loop-permission-options.ts` around lines 24 - 33, Update the lookup
flow around getForgeWorkspaceEntry so exceptions return the empty rules result
without executing perClient.set. Only cache rules after a successful workspace
lookup, including the valid empty result when no rules are configured, and
preserve returning [] for lookup failures.

Comment on lines +64 to +74
export function getForgeWorkspacePermissionRules(entry: Pick<ForgeWorkspaceEntry, 'extra'>): PermissionRule[] {
const raw = entry.extra?.permissionRules
if (!Array.isArray(raw)) return []
return (raw as unknown[]).filter(
(r): r is PermissionRule =>
typeof r === 'object' && r !== null &&
typeof (r as PermissionRule).permission === 'string' &&
typeof (r as PermissionRule).pattern === 'string' &&
((r as PermissionRule).action === 'allow' || (r as PermissionRule).action === 'deny'),
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject non-deny portable workspace rules.

This helper accepts a well-formed allow rule from workspace metadata. buildLoopPermissionRuleset() applies these rules after the blanket external_directory deny. A stale or modified workspace entry can therefore restore external-directory access for later loop sessions and bypass the configuration validation.

Persist and consume only canonical validated deny rules. Reject Forge-managed permissions during workspace-rule extraction. Add a regression test for { permission: 'external_directory', pattern: '*', action: 'allow' }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/workspace/forge-worktree.ts` around lines 64 - 74, Update
getForgeWorkspacePermissionRules to accept only canonical validated deny rules,
rejecting any rule with action "allow" and Forge-managed permissions such as
external_directory. Preserve validation of the rule shape and add a regression
test covering { permission: 'external_directory', pattern: '*', action: 'allow'
} to ensure it is excluded before buildLoopPermissionRuleset consumes the rules.

Comment on lines +600 to +603
const externalAllows = createArgs.permission.filter(
(r: { permission: string; action: string }) =>
r.permission === 'external_directory' && r.action === 'allow',
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the filter predicate.

The first callback returns the second callback function. Functions are truthy, so filter() retains every permission rule. Remove the duplicate arrow function so externalAllows contains only external_directory allow rules.

Proposed fix
 const externalAllows = createArgs.permission.filter(
(r: { permission: string; action: string }) =>
- (r: { permission: string; action: string }) =>
r.permission === 'external_directory' && r.action === 'allow',
)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constexternalAllows=createArgs.permission.filter(
(r: {permission: string;action: string})=>
r.permission==='external_directory'&&r.action==='allow',
)
constexternalAllows=createArgs.permission.filter(
(r: {permission: string;action: string})=>
r.permission==='external_directory'&&r.action==='allow',
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/utils/tui-remote-launch.test.ts` around lines 600 - 603, Fix the filter
predicate used to build externalAllows by removing the duplicate arrow function
and ensuring the callback directly evaluates each rule’s permission and action
checks. Preserve the intended result of retaining only external_directory rules
with action allow.

@chriswritescode-dev
chriswritescode-dev merged commit 5542917 into mainAug 3, 2026
1 of 2 checks passed
@chriswritescode-dev
chriswritescode-dev deleted the feat/configurable-loop-permissions branch August 3, 2026 00:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@chriswritescode-dev