Back to skill

Security audit

Isolated Multi-Agent Control Plane

Security checks for vulnerabilities and agentic risk

Overview

This skill installs a local multi-agent control-plane blueprint, but its included tools do not reliably enforce the security boundaries and audit controls they promise.

Treat this as a prototype, not a secure production control plane. Install only in an isolated test directory or account, and do not rely on it for agent isolation, approval enforcement, mailbox integrity, or audit records until message ID validation, authenticated actor binding, filesystem permissions, log initialization, and pre-mutation integrity checks are added.

Vulnerability Patterns
  • Unauthorized Access and Privilege EscalationObtains permissions beyond the task's legitimate needs
  • 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
Findings (5)

T09 · Insecure Skill Coding Practices

Error
Location
assets/blueprint/scripts/mailboxctl.py:69
Finding
Mailbox Message ID Allows Path Traversal Outside the Mailbox<![CDATA[ ## Vulnerability Details **File Location**: `assets/blueprint/scripts/mailboxctl.py:69-70, 83-104, 184` **Vulnerability Type**: Path traversal and arbitrary file creation **Risk Level**: High ### Vulnerable Code ```python def message_path(message_id): return os.path.join(MAILBOX, f"{message_id}.md") def cmd_send(a): ensure_dirs() mid = a.message_id or next_msg_id() path = message_path(mid) if os.path.exists(path): raise SystemExit(f'message exists: {mid}') header = { 'message_id': mid, 'correlation_id': a.correlation_id, 'task_id': a.task_id, 'sender': a.sender, 'receiver': a.receiver, 'version': '1.0', 'timestamp': now_iso(), 'status': 'UNREAD', 'retry_count': int(a.retry_count), 'checksum': '' } body = f"# Message\n\n{a.body}\n" header['checksum'] = checksum_for(header, body) with open(path, 'w', encoding='utf-8') as f: f.write(render_message(header, body)) ``` ```python s.add_argument('--message-id') ``` ### Technical Analysis The caller-controlled `--message-id` value is interpolated directly into a filesystem path. The code neither restricts message IDs to a safe identifier format nor verifies that the normalized destination remains inside `MAILBOX`. A message ID containing directory traversal components such as `../` causes `os.path.join()` and `open()` to address a path outside the intended mailbox. The `.md` suffix limits the resulting filename extension but does not prevent writing outside the mailbox. The same `message_path()` function is also used by the `show` and `status` commands. Consequently, an existing external `.md` file that follows the expected message format can potentially be read or rewritten through those commands. The existence check followed by a normal `open(..., 'w')` is also vulnerable to a time-of-check/time-of-use race and does not protect against symlink redirection. ### Atta ...[truncated 1301 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Enforce a strict message-ID allowlist, such as `^MSG-[0-9]{4,}$`. - Reject absolute paths, path separators, `.` components, and `..` components. - Resolve the mailbox and destination with `os.path.realpath()` or `pathlib.Path.resolve()`, then verify that the destination is a direct child of the mailbox. - Use `os.open()` with `O_CREAT | O_EXCL` for atomic creation. - Reject symlinks or use platform-supported no-follow options such as `O_NOFOLLOW`. - Apply the same validation to `send`, `show`, and `status`. Example validation: ```python MESSAGE_ID_RE = re.compile(r'^MSG-[0-9]{4,}$') def message_path(message_id): if not MESSAGE_ID_RE.fullmatch(message_id): raise SystemExit('invalid message ID') mailbox = os.path.realpath(MAILBOX) path = os.path.realpath(os.path.join(mailbox, f'{message_id}.md')) if os.path.dirname(path) != mailbox: raise SystemExit('message path escapes mailbox') return path ``` ]]>

T05 · Unauthorized Access and Privilege Escalation

Error
Location
assets/blueprint/scripts/mailboxctl.py:118
Finding
Caller-Supplied Role Names Allow Team Lead and Receiver Impersonation<![CDATA[ ## Vulnerability Details **File Location**: `assets/blueprint/scripts/mailboxctl.py:118-141, 153-169, 177-204` **Vulnerability Type**: Missing authentication and broken role-based access control **Risk Level**: High ### Vulnerable Code ```python def cmd_status(a): p = message_path(a.message_id) h, b = parse_message(p) required_header(h) cur = h.get('status') nxt = a.to if nxt not in STATUS_FLOW.get(cur, set()): raise SystemExit(f'invalid status transition: {cur}->{nxt}') actor = a.actor # Receiver can ACK/RESOLVED/REJECTED for their message; Team Lead can arbitrate any if actor != 'team-lead' and actor != h.get('receiver'): raise SystemExit('only receiver or team-lead can update status') h['status'] = nxt h['timestamp'] = now_iso() if a.increment_retry: h['retry_count'] = int(h.get('retry_count', 0)) + 1 if a.note: b = b + f"\n## Response ({actor})\n\n{a.note}\n" h['checksum'] = checksum_for(h, b) with open(p, 'w', encoding='utf-8') as f: f.write(render_message(h, b)) event({'ts': now_iso(), 'type': 'mail_status', 'message_id': h['message_id'], 'task_id': h['task_id'], 'from': cur, 'to': nxt, 'actor': actor}) ``` ```python def cmd_gc(a): ensure_dirs() if a.actor != 'team-lead': raise SystemExit('only team-lead can run mailbox GC') statuses = set([x.strip() for x in a.statuses.split(',') if x.strip()]) moved = 0 files = sorted([f for f in os.listdir(MAILBOX) if f.endswith('.md')]) for fn in files: src = os.path.join(MAILBOX, fn) h, _ = parse_message(src) st = h.get('status') if st in statuses: dst = os.path.join(ARCHIVE, fn) os.replace(src, dst) moved += 1 event({'ts': now_iso(), 'type': 'mail_gc', 'message_id': h.get('message_id'), 'task_id': h.get('task_id'), 'status': st, ...[truncated 2241 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Do not accept authoritative identity through an unrestricted `--actor` or `--sender` argument. - Derive the caller identity from a trusted execution context, such as: - Separate operating-system users for each agent. - A privileged broker that authenticates agents. - Protected per-agent credentials or signed capability tokens. - Unix-domain socket peer credentials. - Enforce per-role filesystem permissions so only Team Lead can move files into the archive. - Validate that message sender identity matches the authenticated caller. - Validate that status-changing identity matches the authenticated receiver or authenticated Team Lead. - Record both authenticated identity and requested action in the event log. - Protect the log and mailbox from direct writes by untrusted agent processes. - Add negative authorization tests proving that coder, researcher, and QA/Ops cannot claim Team Lead privileges. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
assets/blueprint/scripts/mailboxctl.py:109
Finding
Status Updates Re-Sign Previously Tampered Mailbox Messages<![CDATA[ ## Vulnerability Details **File Location**: `assets/blueprint/scripts/mailboxctl.py:109-140` **Vulnerability Type**: Missing integrity verification before mutation **Risk Level**: Medium ### Vulnerable Code ```python def cmd_show(a): p = message_path(a.message_id) h, b = parse_message(p) required_header(h) chk = checksum_for(h, b) ok = (chk == h.get('checksum')) print(yaml.safe_dump({'header': h, 'checksum_ok': ok}, sort_keys=False, allow_unicode=True).strip()) def cmd_status(a): p = message_path(a.message_id) h, b = parse_message(p) required_header(h) cur = h.get('status') nxt = a.to if nxt not in STATUS_FLOW.get(cur, set()): raise SystemExit(f'invalid status transition: {cur}->{nxt}') actor = a.actor if actor != 'team-lead' and actor != h.get('receiver'): raise SystemExit('only receiver or team-lead can update status') h['status'] = nxt h['timestamp'] = now_iso() if a.increment_retry: h['retry_count'] = int(h.get('retry_count', 0)) + 1 if a.note: b = b + f"\n## Response ({actor})\n\n{a.note}\n" h['checksum'] = checksum_for(h, b) with open(p, 'w', encoding='utf-8') as f: f.write(render_message(h, b)) ``` ### Technical Analysis The `show` command calculates whether the stored checksum matches the current message, but `cmd_status()` does not perform that verification before changing the message. If a message body or header is modified directly, `cmd_status()` accepts the altered data, updates the status, and computes a new valid checksum over the tampered content. The status operation therefore launders prior tampering into an apparently checksum-valid message. Additionally, an unkeyed SHA-256 checksum only detects accidental corruption when the stored digest is protected separately. Any party that can edit the message can calculate a new digest. It does not provide cryptographic authenticity against ...[truncated 1200 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Verify the existing checksum before every status update or other mutation. - Abort the operation and generate a separate tamper event if verification fails. - Use `hmac.compare_digest()` for digest comparisons. - Use an HMAC with a protected key or digital signatures if authenticity against malicious writers is required. - Keep signing keys inaccessible to ordinary agent processes. - Consider an append-only message model in which responses and state changes are separate signed records rather than rewrites of the original message. - Protect message files with filesystem permissions and atomic writes. Example precondition: ```python import hmac stored = str(h.get('checksum', '')) calculated = checksum_for(h, b) if not hmac.compare_digest(stored, calculated): raise SystemExit('message integrity verification failed') ``` ]]>

T05 · Unauthorized Access and Privilege Escalation

Warning
Location
assets/blueprint/scripts/taskctl.py:49
Finding
Task State Machine Does Not Enforce Actor or Reviewer Authorization<![CDATA[ ## Vulnerability Details **File Location**: `assets/blueprint/scripts/taskctl.py:49-67, 74-78` **Vulnerability Type**: Broken workflow authorization and ineffective separation of duties **Risk Level**: Medium ### Vulnerable Code ```python def cmd_transition(a): p,d=find(a.task_id) cur=d.get('status') nxt=a.to if nxt not in VALID: raise SystemExit('invalid target status') if nxt not in TRANSITIONS.get(cur,set()): raise SystemExit(f'invalid transition: {cur}->{nxt}') if nxt=='CLAIMED' and not a.actor: raise SystemExit('CLAIMED requires --actor owner') if cur=='IN_PROGRESS' and nxt=='REVIEW' and (a.actor and a.actor==d.get('owner')): raise SystemExit('self-review forbidden') d['status']=nxt d['updated_at']=now() if nxt=='CLAIMED': d['owner']=a.actor d['claimed_at']=now() save(p,d) event({'ts':now(),'type':'task_transition','task_id':a.task_id, 'from':cur,'to':nxt,'actor':a.actor}) print(f'{a.task_id}: {cur} -> {nxt}') ``` ```python t=sp.add_parser('transition') t.add_argument('--task-id',required=True) t.add_argument('--to',required=True) t.add_argument('--actor',required=False) t.set_defaults(func=cmd_transition) ``` ### Technical Analysis The actor is optional for all transitions except transitions into `CLAIMED`, and supplied actor values are not authenticated. The state machine checks whether a transition is structurally allowed but does not verify that the caller is authorized to perform it. The purported self-review check is applied to `IN_PROGRESS -> REVIEW`. Moving a task into review is normally an owner action, so preventing the owner from performing that transition does not correctly enforce independent review. More importantly, the code does not restrict `REVIEW -> DONE` to the configured reviewer or require that the approving reviewer differ from the owner. No check ensures that: - Work transitions are performed by the task ow ...[truncated 1375 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Require an authenticated actor for every state transition. - Bind the authenticated identity to the process or credential rather than trusting a CLI string. - Define explicit authorization rules for each transition, for example: - `INBOX -> CLAIMED`: authenticated claimant. - `CLAIMED -> IN_PROGRESS`: current owner only. - `IN_PROGRESS -> REVIEW`: current owner only. - `REVIEW -> DONE`: configured reviewer only. - Review rejection or blocking: configured reviewer or explicitly authorized Team Lead. - Enforce `reviewer != owner` when tasks are created, claimed, and approved. - Reject tasks with missing required schema fields. - Record the authenticated principal rather than the caller-provided label. - Add tests for anonymous transitions, owner self-approval, non-reviewer approval, and actor spoofing. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/install_blueprint.sh:6
Finding
Missing Log Directory Causes State Changes Without Corresponding Audit Events<![CDATA[ ## Vulnerability Details **File Location**: `scripts/install_blueprint.sh:6-8`; `assets/blueprint/scripts/mailboxctl.py:23-25, 51-54, 103-105`; `assets/blueprint/scripts/taskctl.py:26-32, 61-67` **Vulnerability Type**: Non-transactional state mutation and incomplete runtime initialization **Risk Level**: Medium ### Vulnerable Code The installer copies the supplied blueprint but does not create the required log directory: ```bash mkdir -p "$TARGET_DIR" cp -R "$SRC"/* "$TARGET_DIR"/ chmod +x "$TARGET_DIR"/scripts/taskctl.py "$TARGET_DIR"/scripts/mailboxctl.py ``` Mailbox initialization creates mailbox directories but not `control-plane/logs`: ```python def ensure_dirs(): os.makedirs(MAILBOX, exist_ok=True) os.makedirs(ARCHIVE, exist_ok=True) ``` Event logging assumes that the missing parent directory already exists: ```python def event(line): import json with open(EVENTS, 'a', encoding='utf-8') as f: f.write(json.dumps(line, ensure_ascii=False) + "\n") ``` Mailbox state is written before the event is recorded: ```python with open(path, 'w', encoding='utf-8') as f: f.write(render_message(header, body)) event({'ts': now_iso(), 'type': 'mail_send', 'message_id': mid, 'task_id': a.task_id, 'sender': a.sender, 'receiver': a.receiver}) ``` Task state is also saved before event logging: ```python def save(path,data): with open(path,'w',encoding='utf-8') as f: yaml.safe_dump(data,f,sort_keys=False,allow_unicode=True) def event(rec): with open(EVENTS,'a',encoding='utf-8') as f: f.write(json.dumps(rec,ensure_ascii=False)+"\n") ``` ```python d['status']=nxt d['updated_at']=now() if nxt=='CLAIMED': d['owner']=a.actor d['claimed_at']=now() save(p,d) event({'ts':now(),'type':'task_transition','task_id':a.task_id, 'from':cur,'to':nxt,'actor':a.actor}) ``` ### Technical Analysis The supplied blueprint does not contain a `control-plane/logs` directory, and empty directories ar ...[truncated 1803 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Create all required runtime directories during installation: ```bash mkdir -p \ "$TARGET_DIR/control-plane/mailbox/archive" \ "$TARGET_DIR/control-plane/logs" touch "$TARGET_DIR/control-plane/logs/events.jsonl" ``` - Make both utilities independently create the log directory before use: ```python os.makedirs(os.path.dirname(EVENTS), exist_ok=True) ``` - Use atomic file writes for task and message state, such as writing to a temporary file in the same directory and calling `os.replace()`. - Design event and state updates as a recoverable transaction: - Validate all preconditions first. - Prepare both records. - Write a pending event or journal entry. - Atomically publish state. - Finalize the event. - If full transactions are impractical, detect logging failure before mutating state or roll back the state mutation when event recording fails. - Add installation smoke tests covering the first message send and first task transition on a clean target. - Add reconciliation tooling that detects state changes with missing audit events. ]]>
Vulnerability Patterns
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
Findings (2)

Lp3

Medium
Category
MCP Least Privilege
Confidence
93% confidence
Finding
The skill instructs users to run shell commands and Python tooling that can read from and write to the filesystem, but it does not declare any explicit tool scope such as permissions or allowed-tools. In a multi-agent control-plane skill, this omission weakens least-privilege guarantees and can let an agent invoke broader file or shell capabilities than reviewers or operators expect.

Autonomous Decision Making

Medium
Category
Excessive Agency
Content
## Security checks

- Coder write scope constrained to coder workspace.
- QA/Ops deploy actions blocked without approval ticket.
- Team Lead-only mailbox GC enforced.
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.

Static analysis

No suspicious patterns detected.