Skip to content

Send somebody a coworker, and grant it nothing on arrival - #299

Draft
jerelvelarde wants to merge 15 commits into
CopilotKit:mainfrom
jerelvelarde:feat/bot-templates
Draft

Send somebody a coworker, and grant it nothing on arrival#299
jerelvelarde wants to merge 15 commits into
CopilotKit:mainfrom
jerelvelarde:feat/bot-templates

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Import a coworker

The problem

A coworker is the most valuable thing a person builds here, and it cannot leave the deployment it was
built in. Somebody spends a week getting a Bot's role right, pairing it with the skills that narrow
its tools, working out which connectors it needs — and the only way to give that to a colleague is to
describe it and have them do the week again. Across a fleet the same Bot is rebuilt by hand in
staging and in production, and the two drift.

Two things almost do this and neither fits. The tenant package is the whole deployment — brand,
channels, model, roster — read from a directory at boot by whoever runs the process; it cannot
describe one coworker, and it is unsafe on a stranger's file, since expandEnvironment substitutes
${NAME} out of the server's own environment before the YAML is parsed. duplicate copies four
presentation fields, drops the endpoint and every grant, and throws ManagedAgentUnavailableError
outright when the deployment has no Bot of its own — so on the one-container image the README
recommends, that button is dead.

The design is already written down in the code that needs it: tenant-package.ts:137 concludes a
skill is safe to seed because "a skill is an instruction and confers nothing", while an MCP grant is
not because "those reach a person's own account". Nothing used it.

The approach

A Bot template is one YAML file describing one coworker — identity and prose, the skills it
depends on, the capabilities it asks for, and a ceiling on what it may do. The vocabulary is the
tenant package's, so anyone who has read examples/fintech/ can read a template.

Configuration travels; capability does not. No id, endpoint URL, credential, MCP grant, component
source or policy rule can travel, because none are fields: a document carrying one fails to parse and
is told which key and why. That beats stripping them silently — an author who tried to ship a key
learns, and a reviewer is not reading a redacted copy of something larger. Import writes exactly one
grant, plugin_grants kind 'skill', marked template:<digest12> so a retraction takes back what
the import gave and never an administrator's own. Everything else lands in a ledger as requested and
not granted, satisfied afterwards on the screens that already decide those things.

Three divergences from the package loader, each a security decision. Parsing is strict, because
an ignored key is one the reviewer's eye slid over and the parser agreed to. There is no environment
interpolation at all, and the check reads raw bytes so a comment cannot carry one either. Slugs use
the API's rule rather than the package's looser one, which admits x and find- — both install
cleanly and are then permanently uneditable through the product.

The consent screen is the control, so it is built as one. The role description and every skill's
instructions render verbatim and unabridged, under the sentence that a stranger wrote them. Invisible
codepoints are a parse refusal rather than a rendering problem, because a review control that can be
made invisible is not a control.

An unmet ask never blocks the install, which is the decision most likely to be questioned.
Blocking would make "grant everything" the fastest route to a working Bot. The Bot arrives cold and
does not lie about it: grantedToolGuidance already builds a Bot's self-description from the tools
it was actually offered, so it says it has no source rather than claiming what the template
promised.

The boundary is disclosed, not enforced. The boundary: block is parsed and shown in plain
English; compiling it into scoped policy is the next change. Until then the consent screen says so —
that this deployment currently allows every action, and an imported Bot has exactly the computer
reach of any other Bot here. That cost is real, and stating it beats a ceiling nobody enforces.

What is not covered

  • template_boundaries ships empty. The compiler and the policyStore.get() composition follow.
    Generated clauses will live in that table rather than in action_policy.deny, because
    policyStore.set replaces the whole array from a screen snapshot with no version column, so a
    clause stored there is erased by an unrelated save.
  • No gallery and no registry. Sharing is a file. examples/templates/ ships three templates and a
    CI check, but nothing in the product reads that directory yet.
  • No update channel. template.version is a string nothing reads. Security fixes do not
    propagate either — that is the trade, and auto-update has no safe form without publisher identity.
  • type: built_in cannot travel, so exporting one of the shipped fintech Bots is not a faithful
    round trip.
  • Components are requested, never withheld. An imported Bot sees every published component like
    any other Bot.
  • Declining an ask does not revoke a grant already made. Revocation goes through the existing
    screens: the ledger stores the ask and a decision, never a second source of truth for a permission.

Verification

format:check, lint and typecheck clean. bun run test:ci reports 2318 pass, 0 fail against
a baseline of 2149 on main — 169 new tests.

Driven end to end against a running deployment, which is how the first two of these were found: the
routes were mounted behind an optional dependency the composition root never built, so
/api/templates answered 404 on a real server while every route test passed; and the packer's own
secret-scanner fixtures were shaped enough like credentials to be blocked by push protection.

Three more came from CI failing where a laptop could not. The suite is order-dependent in ways that
only bite once a test renders a real component tree, and these are the first that do. Six new
integration files held their connection pool open for the rest of the run. Happy DOM registers at
about:blank, whose origin is the string "null", so the first file to reach @/lib/auth/client
under a bare registration threw while being imported. And auth-client.test.ts assigns a window
carrying a location and nothing else, which was harmless while nothing installed a DOM and, once
something did, left document defined and window.history undefined for app/src/router.tsx to
build its history from. That last fix touches a file this feature does not otherwise go near; it is
here because these tests are what made it reachable.

