Module 7 · Tools, Skills & Plugins ⏱ 17 min

Sandboxing: Least Privilege for Tools

By the end of this lesson you will be able to:
  • Explain why a tool's damage radius is the host's permissions unless a guard shrinks it
  • Implement an allowlist guard that permits an action only when a declared capability covers it, defaulting to deny
  • Audit a tool for overreach — the intended actions its own allowlist would reject

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
A guard sits between the model's requested action and the tool, allowing only what the allowlist covers.

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.

The authorize check: an action is allowed only if some rule's verb matches and its prefix starts the path. Press Run.
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
With no matching rule, default-deny blocks (safe); default-allow permits anything not forbidden (the leak).
A guarded tool wraps a handler so every call is checked before it runs. Press Run.
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.

findOverreach returns the actions a tool intends to take that its own allowlist rejects. Press Run.
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
Overreach detection filters a tool's intended actions by authorize: the ones outside the allowlist are flagged before runtime.
Exercise

A guard checks a tool's action against its allowlist and finds no matching rule. What does least privilege require?

Exercise

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));
Exercise

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
}
Exercise

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;
}
Exercise

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
}

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 (read under /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.

Checkpoint quiz

Why does least privilege demand default-deny rather than default-allow?

A tool is allowed read under /data/. Which action will the guard DENY?

Go deeper — technical resources