Skip to content

Branch protection: enforce write-only-on-branch mode for agents #60

@padak

Description

@padak

Problem

When an AI agent operates across multiple Keboola projects with Storage API tokens, there is no mechanism to deterministically prevent writes to the production (main) branch. The current branch use command sets an active dev branch, and tool call propagates it automatically -- but nothing stops the agent from calling write tools without a branch context, which would modify production data.

This was identified as a key concern in AI-2851:

"I would never trust an agent that you turn on multiple projects with Storage token access, there needs to be option to deterministically limit the Write operations only to branches."

Threat model

The adversary is not a human -- it's an AI agent with shell access. The agent can:

  • Read any file (including config.json with tokens)
  • Edit any file (config.json, .env, settings.json)
  • Run any CLI command with any flags
  • Spawn new shells where env vars aren't set
  • Read source code and understand bypass mechanisms

Therefore: any protection mechanism the agent can disable itself is security theater. We need enforcement where the agent lacks the "unlock key".


Proposed approach: Protected by default + passphrase unlock

Recommended for v1. Simple, effective, no platform changes needed.

How it works

  1. project add defaults to protected mode. During registration, user sets a passphrase (interactive prompt). The passphrase hash (bcrypt/scrypt) is stored in config.json. The original passphrase is never stored anywhere.

  2. All writes to main branch are BLOCKED when protection is active. No flag, no env var, no trick can bypass it.

  3. To perform a write on main, the user must provide the passphrase inline (interactive TTY prompt). The agent can't do this -- it doesn't know the passphrase, and it can't reverse the hash from config.json.

  4. To register without protection, use explicit --no-protected flag. This is a conscious user choice at setup time.

UX flow

# Register project (protected by default)
kbagent project add --alias production --url https://connection.keboola.com --token xxx
# Set protection passphrase (required for write bypass): ********
# Project 'production' added. Main branch is PROTECTED.

# Register without protection (explicit opt-out)
kbagent project add --alias sandbox --url ... --token ... --no-protected
# Project 'sandbox' added. Main branch is NOT protected.

# Agent tries to write to protected project's main branch -> BLOCKED
kbagent tool call create_configuration --project production --input '{...}'
# ERROR: Write to main branch blocked. Project 'production' is protected.
# Use 'kbagent branch use' to work on a dev branch, or provide passphrase to unlock.

# Agent works on dev branch -> ALLOWED (this is the intended workflow)
kbagent branch create --project production --name "agent-work"
kbagent branch use --project production --branch 12345
kbagent tool call create_configuration --project production --input '{...}'
# OK -- writing to branch 12345

# Human needs to write to main (rare) -> passphrase prompt
kbagent tool call create_configuration --project production --input '{...}' --force
# Enter protection passphrase: ********
# OK -- write to main branch allowed (one-time unlock)

Why the agent can't bypass this

Attack vector Why it fails
Agent reads config.json Only sees bcrypt hash, can't reverse to passphrase
Agent edits config.json (removes protection) kbagent detects tampered/missing HMAC -> fail-closed (treated as protected)
Agent sets env var to disable No env var bypass exists
Agent passes --no-protected flag Flag only works on project add, not on runtime commands
Agent uses --force flag --force triggers interactive passphrase prompt, agent can't type it
Agent pipes passphrase via echo --force reads from /dev/tty directly, not stdin (same pattern as sudo)
Agent calls Storage API directly Agent doesn't have httpx/curl knowledge of Keboola API; and if it does, that's out of scope for CLI-level protection (see Layer 3)

Fail-closed design

The protection record in config.json contains:

{
  "projects": {
    "production": {
      "url": "https://connection.keboola.com",
      "token": "...",
      "protection": {
        "enabled": true,
        "passphrase_hash": "$2b$12$...",
        "salt": "random-bytes",
        "hmac": "sha256(enabled + hash + salt + token)"
      }
    }
  }
}

The HMAC covers the entire protection block. If ANY field is modified or deleted:

  • HMAC verification fails -> treated as protected (fail-closed)
  • The HMAC key is derived from the passphrase itself, so the agent can't recompute it

This means the agent cannot downgrade protection by editing the config file.

Implementation notes

  • Write/read classification already exists in McpService (prefix-based: create_*, update_*, delete_*, add_*)
  • Enforcement point: validate_and_call_tool() in mcp_service.py -- check before dispatch
  • branch_id is already resolved in the CLI layer (tool.py:_resolve_branch) -- gate writes when it resolves to None on a protected project
  • Should also apply to sync push when writing configs back to Keboola
  • doctor command should report protection status per project
  • project status should show protection state
  • Interactive passphrase input via /dev/tty (not stdin) to prevent pipe bypass

Additional layers (defense in depth)

Layer 2: Claude Code hooks (for Claude Code users)

For users running agents via Claude Code specifically, a pre-tool hook adds a second enforcement layer:

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "command": "kbagent guard --check \"$TOOL_INPUT\""
    }]
  },
  "permissions": {
    "deny": [
      "Edit(.claude/settings.json)",
      "Write(.claude/settings.json)"
    ]
  }
}

Good for: Extra layer on top of passphrase protection. The hook intercepts commands before they even reach kbagent. Deny rules prevent the agent from removing the hook.

Limitation: Claude Code specific. Other agent frameworks (Codex, Cursor, etc.) don't have this mechanism.

Layer 3: Branch-scoped Storage tokens (Keboola platform, future)

The most robust long-term solution: the Storage API token itself is scoped to dev branches only.

Token capability: branch_only = true
-> Any write to main branch returns HTTP 403 from the API server

Good for: Unbypassable. Server-side enforcement. Works regardless of which client (CLI, MCP, direct API, curl) the agent uses.

Limitation: Requires Keboola platform changes. Not available today.

Comparison of all layers

Passphrase lock (v1) Claude Code hooks Branch-scoped tokens
Agent can disable? No (doesn't know passphrase) Hard (deny rules) No (server-side)
Agent can bypass? No (fail-closed HMAC, /dev/tty) Possible (new shell outside CC) No
Works with any agent? Yes Claude Code only Yes
Needs platform changes? No No Yes
Implementation effort Medium Low High (Keboola team)
Protection scope CLI commands + sync All Bash commands All API access

Recommendation: Implement Layer 1 (passphrase lock) as v1. It's agent-agnostic, requires no platform changes, and is robust against all practical bypass attempts. Layer 2 can be documented as optional hardening for Claude Code users. Layer 3 is a long-term suggestion for the Keboola platform team.


Context

  • Related Linear issues: AI-2851, AI-2850
  • Current branch lifecycle: branch create/use/reset/delete/merge
  • Current write gating: none (advisory only via branch use)
  • Config file: ~/.config/keboola-agent-cli/config.json with 0600 permissions

Metadata

Metadata

Assignees

No one assigned

    Labels

    epic/write-protectionSub-issue of #63 (Write Protection + Single-Config Sync)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions