Skip to content

[Security] Unauthenticated prompt_file update allows arbitrary local file read into provider-bound prompts #285

Description

@YLChen-007

Advisory Details

Title: Unauthenticated prompt_file update allows arbitrary local file read into provider-bound prompts

Description:
TinyAGI <= 0.0.20 allows any client that can reach the HTTP management API to set an agent's prompt_file to an arbitrary readable local path and have that file's contents appended to the next provider-bound system prompt.

Summary

The PUT /api/agents/:id management route accepts attacker-controlled prompt_file values without authentication or path validation. On the next normal POST /api/message invocation, TinyAGI reads the referenced host file with fs.readFileSync() and appends the contents to the system prompt before sending it to the configured model provider path. I reproduced this end-to-end on the latest release tag v0.0.20 by setting prompt_file=/etc/hosts and observing /etc/hosts content inside the captured provider-bound prompt.

Details

At release v0.0.20, the API server mounts agentsRoutes immediately after a global CORS middleware and does not place an authentication guard in front of the agent-management routes. The PUT /api/agents/:id handler then persists body.prompt_file directly into agent settings:

s.agents[agentId] = {
    name: body.name!,
    provider: body.provider!,
    model: body.model!,
    working_directory: workingDir,
    ...(body.prompt_file ? { prompt_file: body.prompt_file } : {}),
};

That value later reaches the normal invocation path. invokeAgent() passes agent.prompt_file into buildSystemPrompt(), and buildSystemPrompt() reads the referenced file from the host filesystem and appends the result to the system prompt:

if (configPromptFile) {
    promptFileContent = fs.readFileSync(configPromptFile, 'utf8').trim();
    if (promptFileContent) {
        prompt += '\n\n' + promptFileContent;
    }
}

In the verified path, packages/core/src/adapters/opencode.ts serializes the resulting prompt into OPENCODE_CONFIG_CONTENT, which is then consumed by the provider-side command. The vulnerability therefore crosses the trust boundary from untrusted HTTP input into local filesystem reads and then into provider-facing prompt material.

I verified the issue on the v0.0.20 code path with the real TinyAGI daemon started from packages/main/dist/index.js. The experiment run set prompt_file=/etc/hosts, issued a normal POST /api/message, and captured /etc/hosts content inside captured_prompt.json. The control run set prompt_file to a workspace-local allowed-prompt.md file and confirmed that the workspace canary appeared while /etc/hosts content did not. The reproduction uses a local fake opencode binary only as a capture sink; the unsafe file read itself happens earlier in buildSystemPrompt().

PoC

Prerequisites

  • A checkout of the canonical upstream repository https://github.com/TinyAGI/tinyagi at or before v0.0.20.
  • Node.js dependencies installed and build artifacts present so packages/main/dist/index.js exists. Example: npm install && npm run build.
  • Python 3 available locally.
  • Ability to bind the local TinyAGI API port 3777.
  • A readable benign target file for verification. The provided PoC uses /etc/hosts.

Reproduction Steps

  1. Download the verification PoC from: verification_test.py
  2. Download the shared harness from: harness.py
  3. Download the local capture helper from: opencode, save it as fakebin/opencode, and run chmod +x fakebin/opencode
  4. Download the control script from: control-workspace-prompt.py
  5. From the repository root, place the downloaded files in a directory together, or reuse the copies already present under llm-enhance/cve-finding/similar/info-leak/Advisory-GHSA-jq4x-98m3-ggq6-prompt-file-local-read-exp/
  6. Run the verification flow:
    python3 llm-enhance/cve-finding/similar/info-leak/Advisory-GHSA-jq4x-98m3-ggq6-prompt-file-local-read-exp/verification_test.py
  7. Run the control flow:
    python3 llm-enhance/cve-finding/similar/info-leak/Advisory-GHSA-jq4x-98m3-ggq6-prompt-file-local-read-exp/control-workspace-prompt.py
  8. Inspect captured_prompt.json, verification.log, control.log, and server.stdout.log

