Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
163943 stories
·
33 followers

Cheap Code, Expensive Confidence

1 Share

I recently used a coding agent to build Task Viewer, a small community helper for IBM Bob that we use internally at IBM. It’s a convenience plug-in to Bob, with no critical production role. Despite helping some users internally, for me it’s a real-world experiment on a project to find out how much of the implementation I can delegate. How much to delegate also depends on what the person doing the task still needs to learn. Someone familiar with the application may already know how to check the result, while a developer learning the system may need to work through part of the implementation to develop that understanding.

Even when I hand all the coding to an agent, my basic requirements for myself are to check functionality and the design manually. I want to use the application and see whether it behaves the way I intended. Does the design make sense when I actually have to find something? Is the information presented in a way that I can follow? Those are questions I still need to spend time with, even when the application is small and the implementation was largely delegated.

For Task Viewer, I also created plenty of UI tests to check that the data appeared correctly. That gave me a way to repeat some of the checks as the application changed. There was still a separate question about the calculations, though. Seeing a number in the right place on the screen does not tell me whether it’s the right number. I ran SQL queries against the underlying data to check the math, going back to the records the application was supposed to represent.

There is quite a bit of understanding involved in that seemingly ordinary step. To check a calculation, you need to know what the result is supposed to mean and which records should contribute to it. You need some expectation against which to compare what the application displays. Thankfully, I had help from the team in uncovering relationships in the data that I would not have been able to derive or even explain on my own.

For this particular application, I could inspect both its behavior and the data behind it, and I had people to turn to when my own understanding was incomplete. Its limited role also gave me room to experiment without making the result part of a production system. Building that confidence took time, because I had to check the behavior, establish what the numbers should mean, and resolve the questions I could not answer myself. All of that, for me, belongs in the assessment of how much coding I could delegate.

This is why I find descriptions such as “The agent got me 80% of the way there” increasingly difficult to interpret. I understand the feeling behind them. You have something running, most of the interface exists, and it looks as though the remaining effort should be the smaller part. But a calculation based on the wrong records may look perfectly reasonable until you find a way to check it. Before correcting anything, you have to discover where your understanding and the application’s behavior diverge. The agent may help with that investigation too, although the amount of code it has already produced tells you very little about how long it will take.

DORA’s analysis of 1,110 responses from Google software engineers describes some of the time saved during code generation being used for auditing and verification, alongside productivity benefits. I would expect to check an application I had implemented myself too, so any comparison needs to include the time it takes to reach a result we are prepared to use. Otherwise, we can make agentic development look unusually expensive by counting checks we leave out of the human version, or unusually cheap by stopping the clock when the agent says it has finished.

Some of that effort can make later changes easier to accept. Once a UI test exercises an important behavior, it can keep doing so as the application changes, and a calculation checked against a small set of records can become a regression test. I wrote about this in my earlier article on the right amount of specification. Turning an expectation into an executable check lets us reuse some of the thinking that went into it. We still have to establish that expectation, though. If the application and its tests share the same mistaken interpretation, running the tests more often will keep confirming the same mistake.

For Task Viewer, those expectations depended on relationships in the data that colleagues helped me understand. I knew how to run a query, but I still needed their explanations before I could judge what it should return. Looking only at the time I spent checking would leave out the knowledge they brought to those conversations, just as looking only at the generated code would leave out my checks. The confidence we need to accept a change can depend on several people understanding different parts of the system.

When someone is still learning one of those parts, the implementation itself may be how they begin to understand it. Following a value from the database to the screen gives them reasons to ask why certain records are selected and how the application uses them. If an agent completes that whole path for them, they may reach a working feature without having answered those questions themselves. The same task can be a sensible delegation for an experienced engineer and an important learning opportunity for someone else.

Shen and Tamkin’s study about how AI impacts skill formation reports on a randomized experiment with 52 developers learning an unfamiliar Python library. Participants with AI assistance scored lower on an immediate assessment of their understanding. But the researchers also observed that those participants who stayed actively engaged with the AI had better learning outcomes, although that observation does not establish which way of using AI led to better learning. A short exercise cannot tell us how an engineer develops over several years, but it gives us a reason to look beyond whether the assigned feature was completed when we assess what someone gained from a task.

We can make a decision about autonomy before the agent starts, agreeing on which parts it should carry through on its own and which parts a developer should attempt. Sometimes the reason to limit autonomy is that we do not yet have a convincing way to check the result. Sometimes we can check the result quite easily, but we want the person doing the task to develop a particular skill. In that second case, deliberately keeping part of the implementation with the developer can be the right choice even when the agent could produce acceptable code.

The right choice can probably be made by asking these questions: How much effort will the team need to establish that this result is correct for its intended use? And what does this developer need to learn by doing this part of the task? A feature may be easy for a colleague to verify because they already know the application, while the person implementing it still needs to learn how it works before taking responsibility for future changes. We do not need to turn every routine change into an exercise, but we can choose tasks that give someone a chance to develop understanding they will need again.

Table 1 applies to a particular task, or a part of one, with a particular developer. “Straightforward to verify” means we have a credible way to assess correctness for the intended use. A passing test suite does not settle that question if we are unsure what the tests should assert. When checking is difficult or we do not yet know how to do it, the first step is to make the uncertainty smaller, with help from someone who understands the area. Giving the whole task back to an inexperienced developer would leave that uncertainty unresolved.

Learning value for this developerStraightforward to verifyDifficult or uncertain to verify
Little new learning neededDelegate within scopeAgree on acceptance checks and let the agent implement.Establish evidence firstNarrow the task and resolve unknowns. Delegate the parts you can assess.
Important learning opportunityKeep the learning stepThe developer attempts the relevant reasoning or implementation before seeing a generated solution.Investigate togetherUse a smaller example and an experienced colleague. Limit the agent to specific questions and supporting tasks.
Table 1. Choosing agent autonomy for a specific task

Imagine a new hire adding a “show only open issues” filter to an issue list in a small Java application. An agent could generate the request parameter, the query condition, and the changes to the screen. For someone who already knows the application, that may be a sensible delegation. Someone learning the system also needs to find out which statuses count as open, where the application selects the records, and how the team expects the change to be tested. Discussing those questions with a colleague is part of learning how to contribute to this application.

For this assignment, the developer could follow the existing request through the application and write the first test before asking the agent to implement the filter. Suppose the list shows twenty issues at a time, and the test data contains twenty closed issues followed by five open ones. Selecting “show only open issues” should display those five. If the application first loads twenty issues and then filters them in the browser, the screen will be empty. Working through that difference with a colleague gives the developer an opportunity to understand where filtering and pagination happen. The agent can help with unfamiliar framework code while the developer investigates why the expected issues are missing.

Once the developer can explain the behavior and has a test that catches the mistake, the agent can handle more of the surrounding implementation. A later request to filter by assignee gives them a chance to use what they learned. Can they work out which issues should appear and whether the existing tests cover the combination of filters? Their explanation during review gives the team a basis for deciding how much of that next change to delegate. The first assignment has also given the new hire experience asking about requirements and discussing a change with another developer.

An experienced engineer can also have good reason to limit the agent’s autonomy in an unfamiliar area. I still needed help with Task Viewer’s data, despite being comfortable writing the queries. When a task takes us into a part of the system we do not yet understand, it makes sense to reconsider how much to hand over. A team can make verification easier by improving its tests and sharing what its members know. It can also choose to preserve a learning opportunity after verification has become easy, because another person is going to need that understanding on future changes.

A developer who has been asked to learn through a task needs time to follow the behavior through the application and someone to discuss it with. If we agree on that purpose and then judge the result only by how quickly the feature appeared, we make it difficult for them to keep the learning step for themselves. We may also leave more of the checking with the same experienced colleagues, while giving others fewer chances to develop the knowledge needed to share it.

Before starting, the developer and the colleague supporting them can agree on what the agent will handle, what the developer will do and why, and what would justify changing that division of labor on a later task. For the status-filter example, this means allowing time for the developer to trace the request and write the first test before the agent implements the filter. As that understanding develops, they can revisit the division on a related change.

With Task Viewer, I could delegate much of the coding because I had ways to check the result and colleagues who could help where my understanding was incomplete. I want us to keep creating those conditions as we ask agents to do more. Better checks can reduce the effort needed to accept a change, while time spent learning can help another developer become able to make that judgment. Sometimes that will mean deliberately leaving the first test and investigation with a person, even though the agent could finish them. When we expect that person to take responsibility for the next change, giving them the opportunity to understand this one belongs in our decision about how much to delegate.



Read the whole story
alvinashcraft
14 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Prompts That Can Write Themselves: A Kotlin Experiment

1 Share

TL;DR

Koog Agents Optimization Library is a research library developed by JetBrains Research for optimizing the prompts of Koog agents. With it, you write a first version of each instruction, mark the steps worth tuning, provide the library with a training set, and specify a way to score the agent’s output. The library then runs your agent, learns from what happens, and hands back better instructions as a separate JSON file you can read, review, and commit. No rewrite of the agent is needed.

The examples in this post use GEPA, a prompt optimizer that learns from your agent’s failures and successes. In this post, we walk you through examples running on the privacy benchmark PUPA. GEPA improved our agent’s score from 81.19 to 94.22.

👉 Koog Agent Optimization Library (GitHub) 

👉Docs

👉 Examples

An agent to optimize

Koog is JetBrains’ Kotlin framework for building agents. It treats an agent as a program: you declare control flow as a typed graph of nodes and edges, and each step can carry its own prompt, tools, and model.

We have a whole agent written in it: our port of PAPILLON, a privacy pipeline that GEPA uses as one of its benchmarks. It runs on PUPA, a dataset of user queries that carry personal details. The agent has to answer such a query with help from a big external model without handing that model any of those details, so it rewrites the question without the details, sends that version out, and writes the reply from what comes back.

fun pupaStrategy(untrustedModel: LLModel) = strategy("pupa-papillon") {
    val craftRedactedRequest by subgraphWithTask<PupaAgentInput, PupaRedactedRequest>(
        name = "craft_redacted_request",
    ) { input -> REDACT_INSTRUCTION + "\n\nuser_query:\n${input.userQuery}" }

    val callUntrustedModel by subgraph<String, String>(
        name = "untrusted_model",
        llmModel = untrustedModel,
    ) { /* one call to the big model */ }

    val respondToQuery by subgraphWithTask<PupaResponseInput, PupaFinalResponse>(
        name = "respond_to_query",
    ) { input -> RESPOND_INSTRUCTION + "\n\n" + input.rendered() }

    // …plus three small nodes carrying state
}

Only three steps are needed to do the work here: the first rewrites the question, the second calls the big model, and the third writes the reply. Everything else is plumbing. Two of those three steps hold an instruction that a human wrote, and they are the ones you would want an optimizer to take over.

Marking a step as optimizable

The library adds one builder. Swap subgraphWithTask for optimizableSubgraphWithTask on the steps you want tuned, and lift the instruction out of the string it was buried in:

fun pupaStrategy(untrustedModel: LLModel) = strategy("pupa-papillon") {
    val craftRedactedRequest by optimizableSubgraphWithTask<PupaAgentInput, PupaRedactedRequest>(
        name = "craft_redacted_request",
        optimizableInstruction = REDACT_INSTRUCTION,
    ) { _, input -> "user_query:\n${input.userQuery}" }
    
    val callUntrustedModel by subgraph<String, String>(
        name = "untrusted_model",
        llmModel = untrustedModel,
    ) { /* one call to the model */ }

    val respondToQuery by optimizableSubgraphWithTask<PupaResponseInput, PupaFinalResponse>(
        name = "respond_to_query",
        optimizableInstruction = RESPOND_INSTRUCTION,
    ) { _, input -> input.rendered() }

    // …and the same plumbing and wiring as before.
}

With the step marked as “optimizable”, the graph itself remains unchanged. The instruction just moved into a parameter of its own, where an optimizer can find it and replace it. When there is no artifact to load, the step runs on the text you wrote.

Running the optimizer

GEPA runs your agent over the training set, shows a second model what went wrong, and asks it for a better instruction. Then it tries that instruction and repeats it, round after round, within the budget you set. Across those rounds it keeps several candidate instructions alive at once, so it does not drop one that only wins on your hardest items.

The optimizer needs one thing from the agent’s author: a function that, given a finished run, returns a score and a short summary of how it went. The score decides which candidates survive, and the text is what the second model reads. Here, that text is one line of numbers:

PUPA overall score: 0.50. Quality score: 1.00. Privacy score: 0.00. PII leakage ratio: 1.00.

That line, plus what the step itself received and produced, is everything GEPA had to work from when it wrote the instruction in the next section.

The rest is just two calls:

val optimizer = GEPAOptimizer(
    gepaFeedback = pupaFeedback,
    reflectionModel = OpenAIModels.Chat.GPT4_1Mini,
    numRollouts = 2426, // a practical start is 6 to 20x your training set
    storagePath = ResilientPath("build/artifacts/pupa-gepa.json"),
)

optimizer.train(session) // the session wraps your agent, dataset, and metric
val optimized = optimizer.loadOptimizedAgent(agent)

train writes the artifact, and loadOptimizedAgent reads it back and hands you a copy of your agent running the new instructions. Every optimizer in the library has those same two methods, so switching between them leaves the surrounding code alone.

What GEPA wrote

The step that strips the personal details started with this instruction:

Given a private user query, create a privacy-preserving request for a powerful external LLM.

The LLM may assist without learning private information about the user.

Two lines, deliberately vague. It says nothing about how much redaction would be enough.

By the end of the run, that same step was running a 4,456-character instruction. Here are three pieces of it:

