From 220da8e48700cbe3b3d0b2c9fc65aff9a39edb57 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 22 Apr 2026 21:41:00 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20/gsd-settings-integrations=20=E2=80=94?= =?UTF-8?q?=20configure=20third-party=20search=20and=20review=20integratio?= =?UTF-8?q?ns=20(closes=20#2529)=20(#2604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#2529): /gsd-settings-integrations — third-party integrations command Adds /gsd-settings-integrations for configuring API keys, code-review CLI routing, and agent-skill injection. Distinct from /gsd-settings (workflow toggles) because these are connectivity, not pipeline shape. Three sections: - Search Integrations: brave_search / firecrawl / exa_search API keys, plus search_gitignored toggle. - Code Review CLI Routing: review.models.{claude,codex,gemini,opencode} shell-command strings. - Agent Skills Injection: agent_skills. free-text input, validated against [a-zA-Z0-9_-]+. Security: - New secrets.cjs module with **** masking convention. - cmdConfigSet now masks value/previousValue in CLI output for secret keys. - Plaintext is written only to .planning/config.json; never echoed to stdout/stderr, never written to audit/log files by this flow. - Slug validators reject path separators, whitespace, shell metacharacters. Tests (tests/settings-integrations.test.cjs — 25 cases): - Artifact presence / frontmatter. - Field round-trips via gsd-tools config-set for all four search keys, review.models., agent_skills.. - Config-merge safety: unrelated keys preserved across writes. - Masking: config-set output never contains plaintext sentinel. - Logging containment: plaintext secret sentinel appears only in config.json under .planning/, nowhere else on disk. - Negative: path-traversal, shell-metachar, and empty-slug rejected. - /gsd:settings workflow mentions /gsd:settings-integrations. Docs: - docs/COMMANDS.md: new command entry with security note. - docs/CONFIGURATION.md: integration settings section (keys, routing, skills injection) with masking documentation. - docs/CLI-TOOLS.md: reviewer CLI routing and secret-handling sections. - docs/INVENTORY.md + INVENTORY-MANIFEST.json regenerated. Closes #2529 * fix(#2529): mask secrets in config-get; address CodeRabbit review cmdConfigGet was emitting plaintext for brave_search/firecrawl/exa_search. Apply the same isSecretKey/maskSecret treatment used by config-set so the CLI surface never echoes raw API keys; plaintext still lives only in config.json on disk. Also addresses CodeRabbit review items in the same PR area: - #3127146188: config-get plaintext leak (root fix above) - #3127146211: rename test sentinels to concat-built markers so secret scanners stop flagging the test file. Behavior preserved. - #3127146207: add explicit 'text' language to fenced code blocks (MD040). - nitpick: unify masked-value wording in read_current legend ('****' instead of '**** already set'). - nitpick: extend round-trip test to cover search_gitignored toggle. New regression test 'config-get masks secrets and never echoes plaintext' verifies the fix for all three secret keys. * docs(#2529): bump INVENTORY counts post-rebase (commands 84→85, workflows 82→83) * fix(test): bump CLI Modules count 27→28 after rebase onto main (CI #24811455435) PR #2604 was rebased onto main before #2605 (drift.cjs) merged. The pull_request CI runs against the merge ref (refs/pull/2604/merge), which now contains 28 .cjs files in get-shit-done/bin/lib/, but docs/INVENTORY.md headline still said "(27 shipped)". inventory-counts.test.cjs failed with: AssertionError: docs/INVENTORY.md "CLI Modules (27 shipped)" disagrees with get-shit-done/bin/lib/ file count (28) Rebased branch onto current origin/main (picks up drift.cjs row, which was already added by #2605) and bumped the headline to 28. Full suite: 5200/5200 pass. --- commands/gsd/settings-integrations.md | 44 ++ docs/CLI-TOOLS.md | 19 + docs/COMMANDS.md | 29 ++ docs/CONFIGURATION.md | 35 ++ docs/INVENTORY-MANIFEST.json | 3 + docs/INVENTORY.md | 9 +- get-shit-done/bin/lib/config.cjs | 28 ++ get-shit-done/bin/lib/secrets.cjs | 33 ++ .../workflows/settings-integrations.md | 281 +++++++++++++ get-shit-done/workflows/settings.md | 1 + tests/settings-integrations.test.cjs | 387 ++++++++++++++++++ 11 files changed, 866 insertions(+), 3 deletions(-) create mode 100644 commands/gsd/settings-integrations.md create mode 100644 get-shit-done/bin/lib/secrets.cjs create mode 100644 get-shit-done/workflows/settings-integrations.md create mode 100644 tests/settings-integrations.test.cjs diff --git a/commands/gsd/settings-integrations.md b/commands/gsd/settings-integrations.md new file mode 100644 index 00000000..a3711ef2 --- /dev/null +++ b/commands/gsd/settings-integrations.md @@ -0,0 +1,44 @@ +--- +name: gsd:settings-integrations +description: Configure third-party API keys, code-review CLI routing, and agent-skill injection +allowed-tools: + - Read + - Write + - Bash + - AskUserQuestion +--- + + +Interactive configuration of GSD's third-party integration surface: +- Search API keys: `brave_search`, `firecrawl`, `exa_search`, and + the `search_gitignored` toggle +- Code-review CLI routing: `review.models.{claude,codex,gemini,opencode}` +- Agent-skill injection: `agent_skills.` + +API keys are stored plaintext in `.planning/config.json` but are masked +(`****`) in every piece of interactive output. The workflow never +echoes plaintext to stdout, stderr, or any log. + +This command is deliberately distinct from `/gsd:settings` (workflow toggles) +and any `/gsd:settings-advanced` tuning surface. It handles *connectivity*, +not pipeline shape. + + + +@~/.claude/get-shit-done/workflows/settings-integrations.md + + + +**Follow the settings-integrations workflow** from +`@~/.claude/get-shit-done/workflows/settings-integrations.md`. + +The workflow handles: +1. Resolving `$GSD_CONFIG_PATH` (flat vs workstream) +2. Reading current integration values (masked for display) +3. Section 1 — Search Integrations: Brave / Firecrawl / Exa / search_gitignored +4. Section 2 — Review CLI Routing: review.models.{claude,codex,gemini,opencode} +5. Section 3 — Agent Skills Injection: agent_skills. +6. Writing values via `gsd-sdk query config-set` (which merges, preserving + unrelated keys) +7. Masked confirmation display + diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index d660a782..3bba936f 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -475,6 +475,25 @@ User-facing entry point: `/gsd-graphify` (see [Command Reference](COMMANDS.md#gs --- +## Reviewer CLI Routing + +`review.models.` maps a reviewer flavor to a shell command invoked by the code-review workflow. Set via [`/gsd-settings-integrations`](COMMANDS.md#gsd-settings-integrations) or directly: + +```bash +gsd-sdk query config-set review.models.codex "codex exec --model gpt-5" +gsd-sdk query config-set review.models.gemini "gemini -m gemini-2.5-pro" +gsd-sdk query config-set review.models.opencode "opencode run --model claude-sonnet-4" +gsd-sdk query config-set review.models.claude "" # clear — fall back to session model +``` + +Slugs are validated against `[a-zA-Z0-9_-]+`; empty or path-containing slugs are rejected. See [`docs/CONFIGURATION.md`](CONFIGURATION.md#code-review-cli-routing) for the full field reference. + +## Secret Handling + +API keys configured via `/gsd-settings-integrations` (`brave_search`, `firecrawl`, `exa_search`) are written plaintext to `.planning/config.json` but are masked (`****`) in every `config-set` / `config-get` output, confirmation table, and interactive prompt. See `get-shit-done/bin/lib/secrets.cjs` for the masking implementation. The `config.json` file itself is the security boundary — protect it with filesystem permissions and keep it out of git (`.planning/` is gitignored by default). + +--- + ## See also - [sdk/src/query/QUERY-HANDLERS.md](../sdk/src/query/QUERY-HANDLERS.md) — registry matrix, routing, golden parity, intentional CJS differences diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index bf49a70f..ae8de556 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1075,6 +1075,35 @@ Current values are pre-selected; an empty input keeps the existing value. Numeri See [CONFIGURATION.md](CONFIGURATION.md) for the full schema and defaults. +### `/gsd-settings-integrations` + +Interactive configuration of third-party integrations and cross-tool routing. +Distinct from `/gsd-settings` (workflow toggles) — this command handles +connectivity: API keys, reviewer CLI routing, and agent-skill injection. + +Covers: + +- **Search integrations:** `brave_search`, `firecrawl`, `exa_search` API keys, + and the `search_gitignored` toggle. +- **Code-review CLI routing:** `review.models.{claude,codex,gemini,opencode}` + — a shell command per reviewer flavor. +- **Agent-skill injection:** `agent_skills.` — skill names + injected into an agent's spawn frontmatter. Agent-type slugs are validated + against `[a-zA-Z0-9_-]+` so path separators and shell metacharacters are + rejected. + +API keys are stored plaintext in `.planning/config.json` but displayed masked +(`****`) in every interactive output, confirmation table, and +`config-set` stdout/stderr line. Plaintext is never echoed, never logged, +and never written to any file outside `config.json` by this workflow. + +```bash +/gsd-settings-integrations # Interactive config (three sections) +``` + +See [`docs/CONFIGURATION.md`](CONFIGURATION.md) for the per-field reference and +[`docs/CLI-TOOLS.md`](CLI-TOOLS.md) for the reviewer-CLI routing contract. + ### `/gsd-set-profile` Quick profile switch. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 56c65e5c..99badf39 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -129,6 +129,41 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new --- +## Integration Settings + +Configured interactively via [`/gsd-settings-integrations`](COMMANDS.md#gsd-settings-integrations). These are *connectivity* settings — API keys and cross-tool routing — and are intentionally kept separate from `/gsd-settings` (workflow toggles). + +### Search API keys + +API key fields accept a string value (the key itself). They can also be set to the sentinels `true`/`false`/`null` to override auto-detection from env vars / `~/.gsd/*_api_key` files (legacy behavior, see rows above). + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `brave_search` | string \| boolean \| null | `null` | Brave Search API key used for web research. Displayed as `****` in all UI / `config-set` output; never echoed plaintext | +| `firecrawl` | string \| boolean \| null | `null` | Firecrawl API key for deep-crawl scraping. Masked in display | +| `exa_search` | string \| boolean \| null | `null` | Exa Search API key for semantic search. Masked in display | + +**Masking convention (`get-shit-done/bin/lib/secrets.cjs`):** keys 8+ characters render as `****`; shorter keys render as `****`; `null`/empty renders as `(unset)`. Plaintext is written as-is to `.planning/config.json` — that file is the security boundary — but the CLI, confirmation tables, logs, and `AskUserQuestion` descriptions never display the plaintext. This applies to the `config-set` command output itself: `config-set brave_search ` returns a JSON payload with the value masked. + +### Code-review CLI routing + +`review.models.` maps a reviewer flavor to a shell command. The code-review workflow shells out using this command when a matching flavor is requested. + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `review.models.claude` | string | (session model) | Command for Claude-flavored review. Defaults to the session model when unset | +| `review.models.codex` | string | `null` | Command for Codex review, e.g. `"codex exec --model gpt-5"` | +| `review.models.gemini` | string | `null` | Command for Gemini review, e.g. `"gemini -m gemini-2.5-pro"` | +| `review.models.opencode` | string | `null` | Command for OpenCode review, e.g. `"opencode run --model claude-sonnet-4"` | + +The `` slug is validated against `[a-zA-Z0-9_-]+`. Empty or path-containing slugs are rejected by `config-set`. + +### Agent-skill injection (dynamic) + +`agent_skills.` extends the `agent_skills` map documented below. Slug is validated against `[a-zA-Z0-9_-]+` — no path separators, no whitespace, no shell metacharacters. Configured interactively via `/gsd-settings-integrations`. + +--- + ## Workflow Toggles All workflow toggles follow the **absent = enabled** pattern. If a key is missing from config, it defaults to `true`. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index ee91d5e5..43f5c282 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -104,6 +104,7 @@ "/gsd-set-profile", "/gsd-settings", "/gsd-settings-advanced", + "/gsd-settings-integrations", "/gsd-ship", "/gsd-sketch", "/gsd-sketch-wrap-up", @@ -187,6 +188,7 @@ "secure-phase.md", "session-report.md", "settings-advanced.md", + "settings-integrations.md", "settings.md", "ship.md", "sketch-wrap-up.md", @@ -280,6 +282,7 @@ "profile-pipeline.cjs", "roadmap.cjs", "schema-detect.cjs", + "secrets.cjs", "security.cjs", "state.cjs", "template.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 930c157d..c835f9f3 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -54,7 +54,7 @@ Full roster at `agents/gsd-*.md`. The "Primary doc" column flags whether [`docs/ --- -## Commands (84 shipped) +## Commands (85 shipped) Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md` section order; each row carries the command name, a one-line role derived from the command's frontmatter `description:`, and a link to the source file. `tests/command-count-sync.test.cjs` locks the count against the filesystem. @@ -164,6 +164,7 @@ Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md | `/gsd-profile-user` | Generate developer behavioral profile and Claude-discoverable artifacts. | [commands/gsd/profile-user.md](../commands/gsd/profile-user.md) | | `/gsd-settings` | Configure GSD workflow toggles and model profile. | [commands/gsd/settings.md](../commands/gsd/settings.md) | | `/gsd-settings-advanced` | Power-user configuration — plan bounce, timeouts, branch templates, cross-AI execution, runtime knobs. | [commands/gsd/settings-advanced.md](../commands/gsd/settings-advanced.md) | +| `/gsd-settings-integrations` | Configure third-party API keys, code-review CLI routing, and agent-skill injection. | [commands/gsd/settings-integrations.md](../commands/gsd/settings-integrations.md) | | `/gsd-set-profile` | Switch model profile for GSD agents (quality/balanced/budget/inherit). | [commands/gsd/set-profile.md](../commands/gsd/set-profile.md) | | `/gsd-pr-branch` | Create a clean PR branch by filtering out `.planning/` commits. | [commands/gsd/pr-branch.md](../commands/gsd/pr-branch.md) | | `/gsd-sync-skills` | Sync managed GSD skill directories across runtime roots for multi-runtime users. | [commands/gsd/sync-skills.md](../commands/gsd/sync-skills.md) | @@ -174,7 +175,7 @@ Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md --- -## Workflows (82 shipped) +## Workflows (83 shipped) Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators that commands reference internally; most are not read directly by end users. Rows below map each workflow file to its role (derived from the `` block) and, where applicable, to the command that invokes it. @@ -245,6 +246,7 @@ Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators | `session-report.md` | Session report — token usage, work summary, outcomes. | `/gsd-session-report` | | `settings.md` | Configure GSD workflow toggles and model profile. | `/gsd-settings`, `/gsd-set-profile` | | `settings-advanced.md` | Configure GSD power-user knobs — plan bounce, timeouts, branch templates, cross-AI execution, runtime knobs. | `/gsd-settings-advanced` | +| `settings-integrations.md` | Configure third-party API keys (Brave/Firecrawl/Exa), `review.models.` CLI routing, and `agent_skills.` injection with masked (`****`) display. | `/gsd-settings-integrations` | | `ship.md` | Create PR, run review, and prepare for merge after verification. | `/gsd-ship` | | `sketch.md` | Explore design directions through throwaway HTML mockups with 2-3 variants per sketch. | `/gsd-sketch` | | `sketch-wrap-up.md` | Curate sketch findings and package them as a persistent `sketch-findings-[project]` skill. | `/gsd-sketch-wrap-up` | @@ -356,7 +358,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (27 shipped) +## CLI Modules (28 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -383,6 +385,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | | `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, etc.) | +| `secrets.cjs` | Secret-config masking convention (`****`) for integration keys managed by `/gsd-settings-integrations` — keeps plaintext out of `config-set` output | | `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON/shell helpers | | `state.cjs` | STATE.md parsing, updating, progression, metrics | | `template.cjs` | Template selection and filling with variable substitution | diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 9c82987a..20a990c7 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -11,6 +11,7 @@ const { formatAgentToModelMapAsTable, } = require('./model-profiles.cjs'); const { VALID_CONFIG_KEYS, isValidConfigKey } = require('./config-schema.cjs'); +const { isSecretKey, maskSecret } = require('./secrets.cjs'); const CONFIG_KEY_SUGGESTIONS = { 'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation', @@ -345,6 +346,25 @@ function cmdConfigSet(cwd, keyPath, value, raw) { } const setConfigValueResult = setConfigValue(cwd, keyPath, parsedValue); + + // Mask secrets in both JSON and text output. The plaintext is written + // to config.json (that's where secrets live on disk); the CLI output + // must never echo it. See lib/secrets.cjs. + if (isSecretKey(keyPath)) { + const masked = maskSecret(parsedValue); + const maskedPrev = setConfigValueResult.previousValue === undefined + ? undefined + : maskSecret(setConfigValueResult.previousValue); + const maskedResult = { + ...setConfigValueResult, + value: masked, + previousValue: maskedPrev, + masked: true, + }; + output(maskedResult, raw, `${keyPath}=${masked}`); + return; + } + output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`); } @@ -387,6 +407,14 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) { error(`Key not found: ${keyPath}`); } + // Never echo plaintext for sensitive keys via config-get. Plaintext lives + // in config.json on disk; the CLI surface always shows the masked form. + if (isSecretKey(keyPath)) { + const masked = maskSecret(current); + output(masked, raw, masked); + return; + } + output(current, raw, String(current)); } diff --git a/get-shit-done/bin/lib/secrets.cjs b/get-shit-done/bin/lib/secrets.cjs new file mode 100644 index 00000000..e8a62981 --- /dev/null +++ b/get-shit-done/bin/lib/secrets.cjs @@ -0,0 +1,33 @@ +'use strict'; + +/** + * Secrets handling — masking convention for API keys and other + * credentials managed via /gsd:settings-integrations. + * + * Convention: strings 8+ chars long render as `****`; shorter + * strings render as `****` with no tail (to avoid leaking a meaningful + * fraction of a short secret). null/empty renders as `(unset)`. + * + * Keys considered sensitive are listed in SECRET_CONFIG_KEYS and matched + * at the exact key-path level. The list is intentionally narrow — these + * are the fields documented as secrets in docs/CONFIGURATION.md. + */ + +const SECRET_CONFIG_KEYS = new Set([ + 'brave_search', + 'firecrawl', + 'exa_search', +]); + +function isSecretKey(keyPath) { + return SECRET_CONFIG_KEYS.has(keyPath); +} + +function maskSecret(value) { + if (value === null || value === undefined || value === '') return '(unset)'; + const s = String(value); + if (s.length < 8) return '****'; + return '****' + s.slice(-4); +} + +module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret }; diff --git a/get-shit-done/workflows/settings-integrations.md b/get-shit-done/workflows/settings-integrations.md new file mode 100644 index 00000000..05d876cf --- /dev/null +++ b/get-shit-done/workflows/settings-integrations.md @@ -0,0 +1,281 @@ + +Interactive configuration of third-party integrations for GSD — search API keys +(Brave / Firecrawl / Exa), code-review CLI routing (`review.models.`), and +agent-skill injection (`agent_skills.`). Writes to +`.planning/config.json` via `gsd-sdk`/`gsd-tools` so unrelated keys are +preserved, never clobbered. + +This command is deliberately separate from `/gsd:settings` (workflow toggles) +and any `/gsd:settings-advanced` tuning surface. It exists because API keys and +cross-tool routing are *connectivity* concerns, not workflow or tuning knobs. + + + +**API keys are secrets.** They are written as plaintext to +`.planning/config.json` — that is where secrets live on disk, and file +permissions are the security boundary. The UI must never display, echo, or +log the plaintext value. The workflow follows these rules: + +- **Masking convention: `****`** (e.g. `sk-abc123def456` → `****f456`). + Strings shorter than 8 characters render as `****` with no tail so a short + secret does not leak a meaningful fraction of its bytes. Unset values render + as `(unset)`. +- **Plaintext is never echoed by AskUserQuestion descriptions, confirmation + tables, or any log line.** It is not written to any file under `.planning/` + other than `config.json` itself. +- **`config-set` output is masked** for keys in the secret set + (`brave_search`, `firecrawl`, `exa_search`) — see + `get-shit-done/bin/lib/secrets.cjs`. +- **Agent-type and CLI slug validation.** `agent_skills.` and + `review.models.` keys are matched against `^[a-zA-Z0-9_-]+$`. Inputs + containing path separators (`/`, `\`, `..`), whitespace, or shell + metacharacters are rejected. This closes off skill-injection attacks. + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + + +Ensure config exists and resolve the active config path (flat vs workstream, #2282): + +```bash +gsd-sdk query config-ensure-section +if [[ -z "${GSD_CONFIG_PATH:-}" ]]; then + if [[ -f .planning/active-workstream ]]; then + WS=$(tr -d '\n\r' < .planning/active-workstream) + GSD_CONFIG_PATH=".planning/workstreams/${WS}/config.json" + else + GSD_CONFIG_PATH=".planning/config.json" + fi +fi +``` + +Store `$GSD_CONFIG_PATH`. Every subsequent read/write uses it. + + + +Read the current config and compute a masked view for display. For each +integration field, compute one of: + +- `(unset)` — field is null / missing +- `****` — secret field that is populated (plaintext never shown) +- `` — non-secret routing/skill string, shown as-is + +```bash +BRAVE=$(gsd-sdk query config-get brave_search --default null) +FIRECRAWL=$(gsd-sdk query config-get firecrawl --default null) +EXA=$(gsd-sdk query config-get exa_search --default null) +SEARCH_GITIGNORED=$(gsd-sdk query config-get search_gitignored --default false) +``` + +For each secret key (`brave_search`, `firecrawl`, `exa_search`) the displayed +value is `****` when set, never the raw string. Never echo the +plaintext to stdout, stderr, or any log. + + + + +**Text mode (`workflow.text_mode: true` or `--text` flag):** Set +`TEXT_MODE=true` and replace every `AskUserQuestion` call with a plain-text +numbered list. Required for non-Claude runtimes. + +Ask the user what they want to do for each search API key. For keys that are +already set, show `**** already set` and offer Leave / Replace / Clear. For +unset keys, offer Skip / Set. + +```text +AskUserQuestion([ + { + question: "Brave Search API key — used for web research during plan/discuss phases", + header: "Brave", + multiSelect: false, + options: [ + // When already set: + { label: "Leave (**** already set)", description: "Keep current value" }, + { label: "Replace", description: "Enter a new API key" }, + { label: "Clear", description: "Remove the stored key" } + // When unset: + // { label: "Skip", description: "Leave unset" }, + // { label: "Set", description: "Enter an API key" } + ] + }, + { + question: "Firecrawl API key — used for deep-crawl scraping", + header: "Firecrawl", + multiSelect: false, + options: [ /* same Leave/Replace/Clear or Skip/Set */ ] + }, + { + question: "Exa Search API key — used for semantic search", + header: "Exa", + multiSelect: false, + options: [ /* same Leave/Replace/Clear or Skip/Set */ ] + }, + { + question: "Include gitignored files in local code searches?", + header: "Gitignored", + multiSelect: false, + options: [ + { label: "No (Recommended)", description: "Respect .gitignore. Safer — excludes secrets, node_modules, build artifacts." }, + { label: "Yes", description: "Include gitignored files. Useful when secrets/artifacts genuinely contain searchable intent." } + ] + } +]) +``` + +For each "Set" or "Replace", follow with a text-input prompt that asks for the +key value. **The answer must not be echoed back** in subsequent question +descriptions or confirmation text. Write the value via: + +```bash +gsd-sdk query config-set brave_search "" # masked in output +gsd-sdk query config-set firecrawl "" # masked in output +gsd-sdk query config-set exa_search "" # masked in output +gsd-sdk query config-set search_gitignored true|false +``` + +For "Clear", write `null`: + +```bash +gsd-sdk query config-set brave_search null +``` + + + + +`review.models.` is a map that tells the code-review workflow which +shell command to invoke for a given reviewer flavor. Supported flavors: +`claude`, `codex`, `gemini`, `opencode`. + +```text +AskUserQuestion([ + { + question: "Which reviewer CLI do you want to configure?", + header: "CLI", + multiSelect: false, + options: [ + { label: "Claude", description: "review.models.claude — defaults to session model when unset" }, + { label: "Codex", description: "review.models.codex — e.g. 'codex exec --model gpt-5'" }, + { label: "Gemini", description: "review.models.gemini — e.g. 'gemini -m gemini-2.5-pro'" }, + { label: "OpenCode", description: "review.models.opencode — e.g. 'opencode run --model claude-sonnet-4'" }, + { label: "Done", description: "Skip — finish this section" } + ] + } +]) +``` + +For the selected CLI, show the current value (or `(unset)`) and offer +Leave / Replace / Clear, followed by a text-input prompt for the new command +string. Write via: + +```bash +gsd-sdk query config-set review.models. "" +``` + +Loop until the user selects "Done". + +The `review.models.` key is validated by the dynamic pattern +`^review\.models\.[a-zA-Z0-9_-]+$`. Empty CLI slugs and path-containing slugs +are rejected by `config-set` before any write. + + + + +`agent_skills.` injects extra skill names into an agent's spawn +frontmatter. The slug is user-extensible, so input is free-text validated +against `^[a-zA-Z0-9_-]+$`. Inputs with path separators, spaces, or shell +metacharacters are rejected. + +```text +AskUserQuestion([ + { + question: "Configure agent_skills for which agent type?", + header: "Agent Type", + multiSelect: false, + options: [ + { label: "gsd-executor", description: "Skills injected when spawning executor agents" }, + { label: "gsd-planner", description: "Skills injected when spawning planner agents" }, + { label: "gsd-verifier", description: "Skills injected when spawning verifier agents" }, + { label: "Custom…", description: "Enter a custom agent-type slug" }, + { label: "Done", description: "Skip — finish this section" } + ] + } +]) +``` + +For "Custom…", prompt for a slug and validate it matches +`^[a-zA-Z0-9_-]+$`. If it fails validation, print: + +```text +Rejected: agent-type '' must match [a-zA-Z0-9_-]+ (no path separators, +spaces, or shell metacharacters). +``` + +and re-prompt. + +For a selected slug, prompt for the comma-separated skill list (text input). +Show the current value if any, offer Leave / Replace / Clear. Write via: + +```bash +gsd-sdk query config-set agent_skills. "" +``` + +Loop until "Done". + + + +Display the masked confirmation table. **No plaintext API keys appear in this +output under any circumstance.** + +```text +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► INTEGRATIONS UPDATED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Search Integrations +| Field | Value | +|--------------------|-------------------| +| brave_search | **** | (or "(unset)") +| firecrawl | **** | +| exa_search | **** | +| search_gitignored | true | false | + +Code Review CLI Routing +| CLI | Command | +|-------------|--------------------------------------| +| claude | | +| codex | | +| gemini | | +| opencode | | + +Agent Skills Injection +| Agent Type | Skills | +|------------------|---------------------------| +| | | +| ... | ... | + +Notes: +- API keys are stored plaintext in .planning/config.json. The confirmation + table above never displays plaintext — keys appear as ****. +- Plaintext is not echoed back by this workflow, not written to any log, + and not displayed in error messages. + +Quick commands: +- /gsd:settings — workflow toggles and model profile +- /gsd:set-profile — switch model profile +``` + + + + + +- [ ] Current config read from `$GSD_CONFIG_PATH` +- [ ] User presented with three sections: Search Integrations, Review CLI Routing, Agent Skills Injection +- [ ] API keys written plaintext only to `config.json`; never echoed, never logged, never displayed +- [ ] Masked confirmation table uses `****` for set keys and `(unset)` for null +- [ ] `review.models.` and `agent_skills.` keys validated against `[a-zA-Z0-9_-]+` before write +- [ ] Config merge preserves all keys outside the three sections this workflow owns + diff --git a/get-shit-done/workflows/settings.md b/get-shit-done/workflows/settings.md index 4866ea4b..1d28dfe4 100644 --- a/get-shit-done/workflows/settings.md +++ b/get-shit-done/workflows/settings.md @@ -451,6 +451,7 @@ Display: These settings apply to future /gsd:plan-phase and /gsd:execute-phase runs. Quick commands: +- /gsd:settings-integrations — configure API keys (Brave/Firecrawl/Exa), review.models CLI routing, and agent_skills injection - /gsd:set-profile — switch model profile - /gsd:plan-phase --research — force research - /gsd:plan-phase --skip-research — skip research diff --git a/tests/settings-integrations.test.cjs b/tests/settings-integrations.test.cjs new file mode 100644 index 00000000..7dedd888 --- /dev/null +++ b/tests/settings-integrations.test.cjs @@ -0,0 +1,387 @@ +'use strict'; + +/** + * #2529 — /gsd-settings-integrations: configure third-party search and review integrations. + * + * Covers: + * - Artifacts exist (command, workflow, skill stub) with correct frontmatter + * - Workflow references the four search API key fields + * - Workflow exposes review.models.{claude,codex,gemini,opencode} routing + * - Workflow exposes agent_skills. injection input + * - Masking convention (****last4) is documented in the workflow and the displayed + * confirmation pattern does not echo plaintext + * - config-set round-trips all integration keys through VALID_CONFIG_KEYS + dynamic patterns + * - Config merge preserves unrelated keys + * - /gsd:settings confirmation output mentions /gsd:settings-integrations + * - Negative: invalid agent-type name (path traversal / special char) is rejected + * - Negative: malformed review.models key is rejected + * - Logging: plaintext API keys do not appear in any file written under .planning/ + * by the config-set flow other than config.json itself + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); +const { + VALID_CONFIG_KEYS, + isValidConfigKey, +} = require('../get-shit-done/bin/lib/config-schema.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const COMMAND_PATH = path.join(REPO_ROOT, 'commands', 'gsd', 'settings-integrations.md'); +const WORKFLOW_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'settings-integrations.md'); +const SKILL_PATH = path.join(REPO_ROOT, '.claude', 'skills', 'gsd-settings-integrations.md'); +const SETTINGS_WORKFLOW_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'settings.md'); + +function readIfExists(p) { + try { return fs.readFileSync(p, 'utf-8'); } catch { return null; } +} + +// ─── Artifacts ─────────────────────────────────────────────────────────────── + +describe('#2529 artifacts', () => { + test('command exists at commands/gsd/settings-integrations.md', () => { + assert.ok(fs.existsSync(COMMAND_PATH), `missing ${COMMAND_PATH}`); + }); + + test('command frontmatter declares name gsd:settings-integrations', () => { + const src = fs.readFileSync(COMMAND_PATH, 'utf-8'); + assert.match(src, /^---\s*\nname:\s*gsd:settings-integrations\s*\n/m); + assert.match(src, /description:\s*.+/); + assert.match(src, /allowed-tools:/); + assert.match(src, /AskUserQuestion/); + }); + + test('workflow exists at get-shit-done/workflows/settings-integrations.md', () => { + assert.ok(fs.existsSync(WORKFLOW_PATH), `missing ${WORKFLOW_PATH}`); + }); + + test('skill stub exists at .claude/skills/gsd-settings-integrations.md OR equivalent canonical command surface ships', () => { + // `.claude/skills/` is gitignored in this repo — the skill surface that + // actually ships is `commands/gsd/settings-integrations.md` paired with + // `get-shit-done/workflows/settings-integrations.md`. The `.claude/skills/` + // stub is generated at install time and may also be present locally. + // Treat either as satisfying the acceptance criterion. + const hasStub = fs.existsSync(SKILL_PATH); + const hasCanonical = + fs.existsSync(COMMAND_PATH) && fs.existsSync(WORKFLOW_PATH); + assert.ok( + hasStub || hasCanonical, + `neither ${SKILL_PATH} nor the canonical command/workflow pair exists` + ); + }); + + test('command delegates execution to the workflow', () => { + const src = fs.readFileSync(COMMAND_PATH, 'utf-8'); + assert.match(src, /workflows\/settings-integrations\.md/); + }); +}); + +// ─── Content: search API keys ──────────────────────────────────────────────── + +describe('#2529 workflow — search integrations', () => { + test('workflow references all four search fields', () => { + const src = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + for (const key of ['brave_search', 'firecrawl', 'exa_search', 'search_gitignored']) { + assert.ok(src.includes(key), `workflow must reference ${key}`); + } + }); +}); + +// ─── Content: review.models routing ────────────────────────────────────────── + +describe('#2529 workflow — review.models routing', () => { + test('workflow references all four reviewer CLIs', () => { + const src = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + for (const cli of ['claude', 'codex', 'gemini', 'opencode']) { + assert.ok( + src.includes(`review.models.${cli}`), + `workflow must reference review.models.${cli}` + ); + } + }); + + test('review.models. matches the dynamic pattern validator', () => { + for (const cli of ['claude', 'codex', 'gemini', 'opencode']) { + assert.ok( + isValidConfigKey(`review.models.${cli}`), + `review.models.${cli} must pass isValidConfigKey` + ); + } + }); +}); + +// ─── Content: agent_skills. injection ──────────────────────────── + +describe('#2529 workflow — agent_skills injection', () => { + test('workflow references agent_skills. injection concept', () => { + const src = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + assert.ok(src.includes('agent_skills'), 'workflow must reference agent_skills'); + assert.ok( + /agent_skills\.<[^>]+>|agent_skills\.\w+/.test(src), + 'workflow must reference agent_skills. or concrete agent_skills.' + ); + }); + + test('agent_skills. passes validator', () => { + assert.ok(isValidConfigKey('agent_skills.gsd-executor')); + assert.ok(isValidConfigKey('agent_skills.gsd-planner')); + assert.ok(isValidConfigKey('agent_skills.my_custom_agent')); + }); +}); + +// ─── Content: masking ──────────────────────────────────────────────────────── + +describe('#2529 workflow — API key masking', () => { + test('workflow documents the **** masking convention', () => { + const src = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + // Must reference the **** mask pattern + assert.ok(src.includes('****'), 'workflow must document the **** mask pattern'); + // Must explicitly state that plaintext is not displayed + assert.ok( + /never\s+(echo|display|log|show)[^.]*plaintext|plaintext[^.]*never\s+(echo|display|log|shown)|plaintext[^.]*not\s+(echoed|displayed|logged|shown)|not\s+(echoed|displayed|logged|shown)[^.]*plaintext/i.test(src), + 'workflow must explicitly forbid displaying plaintext API keys' + ); + }); + + test('workflow shows masked-value confirmation pattern, not raw secrets', () => { + const src = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + // The confirmation table in the workflow must describe the masked display + assert.ok( + /\*\*\*\*\w{0,4}|\*\*\*\* *already set|\*\*\*\*/i.test(src), + 'workflow must describe a masked confirmation pattern (e.g. ****last4 or **** already set)' + ); + }); + + test('workflow includes a Leave / Replace / Clear flow for already-set keys', () => { + const src = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + assert.ok(/Leave/i.test(src) && /Replace/i.test(src) && /Clear/i.test(src), + 'workflow must offer Leave / Replace / Clear when a key is already set'); + }); +}); + +// ─── config-set round-trip ─────────────────────────────────────────────────── + +describe('#2529 config-set round-trip', () => { + test('brave_search, firecrawl, exa_search, search_gitignored are valid keys', () => { + for (const k of ['brave_search', 'firecrawl', 'exa_search', 'search_gitignored']) { + assert.ok(VALID_CONFIG_KEYS.has(k), `${k} must be in VALID_CONFIG_KEYS`); + } + }); + + test('config-set writes brave_search, firecrawl, exa_search values to config.json', (t) => { + const tmp = createTempProject(); + t.after(() => cleanup(tmp)); + runGsdTools(['config-ensure-section'], tmp); + + const r1 = runGsdTools(['config-set', 'brave_search', 'BSKY-111111112222'], tmp); + assert.ok(r1.success, `brave_search set failed: ${r1.error}`); + const r2 = runGsdTools(['config-set', 'firecrawl', 'fc-aaaaaaaabbbbcccc'], tmp); + assert.ok(r2.success, `firecrawl set failed: ${r2.error}`); + const r3 = runGsdTools(['config-set', 'exa_search', 'ex-000011112222dddd'], tmp); + assert.ok(r3.success, `exa_search set failed: ${r3.error}`); + const r4 = runGsdTools(['config-set', 'search_gitignored', 'true'], tmp); + assert.ok(r4.success, `search_gitignored set failed: ${r4.error}`); + + const cfg = JSON.parse(fs.readFileSync(path.join(tmp, '.planning', 'config.json'), 'utf-8')); + assert.strictEqual(cfg.brave_search, 'BSKY-111111112222'); + assert.strictEqual(cfg.firecrawl, 'fc-aaaaaaaabbbbcccc'); + assert.strictEqual(cfg.exa_search, 'ex-000011112222dddd'); + assert.ok( + cfg.search_gitignored === true || cfg.search_gitignored === 'true', + `search_gitignored round-trip mismatch: got ${JSON.stringify(cfg.search_gitignored)}` + ); + }); + + test('config-set round-trips review.models.', (t) => { + const tmp = createTempProject(); + t.after(() => cleanup(tmp)); + runGsdTools(['config-ensure-section'], tmp); + + const r = runGsdTools( + ['config-set', 'review.models.codex', 'codex exec --model gpt-5'], + tmp + ); + assert.ok(r.success, `review.models.codex set failed: ${r.error}`); + const cfg = JSON.parse(fs.readFileSync(path.join(tmp, '.planning', 'config.json'), 'utf-8')); + assert.strictEqual(cfg.review?.models?.codex, 'codex exec --model gpt-5'); + }); + + test('config-set round-trips agent_skills.', (t) => { + const tmp = createTempProject(); + t.after(() => cleanup(tmp)); + runGsdTools(['config-ensure-section'], tmp); + + const r = runGsdTools( + ['config-set', 'agent_skills.gsd-executor', 'skill-a,skill-b'], + tmp + ); + assert.ok(r.success, `agent_skills.gsd-executor set failed: ${r.error}`); + const cfg = JSON.parse(fs.readFileSync(path.join(tmp, '.planning', 'config.json'), 'utf-8')); + // Accept either array or string — validator accepts both shapes today. + const v = cfg.agent_skills?.['gsd-executor']; + assert.ok(v === 'skill-a,skill-b' || (Array.isArray(v) && v.join(',') === 'skill-a,skill-b'), + `expected agent_skills.gsd-executor to contain both skills, got ${JSON.stringify(v)}`); + }); +}); + +// ─── Config merge preserves unrelated keys ─────────────────────────────────── + +describe('#2529 config merge safety', () => { + test('setting brave_search preserves unrelated workflow.research key', (t) => { + const tmp = createTempProject(); + t.after(() => cleanup(tmp)); + runGsdTools(['config-ensure-section'], tmp); + runGsdTools(['config-set', 'workflow.research', 'false'], tmp); + + const r = runGsdTools(['config-set', 'brave_search', 'BSKY-preserve-me-9999'], tmp); + assert.ok(r.success, `set failed: ${r.error}`); + + const cfg = JSON.parse(fs.readFileSync(path.join(tmp, '.planning', 'config.json'), 'utf-8')); + assert.strictEqual(cfg.workflow?.research, false, 'unrelated workflow.research must be preserved'); + assert.strictEqual(cfg.brave_search, 'BSKY-preserve-me-9999'); + }); + + test('setting agent_skills.gsd-executor preserves unrelated review.models.codex', (t) => { + const tmp = createTempProject(); + t.after(() => cleanup(tmp)); + runGsdTools(['config-ensure-section'], tmp); + runGsdTools(['config-set', 'review.models.codex', 'codex exec'], tmp); + + const r = runGsdTools(['config-set', 'agent_skills.gsd-planner', 'a,b'], tmp); + assert.ok(r.success, `set failed: ${r.error}`); + + const cfg = JSON.parse(fs.readFileSync(path.join(tmp, '.planning', 'config.json'), 'utf-8')); + assert.strictEqual(cfg.review?.models?.codex, 'codex exec', 'unrelated review.models.codex must be preserved'); + assert.ok(cfg.agent_skills?.['gsd-planner'], 'agent_skills.gsd-planner must be set'); + }); +}); + +// ─── /gsd:settings mentions /gsd:settings-integrations ─────────────────────── + +describe('#2529 /gsd:settings mentions new command', () => { + test('settings workflow mentions /gsd:settings-integrations in its confirmation output', () => { + const src = fs.readFileSync(SETTINGS_WORKFLOW_PATH, 'utf-8'); + assert.ok( + src.includes('/gsd:settings-integrations') || src.includes('/gsd-settings-integrations'), + 'settings.md must mention /gsd:settings-integrations as a follow-up' + ); + }); +}); + +// ─── Negative scenarios ────────────────────────────────────────────────────── + +describe('#2529 negative — invalid inputs rejected', () => { + test('invalid agent-type with path separators is rejected by validator', () => { + assert.ok(!isValidConfigKey('agent_skills.../etc/passwd'), + 'agent_skills.../etc/passwd must be rejected'); + assert.ok(!isValidConfigKey('agent_skills./evil'), + 'agent_skills./evil must be rejected'); + assert.ok(!isValidConfigKey('agent_skills.a b c'), + 'agent_skills with spaces must be rejected'); + assert.ok(!isValidConfigKey('agent_skills.$(whoami)'), + 'agent_skills with shell metacharacters must be rejected'); + }); + + test('config-set rejects agent_skills with path traversal', (t) => { + const tmp = createTempProject(); + t.after(() => cleanup(tmp)); + runGsdTools(['config-ensure-section'], tmp); + + const r = runGsdTools(['config-set', 'agent_skills.../etc/passwd', 'x'], tmp); + assert.ok(!r.success, 'config-set must reject path-traversal agent-type slug'); + }); + + test('malformed review.models entry (empty cli) is rejected', () => { + assert.ok(!isValidConfigKey('review.models.'), + 'review.models. (empty) must be rejected'); + assert.ok(!isValidConfigKey('review.models'), + 'review.models (no cli) must be rejected'); + assert.ok(!isValidConfigKey('review.models.claude/../../x'), + 'review.models with path separators must be rejected'); + }); +}); + +// ─── Security: plaintext never leaks to disk outside config.json ───────────── + +describe('#2529 security — plaintext containment', () => { + test('after setting brave_search, plaintext appears only in config.json', (t) => { + const tmp = createTempProject(); + t.after(() => cleanup(tmp)); + runGsdTools(['config-ensure-section'], tmp); + + // Build sentinel via concat so secret-scanners do not flag the literal. + const marker = ['MASKCHECK', '9f3a7b2c'].join('-'); + const r = runGsdTools(['config-set', 'brave_search', marker], tmp); + assert.ok(r.success, `set failed: ${r.error}`); + + const planning = path.join(tmp, '.planning'); + const hits = []; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + if (!entry.isFile()) continue; + let buf; + try { buf = fs.readFileSync(full, 'utf-8'); } catch { continue; } + if (buf.includes(marker)) hits.push(full); + } + } + walk(planning); + + assert.deepStrictEqual( + hits.map(h => path.basename(h)).sort(), + ['config.json'], + `plaintext marker leaked outside config.json: found in ${hits.join(', ')}` + ); + }); + + test('config-set does not echo plaintext secret on stdout/stderr', (t) => { + const tmp = createTempProject(); + t.after(() => cleanup(tmp)); + runGsdTools(['config-ensure-section'], tmp); + + const marker = ['ECHOCHECK', '77aa33bb'].join('-'); + const r = runGsdTools(['config-set', 'brave_search', marker], tmp); + assert.ok(r.success, `set failed: ${r.error}`); + const combined = `${r.output || ''}\n${r.error || ''}`; + assert.ok( + !combined.includes(marker), + `config-set output must not echo the plaintext marker. Got:\n${combined}` + ); + }); + + test('config-get masks secrets and never echoes plaintext for brave_search/firecrawl/exa_search', (t) => { + const tmp = createTempProject(); + t.after(() => cleanup(tmp)); + runGsdTools(['config-ensure-section'], tmp); + + const cases = [ + { key: 'brave_search', marker: ['GETMASK', 'brave', 'aaaa1111'].join('-') }, + { key: 'firecrawl', marker: ['GETMASK', 'fc', 'bbbb2222'].join('-') }, + { key: 'exa_search', marker: ['GETMASK', 'ex', 'cccc3333'].join('-') }, + ]; + + for (const { key, marker } of cases) { + const set = runGsdTools(['config-set', key, marker], tmp); + assert.ok(set.success, `${key} set failed: ${set.error}`); + + const get = runGsdTools(['config-get', key], tmp); + assert.ok(get.success, `${key} get failed: ${get.error}`); + const combined = `${get.output || ''}\n${get.error || ''}`; + assert.ok( + !combined.includes(marker), + `config-get must not echo plaintext for ${key}. Got:\n${combined}` + ); + // Must contain the masked tail (last 4 of marker) + const expectedMask = '****' + marker.slice(-4); + assert.ok( + combined.includes(expectedMask), + `config-get must show masked form (${expectedMask}) for ${key}. Got:\n${combined}` + ); + } + }); +});