diff --git a/assets/agents/.shared/utils.sh b/assets/agents/.shared/utils.sh index 773f92d..ba3bc15 100755 --- a/assets/agents/.shared/utils.sh +++ b/assets/agents/.shared/utils.sh @@ -40,15 +40,57 @@ _write_project_cache() { _detect_heuristic() { local dir="$1" + local runner="" runner_type="" runner_targets="" + if [[ -f "${dir}/Taskfile.yml" || -f "${dir}/Taskfile.yaml" || -f "${dir}/taskfile.yml" || -f "${dir}/taskfile.yaml" ]]; then + runner="task" runner_type="taskfile" + runner_targets=$( (cd "${dir}" && task --list-all 2>/dev/null | sed -n 's/^\* \([^:[:space:]]*\):.*/\1/p') || true) + elif [[ -f "${dir}/justfile" || -f "${dir}/Justfile" ]]; then + runner="just" runner_type="just" + runner_targets=$( (cd "${dir}" && just --summary 2>/dev/null | tr ' ' '\n') || true) + elif [[ -f "${dir}/Makefile" || -f "${dir}/makefile" || -f "${dir}/GNUmakefile" ]]; then + runner="make" runner_type="make" + local mk mkfiles=() + for mk in Makefile makefile GNUmakefile; do + [[ -f "${dir}/${mk}" ]] && mkfiles+=("${dir}/${mk}") + done + runner_targets=$(sed -n 's/^\([A-Za-z0-9_][A-Za-z0-9_.-]*\):\([^=].*\|\)$/\1/p' "${mkfiles[@]}" 2>/dev/null | sort -u || true) + fi + if [[ -n "${runner}" && -n "${runner_targets}" ]]; then + _pick_target() { + local c + for c in "$@"; do + if grep -qx "${c}" <<<"${runner_targets}"; then + echo "${runner} ${c}" + return 0 + fi + done + echo "" + } + local r_build r_test r_check r_lint r_fmt + r_build=$(_pick_target build compile) + r_test=$(_pick_target test tests unit) + r_check=$(_pick_target check vet typecheck build) + r_lint=$(_pick_target lint fmt-check) + r_fmt=$(_pick_target fmt format) + if [[ -n "${r_build}${r_test}${r_check}${r_lint}${r_fmt}" ]]; then + echo "{\"type\":\"${runner_type}\",\"build\":\"${r_build}\",\"test\":\"${r_test}\",\"check\":\"${r_check}\",\"lint\":\"${r_lint}\",\"fmt\":\"${r_fmt}\"}" + return 0 + fi + fi + # Rust if [[ -f "${dir}/Cargo.toml" ]]; then - echo '{"type":"rust","build":"cargo build","test":"cargo test","check":"cargo check"}' + echo '{"type":"rust","build":"cargo build","test":"cargo test","check":"cargo check","lint":"cargo clippy --no-deps -- -D warnings","fmt":"cargo fmt"}' return 0 fi # Go if [[ -f "${dir}/go.mod" ]]; then - echo '{"type":"go","build":"go build ./...","test":"go test ./...","check":"go vet ./..."}' + local go_lint="" + if compgen -G "${dir}/.golangci.*" &>/dev/null && command -v golangci-lint &>/dev/null; then + go_lint="golangci-lint run" + fi + echo "{\"type\":\"go\",\"build\":\"go build ./...\",\"test\":\"go test ./...\",\"check\":\"go vet ./...\",\"lint\":\"${go_lint}\",\"fmt\":\"gofmt -w .\"}" return 0 fi @@ -65,7 +107,25 @@ _detect_heuristic() { [[ -f "${dir}/pnpm-lock.yaml" ]] && pm="pnpm" [[ -f "${dir}/yarn.lock" ]] && pm="yarn" - echo "{\"type\":\"nodejs\",\"build\":\"${pm} run build\",\"test\":\"${pm} test\",\"check\":\"${pm} run lint\"}" + # Emit only scripts the manifest actually declares (same introspection + # contract as the runner tier: never guess a target into existence). + _pkg_script() { + local s + for s in "$@"; do + if jq -e --arg s "$s" '.scripts[$s] // empty' "${dir}/package.json" &>/dev/null; then + echo "${pm} run ${s}" + return 0 + fi + done + echo "" + } + local p_build p_test p_check p_lint p_fmt + p_build=$(_pkg_script build compile) + p_test=$(_pkg_script test) + p_check=$(_pkg_script check typecheck tsc) + p_lint=$(_pkg_script lint) + p_fmt=$(_pkg_script fmt format prettier) + echo "{\"type\":\"nodejs\",\"build\":\"${p_build}\",\"test\":\"${p_test}\",\"check\":\"${p_check}\",\"lint\":\"${p_lint}\",\"fmt\":\"${p_fmt}\"}" return 0 fi @@ -82,7 +142,7 @@ _detect_heuristic() { check_cmd="uv run ruff check ." fi - echo "{\"type\":\"python\",\"build\":\"\",\"test\":\"${test_cmd}\",\"check\":\"${check_cmd}\"}" + echo "{\"type\":\"python\",\"build\":\"\",\"test\":\"${test_cmd}\",\"check\":\"${check_cmd}\",\"lint\":\"${check_cmd}\",\"fmt\":\"ruff format .\"}" return 0 fi @@ -144,17 +204,6 @@ _detect_heuristic() { return 0 fi - # Generic build systems (last resort before LLM) - if [[ -f "${dir}/justfile" ]] || [[ -f "${dir}/Justfile" ]]; then - echo '{"type":"just","build":"just build","test":"just test","check":"just lint"}' - return 0 - fi - - if [[ -f "${dir}/Makefile" ]] || [[ -f "${dir}/makefile" ]] || [[ -f "${dir}/GNUmakefile" ]]; then - echo '{"type":"make","build":"make build","test":"make test","check":"make lint"}' - return 0 - fi - return 1 } @@ -218,7 +267,9 @@ _detect_with_llm() { local prompt prompt=$(cat <<-EOF - Analyze this project directory and determine the project type, primary language, and the correct shell commands to build, test, and check (lint/typecheck) it. + Analyze this project directory and determine the project type, primary language, and the correct shell commands to build, test, check (typecheck/vet), lint, and format it. + + PRIORITY RULE: if the project declares its own task-runner interface (a Taskfile, justfile, Makefile, package.json scripts, or similar), those declared targets ARE the correct commands — prefer them over generic ecosystem defaults, and never invent a target the interface does not declare. EOF ) @@ -226,12 +277,12 @@ _detect_with_llm() { prompt+=$(cat <<-EOF Respond with ONLY a valid JSON object. No markdown fences, no explanation, no extra text. - The JSON must have exactly these 4 keys: - {"type":"","build":"","test":"","check":""} + The JSON must have exactly these 6 keys: + {"type":"","build":"","test":"","check":"","lint":"","fmt":""} Rules: - "type" must be a single lowercase word (e.g. rust, go, python, nodejs, java, ruby, elixir, cpp, c, zig, haskell, scala, kotlin, dart, swift, php, dotnet, etc.) - - If a command doesn't apply to this project, use an empty string, "" + - If a command doesn't apply to this project, use an empty string, "" — NEVER guess a command that might not exist; a wrongly-guessed command is worse than an empty one - Use the most standard/common commands for the detected ecosystem - If you detect a package manager lockfile, use that package manager (e.g. pnpm over npm) EOF @@ -244,7 +295,7 @@ _detect_with_llm() { llm_response=$(echo "${llm_response}" | grep -o '{[^}]*}' | head -1) if echo "${llm_response}" | jq -e '.type and .build != null and .test != null and .check != null' &>/dev/null; then - echo "${llm_response}" | jq -c '{type: (.type // "unknown"), build: (.build // ""), test: (.test // ""), check: (.check // "")}' + echo "${llm_response}" | jq -c '{type: (.type // "unknown"), build: (.build // ""), test: (.test // ""), check: (.check // ""), lint: (.lint // ""), fmt: (.fmt // "")}' return 0 fi @@ -258,7 +309,7 @@ detect_project() { local cached if cached=$(_read_project_cache "${dir}"); then - echo "${cached}" | jq -c '{type, build, test, check}' + echo "${cached}" | jq -c '{type, build, test, check, lint: (.lint // ""), fmt: (.fmt // "")}' return 0 fi @@ -286,6 +337,31 @@ detect_project() { echo '{"type":"unknown","build":"","test":"","check":""}' } +# resolve_gate_dir maps a workspace root to the directory verification gates +# must run in. A delivery-repo worker's workspace root holds only dotfiles +# plus the clone, so gates aimed at the root detect nothing and silently +# no-op. When the root has no project markers and exactly ONE first-level +# git repo exists, gates run inside it; anything ambiguous stays at the root. +resolve_gate_dir() { + local dir="${1:-.}" + local m + for m in Taskfile.yml Taskfile.yaml taskfile.yml Cargo.toml go.mod package.json pyproject.toml setup.py pom.xml build.gradle mix.exs Gemfile composer.json Makefile justfile Justfile CMakeLists.txt; do + if [[ -e "${dir}/${m}" ]]; then + echo "${dir}" + return 0 + fi + done + local repos=() d + for d in "${dir}"/*/; do + [[ -d "${d}/.git" ]] && repos+=("${d}") + done + if [[ ${#repos[@]} -eq 1 ]]; then + echo "${repos[0]%/}" + return 0 + fi + echo "${dir}" +} + ########################### ## FILE SEARCH UTILITIES ## ########################### diff --git a/assets/agents/coder/graph.yaml b/assets/agents/coder/graph.yaml index d3dd5a8..06e5697 100644 --- a/assets/agents/coder/graph.yaml +++ b/assets/agents/coder/graph.yaml @@ -227,6 +227,11 @@ nodes: on unfamiliar lints, etc.). 4. No dead code, no commented-out blocks, no premature abstractions. 5. End your turn when editing is done. The graph runs verification next. + 6. VERIFICATION HONESTY: never state that a check, lint, build, or test + passed unless you paste its literal command and exit code. A gate + that did not run is UNVERIFIED — say so. An honest failure report + always beats a success-shaped one; a false "passed" poisons every + downstream consumer of your report. Project directory: {{project_dir}} prompt: | @@ -248,7 +253,7 @@ nodes: - fs_write - fs_patch - execute_command - max_iterations: 30 + max_iterations: 100 state_updates: last_node_output: '{{output}}' fallback: end_failure diff --git a/assets/agents/coder/scripts/verify_build.sh b/assets/agents/coder/scripts/verify_build.sh index f9b9d65..c67a7d1 100644 --- a/assets/agents/coder/scripts/verify_build.sh +++ b/assets/agents/coder/scripts/verify_build.sh @@ -13,6 +13,7 @@ else fi project_dir=$(echo "$state" | jq -r '.project_dir // "."') +project_dir=$(resolve_gate_dir "$project_dir") if [[ -n "${BUILD_CMD:-}" ]]; then cmd="$BUILD_CMD" @@ -24,7 +25,7 @@ fi if [[ -z "$cmd" || "$cmd" == "null" ]]; then jq -nc '{ "build_ok": true, - "build_output": "(no build/check command available for this project type)", + "build_output": "(GATE NOT RUN: no build/check command configured or detected. This is NOT evidence that the build passed — set BUILD_CMD, and never report the build as verified.)", "_next": "verify_tests" }' exit 0 diff --git a/assets/agents/coder/scripts/verify_tests.sh b/assets/agents/coder/scripts/verify_tests.sh index a72de94..80edeb8 100644 --- a/assets/agents/coder/scripts/verify_tests.sh +++ b/assets/agents/coder/scripts/verify_tests.sh @@ -13,6 +13,7 @@ else fi project_dir=$(echo "$state" | jq -r '.project_dir // "."') +project_dir=$(resolve_gate_dir "$project_dir") if [[ -n "${TEST_CMD:-}" ]]; then cmd="$TEST_CMD" @@ -24,7 +25,7 @@ fi if [[ -z "$cmd" || "$cmd" == "null" ]]; then jq -nc '{ "tests_ok": true, - "tests_output": "(no test command available for this project type)", + "tests_output": "(GATE NOT RUN: no test command configured or detected. This is NOT evidence that tests passed — set TEST_CMD, and never report the suite as green.)", "_next": "self_review" }' exit 0 diff --git a/assets/agents/sisyphus/config.yaml b/assets/agents/sisyphus/config.yaml index 89a2a05..a0be536 100644 --- a/assets/agents/sisyphus/config.yaml +++ b/assets/agents/sisyphus/config.yaml @@ -266,6 +266,12 @@ instructions: | **No evidence = not complete.** Mark a todo `completed` only after evidence is collected. + ### Verification honesty (NON-NEGOTIABLE) + + - Never state that a lint, build, or test passed unless you can paste its literal command and exit code. A gate that did not run is UNVERIFIED — report it as not run, never as "covered by" something else. + - Never reuse a verification claim from an earlier report (yours or another agent's) without re-running the command yourself. Prior reports are unverified context, not evidence. + - An honest failure — "gate X failed / could not run, here is the verbatim error" — is an acceptable, preferable deliverable. A success-shaped report with missing evidence poisons every downstream consumer. + ### Independent code review (post-coder, non-trivial work) After completing delegated `coder` work, spawn `code-reviewer` for an independent review pass if ANY of these are true: diff --git a/assets/agents/sisyphus/sbx-mixin.yaml b/assets/agents/sisyphus/sbx-mixin.yaml index 22ed62b..e4134d2 100644 --- a/assets/agents/sisyphus/sbx-mixin.yaml +++ b/assets/agents/sisyphus/sbx-mixin.yaml @@ -1,11 +1,38 @@ -schemaVersion: '1' +schemaVersion: '2' kind: mixin name: sisyphus-ddg description: > - Allows Sisyphus to hit all domains since it utilizes the DuckDuckGo - MCP server. This allows the MCP server to actually perform web searches - on arbitrary domains and retrieve info for the agent. + Allows Sisyphus to reach DuckDuckGo plus a curated set of common + content domains for its web-search MCP server. Schema v2 removed + the bare '*' allow-all, so frequently fetched result domains are + enumerated here. -network: - allowedDomains: - - '*' +agentInstructions: + content: | + Web search runs against an enumerated network allow list. If fetching a + search result is blocked by network policy, ask the user to run + `sbx policy allow network ` on the host to extend it. + +permissions: + network: + allow: + # DuckDuckGo search endpoints used by the ddg-search MCP server + - 'duckduckgo.com' + - 'html.duckduckgo.com' + - 'lite.duckduckgo.com' + # Common content/result domains fetched from search results + # ('*.host' matches exactly one label and not the bare host itself) + - '*.wikipedia.org' + - 'github.com' + - '*.githubusercontent.com' + - 'stackoverflow.com' + - '*.stackexchange.com' + - 'developer.mozilla.org' + - 'docs.python.org' + - 'doc.rust-lang.org' + - 'docs.rs' + - 'crates.io' + - 'pypi.org' + - 'www.npmjs.com' + # Jina reader fallback for fetching arbitrary pages as markdown + - 'r.jina.ai' \ No newline at end of file diff --git a/assets/agents/step-runner/graph.yaml b/assets/agents/step-runner/graph.yaml index 08365ce..dc8cbe0 100644 --- a/assets/agents/step-runner/graph.yaml +++ b/assets/agents/step-runner/graph.yaml @@ -439,6 +439,12 @@ nodes: staleness report, gate decisions, and fix loop history. Downstream plan updates come from the sweep results. + VERIFICATION HONESTY: evidence marked "GATE NOT RUN" means that gate + is UNVERIFIED — record it as not run; never paraphrase a skipped gate + as covered, passing, or handled elsewhere. A handoff that admits an + unverified gate is correct; one that dresses it up as verified poisons + every downstream reader. + Then append durable, step-independent facts (if any) to {{notes_path}} - create the file if missing, never rewrite existing entries. diff --git a/assets/agents/step-runner/scripts/verify_build.sh b/assets/agents/step-runner/scripts/verify_build.sh index 23704f3..fdb2bf8 100755 --- a/assets/agents/step-runner/scripts/verify_build.sh +++ b/assets/agents/step-runner/scripts/verify_build.sh @@ -13,6 +13,7 @@ else fi project_dir=$(echo "$state" | jq -r '.project_dir // "."') +project_dir=$(resolve_gate_dir "$project_dir") if [[ -n "${BUILD_CMD:-}" ]]; then cmd="$BUILD_CMD" @@ -24,7 +25,7 @@ fi if [[ -z "$cmd" || "$cmd" == "null" ]]; then jq -nc '{ "build_ok": true, - "build_output": "(no build/check command available for this project type)", + "build_output": "(GATE NOT RUN: no build/check command configured or detected. This is NOT evidence that the build passed — set BUILD_CMD, and never report the build as verified.)", "_next": "verify_tests" }' exit 0 diff --git a/assets/agents/step-runner/scripts/verify_format_lint.sh b/assets/agents/step-runner/scripts/verify_format_lint.sh index c20e2f3..45f5b9d 100755 --- a/assets/agents/step-runner/scripts/verify_format_lint.sh +++ b/assets/agents/step-runner/scripts/verify_format_lint.sh @@ -13,19 +13,18 @@ else fi project_dir=$(echo "$state" | jq -r '.project_dir // "."') -project_type=$(detect_project "$project_dir" | jq -r '.type // "unknown"') +project_dir=$(resolve_gate_dir "$project_dir") +project_info=$(detect_project "$project_dir") +project_type=$(echo "$project_info" | jq -r '.type // "unknown"') format_cmd="${FORMAT_CMD:-}" if [[ -z "$format_cmd" ]]; then - case "$project_type" in - rust) format_cmd="cargo fmt" ;; - go) format_cmd="gofmt -w ." ;; - python) command -v ruff &>/dev/null && format_cmd="ruff format ." ;; - esac + format_cmd=$(echo "$project_info" | jq -r '.fmt // ""') fi +if [[ "$format_cmd" == "null" ]]; then format_cmd=""; fi if [[ -z "$format_cmd" ]]; then - format_output="(no format command configured for project type '$project_type'; skipped. Set FORMAT_CMD to enable.)" + format_output="(GATE NOT RUN: no format command configured or detected for project type '$project_type'. This is NOT evidence that formatting is clean. Set FORMAT_CMD to enable.)" else fmt_rc=0 fmt_out=$(cd "$project_dir" && eval "$format_cmd" 2>&1) || fmt_rc=$? @@ -37,12 +36,18 @@ fi lint_cmd="${LINT_CMD:-}" if [[ -z "$lint_cmd" ]]; then + lint_cmd=$(echo "$project_info" | jq -r '.lint // ""') +fi +# The skip message must read as a WARNING, never a reassurance: the previous +# wording ("linting is covered by the build/check command") was quoted +# verbatim by workers as false evidence that linting passed +if [[ -z "$lint_cmd" || "$lint_cmd" == "null" ]]; then jq -nc \ --arg fo "$format_output" \ '{ "format_output": $fo, "lint_ok": true, - "lint_output": "(no LINT_CMD configured; linting is covered by the build/check command)", + "lint_output": "(GATE NOT RUN: no lint command configured or detected. This is NOT evidence that linting passed — set LINT_CMD or add a Taskfile lint target, and never report linting as covered.)", "_next": "verify_build" }' exit 0 diff --git a/assets/agents/step-runner/scripts/verify_tests.sh b/assets/agents/step-runner/scripts/verify_tests.sh index 481e126..0235909 100755 --- a/assets/agents/step-runner/scripts/verify_tests.sh +++ b/assets/agents/step-runner/scripts/verify_tests.sh @@ -13,6 +13,7 @@ else fi project_dir=$(echo "$state" | jq -r '.project_dir // "."') +project_dir=$(resolve_gate_dir "$project_dir") if [[ -n "${TEST_CMD:-}" ]]; then cmd="$TEST_CMD" @@ -24,7 +25,7 @@ fi if [[ -z "$cmd" || "$cmd" == "null" ]]; then jq -nc '{ "tests_ok": true, - "tests_output": "(no test command available for this project type)", + "tests_output": "(GATE NOT RUN: no test command configured or detected. This is NOT evidence that tests passed — set TEST_CMD, and never report the suite as green.)", "_next": "edge_case_sweep" }' exit 0 diff --git a/assets/functions/sbx-mixin.yaml b/assets/functions/sbx-mixin.yaml index be05515..900dad6 100644 --- a/assets/functions/sbx-mixin.yaml +++ b/assets/functions/sbx-mixin.yaml @@ -1,4 +1,4 @@ -schemaVersion: "1" +schemaVersion: '2' kind: mixin name: built-in-tools description: > @@ -6,39 +6,39 @@ description: > global tools and the default MCP server set. Auto-applied by Coyote's sbx mixin discovery when running `coyote --sandbox`. -network: - allowedDomains: - # fetch_url_via_jina + jina reader fallback - - "r.jina.ai:443" - # get_current_weather (.sh, .py, .ts) - - "wttr.in:443" - # search_arxiv (the .sh tool still uses http://, so :80 is required until fixed) - - "export.arxiv.org:443" - - "export.arxiv.org:80" - # search_arxiv + search_wikipedia may follow DOI redirects - - "doi.org:443" - # search_wikipedia - - "en.wikipedia.org:443" - # search_wolframalpha - - "api.wolframalpha.com:443" - # web_search_perplexity - - "api.perplexity.ai:443" - # web_search_tavily - - "api.tavily.com:443" - # send_twilio - - "api.twilio.com:443" - # MCP: github (built-in mcp.json: api.githubcopilot.com) - - "api.githubcopilot.com:443" - # MCP: atlassian (built-in mcp.json: mcp-remote -> mcp.atlassian.com) - - "mcp.atlassian.com:443" - # MCP: ddg-search (built-in mcp.json: uvx duckduckgo-mcp-server) - - "duckduckgo.com:443" - - "html.duckduckgo.com:443" - - "lite.duckduckgo.com:443" - # MCP: npx-based servers (mcp-remote) pull from npm - - "registry.npmjs.org:443" - # MCP: docker server may pull images from common registries - - "ghcr.io:443" - - "registry-1.docker.io:443" - - "auth.docker.io:443" - - "production.cloudflare.docker.com:443" +permissions: + network: + allow: + # fetch_url_via_jina + jina reader fallback + - 'r.jina.ai' + # get_current_weather (.sh, .py, .ts) + - 'wttr.in' + # search_arxiv (the .sh tool still uses http://, so :80 is required until fixed) + - 'export.arxiv.org' + - 'export.arxiv.org:80' + # search_arxiv + search_wikipedia may follow DOI redirects + - 'doi.org' + # search_wikipedia + - 'en.wikipedia.org' + # search_wolframalpha + - 'api.wolframalpha.com' + # web_search_perplexity + - 'api.perplexity.ai' + # web_search_tavily + - 'api.tavily.com' + # send_twilio + - 'api.twilio.com' + # MCP: github (built-in mcp.json: api.githubcopilot.com) + - 'api.githubcopilot.com' + # MCP: atlassian (built-in mcp.json: mcp-remote -> mcp.atlassian.com) + - 'mcp.atlassian.com' + # MCP: ddg-search (built-in mcp.json: uvx duckduckgo-mcp-server) + - 'duckduckgo.com' + - 'html.duckduckgo.com' + - 'lite.duckduckgo.com' + # MCP: npx-based servers (mcp-remote) pull from npm + - 'registry.npmjs.org' + # MCP: docker server may pull images from common registries + - 'ghcr.io' + - 'registry-1.docker.io' + - 'auth.docker.io' \ No newline at end of file diff --git a/assets/sbx-kit/spec.yaml b/assets/sbx-kit/spec.yaml index 6f24c68..9a96da7 100644 --- a/assets/sbx-kit/spec.yaml +++ b/assets/sbx-kit/spec.yaml @@ -4,7 +4,7 @@ # sbx create --kit ./sbx-kit/ coyote --name testing . # sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/ # sbx run testing --kit ./sbx-kit/ -schemaVersion: '1' +schemaVersion: '2' kind: sandbox name: coyote displayName: Coyote @@ -14,198 +14,255 @@ description: > sandbox: image: 'darkalex17/coyote:v0.8.3' - aiFilename: COYOTE.md - entrypoint: - run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote'] + entrypoint: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote'] -network: - # Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for - # the env var inside the sandbox and rewrites the auth header per - # serviceAuth at request time. Multiple domains may map to one service - # (e.g. jina) so they share a single credential. - serviceDomains: - api.openai.com: openai - api.anthropic.com: anthropic - generativelanguage.googleapis.com: gemini - api.cohere.ai: cohere - api.groq.com: groq - openrouter.ai: openrouter - api.ai21.com: ai21 - api.cloudflare.com: cloudflare - api.deepinfra.com: deepinfra - api.deepseek.com: deepseek - api.mistral.ai: mistral - api.perplexity.ai: perplexity - api.voyageai.com: voyageai - api.x.ai: xai - api.jina.ai: jina - r.jina.ai: jina - qianfan.baidubce.com: ernie - api.hunyuan.cloud.tencent.com: hunyuan - api.minimax.chat: minimax - api.moonshot.cn: moonshot - dashscope.aliyuncs.com: qianwen - open.bigmodel.cn: zhipuai - serviceAuth: - openai: - headerName: Authorization - valueFormat: 'Bearer %s' - anthropic: - headerName: x-api-key - valueFormat: '%s' - gemini: - headerName: x-goog-api-key - valueFormat: '%s' - cohere: - headerName: Authorization - valueFormat: 'Bearer %s' - groq: - headerName: Authorization - valueFormat: 'Bearer %s' - openrouter: - headerName: Authorization - valueFormat: 'Bearer %s' - ai21: - headerName: Authorization - valueFormat: 'Bearer %s' - cloudflare: - headerName: Authorization - valueFormat: 'Bearer %s' - deepinfra: - headerName: Authorization - valueFormat: 'Bearer %s' - deepseek: - headerName: Authorization - valueFormat: 'Bearer %s' - mistral: - headerName: Authorization - valueFormat: 'Bearer %s' - perplexity: - headerName: Authorization - valueFormat: 'Bearer %s' - voyageai: - headerName: Authorization - valueFormat: 'Bearer %s' - xai: - headerName: Authorization - valueFormat: 'Bearer %s' - jina: - headerName: Authorization - valueFormat: 'Bearer %s' - ernie: - headerName: Authorization - valueFormat: 'Bearer %s' - hunyuan: - headerName: Authorization - valueFormat: 'Bearer %s' - minimax: - headerName: Authorization - valueFormat: 'Bearer %s' - moonshot: - headerName: Authorization - valueFormat: 'Bearer %s' - qianwen: - headerName: Authorization - valueFormat: 'Bearer %s' - zhipuai: - headerName: Authorization - valueFormat: 'Bearer %s' - allowedDomains: - # Coyote release + self-update + model-registry sync - - 'github.com:443' - - 'api.github.com:443' - - 'raw.githubusercontent.com:443' - - 'objects.githubusercontent.com:443' - - '*.githubusercontent.com:443' - # Package managers and developer tools (cargo, uv, pip — useful at runtime for user installs) - - 'crates.io:443' - - 'static.crates.io:443' - - 'pypi.org:443' - - 'files.pythonhosted.org:443' - - 'astral.sh:443' - - 'sh.rustup.rs:443' - - 'static.rust-lang.org:443' +permissions: + network: + allow: + # Coyote release + self-update + model-registry sync + - 'github.com' + - 'api.github.com' + - 'raw.githubusercontent.com' + - 'objects.githubusercontent.com' + - '*.githubusercontent.com' + # Package managers and developer tools (cargo, uv, pip — useful at runtime for user installs) + - 'crates.io' + - 'static.crates.io' + - 'pypi.org' + - 'files.pythonhosted.org' + - 'astral.sh' + - 'sh.rustup.rs' + - 'static.rust-lang.org' - # LLM model OAuth + API endpoints - - 'claude.ai:443' - - 'console.anthropic.com:443' - - 'accounts.google.com:443' - # *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints - # (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI. - - '*.googleapis.com:443' + # LLM model OAuth + API endpoints + - 'claude.ai' + - 'console.anthropic.com' + - 'accounts.google.com' + # *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints + # (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI. + - '*.googleapis.com' - # Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy - # cannot rewrite. Domains are allow-listed; credentials must be injected - # separately (see README "Extending"). - - '*.amazonaws.com:443' - - 'models.inference.ai.azure.com:443' + # Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy + # cannot rewrite; credentials must be injected separately (see README + # "Extending"). NOTE: '*.amazonaws.com' matches exactly ONE label, so + # two-label regional Bedrock hosts must be enumerated explicitly + # ('**.' is declared but not yet enforced by sbx). Add your region + # via a mixin if it's missing below. + - '*.amazonaws.com' + - 'bedrock-runtime.us-east-1.amazonaws.com' + - 'bedrock-runtime.us-east-2.amazonaws.com' + - 'bedrock-runtime.us-west-2.amazonaws.com' + - 'bedrock-runtime.eu-west-1.amazonaws.com' + - 'bedrock-runtime.eu-central-1.amazonaws.com' + - 'bedrock-runtime.ap-southeast-2.amazonaws.com' + - 'bedrock-runtime.ap-northeast-1.amazonaws.com' + - 'models.inference.ai.azure.com' + # Proxy-managed LLM provider APIs. Every credentials[].apiKey.inject + # domain below MUST also appear here. sbx does not derive allow entries + # from inject rules. + - 'api.openai.com' + - 'api.anthropic.com' + - 'generativelanguage.googleapis.com' + - 'api.cohere.ai' + - 'api.groq.com' + - 'openrouter.ai' + - 'api.ai21.com' + - 'api.cloudflare.com' + - 'api.deepinfra.com' + - 'api.deepseek.com' + - 'api.mistral.ai' + - 'api.perplexity.ai' + - 'api.voyageai.com' + - 'api.x.ai' + - 'api.jina.ai' + - 'r.jina.ai' + - 'qianfan.baidubce.com' + - 'api.hunyuan.cloud.tencent.com' + - 'api.minimax.chat' + - 'api.moonshot.cn' + - 'dashscope.aliyuncs.com' + - 'open.bigmodel.cn' + +# Proxy-managed LLM providers: inside the sandbox each apiKey env var holds +# the `proxy-managed` sentinel; the proxy injects the real value into the +# request header per the inject rules at request time. Values are bound by +# the user via credential bindings (`sbx secret set `); Coyote +# pre-seeds them from its vault at launch. Multiple domains may map to one +# service (e.g. jina) so they share a single credential. credentials: - sources: - openai: - env: - - OPENAI_API_KEY - anthropic: - env: - - ANTHROPIC_API_KEY - gemini: - env: - - GEMINI_API_KEY - - GOOGLE_API_KEY - cohere: - env: - - COHERE_API_KEY - groq: - env: - - GROQ_API_KEY - openrouter: - env: - - OPENROUTER_API_KEY - ai21: - env: - - AI21_API_KEY - cloudflare: - env: - - CLOUDFLARE_API_KEY - deepinfra: - env: - - DEEPINFRA_API_KEY - deepseek: - env: - - DEEPSEEK_API_KEY - mistral: - env: - - MISTRAL_API_KEY - perplexity: - env: - - PERPLEXITY_API_KEY - voyageai: - env: - - VOYAGE_API_KEY - xai: - env: - - XAI_API_KEY - jina: - env: - - JINA_API_KEY - ernie: - env: - - ERNIE_API_KEY - hunyuan: - env: - - HUNYUAN_API_KEY - minimax: - env: - - MINIMAX_API_KEY - moonshot: - env: - - MOONSHOT_API_KEY - qianwen: - env: - - DASHSCOPE_API_KEY - zhipuai: - env: - - ZHIPUAI_API_KEY + - service: openai + description: OpenAI API key, injected on api.openai.com + apiKey: + name: OPENAI_API_KEY + proxyManaged: true + inject: + - domain: api.openai.com + scheme: bearer + - service: anthropic + description: Anthropic API key, injected as x-api-key on api.anthropic.com + apiKey: + name: ANTHROPIC_API_KEY + proxyManaged: true + inject: + - domain: api.anthropic.com + header: x-api-key + format: '%s' + - service: gemini + description: Google Gemini API key, injected as x-goog-api-key on generativelanguage.googleapis.com + apiKey: + name: GEMINI_API_KEY + proxyManaged: true + inject: + - domain: generativelanguage.googleapis.com + header: x-goog-api-key + format: '%s' + - service: cohere + description: Cohere API key, injected on api.cohere.ai + apiKey: + name: COHERE_API_KEY + proxyManaged: true + inject: + - domain: api.cohere.ai + scheme: bearer + - service: groq + description: Groq API key, injected on api.groq.com + apiKey: + name: GROQ_API_KEY + proxyManaged: true + inject: + - domain: api.groq.com + scheme: bearer + - service: openrouter + description: OpenRouter API key, injected on openrouter.ai + apiKey: + name: OPENROUTER_API_KEY + proxyManaged: true + inject: + - domain: openrouter.ai + scheme: bearer + - service: ai21 + description: AI21 Labs API key, injected on api.ai21.com + apiKey: + name: AI21_API_KEY + proxyManaged: true + inject: + - domain: api.ai21.com + scheme: bearer + - service: cloudflare + description: Cloudflare Workers AI API key, injected on api.cloudflare.com + apiKey: + name: CLOUDFLARE_API_KEY + proxyManaged: true + inject: + - domain: api.cloudflare.com + scheme: bearer + - service: deepinfra + description: DeepInfra API key, injected on api.deepinfra.com + apiKey: + name: DEEPINFRA_API_KEY + proxyManaged: true + inject: + - domain: api.deepinfra.com + scheme: bearer + - service: deepseek + description: DeepSeek API key, injected on api.deepseek.com + apiKey: + name: DEEPSEEK_API_KEY + proxyManaged: true + inject: + - domain: api.deepseek.com + scheme: bearer + - service: mistral + description: Mistral API key, injected on api.mistral.ai + apiKey: + name: MISTRAL_API_KEY + proxyManaged: true + inject: + - domain: api.mistral.ai + scheme: bearer + - service: perplexity + description: Perplexity API key, injected on api.perplexity.ai + apiKey: + name: PERPLEXITY_API_KEY + proxyManaged: true + inject: + - domain: api.perplexity.ai + scheme: bearer + - service: voyageai + description: Voyage AI API key, injected on api.voyageai.com + apiKey: + name: VOYAGE_API_KEY + proxyManaged: true + inject: + - domain: api.voyageai.com + scheme: bearer + - service: xai + description: xAI (Grok) API key, injected on api.x.ai + apiKey: + name: XAI_API_KEY + proxyManaged: true + inject: + - domain: api.x.ai + scheme: bearer + - service: jina + description: Jina API key, injected on api.jina.ai and r.jina.ai + apiKey: + name: JINA_API_KEY + proxyManaged: true + inject: + - domain: api.jina.ai + scheme: bearer + - domain: r.jina.ai + scheme: bearer + - service: ernie + description: Baidu ERNIE API key, injected on qianfan.baidubce.com + apiKey: + name: ERNIE_API_KEY + proxyManaged: true + inject: + - domain: qianfan.baidubce.com + scheme: bearer + - service: hunyuan + description: Tencent Hunyuan API key, injected on api.hunyuan.cloud.tencent.com + apiKey: + name: HUNYUAN_API_KEY + proxyManaged: true + inject: + - domain: api.hunyuan.cloud.tencent.com + scheme: bearer + - service: minimax + description: MiniMax API key, injected on api.minimax.chat + apiKey: + name: MINIMAX_API_KEY + proxyManaged: true + inject: + - domain: api.minimax.chat + scheme: bearer + - service: moonshot + description: Moonshot AI API key, injected on api.moonshot.cn + apiKey: + name: MOONSHOT_API_KEY + proxyManaged: true + inject: + - domain: api.moonshot.cn + scheme: bearer + - service: qianwen + description: Alibaba Qianwen (DashScope) API key, injected on dashscope.aliyuncs.com + apiKey: + name: DASHSCOPE_API_KEY + proxyManaged: true + inject: + - domain: dashscope.aliyuncs.com + scheme: bearer + - service: zhipuai + description: Zhipu AI (GLM) API key, injected on open.bigmodel.cn + apiKey: + name: ZHIPUAI_API_KEY + proxyManaged: true + inject: + - domain: open.bigmodel.cn + scheme: bearer environment: variables: @@ -213,32 +270,14 @@ environment: COYOTE_LOG_LEVEL: INFO COYOTE_CONFIG_DIR: /home/agent/.config/coyote EDITOR: nano - proxyManaged: - - OPENAI_API_KEY - - ANTHROPIC_API_KEY - - GEMINI_API_KEY - - GOOGLE_API_KEY - - COHERE_API_KEY - - GROQ_API_KEY - - OPENROUTER_API_KEY - - AI21_API_KEY - - CLOUDFLARE_API_KEY - - DEEPINFRA_API_KEY - - DEEPSEEK_API_KEY - - MISTRAL_API_KEY - - PERPLEXITY_API_KEY - - VOYAGE_API_KEY - - XAI_API_KEY - - JINA_API_KEY - - ERNIE_API_KEY - - HUNYUAN_API_KEY - - MINIMAX_API_KEY - - MOONSHOT_API_KEY - - DASHSCOPE_API_KEY - - ZHIPUAI_API_KEY + # Alias for the gemini credential: v2 apiKey supports a single env name + # (GEMINI_API_KEY above). Coyote also recognizes GOOGLE_API_KEY, so keep + # it set to the sentinel. Header injection happens per-domain regardless + # of which env var the app reads. + GOOGLE_API_KEY: proxy-managed -commands: - initFiles: +setup: + files: - path: /home/agent/.config/git/ssh-signing-key-command mode: '0755' description: Resolve the forwarded SSH agent key for Git SSH signing @@ -290,39 +329,45 @@ commands: background: false description: Bootstrap Coyote config directory on first sandbox start -agentContext: | - ## Sandbox environment +agentInstructions: + filename: COYOTE.md + content: | + ## Sandbox environment - You are running inside a Docker sandbox launched via `sbx run coyote`. The - user's project workspace is mounted at its absolute host path and is the - current working directory. `sudo` is passwordless; use it for system - package installs. + You are running inside a Docker sandbox launched via `sbx run coyote`. The + user's project workspace is mounted at its absolute host path and is the + current working directory. `sudo` is passwordless; use it for system + package installs. - Coyote's configuration lives at `~/.config/coyote/` and logs at - `~/.cache/coyote/coyote.log`. Persistence is enabled, so config, sessions, - vault state, OAuth tokens, and installed tools survive sandbox restarts. + Coyote's configuration lives at `~/.config/coyote/` and logs at + `~/.cache/coyote/coyote.log`. Persistence is enabled, so config, sessions, + vault state, OAuth tokens, and installed tools survive sandbox restarts. - LLM provider credentials are forwarded by the sandbox HTTP proxy. The - following provider env vars are recognized - export the ones you use on - the host before running `sbx run coyote`: + LLM provider credentials are forwarded by the sandbox HTTP proxy via + credential bindings. Coyote pre-seeds them from its vault at launch + (`sbx secret set `); users can also bind values manually on the + host with `sbx secret set ` or `sbx secret import`. Recognized + services: - OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY / GOOGLE_API_KEY, - COHERE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, AI21_API_KEY, - CLOUDFLARE_API_KEY, DEEPINFRA_API_KEY, DEEPSEEK_API_KEY, - MISTRAL_API_KEY, PERPLEXITY_API_KEY, VOYAGE_API_KEY, XAI_API_KEY, - JINA_API_KEY, ERNIE_API_KEY, HUNYUAN_API_KEY, MINIMAX_API_KEY, - MOONSHOT_API_KEY, DASHSCOPE_API_KEY (Qwen), ZHIPUAI_API_KEY + openai, anthropic, gemini, cohere, groq, openrouter, ai21, + cloudflare, deepinfra, deepseek, mistral, perplexity, voyageai, + xai, jina, ernie, hunyuan, minimax, moonshot, qianwen, zhipuai - Inside the sandbox these appear as the placeholder string `proxy-managed`; - the proxy substitutes the real value at request time. OAuth flows for - Claude Pro/Max and Gemini are also allow-listed. + Inside the sandbox the corresponding env vars (OPENAI_API_KEY, etc.) + hold the placeholder string `proxy-managed`; the proxy substitutes the + real value at request time. OAuth flows for Claude Pro/Max and Gemini + are also allow-listed. - Bedrock (AWS) and VertexAI (Google Cloud) use signed/OAuth-token requests - that the proxy cannot rewrite. Their domains are allow-listed but you must - inject credentials yourself via `sbx run --env AWS_ACCESS_KEY_ID=...` or - a mixin kit that mounts a service-account JSON. + Bedrock (AWS) and VertexAI (Google Cloud) use signed/OAuth-token requests + that the proxy cannot rewrite, so you must inject credentials yourself via + `sbx run --env AWS_ACCESS_KEY_ID=...` or a mixin kit that mounts a + service-account JSON. VertexAI regional endpoints are allow-listed via + `*.googleapis.com`. Bedrock runtime endpoints are allow-listed for + us-east-1/2, us-west-2, eu-west-1, eu-central-1, ap-southeast-2, and + ap-northeast-1 only; other regions need a mixin allow entry + (`bedrock-runtime..amazonaws.com`). - Useful first-run commands: - - `coyote --info` # show config paths and resolved settings - - `coyote --list-secrets` # initialise the local vault - - `coyote --authenticate ` # OAuth flow (Claude Pro/Max, Gemini) + Useful first-run commands: + - `coyote --info` # show config paths and resolved settings + - `coyote --list-secrets` # initialise the local vault + - `coyote --authenticate ` # OAuth flow (Claude Pro/Max, Gemini) \ No newline at end of file diff --git a/src/sandbox/mcp_credentials.rs b/src/sandbox/mcp_credentials.rs new file mode 100644 index 0000000..0132e21 --- /dev/null +++ b/src/sandbox/mcp_credentials.rs @@ -0,0 +1,1136 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, bail}; +use serde::Serialize; +use serde_json::Value; +use url::Url; + +use crate::vault::SECRET_RE; + +pub(crate) const MCP_MIXIN_NAME: &str = "coyote-mcp"; + +const ENV_VAR_PREFIX: &str = "COYOTE_SECRET_"; +const SERVICE_ID_MAX_LEN: usize = 63; + +pub(crate) fn secret_service_id(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut last_was_dash = false; + for ch in name.chars() { + let lower = ch.to_ascii_lowercase(); + if lower.is_ascii_lowercase() || lower.is_ascii_digit() { + out.push(lower); + last_was_dash = false; + } else if !last_was_dash { + out.push('-'); + last_was_dash = true; + } + } + + let mut id: String = out + .trim_matches('-') + .chars() + .take(SERVICE_ID_MAX_LEN) + .collect(); + while id.ends_with('-') { + id.pop(); + } + + id +} + +pub(crate) fn sandbox_secret_env_var(name: &str) -> String { + let mut out = String::from(ENV_VAR_PREFIX); + for ch in name.chars() { + let upper = ch.to_ascii_uppercase(); + if upper.is_ascii_uppercase() || upper.is_ascii_digit() { + out.push(upper); + } else { + out.push('_'); + } + } + + out +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub(crate) struct InjectRule { + pub domain: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub header: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheme: Option, +} + +impl InjectRule { + fn effective_header(&self) -> String { + self.header + .as_deref() + .unwrap_or("Authorization") + .to_ascii_lowercase() + } +} + +/// One credential to register with sbx and declare in the generated mixin. +#[derive(Debug)] +pub(crate) struct CredentialSpec { + pub secret_name: String, + pub service_id: String, + pub env_var: String, + pub proxy_managed: bool, + pub inject: Vec, + pub servers: Vec, +} + +struct Occurrence { + secret_name: String, + /// `Some` when the occurrence can be rewritten by the sandbox proxy. + inject: Option, +} + +struct PlaceholderMatch { + name: String, + start: usize, + end: usize, +} + +/// Collects every `{{placeholder}}` across all MCP servers and folds them into +/// one credential per distinct secret name. A secret is proxy-managed only when +/// every occurrence of it (across all servers) is a proxy-injectable header +/// value AND no other secret targets the same (domain, header) injection slot +/// (proxy inject rules are keyed by domain + header, not by server). +pub(crate) fn collect_credentials( + servers: &serde_json::Map, +) -> Result> { + struct Aggregate { + all_injectable: bool, + inject: BTreeSet, + servers: BTreeSet, + } + + let mut by_secret: BTreeMap = BTreeMap::new(); + for (server_name, config) in servers { + for occurrence in collect_server_occurrences(config)? { + let agg = by_secret + .entry(occurrence.secret_name) + .or_insert_with(|| Aggregate { + all_injectable: true, + inject: BTreeSet::new(), + servers: BTreeSet::new(), + }); + agg.servers.insert(server_name.clone()); + match occurrence.inject { + Some(rule) => { + agg.inject.insert(rule); + } + None => agg.all_injectable = false, + } + } + } + + // The proxy injects per (domain, header), not per server: sbx kits v2 + // spec supports multiple credentials on one domain as long as they + // write different headers. A conflict exists only when two different + // secrets target the SAME header on the same domain, then the proxy + // cannot tell which credential a given request needs, and it could + // clobber a header Coyote resolved from the environment. Demote + // every secret involved in such a collision to env-based resolution. + let mut secrets_by_slot: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + for (secret_name, agg) in &by_secret { + for rule in &agg.inject { + secrets_by_slot + .entry((rule.domain.clone(), rule.effective_header())) + .or_default() + .insert(secret_name.clone()); + } + } + + let mut slot_conflicted: BTreeSet = BTreeSet::new(); + for ((domain, header), secrets) in &secrets_by_slot { + if secrets.len() > 1 { + eprintln!( + "MCP secrets {} all target the '{header}' header for '{domain}'. The \ + sandbox proxy cannot tell which one a given request needs, so these \ + secrets will be resolved from environment variables inside the \ + sandbox instead.", + quoted_list(secrets) + ); + slot_conflicted.extend(secrets.iter().cloned()); + } + } + + let mut credentials = Vec::with_capacity(by_secret.len()); + let mut secrets_by_service_id: BTreeMap = BTreeMap::new(); + let mut secrets_by_env_var: BTreeMap = BTreeMap::new(); + for (secret_name, agg) in by_secret { + let service_id = secret_service_id(&secret_name); + if service_id.is_empty() { + bail!( + "Secret name '{secret_name}' referenced by MCP server(s) {} sanitizes to an \ + empty sbx service id", + quoted_list(&agg.servers) + ); + } + + if let Some(existing) = + secrets_by_service_id.insert(service_id.clone(), secret_name.clone()) + { + bail!( + "MCP secrets '{existing}' and '{secret_name}' both sanitize to sbx service id \ + '{service_id}'; rename one" + ); + } + + let env_var = sandbox_secret_env_var(&secret_name); + if let Some(existing) = secrets_by_env_var.insert(env_var.clone(), secret_name.clone()) { + bail!( + "MCP secrets '{existing}' and '{secret_name}' both sanitize to env var \ + '{env_var}'; rename one" + ); + } + + let proxy_managed = agg.all_injectable && !slot_conflicted.contains(&secret_name); + credentials.push(CredentialSpec { + secret_name, + service_id, + env_var, + proxy_managed, + inject: if proxy_managed { + agg.inject.into_iter().collect() + } else { + Vec::new() + }, + servers: agg.servers.into_iter().collect(), + }); + } + + credentials.sort_by(|a, b| a.service_id.cmp(&b.service_id)); + + Ok(credentials) +} + +pub(crate) fn quoted_list<'a, I: IntoIterator>(items: I) -> String { + items + .into_iter() + .map(|s| format!("'{s}'")) + .collect::>() + .join(", ") +} + +fn collect_server_occurrences(server: &Value) -> Result> { + let Some(fields) = server.as_object() else { + return Ok(Vec::new()); + }; + + let https_domain = fields + .get("url") + .and_then(Value::as_str) + .and_then(parse_https_domain); + + let mut out = Vec::new(); + for (key, value) in fields { + if key == "headers" + && let Some(headers) = value.as_object() + { + for (header, header_value) in headers { + match header_value.as_str() { + Some(s) => classify_header_value(header, s, https_domain.as_deref(), &mut out)?, + None => collect_env_occurrences(header_value, &mut out)?, + } + } + continue; + } + + collect_env_occurrences(value, &mut out)?; + } + + Ok(out) +} + +fn collect_env_occurrences(value: &Value, out: &mut Vec) -> Result<()> { + match value { + Value::String(s) => { + for m in placeholders(s)? { + out.push(Occurrence { + secret_name: m.name, + inject: None, + }); + } + } + Value::Object(map) => { + for v in map.values() { + collect_env_occurrences(v, out)?; + } + } + Value::Array(arr) => { + for v in arr { + collect_env_occurrences(v, out)?; + } + } + _ => {} + } + + Ok(()) +} + +/// A header value is proxy-injectable only when the server URL is https with a +/// parseable host, the value holds exactly one placeholder, and the literal +/// remainder contains no `%`. +fn classify_header_value( + header: &str, + value: &str, + https_domain: Option<&str>, + out: &mut Vec, +) -> Result<()> { + let matches = placeholders(value)?; + if matches.is_empty() { + return Ok(()); + } + + let rule = match (https_domain, matches.as_slice()) { + (Some(domain), [only]) => { + let remainder = format!("{}{}", &value[..only.start], &value[only.end..]); + if remainder.contains('%') { + None + } else { + Some(build_inject_rule(domain, header, value, only)) + } + } + _ => None, + }; + + match rule { + Some(rule) => { + let m = matches.into_iter().next().expect("checked non-empty"); + out.push(Occurrence { + secret_name: m.name, + inject: Some(rule), + }); + } + None => { + for m in matches { + out.push(Occurrence { + secret_name: m.name, + inject: None, + }); + } + } + } + + Ok(()) +} + +fn build_inject_rule(domain: &str, header: &str, value: &str, m: &PlaceholderMatch) -> InjectRule { + let is_bearer = header.eq_ignore_ascii_case("authorization") + && value[..m.start].eq_ignore_ascii_case("Bearer ") + && value[m.end..].is_empty(); + + if is_bearer { + InjectRule { + domain: domain.to_string(), + header: None, + format: None, + scheme: Some("bearer".to_string()), + } + } else { + InjectRule { + domain: domain.to_string(), + header: Some(header.to_string()), + format: Some(format!("{}%s{}", &value[..m.start], &value[m.end..])), + scheme: None, + } + } +} + +/// Returns the inject-rule `domain` for an https URL: the bare host on the +/// default port (443), or `host:port` for any other port. This mirrors the +/// enforced allow-list entry grammar. Bracketed IPv6 hosts and non-https +/// schemes are rejected so their occurrences fall back to env-based +/// resolution. +fn parse_https_domain(raw: &str) -> Option { + let url = Url::parse(raw).ok()?; + if url.scheme() != "https" { + return None; + } + + let host = url.host_str()?; + if host.is_empty() || host.starts_with('[') { + return None; + } + + match url.port() { + None => Some(host.to_string()), + Some(port) => Some(format!("{host}:{port}")), + } +} + +/// Collects a network allow-list entry for every remote MCP server `url` +/// (http/https), so user-configured servers are reachable regardless of how, +/// or whether, their credentials are provisioned. Https on the default port +/// yields a bare host; any other port is spelled `host:port` (both enforced +/// entry formats). Bracketed IPv6 hosts are skipped — the sbx kit v2 spec's +/// allow grammar only covers named hosts. +pub(crate) fn collect_server_allow_entries( + servers: &serde_json::Map, +) -> Vec { + let mut out = BTreeSet::new(); + for config in servers.values() { + let Some(url) = config.get("url").and_then(Value::as_str) else { + continue; + }; + if let Some(entry) = allow_entry_for_url(url) { + out.insert(entry); + } + } + + out.into_iter().collect() +} + +fn allow_entry_for_url(raw: &str) -> Option { + let url = Url::parse(raw).ok()?; + let scheme = url.scheme(); + if scheme != "https" && scheme != "http" { + return None; + } + + let host = url.host_str()?; + if host.is_empty() || host.starts_with('[') { + return None; + } + + match url.port_or_known_default() { + Some(443) if scheme == "https" => Some(host.to_string()), + Some(port) => Some(format!("{host}:{port}")), + None => None, + } +} + +fn placeholders(text: &str) -> Result> { + let mut out = Vec::new(); + for caps in SECRET_RE.captures_iter(text) { + let caps = caps.context("Failed to scan for secret placeholders")?; + let full = caps.get(0).expect("capture group 0 always exists"); + out.push(PlaceholderMatch { + name: caps[1].trim().to_string(), + start: full.start(), + end: full.end(), + }); + } + + Ok(out) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CredentialsMixin { + schema_version: &'static str, + kind: &'static str, + name: &'static str, + description: &'static str, + #[serde(skip_serializing_if = "Vec::is_empty")] + credentials: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + permissions: Option, +} + +#[derive(Serialize)] +struct CredentialEntry { + service: String, + description: String, + #[serde(rename = "apiKey")] + api_key: ApiKey, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ApiKey { + name: String, + proxy_managed: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + inject: Vec, +} + +#[derive(Serialize)] +struct Permissions { + network: Network, +} + +#[derive(Serialize)] +struct Network { + allow: Vec, +} + +pub(crate) fn render_mixin_yaml( + credentials: &[CredentialSpec], + server_allow_entries: &[String], +) -> Result { + let mut allow: BTreeSet = credentials + .iter() + .flat_map(|c| c.inject.iter().map(|r| r.domain.clone())) + .collect(); + allow.extend(server_allow_entries.iter().cloned()); + + let mixin = CredentialsMixin { + schema_version: "2", + kind: "mixin", + name: MCP_MIXIN_NAME, + description: "Auto-generated by Coyote at launch: allows network egress to the user's \ + remote MCP servers and declares their credentials so Docker Sandboxes \ + binds them (bindings are approved on first interactive run). Values are \ + pre-seeded from Coyote's vault via `sbx secret set`.", + credentials: credentials + .iter() + .map(|c| CredentialEntry { + service: c.service_id.clone(), + description: format!( + "Coyote vault secret '{}', used by MCP server(s) {}", + c.secret_name, + quoted_list(&c.servers) + ), + api_key: ApiKey { + name: c.env_var.clone(), + proxy_managed: c.proxy_managed, + inject: c.inject.clone(), + }, + }) + .collect(), + permissions: (!allow.is_empty()).then(|| Permissions { + network: Network { + allow: allow.into_iter().collect(), + }, + }), + }; + + serde_yaml::to_string(&mixin).context("Failed to serialize generated MCP credentials mixin") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Map, json}; + + fn servers(value: Value) -> Map { + value.as_object().unwrap().clone() + } + + #[test] + fn secret_service_id_lowercases_and_dashes() { + assert_eq!(secret_service_id("GITHUB_PAT"), "github-pat"); + } + + #[test] + fn secret_service_id_collapses_and_trims() { + assert_eq!(secret_service_id("__My..Token__"), "my-token"); + } + + #[test] + fn secret_service_id_truncates_without_trailing_dash() { + let long = format!("{}-{}", "a".repeat(62), "b".repeat(10)); + + let id = secret_service_id(&long); + + assert_eq!(id.len(), 62, "trailing dash at char 63 must be dropped"); + assert!(!id.ends_with('-')); + } + + #[test] + fn secret_service_id_all_invalid_yields_empty() { + assert_eq!(secret_service_id("..."), ""); + } + + #[test] + fn sandbox_secret_env_var_uppercases_and_underscores() { + assert_eq!( + sandbox_secret_env_var("GITHUB_PAT"), + "COYOTE_SECRET_GITHUB_PAT" + ); + assert_eq!( + sandbox_secret_env_var("notion-token"), + "COYOTE_SECRET_NOTION_TOKEN" + ); + } + + #[test] + fn collects_every_placeholder_not_just_the_first() { + let servers = servers(json!({ + "multi": { + "command": "run", + "args": ["--token", "{{TOKEN_A}}"], + "env": { + "FIRST": "{{TOKEN_B}}", + "COMBINED": "{{TOKEN_C}}:{{TOKEN_D}}" + } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + let names: Vec<&str> = creds.iter().map(|c| c.secret_name.as_str()).collect(); + + assert_eq!(names, vec!["TOKEN_A", "TOKEN_B", "TOKEN_C", "TOKEN_D"]); + assert!(creds.iter().all(|c| !c.proxy_managed)); + } + + #[test] + fn bearer_authorization_header_is_proxy_managed_with_bearer_scheme() { + let servers = servers(json!({ + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { "Authorization": "Bearer {{GITHUB_PAT}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + assert_eq!(creds.len(), 1); + let cred = &creds[0]; + assert_eq!(cred.service_id, "github-pat"); + assert_eq!(cred.env_var, "COYOTE_SECRET_GITHUB_PAT"); + assert!(cred.proxy_managed); + assert_eq!( + cred.inject, + vec![InjectRule { + domain: "api.githubcopilot.com".to_string(), + header: None, + format: None, + scheme: Some("bearer".to_string()), + }] + ); + } + + #[test] + fn custom_header_becomes_format_rule() { + let servers = servers(json!({ + "notion": { + "url": "https://mcp.notion.com/sse", + "headers": { "X-Api-Key": "token {{notion-token}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds.len(), 1); + assert!(creds[0].proxy_managed); + assert_eq!( + creds[0].inject, + vec![InjectRule { + domain: "mcp.notion.com".to_string(), + header: Some("X-Api-Key".to_string()), + format: Some("token %s".to_string()), + scheme: None, + }] + ); + } + + #[test] + fn lowercase_bearer_prefix_is_proxy_managed() { + let servers = servers(json!({ + "svc": { + "url": "https://api.example.com/mcp", + "headers": { "authorization": "bearer {{KEY}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds.len(), 1); + assert!(creds[0].proxy_managed); + assert_eq!(creds[0].inject[0].scheme.as_deref(), Some("bearer")); + assert!(creds[0].inject[0].header.is_none()); + } + + #[test] + fn non_default_https_port_is_proxy_managed_with_port_in_domain() { + let servers = servers(json!({ + "svc": { + "url": "https://api.example.com:8443/mcp", + "headers": { "Authorization": "Bearer {{KEY}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds.len(), 1); + assert!(creds[0].proxy_managed); + assert_eq!( + creds[0].inject, + vec![InjectRule { + domain: "api.example.com:8443".to_string(), + header: None, + format: None, + scheme: Some("bearer".to_string()), + }] + ); + } + + #[test] + fn port_inject_domain_is_included_in_rendered_allow_list() { + let servers = servers(json!({ + "svc": { + "url": "https://api.example.com:8443/mcp", + "headers": { "Authorization": "Bearer {{KEY}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + let allow = collect_server_allow_entries(&servers); + let yaml = render_mixin_yaml(&creds, &allow).unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + + let rendered_allow = value["permissions"]["network"]["allow"] + .as_sequence() + .unwrap(); + + assert_eq!( + rendered_allow + .iter() + .filter(|v| v.as_str() == Some("api.example.com:8443")) + .count(), + 1, + "host:port must appear exactly once (inject domain deduped against server allow entry)" + ); + } + + #[test] + fn explicit_default_port_is_omitted_from_domain() { + let servers = servers(json!({ + "svc": { + "url": "https://api.example.com:443/mcp", + "headers": { "Authorization": "Bearer {{KEY}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds[0].inject[0].domain, "api.example.com"); + } + + #[test] + fn multi_placeholder_header_falls_back_to_env() { + let servers = servers(json!({ + "svc": { + "url": "https://api.example.com/mcp", + "headers": { "Authorization": "Basic {{USER}}:{{PASS}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds.len(), 2); + assert!(creds.iter().all(|c| !c.proxy_managed)); + assert!(creds.iter().all(|c| c.inject.is_empty())); + } + + #[test] + fn percent_in_header_remainder_falls_back_to_env() { + let servers = servers(json!({ + "svc": { + "url": "https://api.example.com/mcp", + "headers": { "X-Key": "100%-{{KEY}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert!(!creds[0].proxy_managed); + } + + #[test] + fn non_https_url_falls_back_to_env() { + let servers = servers(json!({ + "svc": { + "url": "http://api.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert!(!creds[0].proxy_managed); + } + + #[test] + fn url_placeholder_is_env_based() { + let servers = servers(json!({ + "svc": { "url": "https://api.example.com/mcp?key={{KEY}}" } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds[0].secret_name, "KEY"); + assert!(!creds[0].proxy_managed); + } + + #[test] + fn env_occurrence_in_another_server_demotes_proxy_managed() { + let servers = servers(json!({ + "remote": { + "url": "https://api.example.com/mcp", + "headers": { "Authorization": "Bearer {{SHARED}}" } + }, + "local": { + "command": "run", + "env": { "SHARED": "{{SHARED}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds.len(), 1); + assert!(!creds[0].proxy_managed); + assert!(creds[0].inject.is_empty()); + assert_eq!(creds[0].servers, vec!["local", "remote"]); + } + + #[test] + fn identical_inject_rules_across_servers_are_deduped() { + let servers = servers(json!({ + "a": { + "url": "https://api.example.com/one", + "headers": { "Authorization": "Bearer {{KEY}}" } + }, + "b": { + "url": "https://api.example.com/two", + "headers": { "Authorization": "Bearer {{KEY}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds.len(), 1); + assert!(creds[0].proxy_managed); + assert_eq!(creds[0].inject.len(), 1); + } + + #[test] + fn same_domain_different_secrets_demotes_both_to_env() { + let servers = servers(json!({ + "github-personal": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { "Authorization": "Bearer {{GITHUB_PAT_PERSONAL}}" } + }, + "github-work": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { "Authorization": "Bearer {{GITHUB_PAT_WORK}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds.len(), 2); + assert!( + creds.iter().all(|c| !c.proxy_managed), + "secrets sharing an inject domain must both fall back to env" + ); + assert!(creds.iter().all(|c| c.inject.is_empty())); + } + + #[test] + fn slot_conflict_demotes_secret_on_all_its_domains() { + let servers = servers(json!({ + "a-shared": { + "url": "https://shared.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY_A}}" } + }, + "a-solo": { + "url": "https://solo.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY_A}}" } + }, + "b-shared": { + "url": "https://shared.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY_B}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds.len(), 2); + assert!( + creds + .iter() + .all(|c| !c.proxy_managed && c.inject.is_empty()), + "a secret demoted by a slot conflict must lose ALL its inject rules, \ + not just the conflicting one" + ); + } + + #[test] + fn different_headers_on_same_domain_stay_proxy_managed() { + let servers = servers(json!({ + "svc": { + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer {{KEY_A}}", + "X-Api-Key": "{{KEY_B}}" + } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + assert_eq!(creds.len(), 2); + assert!( + creds.iter().all(|c| c.proxy_managed), + "v2 allows multiple credentials per domain when they target \ + different headers; neither secret may be demoted" + ); + let a = creds.iter().find(|c| c.secret_name == "KEY_A").unwrap(); + assert_eq!(a.inject[0].scheme.as_deref(), Some("bearer")); + let b = creds.iter().find(|c| c.secret_name == "KEY_B").unwrap(); + assert_eq!(b.inject[0].header.as_deref(), Some("X-Api-Key")); + } + + #[test] + fn bearer_scheme_conflicts_with_explicit_authorization_header() { + let servers = servers(json!({ + "bearer-style": { + "url": "https://api.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY_A}}" } + }, + "token-style": { + "url": "https://api.example.com/mcp", + "headers": { "authorization": "token {{KEY_B}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + + assert_eq!(creds.len(), 2); + assert!( + creds + .iter() + .all(|c| !c.proxy_managed && c.inject.is_empty()), + "`scheme: bearer` writes the Authorization header, so it must \ + conflict with an explicit (case-insensitive) Authorization rule" + ); + } + + #[test] + fn env_based_secret_header_occurrence_demotes_injectable_sharer() { + let servers = servers(json!({ + "remote": { + "url": "https://api.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY_A}}" } + }, + "remote-twin": { + "url": "https://api.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY_B}}" } + }, + "local": { + "command": "run", + "env": { "KEY_B": "{{KEY_B}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + assert_eq!(creds.len(), 2); + let a = creds.iter().find(|c| c.secret_name == "KEY_A").unwrap(); + assert!( + !a.proxy_managed && a.inject.is_empty(), + "KEY_B is env-based, but its header occurrence on the shared domain \ + must still demote KEY_A — the proxy would rewrite KEY_B's requests too" + ); + } + + #[test] + fn unsanitizable_secret_name_is_an_error() { + let servers = servers(json!({ + "svc": { "env": { "KEY": "{{...}}" } } + })); + + let err = collect_credentials(&servers).unwrap_err().to_string(); + + assert!(err.contains("empty sbx service id"), "got: {err}"); + } + + #[test] + fn colliding_sanitized_secret_names_are_an_error() { + let servers = servers(json!({ + "svc": { "env": { "A": "{{MY_KEY}}", "B": "{{my-key}}" } } + })); + + let err = collect_credentials(&servers).unwrap_err().to_string(); + + assert!( + err.contains("'MY_KEY'") && err.contains("'my-key'"), + "error must name both secrets, got: {err}" + ); + assert!( + err.contains("service id 'my-key'"), + "error must name the colliding service id, got: {err}" + ); + } + + #[test] + fn rendered_mixin_declares_proxy_managed_credential_and_permissions() { + let servers = servers(json!({ + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { "Authorization": "Bearer {{GITHUB_PAT}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + let yaml = render_mixin_yaml(&creds, &[]).unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + + assert_eq!(value["schemaVersion"].as_str(), Some("2")); + assert_eq!(value["kind"].as_str(), Some("mixin")); + assert_eq!(value["name"].as_str(), Some("coyote-mcp")); + + let cred = &value["credentials"][0]; + assert_eq!(cred["service"].as_str(), Some("github-pat")); + let description = cred["description"].as_str().unwrap(); + assert!( + description.contains("'GITHUB_PAT'") && description.contains("'github'"), + "binding-prompt description must name the vault secret and its server(s), got: {description}" + ); + assert_eq!( + cred["apiKey"]["name"].as_str(), + Some("COYOTE_SECRET_GITHUB_PAT") + ); + assert_eq!(cred["apiKey"]["proxyManaged"].as_bool(), Some(true)); + let inject = &cred["apiKey"]["inject"][0]; + assert_eq!(inject["domain"].as_str(), Some("api.githubcopilot.com")); + assert_eq!(inject["scheme"].as_str(), Some("bearer")); + assert!(inject.get("header").is_none()); + assert!(inject.get("format").is_none()); + + assert_eq!( + value["permissions"]["network"]["allow"][0].as_str(), + Some("api.githubcopilot.com") + ); + } + + #[test] + fn rendered_mixin_omits_inject_and_permissions_for_env_based_secrets() { + let servers = servers(json!({ + "local": { "command": "run", "env": { "KEY": "{{NOTION_TOKEN}}" } } + })); + + let creds = collect_credentials(&servers).unwrap(); + let yaml = render_mixin_yaml(&creds, &[]).unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + + let cred = &value["credentials"][0]; + assert_eq!(cred["apiKey"]["proxyManaged"].as_bool(), Some(false)); + assert!(cred["apiKey"].get("inject").is_none()); + assert!(value.get("permissions").is_none()); + } + + #[test] + fn rendered_mixin_is_deterministic() { + let servers = servers(json!({ + "b": { + "url": "https://b.example.com/mcp", + "headers": { "Authorization": "Bearer {{ZULU}}" } + }, + "a": { + "url": "https://a.example.com/mcp", + "headers": { "Authorization": "Bearer {{ALPHA}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + let services: Vec<&str> = creds.iter().map(|c| c.service_id.as_str()).collect(); + + assert_eq!(services, vec!["alpha", "zulu"]); + assert_eq!( + render_mixin_yaml(&creds, &[]).unwrap(), + render_mixin_yaml(&creds, &[]).unwrap() + ); + } + + #[test] + fn allow_entry_for_url_formats() { + assert_eq!( + allow_entry_for_url("https://api.example.com/mcp"), + Some("api.example.com".to_string()) + ); + assert_eq!( + allow_entry_for_url("https://api.example.com:443/mcp"), + Some("api.example.com".to_string()) + ); + assert_eq!( + allow_entry_for_url("https://api.example.com:8443/mcp"), + Some("api.example.com:8443".to_string()) + ); + assert_eq!( + allow_entry_for_url("http://api.example.com/mcp"), + Some("api.example.com:80".to_string()) + ); + assert_eq!(allow_entry_for_url("ws://api.example.com/mcp"), None); + assert_eq!(allow_entry_for_url("https://[::1]:8443/mcp"), None); + assert_eq!(allow_entry_for_url("not a url"), None); + } + + #[test] + fn collect_server_allow_entries_covers_secretless_servers_and_dedupes() { + let servers = servers(json!({ + "no-secret": { "url": "https://open.example.com/mcp" }, + "no-secret-twin": { "url": "https://open.example.com/mcp" }, + "with-secret": { + "url": "https://api.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY}}" } + }, + "stdio": { "command": "uvx", "args": ["some-mcp-server"] } + })); + + assert_eq!( + collect_server_allow_entries(&servers), + vec![ + "api.example.com".to_string(), + "open.example.com".to_string() + ] + ); + } + + #[test] + fn demoted_secret_server_domain_stays_in_allow() { + let servers = servers(json!({ + "a": { + "url": "https://shared.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY_A}}" } + }, + "b": { + "url": "https://shared.example.com/mcp", + "headers": { "Authorization": "Bearer {{KEY_B}}" } + } + })); + + let creds = collect_credentials(&servers).unwrap(); + assert!(creds.iter().all(|c| !c.proxy_managed), "precondition"); + + let entries = collect_server_allow_entries(&servers); + let yaml = render_mixin_yaml(&creds, &entries).unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + + assert_eq!( + value["permissions"]["network"]["allow"][0].as_str(), + Some("shared.example.com"), + "demoting a credential must not cut the server's egress" + ); + } + + #[test] + fn mixin_with_no_credentials_still_declares_network_allow() { + let servers = servers(json!({ + "open": { "url": "https://open.example.com/mcp" } + })); + + let creds = collect_credentials(&servers).unwrap(); + assert!(creds.is_empty(), "precondition: no secrets referenced"); + + let entries = collect_server_allow_entries(&servers); + let yaml = render_mixin_yaml(&creds, &entries).unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + + assert!( + value.get("credentials").is_none(), + "empty credentials list must be omitted under strict decoding hygiene" + ); + assert_eq!( + value["permissions"]["network"]["allow"][0].as_str(), + Some("open.example.com") + ); + } +} diff --git a/src/sandbox/mixins.rs b/src/sandbox/mixins.rs index e9574a9..57365ae 100644 --- a/src/sandbox/mixins.rs +++ b/src/sandbox/mixins.rs @@ -34,8 +34,12 @@ impl DiscoveredMixin { pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result { let bytes = fs::read(mixin_path) .with_context(|| format!("Failed to read sbx mixin {}", mixin_path.display()))?; + wrap_mixin_bytes_as_kit(&bytes, &mixin_path.display().to_string()) +} + +pub fn wrap_mixin_bytes_as_kit(bytes: &[u8], label: &str) -> Result { let mut hasher = Sha256::new(); - hasher.update(&bytes); + hasher.update(bytes); let hash = format!("{:x}", hasher.finalize()); let kit_dir = paths::sbx_mixin_kits_dir().join(&hash); @@ -49,14 +53,10 @@ pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result { fs::create_dir_all(&kit_dir) .with_context(|| format!("Failed to create mixin kit dir {}", kit_dir.display()))?; - fs::write(&spec_path, &bytes) + fs::write(&spec_path, bytes) .with_context(|| format!("Failed to write {}", spec_path.display()))?; - debug!( - "Wrapped mixin {} as kit at {}", - mixin_path.display(), - kit_dir.display() - ); + debug!("Wrapped mixin {label} as kit at {}", kit_dir.display()); Ok(kit_dir) } @@ -97,15 +97,18 @@ pub fn summarize(path: &Path) -> Result<(usize, usize)> { .with_context(|| format!("Failed to parse sbx mixin {}", path.display()))?; let installs = value - .get("commands") - .and_then(|c| c.get("install")) + .get("setup") + .and_then(|s| s.get("install")) + .or_else(|| value.get("commands").and_then(|c| c.get("install"))) .and_then(|i| i.as_sequence()) .map(|s| s.len()) .unwrap_or(0); let domains = value - .get("network") - .and_then(|n| n.get("allowedDomains")) + .get("permissions") + .and_then(|p| p.get("network")) + .and_then(|n| n.get("allow")) + .or_else(|| value.get("network").and_then(|n| n.get("allowedDomains"))) .and_then(|d| d.as_sequence()) .map(|s| s.len()) .unwrap_or(0); @@ -228,6 +231,34 @@ mod tests { fs::write( &path, r#" +schemaVersion: "2" +kind: mixin +setup: + install: + - command: "echo hi" + - command: "echo bye" +permissions: + network: + allow: + - "a.example.com:443" + - "b.example.com:443" + - "c.example.com:443" +"#, + ) + .unwrap(); + + assert_eq!(summarize(&path).unwrap(), (2, 3)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn summarize_falls_back_to_v1_field_paths() { + let root = unique_root("sbx-mixin-counts-v1"); + let path = root.join("sbx-mixin.yaml"); + fs::write( + &path, + r#" schemaVersion: "1" kind: mixin commands: @@ -375,6 +406,19 @@ network: assert_eq!(fs::read_to_string(&spec).unwrap(), content); } + #[test] + #[serial] + fn wrap_mixin_bytes_as_kit_writes_spec_yaml() { + let _guard = TestCacheDirGuard::new(); + let content = b"schemaVersion: '2'\nkind: mixin\nname: generated\n"; + + let kit_dir = wrap_mixin_bytes_as_kit(content, "generated").unwrap(); + let spec = kit_dir.join("spec.yaml"); + + assert!(spec.exists(), "spec.yaml must exist in wrapped kit dir"); + assert_eq!(fs::read(&spec).unwrap(), content); + } + #[test] #[serial] fn wrap_mixin_as_kit_is_deterministic_for_identical_content() { diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index 91fc33b..8ac1736 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -10,13 +10,17 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use which::which; +mod mcp_credentials; mod mixins; +pub(crate) use mcp_credentials::sandbox_secret_env_var; + use crate::config::AppConfig; use crate::config::Config; use crate::config::VAULT_DATA_FILE_NAME; use crate::config::paths; use crate::rag::RagData; +use crate::sandbox::mcp_credentials::MCP_MIXIN_NAME; use crate::sandbox::mixins::DiscoveredMixin; use crate::utils::run_command_with_output; use crate::vault::SECRET_RE; @@ -51,17 +55,22 @@ pub fn launch(name: Option, fresh: bool) -> Result<()> { let registered = sbx_registered_services()?; inject_llm_secret(&config_content, &vault, ®istered)?; if !fresh { - inject_mcp_secrets(&vault, ®istered)?; inject_rag_secrets(&vault, ®istered)?; } + let credentials_mixin = if fresh { + None + } else { + inject_mcp_secrets(&vault, ®istered)? + }; + let discovered = mixins::discover()?; if sandbox_exists(&name)? { info!("Re-attaching to existing sandbox '{name}'"); } else { mixins::log_discovery(&discovered, false); - create_sandbox(&name, &kit_path, &discovered)?; + create_sandbox(&name, &kit_path, &discovered, credentials_mixin.as_deref())?; if !fresh { copy_host_files(&name)?; } @@ -234,7 +243,7 @@ fn inject_llm_secret( if registered.contains(&service) { eprintln!( "Secret for '{service}' already registered with sbx. \ - To update it, run: sbx secret set -g --force {service}" + To update it, run: sbx secret set --force {service}" ); continue; } @@ -249,23 +258,14 @@ fn inject_llm_secret( Ok(()) } -fn find_secret_placeholder(value: &Value) -> Option { - match value { - Value::String(s) => SECRET_RE - .captures(s) - .ok() - .flatten() - .map(|caps| caps[1].to_string()), - Value::Object(map) => map.values().find_map(find_secret_placeholder), - Value::Array(arr) => arr.iter().find_map(find_secret_placeholder), - _ => None, - } -} - -fn inject_mcp_secrets(vault: &Vault, registered: &HashSet) -> Result<()> { +/// Registers one sbx secret per distinct `{{placeholder}}` in the MCP config +/// and returns the generated schema-v2 `coyote-mcp` mixin (network egress for +/// every remote MCP server + credential declarations), or `None` when the MCP +/// config references no remote servers and no secrets. +fn inject_mcp_secrets(vault: &Vault, registered: &HashSet) -> Result> { let mcp_path = paths::mcp_config_file(); if !mcp_path.exists() { - return Ok(()); + return Ok(None); } let content = fs::read_to_string(&mcp_path) @@ -274,33 +274,44 @@ fn inject_mcp_secrets(vault: &Vault, registered: &HashSet) -> Result<()> .with_context(|| format!("Failed to parse {}", mcp_path.display()))?; let Some(servers) = mcp.get("mcpServers").and_then(|v| v.as_object()) else { - return Ok(()); + return Ok(None); }; - for (server_name, server_config) in servers { - let Some(secret_name) = find_secret_placeholder(server_config) else { - continue; - }; + let credentials = mcp_credentials::collect_credentials(servers)?; + let allow_entries = mcp_credentials::collect_server_allow_entries(servers); + if credentials.is_empty() && allow_entries.is_empty() { + return Ok(None); + } - if registered.contains(server_name.as_str()) { + for credential in &credentials { + if registered.contains(credential.service_id.as_str()) { eprintln!( - "Secret for '{server_name}' already registered with sbx. \ - To update it, run: sbx secret set -g --force {server_name}" + "Secret for '{}' already registered with sbx. \ + To update it, run: sbx secret set --force {}", + credential.service_id, credential.service_id ); continue; } - let secret_value = vault.get_secret(&secret_name, false).with_context(|| { - format!( - "Secret '{secret_name}' referenced by MCP server '{server_name}' not found \ - in vault. Add it with: coyote --add-secret {secret_name}" - ) - })?; + let secret_value = vault + .get_secret(&credential.secret_name, false) + .with_context(|| { + format!( + "Secret '{}' referenced by MCP server(s) {} not found \ + in vault. Add it with: coyote --add-secret {}", + credential.secret_name, + mcp_credentials::quoted_list(&credential.servers), + credential.secret_name + ) + })?; - sbx_secret_set(server_name, &secret_value)?; + sbx_secret_set(&credential.service_id, &secret_value)?; } - Ok(()) + Ok(Some(mcp_credentials::render_mixin_yaml( + &credentials, + &allow_entries, + )?)) } /// Registers the API key of every attached RAG with the sbx proxy. @@ -366,7 +377,7 @@ fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> St match provider_type { "claude" => "anthropic".to_string(), "openai" => "openai".to_string(), - "gemini" | "vertexai" => "google".to_string(), + "gemini" | "vertexai" => "gemini".to_string(), "openai-compatible" => client_name.unwrap_or("openai-compatible").to_string(), other => client_name.unwrap_or(other).to_string(), } @@ -399,25 +410,29 @@ fn sbx_registered_services() -> Result> { fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> { let mut child = Command::new(SBX_BINARY) - .args(["secret", "set", "-g", service]) + .args(["secret", "set", service]) .stdin(Stdio::piped()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn() - .context("Failed to spawn `sbx secret set -g`")?; + .context("Failed to spawn `sbx secret set`")?; if let Some(mut stdin_handle) = child.stdin.take() { stdin_handle .write_all(secret_value.as_bytes()) - .context("Failed to write secret to `sbx secret set -g` stdin")?; + .context("Failed to write secret to `sbx secret set` stdin")?; } let status = child .wait() - .context("Failed to wait for `sbx secret set -g`")?; + .context("Failed to wait for `sbx secret set`")?; if !status.success() { - bail!("`sbx secret set -g {service}` exited with {status}"); + eprintln!( + "Warning: failed to register sbx secret '{service}' \ + (`sbx secret set {service}` exited with {status}). \ + Set it manually with: echo '' | sbx secret set {service}" + ); } Ok(()) @@ -436,9 +451,17 @@ fn sandbox_exists(name: &str) -> Result { .any(|line| line.split_whitespace().next() == Some(name))) } -fn create_sandbox(name: &str, kit_path: &Path, mixins: &[DiscoveredMixin]) -> Result<()> { +fn create_sandbox( + name: &str, + kit_path: &Path, + mixins: &[DiscoveredMixin], + credentials_mixin: Option<&str>, +) -> Result<()> { info!("Creating sandbox '{name}'"); - let args = build_create_args(name, kit_path, mixins)?; + let credentials_kit = credentials_mixin + .map(|yaml| mixins::wrap_mixin_bytes_as_kit(yaml.as_bytes(), MCP_MIXIN_NAME)) + .transpose()?; + let args = build_create_args(name, kit_path, mixins, credentials_kit.as_deref())?; debug!("sbx {}", args.join(" ")); let status = Command::new(SBX_BINARY) .args(&args) @@ -459,6 +482,7 @@ fn build_create_args( name: &str, kit_path: &Path, mixins: &[DiscoveredMixin], + credentials_kit: Option<&Path>, ) -> Result> { let kit_str = kit_path .to_str() @@ -482,6 +506,15 @@ fn build_create_args( args.push(mixin_str); } + if let Some(kit) = credentials_kit { + let cred_str = kit + .to_str() + .ok_or_else(|| anyhow!("Credentials kit path is not valid UTF-8: {}", kit.display()))? + .to_string(); + args.push("--kit".to_string()); + args.push(cred_str); + } + args.push(SANDBOX_AGENT.to_string()); args.push(".".to_string()); @@ -619,6 +652,7 @@ fn chown_agent_recursive(sandbox: &str, path: &str) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn sanitize_name_lowercases() { @@ -687,8 +721,8 @@ mod tests { #[test] fn build_create_args_emits_base_kit_before_mixins() { let kit = PathBuf::from("/cache/sbx-kit"); - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}")); @@ -711,7 +745,7 @@ mod tests { }, ]; - let args = build_create_args("my-box", &kit, &mixins).unwrap(); + let args = build_create_args("my-box", &kit, &mixins, None).unwrap(); assert_eq!( args, @@ -737,7 +771,9 @@ mod tests { #[test] fn build_create_args_with_no_mixins_omits_mixin_kits() { let kit = PathBuf::from("/cache/sbx-kit"); - let args = build_create_args("box", &kit, &[]).unwrap(); + + let args = build_create_args("box", &kit, &[], None).unwrap(); + assert_eq!( args, vec![ @@ -751,4 +787,79 @@ mod tests { ] ); } + + #[test] + fn build_create_args_appends_credentials_kit_after_mixins() { + let kit = PathBuf::from("/cache/sbx-kit"); + let credentials_kit = PathBuf::from("/cache/sbx-mixin-kits/abc123"); + + let args = build_create_args("box", &kit, &[], Some(&credentials_kit)).unwrap(); + + assert_eq!( + args, + vec![ + "create".to_string(), + "--name".to_string(), + "box".to_string(), + "--kit".to_string(), + "/cache/sbx-kit".to_string(), + "--kit".to_string(), + "/cache/sbx-mixin-kits/abc123".to_string(), + "coyote".to_string(), + ".".to_string(), + ] + ); + } + + #[test] + fn build_create_args_orders_base_kit_then_mixins_then_credentials_kit() { + let kit = PathBuf::from("/cache/sbx-kit"); + let credentials_kit = PathBuf::from("/cache/sbx-mixin-kits/abc123"); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = env::temp_dir().join(format!("coyote-mixin-cred-{unique}")); + fs::create_dir_all(&dir).unwrap(); + + let mixins = vec![DiscoveredMixin { + path: dir.clone(), + label: "user".into(), + install_count: 0, + domain_count: 0, + }]; + + let args = build_create_args("box", &kit, &mixins, Some(&credentials_kit)).unwrap(); + + assert_eq!( + args, + vec![ + "create".to_string(), + "--name".to_string(), + "box".to_string(), + "--kit".to_string(), + "/cache/sbx-kit".to_string(), + "--kit".to_string(), + dir.display().to_string(), + "--kit".to_string(), + "/cache/sbx-mixin-kits/abc123".to_string(), + "coyote".to_string(), + ".".to_string(), + ] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn provider_to_sbx_service_maps_gemini_family_to_gemini() { + assert_eq!(provider_to_sbx_service("gemini", None), "gemini"); + assert_eq!(provider_to_sbx_service("vertexai", None), "gemini"); + } + + #[test] + fn provider_to_sbx_service_maps_known_providers() { + assert_eq!(provider_to_sbx_service("claude", None), "anthropic"); + assert_eq!(provider_to_sbx_service("openai", None), "openai"); + } } diff --git a/src/vault/utils.rs b/src/vault/utils.rs index d329148..64cdaf6 100644 --- a/src/vault/utils.rs +++ b/src/vault/utils.rs @@ -1,5 +1,5 @@ use crate::config::ensure_parent_exists; -use crate::sandbox::SANDBOX_ENV_FLAG; +use crate::sandbox::{SANDBOX_ENV_FLAG, sandbox_secret_env_var}; use crate::vault::{SECRET_RE, Vault}; use anyhow::Result; use anyhow::anyhow; @@ -358,7 +358,32 @@ fn required_cli_preflight(label: &str, cli: &str, install_url: &str) { pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec)> { if env::var_os(SANDBOX_ENV_FLAG).is_some() { - return Ok((content.to_string(), vec![])); + let (parsed, missing) = interpolate_secrets_with(content, None, |name| { + env::var(sandbox_secret_env_var(name)).map_err(|_| { + anyhow!(SecretError::NotFound { + key: name.to_string(), + provider: "sandbox environment", + }) + }) + })?; + + if !missing.is_empty() { + let mut env_vars: Vec = missing + .iter() + .map(|name| sandbox_secret_env_var(name)) + .collect(); + env_vars.sort(); + env_vars.dedup(); + eprintln!( + "Config references secrets that are not available inside this sandbox \ + (expected env vars: {}). Sandbox secrets are provisioned at creation \ + from the host; add the missing secrets on the host, then re-create \ + the sandbox.", + env_vars.join(", ") + ); + } + + return Ok((parsed, missing)); } interpolate_secrets_with(content, vault.auth_hint(), |name| { vault.get_secret(name, false)