Log of Evidence

[Mode] End-to-End
[Test Input] unauthenticated HTTP client controls agent.prompt_file
[Interface] PUT /api/agents/:id then POST /api/message
[Processing] settings mutation -> invokeAgent() -> buildSystemPrompt()
[Sink] fs.readFileSync(configPromptFile) content forwarded to provider adapter
Prompt file under test: /etc/hosts
Leaked /etc/hosts content into provider-bound prompt: True
[DEFECT CONFIRMED] Arbitrary host file contents reached the provider-facing prompt over the public API flow

[Mode] End-to-End
[Control] Same public API flow with a workspace-local prompt file
[Interface] PUT /api/agents/:id then POST /api/message
Workspace canary present in provider-bound prompt: True
/etc/hosts content present in control prompt: False
[CONTROL PASSED] Legitimate workspace prompt content is forwarded while unrelated host file content is absent

server.stdout.log from the reproduction also showed the real request and invocation path:

[INFO] API server listening on http://localhost:3777
[INFO] [API] Agent 'reader' saved
[INFO] [API] Message enqueued: reply with one word
[INFO] Processing [api] from API: reply with one word
[DEBUG] Using OpenCode CLI (agent: reader, model: dummy)

Impact

This is an arbitrary local file read that can be triggered by any client that can reach the TinyAGI HTTP API. An attacker can coerce TinyAGI into reading host files such as credentials, API keys, cloud metadata, SSH material, configuration files, or other local secrets that are readable by the TinyAGI service account, and then leak those contents into provider-bound prompt traffic or downstream logs. I verified the downstream leak through the opencode adapter; because the unsafe read happens before adapter invocation, the confidentiality risk applies to the prompt-construction path itself, and any deployment exposing the management API to untrusted clients is affected.

Affected products

  • Ecosystem: npm
  • Package name: tinyagi
  • Affected versions: <= 0.0.20
  • Patched versions:

Severity

  • Severity: High
  • Vector string: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Weaknesses

  • CWE: CWE-73: External Control of File Name or Path

Occurrences

Permalink Description
// CORS middleware
app.use('/*', cors());
// Mount route modules
app.route('/', messagesRoutes);
app.route('/', agentsRoutes);
app.route('/', teamsRoutes);
The API server applies only CORS and mounts agentsRoutes directly, leaving the agent-management endpoints reachable without an authentication gate.
const settings = mutateSettings(s => {
if (!s.agents) s.agents = {};
s.agents[agentId] = {
name: body.name!,
provider: body.provider!,
model: body.model!,
working_directory: workingDir,
...(body.prompt_file ? { prompt_file: body.prompt_file } : {}),
};
PUT /api/agents/:id persists attacker-controlled prompt_file input into agent settings without validation or path restriction.
// Build system prompt in-memory (built-in instructions + teammates + memory + user customization)
const systemPrompt = buildSystemPrompt(agentId, agentDir, agents, teams, agent.system_prompt, agent.prompt_file);
The normal message execution path forwards agent.prompt_file into buildSystemPrompt() during a standard agent invocation.
// Append config system prompt (from settings.json)
let promptFileContent = '';
if (configPromptFile) {
try {
promptFileContent = fs.readFileSync(configPromptFile, 'utf8').trim();
if (promptFileContent) {
prompt += '\n\n' + promptFileContent;
}
buildSystemPrompt() directly reads configPromptFile from the local filesystem with fs.readFileSync() and appends the contents to the system prompt.
// Pass system prompt via OPENCODE_CONFIG_CONTENT env var
if (systemPrompt) {
const configContent = JSON.stringify({
agent: {
[agentId]: {
prompt: systemPrompt
}
}
});
envOverrides.OPENCODE_CONFIG_CONTENT = configContent;
The verified provider path serializes the resulting system prompt into OPENCODE_CONFIG_CONTENT, carrying the leaked file contents into downstream provider-facing input.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions