Skip to content

[Bug]: Concurrent allow-always approvals silently lose allowlist entries (last-write-wins race in exec-approvals.json) #44749

Description

@aukei

[Bug]: Concurrent allow-always approvals silently lose allowlist entries (last-write-wins race in exec-approvals.json)

Bug type

Data loss / Race condition

Summary

When multiple exec approval requests are resolved with allow-always concurrently, only the last write to ~/.openclaw/exec-approvals.json persists. Earlier allowlist entries are silently overwritten. This is a classic read-modify-write race with no file locking.

Environment

  • OS: Linux 6.8.0-101-generic (x64)
  • OpenClaw version: 2026.3.x (gateway, headless)
  • Approval channel: Telegram (chat-based /approve <id> allow-always)
  • Agent: Non-default agent id (researcher, not main)

Steps to reproduce

  1. Configure exec approvals with security: "allowlist" and ask: "on-miss".
  2. Have an agent fire 2+ exec commands simultaneously that each require approval (e.g., binaries not yet in the allowlist).
  3. Approve all of them with /approve <id> allow-always.
  4. Check ~/.openclaw/exec-approvals.json.

Expected: All approved binaries appear in the agent's allowlist.
Actual: Only the last approval to complete has its entries persisted. All earlier entries are silently lost.

Reproduction evidence

Test 1 — 4 simultaneous single-binary commands

Commands: echo "test", sort --version, grep --version, find --version
All approved with allow-always.
Completion order: grep → find → sort → echo.
Result: Only /usr/bin/echo (last to complete) was added to the allowlist. sort, grep, find all lost.

Test 2 — 2 simultaneous single-binary commands

Commands: grep --version, cat --version
Both approved with allow-always.
Result: Only /usr/bin/cat (last to complete) persisted. /usr/bin/grep lost.

Test 3 — Single piped command (control — no race)

Command: aaa.sh | bbb.sh | ccc.sh
Approved with allow-always.
Result: All 3 scripts persisted correctly. ✅
(Sequential addAllowlistEntry calls within one approval handler share the same in-memory object.)

Test 4 — 2 simultaneous piped commands (race)

Command 1: aaa.sh | bbb.sh
Command 2: ccc.sh | sort
Both approved with allow-always.
Completion order: Command 2 first, Command 1 second.
Result: Only aaa.sh and bbb.sh persisted (from Command 1, last to complete). ccc.sh from Command 2 was lost. ❌

Test 5 — Single command (control — no race)

Command: sort --version
Approved with allow-always.
Result: /usr/bin/sort persisted correctly. ✅

Test 6 — Single command (control — no race)

Command: grep --version
Approved with allow-always.
Result: /usr/bin/grep persisted correctly. ✅

Pattern

  • Single approval (simple or piped) → all entries persist ✅
  • Two or more concurrent approvals → last-to-complete wins, all earlier entries lost 🐛
  • Applies equally to single-binary and multi-binary pipe commands
  • Within a single piped command, all segments persist (sequential loop on same object)

Root cause

File: src/infra/exec-approvals.ts

addAllowlistEntry (line ~525)

export function addAllowlistEntry(
  approvals: ExecApprovalsFile,
  agentId: string | undefined,
  pattern: string,
) {
  const target = agentId ?? DEFAULT_AGENT_ID;
  const agents = approvals.agents ?? {};
  const existing = agents[target] ?? {};
  const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
  // ...
  allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() });
  agents[target] = { ...existing, allowlist };
  approvals.agents = agents;
  saveExecApprovals(approvals);  // ← writes entire file from in-memory object
}

saveExecApprovals (line ~364)

export function saveExecApprovals(file: ExecApprovalsFile) {
  const filePath = resolveExecApprovalsPath();
  ensureDir(filePath);
  fs.writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
}

Caller in bash-tools.exec-host-gateway.ts (line ~237)

} else if (decision === "allow-always") {
  approvedByAsk = true;
  if (hostSecurity === "allowlist") {
    const patterns = resolveAllowAlwaysPatterns({
      segments: allowlistEval.segments,
      cwd: params.workdir,
      env: params.env,
      platform: process.platform,
    });
    for (const pattern of patterns) {
      if (pattern) {
        addAllowlistEntry(approvals.file, params.agentId, pattern);
      }
    }
  }
}

Why it happens

The approvals.file object is loaded once at the start of the approval handler (when the exec request is first processed). When two approval requests resolve concurrently:

  1. Request A loaded approvals.file at time T₀ (state: [ls, echo, sort, find, cat, grep])
  2. Request B loaded approvals.file at time T₀ (same state)
  3. Request A resolves → adds ccc.sh → writes file (state now: [..., ccc.sh])
  4. Request B resolves → adds aaa.sh, bbb.sh to its stale T₀ copy → writes file (state now: [..., aaa.sh, bbb.sh]ccc.sh is gone)

There is no file lock, mutex, or re-read-before-write in the write path.

Same race exists in node-host path

src/node-host/invoke-system-run.ts (line ~490) has the same pattern:

addAllowlistEntry(phase.approvals.file, phase.agentId, pattern);

Suggested fix

Option A — Re-read before write (simplest, minimal diff):

export function addAllowlistEntry(
  _staleApprovals: ExecApprovalsFile,  // ignore stale copy
  agentId: string | undefined,
  pattern: string,
) {
  const approvals = loadExecApprovals();  // ← fresh read from disk
  const target = agentId ?? DEFAULT_AGENT_ID;
  const agents = approvals.agents ?? {};
  const existing = agents[target] ?? {};
  const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
  const trimmed = pattern.trim();
  if (!trimmed) return;
  if (allowlist.some((entry) => entry.pattern === trimmed)) return;
  allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() });
  agents[target] = { ...existing, allowlist };
  approvals.agents = agents;
  saveExecApprovals(approvals);
}

This shrinks the race window significantly (read+write are adjacent) but doesn't eliminate it entirely under extreme concurrency.

Option B — In-process mutex (most robust for single-process Node.js):

let writeLock = Promise.resolve();

export function addAllowlistEntry(
  _staleApprovals: ExecApprovalsFile,
  agentId: string | undefined,
  pattern: string,
) {
  writeLock = writeLock.then(() => {
    const approvals = loadExecApprovals();
    // ... add entry ...
    saveExecApprovals(approvals);
  });
}

Option C — File-level locking (cross-process safe):
Use proper-lockfile or fd-level locking. Relevant if macOS companion app and gateway can both write to the same exec-approvals.json.

Impact

  • Users who batch-approve multiple commands lose allowlist entries silently
  • Especially common when agents fire multiple exec calls in a single turn (parallel tool calls) — a normal pattern for AI agents
  • Users end up repeatedly approving the same commands, believing allow-always is broken
  • Affects both gateway-host and node-host approval paths
  • More severe for non-default agents (new agents.<id> section gets overwritten more easily)

Workaround

Approve commands one at a time, waiting for each to complete before approving the next.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-security-reviewClawSweeper marked this issue as needing security-sensitive review.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:data-lossCan lose, corrupt, or silently drop user/session/config data.impact:securitySecurity boundary, credential, authz, sandbox, or sensitive-data risk.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.maturity:stableIssue affects a taxonomy feature currently scored M4/M5.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions