Allow creating and refining Forgejo issues in idea (brainstorm) mode #243
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#243
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Idea mode currently removes every Forgejo write tool from the session's tool set, so a brainstorm cannot end in a filed issue — the agent can only describe what it would have filed. The mode should allow a brainstorm to produce and refine issues, without becoming a general write mode.
Idea mode must allow exactly three Forgejo write tools:
forgejo_create_issue— unrestricted.forgejo_create_issue_comment— issue numbers only; a pull-request number is denied.forgejo_update_issue_description— issue numbers only, and only for issues opened by the agent's own Forgejo account.Everything else stays as it is: all other Forgejo write tools remain unavailable, the filesystem stays read-only, and the three tools keep going through the existing
"Forgejo write action requires approval"prompt (a user allow rule silences it; a user deny rule still wins). Any check that cannot be completed denies the call and names the reason. The idea-mode system prompt is not changed — the agent discovers the tools from the tool list, and the deny messages carry the rules.Plan
1. Carry the issue author in
DispatchApi.readIssueapps/server/src/forgejo/dispatch-api.ts:user?: { login?: string } | null;tointerface RawIssue(currentlynumber/html_url/title/state/pull_request/labels, around line 192).RawPullalready declares exactly this field, so follow its shape./** Login of the user who opened the issue; empty when the instance did not report one. */ author: string;tointerface IssueSummary(around line 109).readIssue's mapping (around line 343), addauthor: raw.user?.login ?? "",alongsidetitle/state/isPull.readIssuedoc line on theDispatchApiinterface (line 30) to mention the author.No other
IssueSummaryproducer exists; consumers only read fields, so adding one is non-breaking. Verify withgrep "IssueSummary"that no object literal outside this file constructs one — test fakes indispatcher.test.tsandsession-policy.test.tsbuild partialDispatchApis viaas unknown as DispatchApi, so they need no change unless a fake literally constructs anIssueSummary, in which case addauthor: "someone"to it.2. Idea-mode Forgejo policy module
New file
apps/server/src/bridge/idea-mode-policy.ts. It is the single authority for which Forgejo writes idea mode permits, deliberately re-deciding the wholeFORGEJO_MUTATING_TOOLSset rather than trusting that the tool was removed from the active set (the tool set is a visibility measure; this is the enforcement).Behaviour, in order:
if (!FORGEJO_MUTATING_TOOLS.has(toolName)) return "default";— read tools and everything non-Forgejo are untouched. ImportFORGEJO_MUTATING_TOOLSfrom../forgejo/tools/index.ts.if (!IDEA_ALLOWED_FORGEJO_TOOLS.has(toolName)) return { deny: "not available in idea mode" };if (toolName === "forgejo_create_issue") return "default";— no number, no authorship question.const number = typeof (params as {number?: unknown})?.number === "number" ? … : null;— onnullreturn{ deny: "idea mode could not read the issue number of this call" }.repo,api()andagentUsername(). If any is null/empty, return{ deny: \idea mode could not verify #${number}: this session is not connected to a Forgejo repository` }`.const res = await api.readIssue(repo.owner, repo.repo, number, signal);— on!res.okreturn{ deny: \idea mode could not verify #${number}: Forgejo read failed (${res.status}: ${res.message})` }`. This covers 404 as well; the message shows the status so the agent can tell a wrong number from an outage.if (res.data.isPull) return { deny: \idea mode works on issues only; #${number} is a pull request` };`forgejo_create_issue_comment, return"default"here.forgejo_update_issue_description:if (res.data.author === "") return { deny: \idea mode could not verify who opened #${number}` };`if (res.data.author !== username) return { deny: \idea mode can only edit the description of issues opened by ${username}; #${number} was opened by ${res.data.author}` };`"default".ApiResult's failure shape is{ ok: false, status, message }(seeapps/server/src/forgejo/client.ts); confirm the exact field names there before writing step 6.3. Enforce the policy inside
PermissionFilterapps/server/src/bridge/permission-filter.tsis already on the call path for the Forgejo custom tools (they are wrapped bywrapToolWithFilterRef) and already holdscwdplusforgejoApi: () => DispatchApi | null. This is where the policy runs — not inidea-mode-guard.ts, which has no API handle and would race the filter's approval prompt with a second verdict.ideaMode?: () => boolean(default() => false) andforgejoRepo?: RepoRef | null(defaultnull), stored as private fields next toforgejoApi(lines 192-207).evaluatePermission, insert the idea-mode branch immediately after the trigger-policy block (which ends at line 255) and before therule.action === "allow"lookup, so the restriction cannot be loosened by an allow rule, while the explicit user deny rule at lines 247-250 still wins:The
Tool call denied for <tool>: <reason>prefix matches the trigger-policy branch at line 254. ImportreadForgejoConfigfrom../forgejo/config.ts; it returnsnullunless url, token andagentUsernameare all stored (config.ts:94-97), so a non-null config always has a username.Trigger sessions cannot enter idea mode (
in-process.ts:2109throws), so the two branches never both fire; no interaction to handle.4. Unblock the three tools in the idea-mode tool set
apps/server/src/bridge/idea-mode-bridge.ts:IDEA_BLOCKED_TOOLS(lines 29-43), change the Forgejo spread to skip the permitted three:...Object.fromEntries([...FORGEJO_MUTATING_TOOLS].filter((n) => !IDEA_ALLOWED_FORGEJO_TOOLS.has(n)).map((k) => [k, true as const])),Import
IDEA_ALLOWED_FORGEJO_TOOLSfrom./idea-mode-policy.ts.idea-mode-policy.ts; every other Forgejo write tool is removed from the tool set here.this.session.getAllToolNames().filter((t) => !(t in IDEA_BLOCKED_TOOLS))) with one exported helper so the rule is testable without an SDK session:Both
enter()andrestore()call it, so the restored-after-restart tool set matches the fresh one automatically.IDEA_MODE_SYSTEM_PROMPT(lines 45-59) is left unchanged.5. Wire the session's idea state and repo into the filter
apps/server/src/bridge/in-process.ts:forgejoRepo: RepoRef | nullparameter toattach(...)(signature at lines 929-939), passed at both call sites from the already-destructuredforgejoRepo(:298increateSession,:454in the resume path). Importtype RepoReffrom../forgejo/repo.ts.new PermissionFilter({ sessionId, cwd, trigger, workspaceId: placement?.workspaceId ?? undefined, forgejoRepo, ideaMode: () => ideaBridgeRef?.current?.isEnabled() === true }).The predicate is evaluated per call, so it is safe that
ideaBridgeRef.currentis only assigned at line 996, after the filter is built.No change to
idea-mode-guard.ts: itswrite/edit/ast_edit/evalcontract is unrelated, andwritetoxd://already reaches the Forgejo tools through the samePermissionFilter.Critical files & anchors
apps/server/src/bridge/permission-filter.ts—evaluatePermission(lines 239-299); the ordering deny-rule → trigger policy → allow-rule →defaultAsk→ prompt is what makes the insertion point load-bearing.apps/server/src/bridge/idea-mode-bridge.ts—IDEA_BLOCKED_TOOLS(29-43) and the two identical filter expressions at 127 and 181.apps/server/src/forgejo/dispatch-api.ts—IssueSummary(109-114),RawIssue(192-199),readIssuemapping (339-353).apps/server/src/bridge/in-process.ts—attachsignature (929-939), filter construction (982), idea bridge creation (992-996),forgejoRepodestructuring (298, 454).apps/server/src/bridge/permission-filter.test.ts—harness()(65-98) is the pattern for the new integration tests: real filter, real temp SQLite, plain tool objects.Verification
All commands run from
apps/server.apps/server/src/bridge/idea-mode-policy.test.ts, drivingdecideIdeaModeForgejoagainst a fakeDispatchApibuilt as{ async readIssue() {…} } as unknown as DispatchApi(same trick assession-policy.test.ts:30-40). Each case states input → expected output:("forgejo_create_issue", { title: "x" })→"default"(noreadIssuecall made).("forgejo_create_issue_comment", { number: 7 })where #7 is{ isPull: false, author: "someone" }→"default".{ isPull: true }→ deny containing#7 is a pull request.("forgejo_update_issue_description", { number: 7 })withauthor: "deckbot"andagentUsername: () => "deckbot"→"default".author: "human"→ deny containingwas opened by human.author: ""→ deny containingcould not verify who opened #7.readIssuefailing with{ ok: false, status: 503, message: "boom" }→ deny containing503.repo: null→ deny containingnot connected to a Forgejo repository, with noreadIssuecall.("forgejo_close_issue", { number: 7 })→ denynot available in idea mode.("forgejo_read_issue", { number: 7 })→"default".("forgejo_update_issue_description", {})(no number) → deny containingcould not read the issue number.apps/server/src/bridge/permission-filter.test.tsin a newdescribe("idea mode"), usingharness()extended to accept the new constructor options:ideaMode: () => true,forgejo_create_issue→ a prompt appears with ruleIddefault:forgejo_create_issue, approving runs the tool.ideaMode: () => true+createPermission({ toolPattern: "^forgejo_create_issue$", action: "allow" })→ runs with no prompt.ideaMode: () => true+ a foreign-author issue forforgejo_update_issue_description→ denied, no prompt emitted, even with an^forgejo_allow rule present (proves the branch sits above the allow lookup).createPermission({ toolPattern: "^forgejo_create_issue$", action: "deny" })+ideaMode: () => true→ denied with the rule's message (deny rule still wins).ideaMode: () => falsewith a foreign-author issue → prompts as before (proves the policy is idea-mode-only).apps/server/src/bridge/idea-mode-bridge.test.tsassertingideaModeToolNames([...FORGEJO_TOOL_NAMES, "bash", "git_commit", "read"])contains the three permitted Forgejo tools andread, and contains neitherbash,git_commit,forgejo_close_issue, norforgejo_post_review.bun testandbun run typecheckinapps/server; thenbun run typecheckat the repo root for the workspace./idea on), ask the agent to file an issue about the brainstorm. Expected:forgejo_create_issueappears as an approval prompt; approving creates the issue on Forgejo. Then ask it to rewrite the description of an issue opened by a human. Expected: no prompt, and the agent reports the denyidea mode can only edit the description of issues opened by <agent>; #N was opened by <human>. Finally ask it to comment on a pull request number. Expected: deny containingis a pull request.Assumptions & contingencies
user.loginonGET /repos/{owner}/{repo}/issues/{n}.RawPullin the same file already relies onuser.loginfor pull requests. If the issue payload turns out to omit it, step 9's empty-author branch already denies safely; do not fall back to a second API call.forgejoRepois non-null exactly when the session has Forgejo tools at all, so a null repo in the policy means the tools were never registered and the deny is unreachable in practice; keep it as the fail-closed path rather than an assertion.