A tool that can read a file can read any file — the source code, the environment variables, the private key on disk — unless something stops it. Left to itself, a tool runs with all the permissions of the program that hosts it. Add a model that decides which arguments to pass, and a crafty prompt can turn a harmless read_report tool into a trip through your secrets directory. The damage radius of a tool is whatever the host can do, and the host can usually do everything.
Least privilege is the fix: declare the narrow capability a tool actually needs and have a guard enforce it, so the tool can do only what it declared and nothing more. This lesson builds that guard — an allowlist of permitted actions and the check that runs before every tool call.
flowchart LR M["model calls tool<br/>with arguments"] --> G["guard checks<br/>action vs allowlist"] G -->|"allowed"| RUN["tool runs"] G -->|"denied"| BLOCK["blocked<br/>default-deny"] style G fill:#b45309,color:#fff style BLOCK fill:#b91c1c,color:#fff style RUN fill:#166534,color:#fff
Capabilities, actions, and the allowlist
Three words do the work here. A capability is a permission the tool declared it would use, written as a verb plus a scope: read under /data/, or write under /tmp/. An action is the specific thing a particular call tries to do, like reading /data/report.csv or writing /etc/hosts. The allowlist is the list of capabilities the tool promised it would need.
The guard's job is one comparison: does any capability in the allowlist cover this action? A read of /data/report.csv is covered by read /data/; a read of /etc/passwd is not. Notice the verb matters as much as the path — a tool allowed to read /data/ must still be denied a write there unless it declared that too.
function authorize(action, allowlist) {
return allowlist.some(function (rule) {
return action.verb === rule.verb && action.path.indexOf(rule.prefix) === 0;
});
}
const allow = [
{ verb: 'read', prefix: '/data/' },
{ verb: 'write', prefix: '/tmp/' }
];
console.log(authorize({ verb: 'read', path: '/data/report.csv' }, allow));
console.log(authorize({ verb: 'write', path: '/data/report.csv' }, allow));
console.log(authorize({ verb: 'read', path: '/etc/passwd' }, allow));
Default-deny: the direction that matters
When no capability in the allowlist covers an action, the guard has two choices: allow it through, or block it. Least privilege demands default-deny — block unless explicitly allowed. The alternative, default-allow, inverts the safety: every path you forgot to list, every verb you did not anticipate, every future directory is automatically permitted, and the allowlist becomes a list of suggestions rather than a boundary.
Default-deny turns the allowlist into a real perimeter. Adding a new capability is a deliberate act — you extend the list, you do not hope the missing entry is harmless. Anything not listed is stopped, which is exactly what makes the guard trustworthy: its failures are loud denials, not silent permissions.
flowchart TD A["action matches a rule?"] -->|"yes"| OK["allow"] A -->|"no match"| D["default-deny: block<br/>the safe choice"] B["default-allow<br/>the unsafe choice"] -->|"no match"| LEAK["permit anything<br/>not explicitly forbidden"] style OK fill:#166534,color:#fff style D fill:#166534,color:#fff style LEAK fill:#b91c1c,color:#fff style B fill:#b45309,color:#fff
function authorize(action, allowlist) {
return allowlist.some(function (rule) {
return action.verb === rule.verb && action.path.indexOf(rule.prefix) === 0;
});
}
function makeTool(allowlist, handler) {
return function (action) {
if (!authorize(action, allowlist)) {
return { ok: false, reason: 'denied: ' + action.verb + ' ' + action.path };
}
return { ok: true, result: handler(action) };
};
}
const readFile = makeTool(
[{ verb: 'read', prefix: '/data/' }],
function (a) { return 'contents of ' + a.path; }
);
console.log(readFile({ verb: 'read', path: '/data/x' }).ok);
console.log(readFile({ verb: 'read', path: '/etc/shadow' }).ok);
Prefixes are recursive
The check uses a path prefix, and that decision has a consequence: a capability for /data/ covers /data/secrets/keys.txt too, because every path under /data/ starts with that prefix. That is usually what you want — grant access to a directory and everything beneath it — but it means your prefix must be exactly as narrow as you intend. A careless / prefix grants the whole filesystem, and /data with no trailing slash would also match /database/. Mind the trailing slash, and prefer the tightest directory that still lets the tool do its job.
Detecting the over-privileged tool
Least privilege is not only enforced at call time — you can also audit it up front. A tool's declared actions should sit inside its allowlist; if any action a tool intends to take is not covered, that tool is over-privileged: it is asking for more than it was granted, or it was granted less than it needs. Either way the mismatch is a bug you want to find before runtime, not after an incident.
The check is the same authorize turned into a filter: walk the tool's intended actions, keep the ones the allowlist rejects, and return them. An empty result means the tool fits its declared permissions; a non-empty result is the exact list of calls the guard would later block — your early warning.
function authorize(action, allowlist) {
return allowlist.some(function (rule) {
return action.verb === rule.verb && action.path.indexOf(rule.prefix) === 0;
});
}
function findOverreach(tool) {
return tool.actions.filter(function (a) { return !authorize(a, tool.allowlist); });
}
const tool = {
actions: [
{ verb: 'read', path: '/data/a.csv' },
{ verb: 'read', path: '/etc/passwd' }
],
allowlist: [{ verb: 'read', prefix: '/data/' }]
};
console.log(findOverreach(tool).length);
console.log(findOverreach(tool)[0].path);
flowchart LR T["intended actions of the tool"] --> F["filter by authorize"] AL["declared allowlist"] --> F F -->|"inside"| OK["covered<br/>safe"] F -->|"outside"| OVER["overreach<br/>flag before runtime"] style OK fill:#166534,color:#fff style OVER fill:#b45309,color:#fff
A guard checks a tool's action against its allowlist and finds no matching rule. What does least privilege require?
Least privilege is default-deny: anything not explicitly allowed is blocked. Defaulting to allow turns the allowlist into a list of suggestions and lets any path you forgot to list straight through.
What does this print (three lines)? authorize returns true only when a rule's verb matches and its prefix starts the action's path.
const allow = [{ verb: 'read', prefix: '/data/' }, { verb: 'write', prefix: '/tmp/' }];
function authorize(a, list) {
return list.some(function (r) { return a.verb === r.verb && a.path.indexOf(r.prefix) === 0; });
}
console.log(authorize({ verb: 'read', path: '/data/x' }, allow));
console.log(authorize({ verb: 'write', path: '/data/x' }, allow));
console.log(authorize({ verb: 'read', path: '/etc/passwd' }, allow));
Line 1: read of /data/x matches the read /data/ rule.
Line 2: write has no matching verb for /data/x; line 3: /etc/passwd is not under /data/.
Write authorize(action, allowlist) that returns true only if some rule in the allowlist has the same verb as the action and a prefix that the action's path starts with. With no matching rule it must return false (default-deny).
function authorize(action, allowlist) {
// true only if some rule has the same verb and a prefix of the action's path
}
Use some() over the allowlist so an empty list returns false automatically.
A prefix match means action.path.indexOf(rule.prefix) === 0.
function authorize(action, allowlist) {
return allowlist.some(function (rule) {
return action.verb === rule.verb && action.path.indexOf(rule.prefix) === 0;
});
}
This authorize is PERMIT-BY-DEFAULT: when no rule matches it returns true, so a tool reading /etc/passwd with an empty allowlist sails straight through. Change only the value returned when nothing matched, so the guard defaults to deny.
function authorize(action, allowlist) {
for (const rule of allowlist) {
if (action.verb === rule.verb && action.path.indexOf(rule.prefix) === 0) return true;
}
return true;
}
The loop already allows a real match correctly.
The bug is the final return: when the loop ends with no match, a safe guard denies, so return false.
function authorize(action, allowlist) {
for (const rule of allowlist) {
if (action.verb === rule.verb && action.path.indexOf(rule.prefix) === 0) return true;
}
return false;
}
Write findOverreach(tool) that returns the actions a tool intends to take that its own allowlist does NOT cover. A tool has actions (an array of {verb, path}) and an allowlist (an array of {verb, prefix}). Use the authorize helper already defined above — keep the actions it rejects, drop the ones it allows.
function authorize(action, allowlist) {
return allowlist.some(function (rule) { return action.verb === rule.verb && action.path.indexOf(rule.prefix) === 0; });
}
function findOverreach(tool) {
// return the actions authorize() rejects
}
Filter tool.actions, keeping each action a for which authorize(a, tool.allowlist) is false.
An action covered by the allowlist is dropped; an uncovered one is kept — that is the overreach.
function authorize(action, allowlist) {
return allowlist.some(function (rule) { return action.verb === rule.verb && action.path.indexOf(rule.prefix) === 0; });
}
function findOverreach(tool) {
return tool.actions.filter(function (a) { return !authorize(a, tool.allowlist); });
}
Recap
- A tool's damage radius is the host's permissions unless a guard shrinks it. Least privilege means declaring the narrow capability a tool needs and enforcing it.
- A capability is a verb plus a scope (
readunder/data/); an action is a specific call; the allowlist is the set of declared capabilities. authorize(action, allowlist)returns true only if some rule's verb matches and its prefix starts the action's path.- Default-deny is the safe direction: block unless explicitly allowed. Default-allow turns the allowlist into suggestions.
- Prefixes are recursive —
/data/covers everything beneath it — so mind the trailing slash and keep prefixes tight. - Overreach detection runs the same check up front: any intended action the allowlist rejects is a tool asking for more than it was granted.
Next you will configure these guarded tools across local, staging, and production without an accidental trip into real data.