Back to skill

Security audit

Paragraph Test

Security checks for vulnerabilities and agentic risk

Overview

This Paragraph publishing skill is mostly purpose-aligned, but it exposes high-impact publishing, subscriber, credential, and local-file upload behavior without enough guardrails.

Review carefully before installing. Use only a narrowly scoped Paragraph API key, leave PARAGRAPH_API_BASE_URL unset unless testing with non-production credentials, require human confirmation before any post creation or subscriber action, set sendWelcomeEmail to false unless outreach is intended, and only import CSV files from a user-approved path after confirming their contents.

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 (3)

T09 · Insecure Skill Coding Practices

Error
Location
skill.js:9
Finding
Configurable API Base URL Enables Bearer Credential and Sensitive Data Disclosure<![CDATA[ ## Vulnerability Details **File Location**: `skill.js:9`, `skill.js:33-76`, and `skill.js:447-459` **Vulnerability Type**: Unrestricted sensitive-data transmission destination **Risk Level**: High ### Vulnerable Code ```javascript const API_BASE = process.env.PARAGRAPH_API_BASE_URL || "https://public.api.paragraph.com/api" ``` ```javascript async function request(method, endpoint, body = null, params = {}, options = {}) { // Read API_KEY from env at call time to respect per-skill injection const apiKey = process.env.PARAGRAPH_API_KEY if (!apiKey) { throw new Error("PARAGRAPH_API_KEY environment variable not set") } const url = new URL(`${API_BASE}${endpoint}`) Object.keys(params).forEach(key => { if (params[key] !== undefined && params[key] !== null) { url.searchParams.append(key, String(params[key])) } }) const headers = { "Authorization": `Bearer ${apiKey}` } let fetchBody = null if (body) { headers["Content-Type"] = "application/json" fetchBody = JSON.stringify(body) } if (options.rawBody) { fetchBody = options.rawBody Object.assign(headers, options.headers) } else if (options.formData) { fetchBody = options.formData // Don't set Content-Type; fetch will set boundary } // Set up abort controller for timeout const controller = new AbortController() const timeoutMs = options.timeout || 30000 const timeoutId = setTimeout(() => controller.abort(), timeoutMs) try { const response = await fetch(url.toString(), { method, headers, body: fetchBody, signal: controller.signal }) ``` The same configurable destination is used for subscriber imports: ```javascript const url = new URL(`${API_BASE}/v1/subscribers/import`) url.searchParams.append('sendWelcomeEmail', sendWelcomeEmail) const formData = new FormData() formData.append('file', csvBuffer, 'subscribers.csv') const response = await fetch(url.toString(), { method: "POST", hea ...[truncated 2481 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Pin production requests to the official endpoint: ```javascript const PARAGRAPH_API_BASE = "https://public.api.paragraph.com/api" ``` 2. If endpoint overrides are required for development, require an explicit non-production mode and reject overrides by default: ```javascript const allowedHosts = new Set(["public.api.paragraph.com"]) const apiUrl = new URL(configuredBase) if (apiUrl.protocol !== "https:" || !allowedHosts.has(apiUrl.hostname)) { throw new Error("Unapproved Paragraph API endpoint") } ``` 3. Never send a production API key to a custom test endpoint. Require a separate test credential variable when development mode is enabled. 4. Normalize the URL and reject embedded credentials, redirects to unapproved hosts, nonstandard schemes, and unexpected ports. 5. Configure `fetch` with manual redirect handling or validate every redirect target before forwarding an `Authorization` header. 6. Display or record the validated destination without logging credentials, and require explicit administrative approval before enabling custom endpoints. 7. Apply equivalent destination validation to the separate CSV-import `fetch` implementation. ]]>

T09 · Insecure Skill Coding Practices

Error
Location
skill.js:440
Finding
Unrestricted Local File Read and Upload Through Subscriber CSV Import<![CDATA[ ## Vulnerability Details **File Location**: `skill.js:440-459` **Vulnerability Type**: Arbitrary local file read, sensitive-file upload, and unbounded synchronous file loading **Risk Level**: High ### Vulnerable Code ```javascript paragraph_importSubscribers: wrapTool(async ({ csvPath, sendWelcomeEmail = true }) => { if (!csvPath) throw new Error("csvPath is required") const fs = await import('fs') const csvBuffer = fs.readFileSync(csvPath) // Build URL with query param const url = new URL(`${API_BASE}/v1/subscribers/import`) url.searchParams.append('sendWelcomeEmail', sendWelcomeEmail) const formData = new FormData() formData.append('file', csvBuffer, 'subscribers.csv') const response = await fetch(url.toString(), { method: "POST", headers: { "Authorization": `Bearer ${process.env.PARAGRAPH_API_KEY}` // Content-type (with boundary) set automatically by fetch when using FormData }, body: formData }) ``` ### Technical Analysis The `csvPath` argument is caller-controlled and passed directly to `fs.readFileSync()`. The Skill does not verify that the path: - Belongs to an approved import directory. - Resolves to a regular file. - Is not a symbolic link. - Has a `.csv` extension. - Contains CSV-formatted data. - Is within the documented 10 MB limit. - Was explicitly selected or approved by the user. Any file readable by the Skill process can therefore be loaded into memory and submitted to the configured API destination under the fixed filename `subscribers.csv`. Renaming the multipart upload does not change or sanitize its contents. The use of synchronous, unbounded file loading also blocks the Node.js event loop and can exhaust process memory when a large file or special filesystem object is supplied. ### Attack Path 1. An attacker influences an agent prompt, tool argument, automated workflow, or other source that controls `csvPath`. 2. The attacker supplies the path of a sensitive readable file ...[truncated 1197 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Restrict imports to a dedicated, user-approved directory. 2. Resolve the canonical path before access and verify that it remains under the approved directory: ```javascript const resolved = await fs.promises.realpath(csvPath) const allowedRoot = await fs.promises.realpath(configuredImportDirectory) if (!resolved.startsWith(`${allowedRoot}${path.sep}`)) { throw new Error("CSV path is outside the approved import directory") } ``` 3. Use `lstat()` and `stat()` to reject symbolic links, directories, devices, pipes, sockets, and other non-regular files. 4. Enforce the documented 10 MB size limit before reading any data. 5. Require an expected `.csv` extension and validate the header and row structure before upload. Extension checks alone are insufficient. 6. Use asynchronous bounded reads or streaming instead of `readFileSync()` to avoid blocking the event loop and loading unbounded input into memory. 7. Require explicit user confirmation that identifies the canonical file path, destination hostname, file size, and whether welcome emails will be sent. 8. Pin the upload destination to the official Paragraph API and apply the endpoint protections described in the first finding. 9. Default `sendWelcomeEmail` to `false` for bulk imports to reduce unintended external side effects. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
skill.js:558
Finding
Public Feed Operations Unnecessarily Transmit the API Bearer Token<![CDATA[ ## Vulnerability Details **File Location**: `skill.js:558-565`, with credential attachment in `skill.js:33-48` **Vulnerability Type**: Excessive credential use and least-privilege violation **Risk Level**: Medium ### Vulnerable Code The feed tool is documented as public and not requiring authentication: ```javascript /** * Get feed (curated posts) - public, no auth required */ paragraph_getFeed: wrapTool(async ({ limit = 20, cursor }) => { const params = { limit } if (cursor) params.cursor = cursor const result = await request("GET", "/v1/posts/feed", null, params) return { posts: result.items || [], pagination: result.pagination || {} } }), ``` However, the shared request helper requires and attaches the bearer token to every request: ```javascript async function request(method, endpoint, body = null, params = {}, options = {}) { // Read API_KEY from env at call time to respect per-skill injection const apiKey = process.env.PARAGRAPH_API_KEY if (!apiKey) { throw new Error("PARAGRAPH_API_KEY environment variable not set") } const url = new URL(`${API_BASE}${endpoint}`) Object.keys(params).forEach(key => { if (params[key] !== undefined && params[key] !== null) { url.searchParams.append(key, String(params[key])) } }) const headers = { "Authorization": `Bearer ${apiKey}` } ``` ### Technical Analysis `paragraph_getFeed` invokes the authenticated `request()` helper even though its own comment states that the endpoint is public and requires no authentication. This forces callers to configure a privileged API key and transmits that key for an operation that does not need it. This violates least-privilege credential handling. It also increases the number of requests and code paths through which the key may be exposed. The risk is amplified by the unrestricted `PARAGRAPH_API_BASE_URL`, because invoking a nominally public operation can disclose the bearer credential to a configured third-party ...[truncated 1235 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Add an explicit authentication option to the request helper: ```javascript async function request(method, endpoint, body = null, params = {}, options = {}) { const headers = {} if (options.auth !== false) { const apiKey = process.env.PARAGRAPH_API_KEY if (!apiKey) { throw new Error("PARAGRAPH_API_KEY environment variable not set") } headers.Authorization = `Bearer ${apiKey}` } // Continue constructing and sending the request. } ``` 2. Invoke public endpoints with authentication disabled: ```javascript const result = await request( "GET", "/v1/posts/feed", null, params, { auth: false } ) ``` 3. Maintain an explicit allowlist of public endpoint patterns rather than relying solely on each caller to select the correct option. 4. Add tests asserting that public requests contain no `Authorization` header and work without `PARAGRAPH_API_KEY`. 5. Disable automatic forwarding of authentication headers across redirects. 6. Document which tools are public and which require privileged credentials so the runtime can request only the minimum necessary secrets. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Rogue AgentSelf-Modification, Session Persistence
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
Findings (15)

Session Persistence

Medium
Category
Rogue Agent
Content
## Prerequisites

1. **Paragraph Account**: Create an account at [paragraph.com](https://paragraph.com)
2. **API Key**: Generate one in Account Settings → Integrations
3. **Node.js 19+**: Required for native fetch (OpenClaw uses Node 24+)
Confidence
60% confidence
Finding
Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Session Persistence

Medium
Category
Rogue Agent
Content
### Agent Prompt Examples

**Create a blog post from research findings**
```
You are a content creator. Use the paragraph skill to publish a new blog post
based on today's research notes. Title: "AI Trends 2026", markdown content
Confidence
60% confidence
Finding
Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Missing User Warnings

Medium
Confidence
95% confidence
Finding
The create-post examples encourage publishing from agent prompts without prominently stating that `paragraph_createPost` publishes immediately. This can cause an agent or user to treat the action as a draft/save step and accidentally post sensitive, incorrect, or unreviewed content to a public onchain platform.

Missing User Warnings

Medium
Confidence
93% confidence
Finding
The subscriber import/add examples normalize handling of email and wallet data, and may trigger welcome emails, without warning about consent, privacy, or notification side effects. In an agent context this can lead to unauthorized processing of personal data or accidental outreach to contacts, causing privacy violations, spam, or compliance issues.

Intent-Code Divergence

Medium
Confidence
98% confidence
Finding
The README describes a draft/review/update workflow using `published: false` and `paragraph_updatePost`, but elsewhere clearly states posts are published immediately and updates are unsupported. In an agent skill, this mismatch is dangerous because operators may rely on a nonexistent safety gate and unintentionally publish unreviewed or sensitive content publicly and onchain.

Session Persistence

Medium
Category
Rogue Agent
Content
1. Writer generates markdown draft
2. Publisher Agent calls `paragraph_createPost` with `published: false`
3. Review/approval step
4. Publish: `paragraph_updatePost` with `published: true` OR create with `published: true`
5. If tokenizing: capture coin ID from response, track via `paragraph_getCoin` and `paragraph_listCoinHolders`
6. Analytics: correlate engagement (views, holders) with content performance
Confidence
60% confidence
Finding
Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Missing User Warnings

Medium
Confidence
89% confidence
Finding
The documentation states that posts are published onchain immediately, but it does not give a sufficiently prominent warning that blockchain publication is effectively permanent, public, and difficult or impossible to reverse. This can lead users or autonomous agents to publish sensitive, copyrighted, regulated, or erroneous content that cannot be fully retracted once committed.

Missing User Warnings

Medium
Confidence
92% confidence
Finding
The skill advertises subscriber addition, tagging, counting, and CSV bulk import of emails and wallet addresses without a prominent privacy, consent, retention, or misuse warning. Because these features handle personal data and audience lists, an agent or operator could import, segment, or contact people without adequate consent or awareness of compliance obligations, increasing risk of privacy violations and spam abuse.

Missing User Warnings

Medium
Confidence
88% confidence
Finding
This tool transmits subscriber contact data to the remote API and may trigger a welcome email by default. In agent workflows, sending personally identifiable information and initiating outbound communication without an explicit warning or opt-in can lead to privacy, consent, and reputational issues even if the API call is expected behavior.

Context-Inappropriate Capability

Medium
Confidence
93% confidence
Finding
The subscriber import tool accepts an arbitrary local file path, reads that file from disk, and transmits its contents to the remote Paragraph API. In an agent setting, this creates a file-exfiltration primitive that is broader than what is necessary for a typical API client, because a prompt or tool caller could coerce the skill into uploading sensitive local files instead of an intended CSV export.

Missing User Warnings

Medium
Confidence
94% confidence
Finding
The import flow reads a local CSV from an arbitrary path and uploads it to an external service without any built-in disclosure, warning, or confirmation mechanism. In an LLM-agent context, this materially increases the risk of unintended sensitive-data transmission, since the caller may not realize that local filesystem contents are being sent off-host.

Intent-Code Divergence

Low
Confidence
78% confidence
Finding
The configuration note says `PARAGRAPH_PUBLICATION_SLUG` is required and 'the skill will not auto-discover the slug,' but the auto-discovery section says the skill automatically discovers `PARAGRAPH_PUBLICATION_ID` from the API key by fetching the public feed. These statements create intent/documentation divergence about what identity metadata is discovered automatically and what the user must configure.

Natural-Language Policy Violations

Low
Confidence
78% confidence
Finding
The prompt example tells the agent to publish content from `memory/2026-03-10.md`, implicitly endorsing use of a fixed internal memory source without an explicit user approval step. In agent systems, this can normalize exfiltration of stored notes or internal data into a public post, especially when combined with immediate publishing behavior.

Intent-Code Divergence

Low
Confidence
99% confidence
Finding
The parameter documentation and nearby comments state that waitForProcessing defaults to false, but line L272 says 'If waitForProcessing is true (default)' which is the opposite of the actual default assignment. This creates a direct contradiction in tool behavior documentation.

Intent-Code Divergence

Low
Confidence
97% confidence
Finding
The comment says the feed endpoint is public and requires no auth, but paragraph_getFeed calls request(), and request() always requires PARAGRAPH_API_KEY and sends an Authorization header. This is an active documentation contradiction about whether authentication is needed.

Static analysis

Detected: suspicious.env_credential_access, suspicious.exposed_secret_literal

Environment variable access combined with network send.

Critical
Code
suspicious.env_credential_access
Location
skill.js:9

File appears to expose a hardcoded API secret or token.

Critical
Code
suspicious.exposed_secret_literal
Location
SKILL.md:222