diff --git a/CHANGELOG.md b/CHANGELOG.md index bb95d0e6..9b8ceeba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +## [0.3.0] - 2026-07-18 + +### Changed + +- [#96](https://github.com/mohanagy/miftah/issues/96) Confirmation-required MCP calls now default to human form elicitation and fail closed when the client cannot present that form. The former self-approval bearer path is available only through explicit `security.approvalMode: "delegated-agent"`, is hidden from normal tool discovery, and is audited as delegated authorization rather than human proof; approval records are bound to that form or delegated mechanism. +- [#97](https://github.com/mohanagy/miftah/issues/97) The generated multi-profile GitHub preset now requires exact profile-switch confirmation and explicit current-session selection before destructive work, preventing a silent profile change or implicit selection from satisfying that boundary. +- [#98](https://github.com/mohanagy/miftah/issues/98) Management tools now publish reviewed MCP behavioral annotations from one contract table, including `miftah_list_approvals` as a read-only local observation. `miftah init --client claude-code` prints exact, manually merged Claude Code permission guidance for visible privileged management tools without modifying client settings. + ## [0.2.1] - 2026-07-17 ### Fixed diff --git a/README.md b/README.md index 21cf0328..4009b5ab 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The default endpoint is `http://127.0.0.1:3000/mcp`; see [HTTP server transport] Profiles are named credential environments. Keep secret values outside JSON and use the exact generated references in the checked-in [GitHub](examples/github.miftah.json), [Sentry](examples/sentry.miftah.json), or [generic reference](examples/generic.miftah.json) example. The strict catalog pins GitHub to `ghcr.io/github/github-mcp-server:v1.5.0` with its documented read-only tool configuration; it does not claim a digest. The [compatibility matrix](docs/presets-and-clients.md) describes safe promotion and deployment recording for that tag. -Claude can call `miftah_list_profiles`, `miftah_current_profile`, `miftah_use_profile`, `miftah_reset_profile`, `miftah_lock_profile`, `miftah_unlock_profile`, `miftah_profile_info`, `miftah_health`, `miftah_validate_config`, `miftah_list_upstream_tools`, `miftah_restart_profile`, `miftah_verify_identity`, `miftah_route_preview`, `miftah_list_approvals`, `miftah_approve`, and `miftah_deny`. In a multi-upstream bundle, upstream tools are exposed as `__`. For a single upstream whose exact tool name collides with a reserved management name, the default is `upstream_`; `tooling.collisionStrategy: "fail"` instead rejects it. Other upstream names that merely start with `miftah_` are not reserved. After a profile change, restart, upstream failure, recovery, or upstream list-change notification that changes a public capability surface, MCP clients receive list-change notifications and should re-list the affected tools, resources, resource templates, or prompts before relying on cached capabilities. +Claude can call `miftah_list_profiles`, `miftah_current_profile`, `miftah_use_profile`, `miftah_reset_profile`, `miftah_lock_profile`, `miftah_unlock_profile`, `miftah_profile_info`, `miftah_health`, `miftah_validate_config`, `miftah_list_upstream_tools`, `miftah_restart_profile`, `miftah_verify_identity`, `miftah_route_preview`, and `miftah_list_approvals`. `miftah_approve` and `miftah_deny` are intentionally advertised only when an operator explicitly sets `security.approvalMode` to `"delegated-agent"`; their names remain reserved in every mode. In a multi-upstream bundle, upstream tools are exposed as `__`. For a single upstream whose exact tool name collides with a reserved management name, the default is `upstream_`; `tooling.collisionStrategy: "fail"` instead rejects it. Other upstream names that merely start with `miftah_` are not reserved. After a profile change, restart, upstream failure, recovery, or upstream list-change notification that changes a public capability surface, MCP clients receive list-change notifications and should re-list the affected tools, resources, resource templates, or prompts before relying on cached capabilities. Active profile state is in-memory by default. `state.scope: "session"` resets on a new MCP transport; opt-in `workspace` or config-identity-namespaced `global` scope persists only safe selection metadata (the profile and timestamp) using atomic owner-restricted storage. Clients cannot choose a scope or state path. Optional runtime locks and risk leases are connection-bound and never enter that durable state. See [active profile state](docs/config.md#active-profile-state) for lock precedence, fallback diagnostics, and platform paths. @@ -117,7 +117,7 @@ Routing can use the active profile or rules matching tool arguments: } ``` -When several profiles match, Miftah refuses to guess. Use explicit profile switching for write and destructive actions. The same routing, policy, redaction, and audit pipeline applies to upstream tool calls, resource reads and subscriptions, and prompt retrieval. Policy patterns use each upstream tool's original name for tools, `resources/read` for reads and subscriptions, and `prompts/get` for prompt retrieval. A deny, blocked, or ambiguous decision is returned before Miftah forwards the request. A confirmation-required operation pauses for a connection-bound, one-time approval: form-capable MCP clients receive a generic boolean elicitation, while other clients receive a short-lived fallback bearer for `miftah_approve` or `miftah_deny`. Provider token scopes still matter: local policy cannot make a write-capable provider token read-only. Profiles that set a policy name must reference an existing entry in `policies`, while profiles with no `policy` field keep the default allow behavior. +When several profiles match, Miftah refuses to guess. Use explicit profile switching for write and destructive actions. The same routing, policy, redaction, and audit pipeline applies to upstream tool calls, resource reads and subscriptions, and prompt retrieval. Policy patterns use each upstream tool's original name for tools, `resources/read` for reads and subscriptions, and `prompts/get` for prompt retrieval. A deny, blocked, or ambiguous decision is returned before Miftah forwards the request. A confirmation-required operation pauses for a connection-bound, one-time approval. The default `security.approvalMode: "human"` uses a generic form only with clients that support MCP form elicitation; a client without that capability fails closed and receives no bearer. An operator may explicitly choose `"delegated-agent"` for automation, which exposes a short-lived bearer and `miftah_approve`/`miftah_deny`; that is delegated agent authorization, never proof of a human decision. Provider token scopes still matter: local policy cannot make a write-capable provider token read-only. Profiles that set a policy name must reference an existing entry in `policies`, while profiles with no `policy` field keep the default allow behavior. Miftah can also match bounded workspace metadata without treating a project file as configuration. It resolves a valid environment hint, then the nearest valid project-marker hint, then matching rules over tool arguments and collected context, then the configured fallback. Rules that select different profiles return `ROUTING_AMBIGUOUS`, and a context hint never authorizes a destructive operation that requires an explicit rule. `miftah_route_preview` and eligible audit records expose only sanitized routing evidence plus risk-classification source/confidence, not raw project environment values, upstream metadata, or project file contents. See [routing context](docs/config.md#routing-context) for the marker schema, root behavior, and evidence boundary. diff --git a/docs/architecture.md b/docs/architecture.md index e259d87b..886ea306 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ Before a configured local STDIO target starts, `ProfileRuntimeIsolation` derives The metadata-only routing context collector is the sole source of workspace context, profile hints, preview evidence, and proxied-operation audit evidence. It reads only allowlisted bounded metadata: file roots, cwd, two environment selectors, strict project markers, package/workspace fields, and a local Git origin. It separately derives canonical GitHub repository identifiers from safe package/workspace metadata and supported local remote forms; it never passes a raw context object to a matcher. The runtime configuration file remains separate from that input. Once initialized, `MiftahServer` capability-gates `roots/list`, caches only root URIs per client connection, and refreshes only on an advertised roots-list-changed notification. Every proxied operation and route preview receives one immutable snapshot; it uses that snapshot for rule matching, hint selection, fixed and plugin matcher projection, and redacted evidence so routing cannot change between those steps. -Every supported MCP request enters one outer audit scope. The scope records one terminal operation event on success or safe failure and also covers discovery/list failures, unknown names, and management tools that do not enter the proxy pipeline. `OperationPipeline` enriches proxied tool calls, resource reads, resource subscriptions, and prompt retrieval with captured source/target profile, upstream, routing, policy, risk, risk source/confidence, sanitized `routingEvidence`, and bounded `routingMatcherEvidence`; it does not emit its own record. It captures the source tool snapshot before awaiting policy work, normalizes only the four MCP behavioral booleans, and accepts them for classification only when the matching configured base upstream explicitly trusts tool annotations. The snapshot fingerprint includes client-visible annotations, so a routed profile with different metadata fails the same schema-compatibility guard as any other differing tool contract. It then captures the source profile state, resolves explicit hints and rules before the matcher band, runs the fixed in-tree registry and explicitly allowlisted routing plugins only through contained child hosts, uses `matcher:` or `matcher:plugin:` only before fallback, evaluates the selected profile policy, and checks a captured lease and explicit-selection boundary before resolving the exact target upstream route. Matcher ambiguity carries only canonical bounded evidence and never resolves an upstream; a matcher reason cannot satisfy an explicit destructive-operation rule. It rechecks that captured lease immediately before execution, so a later renewal cannot authorize an old request and a different routed profile cannot borrow it. Route preview remains side-effect-free: it examines only cached compatible snapshots and falls back conservatively when none exists. Tools retain their original upstream names for routing and policy compatibility while the matcher receives the client-visible name solely for exact provider-token recognition; resource reads and subscriptions use the stable policy name `resources/read`, while prompt retrieval uses `prompts/get`. Denied, blocked, and ambiguous operations never resolve or execute an upstream read/get route. Confirmation-required operations resolve only enough to bind the exact target, then enter a separate approval lifecycle before upstream session execution: state is connection-bound, the form-elicitation path carries only a generic boolean, and a fallback bearer can approve only the exact bound operation once. Approval audit records store lifecycle action and safe context, never the raw approval bearer or operation arguments. Upstream managers publish typed lifecycle transitions, which the server records as separate audit events without letting audit I/O interrupt cleanup or recovery. +Every supported MCP request enters one outer audit scope. The scope records one terminal operation event on success or safe failure and also covers discovery/list failures, unknown names, and management tools that do not enter the proxy pipeline. `OperationPipeline` enriches proxied tool calls, resource reads, resource subscriptions, and prompt retrieval with captured source/target profile, upstream, routing, policy, risk, risk source/confidence, sanitized `routingEvidence`, and bounded `routingMatcherEvidence`; it does not emit its own record. It captures the source tool snapshot before awaiting policy work, normalizes only the four MCP behavioral booleans, and accepts them for classification only when the matching configured base upstream explicitly trusts tool annotations. The snapshot fingerprint includes client-visible annotations, so a routed profile with different metadata fails the same schema-compatibility guard as any other differing tool contract. It then captures the source profile state, resolves explicit hints and rules before the matcher band, runs the fixed in-tree registry and explicitly allowlisted routing plugins only through contained child hosts, uses `matcher:` or `matcher:plugin:` only before fallback, evaluates the selected profile policy, and checks a captured lease and explicit-selection boundary before resolving the exact target upstream route. Matcher ambiguity carries only canonical bounded evidence and never resolves an upstream; a matcher reason cannot satisfy an explicit destructive-operation rule. It rechecks that captured lease immediately before execution, so a later renewal cannot authorize an old request and a different routed profile cannot borrow it. Route preview never forwards the hypothetical upstream operation, but it can invoke configured local routing plugins and therefore is not a permission-free operation. Tools retain their original upstream names for routing and policy compatibility while the matcher receives the client-visible name solely for exact provider-token recognition; resource reads and subscriptions use the stable policy name `resources/read`, while prompt retrieval uses the stable policy name `prompts/get`. Denied, blocked, and ambiguous operations never resolve or execute an upstream read/get route. Confirmation-required operations resolve only enough to bind the exact target, then enter a separate approval lifecycle before upstream session execution: state is connection-bound, the form-elicitation path carries only a generic boolean, and the default human mode fails closed when that form boundary is unavailable. An operator may explicitly select delegated-agent mode, which permits a bearer to approve only the exact bound operation once and is audited as delegated authorization rather than human proof. Approval audit records store lifecycle action, mechanism, and safe context, never the raw approval bearer or operation arguments. The management-tool contract supplies MCP behavioral annotations and exact client permission guidance, but those client hints do not replace server authorization. Upstream managers publish typed lifecycle transitions, which the server records as separate audit events without letting audit I/O interrupt cleanup or recovery. When rotation or integrity is configured, the audit journal layer serializes same-host append, rotation, retention, and reader snapshots with a kernel-released local lock, so an abruptly terminated writer cannot leave a permanent lock artifact. A managed journal is local to one host and must not be shared for concurrent writes across machines. It rotates only between complete JSONL batches, creates a new restrictive active file before accepting the next batch, and limits retention to validated managed regular archives. Integrity transitions persist a restrictive pending intent, validate the next physical state, then persist a committed decision before cleanup; recovery can discard only a pending unacknowledged suffix, while a committed transition must verify or fail closed. Readers and exports copy a coherent retained-segment set into private staging before parsing, so output work does not hold the writer lock. The optional integrity mode calculates a continuing SHA-256 chain over already-redacted records and records segment/retention continuation metadata; verification replays that state and returns the first safe broken segment/record/reason rather than audit contents. This local state is deliberately not a public library API or a substitute for an independently protected archive. diff --git a/docs/cli.md b/docs/cli.md index a2005378..d74cfa45 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -50,7 +50,7 @@ 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`; and `streamable-http` requires `--url` plus optional credential environment/header metadata. `--credential-env` is optional where supported. See [preset and client compatibility](presets-and-clients.md) for exact inputs, pins, provenance, and client snippets. -`--interactive` uses a wizard only when both input and output are TTYs. EOF or Ctrl-C cancels without writing a config. It asks for variable names and safe metadata, never secret values. 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. Regenerate the snippet 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 cancels without writing a config. It asks for variable names and safe metadata, never secret values. 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. ### `migrate-config` @@ -81,7 +81,7 @@ When identity verification is unconfigured, doctor records `DOCTOR_IDENTITY` as ### MCP profile management -`miftah_current_profile` returns the active/default profile plus safe selection metadata: `selectionSource`, `selectedAt`, and `scope`, plus `confirmation`, `lease`, and `lock`. When stored active-profile state is corrupt, stale, or unavailable, it additionally returns a stable `stateDiagnostic`; it never returns the state-file path or raw state contents. `miftah_use_profile` changes the active profile according to the configured scope. `miftah_reset_profile` returns to the configured default and writes that default when the scope is durable. When `security.requireProfileSwitchConfirmation` is enabled, a form-capable client confirms the exact switch with a generic boolean form; a fallback client must use the connection-bound bearer from the failed result with `miftah_approve` and retry the same request. +`miftah_current_profile` returns the active/default profile plus safe selection metadata: `selectionSource`, `selectedAt`, and `scope`, plus `confirmation`, `lease`, and `lock`. When stored active-profile state is corrupt, stale, or unavailable, it additionally returns a stable `stateDiagnostic`; it never returns the state-file path or raw state contents. `miftah_use_profile` changes the active profile according to the configured scope. `miftah_reset_profile` returns to the configured default and writes that default when the scope is durable. When `security.requireProfileSwitchConfirmation` is enabled, the default human mode requires a generic form from a form-capable client and otherwise fails closed. Only the explicit `security.approvalMode: "delegated-agent"` mode offers a connection-bound bearer through `miftah_approve` for the exact retry; it is automation authorization, not a human confirmation. `miftah_lock_profile` and `miftah_unlock_profile` are advertised for a stable MCP surface. Calls reject with `PROFILE_LOCKING_DISABLED` unless `security.allowProfileLockingFromMcp` is enabled. When enabled, they return JSON containing `profileState`, operate only for the current MCP connection, and never modify durable selection state. A configured `security.lockToProfile` cannot be changed with either tool. diff --git a/docs/config.md b/docs/config.md index 510eb90d..31422093 100644 --- a/docs/config.md +++ b/docs/config.md @@ -323,9 +323,9 @@ Every policy decision carries stable `riskSource` and `riskConfidence` values. R When `requireConfirmation` matches, Miftah binds one approval to the current MCP connection, the source and routed profiles, upstream, operation kind, exact target, a normalized argument digest, and a short expiry. It never forwards the protected operation until that approval is consumed, and a consumed, denied, expired, or prior-connection approval cannot be replayed. -Clients that advertise MCP **form elicitation** receive a generic boolean form (`approved`) and never receive the target arguments, digest, or bearer. Clients without that capability receive a one-time fallback bearer in `POLICY_CONFIRMATION_REQUIRED`; use `miftah_approve` with its `approval` field, then retry the exact operation. `miftah_deny` rejects that pending approval, and `miftah_list_approvals` returns only safe pending metadata. A fallback bearer is connection-bound and should be treated as a short-lived capability, not as a durable credential or a human-identity assertion. +The default `security.approvalMode` is `"human"`. Clients that advertise MCP **form elicitation** receive a generic boolean form (`approved`) and never receive target arguments, a digest, or a bearer. A client without form elicitation fails closed with `POLICY_CONFIRMATION_REQUIRED`; it receives no approval bearer and cannot self-approve. Set `security.approvalMode: "delegated-agent"` only for intentionally delegated automation. When form elicitation is unavailable, that explicit mode exposes a connection-bound, one-time bearer in `POLICY_CONFIRMATION_REQUIRED`; use `miftah_approve` or `miftah_deny` with its `approval` field, then retry the exact operation. Delegated-agent approval is not a human-identity assertion. `miftah_list_approvals` returns only safe pending metadata. -Approval lifecycle events record request, approval, denial, expiry, and consumption when audit logging is configured. They contain safe profile/upstream/operation metadata and expiry only; they never contain the fallback bearer or full operation arguments. +Approval lifecycle events record request, approval, denial, expiry, and consumption when audit logging is configured. They contain safe profile/upstream/operation metadata, expiry, and `approvalMechanism` (`"form"` or `"delegated-agent"`) only; they never contain an approval bearer or full operation arguments. Audit logging writes local JSONL when a path is configured. Every supported MCP request emits one terminal operation event with a request ID, per-process session ID, source/selected profiles, stable outcome/error code, duration, and any available upstream, routing, policy, and risk metadata; route previews and proxied operations add sanitized `routingEvidence` when a collector snapshot is available and canonical `routingMatcherEvidence` for a static matcher result or ambiguity. Wrapper and upstream lifecycle transitions emit separate event records. Arguments are excluded unless `includeArguments` is true, and all configured secret values are redacted before writing. Audit directories and files are created with owner-only permissions where the platform supports them. @@ -396,6 +396,7 @@ Profile changes can require connection-bound confirmation: ```json { "security": { + "approvalMode": "human", "requireProfileSwitchConfirmation": true, "allowProfileLockingFromMcp": true, "requireExplicitSelectionForDestructive": true @@ -411,7 +412,9 @@ Profile changes can require connection-bound confirmation: } ``` -`security.requireProfileSwitchConfirmation` makes each `miftah_use_profile` and `miftah_reset_profile` exact-action confirmation-bound. Form-capable MCP clients receive a generic boolean form. Other clients receive one short-lived fallback bearer for `miftah_approve` or `miftah_deny`, then must retry the exact same change; the fallback cannot bypass confirmation, change profile, source selection generation, or connection session. +`security.requireProfileSwitchConfirmation` makes each `miftah_use_profile` and `miftah_reset_profile` exact-action confirmation-bound. With the default `security.approvalMode: "human"`, form-capable MCP clients receive a generic boolean form and clients without form elicitation fail closed. The explicit `"delegated-agent"` mode instead allows a short-lived bearer through `miftah_approve` or `miftah_deny`, then requires the exact same change to be retried; it cannot bypass confirmation, change profile, source selection generation, or connection session. This mode authorizes an agent, not a human. + +The generated multi-profile GitHub preset enables `requireProfileSwitchConfirmation` and `requireExplicitSelectionForDestructive` by default. It therefore cannot silently switch a profile or let an implicit default/profile hint satisfy the destructive-selection boundary. Choose a form-capable MCP client for human confirmation, or explicitly opt in to delegated-agent automation after reviewing that trade-off. `security.allowProfileLockingFromMcp` is an explicit opt-in for `miftah_lock_profile` and `miftah_unlock_profile`. A runtime lock is connection-bound, in-memory, and clears for a new transport. It does not change `state` files. `security.lockToProfile` remains the stronger operator-controlled lock and cannot be removed through MCP. diff --git a/docs/library-api.md b/docs/library-api.md index 41c1b09d..bbd6e9fc 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -66,7 +66,7 @@ Miftah is pre-1.0. The following are public compatibility surfaces. Additive beh | Package root | The documented runtime and type exports on this page are public; internal deep imports are not. | | Plugin subpath | `@lubab/miftah/plugin-api` is an ABI-versioned authoring surface. A new incompatible ABI uses a new `apiVersion`; it does not silently reinterpret an existing one. | -The currently reserved MCP management names are `miftah_list_profiles`, `miftah_current_profile`, `miftah_use_profile`, `miftah_reset_profile`, `miftah_lock_profile`, `miftah_unlock_profile`, `miftah_profile_info`, `miftah_health`, `miftah_validate_config`, `miftah_list_upstream_tools`, `miftah_restart_profile`, `miftah_verify_identity`, `miftah_route_preview`, `miftah_list_approvals`, `miftah_approve`, and `miftah_deny`. In a multi-upstream bundle, every upstream tool uses `__`; for a single upstream whose exact tool name collides with a reserved management name, the default is `upstream_`, while `tooling.collisionStrategy: "fail"` rejects the collision. Other upstream names that merely start with `miftah_` are not reserved. +The currently reserved MCP management names are `miftah_list_profiles`, `miftah_current_profile`, `miftah_use_profile`, `miftah_reset_profile`, `miftah_lock_profile`, `miftah_unlock_profile`, `miftah_profile_info`, `miftah_health`, `miftah_validate_config`, `miftah_list_upstream_tools`, `miftah_restart_profile`, `miftah_verify_identity`, `miftah_route_preview`, `miftah_list_approvals`, `miftah_approve`, and `miftah_deny`. The two decision tools are advertised only with explicit `security.approvalMode: "delegated-agent"`; their names remain reserved and direct calls fail when delegated approval is disabled. In a multi-upstream bundle, every upstream tool uses `__`; for a single upstream whose exact tool name collides with a reserved management name, the default is `upstream_`, while `tooling.collisionStrategy: "fail"` rejects the collision. Other upstream names that merely start with `miftah_` are not reserved. For configuration format retirement specifically, see the longer [format migration window](config.md#configuration-version-compatibility-and-migration). For plugin ABI retirement, see [local plugin API compatibility](plugins.md#api-compatibility). diff --git a/docs/presets-and-clients.md b/docs/presets-and-clients.md index 4fe9f272..29b6efbf 100644 --- a/docs/presets-and-clients.md +++ b/docs/presets-and-clients.md @@ -3,7 +3,7 @@ This is the compatibility source of truth for generated `miftah init` configurations and client snippets. - Catalog version: `1` -- Miftah package version: `0.2.1` +- Miftah package version: `0.3.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. Miftah itself requires Node.js `>=20`. That does not establish an upstream server's Node requirement. @@ -54,7 +54,7 @@ miftah init [name] \ [--header-name ] [--header-prefix ] ``` -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. Creation is exclusive and never overwrites an existing output path. +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. @@ -73,9 +73,19 @@ Miftah does not create any of these files. Copy only the generated JSON into the For Claude Code, the official [`claude mcp add` workflow](https://code.claude.com/docs/en/mcp) is a secondary way to manage its own configuration. Prefer the generated project `.mcp.json` here so the copied JSON remains reviewable and matches Miftah's output. +### Claude Code permission guidance + +The optional `init` permissions fragment is a manual, client-side review layer, not Miftah authorization. Merge it into one appropriate Claude Code settings file—`.claude/settings.local.json`, `.claude/settings.json`, or `~/.claude/settings.json`—rather than `.mcp.json`. It contains only exact `mcp____` rules derived from Miftah's visible management tools, never a wildcard that would also authorize upstream provider tools. Miftah still enforces profile policy and approvals for every MCP client and direct protocol call. + +The fragment is generated only when the configured server name matches the literal grammar `[A-Za-z0-9-]+`; otherwise `init` prints a manual-only warning rather than guessing an undocumented Claude Code permission-pattern escape. The normal human-confirmation default omits `miftah_approve` and `miftah_deny`; they appear only if the configured server explicitly enables delegated-agent approval. Review and merge this fragment yourself—Miftah deliberately does not modify a settings file that can contain unrelated local policy. + +Miftah does not generate equivalent per-tool client permission guidance for Claude Desktop, Cursor, or VS Code in this release. Their generated snippets configure the MCP server entry only. Do not treat a client enable/disable control as a replacement for Miftah's routing, policy, approval, and profile-selection enforcement; add equivalent guidance only after its client contract is explicitly reviewed and supported. + ## Client references - [Claude Code MCP](https://code.claude.com/docs/en/mcp) +- [Claude Code permissions](https://code.claude.com/docs/en/permissions) +- [Claude Code settings](https://code.claude.com/docs/en/settings) - [Cursor MCP](https://cursor.com/docs/mcp) - [VS Code MCP servers](https://code.visualstudio.com/docs/agent-customization/mcp-servers) - [VS Code MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration) diff --git a/docs/security.md b/docs/security.md index eeeffe5d..f2486300 100644 --- a/docs/security.md +++ b/docs/security.md @@ -41,7 +41,7 @@ Audit writes default to fail-closed: Miftah verifies the configured sink before When configured, audit rotation occurs only at completed JSONL batch boundaries and retention acts only on Miftah-managed, single-link regular archive names with stable file identities within the configured directory. Its kernel-released coordination is local to one host, so a managed journal must not be concurrently shared across machines through a network filesystem. The optional `sha256-chain` integrity mode hashes already-redacted records and tracks the retained segment set so `audit-verify` can identify the first safe broken record. It is tamper evidence, not a cryptographic signature, nonrepudiation mechanism, or remotely anchored immutable log: a party able to replace every local journal and its integrity metadata can defeat the evidence. Preserve compliance or incident evidence in an independently protected destination. `audit-export` is a user-invoked local transformation, not telemetry; it repeats redaction and drops stored `arguments` unless `--include-arguments` is explicitly requested. -An approval bearer is a short-lived capability for one pending, exact MCP operation, not a credential or proof of a human identity. Miftah binds it to the connection and target context, stores only keyed digests, and invalidates it on denial, consumption, expiry, or a new connection. Prefer MCP form elicitation when the client supports it. Do not copy a fallback bearer into logs, tickets, or another connection; approval audit events deliberately omit both the bearer and full operation arguments. +The default `security.approvalMode` is `"human"`: a confirmation-required request receives a generic MCP form only from a form-capable client, and a client without that capability fails closed without an approval bearer. An approval bearer exists only when an operator explicitly selects `"delegated-agent"` for automation. It is a short-lived capability for one pending, exact MCP operation, not a credential or proof of a human identity. Miftah binds it to the connection and target context, stores only keyed digests, and invalidates it on denial, consumption, expiry, or a new connection. Do not copy a delegated bearer into logs, tickets, or another connection; approval audit events deliberately omit it and full operation arguments while recording whether the approval mechanism was `form` or `delegated-agent`. Active-profile state is configuration-owned: MCP callers can select a profile but never a scope or storage path. `workspace` and `global` storage require explicit opt-in, derive a config-identity-namespaced location, and reject arbitrary state paths. A record contains only a format version, scope, config identity, selected profile, and timestamp; it never contains a secret, raw config path, provider output, or other MCP request data. Miftah writes it through a synced temporary file and atomic rename, applies owner-only permissions where supported, and reports a safe write failure without changing the in-memory selection. A configured profile lock wins over stored state; corrupt, stale, or unreadable state falls back safely and exposes only a stable diagnostic code. diff --git a/docs/threat-model.md b/docs/threat-model.md index 0a3f4bc8..e9562577 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -30,7 +30,7 @@ The forward-looking [OAuth and local Console design delta](oauth-console-threat- | --- | --- | --- | | Secret values, provider tokens, and provider output | Disclosure can grant access to an upstream account or infrastructure. | Canonical secret references, opt-in plaintext, configured/scoped child environments, bounded provider execution, and shared redaction. | | Configuration, profile selection, and credential-runtime copies | Unintended mutation or disclosure can select the wrong account or expose a credential file. | Configuration-owned paths, explicit persistence, restrictive runtime trees where supported, and fail-closed Windows isolation. | -| MCP session IDs, approval bearers, profile locks, and leases | These capabilities can alter which operation is allowed or selected. | Connection binding, bounded lifetimes, keyed digests for approval bearers, and exact-operation binding. | +| MCP session IDs, delegated approval bearers, profile locks, and leases | These capabilities can alter which operation is allowed or selected. | Connection binding, bounded lifetimes, keyed digests for delegated approval bearers, and exact-operation binding. Default human confirmation creates no bearer without MCP form elicitation. | | Route, policy, and tool-capability decisions | Incorrect routing or risk classification can send an operation to the wrong account or permit an unsafe operation. | Immutable snapshots, explicit configuration, conservative fallbacks, and ambiguity refusal. | | Audit and identity metadata | It supports incident investigation but can itself expose sensitive context or be altered locally. | Metadata-only, redacted journals, restrictive paths where available, and optional local tamper evidence. | | Availability of local runtimes and sessions | Resource exhaustion can deny access or destabilize a shared host. | Bounded request bodies, sessions, output capture, process deadlines, lifecycle state, and capacity limits. | @@ -40,7 +40,7 @@ The forward-looking [OAuth and local Console design delta](oauth-console-threat- | Actor or component | Trust assumption | Boundary implication | | --- | --- | --- | | Operator | Chooses Miftah configuration, providers, plugins, host controls, and deployment topology. | Operator configuration is privileged input; Miftah validates its shape but cannot make a malicious operator-approved target trustworthy. | -| MCP client | May send malformed, ambiguous, or socially engineered requests. | Miftah-owned configuration/state paths and provider routing are not caller-controlled. Approval bearers and runtime locks/leases are connection-bound; profile persistence is explicit and configuration-owned. Forwarded arguments remain an upstream boundary. | +| MCP client | May send malformed, ambiguous, or socially engineered requests. | Miftah-owned configuration/state paths and provider routing are not caller-controlled. Default human confirmation fails closed without an MCP form boundary; delegated approval bearers and runtime locks/leases are connection-bound. Neither mode cryptographically proves a human identity. Profile persistence is explicit and configuration-owned. Forwarded arguments remain an upstream boundary. | | Upstream MCP service | May be unavailable, misleading, compromised, or return sensitive data. | An upstream is a trust boundary even when configured by the operator. Tool annotations are not trusted for risk downgrades by default, and remote diagnostics retain safe codes rather than response bodies. | | Local provider or plugin child | May fail, hang, emit sensitive output, or behave maliciously. | It runs outside the Miftah process with limited inputs, but it is not thereby trusted or sandboxed. | | Same OS user, host administrator, local container daemon, and network | Can inspect or interfere with local resources beyond Miftah's authority. | A same OS user is not a strong isolation boundary; a local daemon and host security remain operator infrastructure. | diff --git a/examples/github.miftah.json b/examples/github.miftah.json index 566f8dd5..ecc8181f 100644 --- a/examples/github.miftah.json +++ b/examples/github.miftah.json @@ -52,7 +52,9 @@ }, "security": { "allowProfileSwitchingFromMcp": true, - "requireExplicitProfileForDestructive": true + "requireExplicitProfileForDestructive": true, + "requireProfileSwitchConfirmation": true, + "requireExplicitSelectionForDestructive": true }, "secrets": { "allowPlaintextSecrets": false diff --git a/package-lock.json b/package-lock.json index 994f5047..2d553df1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lubab/miftah", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lubab/miftah", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 8f1ae1da..28912194 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lubab/miftah", - "version": "0.2.1", + "version": "0.3.0", "description": "Wrap any MCP. Use the right account without reconnecting.", "keywords": [ "mcp", diff --git a/src/approvals/approval-store.ts b/src/approvals/approval-store.ts index 30c17665..a10e454d 100644 --- a/src/approvals/approval-store.ts +++ b/src/approvals/approval-store.ts @@ -8,6 +8,7 @@ const MAX_BEARER_ISSUE_ATTEMPTS = 32; const acceptsAnyBearer = (): boolean => true; export type ApprovalStatus = "pending" | "approved" | "denied" | "consumed" | "expired"; +export type ApprovalMechanism = "form" | "delegated-agent"; export interface ApprovalBinding { readonly sourceProfile: string; @@ -29,6 +30,7 @@ export interface ApprovalSummary { readonly upstream: string; readonly operation: string; readonly name: string; + readonly mechanism: ApprovalMechanism; readonly expiresAt: string; } @@ -56,6 +58,7 @@ interface ApprovalRecord { readonly upstream: string; readonly operation: string; readonly name: string; + readonly mechanism: ApprovalMechanism; readonly sessionId: string; readonly tokenDigests: Buffer[]; readonly bindingDigest: Buffer; @@ -100,7 +103,26 @@ export class ApprovalStore { this.sessionId = this.createSessionId(); } - request(binding: ApprovalBinding, isBearerSafe: (bearer: string) => boolean = acceptsAnyBearer): ApprovalRequest { + /** Issues a connection-bound approval token for an MCP form confirmation. */ + request(binding: ApprovalBinding, mechanism: "form"): ApprovalRequest; + /** Issues a connection-bound token for explicitly enabled delegated-agent confirmation. */ + request( + binding: ApprovalBinding, + mechanism: "delegated-agent", + isBearerSafe: (bearer: string) => boolean + ): ApprovalRequest; + /** Implements the explicit mechanism-bound approval request contract for both confirmation paths. */ + request( + binding: ApprovalBinding, + mechanism: ApprovalMechanism, + isBearerSafe: (bearer: string) => boolean = acceptsAnyBearer + ): ApprovalRequest { + if (mechanism !== "form" && mechanism !== "delegated-agent") { + throw new MiftahError( + "APPROVAL_MECHANISM_MISMATCH", + "APPROVAL_MECHANISM_MISMATCH: approval mechanism must be explicit" + ); + } this.expire(); const bindingDigest = this.digestBinding(binding); const pending = [...this.records.values()].find( @@ -109,7 +131,15 @@ export class ApprovalStore { record.status === "pending" && timingSafeEqual(record.bindingDigest, bindingDigest) ); - if (pending !== undefined) return this.issueToken(pending, false, isBearerSafe); + if (pending !== undefined) { + if (pending.mechanism !== mechanism) { + throw new MiftahError( + "APPROVAL_MECHANISM_MISMATCH", + "APPROVAL_MECHANISM_MISMATCH: pending approval cannot be reused through a different mechanism" + ); + } + return this.issueToken(pending, false, isBearerSafe); + } this.discardTerminalRecords(); if (this.records.size >= this.maxRecords) { throw new MiftahError("APPROVAL_LIMIT_EXCEEDED", "APPROVAL_LIMIT_EXCEEDED: too many outstanding approvals"); @@ -124,6 +154,7 @@ export class ApprovalStore { upstream: binding.upstream, operation: binding.operation, name: binding.displayName, + mechanism, sessionId: this.sessionId, tokenDigests: [], bindingDigest, @@ -158,6 +189,12 @@ export class ApprovalStore { /** Claims an accepted form approval without leaving an async window for a second consumer. */ approveAndConsume(token: string, binding: ApprovalBinding): ApprovalSummary { const record = this.requirePending(token); + if (record.mechanism !== "form") { + throw new MiftahError( + "APPROVAL_MECHANISM_MISMATCH", + "APPROVAL_MECHANISM_MISMATCH: delegated approval cannot be consumed as a form confirmation" + ); + } if (!timingSafeEqual(record.bindingDigest, this.digestBinding(binding))) { throw new MiftahError("APPROVAL_INVALID", "APPROVAL_INVALID: approval token does not match this operation"); } @@ -166,13 +203,14 @@ export class ApprovalStore { } /** Atomically claims one matching approved record before any asynchronous upstream work begins. */ - consume(binding: ApprovalBinding): ApprovalSummary | undefined { + consume(binding: ApprovalBinding, mechanism: ApprovalMechanism): ApprovalSummary | undefined { this.expire(); const bindingDigest = this.digestBinding(binding); const record = [...this.records.values()].find( (candidate) => candidate.sessionId === this.sessionId && candidate.status === "approved" && + candidate.mechanism === mechanism && timingSafeEqual(candidate.bindingDigest, bindingDigest) ); if (record === undefined) return undefined; @@ -291,6 +329,7 @@ export class ApprovalStore { } } +/** Returns the non-sensitive approval metadata that can be exposed to an MCP client or audit caller. */ function summary(record: ApprovalRecord): ApprovalSummary { return { id: record.id, @@ -300,6 +339,7 @@ function summary(record: ApprovalRecord): ApprovalSummary { upstream: record.upstream, operation: record.operation, name: record.name, + mechanism: record.mechanism, expiresAt: new Date(record.expiresAtMs).toISOString() }; } diff --git a/src/audit/audit-trail.ts b/src/audit/audit-trail.ts index b0cc5410..cbdf7bce 100644 --- a/src/audit/audit-trail.ts +++ b/src/audit/audit-trail.ts @@ -8,6 +8,7 @@ import type { AuditStatus, ProfileAuditAction } from "./audit-types.js"; +import type { ApprovalMechanism } from "../approvals/approval-store.js"; import type { RoutingContextEvidence, RoutingMatcherEvidence } from "../routing/routing-types.js"; export interface AuditOperationInput { @@ -58,6 +59,7 @@ export interface AuditApprovalInput { approvalId: string; approvalSessionId: string; approvalAction: ApprovalAuditAction; + approvalMechanism: ApprovalMechanism; sourceProfile: string; profile: string; upstream: string; @@ -144,6 +146,7 @@ export class AuditTrail { await this.writeRequiredBatch([this.approvalEvent(input), this.profileEvent(profile)]); } + /** Builds an approval audit event without including an approval bearer or invocation arguments. */ private approvalEvent(input: AuditApprovalInput): AuditEvent { return { wrapper: this.wrapperName, @@ -153,6 +156,7 @@ export class AuditTrail { approvalId: input.approvalId, approvalSessionId: input.approvalSessionId, approvalAction: input.approvalAction, + approvalMechanism: input.approvalMechanism, sourceProfile: input.sourceProfile, profile: input.profile, upstream: input.upstream, diff --git a/src/audit/audit-types.ts b/src/audit/audit-types.ts index 9115f5d8..6d5c9f0c 100644 --- a/src/audit/audit-types.ts +++ b/src/audit/audit-types.ts @@ -7,6 +7,7 @@ import type { import type { RoutingContextEvidence, RoutingMatcherEvidence } from "../routing/routing-types.js"; import type { IdentityStatus } from "../identity/identity-types.js"; import type { ProfileLeaseStatus, ProfileLockStatus, ProfileSelection } from "../profiles/profile-manager.js"; +import type { ApprovalMechanism } from "../approvals/approval-store.js"; export type AuditFailureMode = "fail-open" | "fail-closed"; @@ -67,6 +68,7 @@ export interface AuditEvent { approvalId?: string; approvalSessionId?: string; approvalAction?: ApprovalAuditAction; + approvalMechanism?: ApprovalMechanism; profileAction?: ProfileAuditAction; expiresAt?: string; lockToProfile?: string; diff --git a/src/cli/client-snippets.ts b/src/cli/client-snippets.ts index 76fcab59..d1921ac9 100644 --- a/src/cli/client-snippets.ts +++ b/src/cli/client-snippets.ts @@ -1,4 +1,5 @@ import { posix, win32 } from "node:path"; +import { managementToolDescriptors } from "../mcp/server/management-tools.js"; export const CLIENT_NAMES = Object.freeze(["claude-desktop", "claude-code", "cursor", "vscode"] as const); @@ -24,6 +25,23 @@ export interface ClientSnippet { json: string; } +export interface ClaudeCodePermissionGuidanceOptions { + /** Includes only management tools exposed by delegated-agent approval mode. */ + delegatedAgentApproval: boolean; +} + +export type ClaudeCodePermissionGuidance = + | { + kind: "snippet"; + target: { label: string }; + json: string; + } + | { + kind: "manual"; + target: { label: string }; + message: string; + }; + export class ClientSnippetError extends Error { constructor(message: string) { super(message); @@ -38,6 +56,10 @@ const targetLabels: Record = { vscode: "VS Code .vscode/mcp.json" }; +const claudeCodePermissionTarget = { label: "Claude Code settings permissions" }; +const literalClaudeCodeServerName = /^[A-Za-z0-9-]+$/u; + +/** Throws one stable input error for invalid client-snippet configuration. */ function inputError(message: string): never { throw new ClientSnippetError(message); } @@ -133,3 +155,31 @@ export function renderClientSnippets(selection: ClientSelection, input: ClientSn } return [renderClientSnippet(selection, input)]; } + +/** + * Renders defense-in-depth Claude Code review rules. These rules never replace + * Miftah's server-side authorization and are intentionally not written to a + * settings file because it may contain unrelated user policy. + */ +export function renderClaudeCodePermissionGuidance( + serverName: string, + options: ClaudeCodePermissionGuidanceOptions +): ClaudeCodePermissionGuidance { + if (typeof serverName !== "string" || !literalClaudeCodeServerName.test(serverName)) { + return { + kind: "manual", + target: claudeCodePermissionTarget, + message: + "Claude Code permission guidance was not generated because the configured server name is not a literal name matching [A-Za-z0-9-]+. Choose a literal server name and manually add exact management-tool rules to Claude Code settings." + }; + } + + const ask = managementToolDescriptors({ delegatedAgentApproval: options.delegatedAgentApproval }) + .filter((descriptor) => descriptor.askInClaudeCode) + .map((descriptor) => `mcp__${serverName}__${descriptor.name}`); + return { + kind: "snippet", + target: claudeCodePermissionTarget, + json: JSON.stringify({ permissions: { ask } }, undefined, 2) + }; +} diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index 3d0b4b74..de0f2850 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -68,6 +68,8 @@ export const ERROR_EXIT_CODES = { ROUTING_PLUGIN_CANCELLED: CLI_EXIT_CODES.policy, POLICY_BLOCKED: CLI_EXIT_CODES.policy, POLICY_CONFIRMATION_REQUIRED: CLI_EXIT_CODES.policy, + APPROVAL_DELEGATION_DISABLED: CLI_EXIT_CODES.policy, + APPROVAL_MECHANISM_MISMATCH: CLI_EXIT_CODES.policy, APPROVAL_INVALID: CLI_EXIT_CODES.policy, APPROVAL_EXPIRED: CLI_EXIT_CODES.policy, APPROVAL_NOT_PENDING: CLI_EXIT_CODES.policy, diff --git a/src/cli/init.ts b/src/cli/init.ts index d0fb21ca..27645a6e 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -9,9 +9,15 @@ import type { MiftahConfig } from "../config/types.js"; import { CLIENT_NAMES, ClientSnippetError, + renderClaudeCodePermissionGuidance, renderClientSnippets } from "./client-snippets.js"; -import type { ClientLauncher, ClientSelection, ClientSnippet } from "./client-snippets.js"; +import type { + ClaudeCodePermissionGuidance, + ClientLauncher, + ClientSelection, + ClientSnippet +} from "./client-snippets.js"; import { CliUsageError } from "./parse.js"; import type { CliOptions } from "./parse.js"; @@ -48,6 +54,7 @@ interface InitPlan { readonly output: string; readonly config: MiftahConfig; readonly snippets: readonly ClientSnippet[]; + readonly claudeCodePermissionGuidance?: ClaudeCodePermissionGuidance; } interface Cancellation { @@ -226,6 +233,7 @@ function isExistingOutputError(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST"; } +/** Resolves user input into a validated, side-effect-free plan for `miftah init`. */ function buildInitPlan(values: InitValues, context: InitCommandContext): InitPlan { const output = resolveOutputPath(values.output, context.cwd); if (values.client !== undefined && !isClientSelection(values.client)) { @@ -250,6 +258,7 @@ function buildInitPlan(values: InitValues, context: InitCommandContext): InitPla } let snippets: ClientSnippet[] = []; + let claudeCodePermissionGuidance: ClaudeCodePermissionGuidance | undefined; if (values.client !== undefined) { try { snippets = renderClientSnippets(values.client, { @@ -261,15 +270,34 @@ function buildInitPlan(values: InitValues, context: InitCommandContext): InitPla if (error instanceof ClientSnippetError) throw new CliUsageError(error.message); throw error; } + if (values.client === "claude-code" || values.client === "all") { + claudeCodePermissionGuidance = renderClaudeCodePermissionGuidance(config.name, { + delegatedAgentApproval: config.security?.approvalMode === "delegated-agent" + }); + } } - return { output, config, snippets }; + return { output, config, snippets, claudeCodePermissionGuidance }; } -function writeSnippets(output: Writable, snippets: readonly ClientSnippet[]): void { +/** Writes copy-paste client configuration and optional Claude Code review guidance without modifying client settings. */ +function writeSnippets( + output: Writable, + snippets: readonly ClientSnippet[], + claudeCodePermissionGuidance: ClaudeCodePermissionGuidance | undefined +): void { for (const snippet of snippets) { output.write(`${snippet.target.label} (${snippet.client}):\n${snippet.json}\n`); } + if (claudeCodePermissionGuidance === undefined) return; + if (claudeCodePermissionGuidance.kind === "manual") { + output.write(`${claudeCodePermissionGuidance.target.label}:\n${claudeCodePermissionGuidance.message}\n`); + return; + } + output.write( + `${claudeCodePermissionGuidance.target.label}:\n${claudeCodePermissionGuidance.json}\n` + + "Manually merge this fragment into .claude/settings.local.json, .claude/settings.json, or ~/.claude/settings.json. It is client-side defense in depth; Miftah enforces authorization.\n" + ); } /** Creates a strict catalog config and optionally prints copy-paste client snippets. */ @@ -289,5 +317,5 @@ export async function runInitCommand(options: InitCommandOptions, context: InitC throw error; } context.output.write(`Created ${plan.output}\n`); - writeSnippets(context.output, plan.snippets); + writeSnippets(context.output, plan.snippets, plan.claudeCodePermissionGuidance); } diff --git a/src/config/presets.ts b/src/config/presets.ts index 2d0b14ac..76384a66 100644 --- a/src/config/presets.ts +++ b/src/config/presets.ts @@ -60,13 +60,19 @@ function validateCredentialEnv(credentialEnv: unknown): void { } } -/** Builds fresh shared runtime defaults so generated configs never share mutable state. */ -function buildSharedDefaults(): SharedDefaults { +/** Builds fresh runtime defaults so generated configs never share mutable state. */ +function buildSharedDefaults(options: { multiProfile?: boolean } = {}): SharedDefaults { return { routing: { mode: "hybrid", fallback: "activeProfile", rules: [] }, security: { allowProfileSwitchingFromMcp: true, - requireExplicitProfileForDestructive: true + requireExplicitProfileForDestructive: true, + ...(options.multiProfile + ? { + requireProfileSwitchConfirmation: true, + requireExplicitSelectionForDestructive: true + } + : {}) }, secrets: { allowPlaintextSecrets: false }, process: { startupTimeoutMs: 30_000 }, @@ -178,7 +184,7 @@ function buildGithubPreset(name: string): MiftahConfig { } }, policies: buildReadonlyPolicies(), - ...buildSharedDefaults() + ...buildSharedDefaults({ multiProfile: true }) }; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 0f4dfc60..d337eec9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -477,6 +477,7 @@ const publicSecuritySchema = z redactSecrets: z.literal(true).optional(), allowProfileSwitchingFromMcp: z.boolean().optional(), requireProfileSwitchConfirmation: z.boolean().optional(), + approvalMode: z.enum(["human", "delegated-agent"]).optional(), allowProfileLockingFromMcp: z.boolean().optional(), requireExplicitProfileForDestructive: z.boolean().optional(), requireExplicitSelectionForDestructive: z.boolean().optional(), @@ -490,6 +491,7 @@ const securitySchema = z redactSecrets: z.boolean().optional(), allowProfileSwitchingFromMcp: z.boolean().optional(), requireProfileSwitchConfirmation: z.boolean().optional(), + approvalMode: z.enum(["human", "delegated-agent"]).optional(), allowProfileLockingFromMcp: z.boolean().optional(), requireExplicitProfileForDestructive: z.boolean().optional(), requireExplicitSelectionForDestructive: z.boolean().optional(), diff --git a/src/config/types.ts b/src/config/types.ts index 90a22669..5553c4c1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -216,6 +216,8 @@ export interface SecurityConfig { redactSecrets?: true; allowProfileSwitchingFromMcp?: boolean; requireProfileSwitchConfirmation?: boolean; + /** Human confirmation is the default; delegated-agent mode explicitly permits the bearer fallback. */ + approvalMode?: "human" | "delegated-agent"; allowProfileLockingFromMcp?: boolean; requireExplicitProfileForDestructive?: boolean; requireExplicitSelectionForDestructive?: boolean; diff --git a/src/mcp/server/management-tools.ts b/src/mcp/server/management-tools.ts new file mode 100644 index 00000000..126711b3 --- /dev/null +++ b/src/mcp/server/management-tools.ts @@ -0,0 +1,252 @@ +import type { Tool, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; + +export type ManagementToolInteraction = "observational" | "state-changing" | "external-probe"; +export type ManagementToolAvailability = "always" | "delegated-agent"; + +export interface ManagementToolInput { + readonly name: string; + readonly required: boolean; + readonly schema: Readonly>; +} + +/** One authoritative management-tool contract for MCP, onboarding, and client guidance. */ +export interface ManagementToolDescriptor { + readonly name: string; + readonly description: string; + readonly inputs: readonly ManagementToolInput[]; + readonly interaction: ManagementToolInteraction; + readonly availability: ManagementToolAvailability; + /** Client-side defense in depth only; Miftah remains the authorization boundary. */ + readonly askInClaudeCode: boolean; + readonly annotations: ToolAnnotations; +} + +interface ManagementToolOptions { + readonly delegatedAgentApproval: boolean; +} + +/** Creates the schema contract for one string-valued management-tool input. */ +const stringInput = (name: string, required = false): ManagementToolInput => ({ + name, + required, + schema: { type: "string" } +}); + +const readOnlyLocal: ToolAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false +}; + +const localMutation: ToolAnnotations = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false +}; + +const MANAGEMENT_TOOL_DESCRIPTORS_INTERNAL: readonly ManagementToolDescriptor[] = [ + { + name: "miftah_list_profiles", + description: "List configured profiles without exposing secrets.", + inputs: [], + interaction: "observational", + availability: "always", + askInClaudeCode: false, + annotations: readOnlyLocal + }, + { + name: "miftah_current_profile", + description: "Show the active and default profile.", + inputs: [], + interaction: "observational", + availability: "always", + askInClaudeCode: false, + annotations: readOnlyLocal + }, + { + name: "miftah_use_profile", + description: "Switch the active profile according to the configured state scope.", + inputs: [stringInput("profile", true)], + interaction: "state-changing", + availability: "always", + askInClaudeCode: true, + annotations: localMutation + }, + { + name: "miftah_reset_profile", + description: "Reset the active profile to the configured default.", + inputs: [], + interaction: "state-changing", + availability: "always", + askInClaudeCode: true, + annotations: localMutation + }, + { + name: "miftah_lock_profile", + description: "Lock the current profile for this MCP connection when enabled.", + inputs: [], + interaction: "state-changing", + availability: "always", + askInClaudeCode: true, + annotations: localMutation + }, + { + name: "miftah_unlock_profile", + description: "Unlock the current profile for this MCP connection when enabled.", + inputs: [], + interaction: "state-changing", + availability: "always", + askInClaudeCode: true, + annotations: { ...localMutation, idempotentHint: true } + }, + { + name: "miftah_profile_info", + description: "Show non-secret metadata for a profile.", + inputs: [stringInput("profile", true)], + interaction: "observational", + availability: "always", + askInClaudeCode: false, + annotations: readOnlyLocal + }, + { + name: "miftah_health", + description: "Show redacted wrapper and upstream health.", + inputs: [], + interaction: "observational", + availability: "always", + askInClaudeCode: false, + annotations: readOnlyLocal + }, + { + name: "miftah_validate_config", + description: "Validate the loaded wrapper configuration.", + inputs: [], + interaction: "observational", + availability: "always", + askInClaudeCode: false, + annotations: readOnlyLocal + }, + { + name: "miftah_list_upstream_tools", + description: "List tools discovered from an upstream profile.", + inputs: [stringInput("profile")], + interaction: "external-probe", + availability: "always", + askInClaudeCode: false, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true + } + }, + { + name: "miftah_restart_profile", + description: "Restart all upstream processes for a profile.", + inputs: [stringInput("profile", true)], + interaction: "state-changing", + availability: "always", + askInClaudeCode: true, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + } + }, + { + name: "miftah_verify_identity", + description: "Explicitly verify configured upstream identity.", + inputs: [stringInput("profile"), stringInput("upstream")], + interaction: "external-probe", + availability: "always", + askInClaudeCode: true, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true + } + }, + { + name: "miftah_route_preview", + description: "Preview routing for a hypothetical tool call.", + inputs: [ + stringInput("toolName", true), + { name: "args", required: false, schema: { type: "object", additionalProperties: true } } + ], + interaction: "external-probe", + availability: "always", + askInClaudeCode: true, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true + } + }, + { + name: "miftah_list_approvals", + description: "List safe metadata for approvals pending in this connection.", + inputs: [], + interaction: "observational", + availability: "always", + askInClaudeCode: false, + annotations: readOnlyLocal + }, + { + name: "miftah_approve", + description: "Approve a pending operation using its one-time approval token.", + inputs: [stringInput("approval", true)], + interaction: "state-changing", + availability: "delegated-agent", + askInClaudeCode: true, + annotations: localMutation + }, + { + name: "miftah_deny", + description: "Deny a pending operation using its one-time approval token.", + inputs: [stringInput("approval", true)], + interaction: "state-changing", + availability: "delegated-agent", + askInClaudeCode: true, + annotations: { ...localMutation, destructiveHint: true } + } +]; + +export const MANAGEMENT_TOOL_DESCRIPTORS = Object.freeze(MANAGEMENT_TOOL_DESCRIPTORS_INTERNAL); +export const MANAGEMENT_TOOL_NAMES = Object.freeze(MANAGEMENT_TOOL_DESCRIPTORS.map((descriptor) => descriptor.name)); + +/** Returns whether a tool name is reserved by Miftah's management surface. */ +export function isManagementToolName(name: string): boolean { + return MANAGEMENT_TOOL_NAMES.includes(name); +} + +/** Returns descriptors visible under the configured delegated-approval mode. */ +export function managementToolDescriptors(options: ManagementToolOptions): readonly ManagementToolDescriptor[] { + return MANAGEMENT_TOOL_DESCRIPTORS.filter( + (descriptor) => descriptor.availability === "always" || options.delegatedAgentApproval + ); +} + +/** Projects the visible descriptor contract into MCP SDK tool definitions. */ +export function managementTools(options: ManagementToolOptions): readonly Tool[] { + return managementToolDescriptors(options).map(toolFromDescriptor); +} + +/** Converts one immutable management descriptor into its MCP tool representation. */ +function toolFromDescriptor(descriptor: ManagementToolDescriptor): Tool { + const required = descriptor.inputs.filter((input) => input.required).map((input) => input.name); + return { + name: descriptor.name, + description: descriptor.description, + inputSchema: { + type: "object", + properties: Object.fromEntries(descriptor.inputs.map((input) => [input.name, input.schema])), + ...(required.length === 0 ? {} : { required }) + }, + annotations: descriptor.annotations + }; +} diff --git a/src/mcp/server/miftah-server.ts b/src/mcp/server/miftah-server.ts index c86ffa2d..00d53551 100644 --- a/src/mcp/server/miftah-server.ts +++ b/src/mcp/server/miftah-server.ts @@ -35,7 +35,12 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import type { MiftahConfig, ToolingConfig } from "../../config/types.js"; import type { PluginRegistry } from "../../plugins/plugin-registry.js"; -import { ApprovalStore, type ApprovalBinding, type ApprovalSummary } from "../../approvals/approval-store.js"; +import { + ApprovalStore, + type ApprovalBinding, + type ApprovalMechanism, + type ApprovalSummary +} from "../../approvals/approval-store.js"; import { SecretRedactor, redactUri } from "../../secrets/redact.js"; import { bindProfileTransitionConfirmationVerifier, @@ -70,6 +75,7 @@ import { type ResolvedOperation } from "./operation-pipeline.js"; import { ResourcePromptRegistry } from "./resource-prompt-registry.js"; +import { isManagementToolName, managementTools } from "./management-tools.js"; import { canonicalJson, ToolRegistry, @@ -79,25 +85,6 @@ import { type ToolSnapshot } from "./tool-registry.js"; -const managementTools: Tool[] = [ - tool("miftah_list_profiles", "List configured profiles without exposing secrets."), - tool("miftah_current_profile", "Show the active and default profile."), - tool("miftah_use_profile", "Switch the active profile according to the configured state scope.", ["profile"]), - tool("miftah_reset_profile", "Reset the active profile to the configured default."), - tool("miftah_lock_profile", "Lock the current profile for this MCP connection when enabled."), - tool("miftah_unlock_profile", "Unlock the current profile for this MCP connection when enabled."), - tool("miftah_profile_info", "Show non-secret metadata for a profile.", ["profile"]), - tool("miftah_health", "Show redacted wrapper and upstream health."), - tool("miftah_validate_config", "Validate the loaded wrapper configuration."), - tool("miftah_list_upstream_tools", "List tools discovered from an upstream profile.", ["profile"]), - tool("miftah_restart_profile", "Restart all upstream processes for a profile.", ["profile"]), - tool("miftah_verify_identity", "Explicitly verify configured upstream identity.", [], ["profile", "upstream"]), - tool("miftah_route_preview", "Preview routing for a hypothetical tool call.", ["toolName"]), - tool("miftah_list_approvals", "List safe metadata for approvals pending in this connection."), - tool("miftah_approve", "Approve a pending operation using its one-time approval token.", ["approval"]), - tool("miftah_deny", "Deny a pending operation using its one-time approval token.", ["approval"]) -]; - const EMPTY_MCP_ROOTS: readonly RoutingContextMcpRoot[] = Object.freeze([]); const defaultResourceSubscriptionCleanupTimeoutMs = 5_000; const emptyRoutingContext: RoutingContextSnapshot = { @@ -110,13 +97,14 @@ export type RoutingContextCollector = ( roots: readonly RoutingContextMcpRoot[] ) => Promise; +/** Derives a collision-safe tool name that can be exposed to an MCP client. */ export function resolveClientVisibleToolName( name: string, upstreamName: string | undefined, collisionStrategy: ToolingConfig["collisionStrategy"] ): string { if (upstreamName) return `${upstreamName}__${name}`; - if (managementTools.some((item) => item.name === name)) { + if (isManagementToolName(name)) { if ((collisionStrategy ?? "prefix-upstream") === "fail") { throw new MiftahError("TOOL_COLLISION", `TOOL_COLLISION: upstream tool '${name}' is reserved by Miftah`); } @@ -139,22 +127,6 @@ export function hasCompatibleCachedToolTarget( ); } -function tool(name: string, description: string, required: string[] = [], optional: string[] = []): Tool { - const fields = [...new Set([...required, ...optional])]; - return { - name, - description, - inputSchema: { - type: "object", - properties: fields.reduce>((result, key) => { - result[key] = { type: "string" }; - return result; - }, {}), - required - } - }; -} - function textResult(text: string, isError = false): CallToolResult { return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) }; } @@ -193,12 +165,13 @@ type ResourcePromptProxyAvailability = ResourcePromptProxyAvailable | ResourcePr type ApprovalResolution = | { readonly kind: "consumed" } - | { readonly kind: "fallback"; readonly token: string } + | { readonly kind: "delegated-agent"; readonly token: string } | { readonly kind: "form"; readonly token: string }; interface ApprovalErrorFactory { required(binding: ApprovalBinding, token: string): MiftahError; notAccepted(binding: ApprovalBinding): MiftahError; + unavailable(binding: ApprovalBinding): MiftahError; } interface ProfileAuditRequest { @@ -259,10 +232,17 @@ const genericApprovalErrors: ApprovalErrorFactory = { "POLICY_CONFIRMATION_REQUIRED", `POLICY_CONFIRMATION_REQUIRED: approval required for '${binding.displayName}'. Use miftah_approve with approval '${token}' then retry the exact operation.` ), + /** Reports an unaccepted ordinary-operation confirmation without exposing a bearer. */ notAccepted: (binding) => new MiftahError( "POLICY_CONFIRMATION_REQUIRED", `POLICY_CONFIRMATION_REQUIRED: approval was not accepted for '${binding.displayName}'` + ), + /** Reports that form-only confirmation cannot be completed by the current client. */ + unavailable: () => + new MiftahError( + "POLICY_CONFIRMATION_REQUIRED", + "POLICY_CONFIRMATION_REQUIRED: human confirmation requires an MCP client that supports form elicitation" ) }; @@ -272,10 +252,17 @@ const profileSwitchApprovalErrors: ApprovalErrorFactory = { "PROFILE_SWITCH_CONFIRMATION_REQUIRED", `PROFILE_SWITCH_CONFIRMATION_REQUIRED: confirmation required for ${binding.displayName}. Use miftah_approve with approval '${token}' then retry the exact operation.` ), + /** Reports an unaccepted profile-switch confirmation without exposing a bearer. */ notAccepted: (binding) => new MiftahError( "PROFILE_SWITCH_CONFIRMATION_REQUIRED", `PROFILE_SWITCH_CONFIRMATION_REQUIRED: confirmation was not accepted for ${binding.displayName}` + ), + /** Reports that form-only profile switching cannot be completed by the current client. */ + unavailable: () => + new MiftahError( + "PROFILE_SWITCH_CONFIRMATION_REQUIRED", + "PROFILE_SWITCH_CONFIRMATION_REQUIRED: human confirmation requires an MCP client that supports form elicitation" ) }; @@ -745,6 +732,7 @@ export class MiftahServer { } } + /** Registers the wrapper's MCP request handlers for the lifetime of this server instance. */ private registerHandlers(): void { this.server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => { const source = await this.captureStableProfileState(); @@ -759,7 +747,7 @@ export class MiftahServer { () => this.activeToolSnapshot(upstreamRequest.options) ); audit.update({ profile }); - return { tools: [...managementTools, ...snapshot.getTools()] }; + return { tools: [...this.visibleManagementTools(), ...snapshot.getTools()] }; } ); }); @@ -768,7 +756,7 @@ export class MiftahServer { const name = request.params.name; const args = request.params.arguments ?? {}; const source = await this.captureStableProfileState(); - const isManagementTool = managementTools.some((tool) => tool.name === name); + const isManagementTool = isManagementToolName(name); const isApprovalManagementTool = name === "miftah_approve" || name === "miftah_deny"; const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( @@ -1128,6 +1116,7 @@ export class MiftahServer { }); } + /** Executes a built-in management request against the caller's captured profile state. */ private async handleManagement( name: string, args: Record, @@ -1298,6 +1287,7 @@ export class MiftahServer { }); } if (name === "miftah_approve") { + this.assertDelegatedAgentApprovalEnabled(); return this.enqueueApprovalTransition(async () => { await this.expireApprovals(); const approval = await this.withApprovalExpiryAudit( @@ -1314,6 +1304,7 @@ export class MiftahServer { }); } if (name === "miftah_deny") { + this.assertDelegatedAgentApprovalEnabled(); return this.enqueueApprovalTransition(async () => { await this.expireApprovals(); const approval = await this.withApprovalExpiryAudit( @@ -1505,6 +1496,7 @@ export class MiftahServer { }; } + /** Requires one mechanism-bound approval, creating a fail-closed request when none is available. */ private async requireApproval( binding: ApprovalBinding, context?: ApprovalRequestContext, @@ -1512,10 +1504,17 @@ export class MiftahServer { ): Promise { const supportsFormElicitation = context !== undefined && this.server.getClientCapabilities()?.elicitation?.form !== undefined; + const approvalMechanism: ApprovalMechanism = supportsFormElicitation ? "form" : "delegated-agent"; + if (!supportsFormElicitation && !this.delegatedAgentApprovalEnabled()) { + await this.enqueueApprovalTransition(async () => { + await this.expireApprovals(); + }); + throw errors.unavailable(binding); + } const resolution = await this.enqueueApprovalTransition(async (): Promise => { await this.expireApprovals(); const consumed = await this.withApprovalExpiryAudit( - () => this.approvals.consume(binding), + () => this.approvals.consume(binding, approvalMechanism), (value) => { if (value !== undefined) this.approvals.revoke(value.id); } @@ -1531,10 +1530,13 @@ export class MiftahServer { } const requested = await this.withApprovalExpiryAudit( () => - this.approvals.request( - binding, - supportsFormElicitation ? undefined : (bearer) => this.redactor.redactText(bearer) === bearer - ), + supportsFormElicitation + ? this.approvals.request(binding, "form") + : this.approvals.request( + binding, + "delegated-agent", + (bearer) => this.redactor.redactText(bearer) === bearer + ), (value) => this.approvals.revoke(value.approval.id) ); if (requested.created) { @@ -1545,10 +1547,12 @@ export class MiftahServer { throw error; } } - return supportsFormElicitation ? { kind: "form", token: requested.token } : { kind: "fallback", token: requested.token }; + return supportsFormElicitation + ? { kind: "form", token: requested.token } + : { kind: "delegated-agent", token: requested.token }; }); if (resolution.kind === "consumed") return; - if (resolution.kind === "fallback") { + if (resolution.kind === "delegated-agent") { throw errors.required(binding, resolution.token); } if (context === undefined) throw new Error("Form approval requires an MCP request context."); @@ -1659,6 +1663,7 @@ export class MiftahServer { } } + /** Persists a safe approval transition and pairs required profile-confirmation audit events. */ private async writeApproval( action: ApprovalAuditAction, approval: ApprovalSummary @@ -1667,6 +1672,7 @@ export class MiftahServer { approvalId: approval.id, approvalSessionId: this.approvals.activeSessionId, approvalAction: action, + approvalMechanism: approval.mechanism, sourceProfile: approval.sourceProfile, profile: approval.profile, upstream: approval.upstream, @@ -1690,6 +1696,26 @@ export class MiftahServer { ); } + /** Whether configuration explicitly permits bearer-based delegated approval. */ + private delegatedAgentApprovalEnabled(): boolean { + return this.config.security?.approvalMode === "delegated-agent"; + } + + /** Returns only management tools valid for the configured approval mode. */ + private visibleManagementTools() { + return managementTools({ delegatedAgentApproval: this.delegatedAgentApprovalEnabled() }); + } + + /** Fails closed before a direct approval-management call when delegation is not configured. */ + private assertDelegatedAgentApprovalEnabled(): void { + if (this.delegatedAgentApprovalEnabled()) return; + throw new MiftahError( + "APPROVAL_DELEGATION_DISABLED", + "APPROVAL_DELEGATION_DISABLED: delegated agent approval is disabled" + ); + } + + /** Produces a lease-issued audit request only while the current profile lease remains active. */ private leaseIssuedAuditAction( sourceProfile: string, operation: string, @@ -2436,6 +2462,7 @@ export class MiftahServer { } } + /** Maps a safe domain error to its terminal audit outcome without exposing diagnostic detail. */ private auditStatus(error: MiftahError): AuditStatus { if ( error.code === "POLICY_BLOCKED" || @@ -2446,7 +2473,8 @@ export class MiftahServer { error.code === "PROFILE_SELECTION_STALE" || error.code === "PROFILE_LEASE_REQUIRED" || error.code === "PROFILE_LEASE_EXPIRED" || - error.code === "PROFILE_SELECTION_REQUIRED" + error.code === "PROFILE_SELECTION_REQUIRED" || + error.code === "APPROVAL_DELEGATION_DISABLED" ) { return "denied"; } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 9fa64254..6455f6d2 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -59,6 +59,8 @@ export type MiftahErrorCode = | "APPROVAL_EXPIRED" | "APPROVAL_NOT_PENDING" | "APPROVAL_LIMIT_EXCEEDED" + | "APPROVAL_DELEGATION_DISABLED" + | "APPROVAL_MECHANISM_MISMATCH" | "TOOL_COLLISION" | "TOOL_NOT_FOUND" | "TOOL_SCHEMA_MISMATCH" diff --git a/tests/approval-docs-contract.test.ts b/tests/approval-docs-contract.test.ts index 2de54b67..52ec919f 100644 --- a/tests/approval-docs-contract.test.ts +++ b/tests/approval-docs-contract.test.ts @@ -16,6 +16,9 @@ describe("approval documentation contract", () => { expect(config).toContain("miftah_approve"); expect(config).toContain("miftah_deny"); expect(config).toContain("form elicitation"); + expect(config).toContain( + "When form elicitation is unavailable, that explicit mode exposes a connection-bound, one-time bearer" + ); expect(security).toContain("approval bearer"); expect(architecture).toContain("approval lifecycle"); expect(architecture).toContain("raw approval bearer or operation arguments"); diff --git a/tests/approval-fallback.test.ts b/tests/approval-fallback.test.ts index d1b61404..c0344886 100644 --- a/tests/approval-fallback.test.ts +++ b/tests/approval-fallback.test.ts @@ -8,13 +8,19 @@ import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { validateConfig } from "../src/config/validate-config.js"; -import { ApprovalStore } from "../src/approvals/approval-store.js"; +import { ApprovalStore, type ApprovalBinding } from "../src/approvals/approval-store.js"; import { MiftahError } from "../src/utils/errors.js"; import { MiftahServer } from "../src/mcp/server/miftah-server.js"; +import { managementToolDescriptors } from "../src/mcp/server/management-tools.js"; import { ProfileManager } from "../src/profiles/profile-manager.js"; import { UpstreamProcessManager } from "../src/upstream/upstream-process-manager.js"; const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "fake-upstream.mjs"); +const delegatedAgentApprovalSecurity = { approvalMode: "delegated-agent" } as const; + +function requestDelegatedApproval(approvals: ApprovalStore, binding: ApprovalBinding) { + return approvals.request(binding, "delegated-agent", () => true); +} describe("approval fallback", () => { it("invalidates pending approvals when an MCP connection begins", async () => { @@ -36,7 +42,7 @@ describe("approval fallback", () => { name: "create_item", displayName: "create_item", arguments: { name: "first" } - }); + }, "form"); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "approval-session-client", version: "1.0.0" }); @@ -50,7 +56,7 @@ describe("approval fallback", () => { } }); - it("advertises management tools for listing and deciding pending approvals", async () => { + it("does not advertise delegated approval tools in human confirmation mode", async () => { const config = validateConfig({ version: "1", name: "accounts", @@ -68,7 +74,36 @@ describe("approval fallback", () => { const tools = await client.listTools(); - expect(tools.tools.map((tool) => tool.name)).toEqual( + const names = tools.tools.map((tool) => tool.name); + expect(names).toEqual(expect.arrayContaining(["miftah_list_approvals"])); + expect(names).not.toEqual(expect.arrayContaining(["miftah_approve", "miftah_deny"])); + for (const descriptor of managementToolDescriptors({ delegatedAgentApproval: false })) { + expect(tools.tools.find((tool) => tool.name === descriptor.name)?.annotations).toEqual(descriptor.annotations); + } + } finally { + await client.close(); + await wrapper.close(); + } + }); + + it("advertises delegated approval tools only after the explicit automation opt-in", async () => { + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { work: {} }, + security: delegatedAgentApprovalSecurity + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "delegated-approval-list-client", version: "1.0.0" }); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + + expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual( expect.arrayContaining(["miftah_list_approvals", "miftah_approve", "miftah_deny"]) ); } finally { @@ -77,7 +112,7 @@ describe("approval fallback", () => { } }); - it("returns a one-time fallback approval without forwarding a confirmation-required call", async () => { + it("does not disclose a fallback bearer to a non-form client by default", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-approval-fallback-")); const createCountPath = join(directory, "create-count"); const config = validateConfig({ @@ -108,12 +143,16 @@ describe("approval fallback", () => { content: [ { type: "text", - text: expect.stringMatching( - /POLICY_CONFIRMATION_REQUIRED: approval required for 'create_item'\. Use miftah_approve with approval '[A-Za-z0-9_-]+' then retry the exact operation\./u - ) + text: "POLICY_CONFIRMATION_REQUIRED: human confirmation requires an MCP client that supports form elicitation" } ] }); + expect(textContent(result)).not.toContain("miftah_approve"); + expect(textContent(result)).not.toMatch(/approval '[A-Za-z0-9_-]+'/u); + expect(await client.callTool({ name: "miftah_approve", arguments: { approval: "not-disclosed" } })).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("APPROVAL_DELEGATION_DISABLED") }] + }); await expect(access(createCountPath)).rejects.toThrow(); } finally { await client.close(); @@ -122,6 +161,60 @@ describe("approval fallback", () => { } }); + it("does not allow a non-form client to self-confirm a profile switch by default", async () => { + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { env: { TEST_ACCOUNT_NAME: "work" } }, + personal: { env: { TEST_ACCOUNT_NAME: "personal" } } + }, + security: { + allowProfileSwitchingFromMcp: true, + requireProfileSwitchConfirmation: true + }, + audit: { enabled: false } + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const profiles = new ProfileManager(config, config.security); + const wrapper = new MiftahServer(config, profiles, manager); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "secure-profile-switch-client", version: "1.0.0" }); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + + expect(await client.callTool({ name: "miftah_use_profile", arguments: { profile: "personal" } })).toMatchObject({ + isError: true, + content: [ + { + type: "text", + text: "PROFILE_SWITCH_CONFIRMATION_REQUIRED: human confirmation requires an MCP client that supports form elicitation" + } + ] + }); + expect(profiles.current().activeProfile).toBe("work"); + expect(await client.callTool({ name: "miftah_reset_profile", arguments: {} })).toMatchObject({ + isError: true, + content: [ + { + type: "text", + text: "PROFILE_SWITCH_CONFIRMATION_REQUIRED: human confirmation requires an MCP client that supports form elicitation" + } + ] + }); + expect(await client.callTool({ name: "miftah_approve", arguments: { approval: "not-disclosed" } })).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("APPROVAL_DELEGATION_DISABLED") }] + }); + } finally { + await client.close(); + await wrapper.close(); + } + }); + it("requires a connection-bound approval before switching profiles when configured", async () => { const config = validateConfig({ version: "1", @@ -132,7 +225,11 @@ describe("approval fallback", () => { work: { env: { TEST_ACCOUNT_NAME: "work" } }, personal: { env: { TEST_ACCOUNT_NAME: "personal" } } }, - security: { allowProfileSwitchingFromMcp: true, requireProfileSwitchConfirmation: true }, + security: { + allowProfileSwitchingFromMcp: true, + requireProfileSwitchConfirmation: true, + ...delegatedAgentApprovalSecurity + }, audit: { enabled: false } }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); @@ -175,7 +272,11 @@ describe("approval fallback", () => { defaultProfile: "work", upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: {}, personal: {} }, - security: { allowProfileSwitchingFromMcp: true, requireProfileSwitchConfirmation: true }, + security: { + allowProfileSwitchingFromMcp: true, + requireProfileSwitchConfirmation: true, + ...delegatedAgentApprovalSecurity + }, audit: { enabled: false } }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); @@ -371,7 +472,8 @@ describe("approval fallback", () => { env: { API_TOKEN: "unsafe", TEST_CREATE_ITEM_COUNT_PATH: createCountPath } } }, - policies: { confirm: { requireConfirmation: ["create_item"] } } + policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); const wrapper = new MiftahServer(config, new ProfileManager(config), manager); @@ -405,7 +507,8 @@ describe("approval fallback", () => { defaultProfile: "work", upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: { policy: "confirm" } }, - policies: { confirm: { requireConfirmation: ["create_item"] } } + policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); const wrapper = new MiftahServer(config, new ProfileManager(config), manager); @@ -437,7 +540,8 @@ describe("approval fallback", () => { defaultProfile: "work", upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: { policy: "confirm" } }, - policies: { confirm: { requireConfirmation: ["create_item"] } } + policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); const wrapper = new MiftahServer(config, new ProfileManager(config), manager); @@ -477,7 +581,8 @@ describe("approval fallback", () => { defaultProfile: "work", upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: { policy: "confirm", env: { TEST_CREATE_ITEM_COUNT_PATH: createCountPath } } }, - policies: { confirm: { requireConfirmation: ["create_item"] } } + policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); const wrapper = new MiftahServer(config, new ProfileManager(config), manager); @@ -526,6 +631,7 @@ describe("approval fallback", () => { upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: { policy: "confirm" } }, policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity, audit: { path: auditPath } }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); @@ -567,7 +673,7 @@ describe("approval fallback", () => { displayName: "create_item", arguments: { name: "first" } }; - const requested = host.approvals.request(binding); + const requested = requestDelegatedApproval(host.approvals, binding); try { host.writeApproval = async (action, approval) => { @@ -603,7 +709,8 @@ describe("approval fallback", () => { defaultProfile: "work", upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: { policy: "confirm" } }, - policies: { confirm: { requireConfirmation: ["create_item"] } } + policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); const wrapper = new MiftahServer(config, new ProfileManager(config), manager); @@ -684,6 +791,7 @@ describe("approval fallback", () => { } }, policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity, audit: { path: auditPath, includeArguments: true } }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); @@ -721,6 +829,12 @@ describe("approval fallback", () => { "consumed", "requested" ]); + expect(approvalEvents.map((event) => event.approvalMechanism)).toEqual([ + "delegated-agent", + "delegated-agent", + "delegated-agent", + "delegated-agent" + ]); expect(JSON.stringify(approvalEvents)).not.toContain(token); expect(JSON.stringify(approvalEvents)).not.toContain('"name":"first"'); } finally { @@ -742,6 +856,7 @@ describe("approval fallback", () => { upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: { policy: "confirm" } }, policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity, audit: { path: auditPath } }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); @@ -797,6 +912,7 @@ describe("approval fallback", () => { upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: { policy: "confirm" } }, policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity, audit: { path: auditPath } }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); @@ -823,7 +939,7 @@ describe("approval fallback", () => { displayName: "create_item", arguments: { name: "first" } }; - const requested = approvals.request(binding); + const requested = requestDelegatedApproval(approvals, binding); approvals.approve(requested.token); raceRead = true; @@ -857,7 +973,8 @@ describe("approval fallback", () => { defaultProfile: "work", upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: { policy: "confirm" } }, - policies: { confirm: { requireConfirmation: ["create_item"] } } + policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); const wrapper = new MiftahServer(config, new ProfileManager(config), manager); @@ -875,7 +992,7 @@ describe("approval fallback", () => { }): Promise; }; host.approvals = approvals; - approvals.request({ + requestDelegatedApproval(approvals, { sourceProfile: "work", profile: "work", upstream: "default", @@ -928,7 +1045,8 @@ describe("approval fallback", () => { defaultProfile: "work", upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, profiles: { work: { policy: "confirm" } }, - policies: { confirm: { requireConfirmation: ["create_item"] } } + policies: { confirm: { requireConfirmation: ["create_item"] } }, + security: delegatedAgentApprovalSecurity }); const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); const wrapper = new MiftahServer(config, new ProfileManager(config), manager); @@ -943,7 +1061,7 @@ describe("approval fallback", () => { ): Promise; }; host.approvals = approvals; - const requested = approvals.request({ + const requested = requestDelegatedApproval(approvals, { sourceProfile: "work", profile: "work", upstream: "default", @@ -1063,13 +1181,13 @@ describe("approval fallback", () => { isError: true, content: [{ type: "text", text: expect.stringContaining("APPROVAL_EXPIRED") }] }); - const approvalActions = (await readFile(auditPath, "utf8")) + const approvalEvents = (await readFile(auditPath, "utf8")) .trim() .split("\n") .map((line) => JSON.parse(line) as Record) - .filter((event) => event.kind === "approval") - .map((event) => event.approvalAction); - expect(approvalActions).toEqual(["requested", "expired"]); + .filter((event) => event.kind === "approval"); + expect(approvalEvents.map((event) => event.approvalAction)).toEqual(["requested", "expired"]); + expect(approvalEvents.map((event) => event.approvalMechanism)).toEqual(["form", "form"]); } finally { await client.close(); await wrapper.close(); diff --git a/tests/approval-store.test.ts b/tests/approval-store.test.ts index 6533bfd9..d105147e 100644 --- a/tests/approval-store.test.ts +++ b/tests/approval-store.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { ApprovalStore } from "../src/approvals/approval-store.js"; +import { ApprovalStore, type ApprovalBinding } from "../src/approvals/approval-store.js"; + +function requestDelegated(store: ApprovalStore, binding: ApprovalBinding) { + return store.request(binding, "delegated-agent", () => true); +} + +function consumeDelegated(store: ApprovalStore, binding: ApprovalBinding) { + return store.consume(binding, "delegated-agent"); +} describe("approval store", () => { it("does not consume an approved approval for a different normalized argument set", () => { @@ -18,11 +26,11 @@ describe("approval store", () => { arguments: { first: "one", second: "two" } }; - const requested = store.request(binding); + const requested = requestDelegated(store, binding); store.approve(requested.token); - expect(store.consume({ ...binding, arguments: { first: "one", second: "changed" } })).toBeUndefined(); - expect(store.consume({ ...binding, arguments: { second: "two", first: "one" } })).toMatchObject({ + expect(consumeDelegated(store, { ...binding, arguments: { first: "one", second: "changed" } })).toBeUndefined(); + expect(consumeDelegated(store, { ...binding, arguments: { second: "two", first: "one" } })).toMatchObject({ id: requested.approval.id, status: "consumed" }); @@ -42,12 +50,39 @@ describe("approval store", () => { arguments: { name: "first" } }; - const first = store.request(binding); - const second = store.request(binding); + const first = requestDelegated(store, binding); + const second = requestDelegated(store, binding); expect(second).toMatchObject({ created: false }); expect(store.approve(first.token)).toMatchObject({ id: first.approval.id, status: "approved" }); - expect(store.consume(binding)).toMatchObject({ id: first.approval.id, status: "consumed" }); + expect(consumeDelegated(store, binding)).toMatchObject({ id: first.approval.id, status: "consumed" }); + }); + + it("fails closed when the same pending operation is requested through another approval mechanism", () => { + const store = new ApprovalStore({ createToken: () => "approval-mechanism-token" }); + const binding = { + sourceProfile: "work", + profile: "work", + upstream: "default", + operation: "tools/call" as const, + name: "create_item", + displayName: "create_item", + arguments: { name: "first" } + }; + + const requested = store.request(binding, "form"); + + expect(requested.approval.mechanism).toBe("form"); + const requestWithoutMechanism = store.request as unknown as (input: typeof binding) => unknown; + expect(() => requestWithoutMechanism.call(store, binding)).toThrow( + expect.objectContaining({ code: "APPROVAL_MECHANISM_MISMATCH" }) + ); + expect(() => store.request(binding, "delegated-agent", () => true)).toThrow( + expect.objectContaining({ code: "APPROVAL_MECHANISM_MISMATCH" }) + ); + store.approve(requested.token); + expect(store.consume(binding, "delegated-agent")).toBeUndefined(); + expect(store.consume(binding, "form")).toMatchObject({ id: requested.approval.id, status: "consumed" }); }); it("bounds bearer variants for one pending operation without retaining bearer values", () => { @@ -63,9 +98,9 @@ describe("approval store", () => { arguments: { name: "first" } }; - for (let index = 0; index < 8; index += 1) store.request(binding); + for (let index = 0; index < 8; index += 1) requestDelegated(store, binding); - expect(() => store.request(binding)).toThrow(expect.objectContaining({ code: "APPROVAL_LIMIT_EXCEEDED" })); + expect(() => requestDelegated(store, binding)).toThrow(expect.objectContaining({ code: "APPROVAL_LIMIT_EXCEEDED" })); expect(JSON.stringify(store.list())).not.toContain("approval-bounded-"); }); @@ -73,7 +108,7 @@ describe("approval store", () => { const token = "approval-record-secret-token"; const secretArgument = "approval-record-sensitive-argument"; const store = new ApprovalStore({ createToken: () => token }); - store.request({ + requestDelegated(store, { sourceProfile: "work", profile: "work", upstream: "default", @@ -98,7 +133,7 @@ describe("approval store", () => { ttlMs: 1_000, createToken: () => "approval-expired-token" }); - const requested = store.request({ + const requested = requestDelegated(store, { sourceProfile: "work", profile: "work", upstream: "default", @@ -130,13 +165,13 @@ describe("approval store", () => { displayName: "create_item", arguments: { name: "first" } }; - const requested = store.request(binding); + const requested = requestDelegated(store, binding); store.approve(requested.token); now = new Date("2026-07-12T00:00:01.000Z"); expect(store.expire()).toEqual([expect.objectContaining({ id: requested.approval.id, status: "expired" })]); - expect(store.consume(binding)).toBeUndefined(); + expect(consumeDelegated(store, binding)).toBeUndefined(); }); it("does not require a caller to sweep expiry before consuming an approval", () => { @@ -155,11 +190,11 @@ describe("approval store", () => { displayName: "create_item", arguments: { name: "first" } }; - const requested = store.request(binding); + const requested = requestDelegated(store, binding); store.approve(requested.token); now = new Date("2026-07-12T00:00:01.000Z"); - expect(store.consume(binding)).toBeUndefined(); + expect(consumeDelegated(store, binding)).toBeUndefined(); }); it("rejects a bearer from a previous connection session", () => { @@ -168,7 +203,7 @@ describe("approval store", () => { createToken: () => "approval-old-session-token", createSessionId: () => `session-${++session}` }); - const requested = store.request({ + const requested = requestDelegated(store, { sourceProfile: "work", profile: "work", upstream: "default", @@ -185,7 +220,7 @@ describe("approval store", () => { it("rejects a replay after an explicit denial", () => { const store = new ApprovalStore({ createToken: () => "approval-denied-token" }); - const requested = store.request({ + const requested = requestDelegated(store, { sourceProfile: "work", profile: "work", upstream: "default", @@ -210,10 +245,13 @@ describe("approval store", () => { displayName: "create_item", arguments: { name: "first" } }; - const requested = store.request(binding); + const requested = requestDelegated(store, binding); store.approve(requested.token); - const consumed = await Promise.all([Promise.resolve().then(() => store.consume(binding)), Promise.resolve().then(() => store.consume(binding))]); + const consumed = await Promise.all([ + Promise.resolve().then(() => consumeDelegated(store, binding)), + Promise.resolve().then(() => consumeDelegated(store, binding)) + ]); expect(consumed.filter((approval) => approval !== undefined)).toEqual([ expect.objectContaining({ id: requested.approval.id, status: "consumed" }) @@ -236,11 +274,13 @@ describe("approval store", () => { arguments: { name } }); - const first = store.request(binding("first")); - store.request(binding("second")); + const first = requestDelegated(store, binding("first")); + requestDelegated(store, binding("second")); - expect(() => store.request(binding("third"))).toThrow(expect.objectContaining({ code: "APPROVAL_LIMIT_EXCEEDED" })); + expect(() => requestDelegated(store, binding("third"))).toThrow( + expect.objectContaining({ code: "APPROVAL_LIMIT_EXCEEDED" }) + ); store.deny(first.token); - expect(store.request(binding("third"))).toMatchObject({ created: true }); + expect(requestDelegated(store, binding("third"))).toMatchObject({ created: true }); }); }); diff --git a/tests/audit-outcomes.test.ts b/tests/audit-outcomes.test.ts index d6c625ec..3dbebf46 100644 --- a/tests/audit-outcomes.test.ts +++ b/tests/audit-outcomes.test.ts @@ -489,6 +489,39 @@ describe("audit outcomes", () => { } }); + it("records disabled delegated approval controls as denied outcomes", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-audit-delegation-denied-")); + const auditPath = join(directory, "audit.jsonl"); + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { work: {} }, + audit: { path: auditPath } + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 1_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config, config.security), manager); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + expect(await client.callTool({ name: "miftah_approve", arguments: { approval: "not-disclosed" } })).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("APPROVAL_DELEGATION_DISABLED") }] + }); + expect(await waitForAuditEvent( + auditPath, + (event) => event.name === "miftah_approve" && event.errorCode === "APPROVAL_DELEGATION_DISABLED" + )).toMatchObject({ status: "denied" }); + } finally { + await client.close(); + await wrapper.close(); + await rm(directory, { recursive: true, force: true }); + } + }); + it("records safe profile confirmation, transition, lease, and runtime-lock actions", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-audit-profile-actions-")); const auditPath = join(directory, "audit.jsonl"); @@ -507,7 +540,8 @@ describe("audit outcomes", () => { security: { allowProfileSwitchingFromMcp: true, requireProfileSwitchConfirmation: true, - allowProfileLockingFromMcp: true + allowProfileLockingFromMcp: true, + approvalMode: "delegated-agent" }, audit: { path: auditPath } }); @@ -564,6 +598,13 @@ describe("audit outcomes", () => { }) ]) ); + const approvalEvents = (await readFile(auditPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record) + .filter((event) => event.kind === "approval"); + expect(approvalEvents).toHaveLength(3); + expect(approvalEvents.every((event) => event.approvalMechanism === "delegated-agent")).toBe(true); expect(JSON.stringify(profileEvents)).not.toContain(token); } finally { await client.close(); diff --git a/tests/changelog-issue-entry.test.ts b/tests/changelog-issue-entry.test.ts new file mode 100644 index 00000000..dd2f1300 --- /dev/null +++ b/tests/changelog-issue-entry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { changelogIssueEntry } from "./helpers/changelog.js"; + +describe("changelog issue-entry helper", () => { + it("bounds a contract assertion to one issue entry", () => { + const changelog = [ + "## [Unreleased]", + "", + "### Changed", + "- [#19](https://example.test/issues/19) Catalog guidance.", + "- [#20](https://example.test/issues/20) Routing-context onboarding." + ].join("\n"); + + const issue19 = changelogIssueEntry(changelog, 19); + expect(issue19).toContain("Catalog guidance."); + expect(issue19).not.toContain("Routing-context onboarding."); + }); +}); diff --git a/tests/cli-exit-codes.test.ts b/tests/cli-exit-codes.test.ts index c0856689..4f575b03 100644 --- a/tests/cli-exit-codes.test.ts +++ b/tests/cli-exit-codes.test.ts @@ -62,6 +62,8 @@ const expectedErrorExitCodes: Record = { ROUTING_PLUGIN_CANCELLED: CLI_EXIT_CODES.policy, POLICY_BLOCKED: CLI_EXIT_CODES.policy, POLICY_CONFIRMATION_REQUIRED: CLI_EXIT_CODES.policy, + APPROVAL_DELEGATION_DISABLED: CLI_EXIT_CODES.policy, + APPROVAL_MECHANISM_MISMATCH: CLI_EXIT_CODES.policy, APPROVAL_INVALID: CLI_EXIT_CODES.policy, APPROVAL_EXPIRED: CLI_EXIT_CODES.policy, APPROVAL_NOT_PENDING: CLI_EXIT_CODES.policy, diff --git a/tests/client-snippets.test.ts b/tests/client-snippets.test.ts index 389ed3b9..b3abf784 100644 --- a/tests/client-snippets.test.ts +++ b/tests/client-snippets.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { CLIENT_NAMES, ClientSnippetError, + renderClaudeCodePermissionGuidance, renderClientSnippet, renderClientSnippets } from "../src/cli/client-snippets.js"; @@ -45,6 +46,48 @@ describe("client snippets", () => { }); }); + it("renders exact Claude Code review rules for visible privileged Miftah tools", () => { + const guidance = renderClaudeCodePermissionGuidance("miftah", { delegatedAgentApproval: false }); + + expect(guidance.kind).toBe("snippet"); + if (guidance.kind !== "snippet") throw new Error("expected a permission snippet"); + expect(guidance.target.label).toContain("Claude Code settings permissions"); + expect(guidance.json).not.toContain("*"); + expect(JSON.parse(guidance.json)).toEqual({ + permissions: { + ask: [ + "mcp__miftah__miftah_use_profile", + "mcp__miftah__miftah_reset_profile", + "mcp__miftah__miftah_lock_profile", + "mcp__miftah__miftah_unlock_profile", + "mcp__miftah__miftah_restart_profile", + "mcp__miftah__miftah_verify_identity", + "mcp__miftah__miftah_route_preview" + ] + } + }); + }); + + it("includes delegated approval decisions only when that automation mode is explicitly enabled", () => { + const guidance = renderClaudeCodePermissionGuidance("miftah", { delegatedAgentApproval: true }); + + expect(guidance.kind).toBe("snippet"); + if (guidance.kind !== "snippet") throw new Error("expected a permission snippet"); + expect(JSON.parse(guidance.json).permissions.ask).toEqual( + expect.arrayContaining(["mcp__miftah__miftah_approve", "mcp__miftah__miftah_deny"]) + ); + }); + + it("refuses to generate permission patterns for names without a documented literal grammar", () => { + const guidance = renderClaudeCodePermissionGuidance("miftah server", { delegatedAgentApproval: false }); + + expect(guidance).toEqual({ + kind: "manual", + target: { label: "Claude Code settings permissions" }, + message: expect.stringContaining("not generated") + }); + }); + it("renders the official Cursor stdio configuration", () => { const snippet = renderClientSnippet("cursor", posixInput); diff --git a/tests/config-schema-contract.test.ts b/tests/config-schema-contract.test.ts index 8468e7f5..6b167b1b 100644 --- a/tests/config-schema-contract.test.ts +++ b/tests/config-schema-contract.test.ts @@ -299,6 +299,7 @@ describe("published config schema", () => { ]); expect(security).toMatchObject({ redactSecrets: { const: true }, + approvalMode: { enum: ["human", "delegated-agent"] }, requireProfileSwitchConfirmation: { type: "boolean" }, allowProfileLockingFromMcp: { type: "boolean" }, requireExplicitSelectionForDestructive: { type: "boolean" } diff --git a/tests/helpers/changelog.ts b/tests/helpers/changelog.ts index cd135c6b..aaff23b6 100644 --- a/tests/helpers/changelog.ts +++ b/tests/helpers/changelog.ts @@ -2,6 +2,13 @@ const unreleasedHeadingPattern = /^## \[Unreleased\]\s*$/mu; const releaseHeadingPattern = /^## \[/mu; const nonReleaseHeadingPattern = /\n## (?!\[)/u; +/** Returns the exact one-line changelog bullet for a tracked issue. */ +export function changelogIssueEntry(changelog: string, issue: number): string { + const entry = new RegExp(`^- \\[#${issue}\\]\\([^\\n]+\\).*$`, "mu").exec(changelog)?.[0]; + if (entry === undefined) throw new Error(`CHANGELOG.md must contain an entry for #${issue}.`); + return entry; +} + /** Returns pending changes, or all released changes once a release empties Unreleased. */ export function documentedChangesSection(changelog: string): string { const afterHeading = changelog.split(unreleasedHeadingPattern)[1]; diff --git a/tests/identity-docs-contract.test.ts b/tests/identity-docs-contract.test.ts index 6c61d1a4..5b394cf3 100644 --- a/tests/identity-docs-contract.test.ts +++ b/tests/identity-docs-contract.test.ts @@ -1,6 +1,5 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { documentedChangesSection } from "./helpers/changelog.js"; const identityVerificationHeadingPattern = /^## Identity verification\s*$/mu; const sectionHeadingPattern = /^## /mu; @@ -75,6 +74,7 @@ describe("identity verification documentation contract", () => { const manager = readRepositoryFile("src/identity/identity-manager.ts"); const statusTypes = readRepositoryFile("src/identity/identity-types.ts"); const server = readRepositoryFile("src/mcp/server/miftah-server.ts"); + const managementTools = readRepositoryFile("src/mcp/server/management-tools.ts"); const pipeline = readRepositoryFile("src/mcp/server/operation-pipeline.ts"); const doctor = readRepositoryFile("src/cli/doctor.ts"); const doctorReport = readRepositoryFile("src/cli/doctor-report.ts"); @@ -98,7 +98,7 @@ describe("identity verification documentation contract", () => { expect(identityConfig).toContain("only when `requiredForRisk` explicitly names the selected write or destructive risk"); expect(identityConfig).toContain("Read discovery, resource reads, and prompt retrieval are not gated"); - expect(server).toContain('tool("miftah_verify_identity"'); + expect(managementTools).toContain('name: "miftah_verify_identity"'); expect(server).toContain("args.profile === undefined ? source.activeProfile"); expect(server).toContain("const targetUpstreams = this.identityTargetUpstreams(requestedUpstream);"); expect(server).toContain('requestedUpstream === "default" && configured.length === 1 && configured[0] === undefined'); @@ -144,6 +144,6 @@ describe("identity verification documentation contract", () => { ]) { expect(security).toContain(claim); } - expect(documentedChangesSection(changelog)).toMatch(documentedIdentityPattern); + expect(changelog).toMatch(documentedIdentityPattern); }); }); diff --git a/tests/init-command.test.ts b/tests/init-command.test.ts index f969f35f..d3f80861 100644 --- a/tests/init-command.test.ts +++ b/tests/init-command.test.ts @@ -150,6 +150,22 @@ describe("init command", () => { }); }); + it("prints Claude Code permission guidance without writing user settings", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "miftah.json"); + + await runInitCommand({ name: "miftah", output: "miftah.json", client: "claude-code" }, commandContext(streams)); + streams.input.end(); + + expect(streams.transcript.contents).toContain("Claude Code settings permissions:"); + expect(streams.transcript.contents).toContain('"mcp__miftah__miftah_use_profile"'); + expect(streams.transcript.contents).not.toContain("miftah_approve"); + expect(streams.transcript.contents).toContain("Manually merge this fragment"); + await expectNoPath(resolve(outputRoot, ".claude", "settings.local.json")); + await expectNoPath(resolve(outputRoot, ".claude", "settings.json")); + await expect(readFile(output, "utf8")).resolves.toContain('"name": "miftah"'); + }); + it("runs the TTY wizard with real streams for a generic config", async () => { const streams = createStreams(); const output = resolve(outputRoot, "wizard-generic.json"); diff --git a/tests/management-tools-contract.test.ts b/tests/management-tools-contract.test.ts new file mode 100644 index 00000000..88930c4c --- /dev/null +++ b/tests/management-tools-contract.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + MANAGEMENT_TOOL_DESCRIPTORS, + managementTools +} from "../src/mcp/server/management-tools.js"; + +describe("management tool descriptors", () => { + it("defines every management tool once and projects reviewed MCP annotations", () => { + const names = MANAGEMENT_TOOL_DESCRIPTORS.map((descriptor) => descriptor.name); + expect(new Set(names).size).toBe(names.length); + + const visible = managementTools({ delegatedAgentApproval: false }); + const visibleNames = visible.map((tool) => tool.name); + expect(visibleNames).not.toContain("miftah_approve"); + expect(visibleNames).not.toContain("miftah_deny"); + + for (const tool of visible) { + const descriptor = MANAGEMENT_TOOL_DESCRIPTORS.find((candidate) => candidate.name === tool.name); + expect(descriptor).toBeDefined(); + expect(tool.annotations).toEqual(descriptor?.annotations); + } + + const delegated = managementTools({ delegatedAgentApproval: true }); + expect(delegated).toHaveLength(MANAGEMENT_TOOL_DESCRIPTORS.length); + for (const descriptor of MANAGEMENT_TOOL_DESCRIPTORS) { + const tool = delegated.find((candidate) => candidate.name === descriptor.name); + expect(tool).toBeDefined(); + expect(tool?.annotations).toEqual(descriptor.annotations); + } + }); + + it("classifies pending-approval listing as a read-only local observation", () => { + const descriptor = MANAGEMENT_TOOL_DESCRIPTORS.find((candidate) => candidate.name === "miftah_list_approvals"); + + expect(descriptor).toMatchObject({ + interaction: "observational", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + } + }); + }); + + it("keeps profile discovery and route-preview schemas aligned with their handlers", () => { + const upstreamTools = MANAGEMENT_TOOL_DESCRIPTORS.find( + (descriptor) => descriptor.name === "miftah_list_upstream_tools" + ); + const routePreview = MANAGEMENT_TOOL_DESCRIPTORS.find((descriptor) => descriptor.name === "miftah_route_preview"); + + expect(upstreamTools?.inputs).toEqual([{ name: "profile", required: false, schema: { type: "string" } }]); + expect(routePreview?.inputs).toEqual([ + { name: "toolName", required: true, schema: { type: "string" } }, + { name: "args", required: false, schema: { type: "object", additionalProperties: true } } + ]); + }); + + it("marks privileged or externally active management tools for explicit client review", () => { + const askNames = MANAGEMENT_TOOL_DESCRIPTORS + .filter((descriptor) => descriptor.askInClaudeCode) + .map((descriptor) => descriptor.name); + + expect(askNames).toEqual([ + "miftah_use_profile", + "miftah_reset_profile", + "miftah_lock_profile", + "miftah_unlock_profile", + "miftah_restart_profile", + "miftah_verify_identity", + "miftah_route_preview", + "miftah_approve", + "miftah_deny" + ]); + }); +}); diff --git a/tests/mcp-wrapper.test.ts b/tests/mcp-wrapper.test.ts index d9a381da..cf4db05d 100644 --- a/tests/mcp-wrapper.test.ts +++ b/tests/mcp-wrapper.test.ts @@ -29,6 +29,7 @@ import { MiftahServer, resolveClientVisibleToolName } from "../src/mcp/server/miftah-server.js"; +import { MANAGEMENT_TOOL_NAMES, managementToolDescriptors } from "../src/mcp/server/management-tools.js"; import type { RegisteredTool } from "../src/mcp/server/tool-registry.js"; import { createMiftahRuntime } from "../src/runtime/create-miftah-runtime.js"; import type { RoutingContextSnapshot } from "../src/routing/routing-types.js"; @@ -37,24 +38,7 @@ import { UpstreamProcessManager } from "../src/upstream/upstream-process-manager const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "fake-upstream.mjs"); const toolCollisionPattern = /TOOL_COLLISION/; -const managementToolNames = [ - "miftah_list_profiles", - "miftah_current_profile", - "miftah_use_profile", - "miftah_reset_profile", - "miftah_lock_profile", - "miftah_unlock_profile", - "miftah_profile_info", - "miftah_health", - "miftah_validate_config", - "miftah_list_upstream_tools", - "miftah_restart_profile", - "miftah_verify_identity", - "miftah_route_preview", - "miftah_list_approvals", - "miftah_approve", - "miftah_deny" -] as const; +const managementToolNames = managementToolDescriptors({ delegatedAgentApproval: false }).map((descriptor) => descriptor.name); function registeredTool(originalName: string): RegisteredTool { return { @@ -78,10 +62,12 @@ describe("cached routed-tool compatibility", () => { describe("client-visible tool compatibility", () => { it("keeps management reservation and upstream namespace rules stable", () => { expect(resolveClientVisibleToolName("search", "github", "prefix-upstream")).toBe("github__search"); - expect(resolveClientVisibleToolName("miftah_health", undefined, "prefix-upstream")).toBe( - "upstream_miftah_health" - ); - expect(() => resolveClientVisibleToolName("miftah_health", undefined, "fail")).toThrow(/TOOL_COLLISION/u); + for (const managementToolName of MANAGEMENT_TOOL_NAMES) { + expect(resolveClientVisibleToolName(managementToolName, undefined, "prefix-upstream")).toBe( + `upstream_${managementToolName}` + ); + expect(() => resolveClientVisibleToolName(managementToolName, undefined, "fail")).toThrow(toolCollisionPattern); + } expect(resolveClientVisibleToolName("miftah_custom", undefined, "fail")).toBe("miftah_custom"); }); }); diff --git a/tests/preset-docs-contract.test.ts b/tests/preset-docs-contract.test.ts index 87e3521b..16eae0e4 100644 --- a/tests/preset-docs-contract.test.ts +++ b/tests/preset-docs-contract.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { buildPresetConfig, PRESET_CATALOG } from "../src/config/presets.js"; import { validateConfig } from "../src/config/validate-config.js"; -import { documentedChangesSection } from "./helpers/changelog.js"; +import { changelogIssueEntry, documentedChangesSection } from "./helpers/changelog.js"; function readRepositoryFile(path: string): string { return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); @@ -89,9 +89,15 @@ describe("preset documentation contract", () => { expect(cli).toContain(option); } 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" + ); const documentedChanges = documentedChangesSection(changelog); - expect(documentedChanges).toMatch(/\[#19\][\s\S]*catalog[\s\S]*onboarding/iu); + const issue19 = changelogIssueEntry(changelog, 19); + expect(issue19).toContain("catalog"); + expect(issue19).toContain("onboarding"); + expect(documentedChanges).toMatch(/\[#98\][\s\S]*permission/iu); expect(documentedChanges).not.toContain("runtime construction"); }); }); diff --git a/tests/presets.test.ts b/tests/presets.test.ts index 46b58f8a..716c73b7 100644 --- a/tests/presets.test.ts +++ b/tests/presets.test.ts @@ -24,6 +24,12 @@ describe("preset config", () => { expect(config.profiles.personal?.env?.GITHUB_PERSONAL_ACCESS_TOKEN).toBe("${GITHUB_PERSONAL_TOKEN}"); expect(config.profiles.work?.policy).toBe("readonly"); expect(config.profiles.personal?.policy).toBe("readonly"); + expect(config.security).toMatchObject({ + allowProfileSwitchingFromMcp: true, + requireProfileSwitchConfirmation: true, + requireExplicitProfileForDestructive: true, + requireExplicitSelectionForDestructive: true + }); const refs = [ config.profiles.work?.env?.GITHUB_PERSONAL_ACCESS_TOKEN, @@ -50,6 +56,8 @@ describe("preset config", () => { }, profiles: { default: { description: "Default account", env: {} } } }); + expect(config.security?.requireProfileSwitchConfirmation).toBeUndefined(); + expect(config.security?.requireExplicitSelectionForDestructive).toBeUndefined(); }); it("retains the public generic fallback for unknown preset names", () => { @@ -74,5 +82,7 @@ describe("preset config", () => { }, policies: { readonly: { allowRisk: ["read"], denyRisk: ["write", "destructive"] } } }); + expect(config.security?.requireProfileSwitchConfirmation).toBeUndefined(); + expect(config.security?.requireExplicitSelectionForDestructive).toBeUndefined(); }); }); diff --git a/tests/profile-leases-docs-contract.test.ts b/tests/profile-leases-docs-contract.test.ts index a26ece2c..2eedc940 100644 --- a/tests/profile-leases-docs-contract.test.ts +++ b/tests/profile-leases-docs-contract.test.ts @@ -1,6 +1,5 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { documentedChangesSection } from "./helpers/changelog.js"; function readRepositoryFile(path: string): string { return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); @@ -33,6 +32,6 @@ describe("profile lease and lock documentation contract", () => { expect(architecture).toContain("captured lease"); expect(cli).toContain("miftah_lock_profile"); expect(cli).toContain("miftah_unlock_profile"); - expect(documentedChangesSection(changelog)).toMatch(/\[#28\][\s\S]*profile/iu); + expect(changelog).toMatch(/\[#28\][\s\S]*profile/iu); }); }); diff --git a/tests/profile-runtime-isolation-docs-contract.test.ts b/tests/profile-runtime-isolation-docs-contract.test.ts index 226a2c3b..2c4ac5f7 100644 --- a/tests/profile-runtime-isolation-docs-contract.test.ts +++ b/tests/profile-runtime-isolation-docs-contract.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { documentedChangesSection } from "./helpers/changelog.js"; +import { changelogIssueEntry } from "./helpers/changelog.js"; function readRepositoryFile(path: string): string { return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); @@ -33,7 +33,7 @@ describe("profile credential isolation documentation contract", () => { expect(architecture).toContain("ProfileRuntimeIsolation"); expect(architecture).toContain("--mount"); expect(architecture).toContain("macOS Podman isolation fail closed"); - expect(documentedChangesSection(changelog)).toMatch(/\[#29\][\s\S]*credential/iu); + expect(changelogIssueEntry(changelog, 29)).toMatch(/credential/iu); }); it("states the native same-user and container boundaries without overclaiming containment", () => { diff --git a/tests/profile-state-docs-contract.test.ts b/tests/profile-state-docs-contract.test.ts index 67605d2c..14015733 100644 --- a/tests/profile-state-docs-contract.test.ts +++ b/tests/profile-state-docs-contract.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { documentedChangesSection } from "./helpers/changelog.js"; +import { changelogIssueEntry } from "./helpers/changelog.js"; const activeProfileStateHeading = /^### Active profile state\s*$/mu; const sectionHeading = /^## |^### /mu; @@ -58,6 +58,6 @@ describe("active profile state documentation contract", () => { } expect(security).toContain("other MCP request data"); expect(cli).toContain("`selectionSource`, `selectedAt`, and `scope`"); - expect(documentedChangesSection(changelog)).toMatch(/\[#23\][\s\S]*active-profile persistence/iu); + expect(changelogIssueEntry(changelog, 23)).toMatch(/active-profile persistence/iu); }); }); diff --git a/tests/release-version.test.ts b/tests/release-version.test.ts index 744a83d8..6807530f 100644 --- a/tests/release-version.test.ts +++ b/tests/release-version.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -const releaseVersion = "0.2.1"; +const releaseVersion = "0.3.0"; function readRepositoryFile(path: string): string { return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); @@ -21,13 +21,13 @@ function releaseNotes(changelog: string, version: string): string { return changelog.slice(match.index, end < 0 ? undefined : end); } -describe("v0.2.1 release artifacts", () => { +describe("v0.3.0 release artifacts", () => { it.each([ - "## [0.2.1] - 2026-7-17\n\n### Fixed\n", - "Release candidate: ## [0.2.1] - 2026-07-17\n\n### Fixed\n" + "## [0.3.0] - 2026-7-18\n\n### Fixed\n", + "Release candidate: ## [0.3.0] - 2026-07-18\n\n### Fixed\n" ])("requires a dated release heading at the start of a line", (changelog) => { expect(() => releaseNotes(changelog, releaseVersion)).toThrow( - "Unable to find the 0.2.1 changelog entry." + "Unable to find the 0.3.0 changelog entry." ); }); @@ -69,14 +69,15 @@ describe("v0.2.1 release artifacts", () => { const changelog = readRepositoryFile("CHANGELOG.md"); const notes = releaseNotes(changelog, releaseVersion); - expect(notes).toContain("### Fixed"); - for (const issue of ["#79", "#80"]) { + expect(notes).toContain("### Changed"); + for (const issue of ["#96", "#97", "#98"]) { expect(notes).toContain(issue); } - expect(notes).toMatch(/OAuth support boundary/iu); - expect(notes).toMatch(/threat.model/iu); - expect(notes).toMatch(/does not (?:implement|run).*native OAuth/iu); - expect(notes).toMatch(/upstream.owned|provider.owned/iu); + expect(notes).toMatch(/fail closed/iu); + expect(notes).toMatch(/delegated-agent/iu); + expect(notes).toMatch(/profile-switch confirmation/iu); + expect(notes).toMatch(/Claude Code/iu); + expect(notes).toMatch(/miftah_list_approvals.*read-only/iu); expect(readRepositoryFile("README.md")).toContain("experimental and pre-1.0"); }); }); diff --git a/tests/routing-context-docs-contract.test.ts b/tests/routing-context-docs-contract.test.ts index c0eb3ef8..1055bc41 100644 --- a/tests/routing-context-docs-contract.test.ts +++ b/tests/routing-context-docs-contract.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { documentedChangesSection } from "./helpers/changelog.js"; +import { changelogIssueEntry } from "./helpers/changelog.js"; function readRepositoryFile(path: string): string { return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); @@ -44,6 +44,8 @@ describe("routing context documentation contract", () => { expect(security).toContain("Project markers cannot"); expect(security).toContain("cannot add credentials"); expect(security).toContain("never contains the raw `MIFTAH_PROJECT` value"); - expect(documentedChangesSection(changelog)).toMatch(/\[#20\][\s\S]*routing context[\s\S]*audit/iu); + const issue20 = changelogIssueEntry(changelog, 20); + expect(issue20).toContain("routing context"); + expect(issue20).toContain("audit"); }); });