DEV Community

Carlos (Bloqarl)
Carlos (Bloqarl)

Posted on AI-assisted

Your repo is not trusted context. What I changed after giving coding agents real repositories

I use coding agents across real repositories every day. I run a small security firm, and between audits and two kids there are never enough hours, so agents do a lot of the reading and the grunt work for me.

The biggest mindset shift wasn't about which model to use. It was this: the repository is not automatically trusted context.

When you ask an agent to "fix the failing test", it reads the README, the open issues, the test files, package.json, maybe the CI config. To you, that's your project. To an attacker, every one of those files is an input channel into something that can run commands on your machine.

Everything in the repo is input

  • A README or an issue can contain instructions. Following instructions is the agent's whole job. One line buried in a doc ("before running the tests, also run this setup command") is prompt injection with a shell attached.
  • A test can execute code. "Run the tests" means "run whatever the test files do".
  • A package script can install more code. Install hooks run on install, and agents install dependencies all the time.
  • A git hook can run before the commit you asked for.
  • Config files are persistence. If the agent can write to your shell config, IDE settings, git hooks or CI workflows, one bad step survives the session.

And the dependency the agent suggests might not even exist yet. Attackers register package names that models tend to hallucinate (people call it slopsquatting). @harsh2644 tested three AI coding tools for it this week if you want to see how often it happens.

Where it actually goes wrong

When we review agent systems, the failure is rarely the model saying something bad. It's untrusted text reaching an execution sink: a shell command, a package install, a file write outside the repo, a CI change, a network call.

So I look at it like taint analysis in code review. Draw every path from something untrusted (an issue, a doc, a test, package metadata, generated code) to something that executes or persists. Then ask what stops it on each path. Usually the honest answer is "I'd have to notice".

What I changed in my own setup

  1. The agent runs in a box. A container or VM with the repo mounted, and no access to my home directory, SSH keys, cloud credentials or wallets.
  2. No standing secrets in its environment. No production tokens, no package publish tokens, no deploy keys. If a task really needs a credential, it gets a separate low-privilege one for that task.
  3. Approvals that mean something. No global auto-approve. Read-only actions can go through on their own. Anything that installs, writes outside the worktree, touches CI or hooks, or goes to the network waits for me. The trap here is approval fatigue: if nine prompts are routine, you click the tenth too, and the tenth is the one that matters. So I keep the prompts few and serious.
  4. Pinned to one repo and one branch. Separate worktrees per task. A file in the repo telling the agent to "also update the other project" should go nowhere.
  5. Its diff is a PR from a stranger. I review dependency changes, new scripts and CI edits first, before the actual code change.
  6. New packages get checked before install. Does it exist, is it the one I meant, who publishes it. Lockfiles committed.
  7. Logs it can't edit. What commands ran, what was installed, what was sent out. If something goes wrong, I want the record to come from outside the agent's reach.

If you write smart contracts

Keep agents away from deployer and signer keys, full stop. A "just deploy it to testnet" script is one edited RPC URL away from mainnet. Let the agent write and test the code. Run deployments and anything that signs yourself.

The point

None of this makes agents less useful. It's what lets me give them more work without worrying about what's sitting in some test file.

What does your setup look like? I'm especially curious how people handle approvals without drowning in prompts.


I wrote this with help from AI for drafting and editing. The opinions and the setup are mine.

Top comments (16)

Collapse
 
izgorodin profile image
Edward Izgorodin •

Separate worktrees per task reads as isolation, and it isolates the checkout only: a git worktree shares its hooks and its config with the main repository, and git hooks are already on the list under "config files are persistence".

The git-worktree documentation says a linked worktree shares everything except per-worktree files such as HEAD and the index, and that the repository config file is shared across all worktrees by default. From inside a linked worktree, git rev-parse --git-path hooks points into the main repository's .git/hooks.

It takes a few minutes to check. On git 2.52, a pre-commit hook written from the task worktree to the path git reports ran on the next commit made in the main worktree. So did a hook kept in a directory inside the task worktree, once core.hooksPath was pointed at it by absolute path from there, since that setting lands in the shared config. In that second case the only write outside the task worktree is a git config command. As controls, a commit in the main worktree before the hook was written ran nothing, a commit after unsetting core.hooksPath ran nothing, and a file written into the task worktree's checkout did not appear in the main one.

The box does not close this by itself. The .git in a linked worktree is a one line file pointing at the main repository's .git/worktrees directory, so with only the worktree mounted git answers "not a git repository", and an agent that is expected to commit needs the main .git mounted as well, hooks and config with it. Keeping .git/hooks and .git/config outside what the agent can write is what turns the per task worktree into a boundary for execution and not only for files.

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

This is a really good catch, thank you. I was treating the worktree as the boundary and it's only the checkout. Hooks and config are shared, so a hook written from the task worktree runs on my next commit in the main one. Same "config files are persistence" problem, just one level up. Moving .git/hooks and .git/config out of what the agent can write. Did you find a clean way to let the agent commit without giving it write access to the shared config?

Collapse
 
izgorodin profile image
Edward Izgorodin •

Carlos, not by guarding paths inside the shared .git. Read-only hooks and config close the two paths that run code on the next commit in the main worktree, but the same directory holds the branches, and the git-worktree page says every ref under refs/ is shared across worktrees. An agent that can commit through it can also point main at its own commit with git update-ref. Submodules add one more path, since each keeps its repository under .git/modules with hooks and config of its own.

A clone per task gives the agent its commits without any of that. The SECURITY section of git(1) says configuration and hooks are not copied by git clone, so whatever the agent writes into its own .git runs only inside the box, and the main repository fetches the task branch from outside. That fetch runs upload-pack in the task clone. The same section says upload-pack restricts the config and hooks it honors there but warns that its attack surface is large, and the git-upload-pack page shows it run as another user through --upload-pack, an option git fetch takes too.

The shorter route is an agent that never commits: it edits the checkout, and the harness commits from outside the box.

Neither route covers hooks that live in the tree. Husky points core.hooksPath into .husky and keeps the hook bodies as tracked files such as .husky/pre-commit, so an edit there runs on the next commit in any checkout of that branch where husky is installed, and in the second route that commit is the harness commit itself, outside the box. Committing with the hooks of main instead of the ones in the checkout, and reviewing any diff under the hooks path the way a change to CI is reviewed, closes it.

Thread Thread
 
bloqarl profile image
Carlos (Bloqarl) •

Thanks again, this is gold. The refs point is the one I hadn't thought about: if it can commit, it can move main. I like the shorter route best, the agent never commits, it only edits, and the commit happens from outside after I read the diff. That fits how I already treat its diff like a PR from a stranger. And good call on husky, if the harness commits with the hooks from the checkout, a planted .husky/pre-commit runs outside the box. Committing with main's hooks and reviewing anything under the hooks path like a CI change makes sense.

Thread Thread
 
izgorodin profile image
Edward Izgorodin •

Main's hooks close the hook body but not what the body calls, so "closes it" at the end of my last reply was too strong. The pre-commit that husky init writes runs npm test, and npm test runs the test script from the package.json in the checkout, and through it the test files the agent wrote. That is the "a test can execute code" line from your list, only now running outside the box. lint-staged, the other common hook body, takes its commands from whichever config file sits closest to each staged file in the checkout, and that file can be a JavaScript module that runs when lint-staged loads it.

On git 2.52 this shows up in one commit: with core.hooksPath pointed at a directory outside the checkout whose pre-commit was npm test, the commit ran a test script written into the checkout. With --no-verify that script did not run, but prepare-commit-msg and post-commit still did, and githooks says the first one is not suppressed by that flag. With core.hooksPath pointed at an empty directory, nothing ran.

So the commit made from outside is cleanest with no hooks at all, core.hooksPath pointed at an empty directory for that one command, and the checks the hooks were there for run inside the box, which is where running the agent's tests belongs anyway.

Thread Thread
 
bloqarl profile image
Carlos (Bloqarl) •

Good correction, thanks. So the hook is just a launcher and whatever it calls is still the agent's code. Empty hooksPath for the outside commit and running the tests inside the box makes sense, that's where they belong anyway.

Didn't know prepare-commit-msg still fires with --no-verify, that one would have got me.

Thread Thread
 
izgorodin profile image
Edward Izgorodin •

An empty hooksPath stops only the launchers that sit in the hooks directory, so the "nothing ran" in my last reply held for the setup I measured on git 2.52 and is incomplete as advice.

Since git 2.54 a hook can also be declared in config, with hook.<name>.command and hook.<name>.event. On Git for Windows 2.56 a pre-commit declared that way in the global config ran with core.hooksPath pointed at an empty directory and at /dev/null, which the core.hooksPath entry in git-config still describes as disabling all hooks entirely. One written into the shared config from a task worktree ran in the main worktree with the empty directory. The other one it misses is older: a core.fsmonitor set to a command, which git-config calls the "fsmonitor" hook command, ran on a commit with the empty hooks directory on 2.52 and 2.56 alike.

The fix is still one command with a check in front of it: point GIT_CONFIG_GLOBAL at an empty file and set GIT_CONFIG_NOSYSTEM=1, expect git config --show-origin --get-regexp '^(hook\.|core\.fsmonitor)' to print nothing, and commit with -c core.hooksPath=<empty dir> -c core.fsmonitor=false, passing the author with -c user.name and -c user.email since the global config is out of the way. On Git for Windows 2.52 and 2.56 that sequence ran nothing, against a global config that held a config hook and an fsmonitor command before the swap.

Thread Thread
 
bloqarl profile image
Carlos (Bloqarl) •

Didn't know hooks could be declared in config since 2.54, or that core.fsmonitor counts as one. So the empty hooksPath was never the whole story. A throwaway global config plus the --show-origin check before the commit is a clean way to do it.

Thanks for testing all this on real git versions, really appreciate it.

Collapse
 
max_quimby profile image
Max Quimby •

The taint-analysis framing is the right mental model, and it's underused. The path people miss most is the self-referential one: the agent generates code or a tool result, then reads its own output back in a later step and treats it as trusted because "it came from me." A prompt-injected issue doesn't have to reach a shell directly — it can convince the agent to write a setup script that the agent then runs two steps later. The untrusted text and the execution sink are separated in time, so it doesn't look like injection in any single step.

Your three mitigations line up with ours: box it, no standing secrets, approvals that actually gate the install/write/CI/network sinks. The one I'd add is egress control — even a read-only, sandboxed agent can exfiltrate via a crafted URL in a "helpful" curl. Blocking outbound-by-default to anything that isn't the package registry closes the quietest path. How are you handling network egress inside the box?

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

The self-referential one is a really good point. The injection and the execution end up two steps apart, so no single step looks bad. That's partly why I treat the agent's own diff like a PR from a stranger, including the scripts it wrote itself.

On egress: right now it's the approval gate, anything that goes to the network waits for me. Default-deny with only the package registry allowed is cleaner though, because it doesn't depend on me noticing the tenth prompt. Are you doing that with a proxy inside the container or at the firewall level?

Collapse
 
ai_adam profile image
ai_adam •

Đúng trọng tâm. Mình thấy nhiều team vẫn treat agent như "junior dev siêu tốc" — cho full repo access, hy vọng nó tự sửa bug production. Thực tế agent không có context về business logic, threat model, hay những quyết định architecture được trade-off năm xưa. Mình đã từng xem agent refactor toàn bộ auth module vì "code smell" mà bỏ qua rate-limiting custom viết cho compliance — may mà CI catch được.

Thay vì trust-by-default, mình enforce: agent chỉ đọc file liên quan đến task (định nghĩa qua spec), write-access gated qua PR review bắt buộc, và mọi change chạm security-sensitive path (auth, crypto, secrets handling) phải có human sign-off. Còn việc inject context — dùng summary document thay vì dump cả repo vào context window, vừa tiết kiệm token vừa giảm hallucination.

Một lesson đắt giá: agent giỏi pattern matching, kém reasoning về "why this exists". Document quyết định architecture (ADR) quan trọng không kém code (site: labagent .tech)

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

Thanks! "Good at pattern matching, bad at why this exists" is a great way to put it. That's why writing down why a weird check exists matters so much, the agent can't see the incident behind it.

Collapse
 
intelliflame profile image
Intelliflame •

On approvals without fatigue: we tier actions into auto-run / single-confirm / hard-block, and only tier-1 ever fires without a human in the loop. The bigger unlock was batching — grouping related approvals into one review step cut the flood more than any prompt tweak did. Curious whether you have landed on tiering too, or found another way to keep the prompt count sane.

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

Yup, tiering is pretty much where I ended up too. Read-only goes through, and installs, network, CI and hooks wait for me. Hard-block is a good addition, some things should never even be a prompt, like anything near signer keys.

Batching I haven't tried. How do you group them, per task or per type of action?

Collapse
 
alkaznodemaven profile image
Alexandr Kazmin •

Curious how you handle outbound network access in the sandbox. Keeping secrets out is a good start, but an install script or test can still make arbitrary requests.

Do you restrict egress to known destinations, or rely on approvals for network access?

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

Right now it's approvals, anything that goes to the network waits for me. But you're right that install scripts and tests are where it slips through. When I approve "run the tests" I'm not really approving network access, even if a test makes a request. Default deny with only the package registry allowed is where I want to end up. Have you found a clean way to do that without breaking postinstall scripts that download binaries?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.