diff --git a/.claude/skills/verification/SKILL.md b/.claude/skills/verification/SKILL.md index e52cdd2..82dcf45 100644 --- a/.claude/skills/verification/SKILL.md +++ b/.claude/skills/verification/SKILL.md @@ -36,7 +36,7 @@ For every `FAIL` line in the output, apply the fix described below, then re-run | `make lint` | Run `make lint` to see current errors. Fix each one - do not add `//nolint:` without a comment explaining why. Re-run until clean. | | `test-race` | Find the failing test(s) in the output above the gate line. Fix the root cause. Do NOT suppress the race detector with `runtime.GOMAXPROCS(1)` or similar hacks. | | coverage below 90% | Open `.reports/coverage-.html` for the listed package. Add tests for uncovered lines. Aim for lines that test real behaviour, not just coverage points. | -| GPG signatures | Do NOT re-sign automatically. Report the unsigned commits to the user, show the exact commits, and ask for explicit confirmation before running the re-sign command from CLAUDE.md. | +| GPG signatures | Do NOT re-sign automatically. Report the unsigned commits to the user, show the exact commits, and ask for explicit confirmation before running the re-sign command from CLAUDE.md. GitHub-generated "Merge pull request" commits are ignored by the gate (their `web-flow` key is often expired/untrusted locally and they can't be re-signed without diverging from the remote). | | CHANGES.md missing `## Development` | Add the section at the top of CHANGES.md with one bullet per user-visible change introduced on this branch. | | CHANGES.md has no bullet entries | Add bullets under `## Development` - one per logical change: what changed and why, in plain English. | | docs not updated | Identify which new or changed behaviour is undocumented. Add a section to `README.md` (user-facing changes) or the relevant file under `docs/` (architecture/config changes). | @@ -73,3 +73,6 @@ git log --format="%G? %h %s" | head -20 - Every line starts with `G` - report "All commits signed. Verification complete." - Any line starts with `N` or `B` - list those commits and ask the user for confirmation before re-signing. Use the re-sign command from CLAUDE.md. +- Lines whose subject begins with `Merge pull request` may carry `E` (expired + GitHub `web-flow` key) - ignore them. Re-signing them would diverge from + the remote and serves no purpose. diff --git a/.scripts/verification.sh b/.scripts/verification.sh index 07c65d4..8ea4b62 100755 --- a/.scripts/verification.sh +++ b/.scripts/verification.sh @@ -96,7 +96,10 @@ else COMMITS=$(git log --format="%G? %h %s" | head -20 || true) fi -UNSIGNED=$(printf '%s\n' "$COMMITS" | { grep -v "^G " || true; }) +# Ignore GitHub-generated "Merge pull request" commits: they are signed by +# GitHub's web-flow key, which is often expired/untrusted locally and shows +# as 'E'. We can't re-sign them anyway (they would diverge from the remote). +UNSIGNED=$(printf '%s\n' "$COMMITS" | { grep -v "^G " || true; } | { grep -v "Merge pull request" || true; }) if [ -z "$UNSIGNED" ]; then gate_pass "all commits on this branch are GPG-signed" else diff --git a/CHANGES.md b/CHANGES.md index 5ec6418..42cee27 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -63,6 +63,10 @@ use `export LOCKSMITH_SESSION=$(locksmith session ensure --quiet)`) and includes usage examples for all three shipped vault types (`gopass`, `keychain`, `1password`). +- Agent templates now document an MCP-wrapper fallback: when a locksmith-wrapped MCP server fails, the agent parses `--header` and `--env` templates from its own MCP config, resolves them via `locksmith get`, and runs the server directly. Templates also whitelist `session ensure`, `serve`, and `get` as the only runtime locksmith commands. +- The verification GPG gate now ignores GitHub-generated "Merge pull request" commits: their `web-flow` signature is often expired locally and they cannot be re-signed without diverging from the remote. +- Agent templates now document a recovery prompt for external HTTP API auth failures: on the first 401/403 for a hostname, the agent asks the user to (1) Skip, (2) provide a locksmith key or vault+path, or (3) paste the token directly. Resolved tokens are cached per hostname for the rest of the session; Skip suppresses further prompts for that hostname. +- Tightened the runtime-whitelist, MCP-fallback, and external-API auth-failure sections in agent templates (~45% shorter; same semantics). The MCP fallback now opens with an explicit "execute these steps; do not ask the user clarifying questions" directive so smaller models commit to the recovery path instead of pausing for confirmation. Validated across multiple parallel sub-agent runs on Sonnet and Haiku covering both flows. ## Version v0.3.0 - 2026-05-15 diff --git a/internal/initflow/agents/claude/templates/claude_md.md.tmpl b/internal/initflow/agents/claude/templates/claude_md.md.tmpl index 00dbb70..5922276 100644 --- a/internal/initflow/agents/claude/templates/claude_md.md.tmpl +++ b/internal/initflow/agents/claude/templates/claude_md.md.tmpl @@ -16,6 +16,125 @@ Just call `locksmith get` directly. locksmith get --vault keychain --path "My API Token" locksmith get --vault op --path "op://Personal/GitHub/token" +## Locksmith Commands (runtime whitelist) + +Use ONLY these at runtime: + +- `locksmith session ensure --quiet` - obtain/refresh a session ID +- `locksmith serve` - start the daemon (rare; `session ensure` spawns it) +- `locksmith get --key ` or `--vault --path ` - fetch a secret + +Never call `init`, `vault`, `plugins`, `config`, `reload`, `mcp`, or any other +subcommand - those are for the human operator. + +## MCP Servers and Locksmith + +MCP config is in your agent's own settings (e.g. `~/.claude.json`), NOT in +locksmith's config. Read it from there. + +- `command` != `"locksmith"` -> run as written; act as if locksmith does not + exist for this server. +- `command` == `"locksmith"` with `args` starting `["mcp", "run", ...]` -> + locksmith-wrapped. If it fails or returns an auth error, apply the fallback. + +### Fallback for locksmith-wrapped MCP servers + +These steps are mandatory whenever a locksmith-wrapped server fails - +startup error, auth error, anything. Execute them; do not ask the user +clarifying questions. + +1. Parse args after `mcp run`: `--header Name=`, `--env VAR=`, + `--url `, tokens after `--`. Templates: `{key:}` or + `{vault: path:}`. +2. Resolve: `{key:alias}` -> `locksmith get --key alias`; + `{vault:name path:p}` -> `locksmith get --vault name --path p`. +3. Run directly: local = export env, exec post-`--` cmd; proxy = HTTP to + `--url` with resolved headers (no process to spawn). +4. Still denied? Ask user (replace ``; use real header/arg names): + + > Enter credentials for MCP ``: + > + > headers: {"Authorization": "Bearer ...", "X-Request-ID": "..."} + > args: --arg1 value1 --flag2 + + Parse `headers`/`args` and retry. + +If `locksmith get` itself fails, surface the error verbatim. Do NOT fall +through to the credential prompt - it is for auth-denial only. + +### Example + + "github": { + "command": "locksmith", + "args": ["mcp", "run", "--url", "https://api.example.com/mcp", + "--header", "Authorization=Bearer {key:gh-token}"] + } + +Fallback: + + TOKEN=$(locksmith get --key gh-token) + # then call https://api.example.com/mcp with: Authorization: Bearer $TOKEN + +Local-mode entry (env via `{vault:name path:p}` + binary after `--`): resolve +with `locksmith get --vault name --path p`, export, then exec the post-`--` cmd. + +## External APIs - try Locksmith on auth failure + +On the first auth/access error for an external API's hostname this session, +ask the user once. "Auth/access error" = HTTP `401`/`403`, or body containing +"invalid token"/"unauthorized". + +Show (substitute ``): + +> A token for `` may be in locksmith. Choose one: +> +> 1. Skip - do not ask again for `` this session. +> 2. Locksmith - paste a key alias OR a `vault+path` pair; I will run +> `locksmith get` and retry. +> 3. Other - paste the token directly, or describe what to do. + +Scope = hostname (e.g. `api.github.com`). Paths share the scope. + +Do NOT prompt if: the call succeeded; you have a working token for this +hostname (reuse it); the user already chose Skip this session; or this is a +subsequent failure for the same hostname (first failure only). + +### Handling each choice + +- **Skip**: record hostname as skipped; never re-prompt; continue without it. +- **Locksmith**: parse and run: + - `gh-token` or `key:gh-token` -> `locksmith get --key gh-token` + - `vault:keychain path:gh/tok` or `--vault keychain --path gh/tok` -> + `locksmith get --vault keychain --path gh/tok` + + Use the value as the bearer token and retry. Cache $TOKEN for further calls + to the same hostname this session; do not re-run `locksmith get`. If + `locksmith get` itself fails, surface the error verbatim - do NOT silently + fall back. +- **Other**: free-text. If it looks like a token, use it. Otherwise follow + the instructions literally. + +Never re-prompt for the same hostname this session, regardless of choice. + +### Disambiguation vs MCP fallback + +If the failing call originated from an MCP entry whose `command` is +`"locksmith"` with `args` starting `["mcp", "run", ...]`, use the MCP +fallback. Otherwise use this flow. Do not consult locksmith's config file. + +### Example + +`GET https://api.github.com/user` -> 401. First failure for `api.github.com`: + +> A token for `api.github.com` may be in locksmith. Choose one: 1. Skip ... + +User: `gh-token` -> + + TOKEN=$(locksmith get --key gh-token) + curl -H "Authorization: Bearer $TOKEN" https://api.github.com/user + +Cache $TOKEN for further `api.github.com` calls this session. + - Sessions expire automatically by TTL; no manual cleanup needed. - Pass `LOCKSMITH_SESSION` to sub-agents to share the parent session. diff --git a/internal/initflow/agents/claude/templates/claude_skill.md.tmpl b/internal/initflow/agents/claude/templates/claude_skill.md.tmpl index 780158a..b36dbf2 100644 --- a/internal/initflow/agents/claude/templates/claude_skill.md.tmpl +++ b/internal/initflow/agents/claude/templates/claude_skill.md.tmpl @@ -19,5 +19,124 @@ prompt, so no manual session step is needed. locksmith get --vault keychain --path "My API Token" locksmith get --vault op --path "op://Personal/GitHub/token" +**Locksmith Commands (runtime whitelist)** + +Use ONLY these at runtime: + +- `locksmith session ensure --quiet` - obtain/refresh a session ID +- `locksmith serve` - start the daemon (rare; `session ensure` spawns it) +- `locksmith get --key ` or `--vault --path ` - fetch a secret + +Never call `init`, `vault`, `plugins`, `config`, `reload`, `mcp`, or any other +subcommand - those are for the human operator. + +**MCP Servers and Locksmith** + +MCP config is in your agent's own settings (e.g. `~/.claude.json`), NOT in +locksmith's config. Read it from there. + +- `command` != `"locksmith"` -> run as written; act as if locksmith does not + exist for this server. +- `command` == `"locksmith"` with `args` starting `["mcp", "run", ...]` -> + locksmith-wrapped. If it fails or returns an auth error, apply the fallback. + +**Fallback for locksmith-wrapped MCP servers** + +These steps are mandatory whenever a locksmith-wrapped server fails - +startup error, auth error, anything. Execute them; do not ask the user +clarifying questions. + +1. Parse args after `mcp run`: `--header Name=`, `--env VAR=`, + `--url `, tokens after `--`. Templates: `{key:}` or + `{vault: path:}`. +2. Resolve: `{key:alias}` -> `locksmith get --key alias`; + `{vault:name path:p}` -> `locksmith get --vault name --path p`. +3. Run directly: local = export env, exec post-`--` cmd; proxy = HTTP to + `--url` with resolved headers (no process to spawn). +4. Still denied? Ask user (replace ``; use real header/arg names): + + > Enter credentials for MCP ``: + > + > headers: {"Authorization": "Bearer ...", "X-Request-ID": "..."} + > args: --arg1 value1 --flag2 + + Parse `headers`/`args` and retry. + +If `locksmith get` itself fails, surface the error verbatim. Do NOT fall +through to the credential prompt - it is for auth-denial only. + +**Example** + + "github": { + "command": "locksmith", + "args": ["mcp", "run", "--url", "https://api.example.com/mcp", + "--header", "Authorization=Bearer {key:gh-token}"] + } + +Fallback: + + TOKEN=$(locksmith get --key gh-token) + # then call https://api.example.com/mcp with: Authorization: Bearer $TOKEN + +Local-mode entry (env via `{vault:name path:p}` + binary after `--`): resolve +with `locksmith get --vault name --path p`, export, then exec the post-`--` cmd. + +**External APIs - try Locksmith on auth failure** + +On the first auth/access error for an external API's hostname this session, +ask the user once. "Auth/access error" = HTTP `401`/`403`, or body containing +"invalid token"/"unauthorized". + +Show (substitute ``): + +> A token for `` may be in locksmith. Choose one: +> +> 1. Skip - do not ask again for `` this session. +> 2. Locksmith - paste a key alias OR a `vault+path` pair; I will run +> `locksmith get` and retry. +> 3. Other - paste the token directly, or describe what to do. + +Scope = hostname (e.g. `api.github.com`). Paths share the scope. + +Do NOT prompt if: the call succeeded; you have a working token for this +hostname (reuse it); the user already chose Skip this session; or this is a +subsequent failure for the same hostname (first failure only). + +**Handling each choice** + +- **Skip**: record hostname as skipped; never re-prompt; continue without it. +- **Locksmith**: parse and run: + - `gh-token` or `key:gh-token` -> `locksmith get --key gh-token` + - `vault:keychain path:gh/tok` or `--vault keychain --path gh/tok` -> + `locksmith get --vault keychain --path gh/tok` + + Use the value as the bearer token and retry. Cache $TOKEN for further calls + to the same hostname this session; do not re-run `locksmith get`. If + `locksmith get` itself fails, surface the error verbatim - do NOT silently + fall back. +- **Other**: free-text. If it looks like a token, use it. Otherwise follow + the instructions literally. + +Never re-prompt for the same hostname this session, regardless of choice. + +**Disambiguation vs MCP fallback** + +If the failing call originated from an MCP entry whose `command` is +`"locksmith"` with `args` starting `["mcp", "run", ...]`, use the MCP +fallback. Otherwise use this flow. Do not consult locksmith's config file. + +**Example** + +`GET https://api.github.com/user` -> 401. First failure for `api.github.com`: + +> A token for `api.github.com` may be in locksmith. Choose one: 1. Skip ... + +User: `gh-token` -> + + TOKEN=$(locksmith get --key gh-token) + curl -H "Authorization: Bearer $TOKEN" https://api.github.com/user + +Cache $TOKEN for further `api.github.com` calls this session. + Never hardcode secrets. Never cache secrets outside of locksmith. diff --git a/internal/initflow/agents/codex/templates/codex_agents.md.tmpl b/internal/initflow/agents/codex/templates/codex_agents.md.tmpl index 1b5722a..bb2bbd1 100644 --- a/internal/initflow/agents/codex/templates/codex_agents.md.tmpl +++ b/internal/initflow/agents/codex/templates/codex_agents.md.tmpl @@ -24,5 +24,124 @@ configured TTL (default 3h). locksmith get --vault keychain --path "My API Token" locksmith get --vault op --path "op://Personal/GitHub/token" +## Locksmith Commands (runtime whitelist) + +Use ONLY these at runtime: + +- `locksmith session ensure --quiet` - obtain/refresh a session ID +- `locksmith serve` - start the daemon (rare; `session ensure` spawns it) +- `locksmith get --key ` or `--vault --path ` - fetch a secret + +Never call `init`, `vault`, `plugins`, `config`, `reload`, `mcp`, or any other +subcommand - those are for the human operator. + +## MCP Servers and Locksmith + +MCP config is in your agent's own settings (e.g. `~/.claude.json`), NOT in +locksmith's config. Read it from there. + +- `command` != `"locksmith"` -> run as written; act as if locksmith does not + exist for this server. +- `command` == `"locksmith"` with `args` starting `["mcp", "run", ...]` -> + locksmith-wrapped. If it fails or returns an auth error, apply the fallback. + +### Fallback for locksmith-wrapped MCP servers + +These steps are mandatory whenever a locksmith-wrapped server fails - +startup error, auth error, anything. Execute them; do not ask the user +clarifying questions. + +1. Parse args after `mcp run`: `--header Name=`, `--env VAR=`, + `--url `, tokens after `--`. Templates: `{key:}` or + `{vault: path:}`. +2. Resolve: `{key:alias}` -> `locksmith get --key alias`; + `{vault:name path:p}` -> `locksmith get --vault name --path p`. +3. Run directly: local = export env, exec post-`--` cmd; proxy = HTTP to + `--url` with resolved headers (no process to spawn). +4. Still denied? Ask user (replace ``; use real header/arg names): + + > Enter credentials for MCP ``: + > + > headers: {"Authorization": "Bearer ...", "X-Request-ID": "..."} + > args: --arg1 value1 --flag2 + + Parse `headers`/`args` and retry. + +If `locksmith get` itself fails, surface the error verbatim. Do NOT fall +through to the credential prompt - it is for auth-denial only. + +### Example + + "github": { + "command": "locksmith", + "args": ["mcp", "run", "--url", "https://api.example.com/mcp", + "--header", "Authorization=Bearer {key:gh-token}"] + } + +Fallback: + + TOKEN=$(locksmith get --key gh-token) + # then call https://api.example.com/mcp with: Authorization: Bearer $TOKEN + +Local-mode entry (env via `{vault:name path:p}` + binary after `--`): resolve +with `locksmith get --vault name --path p`, export, then exec the post-`--` cmd. + +## External APIs - try Locksmith on auth failure + +On the first auth/access error for an external API's hostname this session, +ask the user once. "Auth/access error" = HTTP `401`/`403`, or body containing +"invalid token"/"unauthorized". + +Show (substitute ``): + +> A token for `` may be in locksmith. Choose one: +> +> 1. Skip - do not ask again for `` this session. +> 2. Locksmith - paste a key alias OR a `vault+path` pair; I will run +> `locksmith get` and retry. +> 3. Other - paste the token directly, or describe what to do. + +Scope = hostname (e.g. `api.github.com`). Paths share the scope. + +Do NOT prompt if: the call succeeded; you have a working token for this +hostname (reuse it); the user already chose Skip this session; or this is a +subsequent failure for the same hostname (first failure only). + +### Handling each choice + +- **Skip**: record hostname as skipped; never re-prompt; continue without it. +- **Locksmith**: parse and run: + - `gh-token` or `key:gh-token` -> `locksmith get --key gh-token` + - `vault:keychain path:gh/tok` or `--vault keychain --path gh/tok` -> + `locksmith get --vault keychain --path gh/tok` + + Use the value as the bearer token and retry. Cache $TOKEN for further calls + to the same hostname this session; do not re-run `locksmith get`. If + `locksmith get` itself fails, surface the error verbatim - do NOT silently + fall back. +- **Other**: free-text. If it looks like a token, use it. Otherwise follow + the instructions literally. + +Never re-prompt for the same hostname this session, regardless of choice. + +### Disambiguation vs MCP fallback + +If the failing call originated from an MCP entry whose `command` is +`"locksmith"` with `args` starting `["mcp", "run", ...]`, use the MCP +fallback. Otherwise use this flow. Do not consult locksmith's config file. + +### Example + +`GET https://api.github.com/user` -> 401. First failure for `api.github.com`: + +> A token for `api.github.com` may be in locksmith. Choose one: 1. Skip ... + +User: `gh-token` -> + + TOKEN=$(locksmith get --key gh-token) + curl -H "Authorization: Bearer $TOKEN" https://api.github.com/user + +Cache $TOKEN for further `api.github.com` calls this session. + Pass `LOCKSMITH_SESSION` to sub-agents so they share the parent session. diff --git a/internal/initflow/agents/gemini/templates/gemini_md.md.tmpl b/internal/initflow/agents/gemini/templates/gemini_md.md.tmpl index e4f9937..416b7b9 100644 --- a/internal/initflow/agents/gemini/templates/gemini_md.md.tmpl +++ b/internal/initflow/agents/gemini/templates/gemini_md.md.tmpl @@ -25,6 +25,125 @@ configured TTL (default 3h). locksmith get --vault keychain --path "My API Token" locksmith get --vault op --path "op://Personal/GitHub/token" +## Locksmith Commands (runtime whitelist) + +Use ONLY these at runtime: + +- `locksmith session ensure --quiet` - obtain/refresh a session ID +- `locksmith serve` - start the daemon (rare; `session ensure` spawns it) +- `locksmith get --key ` or `--vault --path ` - fetch a secret + +Never call `init`, `vault`, `plugins`, `config`, `reload`, `mcp`, or any other +subcommand - those are for the human operator. + +## MCP Servers and Locksmith + +MCP config is in your agent's own settings (e.g. `~/.claude.json`), NOT in +locksmith's config. Read it from there. + +- `command` != `"locksmith"` -> run as written; act as if locksmith does not + exist for this server. +- `command` == `"locksmith"` with `args` starting `["mcp", "run", ...]` -> + locksmith-wrapped. If it fails or returns an auth error, apply the fallback. + +### Fallback for locksmith-wrapped MCP servers + +These steps are mandatory whenever a locksmith-wrapped server fails - +startup error, auth error, anything. Execute them; do not ask the user +clarifying questions. + +1. Parse args after `mcp run`: `--header Name=`, `--env VAR=`, + `--url `, tokens after `--`. Templates: `{key:}` or + `{vault: path:}`. +2. Resolve: `{key:alias}` -> `locksmith get --key alias`; + `{vault:name path:p}` -> `locksmith get --vault name --path p`. +3. Run directly: local = export env, exec post-`--` cmd; proxy = HTTP to + `--url` with resolved headers (no process to spawn). +4. Still denied? Ask user (replace ``; use real header/arg names): + + > Enter credentials for MCP ``: + > + > headers: {"Authorization": "Bearer ...", "X-Request-ID": "..."} + > args: --arg1 value1 --flag2 + + Parse `headers`/`args` and retry. + +If `locksmith get` itself fails, surface the error verbatim. Do NOT fall +through to the credential prompt - it is for auth-denial only. + +### Example + + "github": { + "command": "locksmith", + "args": ["mcp", "run", "--url", "https://api.example.com/mcp", + "--header", "Authorization=Bearer {key:gh-token}"] + } + +Fallback: + + TOKEN=$(locksmith get --key gh-token) + # then call https://api.example.com/mcp with: Authorization: Bearer $TOKEN + +Local-mode entry (env via `{vault:name path:p}` + binary after `--`): resolve +with `locksmith get --vault name --path p`, export, then exec the post-`--` cmd. + +## External APIs - try Locksmith on auth failure + +On the first auth/access error for an external API's hostname this session, +ask the user once. "Auth/access error" = HTTP `401`/`403`, or body containing +"invalid token"/"unauthorized". + +Show (substitute ``): + +> A token for `` may be in locksmith. Choose one: +> +> 1. Skip - do not ask again for `` this session. +> 2. Locksmith - paste a key alias OR a `vault+path` pair; I will run +> `locksmith get` and retry. +> 3. Other - paste the token directly, or describe what to do. + +Scope = hostname (e.g. `api.github.com`). Paths share the scope. + +Do NOT prompt if: the call succeeded; you have a working token for this +hostname (reuse it); the user already chose Skip this session; or this is a +subsequent failure for the same hostname (first failure only). + +### Handling each choice + +- **Skip**: record hostname as skipped; never re-prompt; continue without it. +- **Locksmith**: parse and run: + - `gh-token` or `key:gh-token` -> `locksmith get --key gh-token` + - `vault:keychain path:gh/tok` or `--vault keychain --path gh/tok` -> + `locksmith get --vault keychain --path gh/tok` + + Use the value as the bearer token and retry. Cache $TOKEN for further calls + to the same hostname this session; do not re-run `locksmith get`. If + `locksmith get` itself fails, surface the error verbatim - do NOT silently + fall back. +- **Other**: free-text. If it looks like a token, use it. Otherwise follow + the instructions literally. + +Never re-prompt for the same hostname this session, regardless of choice. + +### Disambiguation vs MCP fallback + +If the failing call originated from an MCP entry whose `command` is +`"locksmith"` with `args` starting `["mcp", "run", ...]`, use the MCP +fallback. Otherwise use this flow. Do not consult locksmith's config file. + +### Example + +`GET https://api.github.com/user` -> 401. First failure for `api.github.com`: + +> A token for `api.github.com` may be in locksmith. Choose one: 1. Skip ... + +User: `gh-token` -> + + TOKEN=$(locksmith get --key gh-token) + curl -H "Authorization: Bearer $TOKEN" https://api.github.com/user + +Cache $TOKEN for further `api.github.com` calls this session. + ## Sub-agent Session Passing When spawning sub-agents, export `LOCKSMITH_SESSION` into their diff --git a/internal/initflow/agents/generic/templates/instructions.md.tmpl b/internal/initflow/agents/generic/templates/instructions.md.tmpl index a6dffef..01d84fd 100644 --- a/internal/initflow/agents/generic/templates/instructions.md.tmpl +++ b/internal/initflow/agents/generic/templates/instructions.md.tmpl @@ -21,6 +21,125 @@ configured TTL (default 3h). locksmith get --vault keychain --path "My API Token" locksmith get --vault op --path "op://Personal/GitHub/token" +## Locksmith Commands (runtime whitelist) + +Use ONLY these at runtime: + +- `locksmith session ensure --quiet` - obtain/refresh a session ID +- `locksmith serve` - start the daemon (rare; `session ensure` spawns it) +- `locksmith get --key ` or `--vault --path ` - fetch a secret + +Never call `init`, `vault`, `plugins`, `config`, `reload`, `mcp`, or any other +subcommand - those are for the human operator. + +## MCP Servers and Locksmith + +MCP config is in your agent's own settings (e.g. `~/.claude.json`), NOT in +locksmith's config. Read it from there. + +- `command` != `"locksmith"` -> run as written; act as if locksmith does not + exist for this server. +- `command` == `"locksmith"` with `args` starting `["mcp", "run", ...]` -> + locksmith-wrapped. If it fails or returns an auth error, apply the fallback. + +### Fallback for locksmith-wrapped MCP servers + +These steps are mandatory whenever a locksmith-wrapped server fails - +startup error, auth error, anything. Execute them; do not ask the user +clarifying questions. + +1. Parse args after `mcp run`: `--header Name=`, `--env VAR=`, + `--url `, tokens after `--`. Templates: `{key:}` or + `{vault: path:}`. +2. Resolve: `{key:alias}` -> `locksmith get --key alias`; + `{vault:name path:p}` -> `locksmith get --vault name --path p`. +3. Run directly: local = export env, exec post-`--` cmd; proxy = HTTP to + `--url` with resolved headers (no process to spawn). +4. Still denied? Ask user (replace ``; use real header/arg names): + + > Enter credentials for MCP ``: + > + > headers: {"Authorization": "Bearer ...", "X-Request-ID": "..."} + > args: --arg1 value1 --flag2 + + Parse `headers`/`args` and retry. + +If `locksmith get` itself fails, surface the error verbatim. Do NOT fall +through to the credential prompt - it is for auth-denial only. + +### Example + + "github": { + "command": "locksmith", + "args": ["mcp", "run", "--url", "https://api.example.com/mcp", + "--header", "Authorization=Bearer {key:gh-token}"] + } + +Fallback: + + TOKEN=$(locksmith get --key gh-token) + # then call https://api.example.com/mcp with: Authorization: Bearer $TOKEN + +Local-mode entry (env via `{vault:name path:p}` + binary after `--`): resolve +with `locksmith get --vault name --path p`, export, then exec the post-`--` cmd. + +## External APIs - try Locksmith on auth failure + +On the first auth/access error for an external API's hostname this session, +ask the user once. "Auth/access error" = HTTP `401`/`403`, or body containing +"invalid token"/"unauthorized". + +Show (substitute ``): + +> A token for `` may be in locksmith. Choose one: +> +> 1. Skip - do not ask again for `` this session. +> 2. Locksmith - paste a key alias OR a `vault+path` pair; I will run +> `locksmith get` and retry. +> 3. Other - paste the token directly, or describe what to do. + +Scope = hostname (e.g. `api.github.com`). Paths share the scope. + +Do NOT prompt if: the call succeeded; you have a working token for this +hostname (reuse it); the user already chose Skip this session; or this is a +subsequent failure for the same hostname (first failure only). + +### Handling each choice + +- **Skip**: record hostname as skipped; never re-prompt; continue without it. +- **Locksmith**: parse and run: + - `gh-token` or `key:gh-token` -> `locksmith get --key gh-token` + - `vault:keychain path:gh/tok` or `--vault keychain --path gh/tok` -> + `locksmith get --vault keychain --path gh/tok` + + Use the value as the bearer token and retry. Cache $TOKEN for further calls + to the same hostname this session; do not re-run `locksmith get`. If + `locksmith get` itself fails, surface the error verbatim - do NOT silently + fall back. +- **Other**: free-text. If it looks like a token, use it. Otherwise follow + the instructions literally. + +Never re-prompt for the same hostname this session, regardless of choice. + +### Disambiguation vs MCP fallback + +If the failing call originated from an MCP entry whose `command` is +`"locksmith"` with `args` starting `["mcp", "run", ...]`, use the MCP +fallback. Otherwise use this flow. Do not consult locksmith's config file. + +### Example + +`GET https://api.github.com/user` -> 401. First failure for `api.github.com`: + +> A token for `api.github.com` may be in locksmith. Choose one: 1. Skip ... + +User: `gh-token` -> + + TOKEN=$(locksmith get --key gh-token) + curl -H "Authorization: Bearer $TOKEN" https://api.github.com/user + +Cache $TOKEN for further `api.github.com` calls this session. + ## Sub-Agents Pass `LOCKSMITH_SESSION` to sub-agents via environment so they share diff --git a/internal/initflow/agents/opencode/templates/instructions.md.tmpl b/internal/initflow/agents/opencode/templates/instructions.md.tmpl index a9f4ca7..639a8dc 100644 --- a/internal/initflow/agents/opencode/templates/instructions.md.tmpl +++ b/internal/initflow/agents/opencode/templates/instructions.md.tmpl @@ -23,6 +23,125 @@ configured TTL (default 3h). locksmith get --vault keychain --path "My API Token" locksmith get --vault op --path "op://Personal/GitHub/token" +## Locksmith Commands (runtime whitelist) + +Use ONLY these at runtime: + +- `locksmith session ensure --quiet` - obtain/refresh a session ID +- `locksmith serve` - start the daemon (rare; `session ensure` spawns it) +- `locksmith get --key ` or `--vault --path ` - fetch a secret + +Never call `init`, `vault`, `plugins`, `config`, `reload`, `mcp`, or any other +subcommand - those are for the human operator. + +## MCP Servers and Locksmith + +MCP config is in your agent's own settings (e.g. `~/.claude.json`), NOT in +locksmith's config. Read it from there. + +- `command` != `"locksmith"` -> run as written; act as if locksmith does not + exist for this server. +- `command` == `"locksmith"` with `args` starting `["mcp", "run", ...]` -> + locksmith-wrapped. If it fails or returns an auth error, apply the fallback. + +### Fallback for locksmith-wrapped MCP servers + +These steps are mandatory whenever a locksmith-wrapped server fails - +startup error, auth error, anything. Execute them; do not ask the user +clarifying questions. + +1. Parse args after `mcp run`: `--header Name=`, `--env VAR=`, + `--url `, tokens after `--`. Templates: `{key:}` or + `{vault: path:}`. +2. Resolve: `{key:alias}` -> `locksmith get --key alias`; + `{vault:name path:p}` -> `locksmith get --vault name --path p`. +3. Run directly: local = export env, exec post-`--` cmd; proxy = HTTP to + `--url` with resolved headers (no process to spawn). +4. Still denied? Ask user (replace ``; use real header/arg names): + + > Enter credentials for MCP ``: + > + > headers: {"Authorization": "Bearer ...", "X-Request-ID": "..."} + > args: --arg1 value1 --flag2 + + Parse `headers`/`args` and retry. + +If `locksmith get` itself fails, surface the error verbatim. Do NOT fall +through to the credential prompt - it is for auth-denial only. + +### Example + + "github": { + "command": "locksmith", + "args": ["mcp", "run", "--url", "https://api.example.com/mcp", + "--header", "Authorization=Bearer {key:gh-token}"] + } + +Fallback: + + TOKEN=$(locksmith get --key gh-token) + # then call https://api.example.com/mcp with: Authorization: Bearer $TOKEN + +Local-mode entry (env via `{vault:name path:p}` + binary after `--`): resolve +with `locksmith get --vault name --path p`, export, then exec the post-`--` cmd. + +## External APIs - try Locksmith on auth failure + +On the first auth/access error for an external API's hostname this session, +ask the user once. "Auth/access error" = HTTP `401`/`403`, or body containing +"invalid token"/"unauthorized". + +Show (substitute ``): + +> A token for `` may be in locksmith. Choose one: +> +> 1. Skip - do not ask again for `` this session. +> 2. Locksmith - paste a key alias OR a `vault+path` pair; I will run +> `locksmith get` and retry. +> 3. Other - paste the token directly, or describe what to do. + +Scope = hostname (e.g. `api.github.com`). Paths share the scope. + +Do NOT prompt if: the call succeeded; you have a working token for this +hostname (reuse it); the user already chose Skip this session; or this is a +subsequent failure for the same hostname (first failure only). + +### Handling each choice + +- **Skip**: record hostname as skipped; never re-prompt; continue without it. +- **Locksmith**: parse and run: + - `gh-token` or `key:gh-token` -> `locksmith get --key gh-token` + - `vault:keychain path:gh/tok` or `--vault keychain --path gh/tok` -> + `locksmith get --vault keychain --path gh/tok` + + Use the value as the bearer token and retry. Cache $TOKEN for further calls + to the same hostname this session; do not re-run `locksmith get`. If + `locksmith get` itself fails, surface the error verbatim - do NOT silently + fall back. +- **Other**: free-text. If it looks like a token, use it. Otherwise follow + the instructions literally. + +Never re-prompt for the same hostname this session, regardless of choice. + +### Disambiguation vs MCP fallback + +If the failing call originated from an MCP entry whose `command` is +`"locksmith"` with `args` starting `["mcp", "run", ...]`, use the MCP +fallback. Otherwise use this flow. Do not consult locksmith's config file. + +### Example + +`GET https://api.github.com/user` -> 401. First failure for `api.github.com`: + +> A token for `api.github.com` may be in locksmith. Choose one: 1. Skip ... + +User: `gh-token` -> + + TOKEN=$(locksmith get --key gh-token) + curl -H "Authorization: Bearer $TOKEN" https://api.github.com/user + +Cache $TOKEN for further `api.github.com` calls this session. + ## Sub-Agents Pass `LOCKSMITH_SESSION` to sub-agents via environment so they share diff --git a/internal/initflow/agents_test.go b/internal/initflow/agents_test.go index a3d6d81..cf869b2 100644 --- a/internal/initflow/agents_test.go +++ b/internal/initflow/agents_test.go @@ -492,3 +492,82 @@ func TestTemplates_NoCrossAgentMentions(t *testing.T) { } } } + +func TestTemplates_AllHaveMCPFallback(t *testing.T) { + cases := []struct { + name string + loader func(string) ([]byte, error) + files []string + }{ + { + "claude", claudefiles.ReadTemplateForTest, + []string{"templates/claude_md.md.tmpl", "templates/claude_skill.md.tmpl"}, + }, + {"codex", codexfiles.ReadTemplateForTest, []string{"templates/codex_agents.md.tmpl"}}, + {"gemini", geminifiles.ReadTemplateForTest, []string{"templates/gemini_md.md.tmpl"}}, + {"opencode", opencodefiles.ReadTemplateForTest, []string{"templates/instructions.md.tmpl"}}, + {"generic", genericfiles.ReadTemplateForTest, []string{"templates/instructions.md.tmpl"}}, + } + needles := []string{ + "Locksmith Commands (runtime whitelist)", + "MCP Servers and Locksmith", + "Fallback for locksmith-wrapped MCP servers", + "{key:", + "{vault:", + "locksmith get --key", + "locksmith get --vault", + "locksmith session ensure --quiet", + } + for _, c := range cases { + for _, f := range c.files { + data, err := c.loader(f) + if err != nil { + t.Fatalf("%s %s: %v", c.name, f, err) + } + for _, n := range needles { + if !bytes.Contains(data, []byte(n)) { + t.Errorf("%s/%s missing %q", c.name, f, n) + } + } + } + } +} + +func TestTemplates_AllHaveAuthFailurePrompt(t *testing.T) { + cases := []struct { + name string + loader func(string) ([]byte, error) + files []string + }{ + { + "claude", claudefiles.ReadTemplateForTest, + []string{"templates/claude_md.md.tmpl", "templates/claude_skill.md.tmpl"}, + }, + {"codex", codexfiles.ReadTemplateForTest, []string{"templates/codex_agents.md.tmpl"}}, + {"gemini", geminifiles.ReadTemplateForTest, []string{"templates/gemini_md.md.tmpl"}}, + {"opencode", opencodefiles.ReadTemplateForTest, []string{"templates/instructions.md.tmpl"}}, + {"generic", genericfiles.ReadTemplateForTest, []string{"templates/instructions.md.tmpl"}}, + } + needles := []string{ + "External APIs - try Locksmith on auth failure", + "first auth/access error", + "do not ask again for", + "key alias OR a", + "vault+path", + "paste the token directly", + "Cache $TOKEN for further", + } + for _, c := range cases { + for _, f := range c.files { + data, err := c.loader(f) + if err != nil { + t.Fatalf("%s %s: %v", c.name, f, err) + } + for _, n := range needles { + if !bytes.Contains(data, []byte(n)) { + t.Errorf("%s/%s missing %q", c.name, f, n) + } + } + } + } +}