Your agent logs in as you. That is the problem.
On October 30, Snowflake made Restricted Session Scopes generally available. The idea is simple: when an agent is active in a session, it gets a privilege ceiling that sits below the user's own rights. Their words: it "can only restrict what agents can do on behalf of the user, never expand user privileges."
The same day, Wiz published "Black October" for AI Developer Platforms, listing at least nine AI developer platforms that disclosed incidents this month, with attackers "explicitly hunting stored environment variables" that grant AI access. Different story, same lesson. Whatever access your agent holds is access someone else can end up steering.
You do not need Snowflake to apply the pattern. Here is the tiny version.
The rule: agent access = user access ∩ task ceiling
A human with ACCOUNTADMIN exploring data is fine. An agent with ACCOUNTADMIN reading a poisoned doc is not. So the agent never gets the user's full grants. It gets the intersection of:
- What the user can do (the agent can never exceed this).
- A ceiling for agent sessions (the agent can never exceed this either).
Snowflake's example ceiling is "read everywhere, write only to a sandbox database, block the admin and PII governance roles." Let's build exactly that.
The code
// A tiny session ceiling: the agent gets the intersection of what the
// user can do and what the task scope allows. It can only shrink access.
type Privilege = "read" | "write" | "admin";
type Grant = { privilege: Privilege; database: string };
const user: { grants: Grant[]; roles: string[] } = {
grants: [
{ privilege: "read", database: "*" },
{ privilege: "write", database: "*" },
{ privilege: "admin", database: "*" },
],
roles: ["ANALYST", "ACCOUNTADMIN", "DATA_GOVERNOR"],
};
// "Read everywhere, write only to the sandbox, never the admin roles."
const ceiling = {
allow: [
{ privilege: "read", database: "*" },
{ privilege: "write", database: "AGENT_SANDBOX" },
] as Grant[],
blockedRoles: ["ACCOUNTADMIN", "SYSADMIN", "SECURITYADMIN", "DATA_GOVERNOR"],
};
const covers = (g: Grant[], p: Privilege, db: string) =>
g.some((x) => x.privilege === p && (x.database === "*" || x.database === db));
type Action = { tool: string; privilege: Privilege; database: string; role: string };
function check(a: Action, isAgent: boolean): string {
if (!covers(user.grants, a.privilege, a.database) || !user.roles.includes(a.role))
return "DENY (user lacks it)";
if (!isAgent) return "ALLOW";
if (ceiling.blockedRoles.includes(a.role)) return `DENY (role ${a.role} blocked for agents)`;
if (!covers(ceiling.allow, a.privilege, a.database))
return `DENY (${a.privilege} on ${a.database} is above the agent ceiling)`;
return "ALLOW";
}
const actions: Action[] = [
{ tool: "query_orders", privilege: "read", database: "PROD", role: "ANALYST" },
{ tool: "create_temp_table", privilege: "write", database: "AGENT_SANDBOX", role: "ANALYST" },
{ tool: "drop_table", privilege: "write", database: "PROD", role: "ANALYST" },
{ tool: "unmask_pii", privilege: "read", database: "PROD", role: "DATA_GOVERNOR" },
{ tool: "grant_role", privilege: "admin", database: "PROD", role: "ACCOUNTADMIN" },
];
for (const a of actions) {
console.log(`${a.tool.padEnd(18)} human: ${check(a, false).padEnd(6)} agent: ${check(a, true)}`);
}
Run it with npx tsx ceiling.ts:
query_orders human: ALLOW agent: ALLOW
create_temp_table human: ALLOW agent: ALLOW
drop_table human: ALLOW agent: DENY (write on PROD is above the agent ceiling)
unmask_pii human: ALLOW agent: DENY (role DATA_GOVERNOR blocked for agents)
grant_role human: ALLOW agent: DENY (role ACCOUNTADMIN blocked for agents)
Same user, same tools. The human can drop a prod table. The agent cannot, because the ceiling says writes only land in AGENT_SANDBOX. The agent also cannot switch into DATA_GOVERNOR to unmask PII, even though the user holds that role.
Three things this gets right
It can only shrink. The first check is the user's own grants. The ceiling is applied after, so a bad ceiling config can lock the agent out but can never hand it something the user does not have.
It keys on "is this an agent session," not on the prompt. Prompt injection defenses that read the text are guesses. This check does not care what the model was told. If a malicious Markdown file convinces the agent to run drop_table, the answer is still DENY.
Start read-only, then widen. Snowflake ships a built-in read-only scope and says many customers start there and tailor later. Same advice for your harness: begin with read only, log every DENY, and add a sandbox write grant when the logs show you need one.
Where to put it
Put the check in the tool layer, not in the system prompt. Every tool call goes through check(action, isAgent) before it touches anything. If you already run an approval gate, the ceiling sits in front of it: things above the ceiling are denied outright, and things under it can still require a human click.
If you want the per-task version of this, where each task gets a short-lived grant instead of a fixed ceiling, see tiny-access-grant in the Tiny Agent Marketplace.
Want the full harness?
This is one layer of what I teach in Ship a Safe Agent: permissions, approval gates, budgets and prompt injection defenses for agents you actually run in production. Module 1 is free, so start there: bhjr.dev/courses/ship-a-safe-agent/module-1. The full course is $149 until Nov 8 (then $199).
Code for this post: ceiling.ts and expected output. Example data only, no API key, no model calls.

