Skip to content

[Security] TinyAGI unauthenticated agent ID path traversal escapes the configured workspace root #293

Description

@YLChen-007

Advisory Details

Title: TinyAGI unauthenticated agent ID path traversal escapes the configured workspace root

Description:

Summary

TinyAGI exposes unauthenticated PUT /api/settings and POST /api/message endpoints that let a remote client define an agent with the id .. and then route a message to @... The queue processor later computes path.join(workspacePath, agentId) and bootstraps that agent directory. When agentId is .., TinyAGI initializes the agent in the parent of the configured workspace path instead of inside the intended per-agent workspace root.

Details

I verified this issue end to end against the highest current upstream release tag, v0.0.20, which resolves to commit 1ae8b4eba2fcb963a076655a7293fc77eafeb84c.

The vulnerable flow is:

  1. packages/server/src/routes/settings.ts accepts attacker-controlled JSON and writes settings.agents keys to settings.json without validating the agent id.
  2. packages/core/src/router.ts accepts @.. as a valid route target when the .. key exists in settings.
  3. packages/core/src/invoke.ts uses the resolved agent id directly in path.join(workspacePath, agentId).
  4. packages/core/src/agent.ts bootstraps the new agent directory at that derived path by creating .claude, AGENTS.md, .tinyagi/SOUL.md, memory, and synced skills.

The core issue is that a logical identifier is treated as a trusted filesystem path segment. There is no rejection of reserved ids such as . or .., no separator filtering, and no post-join containment check to ensure the final path remains inside workspacePath.

The vulnerable logic at v0.0.20 is straightforward:

const merged = { ...current, ...body } as Settings;
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2) + '\n');

const candidateId = match[1].toLowerCase();
if (agents[candidateId]) {
    return { agentId: candidateId, message };
}

const agentDir = path.join(workspacePath, agentId);
ensureAgentDirectory(agentDir);

With a benign id such as safeagent, bootstrap files are created under workspace/safeagent. With the attacker-controlled id .., the same bootstrap happens under TINYAGI_HOME, leaving the configured workspace empty and violating the intended workspace boundary.

PoC

Prerequisites

  • Check out TinyAGI v0.0.20 or another affected revision at or below that release.
  • Install dependencies and build the project so packages/main/dist/index.js exists.
  • Have python3 and node available.
  • No authentication or special runtime flags are required.

Reproduction Steps

  1. Download the exploit script from: verification_test.py
  2. Download the control script from: control-safe-agent.py
  3. From the TinyAGI checkout, run the control case:
    TINYAGI_ROOT=/path/to/tinyclaw python3 control-safe-agent.py
  4. Confirm the control case prints [CONTROL-PROTECTED] and records bootstrap files only under workspace/safeagent.
  5. Run the exploit case:
    TINYAGI_ROOT=/path/to/tinyclaw python3 verification_test.py
  6. Confirm the exploit case prints [DEFECT-CONFIRMED].
  7. Inspect the generated verification-result.json. In the vulnerable run, the temporary TINYAGI_HOME root contains .agents, .claude, and memory, while workspace_tree is empty and workspace_agent_exists is false.

Log of Evidence

Control run:
  [CONTROL-PROTECTED]
  "workspace_safe_exists": true
  "root_agents_exists": false
  "root_claude_exists": false
  "root_memory_exists": false

Exploit run:
  [INFO] [API] Settings updated
  [INFO] [API] Message enqueued: @.. hello
  [INFO] Processing [api] from API: @.. hello
  [ERROR] Claude error (agent: ..): Custom provider 'missing' not found in settings.custom_providers
  [DEFECT-CONFIRMED]
  "root_agents_exists": true
  "root_claude_exists": true
  "root_memory_exists": true
  "workspace_tree": []
  "workspace_agent_exists": false

The provider lookup failure happens after invokeAgent() has already initialized the escaped directory, so the defect does not depend on a successful model invocation.

Impact

Any client that can reach TinyAGI's unauthenticated HTTP API can break the configured per-agent workspace boundary and force TinyAGI to create bootstrap files outside the intended workspace root. In practice this gives a remote attacker an unauthorized filesystem write primitive within TINYAGI_HOME, pollutes shared agent state, and breaks the isolation assumptions between agent workspaces and global application state.

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:N/I:H/A:L

Weaknesses

  • CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Occurrences

Permalink Description
https://github.com/TinyAGI/tinyclaw/blob/1ae8b4eba2fcb963a076655a7293fc77eafeb84c/packages/server/src/routes/settings.ts#L36-L40 PUT /api/settings merges attacker-controlled JSON into the persisted settings without validating agent ids before writing settings.json.
https://github.com/TinyAGI/tinyclaw/blob/1ae8b4eba2fcb963a076655a7293fc77eafeb84c/packages/core/src/router.ts#L15-L22 parseAgentRouting() accepts @.. as a routable target when a .. entry exists in the agents map.
https://github.com/TinyAGI/tinyclaw/blob/1ae8b4eba2fcb963a076655a7293fc77eafeb84c/packages/core/src/invoke.ts#L191-L194 invokeAgent() joins the untrusted agentId directly into workspacePath and immediately passes the result to the filesystem bootstrap sink.
https://github.com/TinyAGI/tinyclaw/blob/1ae8b4eba2fcb963a076655a7293fc77eafeb84c/packages/core/src/agent.ts#L86-L88 ensureAgentDirectory() creates the resolved directory path recursively without verifying that the path remains under the configured workspace root.
https://github.com/TinyAGI/tinyclaw/blob/1ae8b4eba2fcb963a076655a7293fc77eafeb84c/packages/core/src/agent.ts#L103-L116 The bootstrap routine then writes AGENTS.md, .tinyagi/SOUL.md, and memory into that escaped location, making the workspace escape observable on disk.

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