Keeping an agent in its folder
A sandbox folder is the one folder an agent may read and write in. Every path it asks for must end up inside that folder.
That is harder than it sounds. A path can climb out with two dots, or go through a link that points somewhere else. The fix is to check where a path really ends up.
By the end of this lesson
Note
I can keep an agent inside one folder by checking the resolved path.
Key words
| Word | What it means | Example |
|---|---|---|
| Sandbox folder | the one folder an agent may use | |
| Resolve | to work out where a path really goes | |
| Link | a file that points at another place | shortcut |
| Escape | to end up outside the folder | |
| Parent | the folder above, written as two dots | .. |
The cells students fill in are left blank in this preview.
Resolve first, then compare
Resolving a path means following every two dots and every link until you have the real address. Only then do you ask: is this inside the folder?
Compare with a slash on the end. The folder box is not the folder box-old, though one name starts with the other.
Worked example: folder /work/box
| Asked for | Really goes to | Inside? |
|---|---|---|
| a.txt | /work/box/a.txt | Yes |
| ../outside/s.txt | /work/outside/s.txt | No |
| /etc/hosts | /etc/hosts | No |
| ../box-old/x.txt | /work/box-old/x.txt | No |
| shortcut/s.txt | /work/outside/s.txt | No: shortcut is a link |
The cells students fill in are left blank in this preview.
A common mistake
Warning
Many people think removing the two dots from a path makes it safe. It does not. A link inside the folder can point outside, so the text looks safe and the real place is not. Always check the real path, not the text.
If you cannot resolve it, refuse
A path may point at a folder that does not exist, or at links that loop for ever. You cannot say where it goes, so you cannot say it is safe.
Refuse it. A refusal costs the agent one step. A wrong yes can cost a whole disk.
Write the model helper, part 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' '// The token counts of a reply, or null when the reply carries none.' 'function count(data, name) {' ' return data.usage && typeof data.usage[name] === \"number\" ? data.usage[name] : null;' '}' > model.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the model helper, part 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function ask(messages, sample, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", {' ' method: \"POST\",' ' headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' ' body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 100 }),' ' signal: AbortSignal.timeout(30000),' ' });' ' if (res.ok === false) throw new Error(\"status \" + res.status);' ' const data = await res.json();' ' const text = data.choices[0].message.content;' ' return { text, inTokens: count(data, \"prompt_tokens\"), outTokens: count(data, \"completion_tokens\"), recorded: false };' ' } catch (error) {' ' console.log(\"Recorded sample: no model answered (\" + error.message + \").\");' ' return { text: sample, inTokens: null, outTokens: null, recorded: true };' ' }' '}' 'module.exports = { ask };' >> model.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the folder check, part 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const fs = require(\"fs\");' 'const path = require(\"path\");' 'const base = path.join(process.cwd(), \"sandbox-lab\");' 'const box = path.join(base, \"box\");' 'fs.rmSync(base, { recursive: true, force: true });' 'fs.mkdirSync(box, { recursive: true });' 'fs.mkdirSync(path.join(base, \"box-old\"));' 'fs.mkdirSync(path.join(base, \"outside\"));' 'fs.writeFileSync(path.join(box, \"a.txt\"), \"inside\");' 'fs.writeFileSync(path.join(base, \"outside\", \"s.txt\"), \"secret\");' 'fs.symlinkSync(path.join(base, \"outside\"), path.join(box, \"shortcut\"));' > confine.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the folder check, part 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const root = fs.realpathSync(box);' '// Where a path really goes: follow links, then compare with the folder.' 'function inside(p) {' ' try {' ' const t = path.resolve(root, p);' ' const real = fs.existsSync(t) ? fs.realpathSync(t) : path.join(fs.realpathSync(path.dirname(t)), path.basename(t));' ' return real === root || real.startsWith(root + path.sep);' ' } catch (e) { return false; }' '}' 'function naive(p) { return path.resolve(root, p).startsWith(root); }' >> confine.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the folder check, part 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const asked = [\"a.txt\", \"new.txt\", \"../outside/s.txt\", \"/etc/hosts\", \"../box-old/x.txt\", \"shortcut/s.txt\"];' 'async function main() {' ' asked.forEach(function (p) { console.log(p + \" text-only:\" + naive(p) + \" resolved:\" + inside(p)); });' ' console.log(\"files in box: \" + fs.readdirSync(box).sort().join(\" \"));' ' const q = \"In one short sentence, why must a path be resolved before it is checked?\";' ' const sample = \"A path can use dots or a link to point somewhere else, so you check where it really ends up.\";' ' const reply = await ask([{ role: \"user\", content: q }], sample, 60);' ' console.log(\"model said: \" + reply.text);' '}' 'main();' >> confine.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Run the folder check
docker exec -w /home/node/project agent node confine.jsShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
What the Run prints
| Line | What it shows |
|---|---|
| a.txt text-only:true resolved:true | A file inside the folder |
| ../outside/s.txt text-only:false ... | Both checks catch the dots |
| ../box-old/x.txt text-only:true resolved:false | A name that starts the same fools the text |
| shortcut/s.txt text-only:true resolved:false | A real link out of the folder |
| files in box: a.txt shortcut | The Run made these files for real |
| model said: ... | A sentence. The words differ each run |
The cells students fill in are left blank in this preview.