Skip to content

added session and always permissions#389

Open
arkml wants to merge 3 commits intodevfrom
permissions
Open

added session and always permissions#389
arkml wants to merge 3 commits intodevfrom
permissions

Conversation

@arkml
Copy link
Contributor

@arkml arkml commented Feb 19, 2026

Feature: Scoped permission approvals for command execution

When the agent wants to run a blocked command, the user can now choose how broadly to approve it —
once, for the session, or permanently.


UI Layer

permission-request.tsx — Replaced the 4-button layout (Allow Once, Allow for Session, Always Allow,
Deny across two rows) with a split-button pattern:

  • Single row: [Approve ▾] [Deny]
  • "Approve" button click → allows once (same as before)
  • Chevron dropdown → "Allow for Session" and "Always Allow" menu items
  • The chevron/dropdown only renders when command is present (i.e. executeCommand tool calls). For other
    tool permission requests, it's a plain Approve button with no dropdown.
  • Uses existing DropdownMenu Radix component. Approve button gets rounded-r-none, chevron button gets
    rounded-l-none with a subtle border divider to form the split-button visual.

Frontend Wiring

App.tsx — handlePermissionResponse callback now accepts two additional optional params: scope ('once' |
'session' | 'always') and command (the raw command string). These are passed through to the
runs:authorizePermission IPC call. The PermissionRequest component gets onApproveSession and
onApproveAlways callbacks that extract the command string from toolCall.arguments.command and call
handlePermissionResponse with the appropriate scope.

chat-sidebar.tsx — Same changes as App.tsx — the onPermissionResponse prop type is extended with scope
and command, and the PermissionRequest component gets the same onApproveSession/onApproveAlways wiring.
This is the sidebar view that renders the same permission cards.

Schema

packages/shared/src/runs.ts — ToolPermissionAuthorizePayload (the Zod schema for the IPC payload) is
extended with:

  • scope: z.enum(["once", "session", "always"]).optional()
  • command: z.string().optional()

These are transport-only fields. They're destructured out before the event is created, so they never
get persisted to the run log.

Backend — Permission Handling

packages/core/src/runs/runs.ts — authorizePermission() now:

  1. Destructures { scope, command, ...rest } from the incoming payload
  2. If the response is "approve" AND both command and scope are present AND scope is not "once":
    - Calls extractCommandNames(command) to parse out the individual command names (e.g. git add . && git
    commit → ["git", "commit"])
    - If scope === "session" → calls addToSessionAllowList(commandNames)
    - If scope === "always" → calls addToSecurityConfig(commandNames) (async, writes to disk)
  3. Passes ...rest (without scope/command) to construct the ToolPermissionResponseEvent and appends it
    to the run log as before

Backend — Security Config

packages/core/src/config/security.ts — Three additions:

  1. sessionAllowSet — A module-level Set that holds commands allowed for the current session.
    Resets on app restart since it's just in-memory.
  2. addToSessionAllowList(commands: string[]) — Normalizes (trim + lowercase) and adds each command to
    sessionAllowSet.
  3. addToSecurityConfig(commands: string[]) — Reads the current security.json allow list, merges in the
    new commands (normalized), writes back sorted JSON, then calls resetSecurityAllowListCache() so the
    next getSecurityAllowList() call picks up the new file.
  4. getSecurityAllowList() — Modified to merge sessionAllowSet into the file-based list before
    returning. Both the cache-hit path and the error/fallback path handle the merge.

Backend — Command Parsing Fix

packages/core/src/application/lib/command-executor.ts — Two changes:

  1. Quote-aware command splitting — The old COMMAND_SPLIT_REGEX (/(?:|||&&|;|||\n)/) was replaced
    with splitCommandSegments(), a character-by-character parser that tracks single/double quote state. The
    regex would incorrectly split on separators inside quoted strings — e.g. grep "foo || bar" file.txt
    would be treated as two commands (grep "foo and bar" file.txt). The new parser correctly keeps that as
    one segment.
  2. sanitizeToken() — Now strips parentheses in addition to quotes (/^['"()]+|['"()]+$/g), handling
    subshell syntax like (cd foo && make).
  3. extractCommandNames — Changed from a private function to an export, so runs.ts can import it to
    parse commands for allow-listing.

@vercel
Copy link

vercel bot commented Feb 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rowboat Ready Ready Preview, Comment Feb 24, 2026 7:26am

Request Review

Copy link
Contributor

@ramnique ramnique left a comment

Choose a reason for hiding this comment

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

Key issues:

  • session-level grant should be written to the run-log and incorporated within the run-state (instead of being stored in memory like it is currently)
  • unclear whether command name extraction is using an idiomatic, documented algorithm
  • frontend should not be sending command back as part of permission response, this is redundant and instead the backend should extract those from the linked tool-call id

subflow: true,
toolCallId: true,
response: true,
}).extend({
Copy link
Contributor

Choose a reason for hiding this comment

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

2 issues

  • why extend the shape instead of adding these fields to the original shape?
  • why do we need to add command here? The response is already linked to a permission request through toolCallId, so the backend would be aware of the original command in the tool-call

/** In-memory session allowlist — resets on app restart */
const sessionAllowSet = new Set<string>();

export function addToSessionAllowList(commands: string[]): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

session means a specific run. This implementation assumes session to be the app-session, which is incorrect. Additionally, it is storing the session prefs in memory, which will not survive app-restarts as documented in comments above.

Instead, the agent runtime should simply incorporate session-wide permissions in its state (the state-builder can take care of this). This way, when the state is rebuilt from the run.jsonl file, the session-specific permission grants are restored automatically


// Handle scope side-effects when approving
if (rest.response === "approve" && command && scope && scope !== "once") {
const commandNames = extractCommandNames(command);
Copy link
Contributor

Choose a reason for hiding this comment

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

command name(s) should be extracted from the original tool call, not from the auth response

ensureSecurityConfigSync();
try {
const stats = fs.statSync(SECURITY_CONFIG_PATH);
let fileList: string[];
Copy link
Contributor

Choose a reason for hiding this comment

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

ideally we don't expect any code changes in this part of security.ts since no session-related data needs to be picked up from here.

* Split a shell command string on command separators (||, &&, ;, |, \n)
* while respecting single and double quotes.
*/
function splitCommandSegments(command: string): string[] {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this approach following any documented practices for parsing commands? If so, can we include a reference to that? If not, can we use a proven parsing-based solution here?

@arkml
Copy link
Contributor Author

arkml commented Feb 23, 2026

Here's how each review comment was addressed:

  1. "Session" should mean a specific run, not the app process

Before: Session grants were stored in an in-memory Set in security.ts (sessionAllowSet) that lived
for the entire app process — all runs shared the same session allowlist.

After: Session grants are persisted as scope: "session" on the ToolPermissionResponseEvent in the
run log. AgentState now has a sessionAllowedCommands set that gets rebuilt from the log during
ingest(). This means session-allowed commands are scoped to a single run and automatically restored
when the state is rebuilt.

  1. command is redundant in the auth payload

Before: The frontend extracted the command string from toolCall.arguments.command and sent it
alongside toolCallId in the IPC payload.

After: command is removed from ToolPermissionAuthorizePayload and all frontend handlers. The backend
derives the command by looking up the matching tool-permission-request event in the run log using
the toolCallId. The frontend just sends { toolCallId, subflow, response, scope }.

  1. scope should be on ToolPermissionResponseEvent itself

Before: scope was added via .extend() on the ToolPermissionAuthorizePayload only — it wasn't part of
the persisted event schema.

After: scope: z.enum(["once", "session", "always"]).optional() is directly on
ToolPermissionResponseEvent. The authorize payload uses .pick() to include it. The scope is now
persisted in the run log, which is what enables the state-builder (point 1) to work.

  1. No session-related changes needed in security.ts

Before: security.ts had sessionAllowSet, addToSessionAllowList(), and getSecurityAllowList() merged
the session set into its return value.

After: All session logic removed from security.ts. getSecurityAllowList() reverted to only returning
the file-based allowlist. Session handling is entirely in the runtime layer now.

  1. Custom splitCommandSegments should use shell-quote

Before: A hand-rolled splitCommandSegments() parser with manual quote tracking and sanitizeToken()
regex stripping.

After: Replaced with shell-quote's parse(). The new extractCommandNames() iterates parsed tokens,
resets on operator tokens ({op: '|'} etc.), skips env assignments (VAR=val), and handles wrapper
commands (sudo, env, time, command).

Copy link
Contributor

@ramnique ramnique left a comment

Choose a reason for hiding this comment

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

Functionally this is fine and good to go.

However, left a couple of concerns in comments that you might want to look at.

// For "always" scope, derive command from the run log and persist to security config
if (rest.response === "approve" && scope === "always") {
const repo = container.resolve<IRunsRepo>('runsRepo');
const run = await repo.fetch(runId);
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: We were already resolving IRunsRepo on line 51. Can't we just move that up and reuse instead of resolving twice?

shell-quote is a quoting/escaping library, not a shell command parser.
Replace with a regex splitter that handles pipes, chains, subshells,
and parenthesized groups. Strips () from tokens via sanitizeToken.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to 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.

2 participants