2. Redaction and Generalization
   - Replace all such information with broad, generic placeholders or neutral terms
     (e.g., "the minor," "the course," "the company," "a noted musician," "two large
     urban communities," "the agency," "the program," or "a fellowship honoring an
     individual").
[...]
Additional Domain-Specific Considerations:
- For academic or educational content, avoid exposing real professor names, specific
  course titles or codes, individual student names, or direct internal feedback. Use
  neutral terms like "the minor," "the course," "professors," "students," and "mentors."
[...]
Overall Strategy:
Create a balanced abstraction that ensures zero leakage of private or identifiable data
while preserving the user's original task in sufficient detail for accurate execution
by a LLM.

Every detail in the instruction above came out of the runs, not from us. You would probably not sit down and write the placeholder vocabulary, the rule about professor names and course codes, or the overall strategy at the end. That strategy states a trade-off we never mentioned: keep the leakage at zero, but leave enough detail to do the job.

Results

We wanted to know whether our GEPA in Koog does what it is supposed to, so we ran it on the benchmarks the project itself uses and checked whether the agents performed better.

Take PUPA, for example, the benchmark that the above agent runs on. Its baseline was already 81.19, with little room left above it, but optimization still improved it by 13 points to 94.22. Both numbers are the mean of five runs, and the gain is statistically significant.

The rest of the benchmarks, including how our numbers line up with the original implementation, can be found in the docs.

Three more optimizers

Besides GEPA, the library implements three more optimizers behind the same interface:

  • BootstrapFewShot mines demonstrations out of your successful runs. It makes no LLM calls beyond running your own agent, making it the cheapest option to try first.
  • MIPROv2 searches across instructions and demonstrations.
  • ACE grows a playbook of insights that rides along with the system prompt, for when you want the agent to accumulate knowledge rather than a better-worded instruction.

Swapping between them is one constructor; the train and loadOptimizedAgent calls around it stay as they are. Writing your own means implementing those same two methods, and all four of ours fit behind them without an escape hatch.

Where to find it

The library, its documentation, and the full benchmark results are in the open-source repository. The optimizable-subgraph support it relies on currently lives in a fork of Koog; we expect it to land upstream.

Read the whole story
alvinashcraft
20 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Looking at a site like an agent — identifying semantics in styling

1 Share
Following up on my previous post about content that loads through JavaScript, this post looks at another agent-readability problem: styling that carries meaning. When gray text marks a module as excluded from a product tier, or a check-mark icon means included, that meaning disappears once an agent fetches the page and converts it to Markdown. I walk through how to compare the human view of a page against the agent view, and why complex tables and elaborate HTML layouts matter less when agents read basic Markdown.
Read the whole story
alvinashcraft
2 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

A JSON schema Is not an AI governance layer

1 Share
The AI governance argument is happening one layer above the code that actually stops your agent.
Read the whole story
alvinashcraft
2 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Mocking Dependencies for Local Development

1 Share

Every service you own eventually grows a tail of other services it talks to. A gateway that started out calling one payment processor picks up a CRM integration, then a calendar sync, then a compliance check, and a year later nobody can run it on their laptop without either a VPN into staging or a pile of commented-out API calls.

We lose so much time to this. The usual version of the story: you clone a service, run it, and it immediately throws on a call to some third-party API you don’t have credentials for. You go hunting through the codebase for every place that service reaches out. You stub out what you can find, miss one, and spend the rest of the day chasing a stack trace that only shows up two services downstream from the one you’re working on.

The annoying part isn’t mocking one dependency. It’s finding all of them, including the ones that don’t live in the repo you’re looking at.

In this walkthrough, I’m setting up an existing gateway service for local development using Claude Code and the Postman plugin. The plugin ships with a set of skills on GitHub that cover exactly this kind of work: one to find what a service depends on, one to mock those dependencies, and one to check that the mocks hold up.

Find out what a service calls

I’d already wired up a handful of third-party integrations on this gateway: Nylas for calendar sync, DocuSign, PayPal, Notion, and a text-filtering service called Pergomalum. Here’s the prompt I gave Claude:

Set up local Postman mocks for this service. I want to test the gateway without hitting any of those real external services.

Before Claude touched anything, it used the api-discovery skill to check the Context Graph for every integration the gateway calls, along with the endpoints and schemas each one exposes. That’s worth noting: this isn’t a grep across the codebase for API client imports. The Context Graph already knows which services exist and how they connect, so Claude got a real dependency list back instead of a guess built from whatever files happened to be open.

Mock each dependency without standing up a server

With the dependency list in hand, Claude moved on to the api-mocking skill, one integration at a time: Pergomalum, then DocuSign, then PayPal, then Notion, then Nylas.

The api-mocking skill can push a mock to Postman’s cloud for a durable URL, but for local work it uses Postman Local Mock Servers instead, a Git-backed mock that lives right next to your collections in the repo. Each one gets a folder under postman/mocks/, with a config file and a JavaScript handler per scenario:

# postman/mocks/pergomalum/config.yaml
name: pergomalum-mock
port: 4511
defaultScenario: happy-path
scenarios:
  - name: happy-path
    file: happy-path.js
  - name: rate-limited
    file: rate-limited.js
// postman/mocks/pergomalum/happy-path.js
// @endpoint POST /v1/filter
function handleRequest(request) {
  return {
    status: 200,
    body: {
      flagged: false,
      categories: [],
      checked_at: new Date().toISOString(),
    },
  };
}

Because the mock is files in the repo, it travels with the code. Commit the postman/mocks/ folder and the next person who clones the gateway gets the same mocks, on the same ports, without rebuilding anything.

Wire the gateway to point at the mocks

A mock running on localhost doesn’t help until something calls it. Claude’s next step was updating the gateway’s environment configuration so each integration’s base URL pointed at its local mock port instead of the real API:

PERGOMALUM_API_URL=http://localhost:4511
DOCUSIGN_API_URL=http://localhost:4512
PAYPAL_API_URL=http://localhost:4513
NOTION_API_URL=http://localhost:4514
NYLAS_API_URL=http://localhost:4515

This is the same override pattern Postman’s own write-up on swapping external dependencies for local mocks describes: point the environment variable your service already reads for that integration at localhost, and nothing in the gateway’s code has to change at all.

Verify the mocks behave like the real thing

A mock that starts without error isn’t the same as a mock that behaves correctly. Once all five were running on their expected ports, Claude used the api-testing skill’s ad-hoc request capability to check each one directly. That included the less obvious cases: PATCH status transitions, and 404, 422, and 401 overrides for the failure paths. Postman’s mock servers support exactly this kind of status-code and scenario override, which is what makes it possible to test how the gateway handles a Notion 401 or a PayPal 422 without waiting for one to happen for real.

What the Context Graph catches that a repo can’t

All five mocks passed verification, but Claude flagged two more dependencies the first pass hadn’t covered: calendar-sync-service, which calls Nylas directly, and demographic-service, which calls Pergomalum directly. Neither was covered by the mocks built so far, because those mocks matched the gateway’s own call patterns, not the patterns these two other services use.

What matters isn’t that there were two more dependencies. It’s that calendar-sync-service doesn’t exist in this local checkout at all. It lives in another repository I hadn’t cloned. The only reason Claude knew it was there, and knew it called Nylas, was the Context Graph. A local search or a dependency scanner limited to the current filesystem would have missed it completely.

Claude wired both: demographic-service reads the existing Pergomalum mock, and calendar-sync-service gets pointed at the existing Nylas mock too, with scenario files extended to cover each service’s specific call patterns. That brought the total to seven local mocks covering the full chain, not five.

Turn the wiring into pull requests

Mocks that only exist on one laptop aren’t worth much. Since the dependency wiring touched code in more than one repository, Claude opened two pull requests: one in the healthcare-demographics repo, one in the healthcare-infra repo. Each contained the environment variable overrides and the committed postman/mocks/ folder for that service. Anyone pulling those branches gets the exact same local setup, on the same ports, without rerunning any of this discovery work themselves.

Where this leaves you

None of the individual pieces here are new. Mock servers have let developers build against an unbuilt or unreliable API for a long time. What’s different is that Claude didn’t have to be told where every dependency lived. The Context Graph found that, including the one dependency sitting in a repo nobody had checked out. The plugin’s skills took it from “here’s what calls what” to a running local environment, without me carrying that information between steps by hand.

Worth saying plainly: this gets you a working local environment, not a green light to ship. You still need to test against the real services before anything goes out the door. What it does buy you is an afternoon back. It’s the one you’d otherwise spend hunting for every place a service reaches outside itself, before you can even start on the change you actually wanted to make.

If you want to try this yourself, install the Postman plugin for Claude Code and ask it to set up local mocks for a service you own. Read more about how Postman works as a local development environment first if you haven’t used Local Mocks before.

Resources

The post Mocking Dependencies for Local Development appeared first on Postman Blog.

Read the whole story
alvinashcraft
2 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Coding Agent Horror Stories: The CVE That Did Not Exist

1 Share

This is Part 6 of our AI Coding Agent Horror Stories series, where we look at real security incidents involving AI coding agents and at how Docker Sandboxes contain agent execution at the boundary rather than at the command line.

Every story in this series so far has been about an agent reaching for something it should not have touched. We have seen agents wander into home directories, drop production databases, leak credentials, and run shell commands that did something very different from what the developer expected. In each case, the damage came from the agent having more reach than it needed.

This story is the opposite. The agent in this one reached nothing at all, because it was deliberately built so that it could not. And yet, on the night in question, it was still the most dangerous thing in the pipeline. 

This story was shared by Pratik Kumar Singh as part of our Agent Hall of Shame contest on social media.

Today’s Horror Story: The Review That Made Everything Up

A platform engineer built a code review bot for his team. The idea was simple and useful. Every time someone pushes code, the bot reads what changed and writes a review into the pipeline, the same kind of first pass a human teammate would give before a person looks at the change.

He named the bot BuaJi. In India, your bua is your father’s sister, and in many families she is the relative who inspects everything you do and always manages to find something wrong with it. For a code reviewer, it is a fitting name.

What made BuaJi interesting was how carefully it was set up. The bot runs on local models through Ollama, which means the model weights sit on the engineer’s own hardware and no request ever goes out to an AI vendor. It also runs as a job in his CI pipeline, triggered whenever someone pushes code. Taken together, this makes the whole setup self-hosted. The code it reads is company source code, and with this design none of it ever leaves the building. That was a deliberate choice, because his company takes that seriously.

He also gave BuaJi one firm rule: it can read, and it can comment, and that is all. It cannot run terraform apply, which is the command that creates or changes real cloud infrastructure. It cannot run kubectl apply, which changes what is actually running in the Kubernetes cluster. And it cannot push a commit, so it has no way to change the code itself. Those are the commands that change the real world, and BuaJi was given none of them. On paper, this is exactly how you would want an AI reviewer to be built.

The version of BuaJi in the companion repo is a deliberately minimal reviewer. It checks Go files against a handful of style rules. The report below is the kind of free-form finding any reviewer produces once you let it comment in prose rather than pass/fail, which is exactly the direction these bots get pushed in practice.

The change he pushed that night

His product ships with a bundled copy of PostgreSQL. Older PostgreSQL releases have published vulnerabilities with CVE numbers attached, and upgrading to a newer patch release is how you make those known issues go away. So he bumped the version.

- POSTGRES_VERSION=16.11
+ POSTGRES_VERSION=16.13

This is about as boring and as responsible as a code change gets. It touches a few lines, it changes a version number, and its only purpose is to close security holes that are already public. Any reviewer, human or machine, should look at it and approve it in a few seconds. There is nothing in that diff to be suspicious about, and that is precisely why what happened next matters.

What came back

BuaJi responded with roughly fifteen hundred words rejecting the change. For context, a normal review comment is a sentence or two.

The report described the version bump as an “architectural takeover,” a structural change smuggled into the codebase under the cover of routine maintenance. It claimed that the new PostgreSQL build was a vehicle for sending internal data to a third-party company, and it named that company outright. The company does not exist.

It went further. It referred to specific Kubernetes resources by name, as though it had inspected the cluster and found them. Those resources had never existed, and BuaJi had no access to the cluster that would have let it look. It then wrote Go scripts to “isolate the infected PostgreSQL nodes,” even though nothing was infected. Finally, it told the engineer to revoke all IAM access immediately, an action that would lock out every person in the organization, including the people who would be needed to fix any real problem.

Holding the whole argument together was a CVE number that has never been issued by anyone. Of everything in the report, this is the detail worth pausing on. A CVE identifier is one of the easiest things in security to verify, since you can look it up in a public registry in a few seconds. The bot invented a fact in the one place where invented facts are easiest to catch.

And yet the report looked completely convincing. The Markdown was clean and well structured. The YAML it proposed would have parsed without errors. The tone was urgent and confident, the way a senior security engineer writes when they have found something serious and need you to act before it gets worse. The engineer spent about two minutes reading the CI logs, realized that none of it was real, and went back to what he was doing.

In this issue, you’ll learn:

  • Why a read-only agent that only writes comments can still get your cluster taken down
  • What makes a fabricated security finding harder to dismiss than an obviously broken one
  • Why agent output belongs in the untrusted column, the same as agent input
  • How to move a real AI code reviewer into a Docker Sandbox, step by step, and what that does and does not protect you from
image2 1

Caption: Comic illustrating how a confident, well-formatted security report from an AI reviewer can be entirely fabricated, and what happens when somebody acts on it.

The Problem

It is important to say clearly that the read-only rule did its job. BuaJi could not carry out any of its own recommendations, and it never had any access to the cluster. Because of that, the entire episode cost the engineer two minutes of his evening and nothing more.

The trouble is that the rule only controlled what BuaJi could do. Nobody had thought about what BuaJi could say. A read-only agent is still allowed to tell people what to do, and the person reading its output usually has exactly the access that the agent was denied. BuaJi could not run the scripts it wrote, but it could write them down and hand them to someone who could.

This engineer caught the problem because he knows his own cluster well. He knew those Kubernetes resources were not real and that nothing had been compromised. Now imagine a newer engineer on call at two in the morning, half awake, looking at a long and alarming security report in the pipeline. Revoking all IAM access in production in the middle of the night is an outage all by itself. Running an unreviewed Go script against your database nodes could be considerably worse. In both scenarios, BuaJi would not have done anything. A person would have done all of it, in good faith, because the report told them to.

What makes this so hard to defend against is that no permission model would have flagged it, because no permission was ever violated. The agent stayed within its boundary. The engineer had every right to run those commands on his own systems. The only thing in the entire chain that was false was the content of the report.

image1 1

How Docker Sandboxes Contain This

Here’s the honest part: a sandbox is not a fact-checker. No microVM boundary will ever make a review comment accurate. What a boundary changes is what that fabricated output can do once it exists.

Remember where BuaJi’s read-only rule actually lived, in a convention. One engineer configured it in his own pipeline and has to keep remembering to leave it in place. Rules like that hold until someone is in a hurry, or a new teammate copies the config and drops a section they did not understand, or a small exception made for a good reason quietly becomes permanent. None of this needs bad intent; it is just how real teams work.

Docker Sandboxes run agents inside isolated microVMs each with its own kernel, its own filesystem, and a network that denies everything by default. An agent inside one can invent anything it likes, but the invention has nowhere to go: no credentials to use, no cluster to reach, no commit to push, unless someone allowed that access ahead of time. The read-only rule stops being something a person remembers and becomes a property of the environment. And when a report claims it inspected your cluster, the policy log is your proof it never could have.

That protects you from the agent. It does nothing for the human who reads the output, and no sandbox ever will. For that half, treat agent review output the way you already treat agent input, untrusted until something checks it: label it so it doesn’t carry a colleague’s weight, verify any CVE numbers, and never run agent-written remediation until a person has read and understood it.

This is the same pattern as the sandbox escapes Pillar published in July. The boundary itself was never broken; a file was written inside the sandbox, and a tool outside later trusted it. An agent’s output is always an input to whatever reads it next and somewhere downstream, something trusts what it receives. Very often, that something is a person.

Let’s move BuaJi into a Docker Sandbox, one step at a time. By the end, the read-only rule is enforced by the environment, not by memory.

How BuaJi runs today

BuaJi is a small Python script, buaji.py. Its GitLab job installs Ollama on a GPU runner, starts ollama serve, loads the model named in LLM, and runs the script.

The script reads review rules from rules.md and finds the changed files with git diff HEAD^ HEAD --name-only. It sends each file and rule to Ollama at http://localhost:11434 and prints the answers to the CI log.

The catch is that everything shares one container: the model, the script, the checkout and every CI variable, including CI_JOB_TOKEN. BuaJi is read-only only because the script happens not to write anything.

What we want instead

We split the job in two. The model stays on the runner next to the GPU. BuaJi moves into a microVM that can read the code, talk to the model, and nothing else.

image3 1

One open path to the model, everything else blocked, and a CVE check on the way out.

Step 1: Let BuaJi find Ollama somewhere other than localhost

Inside a sandbox, localhost is the sandbox itself. So we read the Ollama address from an environment variable and keep the old value as the default:

-    url = "http://localhost:11434/api/generate"
+    base = os.environ.get("OLLAMA_URL", "http://localhost:11434")
+    url = f"{base}/api/generate"

Outside a sandbox, nothing changes. Inside one, we point OLLAMA_URL at the runner.

Step 2: Bake BuaJi’s dependencies into a sandbox image, once

Today the job runs apt-get and pip3 install on every run, which needs internet access each time.

Instead, build a small image once with everything BuaJi needs, so the sandbox never downloads anything:

FROM docker/sandbox-templates:shell
USER root
RUN apt-get update \
 && apt-get install -y --no-install-recommends python3 python3-requests git \
 && rm -rf /var/lib/apt/lists/*
USER agent

Push it to your registry and store its name in a CI variable called BUAJI_TEMPLATE.

Step 3: Keep the model on the runner

Ollama stays on the runner with the GPU. Install it once on the runner instead of running curl | sh in every pipeline.

ollama serve > /tmp/ollama.log 2>&1 &
sleep 2
ollama pull "$LLM"

The model never enters the sandbox. BuaJi reaches it through one port, opened in Step 5.

Step 4: Create the sandbox with the code mounted read-only

This step decides what BuaJi can see and change.

mkdir -p "$CI_BUILDS_DIR/buaji-scratch"

sbx run -d --name "$SBX_NAME" -t "$BUAJI_TEMPLATE" \
  shell "$CI_BUILDS_DIR/buaji-scratch" "$CI_PROJECT_DIR:ro"

Here is what each part does:

  • --name gives each pipeline its own sandbox.
  • -t uses the image from Step 2.
  • shell is the agent type. BuaJi is a plain script, so a shell sandbox is enough.
  • buaji-scratch is an empty folder, the only place BuaJi can write.
  • :ro mounts the repository read-only. BuaJi can read files and run git diff, but cannot change anything.

Notice what is missing: no GitLab token, no kubeconfig, no cloud credentials. Even if BuaJi tried kubectl or terraform, it would have nothing to connect with.

Step 5: Open one network path, to the model

Sandboxes block all outgoing traffic by default. We open one path, for this sandbox only. (This assumes the runner’s default policy is deny-all; on a balanced or open default, set the default to deny-all first.)

sbx policy allow network --sandbox "$SBX_NAME" localhost:11434

The allow target (localhost:11434) and the address BuaJi dials (host.docker.internal:11434) are two names for the same runner port,  the first from the runner’s point of view, the second from inside the microVM. Kubernetes, your cloud APIs, and GitLab all stay blocked. If a report claims BuaJi “inspected the cluster,” this rule proves it could not have.

Kubernetes, your cloud APIs and GitLab all stay blocked. If a report claims BuaJi “inspected the cluster,” this rule proves it could not have.

Step 6: Run the review

BuaJi runs as before, just in a new place with only two variables:

sbx exec -w "$CI_PROJECT_DIR" \
  -e LLM="$LLM" \
  -e OLLAMA_URL=http://host.docker.internal:11434 \
  "buaji-$CI_PIPELINE_ID" python3 buaji.py | tee buaji-review.log

  • -w starts in the checkout, where buaji.py expects rules.md.
  • The two -e flags are the only variables BuaJi can see.
  • tee saves the review to a file on the runner, outside the sandbox.

The review is now just text. BuaJi cannot post it, act on it, or change what happens to it next.

Step 7: Check every CVE number before anyone acts on the report

This check would have caught the fake report in seconds. It runs on the runner, so BuaJi cannot skip it.

It looks up every CVE ID in the NVD database and fails the job if any of them do not exist:

unverified=""
for id in $(grep -oE 'CVE-[0-9]{4}-[0-9]{4,}' buaji-review.log | sort -u); do
  count=$(curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=$id" \
    | jq -r '.totalResults // 0')
  [ "$count" = "0" ] && unverified="$unverified $id"
done

if [ -n "$unverified" ]; then
  echo "&#x26a0;&#xfe0f; BuaJi output mentions CVE IDs that do not exist:$unverified"
  echo "&#x26a0;&#xfe0f; Treat this review as unverified. Do not run any commands it suggests."
  exit 1
fi

Because the job is allow_failure: true, GitLab marks it “passed with warnings.” The pipeline keeps moving, but nobody mistakes the report for a trusted one. This degrades the signal, it does not block the human. The warning is a nudge, not a guardrail ~ a tired on-call engineer can still scroll past it..

It won’t catch every made-up detail, like a fake Kubernetes resource name. But it catches the one that holds most fake reports together.

Step 8: Check what BuaJi tried to reach, then clean up

Finally, review the policy log and remove the sandbox:

sbx policy log "$SBX_NAME"
sbx stop "buaji-$CI_PIPELINE_ID"
sbx rm -f "buaji-$CI_PIPELINE_ID"

The log shows every host BuaJi tried to reach. On a deny-all runner, a healthy run shows only the model on port 11434. Anything else is a signal worth looking at.

Removing the sandbox means every pipeline starts clean.

The complete pipeline job

Put together, the buaji job in .gitlab-ci.yml looks like this:

# BuaJi, moved into a Docker Sandbox. The read-only rule is now a property of
# the environment, not a convention in the script: the code is mounted :ro, no
# credentials are passed in, and only one network path (to the model) is open.
buaji:
  stage: buaji
  tags:
    - self-hosted-gpu-kvm   # GPU for Ollama, KVM for sbx microVMs
  variables:
    LLM: gemma
    GIT_DEPTH: "2"          # buaji.py diffs HEAD^ against HEAD
    SBX_NAME: buaji-$CI_PIPELINE_ID
  before_script:
    - ollama serve > /tmp/ollama.log 2>&1 &
    - sleep 2
    - ollama pull "$LLM"
    - mkdir -p "$CI_BUILDS_DIR/buaji-scratch"
    - sbx run -d --name "$SBX_NAME" -t "$BUAJI_TEMPLATE"
        shell "$CI_BUILDS_DIR/buaji-scratch" "$CI_PROJECT_DIR:ro"
    - sbx policy allow network --sandbox "$SBX_NAME" localhost:11434
  script:
    - sbx exec -w "$CI_PROJECT_DIR"
        -e LLM="$LLM" -e OLLAMA_URL=http://host.docker.internal:11434
        "$SBX_NAME" python3 buaji.py | tee buaji-review.log
    - ./scripts/verify-cves.sh buaji-review.log
  after_script:
    - sbx policy log "$SBX_NAME"
    - sbx stop "$SBX_NAME" && sbx rm -f "$SBX_NAME"
  allow_failure: true
  only:
    changes:
      - "**/*.go"

The runner needs a GPU and KVM, which usually means self-hosted. GIT_DEPTH: "2" gives buaji.py the two commits it compares. The cleanup sits in after_script, so it runs even when the job fails.

What changed

Before, the read-only rule lived in the script. Now it lives in the environment: a read-only checkout, one network path, no credentials, and only checked text coming out.

What hasn’t changed is the human reading the output. The sandbox stops BuaJi from running its scripts. Only the CVE check and a careful reader stop someone else from running them.

Best Practices

Treat an agent’s security finding as a hypothesis, not a conclusion. A report from an AI reviewer is a claim with good formatting. When a human reviewer flags a security issue, there is an implied step where they checked their reasoning. With an agent, that step has not happened, so you have to do it yourself.

Verify every CVE number before acting on it. This is the cheapest check in the whole process and it catches the single most convincing part of a fabricated report. Automate it, as in Step 7, so it runs on every review without anyone having to remember.

Never run agent-written remediation without reviewing it first. Scripts and commands supplied by an agent are the point where a false finding turns into a real incident. Read them with the same care you would give an unknown script from the internet.

Make read-only a property of the environment, not a habit. A restriction you enforce by hand in a pipeline config is always one rushed change away from disappearing. Mount the code read-only, pass in no credentials, and open only the network paths the agent needs, as in Steps 4 to 6.

Clearly label agent review output as agent output. When a bot’s review looks identical to one from a senior engineer, it borrows authority it has not earned. A clear label helps readers apply the right level of skepticism.

Keep an eye on your signal-to-noise ratio. The curl story is the warning here. A review channel that fills up with noise eventually stops being read, and a review channel nobody reads is worse than having none, because people still assume it is protecting them.

Conclusion

The engineer who built BuaJi was right that the read-only rule protected him, and it genuinely did. BuaJi could not touch the cluster, could not revoke anyone’s access, and could not run a single line of the code it had so carefully written. What the rule could not do was stop BuaJi from asking somebody else to run it.

Every other story in this series has been about an agent reaching too far. This one never reached at all. It stayed inside its boundary, did exactly what it was permitted to do, and still produced a document that would have caused a real outage if the person reading it had been a little more tired, or a little less familiar with their own cluster.

As we give agents more responsibility in our pipelines, containing what they can do is only half the job. A sandbox handles that half. The other half is deciding how much we trust what they tell us, and that part is still up to us.

Read the whole story
alvinashcraft
3 minutes ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories