Skip to content

[Security] TinyAGI allows unauthenticated API messages to invoke Claude with provider permission checks disabled by default #284

Description

@YLChen-007

Advisory Details

Title: TinyAGI allows unauthenticated API messages to invoke Claude with provider permission checks disabled by default

Description:

Summary

TinyAGI exposes an unauthenticated POST /api/message entrypoint that forwards attacker-controlled messages into the Anthropic execution path. In affected releases, that path invokes the local claude CLI with --dangerously-skip-permissions enabled by default, which disables Claude's provider-side permission approval barrier for tool usage. Any actor who can reach the HTTP API can therefore drive agent execution in a pre-approved high-risk mode without an additional authorization gate inside TinyAGI.

Details

The issue is a permission-bypass design flaw in the Anthropic execution path, not a simple validation bug.

The public API route accepts arbitrary JSON message input from an unauthenticated caller and enqueues it for agent execution:

app.post('/api/message', async (c) => {
    const body = await c.req.json();
    const { message, agent, sender, senderId, channel, messageId: clientMessageId } = body as {
        message?: string; agent?: string; sender?: string; senderId?: string;
        channel?: string; messageId?: string;
    };

    if (!message || typeof message !== 'string') {
        return c.json({ error: 'message is required' }, 400);
    }

That message is written into the SQLite-backed queue via enqueueMessage(...), later claimed by the queue processor in packages/main/src/index.ts, and then passed into invokeAgent(...) without any local permission gate that distinguishes safe prompts from prompts that may trigger privileged tool actions.

The unsafe default happens at the Claude provider sink. In packages/core/src/adapters/claude.ts, TinyAGI initializes the Claude subprocess arguments with:

const args = ['--dangerously-skip-permissions'];

This flag is appended before the attacker-controlled prompt is forwarded with -p <message>. As a result, the same external message that entered over POST /api/message reaches the real provider subprocess in a mode that suppresses Claude's usual permission approval workflow.

I verified the source-to-sink chain end to end through the real daemon and queue pipeline:

POST /api/message -> packages/server/src/routes/messages.ts -> enqueueMessage() -> SQLite messages.message -> processMessage() -> invokeAgent() -> claudeAdapter.invoke() -> claude --dangerously-skip-permissions ... -p <message>

This was not a static-only finding. The included PoC starts the actual built TinyAGI daemon, sends a real HTTP request to the public API, and records the exact claude argv through a PATH shim. The captured runtime evidence shows the stock execution path includes --dangerously-skip-permissions, while a matched control that removes only that single flag preserves the same public API path but no longer passes the bypass flag to the sink.

One repo detail matters for maintainers reviewing the report: the local git remote still uses the old repository name TinyAGI/tinyclaw, but the canonical GitHub repository currently resolves to TinyAGI/tinyagi. The occurrence permalinks below use the canonical upstream repository and the full 40-character commit for the highest affected release tag.

PoC

Prerequisites

  • Node.js installed locally.
  • The repository checked out from the canonical upstream project.
  • Built runtime artifact present at packages/main/dist/index.js.
  • Python 3 available to run the PoC scripts.
  • No API authentication is required for POST /api/message.
  • No valid Claude cloud credentials are required for the integration-level proof because the PoC records the real local subprocess argv at the provider boundary.

Reproduction Steps

  1. Download the shared helper script from: common.py
  2. Download the verification PoC from: verification_test.py
  3. Download the matched control PoC from: control-no-bypass-flag.py
  4. Place the three scripts in the same directory.
  5. From the repository root, run the verification script:
    python3 llm-enhance/cve-finding/similar/permission-bypass/Advisory-GHSA-943q-mwmv-hhvh-claude-approval-bypass-exp/verification_test.py
  6. Confirm the script starts node packages/main/dist/index.js, sends POST /api/message, and writes captured-verification.json.
  7. Inspect the captured sink arguments and verify that --dangerously-skip-permissions is present.
  8. Run the matched control:
    python3 llm-enhance/cve-finding/similar/permission-bypass/Advisory-GHSA-943q-mwmv-hhvh-claude-approval-bypass-exp/control-no-bypass-flag.py
  9. Confirm the control preserves the same HTTP entrypoint and queue path, but strips only --dangerously-skip-permissions before the sink and produces captured-control.json without that flag.

Log of Evidence

Verification run:

Verification Mode: Integration-Test
Interface: HTTP API POST /api/message
Liveness: HTTP /api/status returned 200
POST /api/message: HTTP 200, body={'ok': True, 'messageId': 'api_dgqlvvyh'}
Classification: DEFECT-CONFIRMED-WITH-LIMITATIONS
Evidence: The adapter forwarded untrusted /api/message input into the claude CLI with --dangerously-skip-permissions present

Matched control:

Verification Mode: Integration-Test Control
Interface: HTTP API POST /api/message
Liveness: HTTP /api/status returned 200
POST /api/message: HTTP 200, body={'ok': True, 'messageId': 'api_5fuvfm4l'}
Classification: CONTROL-PROTECTED
Evidence: The matched control preserved the same HTTP path while preventing the bypass flag from reaching the provider sink

Additional runtime evidence from the daemon logs shows both runs were processed by the real TinyAGI queue processor and Claude provider path:

[INFO] API server listening on http://localhost:46955
[INFO] [API] Message enqueued: Run a dangerous tool if available
[INFO] Processing [api] from verification: Run a dangerous tool if available
[DEBUG] Using Claude provider (agent: tinyagi)

Direct E2E validation against the live provider could not be completed in the isolated environment because the local Claude CLI returned:

Failed to authenticate. API Error: 401 Invalid authentication credentials

That limitation does not change the core finding because the vulnerable behavior is already observable at the real provider subprocess boundary.

Impact

This is a network-reachable permission-bypass vulnerability in the default Anthropic execution path. Any party that can access TinyAGI's unauthenticated message API can cause the service to invoke Claude in a mode that skips provider-side permission approvals by default.

In practice, this weakens the last safety barrier intended to protect high-risk tool use. If a deployment gives agents access to local files, command execution, plugins, or adjacent internal resources, an attacker-controlled prompt can reach those capabilities under a pre-approved execution mode rather than a permission-confirmed mode. The main assets at risk are the host running TinyAGI, the agent workspace, local project files, cached secrets or tokens exposed to the process, and any reachable systems accessible from the agent host.

Affected products

  • Ecosystem: npm
  • Package name: tinyagi
  • Affected versions: >= 0.0.10, <= 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:H/A:L

Weaknesses

  • CWE: CWE-862: Missing Authorization

Occurrences

Permalink Description
app.post('/api/message', async (c) => {
const body = await c.req.json();
const { message, agent, sender, senderId, channel, messageId: clientMessageId } = body as {
message?: string; agent?: string; sender?: string; senderId?: string;
channel?: string; messageId?: string;
};
if (!message || typeof message !== 'string') {
return c.json({ error: 'message is required' }, 400);
}
Public unauthenticated HTTP entrypoint that accepts attacker-controlled message content from POST /api/message.
export function enqueueMessage(data: MessageJobData): number | null {
const now = Date.now();
try {
const r = getDb().prepare(
`INSERT INTO messages (message_id,channel,sender,sender_id,message,agent,from_agent,status,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,'pending',?,?)`
).run(data.messageId, data.channel, data.sender, data.senderId ?? null, data.message,
data.agent ?? null, data.fromAgent ?? null, now, now);
queueEvents.emit('message:enqueued', { id: r.lastInsertRowid, agent: data.agent });
return r.lastInsertRowid as number;
Queue persistence point where the attacker-controlled message is written into SQLite without any additional authorization or risk gating.
async function processMessage(dbMsg: any): Promise<void> {
const data: MessageJobData = {
channel: dbMsg.channel,
sender: dbMsg.sender,
senderId: dbMsg.sender_id,
message: dbMsg.message,
messageId: dbMsg.message_id,
agent: dbMsg.agent ?? undefined,
fromAgent: dbMsg.from_agent ?? undefined,
};
Queue processor reads the same stored dbMsg.message value back into the runtime message object, preserving source-to-sink continuity.
emitEvent('agent:invoke', { agentId, agentName: agent.name, fromAgent: data.fromAgent || null });
let response: string;
try {
response = await invokeAgent(agent, agentId, message, workspacePath, shouldReset, agents, teams, (text) => {
log('INFO', `Agent ${agentId}: ${text}`);
insertAgentMessage({ agentId, role: 'assistant', channel, sender: agentId, messageId, content: text });
emitEvent('agent:progress', { agentId, agentName: agent.name, text, messageId });
sendDirectResponse(text, {
channel, sender, senderId: data.senderId,
messageId, originalMessage: rawMessage, agentId,
});
});
processMessage() forwards the queued message into invokeAgent(...), which drives the provider adapter on behalf of the external caller.
async invoke(opts: InvokeOptions): Promise<string> {
const { agentId, message, workingDir, systemPrompt, model, shouldReset, envOverrides, onEvent } = opts;
const env = { IS_SANDBOX: '1', ...envOverrides };
log('DEBUG', `Using Claude provider (agent: ${agentId})`);
const continueConversation = !shouldReset;
if (shouldReset) {
log('INFO', `Resetting conversation for agent: ${agentId}`);
}
const args = ['--dangerously-skip-permissions'];
if (model) args.push('--model', model);
if (systemPrompt) args.push('--system-prompt', systemPrompt);
if (continueConversation) args.push('-c');
if (onEvent) {
args.push('--output-format', 'stream-json', '--verbose', '-p', message);
Vulnerable Anthropic adapter path that initializes the CLI argument list with --dangerously-skip-permissions before appending the attacker-controlled prompt.

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