Back to skill

Security audit

Butler

Security checks for vulnerabilities and agentic risk

Overview

This is a disclosed local budgeting and dashboard skill with optional sensitive usage collection, and I found no hidden exfiltration, persistence, or destructive behavior.

Install from a reviewed local checkout or pinned commit when possible, use a dedicated private BUTLER_ROOT, do not expose the web port beyond loopback, and only enable transcript scanning, local process inspection, or SSH collection if you are comfortable with those local/remote usage records being read for accounting.

Vulnerability Patterns
  • Insecure DependenciesIntroduces malicious components through unsafe dependency sources
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (1)

T08 · Insecure Dependencies

Warning
Location
README.md:9
Finding

Unpinned Executable Installer and Mutable Skill Source

Content
View full analysis

Vulnerability Details

File Location: README.md:9
Vulnerability Type: Supply-chain risk from unpinned installer and source revisions
Risk Level: Medium

Vulnerable Code

markdown
With the skills CLI: `npx skills add https://github.com/AntreasAntoniou/butler-agent-skill --skill butler`. This installer requires Node/network access; inspect the repository before installing.

Technical Analysis

The documented installation command invokes npx skills without specifying an audited package version and installs Skill content from a mutable GitHub repository reference without pinning a commit.

Consequently, the components executed or installed at installation time are not guaranteed to be the same components reviewed during this audit:

  1. npx may resolve and execute a newer version of the skills npm package.
  2. The GitHub URL may resolve to repository content changed after review.
  3. The command does not verify a cryptographic signature, commit identifier, or checksum before execution or installation.

The warning to inspect the repository reduces accidental risk but does not provide technical integrity enforcement. This is an insecure dependency and installation practice rather than evidence that the currently audited repository contains a malicious payload.

Attack Path

  1. An attacker compromises the npm package publisher, package distribution channel, GitHub account, repository, or default branch.
  2. The attacker publishes a modified installer or changes the repository content after the audited revision.
  3. A user follows the documented npx skills add command.
  4. npx resolves the unpinned package, while the installer retrieves the mutable repository state.
  5. The modified component executes during installation or places attacker-controlled Skill instructions and scripts in the agent’s Skill directory.
  6. Those files can subsequently execute or influence the agent when the installed Skill is loaded or invoked.

Imp

...[truncated 588 chars]

Remediation
View remediation

Remediation Suggestions

  1. Pin the installer to a reviewed package version, for example by using an explicit npx package version rather than resolving the latest release.
  2. Pin the Skill source to an immutable Git commit or signed release tag instead of the repository’s mutable default branch.
  3. Publish expected checksums or signed provenance for release artifacts and verify them before installation.
  4. Prefer a reviewed local clone for sensitive environments:
    • Clone a specific commit.
    • Verify the commit signature or release checksum.
    • Inspect the checked-out files.
    • Install from that local, immutable copy.
  5. Use npm lockfile and integrity metadata where applicable, and document the exact audited installer version and source commit.
  6. Avoid automatically approving package installation prompts from npx; require the operator to confirm the resolved package name and version.
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Supply ChainUnpinned Dependencies, External Script Fetching, Obfuscated Code
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
Findings (28)

Tp4

High
Category
MCP Tool Poisoning
Confidence
96% confidence
Finding

A second independently reported behavior mismatch indicates the skill can modify project/account configuration and ledger state via HTTP endpoints and inspect local processes, while failing to implement the budget-control functions users would expect from its description. In this context, hidden write capabilities plus overstated enforcement can lead to unauthorized state changes, privacy exposure, and unsafe reliance on nonexistent reservation or gating guarantees.

Content

No source excerpt is available for this finding.

Tp4

High
Category
MCP Tool Poisoning
Confidence
96% confidence
Finding

A second independently reported behavior mismatch indicates the skill can modify project/account configuration and ledger state via HTTP endpoints and inspect local processes, while failing to implement the budget-control functions users would expect from its description. In this context, hidden write capabilities plus overstated enforcement can lead to unauthorized state changes, privacy exposure, and unsafe reliance on nonexistent reservation or gating guarantees.

Content

No source excerpt is available for this finding.

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

Scanner excerpt · SKILL.md (reported line 12)May include surrounding context.

md
For a budget check, use `scripts/butler.py gate --project SLUG` or `gpu-gate`: a preview does not reserve GPU capacity. When compute launch is separately author

os.system() or os exec-family call

High
Category
Dangerous Code Execution
Confidence
85% confidence
Finding

os.system() and os exec-family calls run shell commands with the process's full privileges, enabling arbitrary command execution.

Content

Scanner excerpt · scripts/butler.py (reported line 1660)May include surrounding context.

python
def cmd_panel(args):
    os.execv(sys.executable, [sys.executable, str(Path(__file__).parent / "panel.py")])


def cmd_web(args):

os.system() or os exec-family call

High
Category
Dangerous Code Execution
Confidence
85% confidence
Finding

