One agent, three levels of trust
A permission mode decides what an agent may do without asking. In plan mode it only reads. In ask mode it asks before it changes anything. In auto mode it works alone inside limits.
The same agent is safe to leave running in one mode and unsafe in another. So the mode is something you choose per run.
By the end of this lesson
Note
I can say which actions an agent may take without asking in plan, ask and auto modes.
Key words
| Word | What it means |
|---|---|
| Mode | a setting that decides what runs without asking |
| Plan mode | a mode that only reads |
| Risk | how much harm an action can do |
| Allow list | commands approved in advance |
| Protected path | a file that always needs approval |
| Approval | the user's yes or no to one action |
The cells students fill in are left blank in this preview.
A mode is a setting, not a prompt
You could write "never change files" in the prompt. A model may still do it, because a prompt is a request.
A mode is code in the harness. Every action passes through one function before it runs. In plan mode that function refuses a write, whatever the model says. The model never sees a way round it.
Rules are checked in order
Give each action a risk: read, write, run or destructive. Then decide by mode, and put the rules in a fixed order. Plan mode comes first, because it refuses more than any other.
Next, a protected path or a destructive action always asks. Only after that does a mode relax anything. An allow list lets auto mode run named commands without asking.
What each mode does with a risk
| Risk | Plan | Ask | Auto |
|---|---|---|---|
| Read | Allow | Allow | Allow |
| Write | Deny | Ask | Allow |
| Run | Deny | Ask | Allow if listed |
| Destructive | Deny | Ask | Ask |
| Protected path | Deny | Ask | Ask |
The cells students fill in are left blank in this preview.
A common mistake
Warning
Many people ask for approval on every action. People then click yes without reading. Ask only where it matters: protected paths, destructive actions and commands off the list.
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 mode checker, part 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const ALLOWED = [\"pytest\"];' 'const actions = [' ' { tool: \"read_file\", risk: \"read\", target: \"src/app.py\" },' ' { tool: \"write_file\", risk: \"write\", target: \"src/app.py\" },' ' { tool: \"run\", risk: \"run\", target: \"pytest\" },' ' { tool: \"run\", risk: \"run\", target: \"curl site\" },' ' { tool: \"delete_file\", risk: \"destructive\", target: \"src/old.py\" },' ' { tool: \"read_file\", risk: \"read\", target: \".env\" },' '];' > modes3.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the mode checker, part 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function protectedPath(t) {' ' return t === \".env\" || t.startsWith(\".env.\") || t.startsWith(\".git/\") || t.toLowerCase().includes(\"secret\");' '}' 'function decide(mode, a) {' ' if (mode === \"plan\") return a.risk === \"read\" && protectedPath(a.target) === false ? \"allow\" : \"deny\";' ' if (protectedPath(a.target) || a.risk === \"destructive\") return \"ask\";' ' if (mode === \"ask\") return a.risk === \"read\" ? \"allow\" : \"ask\";' ' if (a.risk === \"run\") return ALLOWED.includes(a.target) ? \"allow\" : \"ask\";' ' return \"allow\";' '}' >> modes3.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the mode checker, part 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' for (const a of actions) {' ' const row = [\"ask\", \"plan\", \"auto\"].map(function (m) { return m + \":\" + decide(m, a); });' ' console.log(a.tool + \" \" + a.target + \" -> \" + row.join(\" \"));' ' }' ' const q = \"An agent wants to delete src/old.py. Write one short sentence that asks the user to approve it.\";' ' const reply = await ask([{ role: \"user\", content: q }], \"The agent wants to delete src/old.py. Do you allow it?\", 40);' ' console.log(\"question shown: \" + reply.text);' '}' 'main();' >> modes3.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Run the actions in three modes
docker exec -w /home/node/project agent node modes3.jsShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
The decisions need no model
The six lines come from the decision function alone, so they are the same for every student. The Run then makes one short call, to word the question a user would see before a delete.
With no model it prints a Recorded sample for that one line.
One possible output
| Line | What it shows |
|---|---|
| read_file src/app.py -> ask:allow plan:allow auto:allow | A plain read passes everywhere |
| write_file src/app.py -> ask:ask plan:deny auto:allow | Only auto lets a write through |
| run pytest -> ask:ask plan:deny auto:allow | It is on the allow list |
| run curl site -> ask:ask plan:deny auto:ask | Not on the list, so auto asks |
| delete_file src/old.py -> ask:ask plan:deny auto:ask | A delete always asks |
| read_file .env -> ask:ask plan:deny auto:ask | A protected path always asks |
The cells students fill in are left blank in this preview.