Repository navigation
mecated cannot start below a searchable, non-listable parent on Linux #2394
Description
Bug description
mecated acp exits during managed-temp setup when it runs under a sandboxing launcher that gives each session a private temp directory below a shared, root-owned 0711 parent. The process can pass through that parent (search) but cannot list it. The private directories below it belong to the process's UID.
I can reproduce this on v0.0.48 without a provider connection or an editor. I have a small Linux fix ready to discuss.
Steps to reproduce
Download the v0.0.48 linux/amd64 release binary as mecated (SHA-256 c3980eeca96d31d4deec6efb9beb851cd3e0b61023799cfbe75ee5d7ce875cda) and save the script below as reproduce.mjs next to it. Then:
docker run --rm --read-only --network none --tmpfs /tmp \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \
--security-opt no-new-privileges \
-v "$PWD/reproduce.mjs:/reproduce.mjs:ro" \
-v "$PWD/mecated:/mecated:ro" \
node:26.5.0-bookworm-slim@sha256:2d49d876e96237d76de412761cf05dbfe5aee325cc4406a4d41d5824c5bb8beb \
node /reproduce.mjsThe script creates the 0711 parent as root, checks that UID 20000 cannot list it, then starts mecated acp as UID/GID 20000 and tries ACP initialize and session/new. The API key is a dummy value and networking is off, so no provider is contacted. Exit 0 means the failure reproduced.
reproduce.mjs
// Standalone Linux reproduction: no Orka, real credentials, or model calls.
// Run as root in an ephemeral container; child mecated runs as UID/GID20000.
import { mkdtempSync, mkdirSync, chmodSync, chownSync, statSync } from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { createInterface } from 'node:readline';
const base=mkdtempSync('/tmp/mecatl-0711-'); chmodSync(base,0o755);
const ancestor=base+'/probe';mkdirSync(ancestor,{mode:0o711});
const home=ancestor+'/private';mkdirSync(home,{mode:0o700});
for(const name of ['tmp','config','data','cache','workspace']) {
mkdirSync(home+'/'+name,{mode:0o700});chownSync(home+'/'+name,20000,20000);
}
chownSync(home,20000,20000);
const denial=spawnSync(process.execPath,['-e',`try{require('fs').readdirSync(${JSON.stringify(ancestor)});process.exit(1)}catch(e){process.exit(e.code==='EACCES'?0:2)}`],{uid:20000,gid:20000});
if(denial.status!==0) throw Error('precondition failed: ancestor must not be listable');
console.log('ancestor mode='+statSync(ancestor).mode.toString(8).slice(-3)+' owner='+statSync(ancestor).uid+'; child uid=20000; directory listing denied');
const args=['acp','--workspace='+home+'/workspace','--default-provider=openai','--openai',
'--model=offline-placeholder','--openai-base-url=http://127.0.0.1:1/v1',
'--permissions-conventional=false','--agents-conventional=false','--skills-conventional=false',
'--no-soul','--no-user-model','--toolhive=false','--mcp-resource-tools=false','--mcp-prompts=false',
'--enable-parallel=false','--websearch=off','--no-scheduler','--headless','--posture=strict',
'--product-metrics=false','--metrics-addr='];
const child=spawn('/mecated',args,{uid:20000,gid:20000,
env:{HOME:home,PATH:'/usr/bin:/bin',TMPDIR:home+'/tmp',XDG_CONFIG_HOME:home+'/config',
XDG_DATA_HOME:home+'/data',XDG_CACHE_HOME:home+'/cache',OPENAI_API_KEY:'offline-placeholder'},
stdio:['pipe','pipe','pipe']});
child.on('error',error=>{console.error('spawn failed: '+error.code);process.exit(1)});
let diagnostics='',sessionCreated=false;
child.stderr.on('data',data=>{if(diagnostics.length<32768) diagnostics+=data.toString()});
createInterface({input:child.stdout}).on('line',line=>{
const value=JSON.parse(line);
if(value.id===1&&value.result) {
console.log('ACP initialize passed');
child.stdin.write(JSON.stringify({jsonrpc:'2.0',id:2,method:'session/new',params:{cwd:home+'/workspace',mcpServers:[]}})+'\n');
}
if(value.id===2&&value.result) {sessionCreated=true;console.log('ACP session/new passed');child.stdin.end();}
});
child.stdin.on('error',()=>{});
child.stdin.write(JSON.stringify({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:1,clientCapabilities:{}}})+'\n');
const timer=setTimeout(()=>child.kill('SIGTERM'),15000);
child.on('exit',code=>{
clearTimeout(timer);
const line=diagnostics.split('\n').find(line=>line.includes('open root parent'));
if(line) console.log(line);
console.log('mecated exit='+code+'; sessionCreated='+sessionCreated);
const expectFailure=process.env.EXPECT_FAILURE!=='0';
const expected=expectFailure ? !sessionCreated&&Boolean(line?.includes('permission denied')) : sessionCreated&&code===0;
process.exit(expected?0:1);
});Expected behavior
Startup and session creation work without granting read permission on intermediate ancestors or changing their modes.
Actual behavior
managedtemp: open root parent: openat probe: permission denied
The failure happens before ACP initialization completes. openParent opens each ancestor as a separate os.Root, which needs read access in addition to search access.
Environment
- Linux 6.6, amd64
- mecated v0.0.48
- ACP subprocess under a sandboxing launcher
Additional context
I checked #1337 and #1346. Those are about deleted temp namespaces; this one fails on a freshly created directory tree. The same reproducer passes with my local fix while the parent stays root-owned 0711 and non-listable.
Activity