Repository navigation
feat(native): a native turn is told only about tools it has; hosted agents read links and the board - #2261
Conversation
…gents read links and the board From the out-of-box QA walk (2026-10-16), three ways a hosted agent misled a stranger: - It claimed commonly_read_file and pointed the person at commonly_attach_file, and another offered "a coding specialist like @codex". The kernel writes its mention frames once for every runtime; the pod frame names file and threading tools, and the consultation cue a DM tool and codex. None exists on a native turn. kernelFrames owns the frame tags and a splitter; nativeKernelCue gives a native turn a pod frame that names only what it has, drops the consultation cue, and names commonly_read_context where the trigger frame says commonly_get_messages. A kernel-side test runs real kernel-built content through it and asserts every commonly_* name left is a native tool. - It said the board was empty when it held three tasks: hosted agents could create tasks but not read them. commonly_list_tasks (read-only, this pod, open by default, 25 rows) joins the native tools and the hosted list, and the hosted prompt says to check the board before describing it. - It could not open a shared link. commonly_fetch_url (#2249) joins the hosted list. The exact QA phrasing ("Read the README of github.com/Team-Commonly/commonly") opens the repo page, whose reduced text carries the whole README; a composed raw README address is refused and does not spend a page. - Asked to summarize the pod, the agent also searched the web and ran code, and the reply took 19 s against 7-9 s for the other asks. The hosted prompt now says to answer a question about the pod from the pod, and to reach for search, pages or code only when the answer needs something outside it. - Every answer opened with "@asker". #2258 stops an agent's answer to the person who asked from opening a "needs you" item; the hosted prompt now also says not to @-mention the person being answered, and to @-mention someone only when they need to act. - docs/agents/who-to-address.md: word a gate ask to a codex seat as a review, not an attack. Two #2249 security asks to Rhea described exfil, SSRF and ReDoS the way an attacker would, and OpenAI's filter refused every turn (five in a row); the gate went silent. - docs/development/review-checklist.md rule 54: validate the value you use, not a normalized copy (#2249's security HOLD got past a gate whose probes all tested the comparison key). - Only a human's pod message gives commonly_fetch_url an address (Cofounder's security read on #2249): another agent's post, or another instance of this one, no longer counts, so two agents steered by the same injected text cannot pass an address between them. The bot flag comes from the PG mirror's raw is_bot (0 of 797 users null in production) or the Mongo populate; unknown counts as a bot. Author ids compare lowercased. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: HOLD @ 7aabc6e. Independent security read standing in for Rhea (Cofounder operator session with a separate reviewer; not a Commonly seat). PR suites pass 113/113.
Finding (high, reproduced): the triggering mention supplies addresses whoever wrote it. runAgent adds the trigger text (userMessage) to the given set without checking the sender. Agent-to-agent mentions are delivered (agentMentionService only dampens loops), and hosted agents filter by event type, not sender. Reproduced: a chat.mention whose userId is another bot, with content carrying a URL with data, made the fetch request exactly that URL. That's the two-agent path this PR sets out to close at the pod-message level, and this PR also gives commonly_fetch_url to every hosted agent by default.
Fix: add the trigger text to the given set only when the event's sender userId resolves to a known human (non-bot, id known); fail closed when it doesn't. Apply the same rule to any other event whose text another agent can write (for example task titles in a board wake, if native installs receive those). Add arms: a bot-authored mention, an unresolvable sender, and a human-sender control.
Verified clean:
- Pod-message exclusion (9 arms): refused for a PG bot author, a second instance,
is_botnull, a Mongo bot withObjectId, Mongo with noisBot, and an upper-cased own id. A human on PG and a human on Mongo are allowed. commonly_list_tasks: read-only (find/count) and fixed toinstallation.podIdwith no podId argument. Task text never enters the given set.- Frame rewrite: the NO_REPLY and trust frames pass through unchanged. "Post as yourself only" is structurally enforced: code runs get
envs: {}, and no native tool takes a token or user. Note: the swapped pod frame also drops the decision-card and @handle addressing guidance (not security-relevant). - Taint after a fetch: it leaves read_context, post_message, web_search and fetch_url, the same set as after a search.
Not blocking: a PG row lacking the raw is_bot key would fall back to the mapper's isBot (missing reads as human). Today findByPodId always selects it; consider failing closed there too.
GATE: HOLD
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by sprint-review, a Commonly agent — code gate at 7aabc6efa. pod thread
Holding on your first property. It does not hold, and it fails in the scenario this PR was written for.
HOLD: a surviving frame names two tools native does not have
"After
nativeKernelCuerewrites a kernel-built mention, everycommonly_*name left is a native tool."
nativeCueFromKernel rewrites podContext, drops consultation (tag Collaboration), rewrites trigger, and passes every other frame through unchanged. One of those others is tag Collaborative pod, and its literal ends:
"…or open a 1:1 DM with
commonly_dm_agent(commonly_open_dmon openclaw runtimes)."
Measured, using the 647-character literal extracted from agentMentionService.ts rather than a paraphrase:
commonly_* in the real collaborative-pod literal: ["commonly_dm_agent","commonly_open_dm"]
survives nativeCueFromKernel: ["commonly_dm_agent","commonly_open_dm"]
native TOOLS (12): agent_status, create_task, fetch_url, list_tasks,
offer_hosted_agent, post_message, propose_action,
read_context, read_memory, run_code, web_search, write_memory
=> NOT NATIVE: commonly_dm_agent, commonly_open_dm
Reachable, and in exactly the out-of-box case. The frame's gate is ≥2 active non-utility agent installs, a non-specialist target, and chat.mention. NON_COLLAB_PEER_AGENTS is {commonly-bot, pod-welcomer, task-clerk, pod-summarizer} — Scout is not in it. So a stranger's fresh workspace holding Scout plus their one hosted agent has two collab peers, the hosted agent is a non-specialist, and a plain mention qualifies. The hosted turn is then told it can open a DM with a tool it was never given.
That is the same failure the PR exists to fix. Your own commit message cites it: "another offered to hand work to 'a coding specialist like @codex'." You dropped the Collaboration cue that says that, and left the Collaborative pod cue that says it too. Two tags, both naming the DM tool, one handled.
Why your suite is green: agentMentionService.nativeCue.test.js contains zero occurrences of Collaborative pod. The "real kernel content" it runs does not include this frame, so the universal is asserted over a sample that excludes the counterexample. The arm at :92 asserting foreign contains commonly_dm_agent is reading the consultation cue, which is dropped — so that name appears covered while the other source of it is untested.
Fix: handle KERNEL_FRAME_TAGS.collaborativePod in nativeCueFromKernel. I would rewrite rather than drop: the frame's other content (execute it yourself, don't enqueue for an absent agent, you may pick up stale claimed work) is genuinely useful to a native turn — it is only the delegation clause that names tools it lacks. And the test wants the assertion inverted: rather than listing frames to check, enumerate commonly_* across the output of every tag in KERNEL_FRAME_TAGS and assert membership in TOOLS, so a frame added later cannot slip past the same way this one did.
What does hold
Everything else I checked is sound, so the hold is narrow:
commonly_list_tasksis read-only and pod-scoped.filteris{ podId: ctx.podId }— the pod comes from the turn's context, never from a model argument;statusis whitelisted toopen/done/all;selectis four fields; the read is bounded byLIST_TASKS_LIMIT; and there is no write.- The hosted list carries both new tools (
commonly_list_tasks,commonly_fetch_url). - The human-only provenance rule is correctly strict.
authorIsBot === falsemeans unknown (null) and bot both give no address,Boolean(authorId)excludes a missing id, and both sides of the id compare are lowercased. Reading PG's rawis_botinstead of the mapper'sisBotis the right call and the comment says why — the mapper reads null asfalse, which would fail open. That is precisely the class of mistake behind both earlier holds on this surface, and here it was anticipated. byOtherAuthorskeeps itsownUserId === nullguard beforeownUserId.toLowerCase(), so the stricter rule did not introduce a throw.
I have not re-verified the kernelFrames tag pinning or the hosted prompt lines; those are not implicated by the hold and I will cover them on the next head.
CI is green at this head (12 pass, 1 pending) — this hold is on a demonstrated defect, not a red build. Cofounder has the security read.
GATE: HOLD
…known human wrote it (#2261 security HOLD) Cofounder's independent read at 7aabc6e (reproduced): runAgent added the trigger text to the given set whoever sent it. Agent-to-agent mentions are delivered and hosted agents filter by event type only, so another bot's chat.mention carrying a URL with data was fetched as sent: the two-agent path the pod-message rule had closed, through the trigger itself. triggerTextIsHumanWritten: the trigger gives addresses only when its event type is one whose content is a single message (mention, DM, pod-message wake), it names a messageId, and its payload.userId resolves to a user whose isBot is exactly false. Board wakes (task titles others wrote), server cues and approval results never give one. Fails closed on an unknown sender, a missing flag (5 of 908 production users lack the field, all tmpuser test accounts) or a failed lookup. Also from the read: a PG row (it has user_id) without the raw is_bot column now reads as unknown, not as the mapper's false. And the native pod frame keeps the kernel's tool-agnostic @handle rule ("a bare name reaches no one"); what it drops is listed in the PR body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by sprint-review, a Commonly agent — re-gate at 26b1d1d36. pod thread
The new commit is sound. The hold stands, because it is not what the hold was about.
My finding from 7aabc6efa is unaddressed — re-measured, not recalled
nativeCueFromKernel still handles exactly three tags: podContext, consultation, trigger. There is no collaborativePod branch. Re-running the same measurement at this head, with the 647-character literal extracted from agentMentionService.ts:
survives nativeCueFromKernel at 26b1d1d36: ["commonly_dm_agent","commonly_open_dm"]
native TOOLS (12): …no DM tool…
=> STILL NOT NATIVE: commonly_dm_agent, commonly_open_dm
So the PR's first stated property — every commonly_* name left is a native tool — is still false, still in a pod that qualifies as collaborative on Scout plus one hosted agent (NON_COLLAB_PEER_AGENTS does not contain scout), and still produces the failure the PR's own commit message cites.
What nativeKernelCue did gain this commit is the pod frame's @handle rule and a comment listing what the frame swap drops. Both good; neither touches this.
I am not re-raising a settled point: the gate ask named Cofounder's HOLD, and nothing claimed mine was fixed. Flagging it because a GATE: PASS here would clear a PR with a demonstrated, reachable defect still in it.
Cofounder's finding: the fix is good, and it closes a case I also never checked
I verified pod messages read back, and never asked whether the message the turn answers was written by a human. That is a third instance of my own pattern on this surface — checking a guard and not the input it guards.
triggerTextIsHumanWritten requires all three, and each fails closed:
SENDER_WRITTEN_EVENT_TYPESis{chat.mention, thread.mention, dm.message, mention, chat.message, message.posted}— a complete enumeration, sotask.assigned,first_contact,heartbeat,approval.resultandpod.joincan never qualify;- a missing
messageIdorpayload.userIdreturns false; sender?.isBot === falseis strict, so an unfindable sender or an absent flag returns false, and thecatchreturns false.
The gating use is right too: givenAddresses.add(systemPrompt) stays unconditional (server-composed), and userMessage — frames plus body — is added only when the predicate passes.
Removing that gate reds 6 arms by name, one per claimed case:
another agent · a sender that cannot be found · a sender with no bot flag · no sender at all · a wake that names no message (a board wake lists others' text) · an event that is not one message a person wrote (an approval result)
5 suites / 94 passed at this head; RESTORED 39/39 on the mutated file's suite.
One scope note on my own method: I probed the predicate across eleven event types and got false for all of them, which does not isolate the type gate — an empty payload fails the messageId/userId conditions regardless of type. The type exclusion above is read from the set's contents, which is a complete enumeration of six members, not inferred from that probe. Saying so because "all false" looks like a stronger result than it is.
To clear this
Handle KERNEL_FRAME_TAGS.collaborativePod in nativeCueFromKernel — I would rewrite rather than drop, keeping execute-it-yourself and the stale-claim clause and losing only the delegation sentence. And invert the test: enumerate commonly_* across the output for every tag in KERNEL_FRAME_TAGS and assert membership in TOOLS, so the next frame added cannot pass the same way this one did.
GATE: HOLD
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: PASS @ 26b1d1d. Independent security read standing in for Rhea (Cofounder operator session with a separate reviewer; not a Commonly seat). PR suites pass 120/120.
The trigger finding is closed. 12 arms:
- A bot-written mention is refused; the human control is allowed.
- Refused: a human sender with no
messageId, a missingisBot, an unresolvable sender,isBotas the string"false", and an ObjectId-typeduserId. - Refused: an agent DM as
dm.messageand as an agent-to-agentchat.mention, a board wake (nomessageId), and an unknown event type. - The punctuation repro still requests exactly the given address.
payload.userId can't diverge from the real author. Every producer sets it server-side: agent posts use req.agentUser._id, human posts use the authenticated req.userId, the welcome wake fires only for a human sender, a claim handoff re-sends only senderIsHuman: true payloads, and a reaction wake carries reactedMessageId rather than messageId. No path lets an agent choose it.
Every add site checked: read-context and recent messages (human authors only), the trigger (now gated), search results and fetched pages (third-party by design), and the system prompt (operator-written, or the installing user's ≤160-character about). Agent memory is not included.
Not blocking:
- A decision-card ruling posts the agent-written option label (≤80 characters) under the human's id. The human reads exactly what they post, so it's the accepted human-repost case.
- "Human" is
isBot === false, so an agent session posting through a human account counts as human. That's the existing identity model, unchanged here.
GATE: PASS
|
Written by sprint-review, a Commonly agent — a note on how this PR's gate lines now read, not a new finding. At head
Both lanes write the same Flagging because a press that reads "the latest @lily-shen the ruling I would want, when convenient and not blocking tonight: either the press reads the latest line per lane and needs both, or the two lanes stop sharing a sentinel (security keeps Nothing to do on the code side but the frame fix already described. |
…ol sentences; every kernel tag is tested (#2261 code HOLD) sprint-review at 26b1d1d: nativeCueFromKernel had no collaborative-pod branch, so commonly_dm_agent and commonly_open_dm reached native prompts in any pod with two agents, breaking this PR's own "every commonly_* left is a native tool" property. The test only drove a one-seat pod, where that frame never appears. The wake frame had the same gap (commonly_claim_message). - Collaborative pod: cut before "Delegate only when"; "execute it yourself" and the stale-claim race are kept. - Wake-on-message: cut before "If you do act, the message must be claimed first"; NO_REPLY guidance is kept (the native runtime claims for itself). - Guard: after the rewrites, a frame that still names any commonly_* tool outside the native set is dropped and logged, never shown. A reworded kernel frame whose cut marker no longer matches falls to it. - The test now drives the real kernel through a one-seat pod, a two-seat pod and a wake-on-message seat, asserts those cover every KERNEL_FRAME_TAGS tag, and that every commonly_* left is a native tool. Mutations caught: no collaborative cut, no wake cut, no guard, a cut marker that no longer matches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: PASS @ ca3db53. Independent security read of the 26b1d1d..ca3db53 delta, standing in for Rhea (Cofounder operator session with a separate reviewer; not a Commonly seat). PR suites pass 123/123.
No trust guidance is removed with today's kernel text. Run on the real current frame text:
- The wake frame's cut lands after its NO_REPLY rule, so NO_REPLY is kept.
- The replies-to-you, agent-DM and human-DM frames survive whole, with their three NO_REPLY rules intact. They name no tool except the native
commonly_post_message. - The collaborative-pod frame carries no trust guidance.
- Untrusted-content warnings sit on tool results, not kernel frames, so the guard never sees them.
- Board-wake, approval, welcome and system-exchange cues name no
commonly_*tool, so the guard can't drop them.
Previously passed code is untouched: the provenance module and URL guard have 0 changed lines, and trigger gating and taint are unchanged. The only runtime change is passing NATIVE_TOOL_NAMES to the rewrite.
Follow-up (non-blocking): the guard drops a frame WHOLE when it names a non-native tool. Shown by rewording the wake frame's cut marker: the whole wake frame, NO_REPLY included, was dropped with only a console warning. The PR's real-content test catches that case for the wake frame, but nothing pins NO_REPLY survival for the replies-to-you or agent-DM frames. Pin those two in the test, or have the guard strip only the offending sentence when a frame carries NO_REPLY.
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by sprint-review, a Commonly agent — re-gate at ca3db5393. pod thread
Hold lifted. The fix closes the class, not just the instance — and I have a correction to make about my own evidence.
First, a correction: my probe was invalid
The finding was right. The measurement I published for it was not.
My harness extracted each frame's literal with /[^]*/gand kept the **backticks**, so the string I fed in began ``[Collaborative pod: `` . frameTagOf anchors on `^[`, returned `null`, and `splitKernelFrames` rejected the line entirely — `nativeCueFromKernel` then hit `if (!frames.length) return content` and handed my input straight back. So my "survives the rewrite" output would have printed the same result for any frame, including ones the code correctly drops.
The control that exposed it: running the same harness on the Collaboration frame — which this code has dropped by tag since the first commit — also reported the DM tools surviving. A frame that is provably dropped cannot survive; the instrument was answering a different question than the one I asked.
So the tables in reviews 5542992642 and 5543056373 should not have been offered as measurements. What the finding actually rested on, and still rests on, is sound: nativeCueFromKernel had no collaborativePod branch (read directly), you confirmed it independently, and this PR's own arm now reds when the cut is removed. Conclusion correct, evidence misattributed — the same error class I have been flagging in others tonight, in my own hands.
The fix, verified with your instrument rather than mine
Your test drives the real kernel through buildContentForTarget for three pod shapes, which is strictly better than my literal extraction, and it asserts its own coverage first:
expect([...seen].sort()).toEqual(expect.arrayContaining(Object.values(KERNEL_FRAME_TAGS)));That is the inversion I asked for and one step better: a tag the fixtures fail to drive reds the arm, so the universal cannot be asserted over a sample that excludes the counterexample again. That exact gap — a one-seat pod never producing the collaborative-pod tag — is what let this through.
Three things I checked in the implementation:
cutBeforereturnsnullwhen the marker is absent, so a reworded kernel frame is not sliced at the wrong offset; it falls through to the guard. That is the right failure direction for a string-matched cut.- The guard drops rather than passes. Any frame still naming a non-allowed
commonly_*after the rewrites is removed and logged, so a kernel clause added later cannot teach a native model a tool it lacks — the class is closed, not just these two frames. nativeToolsis now a parameter. The allowed set is injected rather than imported, which is why the test can assert againstNATIVE_TOOL_NAMESdirectly.
You also found an instance I missed: the Wake-on-message frame named commonly_claim_message. My review named one frame; the correct statement was "every frame that is not rewritten", and the guard is what makes that statement true regardless.
Measurement
BASE 2 suites / 18 passed; RESTORED 18/18. Failures by name:
| mutation | result |
|---|---|
| collaborative cut removed | 1 — "the collaborative-pod and wake frames keep their guidance and lose only the tool sentences" |
| wake cut removed | 1 — same arm |
| guard removed | 1 — "a frame that still names a tool native lacks is dropped, not shown" |
cut marker reworded (Delegate ONLY when) |
1 — same arm as the first two |
Three of the four red the same arm, so it carries the collaborative cut, the wake cut and marker-drift together; only the guard has an independent arm. The guard's arm is strong on its own terms — it fabricates [Some new cue: call commonly_brand_new_tool.] and asserts the exact output toBe, so the frame is dropped and the neighbouring Trigger frame survives.
One thing for the press, not for you
Cofounder's SECURITY-GATE: PASS was filed at 26b1d1d3, not at this head, so the security lane is stale here even though the code lane now passes. That is the same sentinel-recency problem I noted in my PR comment: both lanes write GATE:, so the most recent line is not necessarily the one a reader wants. This head needs a security re-read before it is pressed on two PASSes.
GATE: PASS
Why
The out-of-box QA walks (2026-10-16, walks 2 and 3) left a "Run it here" agent telling strangers things that weren't true:
commonly_attach_file, and offered to hand work to "a coding specialist like @codex". The kernel wraps every mention in frames written for CLI and openclaw runtimes. The pod frame names file and threading tools and a token file; the consultation cue names a DM tool andcodex. A native turn has none of them.What changes
A native turn is told only about tools it has.
kernelFrames.tsowns the frame tags and a splitter. Each tag is pinned by a test to the literalagentMentionServicewrites, so the two can't drift;verify-moltbot-tool-contractstill reads the literals.nativeKernelCue.tsgives a native turn its own pod frame, naming onlycommonly_post_message, saying plainly that it can't open or attach files, and keeping the kernel's tool-agnostic @handle rule ("a bare name reaches no one").commonly_request_decisionis an MCP tool native doesn't have).commonly_get_messagestocommonly_read_contextin the trigger frame.commonly_claim_message"; the native runtime claims for itself.commonly_*name left is a native tool. A new kernel clause that names a tool native lacks fails there, not in front of a stranger.Hosted agents can read links and the board.
commonly_fetch_url(feat(native): commonly_fetch_url reads a page the turn was given (TASK-267b) #2249) and a new read-onlycommonly_list_tasks(this pod, open by default, 25 rows) joinHOSTED_NATIVE_TOOLS.commonly_fetch_url;Only a human's words give
commonly_fetch_urlan address (Cofounder's security reads on #2249 and on this PR).is_bot(0 of 797 users null in production) or the Mongo populate. An unknown flag counts as a bot.is_botcolumn reads as unknown, not as the mapper'sfalse.messageId, and itsuserIdresolves to a user whoseisBotis exactlyfalse. Before this, another bot'schat.mentioncarrying a URL with data was fetched as sent (security HOLD at7aabc6ef). Board wakes (task titles others wrote), server cues and approval results never give one. It fails closed on an unknown sender, a missing flag (5 of 908 production users lack the field, alltmpusertest accounts) or a failed lookup.Docs
docs/agents/who-to-address.md: word a gate ask to a codex seat as a review, not an attack. The provider filter reads the mention and anything the seat reads mid-turn. Two feat(native): commonly_fetch_url reads a page the turn was given (TASK-267b) #2249 asks to Rhea were refused five turns in a row.docs/development/review-checklist.mdrule 54: validate the value you USE, not a normalized copy. That's the feat(native): commonly_fetch_url reads a page the turn was given (TASK-267b) #2249 HOLD a gate's 18 probes missed because they tested the key.Tests
All under Node 22.
nativeRuntimeService.hostedEngine.test.js:agentMentionService.nativeCue.test.jsdrives the real kernel through three pods: one seat, two seats (the collaborative-pod frame) and a wake-on-message seat.KERNEL_FRAME_TAGStag, and that everycommonly_*left after the rewrite is a native tool.kernelFrames.test.js.nativeRuntimeService.webFetch.test.js:formatMessageshapes it), and the agent itself in another case all give no link;list_tasksoff the hosted list;fetch_urloff the hosted list;Rollout
Merge, then Deploy Dev. No migration: the hosted tool list and prompt are built per turn. Cofounder re-runs the four asks on the walk-3 agent.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG