diff --git a/docker/agent-grok.Dockerfile b/docker/agent-grok.Dockerfile index 8ff54ccc..ef9e5edd 100644 --- a/docker/agent-grok.Dockerfile +++ b/docker/agent-grok.Dockerfile @@ -20,6 +20,10 @@ RUN npm install -g opencode-ai @ai-sdk/openai-compatible \ && npm cache clean --force \ && rm -rf /root/.npm /tmp/* +# Command guard / secret-scrub plugin (bash-guard parity for the opencode runtime). +# Referenced from the generated opencode.json `plugin:` array. +COPY docker/grok/secret-scrub.js /app/opencode-plugins/secret-scrub.js + # Entrypoint: render opencode.json, then run opencode (overrides base's `claude`). COPY docker/scripts/grok-agent-entrypoint.sh /app/scripts/grok-agent-entrypoint.sh RUN chmod 0755 /app/scripts/grok-agent-entrypoint.sh diff --git a/docker/grok/secret-scrub.js b/docker/grok/secret-scrub.js new file mode 100644 index 00000000..2092b812 --- /dev/null +++ b/docker/grok/secret-scrub.js @@ -0,0 +1,133 @@ +// opencode plugin — command guard / secret-scrub for RoboCo Grok agents. +// +// Ports the security-critical deny rules from docker/scripts/bash-guard-hook.sh +// (the Claude Code PreToolUse guard) to opencode's `tool.execute.before` hook. +// Those rules are Claude Code hooks and do NOT transfer to the opencode runtime, +// so a Grok agent would otherwise run bash unguarded — this restores parity. +// +// Mechanism (confirmed by opencode's own env-protection plugin example): +// throwing inside `tool.execute.before` denies the tool call. For `bash` the +// command is `output.args.command`; for `read`/`edit` the path is +// `output.args.filePath`. +// +// Loaded via the generated opencode.json `plugin:` array (see +// roboco.llm.providers.opencode_config). The agent's bash permission can also +// be set to "deny"/"ask" via ROBOCO_GROK_BASH_PERMISSION as a second gate. +// +// STATUS: unvalidated against a live opencode runtime. Confirm it actually +// fires in the live E2E spawn before pointing a Grok dev-agent at a real repo. +// Deny-on-match is fail-closed: a false positive blocks a legitimate command +// (annoying, safe) rather than letting a dangerous one through. + +const CREDENTIAL_FILE = + /(\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519|id_ecdsa|known_hosts)/; + +const INTERNAL_HOST = + /((https?|wss?):\/\/)?\/?(roboco-[a-z0-9_-]+|localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]/; + +const HTTP_CLIENT_LIB = + /(httpx|requests|urllib|aiohttp|http\.client|httplib|net\/http|net::http|node-fetch|axios|xmlhttprequest|websocket|fetch\s*\()/; + +// Each check takes the lowercased bash command and returns a deny reason, or +// null to allow. Mirrors the categories in bash-guard-hook.sh. +const BASH_CHECKS = [ + (low) => + /(^|[\s;&|])git\s+(fetch|pull|push|clone|remote|ls-remote|checkout|commit|merge|rebase|reset|cherry-pick|revert|tag\s+-d|update-ref)/.test( + low, + ) + ? "shell git for network/auth/branch-mutating ops is blocked — use your role's MCP verb (commit, complete, i_am_done, ...)." + : null, + (low) => + CREDENTIAL_FILE.test(low) + ? "command references a credential file or SSH key — the PAT is injected subprocess-side by the MCP layer, never read from these files." + : null, + (low) => + /\/proc\/(self|\d+|\$\$)\/(environ|cmdline|cwd|exe)/.test(low) + ? "reading /proc/*/environ or /proc/*/cmdline can leak credentials." + : null, + (low) => + /(^|[\s;&|])(curl|wget|http|https|httpie)\s[^|]*(github\.com|api\.github\.com)/.test( + low, + ) + ? "direct GitHub HTTP calls bypass the PAT handler — use the role-appropriate MCP verb." + : null, + (low) => + /(^|[\s;&|])(curl|wget|http|https|httpie)\s/.test(low) && INTERNAL_HOST.test(low) + ? "internal API calls bypass the gateway — use the MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal)." + : null, + (low) => + HTTP_CLIENT_LIB.test(low) && INTERNAL_HOST.test(low) + ? "reaching an internal host via an HTTP client bypasses the gateway, role manifest, tracing and auth (and can forge X-Agent-* headers). Use your MCP verbs." + : null, + (low) => + /(python3?|uv\s+run|poetry\s+run|pipenv\s+run|pdm\s+run|hatch\s+run)/.test(low) && + /(import\s+roboco|from\s+roboco|-m\s+roboco|roboco\.(mcp|services|runtime|foundation|api|enforcement)\b)/.test( + low, + ) + ? "importing or running roboco.* internals from the shell bypasses the MCP role manifest, tracing and auth. Use your role's MCP verbs." + : null, + (low) => + /(^|[\s;&|]|env\s+|export\s+)roboco_agent_id\s*=/.test(low) + ? "ROBOCO_AGENT_ID is your injected identity — overriding it forges another agent's identity. Never set or export it." + : null, + (low) => + /(^|[\s;&|])(env|printenv)([\s]|$)/.test(low) && + !/(^|[\s;&|])env\s+(-i|[a-z_][a-z0-9_]*=)/.test(low) + ? "env / printenv can leak secrets. Ask for the specific value you need via the task description." + : null, + (low) => + /(^|[\s;&|])set([\s]*$|[\s]*[|;&])/.test(low) || + /(^|[\s;&|])(declare|typeset)\s+-[a-z]*[xp]/.test(low) || + /(^|[\s;&|])export\s+-p([\s]|$)/.test(low) || + /(^|[\s;&|])compgen\s+-[a-z]*[ve]/.test(low) + ? "shell built-ins that dump variables/exports can leak credentials." + : null, + (low) => + /(^|[\s;&|])(base64|od|xxd|hexdump|strings|uuencode)\s[^|;&]*(\.env|\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519)/.test( + low, + ) + ? "encoding/inspecting a credential file is still exfiltration." + : null, + (low) => + /(^|[\s;&|])rm\s[^|;&]*-[a-z]*[rf][a-z]*\s/.test(low) && + /(^|[\s;&|])rm\s[^|;&]*(\/app($|[\s/])|\/root|\/etc|\/var|\/usr|\/bin|\/sbin|\/lib|\/home|\s\/\s*(;|\||&|$))/.test( + low, + ) + ? "rm on a system path. Operate inside your own workspace only." + : null, +]; + +// Tools that take a file path we must keep away from credential files. +const PATH_TOOLS = new Set(["read", "edit", "write"]); + +function denyBash(command) { + const low = String(command || "").toLowerCase(); + if (!low) return null; + for (const check of BASH_CHECKS) { + const reason = check(low); + if (reason) return reason; + } + return null; +} + +export default async () => { + return { + "tool.execute.before": async (input, output) => { + const tool = input?.tool; + const args = output?.args || {}; + if (tool === "bash") { + const reason = denyBash(args.command); + if (reason) throw new Error(`Denied by roboco secret-scrub: ${reason}`); + return; + } + if (PATH_TOOLS.has(tool)) { + const path = String(args.filePath || args.path || "").toLowerCase(); + if (path && CREDENTIAL_FILE.test(path)) { + throw new Error( + "Denied by roboco secret-scrub: access to a credential file / SSH key is blocked.", + ); + } + } + }, + }; +}; diff --git a/roboco/billing/pricing.py b/roboco/billing/pricing.py index d1aec73f..d8ba69b2 100644 --- a/roboco/billing/pricing.py +++ b/roboco/billing/pricing.py @@ -4,11 +4,13 @@ Token pricing for Claude API models. Implements per-model USD cost calculation based on Anthropic's published pricing. All prices are in USD per 1 million tokens. -Pricing is provider-aware. A model name resolves to one of three cases: +Pricing is provider-aware. A model name resolves to one of four cases: * **Anthropic** — priced from the table below by substring match. -* **Non-Anthropic** — local self-hosted Ollama models (``ollama/`` prefix or - bare model tags) and Ollama Cloud models (``:cloud`` tag). These have **no +* **Priced non-Anthropic** — xAI Grok (``grok-build-*``, billed per token via + the xAI API) is priced from the table too. Match by substring like the rest. +* **Free non-Anthropic** — local self-hosted Ollama models (``ollama/`` prefix + or bare model tags) and Ollama Cloud models (``:cloud`` tag). These have **no per-token cost**: local inference runs on owned hardware, and Ollama Cloud is billed by flat subscription / GPU-time rather than per token. Both return an intentional ``0.0`` — not an error condition, so they are not warned on. @@ -55,6 +57,10 @@ _PRICING: list[tuple[str, float, float, float, float]] = [ ("claude-haiku-3-5", 1.00, 5.00, 0.10, 1.25), ("claude-3-5-haiku", 1.00, 5.00, 0.10, 1.25), ("claude-haiku-3", 0.25, 1.25, 0.025, 0.0625), + # xAI Grok — priced non-Anthropic (per-token via the xAI API). Cached-input + # read is $0.20/1M; xAI publishes no cache-write premium, so cache_write is + # the normal input rate. https://docs.x.ai/developers/models + ("grok-build", 1.00, 2.00, 0.20, 1.00), # Short aliases used in ROLE_MODEL_MAP / MODEL_MAP ("opus", 5.00, 25.00, 0.50, 6.25), ("sonnet", 3.00, 15.00, 0.30, 0.75), diff --git a/roboco/llm/providers/opencode_config.py b/roboco/llm/providers/opencode_config.py index c6df197d..8e7b312e 100644 --- a/roboco/llm/providers/opencode_config.py +++ b/roboco/llm/providers/opencode_config.py @@ -33,6 +33,10 @@ _OPENCODE_SCHEMA = "https://opencode.ai/config.json" _PROVIDER_ID = "xai" _OPENAI_COMPAT_NPM = "@ai-sdk/openai-compatible" +# Plugins baked into the roboco-agent-grok image (see docker/agent-grok.Dockerfile). +# secret-scrub ports the bash-guard deny rules to opencode's tool.execute.before. +_PLUGINS = ["/app/opencode-plugins/secret-scrub.js"] + @dataclass(frozen=True) class XaiTarget: @@ -91,6 +95,8 @@ def build_opencode_config( "mcp": translate_mcp_servers(mcp_config), "permission": {"bash": bash_permission, "edit": edit_permission}, "instructions": instruction_paths, + # Command guard / secret-scrub (bash-guard parity). Baked into the image. + "plugin": list(_PLUGINS), } diff --git a/tests/unit/billing/test_pricing.py b/tests/unit/billing/test_pricing.py index 1805a980..6b5de9c2 100644 --- a/tests/unit/billing/test_pricing.py +++ b/tests/unit/billing/test_pricing.py @@ -39,6 +39,12 @@ _HAIKU_CACHE_WRITE = 1.25 _HAIKU3_INPUT = 0.25 # claude-haiku-3 is cheaper than haiku-3-5 / haiku-4 +# xAI Grok — priced non-Anthropic (per the xAI API) +_GROK_INPUT = 1.00 +_GROK_OUTPUT = 2.00 +_GROK_CACHE_READ = 0.20 +_GROK_CACHE_WRITE = 1.00 + # Tolerance for floating-point comparisons _TOL = 1e-4 @@ -214,6 +220,46 @@ class TestHaikuTier: assert abs(cost - _HAIKU3_INPUT) < _TOL +# --------------------------------------------------------------------------- +# Grok tier (xAI — priced non-Anthropic) +# --------------------------------------------------------------------------- + + +class TestGrokTier: + """grok-build-0.1 pricing — a non-Anthropic model that IS billed per token.""" + + def test_input_only(self) -> None: + cost = calculate_cost("grok-build-0.1", tokens_input=_M, tokens_output=0) + assert abs(cost - _GROK_INPUT) < _TOL + + def test_output_only(self) -> None: + cost = calculate_cost("grok-build-0.1", tokens_input=0, tokens_output=_M) + assert abs(cost - _GROK_OUTPUT) < _TOL + + def test_cached_input(self) -> None: + cost = calculate_cost( + "grok-build-0.1", tokens_input=0, tokens_output=0, tokens_cache_read=_M + ) + assert abs(cost - _GROK_CACHE_READ) < _TOL + + def test_all_token_types(self) -> None: + cost = calculate_cost( + "grok-build-0.1", + tokens_input=_M, + tokens_output=_M, + tokens_cache_read=_M, + tokens_cache_write=_M, + ) + expected = _GROK_INPUT + _GROK_OUTPUT + _GROK_CACHE_READ + _GROK_CACHE_WRITE + assert abs(cost - expected) < _TOL + + def test_grok_is_not_treated_as_anthropic(self) -> None: + """Priced, but not an Anthropic model (no warn-on-unpriced path).""" + assert _is_anthropic_model("grok-build-0.1") is False + # Still resolves to a real (non-zero) per-token cost. + assert calculate_cost("grok-build-0.1", tokens_input=_M, tokens_output=0) > 0.0 + + # --------------------------------------------------------------------------- # Unknown / edge cases — must return 0.0 without raising # --------------------------------------------------------------------------- diff --git a/tests/unit/llm/test_opencode_config.py b/tests/unit/llm/test_opencode_config.py index 55d926fb..ad4e2ac7 100644 --- a/tests/unit/llm/test_opencode_config.py +++ b/tests/unit/llm/test_opencode_config.py @@ -80,6 +80,8 @@ def test_build_opencode_config_provider_and_model() -> None: # Gateway servers carried through. assert "roboco-flow" in cfg["mcp"] assert cfg["instructions"] == ["/app/system-prompt.md"] + # The secret-scrub command guard is wired in by default. + assert cfg["plugin"] == ["/app/opencode-plugins/secret-scrub.js"] def test_build_opencode_config_bash_permission_is_tunable() -> None: