From c4bfc5822f1e0eddac3e5e8ee061d0768146237a Mon Sep 17 00:00:00 2001 From: Josh Centers Date: Mon, 16 Mar 2026 01:38:09 +0100 Subject: [PATCH 1/3] fix(settings): guard POST /api/setup against agent overwrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/skills/tinyclaw-admin/SKILL.md | 13 ++++++++++++- packages/server/src/routes/settings.ts | 21 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.agents/skills/tinyclaw-admin/SKILL.md b/.agents/skills/tinyclaw-admin/SKILL.md index 55037101..653717b2 100644 --- a/.agents/skills/tinyclaw-admin/SKILL.md +++ b/.agents/skills/tinyclaw-admin/SKILL.md @@ -174,11 +174,18 @@ curl -s http://localhost:3777/api/logs?limit=50 | jq ## Direct settings.json editing -When the API server is not running, edit `~/.tinyclaw/settings.json` directly. Use `jq` for safe atomic edits: +> **WARNING — agents should not edit settings.json directly.** Direct edits bypass all validation and can corrupt or destroy the running configuration. Agents running with `--dangerously-skip-permissions` have unrestricted file access, which makes an accidental overwrite unrecoverable. Always prefer the REST API (`PUT /api/agents`, `PUT /api/teams`, `PUT /api/settings`) which validates input, merges safely, and keeps the system consistent. +> +> Direct editing is a last resort for **human operators only** when the API server is not running and there is no other option. + +If a human operator must edit `~/.tinyclaw/settings.json` while the daemon is stopped, back up the file first and use `jq` for atomic edits: ```bash SETTINGS="$HOME/.tinyclaw/settings.json" +# Always back up first +cp "$SETTINGS" "$SETTINGS.bak" + # Add an agent jq --arg id "analyst" --argjson agent '{"name":"Analyst","provider":"anthropic","model":"sonnet","working_directory":"'$HOME'/tinyclaw-workspace/analyst"}' \ '.agents[$id] = $agent' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS" @@ -193,6 +200,10 @@ jq --arg id "research" --argjson team '{"name":"Research Team","agents":["analys After editing `settings.json`, run `tinyclaw restart` to pick up changes. +## 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. + ## Modifying TinyClaw source code When modifying TinyClaw's own code (features, bug fixes, new routes, etc.): diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index eb52ff46..ae63f39a 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -43,9 +43,21 @@ app.put('/api/settings', async (c) => { }); // POST /api/setup — run initial setup (write settings + create directories) +// Requires ?force=true if settings.json already exists with agents configured, +// to prevent agents from accidentally wiping a live configuration. app.post('/api/setup', async (c) => { + const force = c.req.query('force') === 'true'; const settings = (await c.req.json()) as Settings; + // Guard: refuse to overwrite an existing configured installation unless forced + if (!force && fs.existsSync(SETTINGS_FILE)) { + 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); + } + } + if (settings.workspace?.path) { settings.workspace.path = expandHomePath(settings.workspace.path); } @@ -57,8 +69,15 @@ app.post('/api/setup', async (c) => { } } - // Write settings.json + // Back up existing settings before overwriting fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); + if (fs.existsSync(SETTINGS_FILE)) { + const backupPath = `${SETTINGS_FILE}.bak`; + fs.copyFileSync(SETTINGS_FILE, backupPath); + log('INFO', `[API] Setup: backed up existing settings to ${backupPath}`); + } + + // Write settings.json fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + '\n'); log('INFO', '[API] Setup: settings.json written'); From 98061d242596dc64545795a5c4f66cb983a18116 Mon Sep 17 00:00:00 2001 From: Josh Centers Date: Mon, 16 Mar 2026 02:14:23 +0100 Subject: [PATCH 2/3] fix(daemon): use symlinks for agent skills instead of rm -rf + copy 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 --- lib/daemon.sh | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/lib/daemon.sh b/lib/daemon.sh index 11e1ea08..29d82776 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -373,8 +373,14 @@ status_daemon() { # --- Agent skills management (called by start_daemon) --- -# Ensure all agent workspaces have .agents/skills synced from SCRIPT_DIR -# and .claude/skills as a symlink to .agents/skills +# Ensure all agent workspaces have .agents/skills populated from SCRIPT_DIR +# and .claude/skills as a symlink to .agents/skills. +# +# Each built-in skill is installed as a symlink into the agent's .agents/skills/ +# directory rather than a copy. This means: +# - Built-in skills stay current without wiping agent-created skills. +# - Skills created by agents (not in the source) are left untouched. +# - Stale symlinks (skill removed from source) are cleaned up automatically. ensure_agent_skills_links() { local skills_src="$SCRIPT_DIR/.agents/skills" [ -d "$skills_src" ] || return 0 @@ -389,14 +395,38 @@ ensure_agent_skills_links() { local agent_dir="$agents_dir/$agent_id" [ -d "$agent_dir" ] || continue - # Sync default skills into .agents/skills mkdir -p "$agent_dir/.agents/skills" + + # Install each built-in skill as a symlink (absolute path). + # If a real directory exists with the same name (agent-created copy), + # leave it alone — don't overwrite agent work. for skill_dir in "$skills_src"/*/; do [ -d "$skill_dir" ] || continue local skill_name skill_name="$(basename "$skill_dir")" - rm -rf "$agent_dir/.agents/skills/$skill_name" - cp -r "$skill_dir" "$agent_dir/.agents/skills/$skill_name" + local dest="$agent_dir/.agents/skills/$skill_name" + + if [ -L "$dest" ]; then + # Already a symlink — update it to point to current source + ln -sfn "$skill_dir" "$dest" + elif [ -d "$dest" ]; then + # Real directory: agent-created skill, leave it alone + : + else + # Not present — create symlink + ln -s "$skill_dir" "$dest" + fi + done + + # Remove symlinks that point to skills no longer in source + # (built-in skill was deleted upstream). Real directories are kept. + for dest in "$agent_dir/.agents/skills"/*/; do + [ -L "${dest%/}" ] || continue + local skill_name + skill_name="$(basename "$dest")" + if [ ! -d "$skills_src/$skill_name" ]; then + rm "$agent_dir/.agents/skills/$skill_name" + fi done # Ensure .claude/skills is a symlink to ../.agents/skills From b990fc5360bbecf1220da000965e4e5e91174ebc Mon Sep 17 00:00:00 2001 From: Josh Centers Date: Mon, 16 Mar 2026 02:17:34 +0100 Subject: [PATCH 3/3] fix(settings): remove ?force=true bypass from POST /api/setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/skills/tinyclaw-admin/SKILL.md | 2 +- packages/server/src/routes/settings.ts | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.agents/skills/tinyclaw-admin/SKILL.md b/.agents/skills/tinyclaw-admin/SKILL.md index 653717b2..2ded653c 100644 --- a/.agents/skills/tinyclaw-admin/SKILL.md +++ b/.agents/skills/tinyclaw-admin/SKILL.md @@ -202,7 +202,7 @@ After editing `settings.json`, run `tinyclaw restart` to pick up changes. ## 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. +`POST /api/setup` **fully replaces** settings.json — it does not merge. It will be rejected with HTTP 409 if agents are already configured. There is no override. To modify a running system, use `PUT /api/agents/:id`, `PUT /api/teams/:id`, or `PUT /api/settings`. ## Modifying TinyClaw source code diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index ae63f39a..11b6d20f 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -43,18 +43,21 @@ app.put('/api/settings', async (c) => { }); // POST /api/setup — run initial setup (write settings + create directories) -// Requires ?force=true if settings.json already exists with agents configured, -// to prevent agents from accidentally wiping a live configuration. +// Blocked if settings.json already exists with agents configured. +// To reconfigure a live system, use PUT /api/agents/:id, PUT /api/teams/:id, +// or PUT /api/settings instead. app.post('/api/setup', async (c) => { - const force = c.req.query('force') === 'true'; const settings = (await c.req.json()) as Settings; - // Guard: refuse to overwrite an existing configured installation unless forced - if (!force && fs.existsSync(SETTINGS_FILE)) { + // Guard: refuse to overwrite an existing configured installation. + // There is intentionally no bypass parameter — agents can discover and + // exploit query parameters. Use the individual PUT endpoints to modify + // a running configuration. + if (fs.existsSync(SETTINGS_FILE)) { 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); + log('WARN', '[API] Setup blocked: settings.json already has agents configured.'); + return c.json({ ok: false, error: 'Settings already configured. Use PUT /api/agents, /api/teams, or /api/settings to modify a running system.' }, 409); } }