fix(settings): guard POST /api/setup against agent overwrites#222
fix(settings): guard POST /api/setup against agent overwrites#222jcenters wants to merge 3 commits intoTinyAGI:mainfrom
Conversation
POST /api/setup does a full replacement of settings.json, not a merge. Agents with dangerously-skip-permissions can call this endpoint and silently wipe a live configuration — which is exactly what was happening in the wild. Two changes: - Server: POST /api/setup now returns HTTP 409 if settings.json already has agents configured, unless ?force=true is passed. Also creates a .bak before any overwrite so recovery is possible. - Skill: tinyclaw-admin SKILL.md now warns agents away from both direct file editing and the /api/setup endpoint, and explains why. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds a guard to Key changes:
Issues found:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Agent
participant API as POST /api/setup
participant FS as settings.json
Agent->>API: POST /api/setup (no ?force)
API->>FS: existsSync(SETTINGS_FILE)?
alt File exists with agents
FS-->>API: agents: { ... }
API-->>Agent: 409 — "Pass ?force=true to overwrite" ⚠️ bypass hint exposed
else File missing or no agents
API->>FS: mkdirSync + writeFileSync(settings)
FS-->>API: written
API-->>Agent: 200 ok
end
Agent->>API: POST /api/setup?force=true
API->>FS: existsSync? → copyFileSync → settings.json.bak (single slot, overwritten each time)
API->>FS: writeFileSync(settings)
FS-->>API: written
API-->>Agent: 200 ok
Last reviewed commit: c4bfc58 |
| const existing = (() => { try { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); } catch { return null; } })(); | ||
| if (existing?.agents && Object.keys(existing.agents).length > 0) { | ||
| log('WARN', '[API] Setup blocked: settings.json already has agents configured. Use ?force=true to overwrite.'); | ||
| return c.json({ ok: false, error: 'Settings already configured. Pass ?force=true to overwrite.' }, 409); |
There was a problem hiding this comment.
Guard self-documents its own bypass to agents
The 409 error response body explicitly tells callers how to defeat the guard: "Pass ?force=true to overwrite.". An agent that triggers this error will immediately learn the bypass mechanism from the response itself — without needing to read SKILL.md. This substantially undermines the protection for the very scenario the PR is trying to prevent (an agent hallucinating a setup payload and retrying with the hint from the error).
Consider omitting the ?force=true hint from the agent-facing error body and reserving that detail only for server-side logs (which agents typically can't read directly):
| return c.json({ ok: false, error: 'Settings already configured. Pass ?force=true to overwrite.' }, 409); | |
| return c.json({ ok: false, error: 'Settings already configured. This endpoint is for initial setup only.' }, 409); |
The log message on line 56 already captures the ?force=true detail for human operators, so removing it from the response body doesn't sacrifice operator visibility.
| const backupPath = `${SETTINGS_FILE}.bak`; | ||
| fs.copyFileSync(SETTINGS_FILE, backupPath); | ||
| log('INFO', `[API] Setup: backed up existing settings to ${backupPath}`); |
There was a problem hiding this comment.
Single .bak slot — earlier backups silently overwritten
settings.json.bak is a fixed path, so each call with ?force=true overwrites the previous backup. If an operator (or a misbehaving agent) calls POST /api/setup?force=true more than once, all backups except the most recent one are permanently lost. The PR description frames .bak as a recovery mechanism, but it only guarantees recovery from the last overwrite, not from the original state.
A timestamped backup name (e.g., settings.json.bak.<timestamp>) would make recovery more reliable without much added complexity:
| const backupPath = `${SETTINGS_FILE}.bak`; | |
| fs.copyFileSync(SETTINGS_FILE, backupPath); | |
| log('INFO', `[API] Setup: backed up existing settings to ${backupPath}`); | |
| const backupPath = `${SETTINGS_FILE}.bak.${Date.now()}`; | |
| fs.copyFileSync(SETTINGS_FILE, backupPath); | |
| log('INFO', `[API] Setup: backed up existing settings to ${backupPath}`); |
|
|
||
| ## POST /api/setup is for initial setup only | ||
|
|
||
| `POST /api/setup` **fully replaces** settings.json — it does not merge. It will be rejected with HTTP 409 if agents are already configured, unless `?force=true` is passed. Agents must never call this endpoint on a running system. |
There was a problem hiding this comment.
SKILL.md teaches agents the ?force=true bypass
The SKILL.md is loaded as context by every agent that invokes this skill. This line explicitly teaches agents that a 409 can be resolved by appending ?force=true. A task-oriented agent instructed to "set up tinyclaw" (or to resolve a 409 error it received from POST /api/setup) will read this and know to retry with ?force=true — exactly the overwrite scenario the PR is trying to prevent.
If the intent is that ?force=true is a human-operator-only escape hatch, it should not be documented in agent-readable skill files. Consider moving this detail to a separate operator runbook or docs/ file that is not included in agent context, and changing this line to reinforce the "agents must never call this endpoint" constraint only:
| `POST /api/setup` **fully replaces** settings.json — it does not merge. It will be rejected with HTTP 409 if agents are already configured, unless `?force=true` is passed. Agents must never call this endpoint on a running system. | |
| `POST /api/setup` **fully replaces** `settings.json` — it does not merge. Agents must never call this endpoint on a running system; it is reserved for initial setup by human operators only. |
ensure_agent_skills_links() was unconditionally deleting and re-copying every built-in skill directory on every daemon start. Any skill created by an agent (not present in the source) was silently destroyed on restart. Replace the copy strategy with per-skill symlinks pointing at the source directory. This means: - Built-in skills stay current automatically (symlink always points to source) - Agent-created skills (real directories, not symlinks) are left untouched - Stale symlinks for skills removed from source are cleaned up Also converted existing copied skill directories in all agent workspaces to symlinks so the fix takes effect without requiring a clean reinstall. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous guard allowed ?force=true to skip the 409 check entirely. Agents can discover and exploit query parameters — the tinyclaw-admin skill documents the API in detail — so a bypassable guard is no better than no guard at runtime. Remove the force parameter entirely. The endpoint now unconditionally rejects setup attempts on a configured system. To modify a running installation, use PUT /api/agents/:id, PUT /api/teams/:id, or PUT /api/settings. Updated SKILL.md to match. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Problem
POST /api/setupdoes a full replacement ofsettings.json— not a merge. Agents running with--dangerously-skip-permissionscan call this endpoint (thetinyclaw-adminskill documents it) and silently wipe a live configuration.This is reproducible in practice: agents with a heartbeat or a poorly-scoped task will invoke the admin skill, hallucinate a setup payload, call the endpoint, and replace the real config with partial or empty JSON. On the next restart, TinyClaw comes up with no agents and re-runs setup.
Changes
packages/server/src/routes/settings.tsPOST /api/setupnow returns HTTP 409 ifsettings.jsonalready has agents configured, unless?force=trueis passed.bakbefore any overwrite, so recovery is possible if?force=trueis intentionally used.agents/skills/tinyclaw-admin/SKILL.mdsettings.jsondirectly or callPOST /api/setupon a running systemPOST /api/setupis initial-setup-only and will be rejected on configured systemsTest plan
POST /api/setupwith valid settings succeeds (no agents in file yet)POST /api/setupreturns 409, settings.json unchanged?force=true: overwrites settings.json, creates.bakPUT /api/settings(shallow merge) still works normally🤖 Generated with Claude Code