Back to skill

Security audit

frugal-subagents

Security checks for vulnerabilities and agentic risk

Overview

This is a disclosed cost-control plugin for Claude subagents that runs a local guard hook and bundled file-writing workers, with some implementation hardening caveats but no evidence of malicious behavior.

Before installing, understand that this plugin runs a local Node.js hook whenever Claude Code is about to spawn a subagent, changes missing subagent models to the configured cheaper default, may deny nested or over-budget spawns, and writes small state files in the OS temp directory. The bundled workers can also write research or extraction results to disk, so specify output paths and avoid delegating secrets or sensitive personal data unless you are comfortable storing the results locally.

Vulnerability Patterns
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • 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 (2)

T09 · Insecure Skill Coding Practices

Warning
Location
hooks/guard.js:87
Finding
Non-Atomic Session Counter Allows Concurrent Spawn-Limit Bypass<![CDATA[ ## Vulnerability Details **File Location**: `hooks/guard.js`, lines 87–91 and 122–140 **Vulnerability Type**: Race condition caused by a non-atomic read-check-write operation **Risk Level**: Medium ### Vulnerable Code ```javascript // 2. Per-session spawn budget. Denied calls are not counted. const state = loadState(ev.session_id); const max = parseInt(env('FRUGAL_SUBAGENTS_MAX_SPAWNS', '12'), 10); if (state && Number.isFinite(max) && max > 0 && state.count + 1 > max) { deny(`frugal-subagents: this session has already spawned ${max} subagents (FRUGAL_SUBAGENTS_MAX_SPAWNS). ` + 'Continue with the results you have, or send a follow-up to an agent that is still running ' + 'instead of starting a new one; if more spawns are genuinely needed, ask the user to raise the limit.'); } // Allowed: count it, then either pass through untouched or inject the default. if (state) { state.count += 1; } if (!defaulted) { saveState(state); process.exit(0); } const result = { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput: { ...input, model }, additionalContext: `frugal-subagents: this subagent runs on "${model}" because no model was named. ` + 'Name the model explicitly when a different tier is justified.', }, }; if (state && !state.notified) { state.notified = true; result.systemMessage = `frugal-subagents: subagents spawned without an explicit model run on "${model}" ` + '(FRUGAL_SUBAGENTS_DEFAULT_MODEL). Nested spawns are blocked; ' + `budget ${Number.isFinite(max) && max > 0 ? max : 'unlimited'} spawns per session.`; } saveState(state); out(result); ``` ### Technical Analysis The per-session spawn limit is implemented as a filesystem-backed counter. Each hook process independently: 1. Reads the current counter. 2. Checks whether the next spawn would exceed the limit. 3. Increments its in-m ...[truncated 1831 chars]
Remediation
<![CDATA[ ## Remediation Suggestions Make the load, limit check, increment, and save operation atomic for each session. Recommended hardening steps: 1. Acquire a per-session exclusive lock before reading the counter. 2. Re-read and validate the state only after obtaining the lock. 3. Check the limit and increment the counter while holding the lock. 4. Persist the new state using a temporary file followed by an atomic rename. 5. Release the lock only after the durable update is complete. 6. Define lock timeout and stale-lock recovery behavior so a crashed hook cannot permanently deny future calls. 7. Fail closed for spawn-budget enforcement if valid state cannot be loaded or safely updated, where operational requirements permit. 8. Add an automated concurrency test that launches substantially more simultaneous hook processes than the configured maximum and verifies that no more than the maximum receive approval. A lock library is not strictly required; exclusive file creation with appropriate stale-lock handling can provide serialization. If platform support allows it, a transactional local data store or a dedicated coordinator process would provide stronger semantics. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
hooks/guard.js:39
Finding
Predictable Temporary State Files Permit Local State Manipulation and Symlink Attacks<![CDATA[ ## Vulnerability Details **File Location**: `hooks/guard.js`, lines 39–61 **Vulnerability Type**: Unsafe temporary file and directory handling **Risk Level**: Medium ### Vulnerable Code ```javascript const STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; const stateFile = (sessionId) => { const dir = path.join(os.tmpdir(), 'frugal-subagents'); fs.mkdirSync(dir, { recursive: true }); // Best-effort sweep of state files from sessions older than a week. try { const cutoff = Date.now() - STATE_TTL_MS; for (const f of fs.readdirSync(dir)) { const p = path.join(dir, f); try { if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p); } catch { /* ignore */ } } } catch { /* ignore */ } return path.join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, '_')}.json`); }; const loadState = (sessionId) => { if (!sessionId) return null; try { return { file: stateFile(sessionId), ...JSON.parse(fs.readFileSync(stateFile(sessionId), 'utf8')) }; } catch { try { return { file: stateFile(sessionId), count: 0, notified: false }; } catch { return null; } } }; const saveState = (state) => { if (!state) return; try { fs.writeFileSync(state.file, JSON.stringify({ count: state.count, notified: state.notified, updated: Date.now() })); } catch { /* ignore */ } }; ``` ### Technical Analysis Session state is placed in a fixed directory under the shared operating-system temporary directory: ```text <temporary-directory>/frugal-subagents/<sanitized-session-id>.json ``` The implementation does not explicitly establish restrictive permissions, validate directory or file ownership, reject symbolic links, or open files with exclusive/no-follow semantics. The state filename is also predictable when the session identifier is known or guessed. These properties create several local attack opportunities: - A local account can pre-create the shared directory before the p ...[truncated 2504 chars]
Remediation
<![CDATA[ ## Remediation Suggestions Replace the shared predictable state handling with a private, ownership-validated state directory and safely created files. Recommended hardening steps: 1. Create a user-specific directory with mode `0700`, preferably under an application-specific user runtime or data directory rather than a globally shared temporary path. 2. After creation, use `lstat` to verify that the path is a real directory rather than a symbolic link. 3. Verify that the directory is owned by the effective user running the hook. 4. Create state files with mode `0600`. 5. Use exclusive creation and no-follow behavior where supported; reject any existing state path that is not a regular file owned by the current user. 6. Derive filenames from a collision-resistant cryptographic hash of the complete session identifier instead of lossy character replacement. 7. Write updates to a securely created temporary file in the verified directory, flush as appropriate, and atomically rename it into place. 8. Validate parsed state against a strict schema. Require `count` to be a non-negative safe integer and `notified` to be a Boolean. 9. Use `lstat` during cleanup, skip symbolic links and non-regular files, and only delete files that satisfy ownership and naming requirements. 10. Avoid silently treating unsafe or malformed state as a fresh counter when doing so would weaken enforcement. Log or return an explicit hook denial when secure state cannot be guaranteed. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Trigger AbuseOverly Broad Trigger, Shadow Command Trigger, Keyword Baiting Trigger
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
Findings (8)

Ae1

High
Category
analysis-evasion
Content
Every `Agent` call passes through `hooks/guard.js` before it runs. Nothing is silent: when the hook fills in a model it says so in the tool result, and a blocke
Confidence
100% confidence
Finding
Referenced artifact was not completely inspected

Lp3

Medium
Category
MCP Least Privilege
Confidence
70% confidence
Finding
Without declared permissions the skill's intent is opaque and cannot be validated.

Autonomous Decision Making

Medium
Category
Excessive Agency
Content
The hook is a Node.js script. If Node.js is not installed, Claude Code shows a hook error when a subagent is spawned (typically `node` not found / not recognized) and the spawn proceeds unguarded. When you see that:

1. Tell the user in one plain sentence: the plugin's guard needs Node.js, it isn't installed, so the limits aren't enforced yet — the guidance still applies and you'll follow it manually.
2. Offer to install Node.js LTS and wait for a yes. Use the platform's package manager: Windows `winget install OpenJS.NodeJS.LTS`; macOS `brew install node` (or the installer from nodejs.org if Homebrew is absent); Debian/Ubuntu via the NodeSource setup script or `sudo apt install nodejs npm`; otherwise point to nodejs.org. Don't install without consent, and don't use `sudo` without saying so.
3. After installation, the user starts a new Claude Code session; the guard is active from then on. Until then, apply this skill's rules yourself: cheap tier, no nesting, results in files.

## Companion settings (first-party, optional)
Confidence
75% confidence
Finding
Skill enables autonomous high-impact decisions without human-in-the-loop verification. Critical operations (destructive commands, financial transactions, data deletion) should require explicit user confirmation.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
The hook is a Node.js script. If Node.js is not installed, Claude Code shows a hook error when a subagent is spawned (typically `node` not found / not recognized) and the spawn proceeds unguarded. When you see that:

1. Tell the user in one plain sentence: the plugin's guard needs Node.js, it isn't installed, so the limits aren't enforced yet — the guidance still applies and you'll follow it manually.
2. Offer to install Node.js LTS and wait for a yes. Use the platform's package manager: Windows `winget install OpenJS.NodeJS.LTS`; macOS `brew install node` (or the installer from nodejs.org if Homebrew is absent); Debian/Ubuntu via the NodeSource setup script or `sudo apt install nodejs npm`; otherwise point to nodejs.org. Don't install without consent, and don't use `sudo` without saying so.
3. After installation, the user starts a new Claude Code session; the guard is active from then on. Until then, apply this skill's rules yourself: cheap tier, no nesting, results in files.

## Companion settings (first-party, optional)
Confidence
70% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Vague Triggers

Medium
Confidence
89% confidence
Finding
The description says to use the skill whenever the task is "turn these files into that shape," which is a very broad natural-language trigger for many ordinary file transformation requests. It does not provide explicit trigger phrases, exclusions beyond "Not for judgment calls," or negative examples to clearly bound when this skill should activate.

Missing User Warnings

Low
Confidence
81% confidence
Finding
The README states that bundled agents save their full findings to files, but it does not clearly warn users that collected web research or extracted data will persist on disk. This can lead to unintentional storage of sensitive or regulated information, especially when users delegate broad searches over third-party sites and assume results remain only in transient chat context.

Missing User Warnings

Low
Confidence
81% confidence
Finding
The skill instructs the agent to write results to a path from the brief, or to create a new file next to the source by default, which affects user files on disk. The markdown description does not include a clear warning that the skill will create or modify files, which could impact user data or workspace integrity.

Bundled hooks can execute when matching lifecycle events occur.

Low
Category
Bundled Execution Surface
Confidence
95% confidence
Finding
Bundled lifecycle hooks can run automatically when their configured events occur, so their reach and handler capability require review before installation.

Static analysis

No suspicious patterns detected.