diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d1dbdc4..33fe7043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. The format ### Added -- [#204](https://github.com/mohanagy/miftah/issues/204) Added guided multi-account Google Search Console onboarding through the CLI and local Console, including an explicit opt-in first-success check that resolves only the selected target, verifies audit/policy/identity boundaries before its one declared read-only probe, and cancels with the requesting Console session. The check trusts only the catalog's reviewed launch envelope and fails closed without launching a process after execution-affecting customization. It creates named profiles with an explicit durable default and separate upstream-owned OAuth state directories per generated configuration file and profile, while continuing to keep browser login, token-cache contents, reauthentication, revocation, and account identity verification outside Miftah. +- [#204](https://github.com/mohanagy/miftah/issues/204) Added guided multi-account Google Search Console onboarding through the CLI and local Console, including an explicit opt-in first-success check that resolves only the selected target, verifies audit/policy/identity boundaries before its one declared read-only probe, and cancels with the requesting Console session. The check trusts only the catalog's reviewed launch envelope and fails closed without launching a process after execution-affecting customization. It creates named profiles with an explicit durable default and separate upstream-owned OAuth state directories per generated configuration file and profile, while continuing to keep browser login, token-cache contents, reauthentication, revocation, and account identity verification outside Miftah. It also adds explicitly acknowledged local STDIO setup and client-entry import: literal executable plus bounded argument array, no shell, no secret-shaped values, no generic-command launch during onboarding, read-only default policy, destructive handling for unknown tools, and Windows-only direct absolute `.exe`/`.com` requirements to avoid a command-shell fallback. ### Changed diff --git a/README.md b/README.md index a8e615fa..26a8f5c6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ Start with the row that describes how your upstream MCP authenticates. | Your upstream MCP | Use this Miftah path | | --- | --- | | GitHub or Sentry | Run guided setup with `miftah setup`, or generate a strict built-in preset with `miftah init`. | -| Another exact-pinned local STDIO server | Use `generic-npx` or `generic-docker`, then add profiles around it. | +| Another exact-pinned npm or container STDIO server | Use `generic-npx` or `generic-docker`, then add profiles around it. | +| A local executable and argument array you personally reviewed | Use `local-stdio`; it saves literal command and argument-array metadata without a shell. | | Remote HTTPS Streamable HTTP with a token or API key | Use the `streamable-http` preset with a secret-backed header. | | Remote HTTPS Streamable HTTP with standards-compatible OAuth | Use Native remote OAuth through `miftah dashboard` or the `connection` and `auth` CLI commands. | | Local or provider-specific MCP that opens its own OAuth flow | Use Upstream-owned OAuth. Miftah wraps the process but does not take over its token cache. | @@ -60,7 +61,7 @@ Shell examples below use POSIX syntax, including `~`, `$HOME`, and `\` line cont miftah setup ``` -This walks through a configuration name, a reviewed connector preset, the safe metadata that preset needs, an output location, and an optional client JSON snippet. It never asks for a token, password, or browser cookie. Miftah validates the complete configuration before it writes an owner-restricted file, never overwrites an existing one, and never edits a Claude, Cursor, VS Code, or other MCP client file. Recognized adapters can then offer one explicit, provider-declared read-only readiness check; Miftah never guesses a tool or auto-approves a policy prompt. Use `miftah init` when you want the same catalog in a scripted command. +This walks through a configuration name, a reviewed connector preset, the safe metadata that preset needs, an output location, and an optional client JSON snippet. It never asks for a token, password, or browser cookie. For `local-stdio`, it collects one literal argument at a time, shows a no-secret review summary, and requires an explicit acknowledgement. Miftah does not run the local executable during setup. Miftah validates the complete configuration before it writes an owner-restricted file, never overwrites an existing one, and never edits a Claude, Cursor, VS Code, or other MCP client file. Recognized adapters can then offer one explicit, provider-declared read-only readiness check; Miftah never guesses a tool or auto-approves a policy prompt. Use `miftah init` when you want the same catalog in a scripted command. ## First setup: GitHub with Claude Desktop @@ -175,6 +176,7 @@ Built-in presets are reviewed, exact-pinned configurations. Generic compatibilit | `generic` | MCP reference/test server, not a production provider recommendation | | `generic-npx` | Another exact-version npm MCP package | | `generic-docker` | Another container pinned by canonical `@sha256:` digest | +| `local-stdio` | A reviewed local executable with one literal argument per array element | | `streamable-http` | An exact HTTPS Streamable HTTP upstream | ### Minimal exact-pinned npm MCP template @@ -192,7 +194,28 @@ miftah init analytics \ Replace the example package, version, and variable name with the upstream's documented values. An exact version is required. Run `validate`, `doctor`, `test-profile`, and `list-tools` before adding the printed client JSON. -If the upstream needs custom arguments, headers, working directories, several named upstreams, or profile-specific overrides, generate the nearest safe preset and then use the [Configuration reference](docs/config.md). Always keep subprocess arguments as arrays; Miftah does not need a shell command string. +### Reviewed local executable template + +Use this when the upstream documentation gives you a direct local STDIO executable rather than an exact npm package or digest-pinned container. Each argument is supplied separately—never as one shell command string: + +```bash +miftah init local-tools \ + --preset local-stdio \ + --local-command node \ + --arg server.mjs \ + --arg=--stdio \ + --cwd "$HOME/development/local-tools" \ + --credential-env SERVICE_API_KEY \ + --accept-local-command \ + --output ~/.config/miftah/local-tools.json \ + --client claude-desktop +``` + +On macOS and Linux, `--local-command` accepts a bare executable such as `node` or a native absolute executable path. On Windows, provide an absolute `.exe` or `.com` binary—not a bare command or a `.cmd`/`.bat` shim—so direct argument-array execution never falls back to `cmd.exe`. Every `--arg` becomes one literal argv element; use `--arg=--flag` when an argument itself begins with `--`. Miftah never invokes a shell or expands `$`, `${...}`, or `~` in these values. Keep credentials out of the command and arguments: use `--credential-env` to write only a `${ENV_NAME}` reference into the profile. + +`--accept-local-command` is your acknowledgement that you reviewed the executable and every argument. The generated profile starts with a read-only policy and treats unknown upstream tools as destructive until you add an intentional policy. `init` and `setup` save the configuration only; they do not launch or probe a generic local executable. After you review the generated config, run the diagnostics you choose before adding its printed client JSON. + +For one reviewed local executable, use `local-stdio` for arguments and a working directory. For several named upstreams, profile-specific overrides, or other advanced topology, use the [Configuration reference](docs/config.md). Always keep subprocess arguments as arrays; Miftah does not need a shell command string. ## Reuse one existing local stdio MCP entry @@ -208,6 +231,8 @@ miftah setup posthog-work \ `--import-file` must be an absolute regular JSON file, and `--import-entry` is the exact entry name under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code). Miftah reads that one file through a bounded verified handle and leaves it byte-for-byte untouched. It imports only a finite **static launch grammar**: a literal local executable, an optional absolute working directory, and either an exact-version package-runner launch with a runner-specific safe prefix and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags. +On Windows, the import path accepts only a direct absolute `.exe` or `.com` executable. Bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. + The importer rejects remote transports, `env`, headers, shell settings, unsupported fields, environment wrappers, inline code, opaque values or assignments, URL userinfo, unpinned package references, and credential-shaped arguments. It creates one `default` profile with a read-only policy and treats unknown tools as destructive until you deliberately configure a policy. It does not launch the imported executable or copy credentials; `--verify` is rejected because an imported entry has no reviewed provider adapter. It does not infer OAuth ownership from a command or URL. If the entry needs custom values, OAuth, or an API key, use advanced manual setup, then configure the upstream's documented flow and Miftah secret references separately. For a browser-first first run, `miftah dashboard` offers the same paste-only import path. The Console parses the pasted JSON only for that local request, does not persist or return it, and clears it from the page afterwards. diff --git a/docs/cli.md b/docs/cli.md index 84fc0f80..6a52d311 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,8 +24,8 @@ Documented command names, options, JSON success forms, and exit categories are c | `miftah validate --config ` | `--config` | `--config ` | Validates the JSON configuration without starting an upstream. Writes a JSON object with `ok`, `name`, and `profiles`. | | `miftah doctor --config ` | `--config` | `--config `, `--json` | Validates configuration and checks upstream readiness. Default output is a human-readable report; `--json` writes only the JSON report. A healthy or degraded report exits `0`; a failed report exits `1`. | | `miftah schema` | none | none | Writes the Miftah JSON Schema as pretty-printed JSON. | -| `miftah setup [name]` | none | The safe preset options accepted by `init`, except `--interactive`, plus `--verify`, or `--import-file ` with `--import-entry ` | Opens the guided first-configuration flow. It asks for no secret value, validates the complete candidate, creates the output exclusively with owner-restricted permissions, and can print client JSON for manual review. For Google Search Console it can collect one or more named accounts and an explicit default. `--verify` opts into one provider-declared read-only check for every created reviewed adapter profile after the write; it is rejected for a client-entry import because no reviewed adapter is inferred. The explicit import path accepts only one selected local stdio entry and never edits a client configuration file. | -| `miftah init [name]` | none | `--name `, `--preset `, `--output `, `--interactive`, `--client `, `--credential-env `, `--npm-package `, `--docker-image `, `--url `, `--header-name `, `--header-prefix `, `--oauth-client-secrets-file ` | Writes a strict catalog configuration with exclusive creation and can print client JSON snippets. The positional `name` and `--name` are alternatives; the default name is `miftah-wrapper`. | +| `miftah setup [name]` | none | The safe preset options accepted by `init`, except `--interactive`, plus `--verify`, or `--import-file ` with `--import-entry ` | Opens the guided first-configuration flow. It asks for no secret value, validates the complete candidate, creates the output exclusively with owner-restricted permissions, and can print client JSON for manual review. For `local-stdio`, it collects literal argv elements, shows a no-secret review summary, and requires acknowledgement before saving; it never starts that generic local executable. For Google Search Console it can collect one or more named accounts and an explicit default. `--verify` opts into one provider-declared read-only check for every created reviewed adapter profile after the write; it is rejected for a client-entry import because no reviewed adapter is inferred. The explicit import path accepts only one selected local stdio entry and never edits a client configuration file. | +| `miftah init [name]` | none | `--name `, `--preset `, `--output `, `--interactive`, `--client `, `--credential-env `, `--npm-package `, `--docker-image `, `--url `, `--header-name `, `--header-prefix `, `--oauth-client-secrets-file `, `--local-command `, repeated `--arg `, `--cwd `, `--accept-local-command` | Writes a strict catalog configuration with exclusive creation and can print client JSON snippets. The positional `name` and `--name` are alternatives; the default name is `miftah-wrapper`. | | `miftah migrate-config --config ` | `--config` | `--config `, `--write` | Plans a supported configuration-format migration and writes a safe JSON report. It is dry-run by default. `--write` validates the candidate, makes an exact exclusive `.bak`, then uses a same-directory non-overwriting publication for a changed regular non-symlink source; it never resolves secrets or starts an upstream. | | `miftah connection add --config ` | `--config`, `--profile`, `--issuer`, `--client-registration` | `--connection `, `--upstream `, repeated `--scope `, `--write` | Plans a v3 OAuth binding by default. `--write` applies the reviewed candidate with a unique recovery backup and configured audit event. It never resolves credentials or starts an upstream. | | `miftah connection list --config ` | `--config` | `--client ` | Lists redacted connection state. Optional snippets are copyable JSON only; Miftah never edits client settings. | @@ -59,18 +59,18 @@ The Console API is versioned under `/api/v1` and uses exact Host checks, exact l ### `init` presets and paths -`--preset` defaults to `generic`. The strict catalog accepts `generic`, `github`, `sentry`, `google-search-console`, `generic-npx`, `generic-docker`, and `streamable-http`; an unrecognized preset is a usage error. `--output` defaults to `.miftah.json`. Miftah resolves the output path from the current working directory, creates missing parent directories, and refuses to overwrite an existing file. Quote shell paths and names containing spaces: +`--preset` defaults to `generic`. The strict catalog accepts `generic`, `github`, `sentry`, `google-search-console`, `generic-npx`, `generic-docker`, `local-stdio`, and `streamable-http`; an unrecognized preset is a usage error. `--output` defaults to `.miftah.json`. Miftah resolves the output path from the current working directory, creates missing parent directories, and refuses to overwrite an existing file. Quote shell paths and names containing spaces: ```sh miftah init "work wrapper" --preset github --output "$HOME/Miftah configs/work wrapper.json" miftah validate --config "$HOME/Miftah configs/work wrapper.json" ``` -`generic-npx` requires `--npm-package` with exact package SemVer; `generic-docker` requires a canonical digest in `--docker-image`; `streamable-http` requires `--url` plus optional credential environment/header metadata; and one-account noninteractive `google-search-console` requires `--oauth-client-secrets-file` with an absolute path. Guided GSC setup can collect one or more named accounts, their client-secrets paths, and an explicit default profile. `--credential-env` is optional where supported. The GSC adapter prints credential/browser/identity ownership without printing a configured path. See [preset and client compatibility](presets-and-clients.md) for exact inputs, pins, provenance, and client snippets, and [provider adapters](provider-adapters.md) for the upstream-owned OAuth boundary. +`generic-npx` requires `--npm-package` with exact package SemVer; `generic-docker` requires a canonical digest in `--docker-image`; `streamable-http` requires `--url` plus optional credential environment/header metadata; and one-account noninteractive `google-search-console` requires `--oauth-client-secrets-file` with an absolute path. `local-stdio` requires `--local-command` and `--accept-local-command`; it accepts repeated literal `--arg` values, optional native absolute `--cwd`, and optional `--credential-env`. Use `--arg=--flag` for a value that begins with a dash. On Windows, the local executable must be an absolute `.exe` or `.com` binary; bare commands and `.cmd`/`.bat` shims are rejected so the direct argv path never uses a command processor. It rejects shell executables and wrappers, URL-like commands, environment references, controls, and credential-shaped command or argument values. It writes a read-only profile, treats unknown tools as destructive, and does not launch a generic local executable during `init` or `setup`. Guided GSC setup can collect one or more named accounts, their client-secrets paths, and an explicit default profile. `--credential-env` is optional where supported. The GSC adapter prints credential/browser/identity ownership without printing a configured path. See [preset and client compatibility](presets-and-clients.md) for exact inputs, pins, provenance, and client snippets, and [provider adapters](provider-adapters.md) for the upstream-owned OAuth boundary. -`--interactive` uses a wizard only when both input and output are TTYs. EOF or Ctrl-C while it is collecting initial configuration data cancels without writing a config. It asks for variable names and safe metadata, never secret values. For Google Search Console it asks for each account's profile name, optional description, and client-secrets path, then an explicit default profile. `miftah setup --verify` is an explicit opt-in to run each selected adapter's declared safe read-only check after the configuration write. It never accepts a tool name or arguments from the operator, never guesses a health command, does not auto-approve a policy confirmation, and returns only bounded status rather than provider output. The check runs only while the selected profile still matches the adapter's reviewed launch envelope; otherwise it reports a bounded unsupported status without launching the provider. If the post-write readiness prompt is cancelled, Miftah keeps the configuration, reports incomplete verification, and exits 1. A non-ready `setup --verify` result also keeps the configuration and exits 1. `init` is network-free and does not accept `--verify` or client-entry import flags. In noninteractive use, `init` creates only the config unless `--client` is supplied. `--client` prints JSON with absolute Node and compiled Miftah paths; it does not write a host config. For `claude-code` or `all`, it also prints a separate, exact management-tool `permissions.ask` fragment for manual merge into Claude Code settings; it never writes or overwrites those settings. Regenerate the snippets after moving or upgrading Miftah or changing the config path. +`--interactive` uses a wizard only when both input and output are TTYs. EOF or Ctrl-C while it is collecting initial configuration data cancels without writing a config. It asks for variable names and safe metadata, never secret values. For `local-stdio`, it asks for one argv element at a time and prints only a bounded count-based review summary before the acknowledgement; it does not echo arguments or credential values. For Google Search Console it asks for each account's profile name, optional description, and client-secrets path, then an explicit default profile. `miftah setup --verify` is an explicit opt-in to run each selected adapter's declared safe read-only check after the configuration write. It never accepts a tool name or arguments from the operator, never guesses a health command, does not auto-approve a policy confirmation, and returns only bounded status rather than provider output. The check runs only while the selected profile still matches the adapter's reviewed launch envelope; otherwise it reports a bounded unsupported status without launching the provider. If the post-write readiness prompt is cancelled, Miftah keeps the configuration, reports incomplete verification, and exits 1. A non-ready `setup --verify` result also keeps the configuration and exits 1. `init` is network-free and does not accept `--verify` or client-entry import flags. In noninteractive use, `init` creates only the config unless `--client` is supplied. `--client` prints JSON with absolute Node and compiled Miftah paths; it does not write a host config. For `claude-code` or `all`, it also prints a separate, exact management-tool `permissions.ask` fragment for manual merge into Claude Code settings; it never writes or overwrites those settings. Regenerate the snippets after moving or upgrading Miftah or changing the config path. -`miftah setup --import-file --import-entry ` is a separate no-secret flow, not a generic client migration. The source file must be an absolute regular non-symlink file. Miftah reads it through one bounded verified handle, requires an explicitly selected entry, and never changes the source. It accepts only a local `stdio` entry under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code) that fits its finite static launch grammar: a literal local executable, optional absolute working directory, and either an exact-version package-runner launch with only that runner's fixed safe prefix flags and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags. It rejects remote entries, `env`, headers, shell settings, unknown fields, environment wrappers, inline code, opaque values or assignments, URL userinfo, unpinned package references, and credential-shaped arguments. It creates a read-only default profile with unknown tool risk set to destructive and does not launch the imported program. `--verify` is rejected before publication because no reviewed provider adapter is inferred. Use advanced manual setup when the existing entry does not fit this grammar; configure upstream credentials or OAuth separately through the upstream's documented path and Miftah secret references. +`miftah setup --import-file --import-entry ` is a separate no-secret flow, not a generic client migration. The source file must be an absolute regular non-symlink file. Miftah reads it through one bounded verified handle, requires an explicitly selected entry, and never changes the source. It accepts only a local `stdio` entry under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code) that fits its finite static launch grammar: a literal local executable, optional absolute working directory, and either an exact-version package-runner launch with only that runner's fixed safe prefix flags and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags. On Windows, the import path accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. It rejects remote entries, `env`, headers, shell settings, unknown fields, environment wrappers, inline code, opaque values or assignments, URL userinfo, unpinned package references, and credential-shaped arguments. It creates a read-only default profile with unknown tool risk set to destructive and does not launch the imported program. `--verify` is rejected before publication because no reviewed provider adapter is inferred. Use advanced manual setup when the existing entry does not fit this grammar; configure upstream credentials or OAuth separately through the upstream's documented path and Miftah secret references. ### `migrate-config` diff --git a/docs/console-api.md b/docs/console-api.md index f07cc1b0..b5c1886d 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -33,13 +33,13 @@ Browser sessions have a 15-minute idle limit and a one-hour absolute limit. Rest Every request must use the exact listener `Host`. Browser mutations, including bootstrap, must also use the exact listener `Origin`. Normal same-origin navigation and authenticated `GET` and `HEAD` requests may omit `Origin`, because browsers do not consistently attach it to read requests; hostile or duplicate Origin values are still rejected, cross-site cookies remain blocked by `SameSite=Strict`, and every mutation still requires exact Origin plus CSRF. Except for the bootstrap exchange and static application assets, every API endpoint requires the session cookie. JSON request bodies are capped at 64 KiB and must use `Content-Type: application/json`, except the client-entry onboarding endpoint has a bounded envelope limit of up to 136 KiB so it can carry one separately byte-limited 64 KiB pasted document after JSON escaping. An explicitly configured request-size cap remains authoritative. Headers are capped at 16 KiB. The process admits at most 240 trusted requests per minute and only eight bootstrap attempts per minute; excess requests receive `429` plus `Retry-After`. -`POST /api/v1/sessions`, `POST /api/v1/connections/:ref/connect`, `POST /api/v1/connections/:ref/test`, `POST /api/v1/connections/:ref/reauth`, and `DELETE /api/v1/connections/:ref/credential` must send `Content-Type: application/json` with the JSON body `{}`. `POST /api/v1/connections` instead accepts a strict JSON object with required `profile`, `issuer`, `clientRegistration`, and `scopes` fields plus optional `connectionRef` and `upstream` fields; unknown fields are rejected. `POST /api/v1/profile-readiness` accepts only a selected `profile` plus an optional `upstream`; the Console always selects an exact configured upstream when there is more than one. It can invoke only a provider-declared safe read-only check after audit and policy preflight, binds that operation to the browser request so a disconnected client cancels it, and never accepts a tool name or arguments. Native-OAuth first run accepts only non-secret configuration name, profile, description, exact resource/issuer, client-registration mode, and scopes. Reviewed-preset first run accepts only safe catalog metadata; for Google Search Console that can include strict named account objects containing a profile name, optional description, and client-secrets path, plus an explicit default when there is more than one account. Client-entry first run accepts a bounded in-memory JSON document plus an explicit entry name; it accepts only a selected local stdio entry that fits the same finite static launch grammar as the CLI and never persists, returns, or audits the pasted document. Token, password, cookie, raw secret contents, callback, environment wrappers, inline code, opaque values or assignments, and arbitrary extra fields are rejected. +`POST /api/v1/sessions`, `POST /api/v1/connections/:ref/connect`, `POST /api/v1/connections/:ref/test`, `POST /api/v1/connections/:ref/reauth`, and `DELETE /api/v1/connections/:ref/credential` must send `Content-Type: application/json` with the JSON body `{}`. `POST /api/v1/connections` instead accepts a strict JSON object with required `profile`, `issuer`, `clientRegistration`, and `scopes` fields plus optional `connectionRef` and `upstream` fields; unknown fields are rejected. `POST /api/v1/profile-readiness` accepts only a selected `profile` plus an optional `upstream`; the Console always selects an exact configured upstream when there is more than one. It can invoke only a provider-declared safe read-only check after audit and policy preflight, binds that operation to the browser request so a disconnected client cancels it, and never accepts a tool name or arguments. Native-OAuth first run accepts only non-secret configuration name, profile, description, exact resource/issuer, client-registration mode, and scopes. Reviewed-preset first run accepts only safe catalog metadata; for Google Search Console that can include strict named account objects containing a profile name, optional description, and client-secrets path, plus an explicit default when there is more than one account. The `local-stdio` preset additionally accepts one literal executable, a bounded literal argument array, optional native absolute working directory, optional credential-environment name, and a required `acceptLocalCommand: true` acknowledgement. It never uses a shell or starts that generic local executable during onboarding; shell/wrapper, URL, control, environment-reference, and credential-shaped values are rejected. On Windows, it accepts only a direct absolute `.exe` or `.com` binary, not a bare command or `.cmd`/`.bat` shim, so the direct argv path cannot fall back to a command processor. Client-entry first run accepts a bounded in-memory JSON document plus an explicit entry name; it accepts only a selected local stdio entry that fits the same finite static launch grammar as the CLI and never persists, returns, or audits the pasted document. On Windows, this import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. Token, password, cookie, raw secret contents, callback, environment wrappers, inline code, opaque values or assignments, and arbitrary extra fields are rejected. | Method and path | Purpose | | --- | --- | | `POST /api/v1/sessions` | Exchange the one-use bootstrap code for one browser session. | -| `POST /api/v1/onboarding/preset` | Exclusively create the first validated configuration from a reviewed preset and safe connector metadata. Requires CSRF; GSC accepts only structured account names/descriptions/client-secrets paths and rejects raw credential values. | -| `POST /api/v1/onboarding/client-entry` | Exclusively create the first validated configuration from one explicitly selected local stdio client entry. Requires CSRF; the pasted document is in-memory only and only the shared static launch grammar is accepted. `env`, headers, shell settings, remote transports, environment wrappers, inline code, opaque values or assignments, unpinned package references, and credential-shaped arguments are rejected. | +| `POST /api/v1/onboarding/preset` | Exclusively create the first validated configuration from a reviewed preset and safe connector metadata. Requires CSRF; GSC accepts only structured account names/descriptions/client-secrets paths and rejects raw credential values. `local-stdio` additionally requires an explicit acknowledgement and stores only a literal executable/argument array; onboarding does not launch it. | +| `POST /api/v1/onboarding/client-entry` | Exclusively create the first validated configuration from one explicitly selected local stdio client entry. Requires CSRF; the pasted document is in-memory only and only the shared static launch grammar is accepted. On Windows, it accepts only a direct absolute `.exe` or `.com` executable, not bare runners or `.cmd`/`.bat` shims. `env`, headers, shell settings, remote transports, environment wrappers, inline code, opaque values or assignments, unpinned package references, and credential-shaped arguments are rejected. | | `POST /api/v1/onboarding/native-oauth` | Exclusively create the first validated v3 native-OAuth profile, upstream, and connection. Requires CSRF and refuses an existing file. | | `GET /api/v1/health` | Return safe config identity, Console audit health, and restart-required guidance. | | `GET /api/v1/config` | Return allowlisted configuration metadata only. | diff --git a/docs/presets-and-clients.md b/docs/presets-and-clients.md index 212bc9e8..3e5f14f5 100644 --- a/docs/presets-and-clients.md +++ b/docs/presets-and-clients.md @@ -2,7 +2,7 @@ This is the compatibility source of truth for generated `miftah init` configurations and client snippets. -- Catalog version: `2` +- Catalog version: `3` - Miftah package version: `0.4.0` - Last tested / validation boundary: the catalog builds strict Miftah configuration that `validateConfig` accepts. The docs contract test checks generated configuration only; it does **not** construct a runtime, start, authenticate to, or smoke-test external providers. @@ -18,6 +18,7 @@ Miftah itself requires Node.js `>=20`. That does not establish an upstream serve | `google-search-console` | Noninteractive one-account setup requires `--oauth-client-secrets-file `; guided `miftah setup` supports one or more named accounts. Generated command: `uvx mcp-search-console@0.3.2`; generated environment: `GSC_OAUTH_CLIENT_SECRETS_FILE` and a distinct `GSC_CONFIG_DIR` per generated configuration file and profile. | Python `>=3.11` and `uvx` are required. OAuth, browser handoff, token cache, reauthentication, and revocation remain upstream/manual-owned. Miftah applies a read-only policy, does not set `GSC_ALLOW_DESTRUCTIVE`, and never reads the upstream cache. See the [provider-adapter contract](provider-adapters.md#google-search-console-pilot). | | `generic-npx` | Required: `--npm-package `; optional `--credential-env ` | Only an exact npm package SemVer is accepted. The selected external package, not Miftah, declares its own Node requirement. | | `generic-docker` | Required: `--docker-image `; optional `--credential-env ` | Docker is required. Only a canonical image reference with a 64-hex-character `@sha256:` digest is accepted. | +| `local-stdio` | Required: `--local-command ` and `--accept-local-command`; optional repeated `--arg `, `--cwd `, and `--credential-env `. Use a bare executable or native absolute path on macOS/Linux, and a direct native absolute `.exe` or `.com` binary on Windows. | Each `--arg` is one literal argv element; use `--arg=--flag` when the value starts with a dash. Miftah never uses a shell, expands no environment references, rejects shell executables/wrappers, URLs, controls, and credential-shaped command or argument values, and never starts this generic local command during setup. Windows rejects bare commands and `.cmd`/`.bat` shims so direct argv execution cannot fall back to a command processor. The generated profile is read-only and unknown tools remain destructive until deliberately configured. Review the executable and its arguments yourself before acknowledging it. | | `streamable-http` | Required: `--url `; optional credential metadata: `--credential-env`, `--header-name`, and `--header-prefix` | The URL must be HTTPS and must not contain userinfo, a query, or a fragment. Credentials may appear only as `${ENV_NAME}` through validated header metadata; allowed prefixes are empty, `Bearer `, and `Sentry `. Never place a credential in a URL or URL component. | The three checked-in provider/reference examples are exact catalog output: `examples/generic.miftah.json`, `examples/github.miftah.json`, and `examples/sentry.miftah.json`. They contain secret references only, never credentials. @@ -59,12 +60,14 @@ miftah init [name] \ [--credential-env ] [--npm-package ] \ [--docker-image ] [--url ] \ [--header-name ] [--header-prefix ] \ - [--oauth-client-secrets-file ] + [--oauth-client-secrets-file ] \ + [--local-command ] [--arg ] [--cwd ] \ + [--accept-local-command] ``` Without `--interactive`, `init` creates only a configuration unless `--client` is supplied. With `--client`, it still creates the configuration and prints JSON snippets; it never writes a client file. For `--client claude-code` or `--client all`, it also prints a separately labelled Claude Code `permissions.ask` fragment for the visible Miftah management tools that require explicit client review. It never writes or overwrites Claude Code settings. Creation is exclusive and never overwrites an existing output path. -`--interactive` is available only when both input and output are real TTYs. EOF or Ctrl-C cancels before the configuration write. The wizard asks only for a name, catalog preset, safe preset metadata (variable names, URLs, header metadata, pins), output location, and client selection. It never asks for or echoes a secret value. +`--interactive` is available only when both input and output are real TTYs. EOF or Ctrl-C cancels before the configuration write. The wizard asks only for a name, catalog preset, safe preset metadata (variable names, URLs, header metadata, pins), output location, and client selection. For `local-stdio`, it asks for one literal argv item at a time and gives a count-only review summary before explicit acknowledgement. It never asks for or echoes a secret value. `miftah setup [name]` is the dedicated guided command. It uses that same TTY-only wizard and safe preset options, with interactivity required by the command instead of supplied as `--interactive`. diff --git a/src/cli/init.ts b/src/cli/init.ts index 80b16beb..b528a4df 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -42,6 +42,10 @@ export type InitCommandOptions = Pick< | "headerName" | "headerPrefix" | "oauthClientSecretsFile" + | "localCommand" + | "args" + | "cwd" + | "acceptLocalCommand" > & Pick; export interface InitCommandContext { @@ -130,6 +134,18 @@ async function prompt( return value === "" ? defaultValue : value; } +/** Reads one literal argv value without trimming whitespace that belongs to the argument itself. */ +async function rawPrompt( + line: PromptInterface, + cancellation: Cancellation, + label: string, + defaultValue?: string +): Promise { + const suffix = defaultValue === undefined ? ": " : ` [${defaultValue}]: `; + const answer = await Promise.race([line.question(`${label}${suffix}`), cancellation.promise]); + return answer === "" ? defaultValue : answer; +} + async function collectStreamableOptions( line: PromptInterface, cancellation: Cancellation, @@ -159,6 +175,75 @@ async function collectStreamableOptions( }; } +function parseYesNo(value: string | undefined, label: string): boolean { + switch (value?.toLowerCase()) { + case "y": + case "yes": + return true; + case "n": + case "no": + return false; + default: + usageError(`Answer 'yes' or 'no' when asked to ${label}.`); + } +} + +async function collectLocalStdioOptions( + line: PromptInterface, + cancellation: Cancellation, + options: InitCommandOptions, + output: Writable +): Promise { + const localCommand = options.localCommand ?? (await prompt(line, cancellation, "Local executable (no shell)")); + if (localCommand === undefined) { + usageError("Local stdio setup requires one executable."); + } + + const args = options.args === undefined ? [] : [...options.args]; + if (options.args === undefined) { + while (parseYesNo( + await prompt(line, cancellation, "Add a local argument? (yes/no)", "no"), + "add another local argument" + )) { + const argument = await rawPrompt(line, cancellation, `Argument ${args.length + 1}`); + if (argument === undefined) usageError("Local stdio setup requires an argument value after confirmation."); + args.push(argument); + } + } + + const cwd = options.cwd ?? (await prompt(line, cancellation, "Working directory (absolute path, optional)")); + const credentialEnv = options.credentialEnv ?? (await prompt( + line, + cancellation, + "Credential environment variable name (optional)" + )); + output.write( + `Local command review: 1 executable with ${args.length} argument(s); working directory: ${ + cwd === undefined ? "not set" : "configured" + }; credential environment: ${credentialEnv === undefined ? "not set" : "configured"}.\n` + ); + const confirmed = options.acceptLocalCommand === true + ? true + : parseYesNo( + await prompt( + line, + cancellation, + "Miftah will not run this during setup. It will save this executable and argument array without a shell. Continue only if you trust it and entered no credential (yes/no)", + "no" + ), + "confirm the local executable" + ); + if (!confirmed) usageError("Local executable setup was not confirmed."); + + return { + localCommand, + args, + ...(cwd === undefined ? {} : { cwd }), + ...(credentialEnv === undefined ? {} : { credentialEnv }), + acceptLocalCommand: true + }; +} + function parseAdditionalGoogleSearchConsoleAccount(value: string | undefined): boolean { switch (value?.toLowerCase()) { case "y": @@ -230,7 +315,8 @@ async function collectPresetOptions( line: PromptInterface, cancellation: Cancellation, preset: string, - options: InitCommandOptions + options: InitCommandOptions, + output: Writable ): Promise { switch (preset) { case "google-search-console": @@ -245,6 +331,8 @@ async function collectPresetOptions( credentialEnv: options.credentialEnv, dockerImage: options.dockerImage ?? (await prompt(line, cancellation, "Docker image (digest-pinned)")) }; + case "local-stdio": + return collectLocalStdioOptions(line, cancellation, options, output); case "streamable-http": return collectStreamableOptions(line, cancellation, options); default: @@ -255,7 +343,11 @@ async function collectPresetOptions( url: options.url, headerName: options.headerName, headerPrefix: options.headerPrefix, - oauthClientSecretsFile: options.oauthClientSecretsFile + oauthClientSecretsFile: options.oauthClientSecretsFile, + localCommand: options.localCommand, + args: options.args, + cwd: options.cwd, + acceptLocalCommand: options.acceptLocalCommand }; } } @@ -270,7 +362,7 @@ async function collectInteractiveValues(options: InitCommandOptions, context: In try { const name = options.name ?? (await prompt(line, cancellation, "Name", "miftah-wrapper")); const preset = options.preset ?? (await prompt(line, cancellation, "Catalog preset", "generic")); - const presetOptions = await collectPresetOptions(line, cancellation, preset ?? "generic", options); + const presetOptions = await collectPresetOptions(line, cancellation, preset ?? "generic", options, context.output); const output = options.output ?? (await prompt(line, cancellation, "Output location", `${name}.miftah.json`)); const client = options.client ?? (await prompt( line, @@ -305,6 +397,10 @@ function nonInteractiveValues(options: InitCommandOptions): InitValues { headerName: options.headerName, headerPrefix: options.headerPrefix, oauthClientSecretsFile: options.oauthClientSecretsFile, + localCommand: options.localCommand, + args: options.args, + cwd: options.cwd, + acceptLocalCommand: options.acceptLocalCommand, googleSearchConsoleProfiles: options.googleSearchConsoleProfiles, defaultProfile: options.defaultProfile }; @@ -340,6 +436,10 @@ function buildInitPlan(values: InitValues, context: InitCommandContext): InitPla headerName: values.headerName, headerPrefix: values.headerPrefix, oauthClientSecretsFile: values.oauthClientSecretsFile, + localCommand: values.localCommand, + args: values.args, + cwd: values.cwd, + acceptLocalCommand: values.acceptLocalCommand, googleSearchConsoleProfiles: values.googleSearchConsoleProfiles, defaultProfile: values.defaultProfile }, { diff --git a/src/cli/parse.ts b/src/cli/parse.ts index e5d830d2..917ec848 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -14,6 +14,9 @@ type ValueOptionName = | "headerName" | "headerPrefix" | "oauthClientSecretsFile" + | "localCommand" + | "args" + | "cwd" | "transport" | "connection" | "upstream" @@ -21,7 +24,16 @@ type ValueOptionName = | "clientRegistration" | "scopes" | "port"; -type BooleanOptionName = "follow" | "json" | "interactive" | "includeArguments" | "write" | "nonInteractive" | "noOpen" | "verify"; +type BooleanOptionName = + | "follow" + | "json" + | "interactive" + | "includeArguments" + | "write" + | "nonInteractive" + | "noOpen" + | "verify" + | "acceptLocalCommand"; type CliOptionName = ValueOptionName | BooleanOptionName; export interface CliOptions { @@ -42,6 +54,14 @@ export interface CliOptions { readonly headerName?: string; readonly headerPrefix?: string; readonly oauthClientSecretsFile?: string; + /** One literal executable for the explicitly reviewed local stdio setup flow. */ + readonly localCommand?: string; + /** Repeated literal argv elements for the explicitly reviewed local stdio setup flow. */ + readonly args?: readonly string[]; + /** Optional native absolute working directory for the local stdio setup flow. */ + readonly cwd?: string; + /** Required acknowledgement before persisting an unreviewed local executable. */ + readonly acceptLocalCommand?: true; readonly transport?: "stdio" | "http"; readonly connection?: string; readonly upstream?: string; @@ -106,7 +126,11 @@ export const CLI_COMMANDS = { "url", "headerName", "headerPrefix", - "oauthClientSecretsFile" + "oauthClientSecretsFile", + "localCommand", + "args", + "cwd", + "acceptLocalCommand" ] }, setup: { @@ -126,6 +150,10 @@ export const CLI_COMMANDS = { "headerName", "headerPrefix", "oauthClientSecretsFile", + "localCommand", + "args", + "cwd", + "acceptLocalCommand", "verify" ] }, @@ -301,6 +329,24 @@ const OPTION_DEFINITIONS: Record = { usage: "--oauth-client-secrets-file ", description: "Absolute Google OAuth client-secrets file for the GSC preset." }, + localCommand: { + name: "localCommand", + takesValue: true, + usage: "--local-command ", + description: "Literal executable for the explicitly reviewed local-stdio preset." + }, + args: { + name: "args", + takesValue: true, + usage: "--arg ", + description: "One local-stdio argv value; repeat and use --arg=--flag for a leading dash." + }, + cwd: { + name: "cwd", + takesValue: true, + usage: "--cwd ", + description: "Native absolute working directory for the local-stdio preset." + }, transport: { name: "transport", takesValue: true, @@ -390,6 +436,12 @@ const OPTION_DEFINITIONS: Record = { takesValue: false, usage: "--verify", description: "Run declared safe readiness checks after setup writes the configuration." + }, + acceptLocalCommand: { + name: "acceptLocalCommand", + takesValue: false, + usage: "--accept-local-command", + description: "Confirm review of an untrusted local executable before writing it." } }; @@ -409,6 +461,9 @@ const FLAG_DEFINITIONS: Record "--header-name": OPTION_DEFINITIONS.headerName, "--header-prefix": OPTION_DEFINITIONS.headerPrefix, "--oauth-client-secrets-file": OPTION_DEFINITIONS.oauthClientSecretsFile, + "--local-command": OPTION_DEFINITIONS.localCommand, + "--arg": OPTION_DEFINITIONS.args, + "--cwd": OPTION_DEFINITIONS.cwd, "--transport": OPTION_DEFINITIONS.transport, "--connection": OPTION_DEFINITIONS.connection, "--upstream": OPTION_DEFINITIONS.upstream, @@ -424,6 +479,7 @@ const FLAG_DEFINITIONS: Record "--non-interactive": OPTION_DEFINITIONS.nonInteractive, "--no-open": OPTION_DEFINITIONS.noOpen, "--verify": OPTION_DEFINITIONS.verify, + "--accept-local-command": OPTION_DEFINITIONS.acceptLocalCommand, "--help": "help", "-h": "help", "--version": "version", @@ -446,9 +502,9 @@ function isCliCommand(value: string): value is CliCommand { } function setValueOption(options: { [name: string]: unknown }, name: ValueOptionName, value: string): void { - if (name === "scopes") { - const existing = options.scopes; - options.scopes = [...(Array.isArray(existing) ? existing : []), value]; + if (name === "scopes" || name === "args") { + const existing = options[name]; + options[name] = [...(Array.isArray(existing) ? existing : []), value]; return; } if (options[name] !== undefined) usageError(`Duplicate option '--${name}'.`); diff --git a/src/cli/setup-client-entry-import.ts b/src/cli/setup-client-entry-import.ts index 01e08f65..708e57fd 100644 --- a/src/cli/setup-client-entry-import.ts +++ b/src/cli/setup-client-entry-import.ts @@ -49,6 +49,10 @@ function rejectPresetOptions(options: ClientEntryImportSetupOptions): void { options.headerName, options.headerPrefix, options.oauthClientSecretsFile, + options.localCommand, + options.args, + options.cwd, + options.acceptLocalCommand, options.googleSearchConsoleProfiles, options.defaultProfile ].some((value) => value !== undefined); diff --git a/src/config/presets.ts b/src/config/presets.ts index 756bbc1f..e8895a4d 100644 --- a/src/config/presets.ts +++ b/src/config/presets.ts @@ -18,6 +18,23 @@ const exactSemver = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*)|[0-9A-Za-z]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:(?:0|[1-9][0-9]*)|[0-9A-Za-z]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; const canonicalDigestImage = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?(?:[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\/)*[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?::[A-Za-z0-9_][A-Za-z0-9_.-]*)?@sha256:[A-Fa-f0-9]{64}$/u; +const shellMetacharacter = /[|&;<>`'"]/u; +const credentialFlag = /^(?:-(?:H|b|u).*$|--?(?:access[-_]?key|access[-_]?token|api[-_]?key|auth(?:orization)?|bearer|client[-_]?secret|cookie|credential(?:s)?|env(?:ironment)?|headers?|jwt|key|pass|password|passwd|private[-_]?key|secret|sig(?:nature)?|token|user)(?:=.*)?)$/iu; +const credentialOptionName = /(?:^|[-_])(?:access[-_]?key|access[-_]?token|api[-_]?key|auth(?:orization)?|bearer|client[-_]?secret|cookie|credential(?:s)?|env(?:ironment)?|headers?|jwt|key|pass|password|passwd|private[-_]?key|secret|sig(?:nature)?|token|user)(?:$|[-_])/iu; +const credentialValue = /^(?:(?:(?:proxy-)?authorization|cookie|(?:x-)?api[-_]?key|x[-_]?access[-_]?token|x[-_]?auth(?:orization)?)\s*:\s*\S+|(?:basic|bearer|jwt|token)\s+\S+)$/iu; +const credentialAssignment = /(?:^|[^A-Za-z0-9])(?:access[-_]?key|access[-_]?token|api[-_]?key|auth(?:orization)?|bearer|client[-_]?secret|cookie|credential(?:s)?|jwt|key|password|passwd|private[-_]?key|secret|sig(?:nature)?|token)(?==|:|[-_]|["']\s*:)/iu; +const embeddedCredentialScheme = /(?:^|=|,|:|\{|\[|"|')(?:api[-_ ]?key|basic|bearer|jwt|token)\s+\S+/iu; +const credentialUrlUserinfo = /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#\s@]+@/u; +const credentialQueryParameter = /[?&](?:access[-_]?key|access[-_]?token|api[-_]?key|auth(?:orization)?|bearer|client[-_]?secret|cookie|credential(?:s)?|jwt|key|password|passwd|private[-_]?key|secret|sig(?:nature)?|token)=[^&\s]+/iu; +const uriScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u; +const shellExecutable = /^(?:bash|cmd(?:\.exe)?|fish|powershell(?:\.exe)?|pwsh(?:\.exe)?|sh|zsh)$/iu; +const environmentWrapper = /^env(?:\.exe)?$/iu; +const bareExecutable = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/u; +const windowsDirectExecutable = /\.(?:com|exe)$/iu; +const environmentReferenceArgument = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/u; +const maximumLocalArgumentCount = 128; +const maximumLocalArgumentBytes = 4 * 1024; +const maximumLocalArgumentTotalBytes = 16 * 1024; type CurrentMiftahConfig = Extract; type CurrentUpstreamConfig = NonNullable; @@ -33,6 +50,14 @@ export interface PresetBuildOptions { oauthClientSecretsFile?: string; googleSearchConsoleProfiles?: readonly GoogleSearchConsoleProfileOptions[]; defaultProfile?: string; + /** One literal executable for the explicitly-reviewed local stdio setup path. */ + localCommand?: string; + /** Each item is one literal argv element; Miftah never parses a command line. */ + args?: readonly string[]; + /** Optional native absolute working directory for a local stdio upstream. */ + cwd?: string; + /** Required acknowledgement before persisting an unreviewed local executable. */ + acceptLocalCommand?: boolean; } /** Trusted creation context that is deliberately separate from user-configurable preset options. */ @@ -401,6 +426,132 @@ function buildGenericDockerPreset(name: string, options: PresetBuildOptions): Mi ); } +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f); + }); +} + +function foreignPlatformAbsolutePath(value: string): boolean { + if (process.platform === "win32") return value.startsWith("/"); + return /^(?:[A-Za-z]:[\\/]|\\)/u.test(value); +} + +function nativeAbsolutePath(value: string): boolean { + return isAbsolute(value) && !foreignPlatformAbsolutePath(value); +} + +function executableStem(command: string): string { + const executableName = command.replaceAll("\\", "/").split("/").at(-1) ?? command; + return executableName.replace(/\.(?:cmd|exe)$/iu, "").toLowerCase(); +} + +function normalizeCredentialText(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/gu, "$1-$2"); +} + +function credentialBearingArgument(value: string): boolean { + const normalized = normalizeCredentialText(value); + const optionName = normalized.split(/[=:]/u, 1)[0] ?? normalized; + return credentialOptionName.test(optionName) + || credentialFlag.test(value) + || credentialValue.test(value) + || credentialAssignment.test(normalized) + || embeddedCredentialScheme.test(normalized) + || credentialUrlUserinfo.test(value) + || credentialQueryParameter.test(value); +} + +function requireLocalCommand(value: unknown): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > maximumLocalArgumentBytes || + value.trim() !== value || + hasControlCharacter(value) || + shellMetacharacter.test(value) || + uriScheme.test(value) || + credentialBearingArgument(value) || + foreignPlatformAbsolutePath(value) || + (!nativeAbsolutePath(value) && !bareExecutable.test(value)) + ) { + catalogError("Preset 'local-stdio' requires one literal local executable, not a command line or credential-bearing value."); + } + if (process.platform === "win32" && (!nativeAbsolutePath(value) || !windowsDirectExecutable.test(value))) { + catalogError("Preset 'local-stdio' requires an absolute .exe or .com executable on Windows to preserve direct argument-array execution."); + } + const executable = executableStem(value); + if (shellExecutable.test(executable) || environmentWrapper.test(executable)) { + catalogError("Preset 'local-stdio' does not accept shell or environment-wrapper executables."); + } + return value; +} + +function requireLocalArguments(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > maximumLocalArgumentCount) { + catalogError("Preset 'local-stdio' accepts at most 128 literal argument-array values."); + } + let totalBytes = 0; + return value.map((argument) => { + if (typeof argument !== "string" || hasControlCharacter(argument)) { + catalogError("Preset 'local-stdio' arguments must be strings without control characters."); + } + const bytes = Buffer.byteLength(argument, "utf8"); + totalBytes += bytes; + if (bytes > maximumLocalArgumentBytes || totalBytes > maximumLocalArgumentTotalBytes) { + catalogError("Preset 'local-stdio' argument input exceeds its bounded size limit."); + } + if (credentialBearingArgument(argument) || environmentReferenceArgument.test(argument)) { + catalogError("Preset 'local-stdio' arguments cannot contain credentials or environment-reference syntax. Use credentialEnv instead."); + } + return argument; + }); +} + +function requireLocalCwd(value: unknown): string | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== "string" || + value.length === 0 || + value.length > maximumLocalArgumentBytes || + value.trim() !== value || + hasControlCharacter(value) || + !nativeAbsolutePath(value) + ) { + catalogError("Preset 'local-stdio' working directory must be a native absolute path without controls or surrounding whitespace."); + } + return value; +} + +function buildLocalStdioPreset(name: string, options: PresetBuildOptions): MiftahConfig { + if (options.acceptLocalCommand !== true) { + catalogError("Preset 'local-stdio' requires explicit acknowledgement before writing a local executable configuration."); + } + const command = requireLocalCommand(options.localCommand); + const args = requireLocalArguments(options.args); + const cwd = requireLocalCwd(options.cwd); + const config = buildStandardPreset( + name, + { + transport: "stdio", + command, + args, + ...(cwd === undefined ? {} : { cwd }) + }, + options.credentialEnv + ); + config.profiles.default = { + ...buildCredentialProfile(options.credentialEnv), + description: "Locally configured MCP executable; configure authentication with secret references when required.", + policy: "readonly" + }; + config.policies = buildReadonlyPolicies(); + config.tooling = { ...config.tooling, unknownToolRisk: "destructive" }; + return config; +} + function requireHttpsUrl(value: unknown): string { if (value === undefined) { catalogError("Preset 'streamable-http' requires an HTTPS URL."); @@ -467,7 +618,7 @@ function buildStreamableHttpPreset(name: string, options: PresetBuildOptions): M * caller-supplied inputs each builder may receive. */ export const PRESET_CATALOG = { - version: "2", + version: "3", presets: { generic: { requirements: { credentialEnv: "optional" }, @@ -497,6 +648,16 @@ export const PRESET_CATALOG = { requirements: { dockerImage: "required", credentialEnv: "optional" }, build: buildGenericDockerPreset }, + "local-stdio": { + requirements: { + localCommand: "required", + args: "optional", + cwd: "optional", + credentialEnv: "optional", + acceptLocalCommand: "required" + }, + build: buildLocalStdioPreset + }, "streamable-http": { requirements: { url: "required", diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index c68e894b..c28c1d51 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -347,6 +347,10 @@ export class ConsoleApplicationService implements ConsoleControlApplication { headerName: request.headerName, headerPrefix: request.headerPrefix, oauthClientSecretsFile: request.oauthClientSecretsFile, + localCommand: request.localCommand, + args: request.args, + cwd: request.cwd, + acceptLocalCommand: request.acceptLocalCommand, googleSearchConsoleProfiles: request.googleSearchConsoleProfiles, defaultProfile: request.defaultProfile }, { diff --git a/src/console/console-assets.ts b/src/console/console-assets.ts index f8eb96fa..fc15f6df 100644 --- a/src/console/console-assets.ts +++ b/src/console/console-assets.ts @@ -84,7 +84,7 @@ const page = `

