Implement the designated-target write guard with an allow/refuse matrix #196

Closed
opened 2026-09-14 23:10:48 +00:00 by david · 1 comment
Owner

Summary

Implement the designated-target write guard: a session-scoped allow-list plus an authorisation tool, so the extension writes only to files it created in a nominated project, or to a file the user explicitly named. Every write path checks it before sending anything.

Background

Depends on: #195

Frictionless multi-screen authoring and "the agent scribbled over my real designs" are in tension. The design resolves it structurally rather than by per-write confirmation: bulk screen creation inside a nominated scope stays smooth, while a write to anything outside that scope is refused and reported with an actionable message.

The allow-list has exactly two provenances:

  1. Created by the extension — a file this session created (currently via library import, and via any future create-file path). It is registered at creation time together with the project it was created in.
  2. Explicitly named by the user — the user names a specific file, and the agent records that designation with the authorisation tool. The tool cannot verify who spoke, so its description and the skill's guidance must be unambiguous that the file must be user-named; the guard's job is to make accidental writes impossible, not to police the agent.

The guard is a pure decision function plus a small session store, so the allow/refuse matrix is unit-testable without HTTP, and every write path calls the same one function.

Documentation Required

A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting.

docs/reference/penpot-api/

  • <PENPOT_URL>/api/main/doc/openapi.json — confirm get-file's project/team fields (needed to map a file id to its project) and the permission-related fields returned for a team.
  • https://help.penpot.app/user-guide/teams/ — team roles and permissions, so refusal messages can say precisely which permission is missing when the guard fails for permissions rather than designation.
  • https://help.penpot.app/technical-guide/integration/ — auth context for the read used to resolve a file's project.

docs/reference/pi-coding-agent/

  • https://pi.dev/docs/latest/extensions — tool registration, tool description authoring (this tool's description carries the safety contract), and how to return a refusal as a normal tool result rather than an exception.

docs/reference/typebox/

  • https://github.com/sinclairzx81/typebox — expressing "exactly one of file_id or project_id" in a parameter schema; use a union/optional pair with an explicit runtime check if the schema cannot express it.

Implementation Details

extensions/penpot/src/guard.ts — pure, unit-tested

State (session-scoped, module-level):

{
  allowedFileIds: Set<string>,          // explicitly user-named
  nominatedProjectIds: Set<string>,     // projects the user nominated
  createdFiles: Map<string, string>,    // fileId -> projectId, registered on creation
}