File What it holds down
shared/bot-template.test.ts Every refusal by reason code, invisible codepoints by Unicode property class, digest stability across key order and Unicode form
server/tests/template-pack.test.ts Every strip, the managed/remote split, each secret shape refused with benign near-misses accepted
server/tests/template-resolve.integration.test.ts The five verdicts, slug reuse/suffix/skip, the endpoint slot when no managed agent exists
server/tests/template-install.integration.test.ts One transaction, no MCP grant written anywhere, the digest 409, retraction leaving a hand-made grant intact
server/tests/template-routes.integration.test.ts Each authz boundary, and that an ask recorded unavailable cannot be granted even after the connector appears
server/tests/plugin-store-transaction*.test.ts Rollback through the threaded executor, and that the skills route never sets allowUnknownTools
app/tests/import-template.test.tsx That nothing model-visible is truncated, and that a key typed for one template is not carried to the next
app/tests/audit-template-rows.test.tsx That a refused import reads as refused rather than as allowed

A hostile review over the diff produced 27 candidate defects; 24 survived independent verification and
are fixed in the last five commits — including one where an ask both consent surfaces described as
inert sat beside a button that wrote a live MCP grant.

…tes never mount

The routes were wired into createApp behind an optional dependency and the composition root never
constructed one, so /api/templates answered 404 on a real server while every route test passed: the
tests build the app directly and hand it the dependency the process never had.

Assembled here rather than inside createApp because the installer needs the vault, the plugin store,
the trail and this deployment's endpoint policy, and each of those already exists exactly once in
this file. The endpoint policy is deliberately the same pair agentFetch is given: an address
registered by an import must be held to exactly what an address registered through /api/agents is.
A connector id was checked for length and nothing else, so a template could file a real tool ref as
one. With no tools under it the per-tool check that a ref belongs to its connector never runs, so
`id: google-drive/read_file_content` parsed cleanly and arrived where the server and the profile
screen both re-derive connector-from-tool by looking for a slash.

The invisible-character refusal is now the Unicode property classes rather than an enumerated list,
which had drifted nine blocks narrower than the sentence it defends and let the first sixteen
variation selectors through. Lengths are measured in the units the edit form measures them in, so a
template can no longer install a Bot its owner cannot then save.
The grant route read the ref and never the ledger row's status, so an ask the consent screen had
described as "nothing will be granted and nothing will be written", and the profile as "there is
nothing yet to grant", sat beside a button that wrote a live MCP grant.

Two guards, and the overlap is load-bearing. The status guard refuses an ask that was unavailable
when the person read it, because somebody connecting that server afterwards is not their consent.
The live re-check refuses a ref whose server or tool is absent right now, because the stored status
is a snapshot from resolve time and a grant for a server that does not exist is invisible on every
screen until the day one appears under that id.
The endpoint check and the byte-identical guard behind `reuse` both had no test, so either could
have been deleted and the suite would have stayed green. Two skills in one template can no longer
plan into the same slug, an imported Bot keeps the avatar its template named, and reuse of a skill
somebody else owns falls through to a suffix rather than quietly attaching theirs.
The audit write went to the pool while the caller held a transaction on a pooled connection, so
every import needed two at once and enough concurrent ones would wait on each other.
…be refused

Name and title were truncated on the consent screen and both are concatenated into the standing role
the model is given, so a stranger could write two hundred characters into a field the reviewer saw
forty of. Nothing model-visible is clipped now.

A deployment in dry-run enforces nothing, so it gets the amber warning rather than the reassuring
block. A key typed for one template is no longer carried to the next one's address. The audit page
knows the template events, so a refused import reads as refused rather than as allowed.
bun test runs every file in one process, so a pool left open is held for the rest of the suite. Six
new files opened one and never closed it, and past a certain point PostgreSQL stops handing out
connections — which does not read as a connection limit, it reads as the run dying at whatever file
happened to be next. On CI it died at a different one each time and printed no summary at all.

Every other integration test here already closes. These now do too, after their cleanup rather than
before it.
Happy DOM defaults to about:blank, whose origin is the string "null". Better Auth builds its base
URL from window.location.origin when it is not given one, so the first file to pull in the auth
client under a bare registration threw Invalid base URL: null while it was still being imported,
taking its tests with it and reporting an unhandled error rather than a failure anybody could place.

Locally auth-client.test.ts stubs a window with a real origin and, when it runs first, the client is
already cached by the time a rendered test needs it. That is an ordering accident. On CI the order
differs, eight tests never ran, and the suite reported a count nobody could reconcile.
The sign-in test assigns a window with a location and nothing else. That was harmless while nothing
in the suite installed a DOM; the rendered tests here do, and overwriting a registered window leaves
document defined and window.history undefined. app/src/router.tsx builds its history at module scope
from exactly that pair, so it threw while being imported by whichever file reached it next, and took
that file's tests with it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant