Back to skill

Security audit

unisound-discharge-record

Security checks for vulnerabilities and agentic risk

Overview

This skill has a coherent medical-document purpose, but it sends sensitive records and an API key to a configurable remote endpoint and can persist prepared medical text despite saying it does not.

Install only if you trust the publisher and deployment environment, can restrict the model endpoint to the intended internal HTTPS service, and have a policy for de-identifying medical records before use. Avoid --save-prepared with real patient data, treat outputs as draft documentation requiring clinician review, and do not pass arbitrary prompt files from untrusted sources.

Vulnerability Patterns
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • 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 (3)

T09 · Insecure Skill Coding Practices

Error
Location
scripts/run.py:223
Finding
Arbitrary Endpoint Can Receive Medical Records and API Credentials<![CDATA[ ## Vulnerability Details **File Location**: `scripts/run.py:223-240`, `scripts/run.py:258` **Vulnerability Type**: Unrestricted remote endpoint configuration and sensitive-data disclosure **Risk Level**: High ### Vulnerable Code ```python def _http_post(url: str, payload: dict[str, Any], headers: dict[str, str], *, timeout: int = 0) -> Any: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( url=url, data=data, method="POST", headers={"Content-Type": "application/json", **{key: value for key, value in headers.items() if value}}, ) try: opener = urllib.request.urlopen(req) if not timeout else urllib.request.urlopen(req, timeout=timeout) with opener as resp: body = resp.read().decode("utf-8", errors="replace") return json.loads(body) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc except urllib.error.URLError as exc: raise RuntimeError(f"Network error: {exc}") from exc def call_llm(prompt: str, *, base: str, model: str, appkey: str, timeout: int) -> str: url = f"{base.rstrip('/')}/chat/completions" headers = {"Authorization": f"Bearer {appkey}"} if appkey else {} payload = { "model": model, "messages": [{"role": "user", "content": prompt}], "temperature": 0, } response = _http_post(url, payload, headers, timeout=timeout) ``` The destination is exposed through an unrestricted command-line argument: ```python parser.add_argument("--base", default=DEFAULT_LLM_BASE, help=f"Internal LLM base URL (default: {DEFAULT_LLM_BASE}).") ``` ### Technical Analysis The user-controlled `--base` value is concatenated with `/chat/completions` and used directly as the request destination. The implementation does not enforce HTTPS, validate the hostname against an a ...[truncated 1705 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Remove the `--base` override from production deployments when endpoint customization is unnecessary. 2. If customization is required, parse the URL and enforce: - The `https` scheme. - An explicit allowlist of trusted hostnames. - Approved ports only. - A normalized, expected API path. - Rejection of embedded credentials, fragments, and unexpected query parameters. 3. Disable automatic redirects or validate every redirect target before resending a request. Never forward authorization headers to a different origin. 4. Bind credentials to the intended service where supported, using narrowly scoped and short-lived tokens. 5. Separate endpoint selection from credential selection so a credential cannot be sent to an unrelated host. 6. Add automated tests confirming that HTTP URLs, unapproved hosts, alternate ports, and cross-origin redirects are rejected. 7. Avoid including unnecessary patient identifiers in prompts and apply documented de-identification before transmission. ]]>

T01 · Skill Instruction Hijacking

Error
Location
scripts/run.py:65
Finding
Untrusted JSON and Medical-Record Content Can Override LLM Instructions<![CDATA[ ## Vulnerability Details **File Location**: `scripts/run.py:65-115` **Vulnerability Type**: Prompt replacement and prompt injection **Risk Level**: High ### Vulnerable Code ```python def build_prompt(payload: dict[str, Any]) -> str: raw_prompt = str(payload.get("prompt") or "").strip() if raw_prompt: return raw_prompt records = payload.get("records") if records: records_text = [] current_section = "" for idx, record in enumerate(records): section = str(record.get("section") or "").strip() title = str(record.get("title") or "").strip() content = str(record.get("content") or record.get("text") or "").strip() if section and section != current_section: current_section = section records_text.append(f"#### {section}\n") if title: records_text.append(f"{title}:\n") if content: records_text.append(f"{content}\n") records_text.append("\n") return f"""You are a professional medical-record organization expert. Generate a standard discharge record strictly from the supplied source record according to the fixed rules: 1. Chief complaint: Extract the original chief complaint from the admission record. 2. Admission status: Generate using one of the documented alternatives. 3. Admission diagnosis: Extract the admission or preliminary diagnosis. 4. Treatment course: Summarize disease progression, examinations, treatment, and procedures chronologically. 5. Discharge diagnosis: Consolidate admission, revised, and supplemental diagnoses. 6. Discharge status: Derive the status from the final progress record. 7. Discharge instructions: Extract medication, follow-up examination, rehabilitation, lifestyle, and follow-up advice. Requirements: Remain completely faithful to the source, do not fabricate or add conditions, use standard medica ...[truncated 2529 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Remove support for an unrestricted top-level `prompt` property. Accept only documented medical-record data fields. 2. Place immutable generation policy in a system message rather than combining policy and records in one user message. 3. Serialize medical records as explicitly marked untrusted data using a strict structure such as JSON, while instructing the model that content inside data fields is never authoritative. 4. Define and enforce an output schema containing exactly the seven required fields. 5. Validate that all required fields exist, reject unexpected fields, and enforce length and type constraints. 6. Add factual-grounding checks so diagnoses, medications, tests, and dates in the generated output must be traceable to the source record. 7. Require clinician review before generated content is used in a medical record or treatment workflow. 8. Add adversarial tests for instructions embedded in `prompt`, `section`, `title`, and `content`, including requests to ignore prior instructions or fabricate medical facts. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/run.py:202
Finding
Prepared Medical Records Can Be Persisted Without Protective File Controls<![CDATA[ ## Vulnerability Details **File Location**: `scripts/run.py:202-207`, `scripts/run.py:260` **Vulnerability Type**: Plaintext persistence of sensitive medical information **Risk Level**: Medium ### Vulnerable Code ```python def save_prepared(payload: dict[str, Any], output_path: str, input_path: Path) -> None: save_dir = Path(output_path).parent if output_path else SCRIPT_DIR.parents[1] / "runs" / "discharge-record" save_dir.mkdir(parents=True, exist_ok=True) prepared_path = save_dir / f"{input_path.stem}.prepared.txt" prepared_path.write_text(payload_to_prepared_text(payload), encoding="utf-8") print(f"Prepared text saved to: {prepared_path}", file=sys.stderr) ``` The persistence operation is enabled here: ```python if args.save_prepared: save_prepared(payload, args.output, input_path) ``` The behavior conflicts with the statement in `SKILL.md:23` that input and intermediate results are not written to persistent local storage and are destroyed when the invocation ends. ### Technical Analysis The `--save-prepared` option writes the prepared prompt or medical-record text to a plaintext file. The implementation does not set restrictive permissions, encrypt the file, redact patient identifiers, enforce an approved storage directory, define retention, or remove the file after use. The destination can derive from the user-controlled output path. If no output path is provided, the record is stored under the default `runs/discharge-record` directory and remains there after execution. The filename is predictable from the input filename. Although the option must be explicitly enabled, the documentation's categorical non-persistence assurance may cause users to underestimate the privacy consequences. In shared workstations, containers with mounted volumes, backup systems, or permissively configured environments, another party may obtain the persisted record. ### Attack Path 1. A user or wrapper invokes the Skill with `--save-pre ...[truncated 969 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Disable or remove `--save-prepared` in production and clinical deployments. 2. Update the privacy documentation so it accurately states every condition under which input, intermediate data, or output is persisted. 3. If diagnostic persistence is essential: - Require explicit confirmation and display the destination before writing. - De-identify or redact patient identifiers. - Restrict storage to an approved directory. - Create files atomically with owner-only permissions such as `0600`. - Avoid predictable filenames where they create unnecessary exposure. - Encrypt retained records using an approved key-management mechanism. 4. Implement a defined retention period and reliable deletion process. 5. Exclude diagnostic files from backups, logs, support bundles, and source-control repositories unless explicitly authorized. 6. Add tests verifying restrictive permissions, approved destination enforcement, and cleanup behavior. ]]>
Vulnerability Patterns
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Supply ChainUnpinned Dependencies, External Script Fetching, Obfuscated Code
Findings (7)

Ssd 1

High
Confidence
99% confidence
Finding
If `payload["prompt"]` is present, it is returned verbatim and sent directly to the LLM, bypassing the safety framing and extraction rules otherwise added by `build_prompt()`. This allows any caller-controlled natural-language instructions to override intended behavior, potentially causing disclosure of raw records, policy bypass, or generation of unsafe/unreliable outputs in a medical context.

Ssd 1

High
Confidence
98% confidence
Finding
Untrusted record text is embedded directly into the same prompt channel as instructions, so adversarial content inside the medical document can act as prompt injection and compete with the task instructions. In this skill, that is especially risky because the source material may contain arbitrary text and the output is a medical discharge summary, where manipulated behavior can lead to data leakage, omission, fabrication pressure, or clinically unsafe summaries.

Lp3

Medium
Category
MCP Least Privilege
Confidence
93% confidence
Finding
The skill documentation indicates capabilities to read files, write outputs, and make authenticated network requests to an internal medical model API, but it does not declare any explicit tool scope or permissions boundaries. In a medical-record workflow handling sensitive patient data, this absence of permission scoping increases the risk of overbroad file access, unintended persistence of PHI, and exfiltration to network destinations beyond what operators expect.

Natural-Language Policy Violations

Medium
Confidence
93% confidence
Finding
The manifest description is written as a definitive Chinese-only workflow and output description, with no indication that users may choose another language or that the restriction is region-specific. Under the policy, forcing a specific language without user opt-in is a natural-language policy violation.

Natural-Language Policy Violations

Medium
Confidence
95% confidence
Finding
The prompt text is entirely prescriptive in Chinese and instructs the model to generate the discharge record in that language and style, without offering any language or locale choice. Under the policy, forcing a specific language without user opt-in is a natural-language policy violation unless the locale constraint is explicitly justified.

Natural-Language Policy Violations

Medium
Confidence
88% confidence
Finding
Natural-language strings throughout the file, including the module description and prompt templates, require Chinese-language processing and output, but the script provides no option for user language or locale selection. Under the stated policy, forcing a specific language without user opt-in is a policy violation unless the locale restriction is explicitly justified.

Missing User Warnings

Medium
Confidence
95% confidence
Finding
The script sends full patient record content to a remote HTTPS API in `run()` via `call_llm()` without any explicit consent gate, warning, minimization, or policy enforcement in the code path. Because the data is medical record content, the sensitivity is unusually high: unauthorized transmission, logging, retention, or cross-boundary processing could create a serious privacy and compliance incident even if the endpoint is an internal service.

Static analysis

No suspicious patterns detected.