audit-agent-surface
Inventory what agents can reach in a pracht app — capability exposure (HTTP, WebMCP, remote MCP), `agents` trust config, the destructive-confirmation gate, `llms.txt`, Markdown negotiation, OpenAPI — and report where the surface is wider than intended, or confirm an opt-out app s
Install
npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-agent-surface
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
git clone https://github.com/JoviDeCroock/pracht.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Pracht Audit Agent Surface
Pracht's agent surface is opt-in end to end (https://pracht.resynapse.dev/docs/agents). State that baseline before auditing the opt-outs:
- No loader or API route is ever inferred as a capability; a capability without
exposeis unreachable over the network. destructivecapabilities may be exposed over HTTP and remote MCP, never as WebMCP page tools, and every dispatch is confirmation-gated.- Remote MCP rejects cookie-bearing requests and browser origins not listed in
agents.mcp.allowedOrigins, and serves destructive capabilities only withagents.mcp.destructiveplus a registered approval store — otherwise it filters them out.agents.mcp.authmakes it an OAuth 2.0 protected resource; without it the endpoint is open and authentication is middleware's job. - An app that registers neither capabilities nor
agentshas the dispatch path and Web Bot Auth verifier dropped from its server bundle at build time.
This skill reports; it never mutates. Prerequisites: pracht inspect needs a
vite config registering the pracht plugin. If the pracht MCP server is
registered (pracht dev-mcp), prefer its tools (inspect_agents,
inspect_capabilities, inspect_routes, inspect_api, doctor, verify)
over shelling out.
Step 1: Inventory the declared surface
pracht inspect agents --json # the whole configured surface in one call
pracht inspect capabilities --json # name, effect, transports, HTTP path, middleware, schemas
pracht inspect routes --json # markdown negotiation, hydration, middleware
pracht inspect api --json
pracht verify --json # contract, exposure, and projection checks
inspect agents reports webBotAuth, confirmation policy, MCP endpoint and
OAuth policy, llmsTxt, each capability's effect/policy/transports/path, and
exposure counts (private means unexposed). Use inspect capabilities for
schemas and middleware.
Build the inventory table: capability → effect → transports → HTTP path →
middleware → agentPolicy. A capability reported as unreadable means
@pracht/capabilities is not installed; treat it as an error and stop
reasoning about its policy until it loads.
Cross-check inspect agents against the app's agents config: the manifest's
agents block, or the agents export of src/pages/_app.config.ts when
inspect reports "mode": "pages". It reads resolved app and production
llmsTxt config, including computed branches. llmsTxt.enabled: null means an older
plugin: report unknown and recommend upgrading. Use resolved
mcp.auth; null means framework OAuth is open.
Where capabilities are declared differs by router; what they expose does not.
A pages app registers every module in src/capabilities/ — read that
directory, not a capabilities key. A pages app is not surface-free just
because it has no src/routes.ts.
Step 2: Exposure vs. intent
For every exposed capability, ask whether the exposure is deliberate:
expose.mcpset but noagents.mcpconfigured — declared, served by nothing.pracht verifywarns; report it so the intent gets resolved.expose.mcpset on an operation whose authorization relies on a browser session — remote MCP rejects cookies, so the only credentials it sees are the forwardedAuthorizationheader andcontext.agent. A middleware chain that reads a session cookie authorizes nobody there.expose.webmcp— the in-page agent acts as the signed-in user in their tab. Confirm that is intended for every one, and that the route's hydration is not"none"(which registers no tools). Flag a webmcp capability whose effective agent policy is"require"(capability-level, or inherited fromagents.webBotAuth.policy): page-tool calls are unsigned browser fetches, so the tool is dead — every call 401s. Also check that capabilities returning user-generated or third-party content setexpose.webmcp: { untrustedContent: true }so hosts treat the output as untrusted. FlagexposedToand outputs above 1.5K. Consequential work isdestructive, so never WebMCP.- Private capabilities used as building blocks:
invokeCapability()runs their named middleware but not app-levelapi.middleware. Their named middleware is the only authorization seam — flag private capabilities with an emptymiddlewarelist that touch sensitive data. - Custom
expose.http.pathvalues that land outside/api/**and therefore escape path-scoped middleware or host rules. - Declared vs. actually served:
expose.mcpin source is what the graph claims. Apracht evalscenario with"transport": "mcp"proves what the endpoint answers — it performs a realinitializehandshake and issues each step as atools/call. When the endpoint hasmcp.auth, set scenario-levelmcpHeaders.authorizationso the token is sent on the handshake and every later request; do not commit a production token. Run it only against a local throwaway server, and only withreadsteps. If the app ships MCP-exposed capabilities with no such scenario, report the missing proof: an HTTP-only scenario says nothing about whether an MCP host can reach the tool.
Step 2b: The /mcp auth posture
agents: { mcp: {} } without auth is open; authorization rests entirely on
each tool's named middleware. Report an exposed tool with no middleware as
error; otherwise warn and name the middleware carrying the boundary.
With agents.mcp.auth, check:
resourceis canonical HTTPS (loopback HTTP only), has no query, fragment, or non-root trailing slash, and exactly identifies the endpoint./may identify the deployed root; the origin-root identifier is slashless. Aliases, query variants, and trailing slashes must 308 toresourcebefore challenge.- Every
authorizationServersissuer is canonical HTTPS without query or fragment. Reject unknownagents.mcp/authkeys and API-route collisions. verifyis a module reference undersrc/server,src/middleware, orsrc/capabilities, resolves uniquely, and default-exports a function. Inline, missing, ambiguous, or non-callable verifiers areerror. Its request clone may consume the body without consuming later JSON-RPC dispatch. Overlapping source directories may register the same normalized file more than once; that is one verifier, not ambiguity. Ablockedinspection reason for an unusable verifier is conclusive because the adapter server entry cannot replace the configured module reference.- The verifier binds token audience to
resource; otherwise tokens for another service authenticate here (error). RequirerequiredScopesor per-tool checks ofcontext.tokenAuth.scopes(warnotherwise). The initial challenge must advertise required scopes; scope tokens follow OAuth's printable-ASCII grammar.context.tokenAuthis MCP-only and nested calls cannot replace it. - Under base
/app/, resource includes/app/mcpwhile metadata stays at the origin-root/.well-known/oauth-protected-resource/app/mcp; fetch it and its bare alias, ensuring app/static routes cannot shadow either. The bare well-known path is reserved and its CORS-open metadata is expected; flag only sensitive scope names. CapabilityAuditEventrecords Web Bot Authagent, nottokenAuth. Report this asinfo, orwarnwhen per-account attribution is required and no middleware/capability forwards the principal to an audit sink.
Step 3: The destructive gate
webmcpon adestructivecapability is rejected by the framework — if you find it in source, the build is failing.mcpon adestructivecapability is a served remote tool only when the manifest setsagents: { mcp: { destructive: true } }. Report it as a deliberate widening and check both halves: the opt-in, and asetCapabilityApprovalStore()call the running server actually executes (imported by a server entry, a capability module, or applied API/capability middleware — a module nothing imports registers nothing). Opt-in without a store is anerrorin your report: the endpoint refuses to serve at all, andpracht verifyonly warns (its source scan cannot see a registration in a workspace package, so it must not hard-block). Two more preconditions fail the endpoint the same way — a missingPRACHT_CONFIRMATION_SECRET, andmode: "human"with neither a resolver noragents.mcp.auth— so check all three together. Runtime-backed/_prachtreports a verified endpoint-wide failure by marking every MCP exposuremcp(unserved). Graph-onlypracht dev,pracht inspect capabilities,pracht inspect agents, and MCP inspection usemcp(unverified)when the same missing preconditions may be registered by the adapter server entry they deliberately skip. JSON inspection exposesmcpEndpoint,mcpDestructive,mcpRuntimeStatus, andmcpUnavailableReasons; use those fields instead of treating a declaredmcptransport as proof of reachability. Destructiveexpose.mcpwithout the opt-in is dead exposure: the tool is invisible, andpracht verifywarns.PRACHT_CONFIRMATION_SECRETmust be set in the server environment for each deployment target (build environment too on Vercel, since it becomes the bypass token there). Missing → every destructive call answers403 confirmation_unavailable.- Record the honest limits in the report: the stateless HMAC token is replayable
within its TTL (default 120 s), the calling agent can hand the token back to
itself, and without Web Bot Auth or
setCapabilityApprovalPrincipalResolver()both phases run as"anonymous". Flagconfirmation: { singleUse: true }used as if it were durable — it is a per-instance in-memory cache, lost on restart. - If an approval store is registered, confirm its backend supports atomic
conditional writes and that all replicas share it: with a store registered, a
token whose proposal is unknown is refused, so a per-instance store breaks
commits.
createSqlApprovalStore()over D1/Postgres/Turso qualifies;createMemoryApprovalStore()in a deployed multi-replica app does not, and neither does a hand-rolled store over Cloudflare KV. confirmation: { mode: "human" }without both a store and an end-user principal (a resolver, oragents.mcp.authon MCP) fails closed — check both exist. Web Bot Auth alone is refused: it names the agent.
Step 4: Identity and policy
agents.webBotAuth.policy: "require"gates capability HTTP endpoints only — pages and API routes are not gated. Flag any assumption that it protects pages.directoriesis an allowlist; an empty one means no directory fetching at all (deliberate SSRF protection). Flag a directory origin that is not the agent ecosystem endpoint the app intends to trust.agentPolicy: "require"on a capability whilewebBotAuthis unconfigured answers 401 for every caller — a loud misconfiguration, report aserror.- Replay: the verifier does not enforce
nonceuniqueness, and by default a signature binds only the host. If the identity gates anything andwebBotAuth.requireComponentslacks@methodand@path,warn: a captured signature replays onto any endpoint formaxLifetimeSeconds(24 h default). - Confirm an audit sink exists (
setCapabilityAuditHook(),addCapabilityAuditListener(), oronCapabilityAudit). Grep for all three;setCapabilityAuditHookis a single slot, so two calls to it mean one sink is silently dead — report that as awarnand point ataddCapabilityAuditListener(name, hook). Also flag a computed or non-constant sink name: same-name registration is what makes the call idempotent under dev HMR. A module-scope listener must also register its unsubscribe withimport.meta.hot.dispose(); otherwise removing the module or renaming the sink leaves the old registration active until the dev server restarts. - Know what the trail does not cover before treating it as a security record: a cross-origin 403, an unknown-capability 404, and an unknown or unexposed MCP tool name all return before dispatch and emit no event. An agent enumerating tool names leaves no trace, so never conclude "nothing tried" from an empty trail — that question belongs to the HTTP access log.
- To see the surface actually being exercised rather than merely declared, run
the app with
pracht dev, drive the capability, and read the Agents section of/_pracht(JSON underagentTrafficat/_pracht.json). It records transport,viafor nested composition, verified identity, outcome code, and duration — useful for proving a guard actually fires. The page counts verified identities, MCP, and MCP-caused composition as agent-attributed; shows top-level unsigned HTTP, HTTP-caused composition, and client-declared WebMCP markers separately as unverified client dispatches; and hides onlyinvokeCapability()work with no served-request provenance behind a first-party toggle. The JSON keeps everything. The traffic buffer outlives app-graph HMR, so retained calls stay visible after the final capability is removed, until the dev server restarts. It is dev-only, and under adapter-owned dev servers (Cloudflareworkerd)/_prachtdoes not exist at all — a 404 there means the middleware never ran, not that no agent traffic occurred.
Step 5: The discovery surface
llmsTxtin the vite config: every listed path is a URL the app invites an agent to fetch. An auth-gated route missing fromexcludeis awarn: the framework cannot tell a gating middleware from a logging one. Capabilities appear there with their effect class; destructive ones are annotatedrequires confirmation.- A collection-driven
llmsTxtArtifacts()(see/add-content) is a second generator; compare what each publishes. - Routes exporting
markdownor declaringmarkdown: trueserve a Markdown representation to agents. Confirm the Markdown variant is not more permissive than the HTML page (it carriesVary: Accept, so it is separately cached). SSG routes with amarkdownexport and no middleware also publish it asdist/client/<path>.md(off withmarkdownFiles: false); confirm each file is meant for every visitor. - An enabled OpenAPI document (
/openapi.json,dist/client/openapi.json) is public unless the host protects it — check descriptions and examples for internal hostnames or credentials, and that "Try it out" mutation endpoints carry real authentication. /.well-known/http-message-signatures-directoryand the MCP endpoint path should both be intentional; the MCP endpoint stays active with an empty capability graph./.well-known/oauth-protected-resource(and the RFC 9728 path-suffixed form, e.g./.well-known/oauth-protected-resource/mcp) is served only whenagents.mcp.authis configured. Its presence is a good signal; its absence next to a live/mcpmeans no MCP host can authenticate to the endpoint.
Step 6: Did this change widen the surface?
pracht plan --json --base origin/main
widensAgentSurface and the ! capability lines answer the question a route
diff cannot: a new exposure, a destructive capability reclassified out of the
gate, an agentPolicy downgraded from require, dropped middleware, a
loosened input schema (dropped required, opened additionalProperties, raised
bound), newly enabled agents.mcp, newly enabled agents.mcp.destructive when
a declared destructive MCP capability actually exists, OAuth protection
removed from a still-live MCP endpoint, a removed required scope, or a newly
trusted authorization server. Enabling the destructive switch in advance,
with no such tool, is not a widening. The snapshot records the OAuth policy
separately from the endpoint path, so an unchanged /mcp is not evidence that
the guard stayed the same. Report every widening explicitly, with the
before/after. A stale snapshot makes this useless — pracht verify fails on
staleness, so trust it only when verify passes.
Step 7: The no-agent-surface case
When the app is supposed to have none:
- Confirm no
capabilitiesand noagentsare registered (pages apps: nosrc/capabilities/, no_app.config.ts). That lets the build define the surface away (~15 KB gzip in an example server bundle). - Analysis is one-sided: a spread, a regex literal, or otherwise opaque syntax in the manifest leaves the define unset and keeps the runtime in the bundle. Flag manifest constructs that defeat the static read.
- Confirm
llmsTxtis off if the app should not advertise itself, and no route setsmarkdown: true. create-pracht --no-agent-toolscontrols scaffolded developer tooling (.mcp.json, skills), not the deployed agent surface. Do not conflate them.
Step 8: Report
| Surface | Item | Reach | Guard | Severity |
|---|
Severities:
error— destructive capability reachable without a configured confirmation secret;agents.mcp.destructivewith no registered approval store, or with a memory store on a multi-replica deployment;agentPolicy: "require"with nowebBotAuth; capability module unreadable; MCP-exposed capability whose only authorization is a cookie session; approval store on a backend without conditional writes; unguardedexpose.mcptool on an endpoint with neitheragents.mcp.authnor named middleware;agents.mcp.auth.verifythat does not bind the token audience toresource, or that is an inline function in the manifest.warn— auth-gated route advertised inllms.txt;expose.mcpwith noagents.mcp; destructiveexpose.mcpwith noagents.mcp.destructive(dead exposure); exposed capability with no named middleware; unbounded output (nolimit/maximum); no audit sink; a secondsetCapabilityAuditHook()call silently replacing the first; a module-scope listener without HMR disposal;singleUsetreated as durable;agents.mcp.authwith norequiredScopesand no per-capability scope check.info— exposure that is intentional and guarded, recorded so the reviewer sees the whole surface in one place; framework gaps that are deployment responsibilities (rate limiting, write idempotency, result-size limits).
Rules
- Report only — never change exposure, policy, or configuration. Propose the diff and let the owner apply it.
- Never call a destructive capability to test it, not even the prepare phase, against anything but a local throwaway environment.
- Do not treat client-declared signals as trust: the
webmcptransport marker is informational, and only MCP dispatch state is trustworthy for attributing nested effects. - Distinguish
pracht dev-mcp(the development-time stdio server exposing the app graph to coding agents) from the deployed/mcpendpoint exposing the app's operations. They have different threat models. - Pair with
/audit-auth,/audit-csrf, and/audit-secrets— this skill owns agent reachability, not general request authorization.
$ARGUMENTS
Files (pracht)
-
SKILL.md 19.4 KB
--- name: audit-agent-surface version: 1.1.0 description: | Inventory what agents can reach in a pracht app — capability exposure (HTTP, WebMCP, remote MCP), `agents` trust config, the destructive-confirmation gate, `llms.txt`, Markdown negotiation, OpenAPI — and report where the surface is wider than intended, or confirm an opt-out app ships none. Use for "audit the agent surface", "what can agents do on my site", "is my MCP endpoint safe", "did this PR widen what agents can reach". allowed-tools: - Bash - Read - Grep - Glob --- # Pracht Audit Agent Surface Pracht's agent surface is opt-in end to end (<https://pracht.resynapse.dev/docs/agents>). State that baseline before auditing the opt-outs: - No loader or API route is ever inferred as a capability; a capability without `expose` is unreachable over the network. - `destructive` capabilities may be exposed over HTTP and remote MCP, never as WebMCP page tools, and every dispatch is confirmation-gated. - Remote MCP rejects cookie-bearing requests and browser origins not listed in `agents.mcp.allowedOrigins`, and serves destructive capabilities only with `agents.mcp.destructive` plus a registered approval store — otherwise it filters them out. `agents.mcp.auth` makes it an OAuth 2.0 protected resource; without it the endpoint is open and authentication is middleware's job. - An app that registers neither capabilities nor `agents` has the dispatch path and Web Bot Auth verifier dropped from its server bundle at build time. This skill reports; it never mutates. Prerequisites: `pracht inspect` needs a vite config registering the pracht plugin. If the pracht MCP server is registered (`pracht dev-mcp`), prefer its tools (`inspect_agents`, `inspect_capabilities`, `inspect_routes`, `inspect_api`, `doctor`, `verify`) over shelling out. ## Step 1: Inventory the declared surface ```bash pracht inspect agents --json # the whole configured surface in one call pracht inspect capabilities --json # name, effect, transports, HTTP path, middleware, schemas pracht inspect routes --json # markdown negotiation, hydration, middleware pracht inspect api --json pracht verify --json # contract, exposure, and projection checks ``` `inspect agents` reports `webBotAuth`, confirmation policy, MCP endpoint and OAuth policy, `llmsTxt`, each capability's effect/policy/transports/path, and exposure counts (`private` means unexposed). Use `inspect capabilities` for schemas and middleware. Build the inventory table: capability → effect → transports → HTTP path → middleware → `agentPolicy`. A capability reported as `unreadable` means `@pracht/capabilities` is not installed; treat it as an `error` and stop reasoning about its policy until it loads. Cross-check `inspect agents` against the app's `agents` config: the manifest's `agents` block, or the `agents` export of `src/pages/_app.config.ts` when `inspect` reports `"mode": "pages"`. It reads resolved app and production `llmsTxt` config, including computed branches. `llmsTxt.enabled: null` means an older plugin: report unknown and recommend upgrading. Use resolved `mcp.auth`; `null` means framework OAuth is open. Where capabilities are declared differs by router; what they expose does not. A pages app registers every module in `src/capabilities/` — read that directory, not a `capabilities` key. A pages app is not surface-free just because it has no `src/routes.ts`. ## Step 2: Exposure vs. intent For every exposed capability, ask whether the exposure is deliberate: - `expose.mcp` set but no `agents.mcp` configured — declared, served by nothing. `pracht verify` warns; report it so the intent gets resolved. - `expose.mcp` set on an operation whose authorization relies on a browser session — remote MCP rejects cookies, so the only credentials it sees are the forwarded `Authorization` header and `context.agent`. A middleware chain that reads a session cookie authorizes nobody there. - `expose.webmcp` — the in-page agent acts as the signed-in user in their tab. Confirm that is intended for every one, and that the route's hydration is not `"none"` (which registers no tools). Flag a webmcp capability whose effective agent policy is `"require"` (capability-level, or inherited from `agents.webBotAuth.policy`): page-tool calls are unsigned browser fetches, so the tool is dead — every call 401s. Also check that capabilities returning user-generated or third-party content set `expose.webmcp: { untrustedContent: true }` so hosts treat the output as untrusted. Flag `exposedTo` and outputs above 1.5K. Consequential work is `destructive`, so never WebMCP. - Private capabilities used as building blocks: `invokeCapability()` runs their named middleware but **not** app-level `api.middleware`. Their named middleware is the only authorization seam — flag private capabilities with an empty `middleware` list that touch sensitive data. - Custom `expose.http.path` values that land outside `/api/**` and therefore escape path-scoped middleware or host rules. - Declared vs. actually served: `expose.mcp` in source is what the graph claims. A `pracht eval` scenario with `"transport": "mcp"` proves what the endpoint answers — it performs a real `initialize` handshake and issues each step as a `tools/call`. When the endpoint has `mcp.auth`, set scenario-level `mcpHeaders.authorization` so the token is sent on the handshake and every later request; do not commit a production token. Run it only against a local throwaway server, and only with `read` steps. If the app ships MCP-exposed capabilities with no such scenario, report the missing proof: an HTTP-only scenario says nothing about whether an MCP host can reach the tool. ## Step 2b: The `/mcp` auth posture `agents: { mcp: {} }` without `auth` is open; authorization rests entirely on each tool's named middleware. Report an exposed tool with no middleware as `error`; otherwise `warn` and name the middleware carrying the boundary. With `agents.mcp.auth`, check: - `resource` is canonical HTTPS (loopback HTTP only), has no query, fragment, or non-root trailing slash, and exactly identifies the endpoint. `/` may identify the deployed root; the origin-root identifier is slashless. Aliases, query variants, and trailing slashes must 308 to `resource` before challenge. - Every `authorizationServers` issuer is canonical HTTPS without query or fragment. Reject unknown `agents.mcp`/`auth` keys and API-route collisions. - `verify` is a module reference under `src/server`, `src/middleware`, or `src/capabilities`, resolves uniquely, and default-exports a function. Inline, missing, ambiguous, or non-callable verifiers are `error`. Its request clone may consume the body without consuming later JSON-RPC dispatch. Overlapping source directories may register the same normalized file more than once; that is one verifier, not ambiguity. A `blocked` inspection reason for an unusable verifier is conclusive because the adapter server entry cannot replace the configured module reference. - The verifier binds token audience to `resource`; otherwise tokens for another service authenticate here (`error`). Require `requiredScopes` or per-tool checks of `context.tokenAuth.scopes` (`warn` otherwise). The initial challenge must advertise required scopes; scope tokens follow OAuth's printable-ASCII grammar. `context.tokenAuth` is MCP-only and nested calls cannot replace it. - Under base `/app/`, resource includes `/app/mcp` while metadata stays at the origin-root `/.well-known/oauth-protected-resource/app/mcp`; fetch it and its bare alias, ensuring app/static routes cannot shadow either. The bare well-known path is reserved and its CORS-open metadata is expected; flag only sensitive scope names. - `CapabilityAuditEvent` records Web Bot Auth `agent`, not `tokenAuth`. Report this as `info`, or `warn` when per-account attribution is required and no middleware/capability forwards the principal to an audit sink. ## Step 3: The destructive gate - `webmcp` on a `destructive` capability is rejected by the framework — if you find it in source, the build is failing. - `mcp` on a `destructive` capability is a **served remote tool** only when the manifest sets `agents: { mcp: { destructive: true } }`. Report it as a deliberate widening and check both halves: the opt-in, and a `setCapabilityApprovalStore()` call the running server actually executes (imported by a server entry, a capability module, or applied API/capability middleware — a module nothing imports registers nothing). Opt-in without a store is an `error` in your report: the endpoint refuses to serve at all, and `pracht verify` only warns (its source scan cannot see a registration in a workspace package, so it must not hard-block). Two more preconditions fail the endpoint the same way — a missing `PRACHT_CONFIRMATION_SECRET`, and `mode: "human"` with neither a resolver nor `agents.mcp.auth` — so check all three together. Runtime-backed `/_pracht` reports a verified endpoint-wide failure by marking every MCP exposure `mcp(unserved)`. Graph-only `pracht dev`, `pracht inspect capabilities`, `pracht inspect agents`, and MCP inspection use `mcp(unverified)` when the same missing preconditions may be registered by the adapter server entry they deliberately skip. JSON inspection exposes `mcpEndpoint`, `mcpDestructive`, `mcpRuntimeStatus`, and `mcpUnavailableReasons`; use those fields instead of treating a declared `mcp` transport as proof of reachability. Destructive `expose.mcp` *without* the opt-in is dead exposure: the tool is invisible, and `pracht verify` warns. - `PRACHT_CONFIRMATION_SECRET` must be set in the server environment for each deployment target (build environment too on Vercel, since it becomes the bypass token there). Missing → every destructive call answers `403 confirmation_unavailable`. - Record the honest limits in the report: the stateless HMAC token is replayable within its TTL (default 120 s), the calling agent can hand the token back to itself, and without Web Bot Auth or `setCapabilityApprovalPrincipalResolver()` both phases run as `"anonymous"`. Flag `confirmation: { singleUse: true }` used as if it were durable — it is a per-instance in-memory cache, lost on restart. - If an approval store is registered, confirm its backend supports atomic conditional writes and that all replicas share it: with a store registered, a token whose proposal is unknown is refused, so a per-instance store breaks commits. `createSqlApprovalStore()` over D1/Postgres/Turso qualifies; `createMemoryApprovalStore()` in a deployed multi-replica app does not, and neither does a hand-rolled store over Cloudflare KV. - `confirmation: { mode: "human" }` without both a store and an end-user principal (a resolver, or `agents.mcp.auth` on MCP) fails closed — check both exist. Web Bot Auth alone is refused: it names the agent. ## Step 4: Identity and policy - `agents.webBotAuth.policy: "require"` gates capability HTTP endpoints only — pages and API routes are not gated. Flag any assumption that it protects pages. - `directories` is an allowlist; an empty one means no directory fetching at all (deliberate SSRF protection). Flag a directory origin that is not the agent ecosystem endpoint the app intends to trust. - `agentPolicy: "require"` on a capability while `webBotAuth` is unconfigured answers 401 for every caller — a loud misconfiguration, report as `error`. - Replay: the verifier does not enforce `nonce` uniqueness, and by default a signature binds only the host. If the identity gates anything and `webBotAuth.requireComponents` lacks `@method` and `@path`, `warn`: a captured signature replays onto any endpoint for `maxLifetimeSeconds` (24 h default). - Confirm an audit sink exists (`setCapabilityAuditHook()`, `addCapabilityAuditListener()`, or `onCapabilityAudit`). Grep for all three; `setCapabilityAuditHook` is a single slot, so two calls to it mean one sink is silently dead — report that as a `warn` and point at `addCapabilityAuditListener(name, hook)`. Also flag a computed or non-constant sink name: same-name registration is what makes the call idempotent under dev HMR. A module-scope listener must also register its unsubscribe with `import.meta.hot.dispose()`; otherwise removing the module or renaming the sink leaves the old registration active until the dev server restarts. - Know what the trail does **not** cover before treating it as a security record: a cross-origin 403, an unknown-capability 404, and an unknown or unexposed MCP tool name all return *before* dispatch and emit no event. An agent enumerating tool names leaves no trace, so never conclude "nothing tried" from an empty trail — that question belongs to the HTTP access log. - To see the surface actually being exercised rather than merely declared, run the app with `pracht dev`, drive the capability, and read the **Agents** section of `/_pracht` (JSON under `agentTraffic` at `/_pracht.json`). It records transport, `via` for nested composition, verified identity, outcome code, and duration — useful for proving a guard actually fires. The page counts verified identities, MCP, and MCP-caused composition as agent-attributed; shows top-level unsigned HTTP, HTTP-caused composition, and client-declared WebMCP markers separately as unverified client dispatches; and hides only `invokeCapability()` work with no served-request provenance behind a first-party toggle. The JSON keeps everything. The traffic buffer outlives app-graph HMR, so retained calls stay visible after the final capability is removed, until the dev server restarts. It is dev-only, and under adapter-owned dev servers (Cloudflare `workerd`) `/_pracht` does not exist at all — a 404 there means the middleware never ran, not that no agent traffic occurred. ## Step 5: The discovery surface - `llmsTxt` in the vite config: every listed path is a URL the app *invites* an agent to fetch. An auth-gated route missing from `exclude` is a `warn`: the framework cannot tell a gating middleware from a logging one. Capabilities appear there with their effect class; destructive ones are annotated `requires confirmation`. - A collection-driven `llmsTxtArtifacts()` (see `/add-content`) is a second generator; compare what each publishes. - Routes exporting `markdown` or declaring `markdown: true` serve a Markdown representation to agents. Confirm the Markdown variant is not more permissive than the HTML page (it carries `Vary: Accept`, so it is separately cached). SSG routes with a `markdown` export and no middleware also publish it as `dist/client/<path>.md` (off with `markdownFiles: false`); confirm each file is meant for every visitor. - An enabled OpenAPI document (`/openapi.json`, `dist/client/openapi.json`) is public unless the host protects it — check descriptions and examples for internal hostnames or credentials, and that "Try it out" mutation endpoints carry real authentication. - `/.well-known/http-message-signatures-directory` and the MCP endpoint path should both be intentional; the MCP endpoint stays active with an empty capability graph. - `/.well-known/oauth-protected-resource` (and the RFC 9728 path-suffixed form, e.g. `/.well-known/oauth-protected-resource/mcp`) is served only when `agents.mcp.auth` is configured. Its presence is a *good* signal; its absence next to a live `/mcp` means no MCP host can authenticate to the endpoint. ## Step 6: Did this change widen the surface? ```bash pracht plan --json --base origin/main ``` `widensAgentSurface` and the `!` capability lines answer the question a route diff cannot: a new exposure, a destructive capability reclassified out of the gate, an `agentPolicy` downgraded from `require`, dropped middleware, a loosened input schema (dropped `required`, opened `additionalProperties`, raised bound), newly enabled `agents.mcp`, newly enabled `agents.mcp.destructive` when a declared destructive MCP capability actually exists, OAuth protection removed from a still-live MCP endpoint, a removed required scope, or a newly trusted authorization server. Enabling the destructive switch in advance, with no such tool, is not a widening. The snapshot records the OAuth policy separately from the endpoint path, so an unchanged `/mcp` is not evidence that the guard stayed the same. Report every widening explicitly, with the before/after. A stale snapshot makes this useless — `pracht verify` fails on staleness, so trust it only when verify passes. ## Step 7: The no-agent-surface case When the app is supposed to have none: - Confirm no `capabilities` and no `agents` are registered (pages apps: no `src/capabilities/`, no `_app.config.ts`). That lets the build define the surface away (~15 KB gzip in an example server bundle). - Analysis is one-sided: a spread, a regex literal, or otherwise opaque syntax in the manifest leaves the define unset and keeps the runtime in the bundle. Flag manifest constructs that defeat the static read. - Confirm `llmsTxt` is off if the app should not advertise itself, and no route sets `markdown: true`. - `create-pracht --no-agent-tools` controls *scaffolded developer* tooling (`.mcp.json`, skills), not the deployed agent surface. Do not conflate them. ## Step 8: Report | Surface | Item | Reach | Guard | Severity | | ------- | ---- | ----- | ----- | -------- | Severities: - `error` — destructive capability reachable without a configured confirmation secret; `agents.mcp.destructive` with no registered approval store, or with a memory store on a multi-replica deployment; `agentPolicy: "require"` with no `webBotAuth`; capability module unreadable; MCP-exposed capability whose only authorization is a cookie session; approval store on a backend without conditional writes; unguarded `expose.mcp` tool on an endpoint with neither `agents.mcp.auth` nor named middleware; `agents.mcp.auth.verify` that does not bind the token audience to `resource`, or that is an inline function in the manifest. - `warn` — auth-gated route advertised in `llms.txt`; `expose.mcp` with no `agents.mcp`; destructive `expose.mcp` with no `agents.mcp.destructive` (dead exposure); exposed capability with no named middleware; unbounded output (no `limit`/`maximum`); no audit sink; a second `setCapabilityAuditHook()` call silently replacing the first; a module-scope listener without HMR disposal; `singleUse` treated as durable; `agents.mcp.auth` with no `requiredScopes` and no per-capability scope check. - `info` — exposure that is intentional and guarded, recorded so the reviewer sees the whole surface in one place; framework gaps that are deployment responsibilities (rate limiting, write idempotency, result-size limits). ## Rules 1. Report only — never change exposure, policy, or configuration. Propose the diff and let the owner apply it. 2. Never call a destructive capability to test it, not even the prepare phase, against anything but a local throwaway environment. 3. Do not treat client-declared signals as trust: the `webmcp` transport marker is informational, and only MCP dispatch state is trustworthy for attributing nested effects. 4. Distinguish `pracht dev-mcp` (the development-time stdio server exposing the app *graph* to coding agents) from the deployed `/mcp` endpoint exposing the app's *operations*. They have different threat models. 5. Pair with `/audit-auth`, `/audit-csrf`, and `/audit-secrets` — this skill owns agent reachability, not general request authorization. $ARGUMENTS
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.