read/grep/find/glob bypass the permission filter and can't be path-confined #189
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#189
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
The SDK sends only
bash,edit,deleteandmovethroughClientBridge.requestPermission.PermissionFilter.makeClientBridgehooks into that path, and deck custom tools get their own execute-wrapper. All other built-ins (read,grep,find,glob,write,eval,ast_edit,task,browser,lsp, …) never reachPermissionFilter. As a result, user permission rules cannot gate them in any session, and trigger policies cannot confine them to the checkout. This is why the read access added in #49 was accepted as unconfined. A second problem: the rule matcher's default parameter map (PARAM_FIELD_MAP) uses stale tool names (search,find: paths), so path rules on these tools don't match reliably.The SDK already has a general hook for this. Extension
tool_callhandlers run for every wrapped tool, including nestedxd://dispatches (node_modules/@oh-my-pi/pi-coding-agent/src/extensibility/extensions/wrapper.ts:230-304). A{ block: true, reason }result fails the call.idea-mode-guard.tsalready relies on this. Two constraints apply:restrictToolNames: true(all trigger sessions) ignoreoptions.extensions(sdk.ts:2593-2625) and bind onlypreloadedPreparedExtensions(sdk.ts:2655-2666).tool_callhandlers run under a fail-closed timeout (extensionHandlers.toolCallTimeoutMs, default 30 000 ms). It pauses only duringctx.ui.*dialogs, so a deck permission prompt that waits longer than 30 s would be auto-denied.Decided scope:
read/grep/find/globto the checkout in conversation, pull-request and CI-watchdog trigger sessions. The implementer stays unconfined, since it hasbash.http(s)://,local://andartifact://, and deny every other URI scheme.Plan
Context
Send every tool call the SDK does not already route to
PermissionFilterthrough it, using a decktool_callextension that is active in both normal and trigger sessions. Fix path matching for path-taking tools. Use the new reach to confine read tools to the checkout in the no-bash trigger session kinds.Approach
Expose the filter check and fix path matching (
apps/server/src/bridge/permission-filter.ts).privatefromevaluatePermission(signature unchanged:(toolCallId, toolName, params, signal?) => Promise<"allow" | { deny: string }>).PARAM_FIELD_MAPwith:bash: "command",read: "path",write: "path",edit: "path",grep: "path",find: "path",glob: "path",lsp: "file". Dropsearch.const MULTI_PATH_TOOLS = new Set(["grep", "find", "glob"]). ChangeresolveParamValueto returnstring[]. WhenparamField === null, the tool is inMULTI_PATH_TOOLS, and the value is a string, returnvalue.split(";").map(s => s.trim()).filter(Boolean); if that list is empty, return["."]. If the default field is missing from params for one of these three tools, also return["."], because the tool searches cwd. Otherwise return a one-element array, keeping the current array-first / stringify behaviour.matchRule, evaluateparamPatternper value. A rule withaction === "allow"matches only when every value matches.deny/askrules match when any value matches. This preventssrc;/etcfrom passing an allow rule on^src.permissionGateextension (step 2).Add the gate extension: new file
apps/server/src/bridge/permission-gate.ts. It follows the shape ofidea-mode-guard.ts; no equivalent exists.pi.on("tool_call", async (event) => …). ReturnundefinedwhenCLIENT_BRIDGE_TOOLS.has(event.toolName)orskip.has(event.toolName), because those tools are already gated and checking them again would double-prompt. Also returnundefinedwhenfilterRef.currentis null; this is the zero-prompt window beforeattach(), the same reasoning aswrapToolWithFilterRef.await filterRef.current.evaluatePermission(event.toolCallId, event.toolName, event.input)and map{ deny }to{ block: true, reason: deny }. Pass no signal. The runner already settles the gate on abort (raceHandlerWithTimeout), and turn abort/dispose callcancelAll(), which clears the pending prompt.requestPermission.Install the gate in every session (
apps/server/src/bridge/in-process.ts,createSession~L285-318 andresumeSession~L416-436).customTools(the same[...buildDeckTools, ...buildGitTools, ...forgejoTools, ...portainerTools]) and computeconst gate = permissionGate(filterRef, new Set(customTools.map(t => t.name))). MapcustomToolsthroughwrapToolWithFilterRef(wrapRebaseGate(…))as today.gatetoextensions([gate, rebaseStopGuard(cwd), ideaModeGuard(ideaBridgeRef)]).restrictedToolOptionsa third parametergate: ExtensionFactory. In the trigger branch, addpreloadedPreparedExtensions: [{ path: "deck:permission-gate", resolvedPath: "deck:permission-gate", factory: gate, error: null }]. Update both callers. Update therestrictedToolOptionsdoc comment: the tool list is the surface, and the gate adds per-call checks.permissionGate.Raise the handler timeout (
apps/server/src/model-config.tsL530). Add"extensionHandlers.toolCallTimeoutMs": 86_400_000toDECK_SESSION_OVERRIDES, with a comment: atool_callhandler that awaits a deck permission prompt must not fail closed after the SDK's 30 s default; turn abort still cancels it.Confine read tools in no-bash trigger sessions.
apps/server/src/bridge/path-confinement.ts:read→[params.path].grep/find/glob→params.pathsplit on;, trimmed, empties dropped. A missing or empty path becomes["."]. A non-stringpathis denied with"path must be a string"./^https?:\/\//i→ allowed.local://orartifact://→ allowed./^[a-z][a-z0-9+.-]*:\/\//i→ denied.~expands toos.homedir(); thenabs = path.resolve(root, target). Build candidates fromabsplusabs.slice(0, i)for every indexiof:inabspast the root portion; these coverfile:50-100selectors andarchive.zip:member. Each candidate that exists (fs.existsSync) must have afs.realpathSyncinsiderealRoot. If no candidate exists, walkpath.dirnameup fromabsto the nearest existing ancestor; its realpath must be insiderealRoot. This covers glob patterns and not-yet-existing files.realRoot = fs.realpathSync(root). "Inside" meansp === realRoot || p.startsWith(realRoot + path.sep). Realpath is required because checkout content is untrusted, and a committed symlink could point outside.`${target} is outside this session's checkout; only files in the checkout, http(s) URLs, local:// and artifact:// can be read`.apps/server/src/bridge/session-policy.ts:cwd: stringparameter toSessionPolicy.decide(aftersessionId) and todecidePolicy, and forward it in all fourdecidePolicyswitch arms.forgejoConversationPolicy,forgejoPullRequestPolicyandforgejoCiWatchdogPolicy, startdecidewith:if (READ_TOOLS.has(toolName)) { const d = confinementDenial(toolName, params, cwd); if (d) return { deny: d }; }. Do not changeforgejoImplementerPolicybeyond the new unused parameter (_cwd).CONVERSATION_TOOL_NAMES(L134-138): all tools now reachPermissionFilterthrough the bridge, the custom-tool wrapper, orpermissionGate;toolNamesremains the surface, and read tools are confined to the checkout in the no-bash kinds.permission-filter.ts: storethis.cwd = opts.cwdin the constructor and pass it as the new last argument todecidePolicyinevaluatePermission. Trigger sessions are constructed withcwd= the checkout (in-process.tsL932).decidePolicy(/.decide(caller found withgrep -rn "decidePolicy(\|\.decide(" apps/server/src, including tests, by passing a cwd.Critical files & anchors
apps/server/src/bridge/idea-mode-guard.ts— the pattern for thetool_callextension.apps/server/src/bridge/in-process.ts—restrictedToolOptions(L150),createSession/resumeSessioncreateAgentSessioncalls,attachL931-939.apps/server/src/bridge/permission-filter.ts—PARAM_FIELD_MAP/resolveParamValue/matchRule(L99-151),evaluatePermission(L213).apps/server/src/bridge/session-policy.ts—SessionPolicy.decide, the three no-bash policies,decidePolicy(L566).node_modules/@oh-my-pi/pi-coding-agent/src/sdk.ts:2593-2666— why trigger sessions needpreloadedPreparedExtensions.Verification
Run from
apps/server:bun test src/bridgeandbun run typecheck. Add tests followingpermission-filter.test.tsconventions (bootDb(),createPermission):permission-gate.test.ts: callpermissionGate(ref, new Set(["deck_x"]))with a fakepiwhoseoncaptures the handler.toolPattern: "^read$",paramPattern: "secret", the handler returns{ block: true, reason: "Tool call denied for read: …" }for{ path: "secret.txt" }andundefinedfor{ path: "ok.txt" }.undefinedforbashanddeck_xeven under a deny-all ruletoolPattern: ".*".globemits atool_permission_promptframe, andrespond(id, "approve")resolves the handler toundefined.permission-filter.test.ts: a deny rule withparamPattern: "^/etc"ongrepblocks{ pattern: "x", path: "src;/etc" }. An allow rule^srcongrepcombined with an ask-by-default setup does not allowsrc;/etc.path-confinement.test.tswith a tmp checkout:".", missing path,"src/a.ts:10-20","src/**/*.ts","local://x.md","artifact://3","https://example.com"."../x","/etc/passwd","/etc/passwd:1","skill://grill-me","src;/etc"(on grep).link -> /etccommitted in the checkout is denied for both"link/passwd"and"link:1".toolPattern ^read$,paramPattern \.env. Ask the agent to read.env; the call fails withTool call denied for read: …. Add an ask rule ongloband confirm the prompt appears and still works after waiting more than 30 s.Assumptions & contingencies
bindPreparedExtensionscalls a non-nullfactorywithout importingpath(loader.ts:491-512→bindExtension; unverified — confirm first). If it tries to import the path, usepath: "<inline-0>",resolvedPath: "<inline-0>"instead.Settings.isolatedrejects or ignoresextensionHandlers.toolCallTimeoutMs(check by readingcfgExtensionHandlersToolCallTimeoutMs.get(settings)in a throwaway script), set the key withsettings.set(...)afterreloadForCwdinbuildDeckSessionSettings, in both the main and the fallback path.