diff --git a/.changeset/agent-scoped-submission-tools.md b/.changeset/agent-scoped-submission-tools.md new file mode 100644 index 00000000..de045034 --- /dev/null +++ b/.changeset/agent-scoped-submission-tools.md @@ -0,0 +1,30 @@ +--- +'@formio/mcp': minor +'@formio/ai': minor +--- + +Add five `submission_*` MCP tools that let the agent seed the Resource a `select` reads and write test submissions. Each tool reaches only submissions this server created and signed for the calling working directory. + +**`@formio/mcp`** adds `submission_create`, `submission_list`, `submission_get`, `submission_update`, and `submission_delete`. + +- **The tag.** Every submission the agent writes carries a server-written `metadata.agent` tag with exactly four fields: `source`, `session` (a label derived from the directory's key, so list queries return only that directory's rows), `purpose`, and `sig`. The signature is a deterministic HMAC-SHA256 over the project, form, owner, `session`, `purpose`, and the full stored `data`. The server gets that data from a `?dryrun=1` pass, which validates and normalizes without running actions or saving. The key is 32 random bytes per working directory, kept in `~/.formio/mcp-submission-keys.json` with mode `0600`. It never appears in a tool result or error. +- **Reads.** Every read forces the tag filters and builds its query from structured `data.*` filters. Agent parameters are never forwarded, because Form.io copies unrecognized query parameters straight into its Mongo filter. Each returned record's signature is re-verified, and a record that fails is dropped before the agent sees it. A get by id answers a 404 and a record the agent did not create the same way: not found, with none of its content. +- **Writes.** Update and delete verify the target first. Update keeps the record's `session` and `purpose`, re-signs over the dry-run-normalized data, and refuses caller-supplied `metadata`. Every write result names the form actions it ran. +- **Server instructions.** They now state the scope, the ban on reaching any other submission by another route, and the `action_list` check before a write. +- **API errors.** `FormioApiError` now carries the HTTP `status` and the response `body`, so a dry-run 400 can report Form.io's validation messages. + +**`@formio/ai`** adds one canonical guideline, `formio-mcp-setup/references/agent-submissions.md`, covering when the agent writes a submission: + +- the two purposes, `reference-data` and `test`; +- no reference rows in user-type Resources or in forms with a Login, Role Assignment, or Group Assignment action; +- invented values on a reserved domain; +- `action_list`, a preview, and approval before every write, with a warning for a live project; +- test-row cleanup. + +The skills now use it: + +- `formio-form-builder` offers to seed an empty dropdown source and to write a test submission after SAVE. +- `formio-application` gains Step 3.6, which offers the reference rows the planner marks with a new `Seed: reference-data` line. +- `formio-actions` documents testing an action with a test submission. + +The earlier wording that said no submission tool exists, that no tool returns submission data, and that only an administrator in the portal seeds reference data now names the scoped tools. The ban on reading an end user's submission by any route stays. diff --git a/CLAUDE.md b/CLAUDE.md index b306e3f6..e7e5ae83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -`@formio/ai` — the Form.io agentic coding toolset. A pnpm + Turborepo monorepo shipping three things: the `@formio/mcp` Model Context Protocol server (`packages/mcp-server/`, exposing `form_*` / `role_* `/ `action_*` / `project_*` tools), the `@formio/ai` agent plugin (`plugin/`, bundling the server + skill library behind three manifests — `.claude-plugin/plugin.json` for Claude Code, `.cursor-plugin/plugin.json` for Cursor, and `plugin.json` + `mcp.json` for the vendor-neutral Agent Plugins layout), and a `@formio/skill-tests` package (`packages/skill-tests/`) that runs the `formio-sdk` skill's doc examples against the real `@formio/js`. The skill library lives at `plugin/skills/` (twelve activatable skills). It is also installable skills-only with `npx skills add formio/ai`, in which case [`plugin/skills/formio-mcp-setup/`](plugin/skills/formio-mcp-setup/) connects the MCP server on first use — every other skill carries a preflight that hands off to it when the Form.io tools are missing, and is forbidden from working around the gap with raw HTTP. +`@formio/ai` — the Form.io agentic coding toolset. A pnpm + Turborepo monorepo shipping three things: the `@formio/mcp` Model Context Protocol server (`packages/mcp-server/`, exposing `form_*` / `role_*` / `action_*` / `submission_*` / `project_*` tools), the `@formio/ai` agent plugin (`plugin/`, bundling the server + skill library behind three manifests — `.claude-plugin/plugin.json` for Claude Code, `.cursor-plugin/plugin.json` for Cursor, and `plugin.json` + `mcp.json` for the vendor-neutral Agent Plugins layout), and a `@formio/skill-tests` package (`packages/skill-tests/`) that runs the `formio-sdk` skill's doc examples against the real `@formio/js`. The skill library lives at `plugin/skills/` (twelve activatable skills). It is also installable skills-only with `npx skills add formio/ai`, in which case [`plugin/skills/formio-mcp-setup/`](plugin/skills/formio-mcp-setup/) connects the MCP server on first use — every other skill carries a preflight that hands off to it when the Form.io tools are missing, and is forbidden from working around the gap with raw HTTP. ## Repository Info @@ -38,6 +38,8 @@ A record holds a project and its deployment as a PAIR, and resolution picks ONE Skills never interview for these URLs and never restate the guidance the server owns — the one exception is `plugin/skills/formio-mcp-setup/references/project-urls.md`, the canonical copy every other document links to instead of restating. Before its first deployment-touching call, a tool-calling skill calls the `project_get` MCP tool with `cwd` set to the user's working directory and branches on the `status` it returns (`ok` / `not-configured` / `base-url-unresolved`), recording whatever value the report names with `project_set`; a call that fails outright rather than returning a status is a broken record, not an absent one, so it is relayed rather than interviewed around. Skills do NOT shell out to `npx @formio/mcp project get` for this — the connected server answers it over the open transport with the same resolver every other tool uses. The `project get` / `project set` CLI subcommands remain, for `formio-mcp-setup`, which runs before any tool exists to call. `formio-mcp-setup` is the handoff target for a resolution failure; `formio-resource-planner` is exempt because it calls no MCP tool. Writing a URL into a user's application (`Formio.setBaseUrl`, `Formio.setProjectUrl`, `FormioAppConfig`'s `appUrl` = Project URL and `apiUrl` = Base URL) needs the values rather than a deployment, so it takes them from `project_get` when the tools are callable and from the user when they are not — never hardcoded. +Submissions: the `submission_*` tools reach only submissions the server created and signed for the calling working directory. Each one carries a server-written `metadata.agent` tag, and its HMAC covers the project, form, owner, tag fields and the full stored `data` (obtained with a `?dryrun=1` pass first). The key is per directory, in `~/.formio/mcp-submission-keys.json`. Reads force the tag filters, build the query from structured input (never forwarding agent params), and drop every record that fails verification before the agent sees it. The rules for when the agent seeds reference data or writes test submissions live in one canonical guideline, [`plugin/skills/formio-mcp-setup/references/agent-submissions.md`](plugin/skills/formio-mcp-setup/references/agent-submissions.md), which other skills link to rather than restate. No skill reads an end user's submission by any route. + Authentication: the MCP server uses a browser-based portal-login flow — a short-lived local Express server renders the Form.io portal login form and captures the returned JWT via a `/callback` endpoint; `formioFetch` then attaches `x-jwt-token` on every request. Skills do NOT use PKCE or API-key auth. Skill authoring conventions (not enforced by automated tests): the router's frontmatter and three-clause description, required reference files present and non-empty, the required reference-doc heading layout, the canonical portal-login JWT auth paragraph (except in `server-status.md`), and scope consistency. Terminology is strict, and one spelling per job: an `FORMIO_*` name means the **environment variable** and nothing else. That rule IS enforced for the two URL names — `FORMIO_PROJECT_URL` and `FORMIO_BASE_URL` — over every markdown file under `plugin/skills/`: see `packages/skill-tests/src/skill-descriptions/url-terminology.test.ts`, which rejects either name used as a substitution slot (`${…}`, `$…`, `{…}`, `{{…}}`, `<…>`, `YOUR_…`) or as the name of a value passed between phases, and allows the bare name only in a paragraph whose subject is the environment (an `env` block, an environment variable, the resolution order). `formio-angular/BOOTSTRAP.md`'s `FORMIO_ANGULAR_VERSION` / `FORMIO_JS_VERSION` and `formio-react/BOOTSTRAP.md`'s `FORMIO_REACT_VERSION` capture labels are outside that rule's scope — they name resolved npm versions, not URLs, and no environment variable of either name exists — so if the rule is ever meant to reach them, widen the validator rather than relying on the prose. A substitution slot in an endpoint heading or code example is `{projectUrl}` / `{baseUrl}` — single braces, so it stays distinct from Postman's `{{baseUrl}}`, which must not appear unresolved in prose. A value handed between phases or skills is named in prose ("the Project URL") or as a field called `projectUrl` / `baseUrl`. Spelling a slot or a handoff value `${FORMIO_PROJECT_URL}` tells an agent to read an environment variable in order to build a URL, which is a different and wrong action. diff --git a/README.md b/README.md index 554fdcf1..15aa9828 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,18 @@ The bundled `@formio/mcp` server exposes these tools. Skills prefer these over r | `action_update` | Update an action. | | `action_delete` | Detach an action from a form. | +### Submissions + +Scoped to the agent's own work: each tool reaches only submissions this server created and signed for the calling working directory, and reports any other submission as not found. Used to seed a Resource a `select` reads and to write test submissions; see [`agent-submissions.md`](plugin/skills/formio-mcp-setup/references/agent-submissions.md). + +| Tool | Purpose | +| --- | --- | +| `submission_create` | Create a signed `reference-data` or `test` submission (dry-run first, then write). | +| `submission_list` | List this directory's signed submissions on a form, by purpose and data filters. | +| `submission_get` | Get one of this directory's signed submissions. | +| `submission_update` | Replace the data of one of this directory's signed submissions. | +| `submission_delete` | Delete one of this directory's signed submissions. | + ### Project | Tool | Purpose | diff --git a/openspec/changes/agent-scoped-submission-tools/.openspec.yaml b/openspec/changes/agent-scoped-submission-tools/.openspec.yaml new file mode 100644 index 00000000..18ab2ad0 --- /dev/null +++ b/openspec/changes/agent-scoped-submission-tools/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-tdd +created: 2026-09-29 diff --git a/openspec/changes/agent-scoped-submission-tools/design.md b/openspec/changes/agent-scoped-submission-tools/design.md new file mode 100644 index 00000000..b2dfae3b --- /dev/null +++ b/openspec/changes/agent-scoped-submission-tools/design.md @@ -0,0 +1,118 @@ +## Context + +The MCP server has 23 tools and none reaches a submission. The skills describe that absence as the build-time boundary (`formio-actions/SKILL.md` "Build time vs runtime", `formio-sdk` Security, `formio-application` Step 1), and the skills.sh scanners rated that boundary as mitigated. What it costs: a form whose `select` reads a Resource shows an empty dropdown until someone adds rows, and nothing in the toolset can check a form's behavior with a real submission. Both are handed to "an administrator in the portal". + +The agent authenticates as the developer's portal account, so a submission's `owner` does not separate the agent's rows from rows that developer typed in by hand. An earlier draft of this change kept a local ledger of created ids. It was dropped in favor of a tag on the submission itself, which the portal can show, which survives across sessions, and which needs no id list kept in sync. + +Findings from the Form.io server source (`formio/src`, `resourcejs`, `formio-server/src/hooks`) that constrain the design: + +- **`metadata` is kept as sent on `POST`** (`submissionHandler.js:92` picks `data`, `owner`, `access`, `metadata`) and **can be written by anyone with create permission, anonymous users included**. A tag is therefore a claim, not a proof. +- **The index filters on `metadata.*`**. `metadata` is a Mixed schema path, and `resourcejs` checks only the key's root (`Resource.js:437`). Operators come from `__` suffixes. Values are strings except `true`/`false`/`null` under `__eq`/`__ne`. +- **Query params pass into the Mongo filter with no sanitizing.** Keys not in the schema are copied raw (`Resource.js:492`), and Express's extended `qs` parser turns `a[$ne]=x` into objects. A tool that forwarded agent-supplied params could have its query widened. +- **`PATCH` is an `update`.** It fires update-method actions and re-validates the whole submission (`submissionHandler.js:25-27`, `SubmissionResource.js:137`, `submissionApplyPatch.js`). +- **`PUT` replaces `metadata` wholesale** (Mixed path, `item.set`). +- **`?dryrun=1` validates and returns the normalized submission without running actions or saving** (`submissionHandler.js:89,150,218,373`). It is available to any caller. +- **Server code also writes some `metadata` keys:** `jwtIssuedAfter`, a webhook action's `metadata[action.title]`, and enterprise revisions' `previousData`/`jsonPatch`. The tag lives under one key, `metadata.agent`, so none of these touch it. + +## Goals / Non-Goals + +**Goals:** + +- The agent can create reference-data and test submissions and read, update, and delete its own, from the same working directory, across sessions. +- No submission the agent did not create reaches a tool result. That includes a forged tag, a copied tag, and a query the agent tries to widen, and it is enforced in server code. +- The skills can describe the boundary as it is, "returns only submissions this server created and signed", and that sentence is true. +- Writes have no side effect beyond the one real write the user approved. + +**Non-Goals:** + +- Reading end-user submissions for any reason: support, debugging, reporting, or migration. That remains application code or portal work. +- Sharing agent rows between machines or teammates. The key is per directory on one machine. +- Bulk import. One row per `submission_create` call keeps every write previewable. +- Hiding agent rows from the portal. They are ordinary submissions, visibly tagged. + +## Decisions + +### D1. Tag under `metadata.agent`, signed, not a bare label + +`metadata.agent = { source, session, purpose, sig }`. `sig = HMAC-SHA256(key, canonical({ projectUrl, formId, owner, session, purpose, data }))`. + +- **Why signed:** anyone who submits can write `metadata`, and the rows a dropdown reads are readable by end users, so both a constant `source: agent` and a secret session label leak and can be copied. +- **Why the whole `data` is covered:** a copied tag verifies only on a record whose data is byte-identical to the agent's own, so outsider-authored content can never verify. +- **Why `owner` is covered:** a non-admin cannot set `owner` to the developer's account (to confirm in task 1.2), so even an exact replica must come from someone who already administers the project. +- **Alternative rejected: sign `_id` after create.** Binding to the server-assigned `_id` needs a second write, and a `PATCH` or `PUT` fires update-method actions: a second email or webhook the user did not approve. +- **Alternative rejected: an id ledger.** It is invisible in the portal, lost on another machine, and has to stay in sync with deletions made in the portal. + +- **Why only four fields.** `sig` is the proof, and `source` lets both the index query and a person in the portal pick out agent rows. `session` and `purpose` are not proof — the per-directory key already isolates directories, and the signature already covers both — but each does a job the signature cannot, because Mongo cannot verify an HMAC and can only filter on plain values. `session` confines the index query to this directory's rows, so other directories' agent rows never fill a page and push this directory's own rows past the `limit`. `purpose` is what `submission_list` with `purpose: "test"` filters on, so a later session can find and delete its test rows. An earlier draft also carried a random `nonce`. It was dropped: with the full `data` and `owner` signed, it added no protection, and two identical seed rows sharing a signature is harmless. + +### D2. Dry-run first, then sign what the server will store + +The signature has to cover the stored `data`, but the server fills defaults, computes calculated values, and strips unknown keys. `?dryrun=1` returns exactly that normalized submission without running actions or saving. The tool signs the dry-run `data`, sends it as the real body, and verifies the real response. + +- **Cost:** one extra request per write. +- **Failure mode:** a server-side value that changes between two evaluations (a calculated "now") makes the stored record fail verification. The tool fails closed: it reports the differing field names and the `_id`, returns none of their values, and tells the user to remove the row in the portal. `submission_delete` makes no exception for it, since an exception keyed on anything weaker than the signature is one a copied tag could meet. +- **Alternative rejected: signing only the agent-supplied keys.** Stored values of unsigned keys could then be outsider-written, and returning them would break the boundary. + +### D3. One key per working directory; the session label is derived from it + +The key store lives at `~/.formio/mcp-submission-keys.json` as `{ [normalizedCwd]: base64Key }`, mode `0600`, using the same read-modify-write helpers as `token-cache.ts`. The cwd is normalized the way `project-map.ts` normalizes it. `session = HMAC(key, "agent-session")` truncated to 32 hex characters. + +- **Isolation is cryptographic:** another directory's agent cannot verify these rows even with the label. +- **The label is not a secret.** It exists so the index query is narrow. It is derived rather than being a path hash, so it does not reveal the directory name. +- **Alternative rejected: one key per server process.** The user chose per-directory scope so test rows can be cleaned up in a later session. + +### D4. The server builds every query; agent input is structured, never forwarded + +`submission_list` takes `filters` as `Record` whose keys match `^data\.[A-Za-z0-9_.]+(__(eq|ne|gt|gte|lt|lte|in|nin|exists|regex))?$`. Values must be strings with no `[`, `]`, or `$`. The tool assembles `URLSearchParams` itself, appends the forced `metadata.agent.source` / `metadata.agent.session` (and `metadata.agent.purpose` when given), and sets `select=_id,form,owner,data,metadata,created,modified`. + +- Anything outside that grammar is refused before any request. +- This closes the raw-param widening the source review found, whatever the server's parser does. + +### D5. Verify per record; a failed or missing record looks the same + +`verifyRecord(record, ctx)` is a pure function. It returns the projected record, or `null` when any of these fail: the tag is missing, `source` or `session` differ, `form` differs, or the HMAC check fails. The check uses `timingSafeEqual`. + +- List results drop the nulls and report a `dropped` count. +- A get by id returns the same not-found result whether Form.io said 404 or verification failed. The agent learns nothing about a record it does not own, not even that it exists. + +### D6. Update and delete verify first; update keeps identity and re-signs + +- **Update:** `GET` and verify, dry-run the `PUT` body, sign keeping the original `session` and `purpose`, then `PUT` the full tag, because a `PUT` replaces `metadata` wholesale. +- **Delete:** `GET` and verify, then `DELETE`. +- **Effects are named:** each write result lists the form's actions matching its method, so the user sees what the write triggered. The skill guideline requires naming them before the write as well. + +### D7. Pure core, I/O at the edges + +- `agent-scope.ts` holds the pure functions: `canonicalize`, `signTag`, `verifyRecord`, `buildListQuery`, `projectRecord`. +- `submission-keys.ts` holds the key store's I/O. +- Each `submission_*.ts` tool wires them to `formioFetch`. + +This follows the repo's functional-core convention and keeps the security logic testable with no network. + +### D8. Guideline lives in `formio-mcp-setup/references/agent-submissions.md` + +- It sits beside `project-urls.md`, the library's other server-owned guidance, which every skill links to rather than restating. +- The server's `instructions` carry the short form for clients without the skills. +- The ban on reaching other submissions by HTTP, scripts, or pasted content stays in each skill's existing "Never work around missing tools" text. + +## Risks / Trade-offs + +- **[A project admin copies an agent row exactly, including `owner`]** → They see and control the project already. The copy has byte-identical data, so no outsider content verifies. Accepted. +- **[API-key auth leaves `owner` unset]** → The signature covers `owner: null`, so copying needs create access plus identical data. The copy still carries no outsider content. Accepted. +- **[A calculated value that isn't deterministic makes seeds unreadable]** → The tool detects this at create time, says so, and names the `_id` so the user can remove the row in the portal. The guideline tells the agent to prefer forms without server-side "now" values for test rows. +- **[`dryrun` behaves differently on some deployment or version]** → Task 1.1 confirms it against a live deployment. If it is missing, create fails closed rather than falling back to an unsigned write. +- **[The real write fires create actions]** → That is inherent to submitting. The guideline's `action_list` check, the named effects in the approval preview, and the action names in the tool result cover it. +- **[`__regex` filters allow expensive patterns]** → Patterns are limited to 200 characters. The query is already confined to the agent's own rows by the forced filters. +- **[The scanners flag the new tools anyway]** → The skills describe enforced behavior, and a test asserts that a forged record never reaches a result. Re-scan outcomes are recorded after release, not assumed. +- **[Key file loss]** → Rows the agent made become unreadable to it (fail closed) but stay visible and deletable in the portal. The guideline notes this. + +## Migration Plan + +- The change is additive: no existing tool changes behavior. +- The skill prose revisions ship with the tools in one release, so no published skill describes tools that do not exist, or says tools are missing when they exist. +- Rollback: revert the release. Tagged rows remain ordinary submissions and can be deleted in the portal by filtering on `metadata.agent.source=agent`. + +## Open Questions + +- **Q1 (task 1.1) — resolved 2026-09-29.** Against Form.io Enterprise 9.9.2 (`https://form.test/sandbox`), `live-submission-behavior.test.ts` confirmed that a `?dryrun=1` `POST` returns the default-filled and server-calculated `data` with no record saved and no action run; the real write stores exactly the dry-run `data`, with the same `owner`; and a `?dryrun=1` `PUT` returns the recomputed data while leaving the stored record and the update actions untouched. D2 stands. The hosted cloud was not exercised; it runs the same server code, and the create-time check fails closed if a deployment ever differs. +- **Q2 (task 1.2) — resolved 2026-09-29.** On the same deployment, `metadata.agent` is stored verbatim, and a non-admin `POST` that sets `owner` to the admin's id is stored with the caller as `owner`. Covering `owner` in the signature therefore adds protection: an exact replica of an agent row has to come from someone who already administers the project. +- **Q3:** Should `submission_list` allow `sort` on `metadata.agent.purpose`? The current answer is no: filter by `purpose` instead. diff --git a/openspec/changes/agent-scoped-submission-tools/proposal.md b/openspec/changes/agent-scoped-submission-tools/proposal.md new file mode 100644 index 00000000..3d3a48e4 --- /dev/null +++ b/openspec/changes/agent-scoped-submission-tools/proposal.md @@ -0,0 +1,39 @@ +## Why + +The skills.sh remediation (PRs for `mitigate-skills-sh-security-findings` and #80) closed the scanners' third-party-content findings by stating that the agent never touches submission data, and the toolset backs that with the absence of any submission tool. That over-corrects: building a form whose `select` reads a Resource (`dataSrc: resource`) needs rows in that Resource before the dropdown shows anything, and verifying a form's validation, conditionals, calculated values, and actions needs a test submission. Today both are pushed to "an administrator signs in to the portal", which stops an otherwise complete build. The agent needs to write and read back its own submissions, while every submission an end user or another agent wrote stays out of reach — enforced by the MCP server, not by prose asking the agent to behave, because an enforced boundary is what the scanners have rated as mitigated and a prose-only one is what they flag. + +## What Changes + +- **New MCP tools `submission_create`, `submission_get`, `submission_list`, `submission_update`, `submission_delete`.** Every one is scoped by the server to submissions this server created for the calling working directory; there is no parameter that widens the scope. +- **Every agent-created submission carries a server-written tag** in `metadata`: `source: 'agent'`, an `agentSession` label derived from the working directory, and an `agentSig` HMAC that binds the tag to the submission's project, form, owner, and full stored `data`, using a signing key held only on the machine running the server and generated per working directory. `metadata` is writable by anyone who submits, so the label alone is a claim; the signature is the proof. +- **Reads return only verified records.** `submission_list` and `submission_get` force the tag filter onto every query, discard any `metadata.*` filter the agent supplies, and re-verify the signature of every returned record, dropping any that fail before the result reaches the agent. A record that fails verification is reported as not found, with none of its content. +- **Writes act only on verified records.** `submission_update` and `submission_delete` verify the target first and refuse otherwise; `submission_update` refuses any change to the tag fields. +- **`submission_create` requires a `purpose`** — `reference-data` or `test` — recorded in the tag so test rows can be found and removed as a set. +- **One canonical guideline** at `plugin/skills/formio-mcp-setup/references/agent-submissions.md` states when the agent may create, update, read, and delete submissions: only its own; only invented values; `action_list` checked before every create, because email, webhook, login, and role actions have real-world effects; a preview and approval before every write; an explicit warning for test data in a live project; and cleanup of test rows. Skills that seed or test link to it rather than restate it. +- **The over-corrected prose is revised** to the enforced boundary — "the submission tools return only submissions this server created and signed": `formio-actions/SKILL.md`'s "no submission tool" paragraph, the last rule of `formio-sdk`'s Security section, `formio-application` Step 1's "reads no submission data", and the planner references that assign reference-data seeding to an administrator in the portal. The ban on reading submissions by any other route (HTTP, scripts, SDK) stays. +- **Skills gain the seeding and testing step where it belongs:** `formio-form-builder` (seed a Resource a select reads; submit a test row), `formio-application` (optional reference-data seeding after import), and `formio-actions` (exercise an action with a test submission after reviewing its effects). + +## Capabilities + +### New Capabilities + +- `submission-crud`: the five `submission_*` MCP tools — registration, inputs, the Form.io endpoints each calls, annotations, output shapes, and error behavior. +- `agent-submission-scope`: the server-enforced boundary — the metadata tag, the per-working-directory session label, the signing key and its storage, forced query filters, signature verification on every read and before every write, and immutability of the tag fields. + +### Modified Capabilities + +- `formio-mcp-setup-skill`: gains the canonical `references/agent-submissions.md` guideline. +- `formio-actions-skill`: replaces "the toolset cannot reach a submission" with the scoped-tools boundary and the test-an-action flow. +- `formio-sdk-skill`: the Security section's build-time rule names the scoped submission tools instead of stating that no tool returns submission data. +- `formio-application-skill`: Step 1's first-party-inputs statement names the agent's own submissions, and an optional reference-data seeding step follows import. +- `formio-resource-planner-skill`: reference-data seeding is no longer an administrator-only portal task; the planner notes which Resources need seed rows for their selects. +- `formio-form-builder-skill`: gains seed-and-test steps that follow the guideline. + +## Impact + +- **Server code:** new `packages/mcp-server/src/tools/submission_*.ts`, a new scope module (tagging, signing, verification, query shaping), a signing-key store under `~/.formio/` following `token-cache.ts`'s pattern, registration in `tools/index.ts`, and the `.mcpb` manifest tool list. Tests in `packages/mcp-server/src/__tests__/`, including that a record failing verification never reaches the tool result. +- **Skill docs:** the files listed above, plus `formio-api/references/runtime-submissions.md`'s MCP Tool Preference section, which names the scoped tools for the build-time cases and keeps runtime CRUD as application code. +- **Skill tests:** `formio-sdk/security-section.test.ts` and `skill-descriptions/application-orchestration.test.ts` assertions written for #80 ("none returns submission data", "reads no submission data") change to the scoped wording; `build-time-vs-runtime.test.ts` keeps its ban on build-time HTTP and gains an allowance for the named tools. +- **Server instructions** (the MCP server's `instructions` text) state the scope rule once, so a client without the skills still gets it. +- **Scanner exposure:** the stated boundary is a true description of enforced behavior. The scanners are model-based, so re-scan outcomes are recorded after release rather than assumed. +- **No breaking change** to existing tools. No new runtime dependency; HMAC uses `node:crypto`. diff --git a/openspec/changes/agent-scoped-submission-tools/specs/agent-submission-scope/spec.md b/openspec/changes/agent-scoped-submission-tools/specs/agent-submission-scope/spec.md new file mode 100644 index 00000000..c1c816ca --- /dev/null +++ b/openspec/changes/agent-scoped-submission-tools/specs/agent-submission-scope/spec.md @@ -0,0 +1,100 @@ +## ADDED Requirements + +### Requirement: Each working directory has its own signing key and session label + +The server SHALL keep one signing key per resolved working directory in a local key store under `~/.formio/`, written with file mode `0600` and following the read-modify-write pattern of the token cache. The key SHALL be 32 random bytes from `node:crypto`, created on the first submission tool call from that directory. The session label SHALL be derived from the key (`HMAC(key, "agent-session")`, hex, truncated to 32 characters) so it names the directory without revealing the key or the path. Neither the key nor the key-store path SHALL appear in any tool result, error message, or log line. + +#### Scenario: First call creates the key + +- **WHEN** `submission_create` is called with a `cwd` that has no key in the store +- **THEN** the server generates a 32-byte key, writes it to the store with mode `0600`, and derives the session label from it + +#### Scenario: Two directories are isolated + +- **WHEN** directory A creates a submission and directory B calls `submission_get` for its `_id` against the same project +- **THEN** B's verification fails because B's key produces a different signature, and B receives the not-found result + +#### Scenario: The key never leaves the server + +- **WHEN** any submission tool returns a result or an error +- **THEN** the result contains neither the key bytes nor the key-store path + +### Requirement: Agent-created submissions carry a server-written, signed tag + +Every submission written by `submission_create` or `submission_update` SHALL carry `metadata.agent` containing `source: "agent"`, `session` (the session label), `purpose` (`"reference-data"` or `"test"`), and `sig`, and no other key. `sig` SHALL be `HMAC-SHA256(key, canonical({ projectUrl, formId, owner, session, purpose, data }))`, where `canonical` is key-sorted JSON and `data` is the submission data as the server will store it. The signature is deterministic: the same inputs produce the same tag, and no per-tag randomness is added, because binding the full `data` and `owner` already confines a copied tag to a byte-identical copy of the agent's own row. The agent SHALL NOT be able to supply any part of `metadata`; any `metadata` in the tool input is rejected. + +#### Scenario: The tag is written by the server + +- **WHEN** `submission_create` is called with `data` and `purpose: "test"` +- **THEN** the request body's `metadata.agent` holds exactly `source`, `session`, `purpose`, and `sig`, all computed by the server + +#### Scenario: Agent-supplied metadata is refused + +- **WHEN** `submission_create` or `submission_update` is called with a `metadata` field in its input +- **THEN** the tool returns `isError: true` without making any request + +### Requirement: The signature covers the data as stored, obtained without running actions + +Before the real write, the server SHALL submit the same body with `?dryrun=1`, which validates and normalizes the data (defaults, calculated values, stripped unknown keys) without executing actions or saving. It SHALL sign the `data` the dry run returns, then send the real write with that data and the tag. After the real write, it SHALL verify the response. When the stored data does not verify — a server-side value that differs between two evaluations — the tool SHALL report which fields differed, report the `_id`, and state that the record is not reachable by the submission tools and is removed in the Form.io portal. No submission tool makes an exception for such a record: an exception keyed on anything but a valid signature would let a copied tag direct a write at a record the agent did not create. + +#### Scenario: Normalized data is what gets signed + +- **WHEN** a form fills a default value for a field the agent omitted +- **THEN** the dry run returns the default, the signature covers it, and a later `submission_get` verifies the record + +#### Scenario: The dry run fires no action + +- **WHEN** `submission_create` runs against a form with an email action on `create` +- **THEN** the dry-run request carries `dryrun=1`, and only the real write can trigger the action + +#### Scenario: Non-deterministic stored data fails closed + +- **WHEN** the stored data differs from the dry-run data +- **THEN** the tool reports the differing field names and the `_id`, and returns none of the differing values + +### Requirement: Every read is filtered by the server and verified per record + +`submission_list` SHALL build its query string itself from structured inputs. It SHALL always include `metadata.agent.source=agent` and `metadata.agent.session=