diff --git a/plugins/brains/.claude-plugin/plugin.json b/plugins/brains/.claude-plugin/plugin.json index 70b13ff..3f0dca1 100644 --- a/plugins/brains/.claude-plugin/plugin.json +++ b/plugins/brains/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "brains", "description": "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, with reflexive recall, hook-driven turn-by-turn capture and inbox delivery, and boards/automations/workflows on top.", - "version": "2.9.0", + "version": "2.9.1", "author": { "name": "brains (ssvlabs)" }, diff --git a/plugins/brains/.codex-plugin/plugin.json b/plugins/brains/.codex-plugin/plugin.json index 52ab452..51b3452 100644 --- a/plugins/brains/.codex-plugin/plugin.json +++ b/plugins/brains/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brains", - "version": "2.9.0", + "version": "2.9.1", "description": "Your personal memory layer for Codex: query Gmail, Calendar, Drive, and prior conversations, then build boards, automations, and workflows.", "author": { "name": "brains (ssvlabs)", diff --git a/plugins/brains/generated/capability-catalog.json b/plugins/brains/generated/capability-catalog.json index 5d698d7..c8a6de4 100644 --- a/plugins/brains/generated/capability-catalog.json +++ b/plugins/brains/generated/capability-catalog.json @@ -7,15 +7,15 @@ "artifacts": [ { "capability_id": "integration-actions", - "catalog_sha256": "29f58ddc5dab294814934fe7af0d808f4b7dc441e3c3d29bb0e67fa705d7add4", + "catalog_sha256": "f93159bc3af5e891ae4b31c82178535242783c5419a07cd76b5aa3598ff299a7", "artifact_path": "plugins/brains/skills/brains-write/SKILL.md", - "artifact_sha256": "21f7cd40bedb7602efbbd43f718c1e5e78e41a19de3d96f3dd599b95fb17732d" + "artifact_sha256": "c289803f96c35dcfd5347e46177c009850dfdc4b42579a770ea6516d0c60e2af" }, { "capability_id": "brains-features", - "catalog_sha256": "a1677c30594d44e516a3aaa8e6524829360e44d90f8304d2ab8291b7e5ed6a3a", + "catalog_sha256": "9976952657698137f1ab4cb0d9eb547555e0c966659632cc4d14e5797d66830b", "artifact_path": "plugins/brains/skills/brains-build/SKILL.md", - "artifact_sha256": "f9196254dd49af3cd8bb1d4d93b1ba36ff10d8efc0bb039fb464901e143e143f" + "artifact_sha256": "95261ee804b3b3cae4f42231264aafa64c96753dce71ab851fad6f97eed37c48" }, { "capability_id": "procedure:board", @@ -27,7 +27,7 @@ "capability_id": "procedure:automation", "catalog_sha256": "ea1e883788e138e39b80627e5a14d6ac35dc7f658b4628e7e32084bb6620fe8b", "artifact_path": "plugins/brains/skills/brains-automation/SKILL.md", - "artifact_sha256": "78c63d6a09ffdf55f80f3b686b1cd0d1000585bc1d5d148323ea03ebda018bc2" + "artifact_sha256": "c91cc266c317a5d2ae479594ecad0e5048970d7431139777ea61f401bb4e9cc6" }, { "capability_id": "procedure:workflow", @@ -36,5 +36,5 @@ "artifact_sha256": "b22814fd630ed78964d54440ec327e3aebcde8c812aaa9985fff9c4d345d3f09" } ], - "source_commit": "e332512644b2f06a6b7068dcc9196eab2d31c05a" + "source_commit": "6fb7b2356592b4e5313176cc55d6d06cb3130e31" } diff --git a/plugins/brains/skills/brains-automation/SKILL.md b/plugins/brains/skills/brains-automation/SKILL.md index 4cb3388..0f22f5f 100644 --- a/plugins/brains/skills/brains-automation/SKILL.md +++ b/plugins/brains/skills/brains-automation/SKILL.md @@ -176,7 +176,7 @@ After one hour a draft moves to the `/inbox` Expired tab but remains approvable. There is no edit-at-confirm step: call `discard_action` before re-drafting corrected input. -Automation tokens cannot self-confirm. A confirmation-required action therefore waits for the human at `/inbox`, but a `requires_confirmation:false` action still executes inline for any caller granted `act_on_integration`. Get explicit agreement before saving an automation that performs an unattended external write. New automations default to `write_policy:'always_draft'`; only `update_automation` can switch to `auto_confirm_safe`, and that policy does not override an action whose recipe already auto-executes. +Automation tokens cannot self-confirm: `confirm_action` is refused for them under either policy, so a confirmation-required action is released by the `write_policy` rule above, not by the sandbox. Get explicit agreement before saving an automation that performs an unattended external write. New automations default to `write_policy:'always_draft'`; switching to `auto_confirm_safe` is an explicit configuration change, and that policy does not override an action whose recipe already auto-executes. **For "notify me" / "ping me" / "DM me" asks**, default to `telegram_push` when the user has Telegram connected — it's the user's primary notification surface. Reach for `act_on_integration` only when the notification belongs in a specific channel (email thread, calendar invite, Drive doc). `telegram_push` bypasses the draft/confirm step and sends immediately, so the user must have opted in by connecting the bot. @@ -248,7 +248,7 @@ Derive from Steps 3 + 4. The user doesn't pick this — you do. Mention it inlin | Un-retire a row (`brains.boards.restore`) | `restore_board_row` | | Pull fresh gmail/calendar/drive data | `fetch_from_integration` | | Read a github/monday adapter (`brains.call("adapter_query", …)`) | `adapter_query` | -| Send | `act_on_integration` (drafts for a confirm-required action; an action declaring `requires_confirmation:false` sends inline) | +| Send | `act_on_integration` (drafts or sends inline per requires_confirmation and write_policy; see the contract above) | | Send a Telegram DM (only when user explicitly asked) | `telegram_push` (bypasses draft/confirm — delivers immediately) | | Write a memory page | `create_page` | | LLM call from sandbox | `automation_complete` | @@ -377,8 +377,8 @@ The new agent lands in `active` state — it will start firing on its cron / pag Before handing off, prove the source compiles and runs end-to-end against a real trigger payload by firing one manual dry run. Call **`run_automation_once`** with: - `automation_id` — from Step 8 -- `dry_run: true` — default; lets the user code execute but suppresses outbound writes (same hook the admin Dry-Run button uses) -- `verify_mode: true` — **REQUIRED for self-verification during this flow.** When true the MCP server suppresses all outbound side-effect tools (telegram_push, email / calendar / drive sends via act_on_integration, fetch_from_integration writes, and non-GET http_fetch) and returns suppressed-shape stubs instead. This lets you confirm the source compiles and the logic path executes end-to-end **without spamming the user's phone / inbox / drive** while you're still iterating. The user code still runs; only the deliveries are muted. +- `dry_run: true` — default; suppresses act sends and `telegram_push`. Board and page writes run live under `dry_run` AND `verify_mode` — a smoke test mutates real rows +- `verify_mode: true` — **REQUIRED for self-verification during this flow.** When true the MCP server suppresses all outbound side-effect tools (telegram_push, email / calendar / drive sends via act_on_integration, fetch_from_integration writes, and non-GET http_fetch) and returns suppressed-shape stubs instead. This lets you confirm the source compiles and the logic path executes end-to-end **without spamming the user's phone / inbox / drive** while you're still iterating. The tool enqueues a `trigger_kind='manual'` run and polls `automation_runs` until it terminates (or `wait_seconds` elapses). Default wait is `max_wall_seconds + 90` to cover the runner's ~30s tick + sandbox spawn. diff --git a/plugins/brains/skills/brains-build/SKILL.md b/plugins/brains/skills/brains-build/SKILL.md index 2ca0059..0ee2438 100644 --- a/plugins/brains/skills/brains-build/SKILL.md +++ b/plugins/brains/skills/brains-build/SKILL.md @@ -23,12 +23,13 @@ Don't restate that artifact here — open it. | **Recurring brief / digest** | Install a prebuilt automation that sends a recurring summary instead of scaffolding one. | `list_recipes` → `install_recipe` | | **Workflow** | A goal container: charter, KPIs, deadlines, roster, an owned board and template automations. | `create_workflow_flow` if your client exposes it (follow the playbook it returns), otherwise `create_workflow` | | **Mini-site** | Static sandboxed HTML on a brain — a deck, a one-pager, a shareable link. | `create_mini_site` | -| **Sharing** | Give someone access — share_board, share_folder (cascades), share_mini_site, create_share_circle. | `share_board` | +| **Sharing** | Give someone access — share_board, share_folder (cascades), share_mini_site. | `share_board` | | **Board source link** | Bind a board to an external resource through an adapter (http_json, ics, github, monday). | `create_board_link` | | **Dataset recipe** | Schedule a board link to materialize into a dataset on a cron — set up on the board's sources page. | the board's Sources page (`/boards//sources`) — no agent tool for this | -| **Integrations** | See what's connected and install, upgrade or remove an integration. | the `brains-integrations` skill if your client loads skills, otherwise `list_my_integrations` → `install_integration` | +| **Integrations** | Connectors: what's connected; install, upgrade, remove. CLI/connector `create_integration_flow` authors one. | the `brains-integrations` skill if your client loads skills, otherwise `list_my_integrations` → `install_integration` | | **Recipe publishing** | Publish a board or other supported build to the catalog — publish_recipe names the kinds it accepts. | `publish_recipe` | -| **Telegram bot** | Reach the brain from a phone, without the laptop. | the Connect Telegram page (`/install/telegram`) — no agent tool for this | +| **Recipe install** | Catalog recipe: install_recipe, uninstall_recipe removes/deletes; list_installed_versions reads 3 of 5. | `list_recipes` | +| **Telegram bot** | Reach the brain from Telegram on a phone, without the laptop. | the Connect Telegram page (`/install/telegram`) — no agent tool for this | Reach for a `*_flow` tool for any create/set-up/scaffold ask: it returns the playbook that produces a structure which actually fits. Use the bare diff --git a/plugins/brains/skills/brains-write/SKILL.md b/plugins/brains/skills/brains-write/SKILL.md index e98d9e7..14483e2 100644 --- a/plugins/brains/skills/brains-write/SKILL.md +++ b/plugins/brains/skills/brains-write/SKILL.md @@ -23,14 +23,16 @@ execution in their brains settings, in which case a short allowlist of low-risk actions also runs inline when called from an eligible client signed in from the user's own computer (CLI clients such as Claude Code and Codex) — every execution is still recorded in /inbox. The mode is decided server-side per call, so treat -any call as potentially executing and never assume a `draft_id`. If +any call as potentially executing and never assume a `draft_id`. Get the user's +go-ahead for the write before you call; do not call to find out whether it +drafts. If `requires_confirmation` is absent, the page predates the field: treat whether it drafts or runs as unknown. `side_effect` says where it writes (`external` = the provider, visible outside brains; `null` or absent = undeclared, treat as external). Inline external writes include `rsvp_event`, `create_draft`, and `add_labels`; do not infer safety from read vs write. -Cap: 30 auto-executions/install/60s. +Cap: 30 auto-executions/install/60s. Direct execution is additionally capped at 10/user/60s across every install; past either cap a direct execution degrades to a draft. Automation `dry_run` suppresses external writes to no-call `[DRY RUN]` drafts. | `kind` | What happened | What you do | diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index 0b9d9ba..13e67bd 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -1642,14 +1642,16 @@ const WRITE_ACTION_CONTRACT_REGION = [ "actions also runs inline when called from an eligible client signed in from the", "user's own computer (CLI clients such as Claude Code and Codex) — every execution", "is still recorded in /inbox. The mode is decided server-side per call, so treat", - "any call as potentially executing and never assume a `draft_id`. If", + "any call as potentially executing and never assume a `draft_id`. Get the user's", + "go-ahead for the write before you call; do not call to find out whether it", + "drafts. If", "`requires_confirmation` is absent, the page predates the field: treat whether", "it drafts or runs as unknown. `side_effect` says where it writes", "(`external` = the provider, visible outside brains;", "`null` or absent = undeclared, treat as external). Inline external writes", "include `rsvp_event`,", "`create_draft`, and `add_labels`; do not infer safety from read vs write.", - "Cap: 30 auto-executions/install/60s.", + "Cap: 30 auto-executions/install/60s. Direct execution is additionally capped at 10/user/60s across every install; past either cap a direct execution degrades to a draft.", "Automation `dry_run` suppresses external writes to no-call `[DRY RUN]` drafts.", ].join("\n"); // Asserted AFTER the per-rule checks below, deliberately: they run first and name @@ -1782,6 +1784,119 @@ assert( "brains-automation lost the handling for a secret the user pasted anyway", ); +// --------------------------------------------------------------------------- +// The automation draft-vs-execute rules, pinned the same way brains-write's +// contract is (see WRITE_ACTION_CONTRACT_REGION above) and for a reason this +// artifact demonstrated: the three checks above are the ONLY content assertions +// brains-automation had. Everything else about this file was digest equality +// (`run.ts` line ~1447), which proves the bytes came from the catalog and says +// nothing about what they say. A regeneration could silently re-drop any rule +// below and CI here would stay green — which is exactly how the upstream defect +// these rules fix reached production in the first place. +// +// WHY WHOLE REGIONS, not substrings. Two failure modes, both already observed in +// this repo. A keyword pin dies to paraphrase: "cannot self-confirm" survives +// "the sandbox may confirm when trusted". A verbatim sentence pin dies at the +// SEAM: leave the pinned sentence intact and write the contradiction NEXT to it +// ("verify_mode also holds back board writes") and every substring check stays +// green while the reader is told the opposite. Only whole-region equality plus a +// composition check refuses both. +// +// COUPLING, same as the block above: these bytes are generated from a different, +// PRIVATE repo, so an author rewording the automation procedure reds a build in a +// public repo they may not know exists. When that happens the fix is to re-derive +// the rule upstream and regenerate — never to trim a region or soften an assert +// so a sync goes green. That is the hole all of this exists to close. +// +// TO UPDATE: read the upstream diff, satisfy yourself the new wording is true of +// what the server does, then replace the constant with the new bytes. + +// Rule 1 — automation tokens cannot self-confirm, and what releases a +// confirmation-required action instead. Region runs from the `You can't confirm` +// opener to the `**For "notify me"` lead-in, EXCLUSIVE: both anchors are +// structural rather than editorial, and the span covers the confirm-boundary +// prose AND the write_policy paragraph, so a contradiction inserted between them +// — the natural place to put one — lands inside the pin. +const AUTOMATION_WRITE_POLICY_REGION = [ + "You can't confirm a draft from this loop. Only the user's out-of-band", + "`/inbox` or Telegram surface has the confirmation secret. So never call `confirm_action` yourself.", + "After one hour a draft moves to the `/inbox` Expired tab but remains", + "approvable. There is no edit-at-confirm step: call", + "`discard_action` before re-drafting corrected input.", + "", + "Automation tokens cannot self-confirm: `confirm_action` is refused for them under either policy, so a confirmation-required action is released by the `write_policy` rule above, not by the sandbox. Get explicit agreement before saving an automation that performs an unattended external write. New automations default to `write_policy:'always_draft'`; switching to `auto_confirm_safe` is an explicit configuration change, and that policy does not override an action whose recipe already auto-executes.", +].join("\n"); +const automationPolicyStart = automationSkill.indexOf("You can't confirm a draft from this loop."); +const automationPolicyEnd = automationSkill.indexOf('**For "notify me"'); +assert(automationPolicyStart > 0, "brains-automation must keep its confirmation-boundary opener"); +assert( + automationPolicyEnd > automationPolicyStart, + "brains-automation's write-policy paragraph must precede the notify-me guidance — the slice below depends on it", +); +assert( + normalizeRegion(automationSkill.slice(automationPolicyStart, automationPolicyEnd)) === + AUTOMATION_WRITE_POLICY_REGION, + "brains-automation's write-policy contract must match the approved copy exactly — a sentence added beside these rules can reverse them while every keyword check passes (regenerate upstream, re-read the change, then update AUTOMATION_WRITE_POLICY_REGION)", +); + +// Rule 2 — the dry_run/verify_mode suppression boundary. This is the one whose +// previous wording was FALSE: it implied a smoke test muted every side effect, +// when suppression fires at four call sites only and board and page writes run +// live. Region is the whole `run_automation_once` argument list, so the +// verify_mode bullet — the adjacent bullet, and the obvious seam for a +// contradicting claim — is inside the pin rather than beside it. +const AUTOMATION_SMOKE_TEST_REGION = [ + "Call **`run_automation_once`** with:", + "", + "- `automation_id` — from Step 8", + "- `dry_run: true` — default; suppresses act sends and `telegram_push`. Board and page writes run live under `dry_run` AND `verify_mode` — a smoke test mutates real rows", + "- `verify_mode: true` — **REQUIRED for self-verification during this flow.** When true the MCP server suppresses all outbound side-effect tools (telegram_push, email / calendar / drive sends via act_on_integration, fetch_from_integration writes, and non-GET http_fetch) and returns suppressed-shape stubs instead. This lets you confirm the source compiles and the logic path executes end-to-end **without spamming the user's phone / inbox / drive** while you're still iterating.", +].join("\n"); +const automationSmokeStart = automationSkill.indexOf("Call **`run_automation_once`** with:"); +const automationSmokeEnd = automationSkill.indexOf("The tool enqueues"); +assert(automationSmokeStart > 0, "brains-automation must keep its smoke-test invocation"); +assert( + automationSmokeEnd > automationSmokeStart, + "brains-automation's smoke-test arguments must precede the polling paragraph — the slice below depends on it", +); +assert( + normalizeRegion(automationSkill.slice(automationSmokeStart, automationSmokeEnd)) === + AUTOMATION_SMOKE_TEST_REGION, + "brains-automation's smoke-test contract must match the approved copy exactly — this is the region that tells an author a mandated dry run still mutates real board and page rows (regenerate upstream, re-read the change, then update AUTOMATION_SMOKE_TEST_REGION)", +); + +// Rule 3 — the grant table's Send row, which states when `act_on_integration` +// drafts versus sends inline. Pinned as a whole row plus a UNIQUENESS check over +// the table, not as a region: the other twenty rows are a grant inventory that +// moves for unrelated reasons, and pinning them would red this build on every +// upstream grant edit while adding no coverage of this rule. The uniqueness check +// is what closes the seam a lone row pin would leave — an added row naming +// `act_on_integration` with a different disposition fails it even though the +// pinned row is untouched. Table sliced between its own header and the +// grant-by-bare-name paragraph; the two other rows in this file that name the +// tool live in a different table and are out of scope by construction. +const AUTOMATION_SEND_GRANT_ROW = + "| Send | `act_on_integration` (drafts or sends inline per requires_confirmation and write_policy; see the contract above) |"; +const grantTableStart = automationSkill.indexOf("| Operation | Grant |"); +const grantTableEnd = automationSkill.indexOf("**Grant by bare MCP tool name.**"); +assert(grantTableStart > 0, "brains-automation must keep its grant-selection table"); +assert( + grantTableEnd > grantTableStart, + "brains-automation's grant table must precede the bare-tool-name rule — the slice below depends on it", +); +const grantTableRows = normalizeRegion(automationSkill.slice(grantTableStart, grantTableEnd)) + .split("\n") + .filter((line) => line.startsWith("|")); +const actGrantRows = grantTableRows.filter((line) => line.includes("act_on_integration")); +assert( + actGrantRows.length === 1, + `brains-automation's grant table must state act_on_integration's draft-vs-send disposition exactly once — ${actGrantRows.length} rows name it, so a reader can be routed by whichever they read first (regenerate upstream, re-read the change, then update AUTOMATION_SEND_GRANT_ROW)`, +); +assert( + actGrantRows[0] === AUTOMATION_SEND_GRANT_ROW, + "brains-automation's Send grant row must match the approved copy exactly — it is where an author learns that this tool may send without drafting (regenerate upstream, re-read the change, then update AUTOMATION_SEND_GRANT_ROW)", +); + // This README is the install instructions for anyone who finds the repo directly rather than the // guided page, so it has to carry the same contract. It documented `export BRAINS_API_TOKEN` as the // way in long after that stopped being able to work, which is exactly the drift these pin.