From 3b2bf5b712bdd9ec8cc4bc618b233e443e508cd9 Mon Sep 17 00:00:00 2001 From: I4cDeath Date: Mon, 27 Apr 2026 20:24:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20v0.11.7=20=E2=80=94=20Glama=20TDQS=20ov?= =?UTF-8?q?erhaul=20for=20all=2044=20MCP=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 44 tools now ship a 3-sentence description hitting the six Glama Tool Definition Quality Score dimensions (purpose, when-to-use vs. siblings, side-effects/audit/network, parameter semantics, conciseness, returns). Common parameter schemas (`scope`, `projectPath`, `env`, `teamId`, `orgId`) and per-tool Zod `.describe()` strings now include formats, defaults, and concrete examples to lift the per-tool minimum score that dominates the server-level grade (60% mean / 40% min). - src/mcp/tools/_shared.ts: enriched commonSchemas describe() strings - src/mcp/tools/{secrets,project,tunnel,teleport,audit,validation,hooks, tooling,agent,policy}.ts: 44 tool descriptions rewritten + parameter describe() strings tightened - README.md: MCP tables resynced with new one-liners - CHANGELOG.md: 0.11.7 entry - package.json / server.json / plugin.json / marketplace.json: 0.11.7 No runtime / MCP wire-format changes — agents will see longer, clearer tool descriptions on next tools/list refresh; tool names, parameters, and return shapes are unchanged. Test plan: - pnpm run typecheck ✓ - pnpm run lint ✓ (max-warnings 0) - pnpm run build ✓ - pnpm run test:ci ✓ (24 files / 164 tests) Made-with: Cursor --- .cursor-plugin/marketplace.json | 2 +- CHANGELOG.md | 11 ++ README.md | 88 ++++----- cursor-plugin/.cursor-plugin/plugin.json | 2 +- package.json | 2 +- server.json | 4 +- src/mcp/tools/_shared.ts | 20 +- src/mcp/tools/agent.ts | 39 +++- src/mcp/tools/audit.ts | 74 ++++++-- src/mcp/tools/hooks.ts | 82 ++++++-- src/mcp/tools/policy.ts | 31 ++- src/mcp/tools/project.ts | 24 ++- src/mcp/tools/secrets.ts | 230 ++++++++++++++++++----- src/mcp/tools/teleport.ts | 38 +++- src/mcp/tools/tooling.ts | 91 +++++++-- src/mcp/tools/tunnel.ts | 52 ++++- src/mcp/tools/validation.ts | 45 ++++- 17 files changed, 651 insertions(+), 184 deletions(-) diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index f50eebe..2a6022b 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "qring", "source": "cursor-plugin", "description": "Quantum keyring for AI agents — manage secrets, scan for leaks, rotate keys, and enforce policy directly from Cursor.", - "version": "0.11.5", + "version": "0.11.7", "keywords": [ "secrets", "keyring", diff --git a/CHANGELOG.md b/CHANGELOG.md index f03d84a..0b95921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.11.7] — 2026-04-27 + +### Changed +- **MCP tool descriptions overhauled for Glama TDQS** — every one of the 44 MCP tools now ships a 3-sentence description hitting all six [Tool Definition Quality Score](https://glama.ai/blog/2026-04-03-tool-definition-quality-score-tdqs) dimensions (purpose, when-to-use vs. siblings, side effects/audit/network, parameter semantics, conciseness, returns). Common parameter schemas (`scope`, `projectPath`, `env`, `teamId`, `orgId`) and per-tool Zod `.describe()` strings now include formats, defaults, and concrete examples to lift the per-tool minimum score (which dominates the server-level grade at 60% mean / 40% min). README MCP tables resynced with the new one-liners. +- **`feature-docs-sync.mdc` rule rewritten** — drops stale `web/components/...` globs (the marketing site was extracted to its own repo in 0.11.5) and replaces them with an explicit `quantum_ring` ↔ `qring.i4c.studio` cross-repo file mapping covering `lib/data/{features,mcp-tools,cli-commands,cli-reference,changelog,version}.ts`. +- **`release-process.mdc` rule** — `Downstream Sync` table now lists the marketing site, Cursor plugin, Kiro plugin, and Claude Code plugin alongside Glama, with explicit commands and a note that the marketing-site sync is not enforced by `quantum_ring` CI. + +### Notes +- No runtime / MCP wire-format changes — this is a documentation-quality release. Existing agents and integrations will see longer, clearer tool descriptions and richer parameter help when they next refresh `tools/list`, but tool names, parameter names, and return shapes are unchanged. +- After publish, trigger a Glama re-sync from the admin panel so the new descriptions feed the next TDQS scoring run. + ## [0.11.5] — 2026-04-27 ### Added diff --git a/README.md b/README.md index c874332..759e11a 100644 --- a/README.md +++ b/README.md @@ -663,95 +663,95 @@ q-ring includes a full MCP server with 44 tools for AI agent integration. | Tool | Description | |------|-------------| -| `get_secret` | Retrieve with superposition collapse + observer logging | -| `list_secrets` | List keys with quantum metadata, filterable by tag/expiry/pattern | -| `set_secret` | Store with optional TTL, env state, tags, rotation format | -| `delete_secret` | Remove a secret | -| `has_secret` | Boolean check (respects decay) | -| `export_secrets` | Export as .env/JSON with optional key and tag filters | -| `import_dotenv` | Parse and import secrets from .env content | -| `check_project` | Validate project secrets against `.q-ring.json` manifest | -| `env_generate` | Generate .env content from the project manifest | +| `get_secret` | Read a secret value (collapses superposition, audits the read) | +| `list_secrets` | List keys + metadata in scope (values never exposed); filter by tag, expiry, glob | +| `set_secret` | Create or overwrite a single secret with optional TTL, per-env state, tags, rotation format | +| `delete_secret` | Permanently remove a secret value (not undoable from q-ring) | +| `has_secret` | Boolean existence check that respects decay (no audit read) | +| `export_secrets` | Render multiple secrets as `.env` or JSON for one-off export | +| `import_dotenv` | Parse `.env` text and bulk-store every key/value pair | +| `check_project` | Compare `.q-ring.json` manifest against the keyring for missing/expired/stale keys | +| `env_generate` | Render a complete `.env` body from the project manifest, with warnings for gaps | ### Quantum Tools | Tool | Description | |------|-------------| -| `inspect_secret` | Full quantum state (states, decay, entanglement, access count) | -| `detect_environment` | Wavefunction collapse — detect current env context | -| `generate_secret` | Quantum noise — generate and optionally save secrets | -| `entangle_secrets` | Link two secrets for synchronized rotation | -| `disentangle_secrets` | Remove entanglement between two secrets | +| `inspect_secret` | Show metadata for one key (states, decay, entanglement, access count) without revealing the value | +| `detect_environment` | Resolve which env slug should drive superposition collapse for the current context | +| `generate_secret` | Generate a CSPRNG-backed value in a chosen format and optionally store it | +| `entangle_secrets` | Link two keys so future writes/rotations propagate the same value | +| `disentangle_secrets` | Break the sync link between two keys (does not delete values) | ### Tunneling Tools | Tool | Description | |------|-------------| -| `tunnel_create` | Create ephemeral in-memory secret | -| `tunnel_read` | Read (may self-destruct) | -| `tunnel_list` | List active tunnels | -| `tunnel_destroy` | Immediately destroy | +| `tunnel_create` | Stash a value in process memory and return an opaque ID (never touches disk) | +| `tunnel_read` | Fetch a tunneled value by ID — may self-destruct on read | +| `tunnel_list` | Enumerate active tunnels with remaining read budget and TTL (IDs only) | +| `tunnel_destroy` | Immediately remove a tunnel from memory before its TTL/reads run out | ### Teleportation Tools | Tool | Description | |------|-------------| -| `teleport_pack` | Encrypt secrets into a portable bundle | -| `teleport_unpack` | Decrypt and import a bundle | +| `teleport_pack` | Encrypt selected secrets into a passphrase-protected AES-256-GCM bundle | +| `teleport_unpack` | Decrypt a teleport bundle and import each secret (with optional dry-run) | ### Validation Tools | Tool | Description | |------|-------------| -| `validate_secret` | Test if a secret is valid with its target service (OpenAI, Stripe, GitHub, etc.) | -| `list_providers` | List all available validation providers | +| `validate_secret` | Hit the upstream service (OpenAI/Stripe/GitHub/AWS/HTTP) to confirm a single key is still live | +| `list_providers` | Enumerate built-in validation providers and their auto-detect prefixes | ### Hook Tools | Tool | Description | |------|-------------| -| `register_hook` | Register a shell/HTTP/signal callback on secret changes | -| `list_hooks` | List all registered hooks with match criteria and status | -| `remove_hook` | Remove a registered hook by ID | +| `register_hook` | Register a shell/HTTP/signal side-effect that fires on write/delete/rotate | +| `list_hooks` | Show every registered hook with match criteria, type, and enabled flag | +| `remove_hook` | Detach a single hook by ID without touching any secrets | ### Execution & Scanning Tools | Tool | Description | |------|-------------| -| `exec_with_secrets` | Run a shell command securely with secrets injected, auto-redacted output, and exec profile enforcement | -| `scan_codebase_for_secrets` | Scan a directory for hardcoded secrets using regex heuristics and entropy analysis | -| `lint_files` | Lint specific files for hardcoded secrets with optional auto-fix | +| `exec_with_secrets` | Run a child command with secrets injected as env vars and any leaked values redacted from output | +| `scan_codebase_for_secrets` | Walk a directory tree and flag hardcoded secrets via regex + entropy heuristics | +| `lint_files` | Inspect a specific file list for hardcoded secrets with optional auto-fix to `process.env.KEY` | ### AI Agent Tools | Tool | Description | |------|-------------| -| `get_project_context` | Safe, redacted overview of project secrets, environment, manifest, and activity | -| `agent_remember` | Store a key-value pair in encrypted agent memory (persists across sessions) | -| `agent_recall` | Retrieve from agent memory, or list all stored keys | -| `agent_forget` | Delete a key from agent memory | -| `analyze_secrets` | Usage analytics: most accessed, stale, unused, and rotation recommendations | +| `get_project_context` | Single redacted snapshot of secrets, env, manifest, hooks, and recent audit activity | +| `agent_remember` | Persist a non-secret note in encrypted agent memory across sessions | +| `agent_recall` | Read a memory value, or list every stored key when no key is supplied | +| `agent_forget` | Permanently delete a key from agent memory | +| `analyze_secrets` | Usage profile: most-accessed, stale, never-accessed, no-rotation candidates | ### Observer & Health Tools | Tool | Description | |------|-------------| -| `audit_log` | Query access history | -| `detect_anomalies` | Scan for unusual access patterns | -| `verify_audit_chain` | Verify tamper-evident hash chain integrity | -| `export_audit` | Export audit events in jsonl, json, or csv format | -| `health_check` | Full health report | -| `status_dashboard` | Launch the quantum status dashboard (SSE) — live KPIs, health, secrets table, manifest, policy, approvals, hooks, agent memory, anomalies, and audit feed | -| `agent_scan` | Run autonomous agent scan | +| `audit_log` | Query the tamper-evident audit log filtered by key, action, and limit | +| `detect_anomalies` | Surface burst-read and off-hours findings from audit history | +| `verify_audit_chain` | Recompute the audit hash chain and report the first break point if tampered | +| `export_audit` | Export audit events as jsonl, json, or csv for archival/SIEM | +| `health_check` | Read-only scope sweep: decay/stale/expired counts plus current anomalies | +| `status_dashboard` | Start a local SSE dashboard with live KPIs, secrets, hooks, and audit feed | +| `agent_scan` | Multi-project health pass with optional `autoRotate` for expired secrets | ### Governance & Policy Tools | Tool | Description | |------|-------------| -| `check_policy` | Check if an action (tool use, key read, exec) is allowed by project policy | -| `get_policy_summary` | Get a summary of the project's governance policy configuration | -| `rotate_secret` | Attempt issuer-native rotation via detected or specified provider | -| `ci_validate_secrets` | CI-oriented batch validation of all secrets with structured pass/fail report | +| `check_policy` | Dry-run a tool/key/exec action against `.q-ring.json` policy without performing it | +| `get_policy_summary` | High-level overview of policy rule counts and approval/rotation requirements | +| `rotate_secret` | Ask the upstream provider to issue a new credential and store it back in the keyring | +| `ci_validate_secrets` | Batch-validate every accessible secret in scope and return a structured pass/fail report | ### Cursor / Kiro Configuration diff --git a/cursor-plugin/.cursor-plugin/plugin.json b/cursor-plugin/.cursor-plugin/plugin.json index f86e029..b6ed82e 100644 --- a/cursor-plugin/.cursor-plugin/plugin.json +++ b/cursor-plugin/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "qring", "description": "Quantum keyring for AI agents — manage secrets, scan for leaks, rotate keys, and enforce policy directly from Cursor.", - "version": "0.11.5", + "version": "0.11.7", "author": { "name": "I4cTime" }, diff --git a/package.json b/package.json index 141307d..82d462b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@i4ctime/q-ring", - "version": "0.11.5", + "version": "0.11.7", "mcpName": "io.github.I4cTime/q-ring", "description": "Quantum keyring for AI coding tools — Cursor, Kiro, Claude Code. Secrets, superposition, entanglement, MCP.", "type": "module", diff --git a/server.json b/server.json index cbf8314..f06224f 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/I4cTime/quantum_ring", "source": "github" }, - "version": "0.11.5", + "version": "0.11.7", "packages": [ { "registryType": "npm", "identifier": "@i4ctime/q-ring", - "version": "0.11.5", + "version": "0.11.7", "transport": { "type": "stdio" } diff --git a/src/mcp/tools/_shared.ts b/src/mcp/tools/_shared.ts index 04dd3b4..af3ecb1 100644 --- a/src/mcp/tools/_shared.ts +++ b/src/mcp/tools/_shared.ts @@ -52,21 +52,31 @@ export const commonSchemas = { teamId: z .string() .optional() - .describe("Team identifier for team-scoped secrets"), + .describe( + "Team identifier for team-scoped secrets. Required only when scope='team'. Example: 'acme-platform'.", + ), orgId: z .string() .optional() - .describe("Org identifier for org-scoped secrets"), + .describe( + "Organization identifier for org-scoped secrets. Required only when scope='org'. Example: 'acme-corp'.", + ), scope: z .enum(["global", "project", "team", "org"]) .optional() - .describe("Scope: global, project, team, or org"), + .describe( + "Where the secret lives. 'global' = user keyring (default if omitted on reads), 'project' = scoped to projectPath, 'team' = team-shared (needs teamId), 'org' = org-shared (needs orgId).", + ), projectPath: z .string() .optional() - .describe("Project root path for project-scoped secrets"), + .describe( + "Absolute path to the project root for project-scoped secrets and policy resolution. Defaults to the MCP server's current working directory when omitted.", + ), env: z .string() .optional() - .describe("Environment for superposition collapse (e.g., dev, staging, prod)"), + .describe( + "Environment slug used to collapse superposition when a secret has multiple per-env states. Examples: 'dev', 'staging', 'prod'. If omitted, the secret's defaultEnv is used.", + ), } as const; diff --git a/src/mcp/tools/agent.ts b/src/mcp/tools/agent.ts index 78ee4fb..c33fa0a 100644 --- a/src/mcp/tools/agent.ts +++ b/src/mcp/tools/agent.ts @@ -6,10 +6,22 @@ import { text, enforceToolPolicy } from "./_shared.js"; export function registerAgentTools(server: McpServer): void { server.tool( "agent_remember", - "[agent] Store a key-value pair in encrypted agent memory that persists across sessions. Use this to remember decisions, rotation history, or project-specific context.", + [ + "[agent] Persist a non-secret key/value note in encrypted, on-disk agent memory that survives across MCP sessions.", + "Use to record stable agent context — last rotation date for a key, the user's deployment preferences, decisions taken in earlier sessions; do NOT use this to store secrets (use `set_secret` instead) and prefer chat scratchpad for purely transient state.", + "Mutates the encrypted memory store. Idempotent: rewriting the same key with a new value simply overwrites. Returns 'Remembered \"KEY\"' on success.", + ].join(" "), { - key: z.string().describe("Memory key"), - value: z.string().describe("Value to store"), + key: z + .string() + .describe( + "Memory key (free-form string). Convention: lowercase dotted namespaces, e.g. 'project.lastDeploy'.", + ), + value: z + .string() + .describe( + "Plain-string value to store. JSON-stringify structured data on the caller side if needed.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("agent_remember"); @@ -22,9 +34,18 @@ export function registerAgentTools(server: McpServer): void { server.tool( "agent_recall", - "[agent] Retrieve a value from agent memory, or list all stored keys if no key is provided.", + [ + "[agent] Read a value from encrypted agent memory, or list every stored key when no specific key is supplied.", + "Use at the start of an agent loop to rehydrate prior context, or to look up a single remembered fact; prefer `get_project_context` for a redacted overview of secrets and `get_secret` for actual credential values.", + "Read-only. With a `key` argument: returns JSON `{ ok, data: { key, value } }` or a not-found error. Without `key`: returns a JSON listing of every stored key (no values), or 'Agent memory is empty'.", + ].join(" "), { - key: z.string().optional().describe("Memory key to recall (omit to list all)"), + key: z + .string() + .optional() + .describe( + "Memory key to read. Omit to list every stored key (without values).", + ), }, async (params) => { const toolBlock = enforceToolPolicy("agent_recall"); @@ -46,9 +67,13 @@ export function registerAgentTools(server: McpServer): void { server.tool( "agent_forget", - "[agent] Delete a key from agent memory.", + [ + "[agent] Permanently delete a single key from encrypted agent memory.", + "Use to retract obsolete or misremembered context; prefer overwriting via `agent_remember` when you just want to update the value, and use `delete_secret` for actual credentials (which never live in agent memory).", + "Destructive: there is no recycle bin. Returns 'Forgot \"KEY\"' on success or a not-found error if the key was already absent.", + ].join(" "), { - key: z.string().describe("Memory key to forget"), + key: z.string().describe("Memory key to delete."), }, async (params) => { const toolBlock = enforceToolPolicy("agent_forget"); diff --git a/src/mcp/tools/audit.ts b/src/mcp/tools/audit.ts index ebd06d8..08ef336 100644 --- a/src/mcp/tools/audit.ts +++ b/src/mcp/tools/audit.ts @@ -14,9 +14,18 @@ const { teamId, orgId, scope, projectPath } = commonSchemas; export function registerAuditTools(server: McpServer): void { server.tool( "audit_log", - "[audit] Query the audit log for secret access history (observer effect). Shows who accessed what and when.", + [ + "[audit] Query the q-ring audit log — a tamper-evident record of every read/write/delete touching a secret.", + "Use to investigate 'who accessed KEY recently?' or to feed an agent the access timeline for a specific credential; prefer `detect_anomalies` for automated unusual-pattern detection and `health_check` for decay-state-plus-anomalies in one call.", + "Read-only. Returns one line per event in chronological order, formatted `timestamp | action | key | [scope] | env:NAME | detail`. Returns 'No audit events found' when the filter matches nothing.", + ].join(" "), { - key: z.string().optional().describe("Filter by key"), + key: z + .string() + .optional() + .describe( + "Limit to events touching this exact key. Omit for the full log.", + ), action: z .enum([ "read", @@ -31,8 +40,16 @@ export function registerAuditTools(server: McpServer): void { "collapse", ]) .optional() - .describe("Filter by action"), - limit: z.number().optional().default(20).describe("Max events to return"), + .describe( + "Limit to a single action verb (e.g. 'read' to see only reads). Omit for all actions.", + ), + limit: z + .number() + .optional() + .default(20) + .describe( + "Maximum events to return, newest first. Defaults to 20. Increase for deeper investigations.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("audit_log"); @@ -61,9 +78,18 @@ export function registerAuditTools(server: McpServer): void { server.tool( "detect_anomalies", - "[audit] Read-only scan of audit history for burst reads and unusual-hour access (text lines per finding). Optional key filter. Use health_check for full decay inventory + anomaly count in scope; use agent_scan for multi-project JSON reports or optional auto-rotation. Does not mutate secrets.", + [ + "[audit] Scan the audit history for suspicious access patterns — burst reads of the same key, off-hours access, and other heuristics.", + "Use as a quick triage signal when investigating a single key or before letting an agent rotate credentials; prefer `health_check` for a scope-wide decay+anomaly summary, and `agent_scan` for multi-project JSON reports with optional auto-rotation.", + "Read-only; never mutates secrets or the audit log. Returns one line per finding formatted `[type] description`, or 'No anomalies detected' when the log looks clean.", + ].join(" "), { - key: z.string().optional().describe("Check anomalies for a specific key"), + key: z + .string() + .optional() + .describe( + "If provided, narrow the scan to this exact key. Omit to scan across every key in the audit log.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("detect_anomalies"); @@ -79,7 +105,11 @@ export function registerAuditTools(server: McpServer): void { server.tool( "health_check", - "[health] Read-only scoped pass: decay/stale/expired counts, per-secret issue lines, plus audit-derived anomalies. No writes. Use check_project for .q-ring.json manifest compliance; use detect_anomalies for audit-pattern-only triage; use agent_scan for multi-project JSON or optional autoRotate credential replacement.", + [ + "[health] Run a single read-only sweep over every secret in the requested scope and report counts of healthy/stale/expired secrets plus any current audit anomalies.", + "Use as the default 'is everything OK?' command for an agent or operator; prefer `check_project` to validate manifest compliance specifically, `detect_anomalies` for audit-only triage, and `agent_scan` for multi-project JSON output or optional auto-rotation.", + "Read-only — never writes. Returns a multi-line text summary: header counts (Total / Healthy / Stale / Expired / No decay / Anomalies), then per-secret `EXPIRED:` / `STALE:` issue lines, then per-anomaly `[type] description` lines.", + ].join(" "), { scope, projectPath, @@ -140,7 +170,11 @@ export function registerAuditTools(server: McpServer): void { server.tool( "verify_audit_chain", - "[audit] Verify the tamper-evident hash chain of the audit log. Returns integrity status and the first break point if tampered.", + [ + "[audit] Recompute the SHA-256 hash chain over the audit log and confirm no event has been mutated, deleted, or reordered.", + "Use periodically as a tamper-evidence check, or whenever you suspect the audit log has been touched outside q-ring; the result is informational — this tool does not repair the chain if it is broken.", + "Read-only. Returns JSON `{ ok, valid, brokenAt? }` where `valid` is `true` for an intact chain and `brokenAt` (when present) names the first event whose hash did not match.", + ].join(" "), {}, async () => { const toolBlock = enforceToolPolicy("verify_audit_chain"); @@ -153,15 +187,31 @@ export function registerAuditTools(server: McpServer): void { server.tool( "export_audit", - "[audit] Export audit events in a portable format (jsonl, json, or csv) with optional time range filtering.", + [ + "[audit] Export the audit log as a portable text artifact suitable for archiving or feeding into another SIEM/analyzer.", + "Use for compliance exports, after-the-fact investigations, or to hand the trail to a non-MCP consumer; prefer `audit_log` for an in-conversation tail and `verify_audit_chain` to confirm integrity before exporting.", + "Read-only. Returns the rendered text directly (no JSON wrapper). 'jsonl' is one event per line; 'json' is a single array; 'csv' is a header row plus events. Time filters are applied to the event timestamps before formatting.", + ].join(" "), { - since: z.string().optional().describe("Start date (ISO 8601)"), - until: z.string().optional().describe("End date (ISO 8601)"), + since: z + .string() + .optional() + .describe( + "Inclusive lower bound on event timestamp, ISO 8601. Example: '2026-04-01T00:00:00Z'. Omit for no lower bound.", + ), + until: z + .string() + .optional() + .describe( + "Inclusive upper bound on event timestamp, ISO 8601. Omit for now/no upper bound.", + ), format: z .enum(["jsonl", "json", "csv"]) .optional() .default("jsonl") - .describe("Output format"), + .describe( + "Output format. 'jsonl' (default) is most stream-friendly; 'json' is a single array; 'csv' is spreadsheet-friendly.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("export_audit"); diff --git a/src/mcp/tools/hooks.ts b/src/mcp/tools/hooks.ts index dd331f1..574e0c4 100644 --- a/src/mcp/tools/hooks.ts +++ b/src/mcp/tools/hooks.ts @@ -12,39 +12,79 @@ import { text, enforceToolPolicy } from "./_shared.js"; export function registerHookTools(server: McpServer): void { server.tool( "register_hook", - "[hooks] Register a webhook/callback that fires when a secret is updated, deleted, or rotated. Supports shell commands, HTTP webhooks, and process signals.", + [ + "[hooks] Register a side-effect (shell command, HTTP webhook, or process signal) that fires automatically when a matching secret is written, deleted, or rotated.", + "Use to keep external systems in sync (restart a service after rotation, post to Slack on delete, kick a build); prefer `agent_remember` for storing facts an agent should recall later, and `register_hook` is not the right tool for time-based scheduled rotation (use `agent_scan` for that).", + "Mutates the hook registry on disk. At least one match criterion (`key`, `keyPattern`, or `tag`) is required — calls without any return an error. Returns JSON of the registered hook entry including its assigned `id` (use that `id` with `remove_hook`).", + ].join(" "), { - type: z.enum(["shell", "http", "signal"]).describe("Hook type"), - key: z.string().optional().describe("Trigger on exact key match"), + type: z + .enum(["shell", "http", "signal"]) + .describe( + "Hook delivery mechanism. 'shell' runs a local command, 'http' POSTs JSON to a URL, 'signal' sends an OS signal to a named process.", + ), + key: z + .string() + .optional() + .describe( + "Trigger only on this exact key name. Pick at most one of `key` / `keyPattern` / `tag` (or combine for stricter matching).", + ), keyPattern: z .string() .optional() - .describe("Trigger on key glob pattern (e.g. DB_*)"), - tag: z.string().optional().describe("Trigger on secrets with this tag"), + .describe( + "Trigger on any key matching this glob pattern. Examples: 'DB_*', 'STRIPE_*'.", + ), + tag: z + .string() + .optional() + .describe( + "Trigger on any secret carrying this exact tag. Combinable with key/keyPattern as an AND filter.", + ), scope: z .enum(["global", "project"]) .optional() - .describe("Trigger only for this scope"), + .describe( + "Restrict the hook to secrets in this scope. Omit to fire across both global and project secrets.", + ), actions: z .array(z.enum(["write", "delete", "rotate"])) .optional() .default(["write", "delete", "rotate"]) - .describe("Which actions trigger this hook"), + .describe( + "Which lifecycle actions trigger this hook. Defaults to all three.", + ), command: z .string() .optional() - .describe("Shell command to execute (for shell type)"), - url: z.string().optional().describe("URL to POST to (for http type)"), + .describe( + "Required when type='shell'. The literal shell command to run; q-ring exposes the matching key as $QRING_HOOK_KEY and action as $QRING_HOOK_ACTION.", + ), + url: z + .string() + .optional() + .describe( + "Required when type='http'. Full URL to POST a JSON body `{ id, key, scope, action, timestamp }` to (the value itself is never sent).", + ), signalTarget: z .string() .optional() - .describe("Process name or PID (for signal type)"), + .describe( + "Required when type='signal'. Either a numeric PID or a process name resolvable via `ps`.", + ), signalName: z .string() .optional() .default("SIGHUP") - .describe("Signal to send (for signal type)"), - description: z.string().optional().describe("Human-readable description"), + .describe( + "Signal name to send (e.g. 'SIGHUP', 'SIGUSR1'). Defaults to SIGHUP, which most daemons treat as 'reload config'.", + ), + description: z + .string() + .optional() + .describe( + "Free-text human-readable description, surfaced by `list_hooks` and the dashboard.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("register_hook"); @@ -81,7 +121,11 @@ export function registerHookTools(server: McpServer): void { server.tool( "list_hooks", - "[hooks] List all registered secret change hooks with their match criteria, type, and status.", + [ + "[hooks] Enumerate every registered lifecycle hook with its match criteria, delivery type, enabled flag, and description.", + "Use to find a hook's `id` before calling `remove_hook`, audit what side effects are wired up, or diagnose why a hook did not fire.", + "Read-only. Returns pretty-printed JSON array of hook entries, or 'No hooks registered' when the registry is empty.", + ].join(" "), {}, async () => { const toolBlock = enforceToolPolicy("list_hooks"); @@ -95,9 +139,17 @@ export function registerHookTools(server: McpServer): void { server.tool( "remove_hook", - "[hooks] Remove a lifecycle hook entry by id from the hook registry only (stops callbacks; does not touch secret values). Call list_hooks first for ids. Contrast delete_secret (credential removal) or tunnel_destroy (ephemeral tunnel). Returns success or not-found; subject to tool policy.", + [ + "[hooks] Detach a single lifecycle hook by its registry id so it stops firing.", + "Use to retire a specific webhook/command without touching any secrets; prefer `delete_secret` to remove a credential and `tunnel_destroy` for ephemeral tunnels.", + "Mutates the hook registry only — does not touch secret values, audit log, or env states. Idempotent in spirit: removing an already-absent id returns a not-found error rather than partial work. Returns 'Removed hook ID' on success.", + ].join(" "), { - id: z.string().describe("Hook ID to remove"), + id: z + .string() + .describe( + "Hook id returned by `register_hook` or visible in `list_hooks` (opaque string).", + ), }, async (params) => { const toolBlock = enforceToolPolicy("remove_hook"); diff --git a/src/mcp/tools/policy.ts b/src/mcp/tools/policy.ts index 797d8be..3da073b 100644 --- a/src/mcp/tools/policy.ts +++ b/src/mcp/tools/policy.ts @@ -13,20 +13,35 @@ const { projectPath } = commonSchemas; export function registerPolicyTools(server: McpServer): void { server.tool( "check_policy", - "[policy] Check if an action is allowed by the project's governance policy. Returns the policy decision and source.", + [ + "[policy] Ask whether a single intended action would be allowed by the project's `.q-ring.json` policy without actually performing it.", + "Use as a dry-run before calling a potentially-blocked tool, attempting to read a sensitive key, or invoking `exec_with_secrets` with a non-trivial command; prefer `get_policy_summary` for a one-shot overview of the entire policy.", + "Read-only. Returns JSON `{ allowed, reason?, policySource }` describing the decision. Returns an error 'Missing required parameter for the selected action type' if the matching argument for the chosen `action` is not supplied.", + ].join(" "), { action: z .enum(["tool", "key_read", "exec"]) - .describe("Type of policy check"), + .describe( + "Which policy surface to query. 'tool' = MCP tool gate (needs `toolName`); 'key_read' = secret read gate (needs `key`); 'exec' = exec_with_secrets command gate (needs `command`).", + ), toolName: z .string() .optional() - .describe("Tool name to check (for action=tool)"), - key: z.string().optional().describe("Secret key to check (for action=key_read)"), + .describe( + "Tool id to evaluate, e.g. 'rotate_secret'. Required when `action` is 'tool'.", + ), + key: z + .string() + .optional() + .describe( + "Secret key name to evaluate. Required when `action` is 'key_read'.", + ), command: z .string() .optional() - .describe("Command to check (for action=exec)"), + .describe( + "Command to evaluate against the exec allowlist/denylist. Required when `action` is 'exec'.", + ), projectPath, }, async (params) => { @@ -51,7 +66,11 @@ export function registerPolicyTools(server: McpServer): void { server.tool( "get_policy_summary", - "[policy] Get a summary of the project's governance policy configuration.", + [ + "[policy] Return a high-level summary of the project's `.q-ring.json` governance policy — counts of allow/deny rules for tools, key reads, exec commands, plus approval and rotation requirements.", + "Use to orient an agent (or the user) on what guardrails are active before attempting policy-restricted actions; prefer `check_policy` for a precise per-action verdict.", + "Read-only. Returns pretty-printed JSON; missing policy file returns an empty/default summary rather than an error so callers can branch on the counts.", + ].join(" "), { projectPath, }, diff --git a/src/mcp/tools/project.ts b/src/mcp/tools/project.ts index a63ea63..b4ac0b6 100644 --- a/src/mcp/tools/project.ts +++ b/src/mcp/tools/project.ts @@ -10,7 +10,11 @@ const { teamId, orgId, scope, projectPath, env } = commonSchemas; export function registerProjectTools(server: McpServer): void { server.tool( "check_project", - "[project] Validate project secrets against the .q-ring.json manifest. Returns which required secrets are present, missing, expired, or stale. Use this to verify project readiness.", + [ + "[project] Compare the keys declared in the project's `.q-ring.json` manifest against what is actually present in the keyring.", + "Use as the canonical 'is this project ready to run' gate before starting a dev server, deploying, or onboarding a teammate; prefer `health_check` for a scope-wide decay sweep (no manifest), and `agent_scan` for multi-project scans with optional auto-rotation.", + "Read-only; does not mutate the keyring or audit log materially beyond a 'list' read. Returns JSON `{ total, present, missing, expired, stale, ready, secrets: [...] }` where `ready` is true only when nothing is missing or expired. Errors with 'No secrets manifest found in .q-ring.json' if the project has no manifest.", + ].join(" "), { projectPath, }, @@ -87,7 +91,11 @@ export function registerProjectTools(server: McpServer): void { server.tool( "env_generate", - "[project] Generate .env file content from the project manifest (.q-ring.json). Resolves each declared secret from q-ring, collapses superposition, and returns .env formatted output. Warns about missing or expired secrets.", + [ + "[project] Render a complete `.env` file body from the project's `.q-ring.json` manifest, resolving each declared key from the keyring.", + "Use when a build step or local runtime needs a real `.env` materialized on disk and you want exactly the keys the manifest declares; prefer `export_secrets` when you want every key in scope (manifest-agnostic) and `exec_with_secrets` to inject secrets into a child process without writing them to a file.", + "Reads values (records 'read' audit events) and collapses superposition for the requested env. Returns the raw `.env` text, with `# MISSING (required): KEY` / `# EXPIRED: KEY` / `# STALE: KEY` warnings appended as comments. Missing keys appear as commented-out `# KEY=` placeholders so the file remains a valid drop-in.", + ].join(" "), { projectPath, env, @@ -147,7 +155,11 @@ export function registerProjectTools(server: McpServer): void { server.tool( "detect_environment", - "[project] Detect the current environment context (wavefunction collapse). Returns the detected environment and its source (NODE_ENV, git branch, project config, etc.).", + [ + "[project] Resolve which environment slug (e.g. 'dev', 'staging', 'prod') the current invocation should collapse to.", + "Use before reading secrets when you want to mirror the same env q-ring would auto-pick (e.g. to log it, or to pass through to another tool); prefer passing an explicit `env` to `get_secret`/`env_generate` when you already know which env you want.", + "Read-only; checks the QRING_ENV env var, NODE_ENV, the project's `.q-ring.json`, and the current git branch in priority order. Returns JSON `{ env, source }` (e.g. `{ env: 'dev', source: 'NODE_ENV' }`), or a plain message indicating that no env could be detected.", + ].join(" "), { projectPath, }, @@ -174,7 +186,11 @@ export function registerProjectTools(server: McpServer): void { server.tool( "get_project_context", - "[agent] Get a safe, redacted overview of the project's secrets, environment, manifest, providers, hooks, and recent audit activity. No secret values are ever exposed. Use this to understand what secrets exist before asking to read them.", + [ + "[agent] Return a single redacted snapshot of everything an AI agent typically wants to know about this project: secrets present (keys + metadata only), detected env, manifest declarations, configured providers, registered hooks, and recent audit activity.", + "Use this as the very first call in a session to orient the agent before it asks for any individual secret; prefer `list_secrets` for a flat key listing, `check_project` for manifest-vs-keyring drift, and `audit_log` for a deeper access trail.", + "Read-only and value-safe — no plaintext secret values are ever included. Returns a single pretty-printed JSON document; shape is intentionally broad and may grow over time, so read defensively.", + ].join(" "), { scope, projectPath, diff --git a/src/mcp/tools/secrets.ts b/src/mcp/tools/secrets.ts index 7ce2e13..77ca821 100644 --- a/src/mcp/tools/secrets.ts +++ b/src/mcp/tools/secrets.ts @@ -28,9 +28,17 @@ const { teamId, orgId, scope, projectPath, env } = commonSchemas; export function registerSecretTools(server: McpServer): void { server.tool( "get_secret", - "[secrets] Retrieve a secret by key. Collapses superposition if the secret has multiple environment states. Records access in audit log (observer effect).", + [ + "[secrets] Read the plaintext value of a single secret from the q-ring keyring.", + "Use when an agent needs the actual credential to call an external API or inject into a runtime; prefer `inspect_secret` to see metadata only, `has_secret` for presence-only checks, and `exec_with_secrets` to run a command without exposing the value to chat.", + "Side effects: collapses superposition (selects the per-env state) and writes a 'read' event to the audit log (observer effect). Subject to project tool/key policy and may be denied with a 'Policy Denied' message. Returns JSON `{ ok, data: { key, value } }` on success or an error message if missing/blocked.", + ].join(" "), { - key: z.string().describe("The secret key name"), + key: z + .string() + .describe( + "Exact secret key name as stored in the keyring (case-sensitive). Example: 'OPENAI_API_KEY'.", + ), scope, projectPath, env, @@ -64,20 +72,38 @@ export function registerSecretTools(server: McpServer): void { server.tool( "list_secrets", - "[secrets] List all secret keys with quantum metadata (scope, decay status, superposition states, entanglement, access count). Values are never exposed. Supports filtering by tag, expiry state, and key pattern.", + [ + "[secrets] List secret keys and quantum metadata in the requested scope, never the values.", + "Use to discover what secrets exist before reading or writing; pair with `inspect_secret` for full metadata on one key, `analyze_secrets` for usage trends, or `health_check` for decay/anomaly summaries.", + "Read-only; safe to call repeatedly. Returns JSON `{ ok, data: { entries: [...] } }` where each entry has scope, key, stateKeys (env names if superposed), expired, stale, lifetimePercent, timeRemaining, entangledCount, accessCount.", + ].join(" "), { scope, projectPath, - tag: z.string().optional().describe("Filter by tag"), - expired: z.boolean().optional().describe("Show only expired secrets"), + tag: z + .string() + .optional() + .describe( + "Return only secrets that include this exact tag (case-sensitive). Example: 'production'.", + ), + expired: z + .boolean() + .optional() + .describe( + "If true, return only secrets whose decay TTL has elapsed (lifetimePercent >= 100).", + ), stale: z .boolean() .optional() - .describe("Show only stale secrets (75%+ decay)"), + .describe( + "If true, return only secrets in the stale window (lifetimePercent >= 75 and not yet expired).", + ), filter: z .string() .optional() - .describe("Glob pattern on key name (e.g., 'API_*')"), + .describe( + "Glob pattern matched against the key name. Supports `*` and `?`. Examples: 'API_*', 'STRIPE_?_KEY'.", + ), teamId, orgId, }, @@ -123,24 +149,48 @@ export function registerSecretTools(server: McpServer): void { server.tool( "set_secret", - "[secrets] Create or overwrite a secret value plus optional metadata (TTL/decay, per-env superposition, description, tags). Overwrites existing values for the same key/scope; records access in the audit log. Use import_dotenv for bulk .env ingest. Subject to tool policy; no external rate limits beyond provider calls when validating elsewhere.", + [ + "[secrets] Create or overwrite a single secret value, optionally with TTL/decay, per-env superposition, description, tags, and rotation hints.", + "Use to add or update one key at a time; prefer `import_dotenv` for bulk .env ingest, `generate_secret` (with saveAs) to generate-and-store in one step, and `entangle_secrets` instead of duplicating the same value under two keys.", + "Mutates the keyring (overwrites any existing value at the same key/scope), writes a 'write' event to the audit log, and triggers any matching hooks. Subject to tool policy. Returns a short confirmation text like '[scope] KEY saved' (or '[scope] KEY set for env:NAME' when `env` is provided).", + ].join(" "), { - key: z.string().describe("The secret key name"), - value: z.string().describe("The secret value"), + key: z + .string() + .describe( + "Secret key name (UPPER_SNAKE_CASE recommended). Example: 'STRIPE_SECRET_KEY'.", + ), + value: z + .string() + .describe( + "The secret value to store. Stored as-is; never logged or echoed. May be empty only when `env` is provided to register a new env without a default.", + ), scope: scope.default("global"), projectPath, env: z .string() .optional() .describe( - "If provided, sets the value for this specific environment (superposition)", + "If set, writes this value to the named per-env state (superposition) instead of the default slot. Existing default value is preserved as state 'default'. Example: 'prod'.", ), ttlSeconds: z .number() .optional() - .describe("Time-to-live in seconds (quantum decay)"), - description: z.string().optional().describe("Human-readable description"), - tags: z.array(z.string()).optional().describe("Tags for organization"), + .describe( + "Quantum decay window in seconds. After this many seconds the secret is marked expired (still readable, but `has_secret` returns false and `health_check` flags it). Omit for no decay.", + ), + description: z + .string() + .optional() + .describe( + "Free-text human-readable description shown in `inspect_secret` and the dashboard.", + ), + tags: z + .array(z.string()) + .optional() + .describe( + "Tag list for filtering and hook matching. Example: ['production', 'payments'].", + ), rotationFormat: z .enum([ "hex", @@ -152,11 +202,15 @@ export function registerSecretTools(server: McpServer): void { "password", ]) .optional() - .describe("Format for auto-rotation when this secret expires"), + .describe( + "Format used by `agent_scan --autoRotate` and `rotate_secret` when this secret expires. Pick the format that matches the upstream service's accepted shape.", + ), rotationPrefix: z .string() .optional() - .describe("Prefix for auto-rotation (e.g. 'sk-')"), + .describe( + "Literal prefix prepended on auto-rotation (only used with rotationFormat 'api-key' or 'token'). Example: 'sk-'.", + ), teamId, orgId, }, @@ -206,9 +260,15 @@ export function registerSecretTools(server: McpServer): void { server.tool( "delete_secret", - "[secrets] Permanently remove a secret value from the keyring for the given scope/path (not recoverable from q-ring). Does not remove hooks, tunnels, or entanglement metadata alone—use remove_hook, tunnel_destroy, or disentangle_secrets respectively. Returns success or not-found text; subject to tool policy.", + [ + "[secrets] Permanently remove a secret value (and all its env states) from the keyring for the given scope.", + "Use when a credential is being retired or was created in error; prefer `disentangle_secrets` to break a sync link without erasing values, `remove_hook` to detach lifecycle callbacks, and `tunnel_destroy` for ephemeral tunnels.", + "Destructive and not undoable from q-ring (no built-in trash). Writes a 'delete' event to the audit log and fires matching hooks. Returns 'Deleted \"KEY\"' on success or a not-found error if the key did not exist in the requested scope. Subject to tool policy.", + ].join(" "), { - key: z.string().describe("The secret key name"), + key: z + .string() + .describe("Exact secret key name to delete. Example: 'OLD_API_KEY'."), scope, projectPath, teamId, @@ -228,9 +288,15 @@ export function registerSecretTools(server: McpServer): void { server.tool( "has_secret", - "[secrets] Check if a secret exists. Returns boolean. Never reveals the value. Respects decay — expired secrets return false.", + [ + "[secrets] Check whether a secret exists in the requested scope without reading the value.", + "Use as a cheap precondition before reading or writing — for example, to skip prompting the user for a key that is already configured. Prefer `inspect_secret` when you also need metadata.", + "Read-only; does not record a 'read' in the audit log. Decay-aware: returns 'false' for expired secrets even though the value is still in the store. Returns the literal text 'true' or 'false'.", + ].join(" "), { - key: z.string().describe("The secret key name"), + key: z + .string() + .describe("Exact secret key name. Example: 'GITHUB_TOKEN'."), scope, projectPath, teamId, @@ -246,21 +312,31 @@ export function registerSecretTools(server: McpServer): void { server.tool( "export_secrets", - "[secrets] Export secrets as .env or JSON format. Collapses superposition. Supports filtering by specific keys or tags.", + [ + "[secrets] Render multiple secrets as a single .env or JSON document for piping into another tool or file.", + "Use to materialize secrets for a one-off export or copy; prefer `env_generate` when you want output driven by the project's `.q-ring.json` manifest, and `teleport_pack` for an encrypted bundle to share between machines.", + "Reads values (collapses superposition for the requested env) and writes one 'export' event per included secret to the audit log. Returns the rendered text directly (no JSON wrapper). Returns an error if no secrets matched the filters. Values are surfaced in plaintext — handle with care.", + ].join(" "), { format: z .enum(["env", "json"]) .optional() .default("env") - .describe("Output format"), + .describe( + "'env' renders KEY=\"value\" lines suitable for a .env file; 'json' renders an object keyed by secret name. Defaults to 'env'.", + ), keys: z .array(z.string()) .optional() - .describe("Only export these specific key names"), + .describe( + "Whitelist of exact key names to include. If omitted, every key in scope is considered (subject to `tags`).", + ), tags: z .array(z.string()) .optional() - .describe("Only export secrets with any of these tags"), + .describe( + "Include only secrets tagged with at least one of these tags. Combined with `keys` as an AND filter when both are supplied.", + ), scope, projectPath, env, @@ -285,21 +361,33 @@ export function registerSecretTools(server: McpServer): void { server.tool( "import_dotenv", - "[secrets] Import secrets from .env file content. Parses standard dotenv syntax (comments, quotes, multiline escapes) and stores each key/value pair in q-ring.", + [ + "[secrets] Parse standard dotenv-formatted text and store each key/value pair into the keyring in one batch.", + "Use when migrating an existing `.env` file into q-ring or onboarding a new project; prefer `set_secret` for a single key, and `teleport_unpack` to import an encrypted bundle.", + "Mutates the keyring (one write per parsed key) and emits a 'write' audit event for each. Supports comments, single/double quotes, and `\\n` escapes. Returns a multiline summary listing imported keys and any skipped (existing) keys; in dryRun mode no writes happen and the same summary is produced for review.", + ].join(" "), { - content: z.string().describe("The .env file content to parse and import"), + content: z + .string() + .describe( + "Raw .env file content as a single string (newline-separated KEY=VALUE lines, comments allowed).", + ), scope: scope.default("global"), projectPath, skipExisting: z .boolean() .optional() .default(false) - .describe("Skip keys that already exist in q-ring"), + .describe( + "If true, leave already-present keys untouched and add them to the 'skipped' list instead of overwriting.", + ), dryRun: z .boolean() .optional() .default(false) - .describe("Preview what would be imported without saving"), + .describe( + "If true, parse and report what would happen but do not write to the keyring. Useful for previewing imports before committing.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("import_dotenv", params.projectPath); @@ -332,9 +420,15 @@ export function registerSecretTools(server: McpServer): void { server.tool( "inspect_secret", - "[secrets] Show full quantum state of a secret: superposition states, decay status, entanglement links, access history. Never reveals the actual value.", + [ + "[secrets] Show full metadata for a single secret — env states, decay window, entanglement links, access counters — without ever revealing the value.", + "Use when you need to understand the shape of a key before reading it or to debug 'why is this expired/stale'; prefer `get_secret` for the actual value, `list_secrets` for a many-key overview, and `audit_log` for the full access timeline.", + "Read-only; does not write a 'read' event since the value is not exposed. Returns pretty-printed JSON with fields: key, scope, type ('superposition'|'collapsed'), created, updated, accessCount, lastAccessed, environments, defaultEnv, decay { expired, stale, lifetimePercent, timeRemaining }, entangled, description, tags. Errors with not-found if the key is absent.", + ].join(" "), { - key: z.string().describe("The secret key name"), + key: z + .string() + .describe("Exact secret key name to inspect. Example: 'OPENAI_API_KEY'."), scope, projectPath, teamId, @@ -388,7 +482,11 @@ export function registerSecretTools(server: McpServer): void { server.tool( "generate_secret", - "[secrets] Generate a cryptographic secret (quantum noise). Formats: hex, base64, alphanumeric, uuid, api-key, token, password. Optionally save directly to the keyring.", + [ + "[secrets] Generate a cryptographically random secret using Node's CSPRNG and optionally store it in the keyring in one step.", + "Use to create new credentials that you control (signing keys, internal tokens, passwords); for issuer-issued credentials (Stripe/OpenAI etc.) use `rotate_secret` to ask the upstream provider for a fresh key, and use `set_secret` for values you already have in hand.", + "If `saveAs` is provided this mutates the keyring (one 'write' event) and returns a summary like 'Generated and saved as \"KEY\" (FORMAT, ~N bits entropy)'. Without `saveAs` the call is read-only and returns JSON `{ ok, data: { value } }` containing the freshly generated string.", + ].join(" "), { format: z .enum([ @@ -402,13 +500,27 @@ export function registerSecretTools(server: McpServer): void { ]) .optional() .default("api-key") - .describe("Output format"), - length: z.number().optional().describe("Length in bytes or characters"), - prefix: z.string().optional().describe("Prefix for api-key/token format"), + .describe( + "Output shape. 'hex' / 'base64' / 'alphanumeric' = raw random string of `length` characters; 'uuid' = RFC4122 v4; 'api-key' / 'token' = random alphanumeric with optional `prefix`; 'password' = mixed-case alphanumeric with symbols. Defaults to 'api-key'.", + ), + length: z + .number() + .optional() + .describe( + "Number of characters (or bytes for hex/base64) to generate. Ignored for 'uuid'. Defaults to a sensible per-format value (e.g. 32 for api-key).", + ), + prefix: z + .string() + .optional() + .describe( + "Literal prefix prepended to the random portion. Only meaningful for 'api-key' and 'token'. Example: 'sk-' or 'svc_'.", + ), saveAs: z .string() .optional() - .describe("If provided, save the generated secret with this key name"), + .describe( + "If provided, store the generated value at this key name in the keyring (one mutation). Omit to just return the value without persisting.", + ), scope: scope.default("global"), projectPath, teamId, @@ -441,14 +553,32 @@ export function registerSecretTools(server: McpServer): void { server.tool( "entangle_secrets", - "[secrets] Link two keys so source updates/rotations propagate the same value to the target (mutates metadata; future writes sync). Reverse with disentangle_secrets without deleting values; do not confuse with set_secret (single-key write). Subject to tool policy.", + [ + "[secrets] Link two keys (across the same or different scopes) so future writes/rotations of either propagate the same value to the other.", + "Use when one logical credential lives under multiple names (e.g. `STRIPE_SECRET_KEY` global and project) and should never drift; prefer `set_secret` for unrelated values, and reverse the link with `disentangle_secrets` (does not delete values).", + "Mutates only the metadata of both envelopes — the values themselves are not changed by this call. Idempotent: re-running on an already-entangled pair is a no-op. Subject to tool policy. Returns a short confirmation: 'Entangled: SOURCE <-> TARGET'.", + ].join(" "), { - sourceKey: z.string().describe("Source secret key"), - targetKey: z.string().describe("Target secret key"), + sourceKey: z + .string() + .describe("First secret key in the pair. Example: 'STRIPE_SECRET_KEY'."), + targetKey: z + .string() + .describe("Second secret key to keep in lockstep with the source."), sourceScope: scope.default("global"), targetScope: scope.default("global"), - sourceProjectPath: z.string().optional(), - targetProjectPath: z.string().optional(), + sourceProjectPath: z + .string() + .optional() + .describe( + "Project root for sourceKey when sourceScope='project'. Defaults to the server cwd.", + ), + targetProjectPath: z + .string() + .optional() + .describe( + "Project root for targetKey when targetScope='project'. Defaults to the server cwd.", + ), }, async (params) => { const toolBlock = enforceToolPolicy( @@ -478,14 +608,24 @@ export function registerSecretTools(server: McpServer): void { server.tool( "disentangle_secrets", - "[secrets] Remove the sync link between two keys so rotations stop propagating. Does not delete either secret—use delete_secret to erase values. Contrast entangle_secrets (creates link). Safe if the link was already absent; updates metadata; subject to tool policy.", + [ + "[secrets] Break the sync link between two previously entangled keys so future rotations no longer propagate.", + "Use when one of the keys is being retired or should diverge intentionally; pair with `delete_secret` if you also want to erase one of the values, and use `entangle_secrets` to recreate the link.", + "Mutates only metadata; the current values remain untouched. Safe and idempotent — running on a pair that was never linked returns success without effect. Subject to tool policy. Returns 'Disentangled: SOURCE TARGET'.", + ].join(" "), { - sourceKey: z.string().describe("Source secret key"), - targetKey: z.string().describe("Target secret key"), + sourceKey: z.string().describe("First key in the previously linked pair."), + targetKey: z.string().describe("Second key in the previously linked pair."), sourceScope: scope.default("global"), targetScope: scope.default("global"), - sourceProjectPath: z.string().optional(), - targetProjectPath: z.string().optional(), + sourceProjectPath: z + .string() + .optional() + .describe("Project root for sourceKey when sourceScope='project'."), + targetProjectPath: z + .string() + .optional() + .describe("Project root for targetKey when targetScope='project'."), }, async (params) => { const toolBlock = enforceToolPolicy( diff --git a/src/mcp/tools/teleport.ts b/src/mcp/tools/teleport.ts index c2d80fc..ca7c78a 100644 --- a/src/mcp/tools/teleport.ts +++ b/src/mcp/tools/teleport.ts @@ -13,13 +13,23 @@ const { teamId, orgId, scope, projectPath } = commonSchemas; export function registerTeleportTools(server: McpServer): void { server.tool( "teleport_pack", - "[teleport] Pack secrets into an AES-256-GCM encrypted bundle for sharing between machines (quantum teleportation).", + [ + "[teleport] Encrypt one or more secrets into a single AES-256-GCM bundle string that can be safely transferred between machines.", + "Use to hand off a curated set of credentials to another developer or environment; prefer `export_secrets` for plaintext .env output (single machine, trusted) and `tunnel_create` for ephemeral one-shot delivery on the same machine.", + "Reads each secret value (records 'export' audit events) and produces a base64-encoded ciphertext. The bundle is unreadable without the same passphrase via `teleport_unpack`. Returns the bundle string directly. Errors with 'No secrets to pack' if the filter matched zero secrets.", + ].join(" "), { keys: z .array(z.string()) .optional() - .describe("Specific keys to pack (all if omitted)"), - passphrase: z.string().describe("Encryption passphrase"), + .describe( + "Whitelist of exact key names to include. Omit to pack every secret in the requested scope.", + ), + passphrase: z + .string() + .describe( + "Symmetric passphrase used to derive the AES-256-GCM key. The receiver must supply the same string to `teleport_unpack`. Pick something high-entropy and share it out-of-band.", + ), scope, projectPath, teamId, @@ -50,10 +60,22 @@ export function registerTeleportTools(server: McpServer): void { server.tool( "teleport_unpack", - "[teleport] Decrypt and import secrets from a teleport bundle.", + [ + "[teleport] Decrypt a bundle produced by `teleport_pack` and import each contained secret into the local keyring.", + "Use on the receiving machine after a packer hands you the bundle and passphrase out-of-band; prefer `dryRun=true` first to preview what will be written.", + "When dryRun is false this mutates the keyring (one 'write' event per imported secret) at the requested scope. Bad passphrase or tampered bundle returns JSON `{ ok: false, error: { message } }` with `isError: true`. On success returns 'Imported N secret(s) from teleport bundle'; in dryRun mode returns 'Would import N secrets:' followed by a `KEY [scope]` listing.", + ].join(" "), { - bundle: z.string().describe("Base64-encoded encrypted bundle"), - passphrase: z.string().describe("Decryption passphrase"), + bundle: z + .string() + .describe( + "Base64-encoded ciphertext returned by `teleport_pack`. Pass through whitespace untouched if possible.", + ), + passphrase: z + .string() + .describe( + "The same passphrase that was used to pack this bundle. Bad passphrases return an authentication error rather than wrong plaintext.", + ), scope: scope.default("global"), projectPath, teamId, @@ -62,7 +84,9 @@ export function registerTeleportTools(server: McpServer): void { .boolean() .optional() .default(false) - .describe("Preview without importing"), + .describe( + "If true, decrypt and report what would be written but do not mutate the keyring. Useful for verifying bundle contents before commit.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("teleport_unpack", params.projectPath); diff --git a/src/mcp/tools/tooling.ts b/src/mcp/tools/tooling.ts index 903bcf7..cf55357 100644 --- a/src/mcp/tools/tooling.ts +++ b/src/mcp/tools/tooling.ts @@ -14,23 +14,42 @@ const { teamId, orgId, scope, projectPath } = commonSchemas; export function registerToolingTools(server: McpServer): void { server.tool( "exec_with_secrets", - "[exec] Run a shell command securely. Project secrets are injected into the environment, and any secret values in the output are automatically redacted to prevent leaking into transcripts.", + [ + "[exec] Run a child shell command with project secrets injected as environment variables and any leaked secret values redacted from captured stdout/stderr before they return to the agent.", + "Use to let an agent run a script that needs credentials (`npm run db:migrate`, `terraform plan`, `vercel deploy`) without ever putting plaintext values in the chat; prefer `env_generate` if you need to write a `.env` file to disk and `validate_secret` for upstream liveness checks.", + "Spawns a real child process — has whatever side effects the command itself causes (writes, network, exec). Subject to BOTH tool policy and exec policy (allowlist/denylist). Returns a text body with `Exit code: N` then `STDOUT:` and `STDERR:` blocks; both streams are scrubbed against the secret values that were injected.", + ].join(" "), { - command: z.string().describe("Command to run"), - args: z.array(z.string()).optional().describe("Command arguments"), + command: z + .string() + .describe( + "Executable name or full command to run. Example: 'pnpm', 'node', '/usr/bin/env'. Must be allowed by exec policy.", + ), + args: z + .array(z.string()) + .optional() + .describe( + "Positional arguments passed to `command`. Example: ['run', 'db:migrate']. Each element is passed verbatim with no extra shell parsing.", + ), keys: z .array(z.string()) .optional() - .describe("Only inject these specific keys"), + .describe( + "Whitelist of exact key names to inject. Omit to inject every secret in scope (subject to `tags`).", + ), tags: z .array(z.string()) .optional() - .describe("Only inject secrets with these tags"), + .describe( + "Inject only secrets carrying at least one of these tags. Combinable with `keys` as an AND filter.", + ), profile: z .enum(["unrestricted", "restricted", "ci"]) .optional() .default("restricted") - .describe("Exec profile: unrestricted, restricted, or ci"), + .describe( + "Exec sandbox profile. 'restricted' (default) limits PATH and inheritable env vars; 'ci' is restricted plus CI-friendly defaults (no TTY); 'unrestricted' inherits the full server environment — only pick this when you understand the leak risk.", + ), scope, projectPath, teamId, @@ -75,11 +94,17 @@ export function registerToolingTools(server: McpServer): void { server.tool( "scan_codebase_for_secrets", - "[scan] Scan a directory for hardcoded secrets using regex heuristics and Shannon entropy analysis. Returns file paths, line numbers, and the matched key/value to help migrate legacy codebases into q-ring.", + [ + "[scan] Walk a directory tree and flag plausible hardcoded secrets using regex heuristics plus Shannon-entropy scoring on string literals.", + "Use as a one-shot 'is anything leaking in this repo?' audit before commit/release; prefer `lint_files` when you already know the specific files to check (and want optional auto-fix).", + "Read-only — never modifies source files. Honors `.gitignore`. Returns JSON array of `{ file, line, key, value, kind }` findings, or 'No hardcoded secrets found in the specified directory.' when clean. False positives are possible — review before treating as ground truth.", + ].join(" "), { dirPath: z .string() - .describe("Absolute or relative path to the directory to scan"), + .describe( + "Directory to scan, absolute or relative to the server cwd. The scan recurses into subdirectories.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("scan_codebase_for_secrets"); @@ -102,14 +127,24 @@ export function registerToolingTools(server: McpServer): void { server.tool( "lint_files", - "[scan] Scan specific files for hardcoded secrets. Optionally auto-fix by replacing them with process.env references and storing the values in q-ring.", + [ + "[scan] Inspect a specific list of files for hardcoded secrets and, when `fix` is true, replace each finding with `process.env.KEY` while storing the extracted value into the keyring.", + "Use to migrate a known set of files (e.g. just-changed files in a pre-commit hook) into q-ring; prefer `scan_codebase_for_secrets` for a whole-tree audit and `import_dotenv` to ingest an existing .env.", + "With `fix: false` this is read-only. With `fix: true` this MUTATES the listed source files in place (review with git diff!) and writes one new secret per finding to the keyring. Returns a JSON array of `{ file, line, key, value, kind }` findings, or 'No hardcoded secrets found in the specified files.'.", + ].join(" "), { - files: z.array(z.string()).describe("File paths to lint"), + files: z + .array(z.string()) + .describe( + "Absolute or relative paths to lint. Non-existent paths surface as scan errors.", + ), fix: z .boolean() .optional() .default(false) - .describe("Auto-replace and store secrets"), + .describe( + "If true, rewrite the source files to read `process.env.KEY` and store the extracted value in the keyring. If false (default), only report findings.", + ), scope, projectPath, teamId, @@ -140,7 +175,11 @@ export function registerToolingTools(server: McpServer): void { server.tool( "analyze_secrets", - "[agent] Analyze secret usage patterns and provide optimization suggestions including most accessed, stale, unused, and rotation recommendations.", + [ + "[agent] Cross-reference the secrets in scope with recent audit events to produce a usage profile and rotation/retirement suggestions.", + "Use as a quarterly hygiene check or as input to a planner that decides what to rotate or delete; prefer `health_check` for decay-only triage and `audit_log` to inspect access timelines for one key.", + "Read-only; uses the most recent ~500 audit events. Returns JSON `{ total, expired, stale, neverAccessed: [...], noRotationFormat: [...], mostAccessed: [{ key, reads }] }`. `neverAccessed` and `noRotationFormat` are good candidates for cleanup or for adding rotation hints.", + ].join(" "), { scope, projectPath, @@ -189,9 +228,19 @@ export function registerToolingTools(server: McpServer): void { server.tool( "status_dashboard", - "[dashboard] Launch the quantum status dashboard — a local SSE-driven web page showing live KPIs (secrets, env, protected, approvals, hooks, 24h reads, anomalies), health summary, environment, .q-ring.json manifest gaps, governance policy summary, sortable searchable secrets table, decay/superposition/entanglement/tunnel cards, active approvals & hooks, agent memory, anomaly alerts, and a filterable 24h audit feed. Returns the URL to open in a browser. Never exposes secret values.", + [ + "[dashboard] Start a local web dashboard (`http://127.0.0.1:PORT`) that streams live KPIs, secret tables, manifest gaps, hooks, audit events, and anomalies via Server-Sent Events.", + "Use when an operator (or an agent on behalf of one) wants a richer visual surface than chat output; prefer `health_check` / `analyze_secrets` for one-shot text summaries inside the conversation.", + "Side effect: binds an HTTP server on the requested port (one process-wide instance — re-running returns the existing URL instead of starting a second server). Never exposes secret values. Returns the URL string to open in a browser.", + ].join(" "), { - port: z.number().optional().default(9876).describe("Port to serve on"), + port: z + .number() + .optional() + .default(9876) + .describe( + "TCP port to listen on (default 9876). Pick another port if 9876 is already in use; the call fails if binding errors.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("status_dashboard"); @@ -214,17 +263,25 @@ export function registerToolingTools(server: McpServer): void { server.tool( "agent_scan", - "[agent] Multi-project health pass: decay, staleness, audit anomalies, manifest gaps; returns JSON. Prefer health_check for a read-only scoped decay/anomaly text summary (no writes). Prefer detect_anomalies for audit-pattern spikes on one key. With autoRotate=true, overwrites expired secret values in the keyring (credential change—not undoable); leave false unless intentional rotation. Same policy gates as other MCP tools; no separate external auth.", + [ + "[agent] Run a multi-project health pass that gathers decay status, audit anomalies, and `.q-ring.json` manifest gaps across one or more project paths and (optionally) auto-rotates expired secrets with freshly generated values.", + "Use as the canonical 'agent maintenance loop' across a portfolio of repos; prefer `health_check` for a single read-only scope, `detect_anomalies` for audit-only triage, and `check_project` for a single-project manifest check.", + "With `autoRotate=false` (default) this is read-only. With `autoRotate=true` it OVERWRITES expired secret values in the keyring with generated replacements — credential changes that may break upstream integrations until they are propagated. Subject to tool policy. Returns a JSON report of per-project findings and any rotations performed.", + ].join(" "), { autoRotate: z .boolean() .optional() .default(false) - .describe("Auto-rotate expired secrets with generated values"), + .describe( + "If true, replace expired secrets with newly generated values (using each secret's `rotationFormat`/`rotationPrefix`). Only enable when intentional rotation is desired — this is destructive on the upstream side.", + ), projectPaths: z .array(z.string()) .optional() - .describe("Project paths to monitor"), + .describe( + "List of absolute project roots to scan. Defaults to `[server.cwd]` when omitted.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("agent_scan"); diff --git a/src/mcp/tools/tunnel.ts b/src/mcp/tools/tunnel.ts index eb9fba1..ed88461 100644 --- a/src/mcp/tools/tunnel.ts +++ b/src/mcp/tools/tunnel.ts @@ -11,11 +11,29 @@ import { text, enforceToolPolicy } from "./_shared.js"; export function registerTunnelTools(server: McpServer): void { server.tool( "tunnel_create", - "[tunnel] Create an ephemeral secret that exists only in memory (quantum tunneling). Never persisted to disk. Optional TTL and max-reads for self-destruction.", + [ + "[tunnel] Stash a one-shot or short-lived secret in the q-ring server's process memory and return an ID that can be used to read it back.", + "Use for handing a one-time value to another tool/process without persisting it (npm OTP codes, magic-link tokens, copy/paste between machines via a relay); prefer `set_secret` with `ttlSeconds` when you actually want a tracked, auditable secret.", + "Mutates only in-memory state — the value never touches disk and is lost on server restart. Subject to tool policy. Returns JSON `{ ok, data: { id } }` where `id` is an opaque string to pass to `tunnel_read`/`tunnel_destroy`.", + ].join(" "), { - value: z.string().describe("The secret value"), - ttlSeconds: z.number().optional().describe("Auto-expire after N seconds"), - maxReads: z.number().optional().describe("Self-destruct after N reads"), + value: z + .string() + .describe( + "The plaintext value to tunnel. Held only in process memory; never logged.", + ), + ttlSeconds: z + .number() + .optional() + .describe( + "Auto-destroy the tunnel after this many seconds. Omit for no time limit (then a `maxReads` is highly recommended).", + ), + maxReads: z + .number() + .optional() + .describe( + "Self-destruct after this many successful `tunnel_read` calls. Use 1 for true one-shot delivery.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("tunnel_create"); @@ -31,9 +49,17 @@ export function registerTunnelTools(server: McpServer): void { server.tool( "tunnel_read", - "[tunnel] Read an ephemeral tunneled secret by ID. May self-destruct if max-reads is reached.", + [ + "[tunnel] Fetch the value stashed by a prior `tunnel_create` call by its ID.", + "Use exactly once per intended consumer; the value is destructive-by-design and may self-delete after this call.", + "Increments the read counter and may auto-destroy the tunnel if `maxReads` was set. Returns JSON `{ ok, data: { id, value } }` on success, or an error 'Tunnel \"...\" not found or expired' if the tunnel has been destroyed, hit its TTL, or never existed.", + ].join(" "), { - id: z.string().describe("Tunnel ID"), + id: z + .string() + .describe( + "The opaque tunnel ID returned by `tunnel_create`. Case-sensitive.", + ), }, async (params) => { const toolBlock = enforceToolPolicy("tunnel_read"); @@ -51,7 +77,11 @@ export function registerTunnelTools(server: McpServer): void { server.tool( "tunnel_list", - "[tunnel] List active tunneled secrets (IDs and metadata only, never values).", + [ + "[tunnel] Enumerate all currently-active tunnels in the q-ring server with their remaining read budget and time-to-live.", + "Use to audit what is still in memory or to look up an ID you forgot; values are never included in the output.", + "Read-only. Returns one line per tunnel formatted as `id | reads:N | max:N | expires:Ns`, or the literal text 'No active tunnels' when the list is empty.", + ].join(" "), {}, async () => { const toolBlock = enforceToolPolicy("tunnel_list"); @@ -80,9 +110,13 @@ export function registerTunnelTools(server: McpServer): void { server.tool( "tunnel_destroy", - "[tunnel] Immediately destroy a tunneled secret.", + [ + "[tunnel] Immediately remove a tunnel from memory, regardless of remaining reads or TTL.", + "Use when a tunneled value should be cancelled before delivery (e.g. wrong recipient, secret already rotated); prefer letting `maxReads`/TTL handle cleanup for normal flows.", + "Mutates in-memory state only. Returns 'Destroyed ID' on success or a not-found error if the ID is unknown or already gone.", + ].join(" "), { - id: z.string().describe("Tunnel ID"), + id: z.string().describe("The opaque tunnel ID to destroy."), }, async (params) => { const toolBlock = enforceToolPolicy("tunnel_destroy"); diff --git a/src/mcp/tools/validation.ts b/src/mcp/tools/validation.ts index 2e2328e..2de5911 100644 --- a/src/mcp/tools/validation.ts +++ b/src/mcp/tools/validation.ts @@ -20,13 +20,23 @@ const { teamId, orgId, scope, projectPath } = commonSchemas; export function registerValidationTools(server: McpServer): void { server.tool( "validate_secret", - "[validation] Test if a secret is actually valid with its target service (e.g., OpenAI, Stripe, GitHub). Uses provider auto-detection based on key prefixes, or accepts an explicit provider name. Never logs the secret value.", + [ + "[validation] Test whether a stored secret is still accepted by its upstream service (OpenAI, Stripe, GitHub, AWS, generic HTTP, etc.) by making a minimal authenticated request.", + "Use to confirm liveness before relying on a credential or as the verification step after `rotate_secret`; prefer `ci_validate_secrets` for a batch run across every key in scope.", + "Side effects: makes one outbound network request per call (may incur tiny provider-side rate-limit cost). Records 'read' for the underlying secret value in the audit log; the value itself is never logged. Returns JSON `{ valid, provider, status?, message?, rateLimit?, ... }` (provider-specific shape).", + ].join(" "), { - key: z.string().describe("The secret key name"), + key: z + .string() + .describe( + "The exact key whose value should be tested upstream. Example: 'OPENAI_API_KEY'.", + ), provider: z .string() .optional() - .describe("Force a specific provider (openai, stripe, github, aws, http)"), + .describe( + "Force a specific provider id. Built-ins include 'openai', 'stripe', 'github', 'aws', 'http'. Omit to auto-detect from the value's prefix or the secret's stored provider hint.", + ), scope, projectPath, teamId, @@ -49,7 +59,11 @@ export function registerValidationTools(server: McpServer): void { server.tool( "list_providers", - "[validation] List all available validation providers for secret liveness testing.", + [ + "[validation] Enumerate the secret-validation providers q-ring knows how to call (OpenAI, Stripe, GitHub, …) along with their auto-detect prefixes.", + "Use to discover what `provider` string to pass to `validate_secret`/`rotate_secret`, or to check whether your custom provider is registered.", + "Read-only. Returns JSON array of `{ name, description, prefixes }` objects. `prefixes` are the literal key-value prefixes (e.g. 'sk-' for OpenAI) used for auto-detection.", + ].join(" "), {}, async () => { const toolBlock = enforceToolPolicy("list_providers"); @@ -66,10 +80,21 @@ export function registerValidationTools(server: McpServer): void { server.tool( "rotate_secret", - "[validation] Attempt issuer-native rotation of a secret via its detected or specified provider. Returns rotation result.", + [ + "[validation] Ask the upstream provider to issue a fresh credential for this secret and store the new value back into the keyring.", + "Use when a secret is expiring, leaked, or part of a scheduled rotation; prefer `generate_secret` for self-managed values you fully control, and `agent_scan --autoRotate` for sweep-style rotation across multiple expired keys.", + "Mutates the keyring with the newly-issued value if rotation succeeds (one 'write' audit event), and makes outbound network requests against the provider's rotation API. Returns JSON `{ rotated, newValue?, message?, ... }`. If `rotated` is false, the existing value is left untouched.", + ].join(" "), { - key: z.string().describe("The secret key to rotate"), - provider: z.string().optional().describe("Force a specific provider"), + key: z + .string() + .describe("Exact key to rotate. Must already exist in the keyring."), + provider: z + .string() + .optional() + .describe( + "Force a specific provider id (see `list_providers`). Omit to auto-detect from the current value or the secret's stored provider hint.", + ), scope, projectPath, teamId, @@ -96,7 +121,11 @@ export function registerValidationTools(server: McpServer): void { server.tool( "ci_validate_secrets", - "[validation] CI-oriented batch validation: validates all accessible secrets against their providers and returns a structured pass/fail report.", + [ + "[validation] Validate every accessible secret in the requested scope against its detected provider in a single batch and return a structured pass/fail report.", + "Use as a CI gate ('do all our credentials still work before deploy?') or as a pre-rotation health pass; prefer `validate_secret` for a single key.", + "Side effects: one outbound request per validatable secret (cost scales with N). Reads each secret value (records 'read' audit events). Returns JSON `{ total, valid, invalid, results: [...] }` listing per-key status, provider, and error messages where applicable. Returns 'No secrets to validate' if nothing in scope has a provider mapping.", + ].join(" "), { scope, projectPath,