02 / First connection

Set up an MCP

-

Choose a known connector. Miftah writes only validated configuration references; it never asks for a password or token here.

+

Choose a known connector, a reviewed local executable, or a remote HTTPS endpoint. Miftah writes validated configuration references; it never asks for a password or token here.

@@ -96,11 +96,16 @@ const page = ` - + + + + + + - + -

For provider-owned login such as Google Search Console, Miftah saves the client-secrets path only. The upstream owns its browser login and private token cache.

+

For provider-owned login such as Google Search Console, Miftah saves the client-secrets path only. The upstream owns its browser login and private token cache. For a local executable, use the environment-variable field for a secret reference; never put a token in an argument.

@@ -381,6 +386,7 @@ const script = `(() => { "google-search-console": [], "generic-npx": ["credentialEnv", "npmPackage"], "generic-docker": ["credentialEnv", "dockerImage"], + "local-stdio": ["credentialEnv", "localCommand", "args", "cwd", "acceptLocalCommand"], "streamable-http": ["credentialEnv", "url", "headerName", "headerPrefix"] }); let csrfToken = ""; @@ -786,13 +792,19 @@ const script = `(() => { if (!(field instanceof HTMLElement)) return; const visible = (field.dataset.presetField || "").split(" ").includes(preset); field.hidden = !visible; - field.querySelectorAll("input, select").forEach((control) => { - if (!(control instanceof HTMLInputElement) && !(control instanceof HTMLSelectElement)) return; + field.querySelectorAll("input, select, textarea").forEach((control) => { + if ( + !(control instanceof HTMLInputElement) && + !(control instanceof HTMLSelectElement) && + !(control instanceof HTMLTextAreaElement) + ) return; control.disabled = !visible; - if (control instanceof HTMLInputElement) { + if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) { control.required = visible && ( (control.name === "npmPackage" && preset === "generic-npx") || (control.name === "dockerImage" && preset === "generic-docker") || + (control.name === "localCommand" && preset === "local-stdio") || + (control.name === "acceptLocalCommand" && preset === "local-stdio") || (control.name === "url" && preset === "streamable-http") || (preset === "google-search-console" && ( control.dataset.gscProfileName === "true" || control.dataset.gscClientSecretsFile === "true" @@ -972,6 +984,16 @@ const script = `(() => { } request.googleSearchConsoleProfiles = googleSearchConsoleProfiles; request.defaultProfile = defaultProfile; + } else if (preset === "local-stdio") { + const localCommand = String(data.get("localCommand") || "").trim(); + const argumentText = String(data.get("args") || ""); + const cwd = String(data.get("cwd") || "").trim(); + const credentialEnv = String(data.get("credentialEnv") || "").trim(); + request.localCommand = localCommand; + request.args = argumentText === "" ? [] : argumentText.split(/\\r?\\n/u); + request.acceptLocalCommand = data.get("acceptLocalCommand") === "true"; + if (cwd) request.cwd = cwd; + if (credentialEnv) request.credentialEnv = credentialEnv; } else { ["credentialEnv", "npmPackage", "dockerImage", "url", "headerName", "oauthClientSecretsFile"].forEach((name) => { if (!allowedNames.includes(name)) return; diff --git a/src/console/console-server.ts b/src/console/console-server.ts index b47cb16d..c7f581e2 100644 --- a/src/console/console-server.ts +++ b/src/console/console-server.ts @@ -63,6 +63,10 @@ const presetOnboardingSchema = z.object({ headerName: z.string().min(1).max(256).optional(), headerPrefix: z.string().max(256).optional(), oauthClientSecretsFile: z.string().min(1).max(4_096).optional(), + localCommand: z.string().min(1).max(4_096).optional(), + args: z.array(z.string().max(4_096)).max(128).optional(), + cwd: z.string().min(1).max(4_096).optional(), + acceptLocalCommand: z.literal(true).optional(), googleSearchConsoleProfiles: z.array(googleSearchConsoleProfileSchema).min(1).optional(), defaultProfile: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,63})$/u).optional() }).strict().superRefine((request, context) => { @@ -77,6 +81,13 @@ const presetOnboardingSchema = z.object({ message: "Google Search Console setup requires an explicit default profile when more than one account is configured." }); } + if (request.preset === "local-stdio" && request.acceptLocalCommand !== true) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["acceptLocalCommand"], + message: "Local stdio setup requires explicit acknowledgement before a command is saved." + }); + } }); const clientEntryOnboardingSchema = z.object({ name: z.string().min(1).max(256), diff --git a/src/setup/client-entry-import.ts b/src/setup/client-entry-import.ts index 4788e10c..fe4e8b07 100644 --- a/src/setup/client-entry-import.ts +++ b/src/setup/client-entry-import.ts @@ -13,6 +13,7 @@ const embeddedCredentialScheme = /(?:^|=|,|:|\{|\[|"|')(?:api[-_ ]?key|basic|bea const credentialUrlUserinfo = /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#\s@]+@/u; const uriScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u; const shellExecutable = /^(?:bash|cmd(?:\.exe)?|fish|powershell(?:\.exe)?|pwsh(?:\.exe)?|sh|zsh)$/iu; +const windowsDirectExecutable = /\.(?:com|exe)$/iu; const staticFlag = /^--?[A-Za-z][A-Za-z0-9-]*$/u; const staticPackageSpecifier = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*@v?\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/u; const staticScriptPath = /^[A-Za-z0-9._~@%+\\/-]+$/u; @@ -293,6 +294,12 @@ function selectedStdioEntry(value: unknown, container: ClientEntryContainer): Im if (shellExecutable.test(executable)) { importError("Shell executables cannot be imported. Use the upstream executable and argument array directly."); } + if (process.platform === "win32" && (!absolutePath(command) || !windowsDirectExecutable.test(command))) { + importError( + "Windows client-entry import requires a direct absolute .exe or .com executable instead of a command-processor shim.", + "static-launch" + ); + } const rawArgs = value.args ?? []; if (!Array.isArray(rawArgs)) importError("The selected MCP entry arguments must be an array of strings."); diff --git a/tests/cli-parse.test.ts b/tests/cli-parse.test.ts index 1475344d..4a6fe340 100644 --- a/tests/cli-parse.test.ts +++ b/tests/cli-parse.test.ts @@ -211,6 +211,35 @@ describe("CLI parser", () => { expectUsageError(["init", "--import-file", "/Users/example/config.json", "--import-entry", "posthog"]); }); + it("parses an explicitly reviewed local executable as a literal argument array", () => { + expect(parseCli([ + "setup", + "local-tools", + "--preset=local-stdio", + "--local-command=node", + "--arg=server.mjs", + "--arg=--stdio", + "--arg=$pageview", + "--cwd=/Users/example/projects/local-tools", + "--credential-env=LOCAL_MCP_TOKEN", + "--accept-local-command" + ])).toEqual({ + kind: "run", + command: "setup", + options: { + name: "local-tools", + preset: "local-stdio", + localCommand: "node", + args: ["server.mjs", "--stdio", "$pageview"], + cwd: "/Users/example/projects/local-tools", + credentialEnv: "LOCAL_MCP_TOKEN", + acceptLocalCommand: true + } + }); + expectUsageError(["setup", "--arg", "--stdio"]); + expectUsageError(["validate", "--local-command", "node"]); + }); + it("parses all init-only onboarding options before or after init, including equals values", () => { expect( parseCli([ diff --git a/tests/client-entry-import.test.ts b/tests/client-entry-import.test.ts index cf7a6cba..0ee9ee6d 100644 --- a/tests/client-entry-import.test.ts +++ b/tests/client-entry-import.test.ts @@ -6,6 +6,12 @@ import { inspectClientConfiguration } from "../src/setup/client-entry-import.js"; +function importableEntry(): { readonly command: string; readonly args: readonly string[] } { + return process.platform === "win32" + ? { command: process.execPath, args: ["server.mjs"] } + : { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] }; +} + describe("client entry import", () => { it("lists supported client entries without returning their configuration values", () => { const document = JSON.stringify({ @@ -22,14 +28,12 @@ describe("client entry import", () => { }); it("imports one reviewed Claude-compatible stdio entry into a validated safe-default configuration", () => { + const entry = importableEntry(); const config = createImportedClientConfiguration({ configurationName: "posthog-work", document: JSON.stringify({ mcpServers: { - posthog: { - command: "npx", - args: ["--yes", "@posthog/mcp@1.2.3"] - } + posthog: entry } }), entry: "posthog" @@ -42,8 +46,8 @@ describe("client entry import", () => { defaultProfile: "default", upstream: { transport: "stdio", - command: "npx", - args: ["--yes", "@posthog/mcp@1.2.3"] + command: entry.command, + args: entry.args }, profiles: { default: {} }, security: { requireExplicitProfileForDestructive: true } @@ -51,7 +55,7 @@ describe("client entry import", () => { }); it("imports an exact-version pnpm dlx launch without treating dlx as the package", () => { - const config = createImportedClientConfiguration({ + const request = { configurationName: "posthog-work", document: JSON.stringify({ mcpServers: { @@ -62,7 +66,14 @@ describe("client entry import", () => { } }), entry: "posthog" - }); + } as const; + + if (process.platform === "win32") { + expect(() => createImportedClientConfiguration(request)).toThrow(ClientEntryImportError); + return; + } + + const config = createImportedClientConfiguration(request); expect(config.upstream).toMatchObject({ transport: "stdio", @@ -76,8 +87,8 @@ describe("client entry import", () => { { command: "bunx", args: ["--bun", "--no-install", "--silent", "@posthog/mcp@1.2.3"] }, { command: "pnpx", args: ["@posthog/mcp@1.2.3"] }, { command: "uvx", args: ["--isolated", "mcp-search-console@0.3.2"] } - ])("imports a known-safe exact package-runner launch: $command $args", ({ command, args }) => { - const config = createImportedClientConfiguration({ + ])("handles a known exact package-runner launch according to the platform boundary: $command $args", ({ command, args }) => { + const request = { configurationName: "static-runner", document: JSON.stringify({ mcpServers: { @@ -85,11 +96,38 @@ describe("client entry import", () => { } }), entry: "example" - }); + } as const; + + if (process.platform === "win32") { + expect(() => createImportedClientConfiguration(request)).toThrow(ClientEntryImportError); + return; + } + + const config = createImportedClientConfiguration(request); expect(config.upstream).toMatchObject({ command, args }); }); + it("rejects Windows client entries that would require a command-processor shim", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + try { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + for (const entry of [ + { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] }, + { command: "node", args: ["server.mjs"] }, + { command: "C:\\tools\\server.cmd", args: [] } + ]) { + expect(() => createImportedClientConfiguration({ + configurationName: "windows-shell-shim", + document: JSON.stringify({ mcpServers: { example: entry } }), + entry: "example" + })).toThrow(ClientEntryImportError); + } + } finally { + if (platformDescriptor !== undefined) Object.defineProperty(process, "platform", platformDescriptor); + } + }); + it.each([ { command: "npx", args: ["--yes", "-y", "@posthog/mcp@1.2.3"] }, { command: "bunx", args: ["--silent", "--verbose", "@posthog/mcp@1.2.3"] }, diff --git a/tests/console-application-service.test.ts b/tests/console-application-service.test.ts index ddb3dc9b..204eb98a 100644 --- a/tests/console-application-service.test.ts +++ b/tests/console-application-service.test.ts @@ -15,6 +15,12 @@ import { createPrivateConsoleDirectory } from "./helpers/private-console-directo const temporaryDirectories: string[] = []; const connectionRef = "oauthconn:31cb3ef5-22cb-4bf7-9ebf-e4a2d32bf18c"; +function importableClientEntry(): { readonly command: string; readonly args: readonly string[] } { + return process.platform === "win32" + ? { command: process.execPath, args: ["server.mjs"] } + : { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] }; +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); @@ -223,19 +229,52 @@ describe("Console application service", () => { })).rejects.toMatchObject({ code: "CONFIG_ALREADY_EXISTS" }); }); + it("creates an explicitly acknowledged local stdio configuration through the same preset path", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-local-stdio-")); + temporaryDirectories.push(root); + const privateParent = await createPrivateConsoleDirectory(root); + const configPath = join(privateParent, "miftah", "miftah.json"); + const service = new ConsoleApplicationService(configPath); + const localCommand = process.platform === "win32" ? process.execPath : "node"; + + await expect(service.onboardPreset({ + name: "local-tools", + preset: "local-stdio", + localCommand, + args: ["server.mjs", "--stdio", "$pageview"], + cwd: root, + credentialEnv: "LOCAL_MCP_TOKEN", + acceptLocalCommand: true + })).resolves.toEqual({ + changed: true, + write: true, + name: "local-tools", + defaultProfile: "default", + profileCount: 1, + actions: ["Created Miftah configuration 'local-tools' from preset 'local-stdio'."] + }); + + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + upstream: { transport: "stdio", command: localCommand, args: ["server.mjs", "--stdio", "$pageview"], cwd: root }, + profiles: { default: { env: { LOCAL_MCP_TOKEN: "${LOCAL_MCP_TOKEN}" }, policy: "readonly" } }, + tooling: { unknownToolRisk: "destructive" } + }); + }); + it("creates a first-run configuration from one explicitly selected local stdio client entry without accepting credentials", async () => { const root = await mkdtemp(join(tmpdir(), "miftah-console-client-entry-")); temporaryDirectories.push(root); const privateParent = await createPrivateConsoleDirectory(root); const configPath = join(privateParent, "miftah", "miftah.json"); const service = new ConsoleApplicationService(configPath); + const entry = importableClientEntry(); await expect(service.onboardClientEntry({ name: "posthog-work", entry: "posthog", document: JSON.stringify({ mcpServers: { - posthog: { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] } + posthog: entry } }) })).resolves.toEqual({ @@ -249,7 +288,7 @@ describe("Console application service", () => { expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ name: "posthog-work", - upstream: { transport: "stdio", command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] }, + upstream: { transport: "stdio", command: entry.command, args: entry.args }, profiles: { default: { policy: "readonly" } }, tooling: { unknownToolRisk: "destructive" } }); diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index 92fa6098..3a63626a 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -12,6 +12,12 @@ import { const temporaryDirectories: string[] = []; +function importableClientEntry(): { readonly command: string; readonly args: readonly string[] } { + return process.platform === "win32" + ? { command: process.execPath, args: ["server.mjs"] } + : { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] }; +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); @@ -165,12 +171,13 @@ describe("Console dashboard application service", () => { configDirectory: directory, launcher: { command: process.execPath, args: ["serve"] } }); + const entry = importableClientEntry(); await expect(service.onboardClientEntry({ name: "posthog-work", entry: "posthog", document: JSON.stringify({ - mcpServers: { posthog: { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] } } + mcpServers: { posthog: entry } }) })).resolves.toMatchObject({ name: "posthog-work", defaultProfile: "default" }); expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index 756200c2..5b1f48df 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -18,6 +18,12 @@ import { const temporaryDirectories: string[] = []; +function importableClientEntry(): { readonly command: string; readonly args: readonly string[] } { + return process.platform === "win32" + ? { command: process.execPath, args: ["server.mjs"] } + : { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] }; +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); @@ -100,7 +106,10 @@ async function bootstrapSession(server: Awaited> { +async function submitPresetFormWithStaleValue( + javascript: string, + suppliedValues?: Readonly> +): Promise> { type SubmitListener = (event: { readonly preventDefault: () => void }) => void | Promise; class FakeForm { readonly listeners = new Map(); @@ -108,7 +117,8 @@ async function submitPresetFormWithStaleValue(javascript: string): Promise void>(); + constructor(public value: string) {} + addEventListener(name: string, listener: () => void): void { this.listeners.set(name, listener); } @@ -138,7 +149,7 @@ async function submitPresetFormWithStaleValue(javascript: string): Promise = []; runInNewContext(javascript, { document: { @@ -173,10 +184,12 @@ async function submitPresetFormWithStaleValue(javascript: string): Promise undefined }); @@ -610,7 +623,7 @@ function observePresetFieldConstraintState(javascript: string): { querySelectorAll(selector: string): readonly unknown[] { if (selector === "input") return this.controls.filter((control): control is FakeInput => control instanceof FakeInput); - if (selector === "input, select") return this.controls; + if (selector === "input, select, textarea") return this.controls; return []; } } @@ -653,7 +666,7 @@ function observePresetFieldConstraintState(javascript: string): { HTMLElement: FakeElement, HTMLInputElement: FakeInput, HTMLButtonElement: FakeElement, - HTMLTextAreaElement: FakeElement, + HTMLTextAreaElement: class {}, Element: FakeElement }); @@ -697,6 +710,9 @@ describe("local Console control server", () => { expect(html).toContain("Upstream-owned auth"); expect(html).toContain("Unsupported state"); expect(html).toContain("Set up an MCP"); + expect(html).toContain("Local executable + argument array"); + expect(html).toContain("Remote HTTPS MCP endpoint"); + expect(html).toContain("acceptLocalCommand"); expect(html).toContain('id="preset-onboarding-view"'); expect(html).toContain('id="client-entry-onboarding-view"'); expect(html).toContain('id="client-entry-onboarding-form"'); @@ -722,12 +738,31 @@ describe("local Console control server", () => { expect(javascript).toContain("/api/v1/sessions"); expect(javascript).toContain("/api/v1/onboarding/native-oauth"); expect(javascript).toContain("/api/v1/onboarding/preset"); + expect(javascript).toContain("local-stdio"); + expect(javascript).toContain("acceptLocalCommand"); expect(javascript).toContain("/api/v1/onboarding/client-entry"); await expect(submitPresetFormWithStaleValue(javascript)).resolves.toEqual({ name: "analytics", preset: "generic", credentialEnv: "ANALYTICS_TOKEN" }); + await expect(submitPresetFormWithStaleValue(javascript, { + name: "local-tools", + preset: "local-stdio", + localCommand: "node", + args: "server.mjs\n\n--stdio\n$pageview", + cwd: "/Users/example/local-tools", + credentialEnv: "LOCAL_MCP_TOKEN", + acceptLocalCommand: "true" + })).resolves.toEqual({ + name: "local-tools", + preset: "local-stdio", + localCommand: "node", + args: ["server.mjs", "", "--stdio", "$pageview"], + cwd: "/Users/example/local-tools", + credentialEnv: "LOCAL_MCP_TOKEN", + acceptLocalCommand: true + }); await expect(submitMultiAccountGscPresetForm(javascript)).resolves.toMatchObject({ request: { name: "gsc", @@ -995,6 +1030,70 @@ describe("local Console control server", () => { } }); + it("requires explicit acknowledgement before the first-run API stores a local stdio argument array", async () => { + const server = await startConsoleServer(configPath, { + bootstrapCredential: "test-only-bootstrap-credential", + allowMissingConfig: true + }); + + try { + const session = await bootstrapSession(server); + const endpoint = new URL("/api/v1/onboarding/preset", server.url); + const localCommand = process.platform === "win32" ? process.execPath : "node"; + const request = { + name: "local-tools", + preset: "local-stdio", + localCommand, + args: ["server.mjs", "--stdio", "$pageview"], + cwd: tmpdir(), + credentialEnv: "LOCAL_MCP_TOKEN", + acceptLocalCommand: true + }; + + const missingAcknowledgement = await fetch(endpoint, { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify({ ...request, acceptLocalCommand: undefined }) + }); + expect(missingAcknowledgement.status).toBe(422); + await expect(readFile(configPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + + const created = await fetch(endpoint, { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify(request) + }); + expect(created.status).toBe(201); + expect(await created.json()).toEqual({ + data: { + changed: true, + write: true, + name: "local-tools", + defaultProfile: "default", + profileCount: 1, + actions: ["Created Miftah configuration 'local-tools' from preset 'local-stdio'."] + } + }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + upstream: { transport: "stdio", command: localCommand, args: ["server.mjs", "--stdio", "$pageview"] }, + profiles: { default: { env: { LOCAL_MCP_TOKEN: "${LOCAL_MCP_TOKEN}" }, policy: "readonly" } }, + tooling: { unknownToolRisk: "destructive" } + }); + } finally { + await server.close(); + } + }); + it("imports one selected local stdio client entry through a CSRF-protected no-secret endpoint", async () => { const server = await startConsoleServer(configPath, { bootstrapCredential: "test-only-bootstrap-credential", @@ -1143,6 +1242,7 @@ describe("local Console control server", () => { expect(JSON.stringify(advancedManualBody)).not.toContain("craftmyletter"); await expect(readFile(configPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + const entry = importableClientEntry(); const created = await fetch(endpoint, { method: "POST", headers: { @@ -1156,7 +1256,7 @@ describe("local Console control server", () => { entry: "posthog", document: JSON.stringify({ mcpServers: { - posthog: { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] } + posthog: entry } }) }) @@ -1190,7 +1290,7 @@ describe("local Console control server", () => { try { const session = await bootstrapSession(server); const endpoint = new URL("/api/v1/onboarding/client-entry", server.url); - const entry = JSON.stringify({ mcpServers: { example: { command: "node", args: ["server.mjs"] } } }); + const entry = JSON.stringify({ mcpServers: { example: importableClientEntry() } }); const document = `${entry}${" ".repeat(64 * 1024 - Buffer.byteLength(entry, "utf8"))}`; const request = { name: "maximum-document", diff --git a/tests/init-command.test.ts b/tests/init-command.test.ts index a80e4324..ae52b41b 100644 --- a/tests/init-command.test.ts +++ b/tests/init-command.test.ts @@ -16,12 +16,12 @@ interface TtyStreams { class StreamTranscript { #contents = ""; - #waiters: Array<{ readonly text: string; readonly resolve: () => void }> = []; + #waiters: Array<{ readonly text: string; readonly occurrences: number; readonly resolve: () => void }> = []; append(chunk: Buffer | string): void { this.#contents += chunk.toString(); this.#waiters = this.#waiters.filter((waiter) => { - if (!this.#contents.includes(waiter.text)) return true; + if (this.#contents.split(waiter.text).length - 1 < waiter.occurrences) return true; waiter.resolve(); return false; }); @@ -31,10 +31,10 @@ class StreamTranscript { return this.#contents; } - waitFor(text: string): Promise { - if (this.#contents.includes(text)) return Promise.resolve(); + waitFor(text: string, occurrences = 1): Promise { + if (this.#contents.split(text).length - 1 >= occurrences) return Promise.resolve(); return new Promise((resolve) => { - this.#waiters.push({ text, resolve }); + this.#waiters.push({ text, occurrences, resolve }); }); } } @@ -59,8 +59,8 @@ function commandContext(streams: TtyStreams) { }; } -async function answer(streams: TtyStreams, prompt: string, value: string): Promise { - await streams.transcript.waitFor(prompt); +async function answer(streams: TtyStreams, prompt: string, value: string, occurrences = 1): Promise { + await streams.transcript.waitFor(prompt, occurrences); streams.input.write(`${value}\n`); } @@ -200,6 +200,38 @@ describe("init command", () => { expect(config.upstream?.args).toEqual(["--yes", "@scope/server@1.2.3"]); }); + it("guides a reviewed local executable through discrete argv prompts without shell parsing", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "wizard-local.json"); + const localCommand = process.platform === "win32" ? process.execPath : "node"; + const command = runInitCommand( + { interactive: true, name: "wizard-local", preset: "local-stdio", output: "wizard-local.json" }, + commandContext(streams) + ); + + await answer(streams, "Local executable (no shell)", localCommand); + await answer(streams, "Add a local argument? (yes/no) [no]", "yes"); + await answer(streams, "Argument 1", "server.mjs"); + await answer(streams, "Add a local argument? (yes/no) [no]", "yes", 2); + await answer(streams, "Argument 2", "$pageview"); + await answer(streams, "Add a local argument? (yes/no) [no]", "no", 3); + await answer(streams, "Working directory (absolute path, optional)", outputRoot); + await answer(streams, "Credential environment variable name (optional)", "LOCAL_MCP_TOKEN"); + await answer( + streams, + "Miftah will not run this during setup. It will save this executable and argument array without a shell.", + "yes" + ); + await answer(streams, "Client", ""); + await command; + streams.input.end(); + + expect(JSON.parse(await readFile(output, "utf8"))).toMatchObject({ + upstream: { transport: "stdio", command: localCommand, args: ["server.mjs", "$pageview"], cwd: outputRoot }, + profiles: { default: { env: { LOCAL_MCP_TOKEN: "${LOCAL_MCP_TOKEN}" }, policy: "readonly" } } + }); + }); + it("preserves a supported streamable HTTP credential header prefix", async () => { const streams = createStreams(); const output = resolve(outputRoot, "wizard-http.json"); diff --git a/tests/preset-catalog.test.ts b/tests/preset-catalog.test.ts index 4a5cb9fb..fbd86093 100644 --- a/tests/preset-catalog.test.ts +++ b/tests/preset-catalog.test.ts @@ -1,5 +1,5 @@ import { isAbsolute, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { buildPresetConfig, PRESET_CATALOG, @@ -13,10 +13,19 @@ function serializedConfig(config: unknown): string { } const gscClientSecretsFile = resolve("fixtures", "gsc", "client-secrets.json"); +const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + +function localExecutable(): string { + return process.platform === "win32" ? process.execPath : "node"; +} + +afterEach(() => { + if (platformDescriptor !== undefined) Object.defineProperty(process, "platform", platformDescriptor); +}); describe("preset catalog", () => { it("publishes one versioned catalog with inspectable preset requirements", () => { - expect(PRESET_CATALOG.version).toBe("2"); + expect(PRESET_CATALOG.version).toBe("3"); expect(Object.keys(PRESET_CATALOG.presets)).toEqual([ "generic", "github", @@ -24,10 +33,13 @@ describe("preset catalog", () => { "google-search-console", "generic-npx", "generic-docker", + "local-stdio", "streamable-http" ]); expect(PRESET_CATALOG.presets["generic-npx"].requirements.npmPackage).toBe("required"); expect(PRESET_CATALOG.presets["generic-docker"].requirements.dockerImage).toBe("required"); + expect(PRESET_CATALOG.presets["local-stdio"].requirements.localCommand).toBe("required"); + expect(PRESET_CATALOG.presets["local-stdio"].requirements.acceptLocalCommand).toBe("required"); expect(PRESET_CATALOG.presets["streamable-http"].requirements.url).toBe("required"); expect(PRESET_CATALOG.presets["google-search-console"].requirements.oauthClientSecretsFile).toBe("required"); }); @@ -49,6 +61,12 @@ describe("preset catalog", () => { dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", credentialEnv: "DOCKER_SERVER_TOKEN" }), + buildPresetConfig("local", "local-stdio", { + localCommand: localExecutable(), + args: ["server.mjs"], + acceptLocalCommand: true, + credentialEnv: "LOCAL_MCP_TOKEN" + }), buildPresetConfig("remote", "streamable-http", { url: "https://mcp.example.com/v1", credentialEnv: "REMOTE_TOKEN", @@ -226,6 +244,68 @@ describe("preset catalog", () => { })).not.toThrow(); }); + it("requires explicit acknowledgement before creating a restricted local stdio configuration", () => { + const localCommand = localExecutable(); + const args = [resolve("fixtures", "fake-local-mcp.mjs"), "--stdio", "$pageview"]; + + expect(() => buildPresetConfig("local-tools", "local-stdio", { localCommand, args })).toThrow(PresetCatalogError); + + const config = buildPresetConfig("local-tools", "local-stdio", { + localCommand, + args, + acceptLocalCommand: true, + credentialEnv: "LOCAL_MCP_TOKEN" + }); + + expect(config.upstream).toEqual({ transport: "stdio", command: localCommand, args }); + expect(config.profiles.default).toEqual({ + description: "Locally configured MCP executable; configure authentication with secret references when required.", + env: { LOCAL_MCP_TOKEN: "${LOCAL_MCP_TOKEN}" }, + policy: "readonly" + }); + expect(config.policies).toEqual({ + readonly: { allowRisk: ["read"], denyRisk: ["write", "destructive"] } + }); + expect(config.tooling?.unknownToolRisk).toBe("destructive"); + expect(() => validateConfig(config)).not.toThrow(); + }); + + it("rejects shell-shaped, credential-bearing, and non-native local stdio inputs without echoing them", () => { + const secret = "local-secret-that-must-not-appear"; + const base = { localCommand: localExecutable(), args: ["server.mjs"], acceptLocalCommand: true } as const; + const foreignPath = process.platform === "win32" ? "/tmp/server" : "C:\\tools\\server.exe"; + const unsafe = [ + { ...base, localCommand: "/bin/sh" }, + { ...base, localCommand: "env" }, + { ...base, localCommand: `node?token=${secret}` }, + { ...base, args: [`--token=${secret}`] }, + { ...base, args: [`https://example.test/mcp?signature=${secret}`] }, + { ...base, args: ["${LOCAL_MCP_TOKEN}"] }, + { ...base, args: ["--config=${LOCAL_MCP_CONFIG}"] }, + { ...base, cwd: "relative-directory" }, + { ...base, cwd: foreignPath } + ]; + + for (const options of unsafe) { + try { + buildPresetConfig("local-tools", "local-stdio", options); + throw new Error("Expected local stdio input to be rejected."); + } catch (error) { + expect(error).toBeInstanceOf(PresetCatalogError); + expect(error instanceof Error ? error.message : "").not.toContain(secret); + } + } + }); + + it("requires a direct Windows binary for local stdio instead of a command-processor shim", () => { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + const base = { args: ["server.mjs"], acceptLocalCommand: true } as const; + + for (const localCommand of ["node", "server.cmd", "server.bat"]) { + expect(() => buildPresetConfig("local-tools", "local-stdio", { ...base, localCommand })).toThrow(PresetCatalogError); + } + }); + it("accepts only safe streamable HTTP credential header inputs", () => { const config = buildPresetConfig("remote", "streamable-http", { url: "https://mcp.example.com/v1", diff --git a/tests/preset-docs-contract.test.ts b/tests/preset-docs-contract.test.ts index ddef3214..ebc8b663 100644 --- a/tests/preset-docs-contract.test.ts +++ b/tests/preset-docs-contract.test.ts @@ -94,10 +94,17 @@ describe("preset documentation contract", () => { "--header-name", "--header-prefix", "--oauth-client-secrets-file", + "--local-command", + "--arg", + "--cwd", + "--accept-local-command", "--verify" ]) { expect(cli).toContain(option); } + expect(compatibility).toContain("Each `--arg` is one literal argv element"); + expect(compatibility).toContain("never uses a shell"); + expect(compatibility).toContain("direct native absolute `.exe` or `.com` binary on Windows"); expect(compatibility).not.toContain("runtime construction"); expect(compatibility).toContain( "Miftah does not generate equivalent per-tool client permission guidance for Claude Desktop, Cursor, or VS Code" @@ -106,11 +113,13 @@ describe("preset documentation contract", () => { const issue19 = changelogIssueEntry(changelog, 19); const issue98 = changelogIssueEntry(changelog, 98); const issue87 = changelogIssueEntry(changelog, 87); + const issue204 = changelogIssueEntry(changelog, 204); expect(issue19).toContain("catalog"); expect(issue19).toContain("onboarding"); expect(issue98).toContain("permission"); expect(issue98).not.toContain("runtime construction"); expect(issue87).toContain("upstream-owned"); + expect(issue204).toContain("local STDIO"); expect(providerAdapters).toContain("## Google Search Console pilot"); expect(providerAdapters).toContain("Credential ownership | Upstream"); expect(providerAdapters).toContain("Each generated Miftah configuration/profile pair passes a distinct `GSC_CONFIG_DIR`"); diff --git a/tests/readme-product-contract.test.ts b/tests/readme-product-contract.test.ts index f8d21f22..26136f42 100644 --- a/tests/readme-product-contract.test.ts +++ b/tests/readme-product-contract.test.ts @@ -76,6 +76,13 @@ describe("product README", () => { expect(readme).toContain("## Add another MCP"); expect(readme).toContain("--preset generic-npx"); expect(readme).toContain("--npm-package"); + expect(readme).toContain("### Reviewed local executable template"); + expect(readme).toContain("--preset local-stdio"); + expect(readme).toContain("--local-command node"); + expect(readme).toContain("--arg=--stdio"); + expect(readme).toContain("--accept-local-command"); + expect(readme).toContain("Miftah does not run the local executable during setup"); + expect(readme).toContain("On Windows, provide an absolute `.exe` or `.com` binary"); expect(readme).toContain("## OAuth and the local dashboard"); expect(readme).toContain("Native remote OAuth"); expect(readme).toContain("Upstream-owned OAuth"); @@ -109,6 +116,7 @@ describe("product README", () => { expect(readme).toContain("`--verify` is rejected because an imported entry has no reviewed provider adapter"); expect(readme).toContain("static launch grammar"); expect(readme).toContain("exact version"); + expect(readme).toContain("On Windows, the import path accepts only a direct absolute `.exe` or `.com` executable"); expect(readme).toContain("advanced manual setup"); }); @@ -188,7 +196,7 @@ describe("product README", () => { expect(Object.keys(CLI_COMMANDS), `unknown README command: ${command}`).toContain(command); const help = renderCommandHelp(command); const supportedFlags = new Set(help.match(/--[a-z][a-z-]*/gu) ?? []); - for (const flag of line.match(/--[a-z][a-z-]*/gu) ?? []) { + for (const flag of [...line.matchAll(/(?:^|\s)(--[a-z][a-z-]*)/gu)].map((flagMatch) => flagMatch[1]!)) { expect(supportedFlags, `unsupported README flag for ${command}: ${flag}`).toContain(flag); } } diff --git a/tests/setup-command.test.ts b/tests/setup-command.test.ts index 0257598a..76bec445 100644 --- a/tests/setup-command.test.ts +++ b/tests/setup-command.test.ts @@ -15,6 +15,12 @@ import { validateConfig } from "../src/config/validate-config.js"; const outputRoot = resolve(process.cwd(), ".setup-command-test-output"); +function importableClientEntry(): { readonly command: string; readonly args: readonly string[] } { + return process.platform === "win32" + ? { command: process.execPath, args: ["server.mjs"] } + : { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] }; +} + class StreamTranscript { #contents = ""; #waiters: Array<{ readonly text: string; readonly occurrences: number; readonly resolve: () => void }> = []; @@ -98,12 +104,57 @@ describe("setup command", () => { } }); + it("stores a reviewed local argv configuration without launching or probing it", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "local-tools.json"); + const localCommand = process.platform === "win32" ? process.execPath : "node"; + const command = runSetupCommand({ + name: "local-tools", + preset: "local-stdio", + output: "local-tools.json", + localCommand, + args: ["server.mjs", "--stdio", "$pageview"], + cwd: outputRoot, + credentialEnv: "LOCAL_MCP_TOKEN" + }, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await answer( + streams, + "Miftah will not run this during setup. It will save this executable and argument array without a shell.", + "yes" + ); + await answer(streams, "Client", ""); + + await expect(command).resolves.toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); + streams.input.end(); + + expect(JSON.parse(await readFile(output, "utf8"))).toMatchObject({ + upstream: { transport: "stdio", command: localCommand, args: ["server.mjs", "--stdio", "$pageview"], cwd: outputRoot }, + profiles: { default: { env: { LOCAL_MCP_TOKEN: "${LOCAL_MCP_TOKEN}" }, policy: "readonly" } }, + tooling: { unknownToolRisk: "destructive" } + }); + expect(profileReadinessMocks.run).not.toHaveBeenCalled(); + expect(streams.transcript.contents).toContain( + "Local command review: 1 executable with 3 argument(s); working directory: configured; credential environment: configured." + ); + expect(streams.transcript.contents).not.toContain("$pageview"); + }); + it("imports one explicitly selected local stdio entry without modifying the source client file", async () => { const source = resolve(outputRoot, "claude-desktop.json"); const output = resolve(outputRoot, "posthog.json"); + const entry = importableClientEntry(); const document = JSON.stringify({ mcpServers: { - posthog: { command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] } + posthog: entry } }); await mkdir(outputRoot, { recursive: true, mode: 0o700 }); @@ -130,7 +181,7 @@ describe("setup command", () => { expect(result).toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); expect(validateConfig(JSON.parse(await readFile(output, "utf8")))).toMatchObject({ name: "posthog-work", - upstream: { transport: "stdio", command: "npx", args: ["--yes", "@posthog/mcp@1.2.3"] }, + upstream: { transport: "stdio", command: entry.command, args: entry.args }, profiles: { default: { policy: "readonly" } } }); expect(await readFile(source, "utf8")).toBe(document);