Screenshot guardrails: name every screenshot not COMPLETE on 409 ASSET_IN_POST_PROCESSING (submit, validate; stuck after 5 min in UPLOAD_COMPLETE), apple screenshots --repair-stuck replaces only those in place, part uploads without Expect: 100-continue (failed part deletes the reservation, one retry, never committed), storebound api --confirm <operationId> for agents' plain writes (never owner-only)

This commit is contained in:
CRTkafa 2026-10-21 12:40:26 +07:00
parent 164182012f
commit 00756e6908
19 changed files with 475 additions and 45 deletions

View file

@ -2,7 +2,8 @@
//
// read GET (and a few POSTs that only compute or query, listed below): runs right away.
// write any other POST / PATCH / PUT / DELETE: prints the exact request; sent only with --yes in an interactive
// terminal, never under MCP (`storebound mcp`) or the desktop app (`storebound serve`).
// terminal, or with --yes --confirm <operationId> (equal to the resolved id) from an agent's shell without a
// terminal; never under MCP (`storebound mcp`) or the desktop app (`storebound serve`).
// owner writes that submit, release, delete, cancel, change prices, invite or remove testers, send review replies,
// start or stop experiments, refund or rotate keys (AGENTS.md rule 1): a write that also needs the operation id
// typed at the prompt. Never reachable through MCP or serve.
@ -78,6 +79,10 @@ export interface GateInput {
agent: boolean;
/** `storebound api get` (the MCP api_get tool): plain GETs only */
getOnly: boolean;
/** --confirm <operationId>: the non-interactive confirmation for an agent in a shell (plain writes only) */
confirm?: string;
/** the operation id the call resolved to (what --confirm must equal) */
opId?: string;
}
export type Decision =
@ -87,9 +92,13 @@ export type Decision =
| { action: 'refuse'; why: string };
/** --yes without a terminal: refused loudly (exit 1, stderr), never a silent dry run (pitfall 77). */
export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, or use the dedicated storebound command.';
export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm <operationId> (agents, plain writes only), or use the dedicated storebound command.';
/** What to do with one call. Pure. */
/** --confirm never stands in for the owner's typed id on an owner-only operation. */
export const OWNER_NO_CONFIRM = 'refused: owner-only operation: --confirm does not apply; the owner types the operation id in an interactive terminal. Nothing was sent.';
/** What to do with one call. Pure. A plain write without a terminal is sent only with --yes and --confirm <operationId>
* equal to the resolved operation id (the agent path); owner-only operations always need the owner's terminal. */
export function decide(g: GateInput): Decision {
if (g.getOnly && g.method.toUpperCase() !== 'GET') return { action: 'refuse', why: 'api get runs GET operations only' };
if (g.kind === 'read') return g.dry ? { action: 'dry', why: '--dry' } : { action: 'send' };
@ -97,6 +106,12 @@ export function decide(g: GateInput): Decision {
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
if (g.confirm !== undefined) {
// an agent's typed confirmation: only for plain writes, only when it names this exact operation
if (g.kind === 'owner') return { action: 'refuse', why: OWNER_NO_CONFIRM };
if (!g.opId || g.confirm !== g.opId) return { action: 'refuse', why: `refused: --confirm ${g.confirm} does not equal the operation id ${g.opId ?? '?'}. Nothing was sent.` };
return { action: 'send' };
}
if (!g.tty) return { action: 'refuse', why: NOT_A_TTY };
return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' };
}