Skip to content

mecated cannot start below a searchable, non-listable parent on Linux #2394

Open

Description

@gambtho

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.mjs

The 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions