"Shell access" sounds like one permission, but in practice it's a bundle of very different capabilities that teams tend to grant all at once because splitting them feels like extra setup work. That bundling is where things go wrong, not because AI assistants are reckless, but because a single broad grant makes it hard to reason about what could actually happen when the assistant does something you didn't expect.
The bundle nobody unbundles
A typical "give the assistant shell access" setup lets it run tests, install dependencies, read files, write files, and execute arbitrary commands, all under the same permission. Each of those individually is low risk. Combined, they add up to something closer to "this process can do anything a developer with your credentials could do," which is a much bigger grant than most teams intend when they check the box.
# what "shell access" usually actually means in practice
npm test # low risk
npm install <pkg> # medium risk: supply chain exposure
rm -rf ./tmp # fine, until the assistant's idea of "tmp" differs from yours
psql -f migration.sql # high risk: no undo
The fix isn't revoking shell access entirely, it's granting the specific commands a workflow actually needs instead of a blanket shell.
Where the actual incidents come from
Most real incidents don't come from the assistant doing something it wasn't asked to do. They come from the assistant correctly executing a command whose scope was broader than the human intended. Someone asks it to "clean up temp files" and it interprets "temp" more broadly than expected. Someone asks it to "reset the local database" and the connection string in the environment pointed somewhere other than local, because an environment variable got left over from an earlier session.
# a pattern that prevents the second failure mode above
import os
def require_local_db():
db_url = os.environ.get("DATABASE_URL", "")
if "localhost" not in db_url and "127.0.0.1" not in db_url:
raise RuntimeError(f"Refusing to run against non-local database: {db_url}")
A one-line guard like this, run before any destructive local command, catches an entire category of "wrong environment" incidents regardless of whether a human or an assistant issued the command.
Scoping shell access by task, not by tool
Rather than one broad shell grant, define specific allowed commands per task type. A test-running task gets npm test and nothing else. A dependency-update task gets npm install scoped to a lockfile diff a human reviews after. A migration-drafting task gets read access to the schema and write access to a migration file, but never execute access against anything but a disposable local database.
# example: scoping assistant permissions by task type
tasks:
run_tests:
allowed_commands: ["npm test", "npm run lint"]
draft_migration:
allowed_commands: ["npm run migrate:generate"]
forbidden_commands: ["npm run migrate:apply"]
update_deps:
allowed_commands: ["npm install"]
requires_human_review: true
This is more setup than granting one shell and calling it done, but it converts "trust the assistant to behave" into "the assistant physically cannot do the thing we don't want," which is a much sturdier guarantee. Sandboxing tools like Docker make this concrete: run the assistant's shell inside a container with no route to production credentials, and the scoping problem partly solves itself at the infrastructure layer instead of relying entirely on configuration discipline. Orchestration platforms like Kubernetes extend this further for teams running assistant sandboxes at scale, with network policies that can enforce the isolation boundary at the cluster level rather than trusting every container to be configured correctly by hand.
Why scoping by task beats scoping by tool
A common instinct is to scope permissions per tool, this assistant gets shell, this other integration gets API access, and call it done. That misses the actual risk surface, because the same tool used for two different tasks can have wildly different blast radius. A shell that can run npm test is fine for a hundred different task types. A shell that can also run npm run migrate:apply is not fine for almost any task type except the one specifically designed to apply reviewed migrations through a gated path. Scoping by tool treats these as the same grant. Scoping by task treats them correctly as different ones.
# a task-scoped config catches what a tool-scoped one misses
# BAD: one shell grant covers everything
assistant_permissions:
shell: true # now it can run npm test AND npm run migrate:apply
# BETTER: permissions attach to the task, not the tool
tasks:
fix_failing_test:
shell_commands: ["npm test", "npm run lint"]
draft_schema_change:
shell_commands: ["npm run migrate:generate"]
forbidden: ["npm run migrate:apply", "npm run deploy"]
The credential leakage problem that's easy to miss
Even with commands properly scoped, a shell session often carries environment variables far broader than the task needs, database URLs for every environment, API keys for services the current task never touches, cloud credentials with permissions well beyond what a test run requires. An assistant with shell access to run tests, but whose environment happens to include a production database URL as an unused variable, has effectively been granted access to that database the moment any command in its session could reference that variable, intentionally or not.
# strip unrelated credentials before handing off a shell session
import os
ALLOWED_FOR_TEST_RUN = {"NODE_ENV", "CI", "DATABASE_URL_TEST"}
def scoped_env():
return {k: v for k, v in os.environ.items() if k in ALLOWED_FOR_TEST_RUN}
This is a small amount of extra plumbing that closes off an entire class of "the assistant technically had access" incidents that never show up in an audit of granted permissions, because the permission was never explicitly granted, it was just sitting in an environment nobody scoped.
The migration case specifically
Database migrations are the sharpest example of why bundled shell access is dangerous, because they're one of the few operations in a typical stack that isn't cleanly reversible by reverting a commit. Reference material on the database side matters too. The PostgreSQL documentation covers exactly which DDL operations are transactional and reversible within a session, knowledge that should inform where you draw the disposable-versus-shared line for any given migration. We wrote a full breakdown of how to tier assistant permissions specifically around migrations, including a concrete pattern for keeping draft and apply as separate, separately-gated actions, in our guide to AI coding assistant guardrails for database migrations. The scoping principle there generalizes to shell access broadly: separate what the assistant can propose from what it can execute, and gate the second one on a human every time the action isn't trivially reversible.
A checklist worth running before granting shell access at all
Before wiring an AI coding assistant into any shell environment, walk through a short list: what specific commands does this task actually need, not what commands might be convenient to have available. Does the environment the shell runs in contain any credential broader than the current task requires. Is there a disposable version of whatever this command would touch, and if so, is the assistant actually pointed at that disposable version by default rather than by careful configuration someone has to remember every time. Is there a human or CI gate between "this command ran successfully" and "this change is live somewhere that matters."
None of these questions require sophisticated tooling to answer, they require someone to actually sit down and answer them before the first grant happens, rather than retroactively after an incident forces the question. Teams that do this upfront tend to end up with narrower, more specific shell configurations than teams that grant broadly and plan to tighten later, mostly because tightening later requires admitting the original grant was too broad, which is a harder conversation to have than getting the scope right from the start.
Why this is worth the setup time
Scoped shell access takes longer to configure than a single broad grant, there's no getting around that. But the alternative cost isn't zero, it's deferred and often invisible until the specific command that shouldn't have been possible actually runs. A well-scoped configuration, once built, is also reusable across projects and easier to reason about during a security review, since "here's exactly what this assistant can execute and why" is a much stronger answer than "it has shell access and we trust it," even when the assistant has, so far, behaved exactly as expected.
More on how we approach this kind of tooling setup at 137foundry.com.
Top comments (0)