os.system() and os exec-family calls run shell commands with the process's full privileges, enabling arbitrary command execution.

Content

Scanner excerpt · scripts/butler.py (reported line 1664)May include surrounding context.

python
def cmd_web(args):
    os.execv(sys.executable, [sys.executable, str(Path(__file__).parent / "webui.py")])


def main():

Env Variable Harvesting

High
Category
Data Exfiltration
Confidence
60% confidence
Finding

Code enumerates, copies, or searches environment variables for secrets. Bulk environment access can collect credentials unrelated to the skill's stated purpose.

Content

Scanner excerpt · tests/test_gpu_accounting.py (reported line 19)May include surrounding context.

python
def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name)
        self.env = {**os.environ, "BUTLER_ROOT": str(self.root)}
        self.write_project()

    def tearDown(self):

Env Variable Harvesting

High
Category
Data Exfiltration
Confidence
60% confidence
Finding

Code enumerates, copies, or searches environment variables for secrets. Bulk environment access can collect credentials unrelated to the skill's stated purpose.

Content

Scanner excerpt · tests/test_gpu_scopes.py (reported line 27)May include surrounding context.

python
def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name)
        self.env = {**os.environ, "BUTLER_ROOT": str(self.root)}
        self.write_project()

    def tearDown(self):

Env Variable Harvesting

High
Category
Data Exfiltration
Confidence
60% confidence
Finding

Code enumerates, copies, or searches environment variables for secrets. Bulk environment access can collect credentials unrelated to the skill's stated purpose.

Content

Scanner excerpt · tests/test_gpu_ledger_integrity.py (reported line 30)May include surrounding context.

python
self.root = base / "butler"
        home = base / "home"
        home.mkdir(parents=True)
        self.env = {
            **os.environ,
            "BUTLER_ROOT": str(self.root),
            "HOME": str(home),

Env Variable Harvesting

High
Category
Data Exfiltration
Confidence
60% confidence
Finding

Code enumerates, copies, or searches environment variables for secrets. Bulk environment access can collect credentials unrelated to the skill's stated purpose.

Content

Scanner excerpt · tests/test_gpu_reconcile_outcomes.py (reported line 65)May include surrounding context.

python
# -- harness ---------------------------------------------------------------

    def env(self):
        environment = dict(os.environ)
        environment["BUTLER_ROOT"] = str(self.butler_root)
        environment["HOME"] = str(self.home)
        return environment

Env Variable Harvesting

High
Category
Data Exfiltration
Confidence
60% confidence
Finding

Code enumerates, copies, or searches environment variables for secrets. Bulk environment access can collect credentials unrelated to the skill's stated purpose.

Content

Scanner excerpt · tests/test_gpu_usage_import.py (reported line 34)May include surrounding context.

python
self.root = base / "butler"
        self.home = base / "home"
        self.home.mkdir(parents=True)
        self.env = {
            **os.environ,
            "BUTLER_ROOT": str(self.root),
            "HOME": str(self.home),

Rp1

Medium
Category
MCP Rug Pull
Confidence
92% confidence
Finding

The README instructs users to run npx skills add ..., which pulls and executes an unpinned package/tooling path at install time. If the referenced package, its transitive dependencies, or the resolution path is compromised, a user could execute attacker-controlled code during installation with the user's privileges.

Content

No source excerpt is available for this finding.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Confidence
80% confidence
Finding

Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Content

Scanner excerpt · SECURITY.md (reported line 3)May include surrounding context.

md
# Security

This is a local, single-user runtime, not a multi-user service. Never expose its HTTP port to a network, use a reverse proxy without authentication, or run as root. A local process running as you already has access to your state. HTTP Host checks resist DNS rebinding; same-origin JSON mutation checks resist browser CSRF. There is no remote authentication layer.

Keep state and source inputs private. Do not attach real ledgers, transcripts, account caches, machine inventories, tokens, or local paths to public issues. Use synthetic reproductions. Report sensitive vulnerabilities through the repository's private security reporting feature if enabled; otherwise open an issue asking for a private channel without exploit details or private data.

Undeclared Tool Scope

Medium
Category
MCP Least Privilege
Confidence
89% confidence
Finding

The skill declares no explicit tool scope even though it appears to rely on powerful capabilities including environment access, file read/write, network, and shell. In an agent setting, missing capability boundaries increases the chance the skill can access or modify data beyond the user's expectations, especially because the skill text also references optional SSH collection and local process inspection.

Content

No source excerpt is available for this finding.

Description-Behavior Mismatch

Medium
Category
Not specified by scanner
Confidence
96% confidence
Finding

The skill advertises itself as local budgeting and reservation tooling, but this code can reach out over SSH to other machines and collect transcript-derived usage data from ~/.claude/projects remotely. That expands the trust boundary and can expose sensitive metadata or usage records from additional hosts without strong in-band disclosure or per-run consent, making the behavior security-relevant even if not overtly malicious.

Content

No source excerpt is available for this finding.

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · scripts/butler.py (reported line 384)May include surrounding context.

python
script = (REMOTE_COLLECTOR
              .replace("__SINCE__", repr(float(since_epoch)))
              .replace("__WEIGHTS__", json.dumps(config()["weights"])))
    r = sp.run(["ssh", "-o", "ConnectTimeout=6", "-o", "BatchMode=yes", mcfg["ssh"], "python3", "-"],
               input=script, capture_output=True, text=True, timeout=180)
    if r.returncode != 0 or not r.stdout.strip():
        raise RuntimeError((r.stderr or "no output").strip()[-300:])

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · scripts/panel.py (reported line 99)May include surrounding context.

python
for m in re.finditer(r"\|\s*(ttys\d+)\s*\|\s*\*?\*?([^|*]+?)\*?\*?\s*\|\s*([^|]+?)\s*\|",
                                 fleet_md.read_text()):
                names[m.group(1)] = (m.group(2).strip(), m.group(3).strip())
        out = subprocess.run(["ps", "-axo", "pid=,tty=,etime=,command="],
                             capture_output=True, text=True).stdout
        for line in out.splitlines():
            parts = line.split(None, 3)

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · scripts/webui.py (reported line 149)May include surrounding context.

python
for m in re.finditer(r"\|\s*(ttys\d+)\s*\|\s*\*?\*?([^|*]+?)\*?\*?\s*\|\s*([^|]+?)\s*\|",
                                 fleet_md.read_text()):
                names[m.group(1)] = (m.group(2).strip(), m.group(3).strip())
        out = subprocess.run(["ps", "-axo", "pid=,tty=,etime=,command="],
                             capture_output=True, text=True).stdout
        for line in out.splitlines():
            parts = line.split(None, 3)

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · tests/test_gpu_accounting.py (reported line 41)May include surrounding context.

python
path.write_text(json.dumps(project))

    def run_cli(self, *args):
        return subprocess.run(
            [sys.executable, str(CLI), *args], env=self.env,
            text=True, capture_output=True,
        )

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · tests/test_gpu_ledger_integrity.py (reported line 70)May include surrounding context.

python
path.write_text(json.dumps(project))

    def run_cli(self, *args):
        return subprocess.run(
            [sys.executable, str(CLI), *args], env=self.env,
            text=True, capture_output=True,
        )

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · tests/test_gpu_scopes.py (reported line 79)May include surrounding context.

python
path.write_text(json.dumps(project))

    def run_cli(self, *args):
        return subprocess.run(
            [sys.executable, str(CLI), *args], env=self.env,
            text=True, capture_output=True,
        )

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · tests/test_gpu_usage_import.py (reported line 70)May include surrounding context.

python
path.write_text(json.dumps(project))

    def run_cli(self, *args):
        return subprocess.run(
            [sys.executable, str(CLI), *args], env=self.env,
            text=True, capture_output=True,
        )

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · tests/test_gpu_accounting.py (reported line 68)May include surrounding context.

python
def test_parallel_reservations_are_admitted_atomically(self):
        base = [sys.executable, str(CLI), "gpu-reserve", "--project", "synthetic-project",
                "--gpus", "1", "--hours", "8"]
        procs = [subprocess.Popen(base + ["--job", f"job-{i}"], env=self.env,
                                  text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
                 for i in range(6)]
        results = [p.communicate() + (p.returncode,) for p in procs]

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · tests/test_gpu_reconcile_outcomes.py (reported line 71)May include surrounding context.

python
return environment

    def butler(self, *args, expect=0):
        proc = subprocess.run([sys.executable, str(BUTLER), *args],
                              env=self.env(), capture_output=True, text=True)
        self.assertEqual(proc.returncode, expect,
                         f"butler {' '.join(args)}\n{proc.stdout}\n{proc.stderr}")

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · tests/test_gpu_reconcile_outcomes.py (reported line 84)May include surrounding context.

python
"""Evaluate an expression inside butler's module namespace, under the temp root."""
        source = (f"import json, sys; sys.path.insert(0, {str(SCRIPTS)!r}); "
                  f"import butler; print(json.dumps({expression}))")
        proc = subprocess.run([sys.executable, "-c", source],
                              env=self.env(), capture_output=True, text=True)
        self.assertEqual(proc.returncode, 0, proc.stderr)
        return json.loads(proc.stdout)

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · tests/test_gpu_reconcile_outcomes.py (reported line 227)May include surrounding context.

python
def test_unknown_outcomes_are_still_refused(self):
        self.reserve("infra-job")
        proc = subprocess.run(
            [sys.executable, str(BUTLER), "gpu-reconcile", "--project", PROJECT,
             "--reservation-id", "infra-job", "--actual-gpu-hours", "1",
             "--outcome", "infrastructure"],

Static analysis

Detected: suspicious.dynamic_code_execution

Dynamic code execution detected.

Critical
Code
suspicious.dynamic_code_execution
Location
tests/test_runtime.py:73