From 90d1ad9daa4017e29680a9c4935727fd4373189e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:29:28 -0700 Subject: [PATCH 1/3] fix(soul): state the NO_REPLY position rules the sanitizer actually implements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SOUL/HEARTBEAT footer every moltbot reads told agents that NO_REPLY "only suppresses output when it is your *entire* reply" and that appending it means the token "will be sent verbatim and the user will see it". Both halves are false at main, and they err in OPPOSITE directions, so a reader who notices one and inverts the sentence lands on the other error: - Understates suppression at the front. `opensWithBareSentinel` (agentMessageService.ts) tests `startsWith`, not equality — a leading bare sentinel suppresses the whole reply even when substantive text follows. That is TASK-067's ruling, implemented in #1321. - Denies stripping at the back. Since #785 a bare sentinel elsewhere in a substantive reply is treated as producer leakage: it is stripped and the rest POSTS. "Visible but harmless" is exactly backwards — the token that would have signalled the author never meant to send it is the part that gets removed. AX entry 43 is 11 leaked private rationales in one day. Rewritten from the three real branches (leading / bare elsewhere / backticked) rather than by negating the sentence, since negation lands on the other error. Co-Authored-By: Claude Opus 5 --- backend/services/agentProvisionerServiceK8s.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/services/agentProvisionerServiceK8s.ts b/backend/services/agentProvisionerServiceK8s.ts index c088e6b2ab..e9a1083892 100644 --- a/backend/services/agentProvisionerServiceK8s.ts +++ b/backend/services/agentProvisionerServiceK8s.ts @@ -437,9 +437,20 @@ naturally well before the guard trips. ### Use of NO_REPLY -\`NO_REPLY\` only suppresses output when it is your *entire* -reply. Do not append it to normal text — it will be sent -verbatim and the user will see it. +Position decides what happens, and the three cases differ: + +- **It opens your reply** — the entire reply is suppressed, even + when substantive text follows. A leading \`NO_REPLY\` is read as + intended silence, not as a prefix to something you meant to say. +- **Anywhere else, bare** — the token is stripped and *the rest of + your message posts*. Appending it does not go silent; it + publishes whatever you wrote around it, without the token to + signal you never meant to send it. +- **Inside backticks or a code fence** — preserved verbatim. + Backtick the token when you want to mention it. + +So: to stay silent, send \`NO_REPLY\` and nothing else. Never +append it to text you would mind publishing. `; const ensureWorkspaceSoulFile = async (accountId: any, content: any, { gateway } : any = {}) => { From 83a718883bdf9e568ae770d1d047c9b7dc11b155 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:40:29 -0700 Subject: [PATCH 2/3] fix(soul): split the fence case from the backtick case in the NO_REPLY passage A fenced sentinel-only reply is suppressed, not preserved: the total-match check sits above the outerFence return, and the outer fence is removed before storage, so "preserved verbatim" was wrong in both directions for a fence. Measured at main 7c79af01: '```text\nNO_REPLY\n```' -> '' '```text\nNO_REPLY is discussed here.\n```' -> 'NO_REPLY is discussed here.' '`NO_REPLY`' -> '`NO_REPLY`' So backticks and fences differ on the sentinel-only case and the passage now says which is which. Co-Authored-By: Claude Opus 5 --- backend/services/agentProvisionerServiceK8s.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/services/agentProvisionerServiceK8s.ts b/backend/services/agentProvisionerServiceK8s.ts index e9a1083892..039f7a7895 100644 --- a/backend/services/agentProvisionerServiceK8s.ts +++ b/backend/services/agentProvisionerServiceK8s.ts @@ -437,7 +437,7 @@ naturally well before the guard trips. ### Use of NO_REPLY -Position decides what happens, and the three cases differ: +Position decides what happens, and the cases differ: - **It opens your reply** — the entire reply is suppressed, even when substantive text follows. A leading \`NO_REPLY\` is read as @@ -446,8 +446,14 @@ Position decides what happens, and the three cases differ: your message posts*. Appending it does not go silent; it publishes whatever you wrote around it, without the token to signal you never meant to send it. -- **Inside backticks or a code fence** — preserved verbatim. - Backtick the token when you want to mention it. +- **Inside backticks** — kept, so backtick the token when you want + to mention it. A reply that is *only* \`NO_REPLY\` in backticks + posts that literal; it does not go silent. +- **Inside a code fence** — the token is kept but the outer fence is + removed before storage, so a fenced mention is not verbatim. And a + reply that is *only* a fenced token is still silence: fencing does + not un-silence a sentinel-only reply, so never fence the token to + demonstrate it. So: to stay silent, send \`NO_REPLY\` and nothing else. Never append it to text you would mind publishing. From c8b81187266404aa4a83a95f963769c52c5b0a23 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:07:41 -0700 Subject: [PATCH 3/3] test(soul): pin the NO_REPLY passage against the sanitizer it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK-074. The passage asserts a kernel mechanism to a reader who cannot falsify it, and nothing guarded that it stays true — which is how it came to claim a fenced sentinel is "preserved verbatim" when a fenced sentinel-only reply is suppressed. Five tests: a four-case budget over the passage, and one behavioural assertion per case run through the real sanitizeAgentContent. Mutations, each reverted after: A collapse bullets 3+4 back to one -> budget red, behaviour green B total-match below the fence return -> case 4 red C strip backticks before total-match -> case 3 red Exclusion arm on B and C: chatNoise.test.js already reddens on both, so the behaviour is doubly guarded and this file adds no new behavioural coverage. Only A is caught here alone — the gap was never the mechanism, it was the cue describing it. Co-Authored-By: Claude Opus 5 --- .../services/soulSentinelContract.test.js | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 backend/__tests__/unit/services/soulSentinelContract.test.js diff --git a/backend/__tests__/unit/services/soulSentinelContract.test.js b/backend/__tests__/unit/services/soulSentinelContract.test.js new file mode 100644 index 0000000000..0d52d48e54 --- /dev/null +++ b/backend/__tests__/unit/services/soulSentinelContract.test.js @@ -0,0 +1,69 @@ +// TASK-074. The SOUL footer's "Use of NO_REPLY" passage asserts a kernel +// mechanism to a reader who cannot falsify it: an agent acts on the passage +// and has no view of `sanitizeAgentContent`. Nothing guarded that the passage +// stays true, so the sanitizer could change and the copy would go silently +// wrong — which is exactly how the passage came to be false before #1354 +// (it said a fenced sentinel is "preserved verbatim"; a fenced sentinel-only +// reply is suppressed, and a fence is stripped from storage either way). +// +// So these tests pin the BEHAVIOUR each documented case promises, not the +// wording that promises it. If the sanitizer's position rules move, this file +// reddens and names the passage that must move with it. +const fs = require('fs'); +const path = require('path'); +const AgentMessageService = require('../../../services/agentMessageService'); + +const PROVISIONER_SOURCE = path.join( + __dirname, '..', '..', '..', 'services', 'agentProvisionerServiceK8s.ts', +); + +// The passage ships as data inside a template literal, so reading the source +// here is reading the artifact, not asserting that a code path executes. +const readSentinelPassage = () => { + const source = fs.readFileSync(PROVISIONER_SOURCE, 'utf8'); + const start = source.indexOf('### Use of NO_REPLY'); + expect(start).toBeGreaterThan(-1); + const rest = source.slice(start + 1); + const end = rest.search(/\n###\s|\n`;/); + return rest.slice(0, end === -1 ? undefined : end); +}; + +const sanitize = (input) => AgentMessageService.sanitizeAgentContent(input); + +describe('SOUL footer — the NO_REPLY passage matches the sanitizer', () => { + it('documents exactly the cases the tests below cover', () => { + // A budget, not a copy assertion: adding a fifth case to the passage + // without a behavioural pin for it reddens here rather than shipping an + // unguarded promise. Rewording any existing bullet does not. + const bullets = readSentinelPassage() + .split('\n') + .filter((line) => line.startsWith('- **')); + expect(bullets).toHaveLength(4); + }); + + it('case 1 — a leading bare sentinel suppresses the WHOLE reply', () => { + expect(sanitize('NO_REPLY')).toBe(''); + expect(sanitize('NO_REPLY\nHere is the real answer.')).toBe(''); + }); + + it('case 2 — a bare sentinel anywhere else is stripped and the rest POSTS', () => { + expect(sanitize('Shipped the fix.\nNO_REPLY')).toBe('Shipped the fix.'); + expect(sanitize('Reply with NO_REPLY when done.')).toBe('Reply with when done.'); + }); + + it('case 3 — backticks keep the token, INCLUDING as the entire reply', () => { + expect(sanitize('Reply with `NO_REPLY` when done.')) + .toBe('Reply with `NO_REPLY` when done.'); + // The half that makes case 3 and case 4 different bullets rather than one: + // an inline-backticked sentinel-only reply posts that literal. + expect(sanitize('`NO_REPLY`')).toBe('`NO_REPLY`'); + expect(sanitize('``NO_REPLY``')).toBe('``NO_REPLY``'); + }); + + it('case 4 — a fence keeps the token, drops the fence, and stays silent alone', () => { + expect(sanitize('```text\nNO_REPLY is discussed here.\n```')) + .toBe('NO_REPLY is discussed here.'); + expect(sanitize('```text\nNO_REPLY\n```')).toBe(''); + expect(sanitize('```\nNO_REPLY\n```')).toBe(''); + }); +});