Repository navigation
Description
Feature hasn't been suggested before
I searched the issue tracker for permission races, stale permissions, revalidation
and TOCTOU. I did not find a matching effect-admission contract request. Please
redirect this request if an existing issue or supported API covers it.
Describe the enhancement you want to request
A native host integration needs an atomic boundary between permission evaluation
and tool effects. If an owner policy update commits while an earlier decision is
still in flight, the operation should either revalidate against that policy or
reject the stale decision before its effects. Is there a supported native
revision/lease or equivalent contract for this?
This is a feature request for stronger ordering, not a claim that the current
SDK promises it. Another plugin snapshot or an approval responder is insufficient.
Observed with native OpenCode V2 2.0.32 on macOS arm64 and Linux x86_64:
- Stable native allow executes the dummy shell command.
- A later evaluation hook appends or replaces session rules with a shell deny,
awaits the native update and independently reads that deny back. - The command still writes its effect file. The deny remains stored afterward.
- An independent native read succeeds; no model inputs are submitted.
The same standalone probe also reproduces that ordering on native V2 2.1.1
on macOS arm64, with no model input.
Both changed cases record:
native_allow_observed → owner_deny_update_returned_and_read_back → native_command_finished.
A standalone plugin is below. It has no integration-adapter imports, remote
services or credentials. Run only in a new disposable directory: it grants shell
permission to its three test sessions and writes small local control/effect files.
- Create a temporary workspace plus an isolated
XDG_CONFIG_HOME. - Save the plugin as
$XDG_CONFIG_HOME/opencode/plugins/admission-probe/index.mjs
and a neighboringpackage.jsoncontaining{"type":"module","main":"index.mjs"}. - Start
opencode serve --hostname 127.0.0.1 --port 9876 --log-level errorfrom
that temporary workspace. SetOPENCODE_PASSWORDto an ephemeral local value. - Make an authenticated local
GET /api/mcpto instantiate the plugin context. - Inspect
result.jsonin the plugin directory and the workspace effect files;
stop the test host afterward. The probe itself is finite and sends no prompts.
import {Plugin} from '@opencode/plugin';
import {writeFile,readFile} from 'node:fs/promises';
import {randomBytes} from 'node:crypto';
// Standalone SDK probe: no model, integration adapter, remote service or credentials.
export default Plugin.define({id:'fixture.native-policy-admission',async setup(ctx){
let stopped=false;
const run=async()=>{
const shell=(await ctx.tool.list()).find(t=>t.name==='shell');
const read=(await ctx.tool.list()).find(t=>t.name==='read');
if(!shell||!read)throw Error('Native shell/read unavailable');
await writeFile(ctx.location.directory+'/read-control.txt','READ_CONTROL\n');
const cases=[];
for(const change of ['stable','append','replace']){
if(stopped)return;
const created=await ctx.session.create({agent:'build',title:'Finite native admission probe',permissions:[{action:'shell',resource:'*',effect:'allow'}]});
const session=created.data??created;const steps=[];let changed=false;
const observe=await ctx.permission.hook('evaluate',event=>{
if(event.sessionID===session.id&&event.action==='shell'){
if(event.effect!=='allow')throw Error('Control was not natively allowed');
steps.push('native_allow_observed');
}
});
const owner=await ctx.permission.hook('evaluate',async event=>{
if(event.sessionID!==session.id||event.action!=='shell'||change==='stable'||changed)return;
const reply=await ctx.session.get({sessionID:session.id});const current=reply.data??reply;
const deny={action:'shell',resource:'*',effect:'deny'};
await ctx.session.update({sessionID:session.id,permissions:change==='append'?[...current.permissions,deny]:[deny]});
const confirmed=await ctx.session.get({sessionID:session.id});
if(!(confirmed.data??confirmed).permissions.some(r=>r.action==='shell'&&r.effect==='deny'))throw Error('Owner deny not stored');
changed=true;steps.push('owner_deny_update_returned_and_read_back');
});
try{
const context={sessionID:session.id,agent:'build',messageID:'msg_'+randomBytes(12).toString('hex'),id:'probe_'+randomBytes(12).toString('hex'),signal:new AbortController().signal,progress:async()=>{}};
let executed=false,errorName=null;
try{await shell.execute({command:'printf EFFECT > '+change+'-effect.txt',description:'Finite native admission probe',timeout:10000},context);executed=true;steps.push('native_command_finished');}
catch(error){errorName=error?.name??'Error';steps.push('native_command_rejected');}
const effect=await readFile(ctx.location.directory+'/'+change+'-effect.txt','utf8').catch(()=>null);
const independent=await read.execute({path:ctx.location.directory+'/read-control.txt'},{...context,id:'probe_read_'+randomBytes(12).toString('hex')});
const history=await ctx.session.context({sessionID:session.id});
const final=await ctx.session.get({sessionID:session.id});
cases.push({change,steps,owner_deny_committed:changed,native_command_executed:executed,effect_verified:effect==='EFFECT',independent_read_succeeded:JSON.stringify(independent).includes('READ_CONTROL'),native_model_events:(history.data??history).length,deny_retained:change==='stable'||(final.data??final).permissions.some(r=>r.action==='shell'&&r.effect==='deny'),error_name:errorName});
}finally{await observe.dispose();await owner.dispose();}
}
await writeFile(new URL('result.json',import.meta.url),JSON.stringify({cases},null,2));
};
const timer=setTimeout(()=>void run().catch(async error=>{await writeFile(new URL('failure.json',import.meta.url),JSON.stringify({name:error?.name,message:String(error?.message)},null,2));}),10);
return async()=>{stopped=true;clearTimeout(timer);};
}});Desired acceptance coverage includes appended/replaced session rules, updates
committing after callbacks, concurrent policy writers, managed restrictions,
inspection failure, queued/background work and stale/duplicate observations.
A stale decision should grant no permission while independent permitted work
remains usable. The exact schema and implementation can follow native design.
Activity
opencode-agent commentedon Oct 21, 2026
Thanks for the detailed request and the standalone probe!
To restate it: you'd like a native guarantee, such as a policy revision or lease, that a permission decision made before an owner policy update can't let tool effects through after that update has committed. Either the call is checked again against the new policy, or the stale decision is rejected.
For context, this matches how the code currently works: in
packages/core/src/permission.tsthe session and agent rules are read and the effect is computed before thepermission/evaluatehooks run. The effect a hook returns is then used without reading the rules again. So a hook that writes a deny into the session doesn't change the call already being decided. A hook can deny that call directly by returningeffect: "deny", but that doesn't give the general ordering guarantee you describe, which also covers concurrent writers and queued or background work.Maintainers will consider this. Related issues you may want to follow:
askeffect fails open for shell commands