Functions:

  • authorizeFile(fileId, note?) / authorizeProject(projectId, note?) — record a designation.
  • registerCreatedFile(fileId, projectId) — called by any tool that creates a file.
  • resolveFileProject(fileId) — look up a file's project (via get-file, then team/project fields), used only when a file is not in createdFiles and a project nomination might cover it.
  • isWriteAllowed(fileId, deps){ allowed: true, provenance: "created" | "named" | "nominated-project" } | { allowed: false, reason, hint }.
    • allowed when the file is in createdFiles, or in allowedFileIds, or resolves to a project in nominatedProjectIds.
    • reason distinguishes: file unknown to the allow-list; project not nominated; resolution failed (could not determine the file's project) — the last must fail closed, never default to allowed.
    • hint is the actionable next step: name the file with penpot_authorize_target, or nominate its project.
  • clearAuthorizations() — used by tests and by session reset.

extensions/penpot/src/tools/target.tspenpot_authorize_target

Parameters: exactly one of file_id (string) or project_id (string), plus note (string, optional). Validating "exactly one" is a runtime check with a clear error. The tool description must state plainly: only call this for a file or project the user has explicitly named; it is the write allow-list.

Returns the current allow-list contents (so the agent can see the effective scope) in text and in details.

Wiring

  • penpot_commit (and every later write tool) calls isWriteAllowed before building the request. A refusal returns a normal tool result explaining what was refused, why, and the exact authorisation call that would permit it — nothing is written and the changeset stays staged.
  • Wire the guard into penpot_commit by replacing the permissive placeholder left by the previous step.
  • Register penpot_authorize_target in src/index.ts.

Acceptance Criteria

  • A file in createdFiles is allowed, and the provenance is reported as created.
  • A file explicitly authorised is allowed, provenance named.
  • A file whose project is in nominatedProjectIds is allowed, provenance nominated-project.
  • A file outside every allow-list is refused with a reason and a hint naming penpot_authorize_target.
  • When the file's project cannot be resolved, the guard fails closed (refused) rather than allowing the write.
  • penpot_commit against a non-designated file is refused, reports the refusal, and leaves the staged changeset intact.
  • penpot_authorize_target requires exactly one of file_id/project_id and errors clearly when given neither or both.
  • The tool description states the user-naming requirement for the allow-list.
  • Unit tests cover the full allow/refuse matrix, including fail-closed resolution, with no network access.

Test Plan

node --test extensions/penpot/src/guard.test.ts extensions/penpot/src/tools/target.test.ts

Live validation (requires PENPOT_URL/PENPOT_TOKEN, scratch project):

  1. Stage a colour against a scratch file that has not been authorised; call penpot_commit → expect a refusal naming the authorisation tool, and confirm by re-reading that the file's revn did not change.
  2. Call penpot_authorize_target with that file_id, then commit again → expect success and the assets present.
  3. Call penpot_authorize_target with the scratch project's project_id, then commit to a second file in that project → expect success; commit to a file in a different project → expect refusal.
  4. Call penpot_authorize_target with both parameters and with neither → expect clear errors.
## Summary Implement the designated-target write guard: a session-scoped allow-list plus an authorisation tool, so the extension writes **only** to files it created in a nominated project, or to a file the user explicitly named. Every write path checks it before sending anything. ## Background **Depends on:** #195 Frictionless multi-screen authoring and "the agent scribbled over my real designs" are in tension. The design resolves it structurally rather than by per-write confirmation: bulk screen creation inside a nominated scope stays smooth, while a write to anything outside that scope is refused and reported with an actionable message. The allow-list has exactly two *provenances*: 1. **Created by the extension** — a file this session created (currently via library import, and via any future create-file path). It is registered at creation time together with the project it was created in. 2. **Explicitly named by the user** — the user names a specific file, and the agent records that designation with the authorisation tool. The tool cannot verify who spoke, so its description and the skill's guidance must be unambiguous that the file must be user-named; the guard's job is to make accidental writes impossible, not to police the agent. The guard is a **pure decision function** plus a small session store, so the allow/refuse matrix is unit-testable without HTTP, and every write path calls the same one function. ## Documentation Required A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting. **`docs/reference/penpot-api/`** - `<PENPOT_URL>/api/main/doc/openapi.json` — confirm `get-file`'s project/team fields (needed to map a file id to its project) and the permission-related fields returned for a team. - https://help.penpot.app/user-guide/teams/ — team roles and permissions, so refusal messages can say precisely which permission is missing when the guard fails for permissions rather than designation. - https://help.penpot.app/technical-guide/integration/ — auth context for the read used to resolve a file's project. **`docs/reference/pi-coding-agent/`** - https://pi.dev/docs/latest/extensions — tool registration, tool `description` authoring (this tool's description carries the safety contract), and how to return a refusal as a normal tool result rather than an exception. **`docs/reference/typebox/`** - https://github.com/sinclairzx81/typebox — expressing "exactly one of `file_id` or `project_id`" in a parameter schema; use a union/optional pair with an explicit runtime check if the schema cannot express it. ## Implementation Details ### `extensions/penpot/src/guard.ts` — pure, unit-tested State (session-scoped, module-level): ```ts { allowedFileIds: Set<string>, // explicitly user-named nominatedProjectIds: Set<string>, // projects the user nominated createdFiles: Map<string, string>, // fileId -> projectId, registered on creation } ``` Functions: - `authorizeFile(fileId, note?)` / `authorizeProject(projectId, note?)` — record a designation. - `registerCreatedFile(fileId, projectId)` — called by any tool that creates a file. - `resolveFileProject(fileId)` — look up a file's project (via `get-file`, then team/project fields), used only when a file is not in `createdFiles` and a project nomination might cover it. - `isWriteAllowed(fileId, deps)` → `{ allowed: true, provenance: "created" | "named" | "nominated-project" } | { allowed: false, reason, hint }`. - `allowed` when the file is in `createdFiles`, or in `allowedFileIds`, or resolves to a project in `nominatedProjectIds`. - `reason` distinguishes: file unknown to the allow-list; project not nominated; resolution failed (could not determine the file's project) — the last must **fail closed**, never default to allowed. - `hint` is the actionable next step: name the file with `penpot_authorize_target`, or nominate its project. - `clearAuthorizations()` — used by tests and by session reset. ### `extensions/penpot/src/tools/target.ts` — `penpot_authorize_target` Parameters: exactly one of `file_id` (string) or `project_id` (string), plus `note` (string, optional). Validating "exactly one" is a runtime check with a clear error. The tool description must state plainly: *only call this for a file or project the user has explicitly named; it is the write allow-list.* Returns the current allow-list contents (so the agent can see the effective scope) in text and in `details`. ### Wiring - `penpot_commit` (and every later write tool) calls `isWriteAllowed` before building the request. A refusal returns a normal tool result explaining what was refused, why, and the exact authorisation call that would permit it — nothing is written and the changeset stays staged. - Wire the guard into `penpot_commit` by replacing the permissive placeholder left by the previous step. - Register `penpot_authorize_target` in `src/index.ts`. ## Acceptance Criteria - [ ] A file in `createdFiles` is allowed, and the provenance is reported as `created`. - [ ] A file explicitly authorised is allowed, provenance `named`. - [ ] A file whose project is in `nominatedProjectIds` is allowed, provenance `nominated-project`. - [ ] A file outside every allow-list is refused with a reason and a hint naming `penpot_authorize_target`. - [ ] When the file's project cannot be resolved, the guard **fails closed** (refused) rather than allowing the write. - [ ] `penpot_commit` against a non-designated file is refused, reports the refusal, and leaves the staged changeset intact. - [ ] `penpot_authorize_target` requires exactly one of `file_id`/`project_id` and errors clearly when given neither or both. - [ ] The tool description states the user-naming requirement for the allow-list. - [ ] Unit tests cover the full allow/refuse matrix, including fail-closed resolution, with no network access. ## Test Plan ```bash node --test extensions/penpot/src/guard.test.ts extensions/penpot/src/tools/target.test.ts ``` Live validation (requires `PENPOT_URL`/`PENPOT_TOKEN`, scratch project): 1. Stage a colour against a scratch file that has **not** been authorised; call `penpot_commit` → expect a refusal naming the authorisation tool, and confirm by re-reading that the file's `revn` did not change. 2. Call `penpot_authorize_target` with that `file_id`, then commit again → expect success and the assets present. 3. Call `penpot_authorize_target` with the scratch project's `project_id`, then commit to a second file in that project → expect success; commit to a file in a different project → expect refusal. 4. Call `penpot_authorize_target` with both parameters and with neither → expect clear errors.
david closed this issue 2026-09-15 02:43:26 +00:00
Author
Owner

pi-loop opened and merged a pull request for this issue: #229

pi-loop opened and merged a pull request for this issue: https://git.excelera.net/david/pi-extensions-and-skills/pulls/229
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
david/pi-extensions-and-skills#196
No description provided.