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
-
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.
-
All writes to main branch are BLOCKED when protection is active. No flag, no env var, no trick can bypass it.
-
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.
-
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:
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
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 usecommand sets an active dev branch, andtool callpropagates 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:
Threat model
The adversary is not a human -- it's an AI agent with shell access. The agent can:
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
project adddefaults 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.All writes to main branch are BLOCKED when protection is active. No flag, no env var, no trick can bypass it.
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.
To register without protection, use explicit
--no-protectedflag. This is a conscious user choice at setup time.UX flow
Why the agent can't bypass this
--no-protectedflagproject add, not on runtime commands--forceflag--forcetriggers interactive passphrase prompt, agent can't type it--forcereads from/dev/ttydirectly, not stdin (same pattern assudo)httpx/curlknowledge 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:
This means the agent cannot downgrade protection by editing the config file.
Implementation notes
McpService(prefix-based:create_*,update_*,delete_*,add_*)validate_and_call_tool()inmcp_service.py-- check before dispatchbranch_idis already resolved in the CLI layer (tool.py:_resolve_branch) -- gate writes when it resolves toNoneon a protected projectsync pushwhen writing configs back to Kebooladoctorcommand should report protection status per projectproject statusshould show protection state/dev/tty(not stdin) to prevent pipe bypassAdditional 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:
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.
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
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
branch create/use/reset/delete/mergebranch use)~/.config/keboola-agent-cli/config.jsonwith0600permissions