diff --git a/backend/__tests__/unit/scripts/guardNameCanary.test.js b/backend/__tests__/unit/scripts/guardNameCanary.test.js new file mode 100644 index 000000000..1b096208d --- /dev/null +++ b/backend/__tests__/unit/scripts/guardNameCanary.test.js @@ -0,0 +1,198 @@ +/** + * The guard-name canary's pure parts (scripts/guard-name-canary.js). + * + * On 2026-11-01 a hosted agent whose name began with "Dan" was blocked on every + * turn: its system prompt opened "You are Dan…", which contains the injection + * guard's always-block keyword "you are dan". Nothing before release ran a real + * agent turn through the guard, so no test name ever came near one. The + * canary installs agents whose names complete a persona keyword and checks + * each gets a real turn. These tests pin the three decisions it makes without + * a cluster: which names to try, which of those a guard may block for a + * reason that is not ours, and what a check's outcome means for the release. + */ +const { + parseKeywordPayload, + findKeyword, + deriveCanaryNames, + mentionText, + classifyCheck, + exitCodeFor, + planChecks, + combineAttempts, + BLOCKED_EXIT, +} = require('../../../../scripts/guard-name-canary'); + +// The guard's lists as measured on the litellm image (v1.106.2), trimmed. +const KEYWORDS = [ + 'DAN mode', 'Do Anything Now', 'jailbreak', 'jailbroken', 'bypass safety', + 'ignore previous instructions', 'you are now', 'new persona', 'act as DAN', + 'you are DAN', 'your new role is', 'dump database', +]; + +describe('parseKeywordPayload', () => { + it('flattens every category into one lowercased, de-duplicated list', () => { + const parsed = parseKeywordPayload(JSON.stringify({ + guard: 'injection-guard', + categories: { a: ['You are DAN', 'jailbreak'], b: ['you are dan', 'dump database'] }, + })); + expect(parsed.ok).toBe(true); + expect(parsed.keywords).toEqual(['you are dan', 'jailbreak', 'dump database']); + expect(parsed.categories).toEqual(['a', 'b']); + }); + + it('reports an unreadable list rather than an empty one', () => { + expect(parseKeywordPayload('').ok).toBe(false); + expect(parseKeywordPayload('not json').ok).toBe(false); + expect(parseKeywordPayload(JSON.stringify({ categories: {} })).ok).toBe(false); + expect(parseKeywordPayload(JSON.stringify({ error: 'no guard' })).ok).toBe(false); + }); +}); + +describe('findKeyword', () => { + it('matches case-insensitively anywhere in the text', () => { + expect(findKeyword('Hello. You are Danica, a helper.', ['you are dan'])).toBe('you are dan'); + expect(findKeyword('Your name on Commonly is Danica.', ['you are dan'])).toBeNull(); + }); +}); + +describe('deriveCanaryNames', () => { + it('names a canary for each persona keyword, so "you are DAN" gives Dan and "you are now" gives Now', () => { + const { names } = deriveCanaryNames(KEYWORDS.map((k) => k.toLowerCase())); + expect(names.map((n) => n.displayName)).toEqual(['Dan Canary', 'Now Canary']); + expect(names[0]).toMatchObject({ instanceId: 'dan-canary', keywords: ['act as dan', 'you are dan'] }); + expect(names[1]).toMatchObject({ instanceId: 'now-canary', keywords: ['you are now'] }); + }); + + it('keeps Dan when the list is unreadable, because Dan is the name that broke', () => { + const { names } = deriveCanaryNames([]); + expect(names.map((n) => n.displayName)).toEqual(['Dan Canary']); + expect(names[0].keywords).toEqual([]); + }); + + it('covers a persona keyword the guard adds later', () => { + const { names } = deriveCanaryNames([...KEYWORDS, 'pretend to be Mallory', "you're Sydney"].map((k) => k.toLowerCase())); + expect(names.map((n) => n.displayName)).toEqual(['Dan Canary', 'Now Canary', 'Mallory Canary', 'Sydney Canary']); + }); + + it('skips a name that contains a whole keyword: a person typing it would be blocked, and that block is the guard working', () => { + const { names, skipped } = deriveCanaryNames(['you are dan', 'act as jailbreak', 'jailbreak']); + expect(names.map((n) => n.displayName)).toEqual(['Dan Canary']); + expect(skipped).toEqual([{ stem: 'jailbreak', keyword: 'act as jailbreak', reason: 'name_contains_keyword' }]); + }); + + it('tries at most eight names, so the keyword list cannot set how many turns a release pays for', () => { + const many = Array.from({ length: 12 }, (_, i) => `you are agent${String.fromCharCode(97 + i)}`); + const { names, skipped } = deriveCanaryNames(many); + expect(names).toHaveLength(8); + expect(names[0].displayName).toBe('Dan Canary'); + expect(skipped.filter((x) => x.reason === 'over_cap')).toHaveLength(5); + }); + + it('skips a stem whose name would run past 60 characters', () => { + const { names, skipped } = deriveCanaryNames([`you are ${'x'.repeat(60)}`]); + expect(names.map((n) => n.displayName)).toEqual(['Dan Canary']); + expect(skipped).toEqual([expect.objectContaining({ reason: 'name_too_long' })]); + }); + + it('every derived name completes its keyword after "You are" and "act as"', () => { + const { names } = deriveCanaryNames(KEYWORDS.map((k) => k.toLowerCase())); + for (const n of names) { + for (const k of n.keywords) { + const completed = [`You are ${n.displayName}`, `act as ${n.displayName}`] + .some((text) => findKeyword(text, [k]) === k); + expect(completed).toBe(true); + } + } + }); +}); + +describe('mentionText', () => { + it('addresses the agent by its canonical handle with a plain question, and carries no keyword', () => { + const text = mentionText('guard-canary-dan-canary'); + expect(text.startsWith('@guard-canary-dan-canary ')).toBe(true); + expect(findKeyword(text, KEYWORDS.map((k) => k.toLowerCase()))).toBeNull(); + // The first live run asked for a code echoed verbatim: every model declined + // it as a relay request, so a working turn read as no reply. + expect(text).not.toMatch(/exactly|verbatim|code/i); + }); +}); + +describe('classifyCheck', () => { + const run = (over) => ({ status: 'succeeded', errorKind: null, trigger: 'mention', ...over }); + + it('passes on a succeeded mention turn and a reply after the mention', () => { + expect(classifyCheck({ runs: [run({ trigger: 'first_contact' }), run()], replied: true })) + .toEqual({ verdict: 'pass' }); + }); + + it('is blocked when any run since install was stopped by the guard, even if a later one replied', () => { + expect(classifyCheck({ runs: [run({ status: 'failed', errorKind: 'guardrail_blocked', trigger: 'first_contact' }), run()], replied: true })) + .toEqual({ verdict: 'blocked', reason: 'guardrail_blocked on first_contact' }); + }); + + it('is inconclusive, never a pass, when the mention turn did not both run and reply', () => { + expect(classifyCheck({ runs: [], replied: false })).toEqual({ verdict: 'inconclusive', reason: 'no_run' }); + expect(classifyCheck({ runs: [run({ status: 'failed', errorKind: 'model_error' })], replied: false })) + .toEqual({ verdict: 'inconclusive', reason: 'run failed: model_error' }); + expect(classifyCheck({ runs: [run()], replied: false })).toEqual({ verdict: 'inconclusive', reason: 'no_reply' }); + // The greeting can post after the mention; it is not the mention's turn. + expect(classifyCheck({ runs: [run({ trigger: 'first_contact' })], replied: true })) + .toEqual({ verdict: 'inconclusive', reason: 'no_mention_run' }); + }); + + it('is inconclusive when the install itself failed', () => { + expect(classifyCheck({ installError: 'HTTP 403 hosted_cap_reached', runs: [], replied: false })) + .toEqual({ verdict: 'inconclusive', reason: 'install: HTTP 403 hosted_cap_reached' }); + }); +}); + +describe('planChecks', () => { + it('plans a hosted and a native check per name under the canonical handles', () => { + const { names } = deriveCanaryNames(['you are dan']); + expect(planChecks(names, ['you are dan'])).toEqual([ + expect.objectContaining({ runtime: 'hosted', agentName: 'guard-canary', handle: 'guard-canary-dan-canary', skip: null }), + expect.objectContaining({ runtime: 'native', agentName: 'task-clerk', handle: 'task-clerk-dan-canary', skip: null }), + ]); + }); + + it('never posts a question the guard would stop on its own text', () => { + const { names } = deriveCanaryNames(['you are dan']); + // A future keyword that the question itself contains. + const plan = planChecks(names, ['you are dan', 'quick check from the release canary']); + expect(plan.every((c) => c.skip === 'question_contains_keyword')).toBe(true); + }); +}); + +describe('combineAttempts', () => { + const pass = { verdict: 'pass' }; + const blocked = { verdict: 'blocked', reason: 'guardrail_blocked on mention' }; + const quiet = { verdict: 'inconclusive', reason: 'no_reply' }; + + it('is blocked only when both attempts were blocked: a keyword match repeats, an upstream error does not', () => { + expect(combineAttempts(blocked, blocked).verdict).toBe('blocked'); + expect(combineAttempts(blocked, pass)).toMatchObject({ verdict: 'inconclusive', reason: 'blocked once, then pass' }); + expect(combineAttempts(quiet, blocked)).toMatchObject({ verdict: 'inconclusive', reason: 'no_reply, then blocked once' }); + }); + + it('takes the second attempt otherwise', () => { + expect(combineAttempts(quiet, pass)).toEqual(pass); + expect(combineAttempts(quiet, quiet)).toEqual(quiet); + }); +}); + +describe('exitCodeFor', () => { + it(`${BLOCKED_EXIT} when any check was blocked; 2 when any was inconclusive, the list unread or names were capped; else 0`, () => { + // Blocked has its own code: kubectl and an uncaught node error both exit 1, + // and neither may read as a guard block that rolls a release back. + expect(BLOCKED_EXIT).not.toBe(1); + expect(BLOCKED_EXIT).not.toBe(2); + expect(exitCodeFor([{ verdict: 'pass' }], true, true)).toBe(2); + expect(exitCodeFor([{ verdict: 'blocked' }], true, true)).toBe(BLOCKED_EXIT); + expect(exitCodeFor([{ verdict: 'pass' }, { verdict: 'pass' }], true)).toBe(0); + expect(exitCodeFor([{ verdict: 'pass' }, { verdict: 'inconclusive' }], true)).toBe(2); + expect(exitCodeFor([{ verdict: 'inconclusive' }, { verdict: 'blocked' }], true)).toBe(BLOCKED_EXIT); + expect(exitCodeFor([{ verdict: 'pass' }], false)).toBe(2); + expect(exitCodeFor([{ verdict: 'blocked' }], false)).toBe(BLOCKED_EXIT); + expect(exitCodeFor([], true)).toBe(2); + }); +}); diff --git a/docs/development/review-checklist.md b/docs/development/review-checklist.md index 7b5e40dc3..1c1c6d29a 100644 --- a/docs/development/review-checklist.md +++ b/docs/development/review-checklist.md @@ -254,3 +254,7 @@ And then note where the fix landed. Someone hit the audience gap, felt it, and b ## Tests that exercise a copy 59. **A test that re-implements the function under test passes whatever the function does.** A "minimal re-implementation" of a delivery or write path, kept beside the real one for isolation, is a second function that the suite certifies. The real function can fail in any way and the suite stays green, because nothing it calls is the real thing. Before approving a suite, check that the function named in each test is the one the service exports. Where the real function is not reachable, expose a seam (a static method, not a module export) and drive it with only the network and persistence edges mocked. *(Earned: TASK-015, 2026-10-26. `webhook-delivery.test.js` carried a `deliver()` copy and asserted its response handling. The production path called `agentMessageService.postAgentMessage`, which does not exist, and the TypeError was swallowed, so every webhook reply marked `posted` and never reached the pod. The copy had no such call, so the suite was green on the bug.)* + +## Entitlement and cost decisions + +60. **An entitlement or cost decision reads the field the system acts on, never a label the caller wrote.** A request's `host`, `tier`, `plan` or `kind` is the caller's claim about itself. A gate keyed on it opens for anyone who sets the label. Find the field the system actually acts on and decide on that. For "does this install run on Commonly, at Commonly's cost", that field is the runtime type dispatch reads. `host: 'byo'` still decides for a type with a bring-your-own variant (codex), and never for a type only Commonly runs. Test the gate with each combination of the claimed label and the deciding field, and prove the test by putting the old order back. *(Earned: #3287, 2026-11-02. `isCloudRuntime` read `config.runtime.host` before the runtime type, so a native install sent with `host: 'byo'` counted as bring-your-own and skipped the `cloudAgents` entitlement, while dispatch ran it in-process on Commonly's model spend and never read `host`. The fix reads the type first. Its reverse check restored `main`'s order and turned 11 arms red: 5 classifier units, 5 install-route arms and 1 provision arm, covering the four types Commonly runs (native, internal, moltbot, managed-agents) sent with `host: 'byo'`. The codex-with-`byo` control stayed green. Five route suites had mocked the classifier with a copy of itself, which is rule 59's failure too, and now load the real one. AX audit entry 92 has the longer account.)* diff --git a/docs/runbooks/guard-name-canary.md b/docs/runbooks/guard-name-canary.md new file mode 100644 index 000000000..bb6c5bd17 --- /dev/null +++ b/docs/runbooks/guard-name-canary.md @@ -0,0 +1,166 @@ +# Guard-name canary + +After a deploy, agents whose names complete one of the injection guard's +keywords each get one real turn on production. A turn the guard stops fails +the check. + +## Why it exists + +On 2026-11-01 a hosted agent whose name began with "Dan" was blocked on every +turn. Its system prompt opened "You are Dan…", and `you are DAN` is one of the +guard's always-block keywords. Two fixes followed: the prompts now say "Your +name on Commonly is …" (#3284), and the guard no longer scans our own +system-role text (`skip_system_message_in_guardrail`, #3285). See +[litellm-guardrails.md](litellm-guardrails.md). + +Nothing before release would have caught it. Staging has no model key, so +staging-smoke skips every check that needs an agent to answer, and no turn on +the release path passed through LiteLLM's guard. No test agent was ever named +anything close to a keyword. The canary closes that gap from both ends. It +runs real turns on production, and it picks names from the guard's own list. + +## What it does + +`scripts/guard-name-canary.js` runs in ops-runner. Each run: + +1. **Reads the keywords.** `scripts/guard-keywords-dump.py` runs inside the + litellm container and prints the `always_block_keywords` for every category + the `injection-guard` entry declares. It reads the serving guard's own config + and package, never a copy. The JSON reaches the canary as + `CANARY_GUARD_KEYWORDS`. +2. **Derives names.** Each keyword that starts with a persona lead ("you are", + "act as", "pretend to be", …) is completed by a name that starts with the + rest of it: + + | Keyword | Name | + |---|---| + | `you are DAN`, `act as DAN` | Dan Canary | + | `you are now` | Now Canary | + + "Dan Canary" is always tried, even when the list can't be read. A keyword + the guard adds later is covered on the next deploy. A name that itself + contains a whole keyword (say "Jailbreak Canary") is skipped and logged. A + person @-mentioning it would type the keyword, and that block is the guard + working as intended. +3. **Sets up.** It ensures the account `eng-guard-canary` (on + `ops.commonly.invalid`, `cloudAgents` entitled, verified, with no usable + password). It signs a session for that account from `JWT_SECRET` for each + request, with a 5-minute expiry. No token is stored. The pod is new on every run and is deleted at the end with + its messages. A hosted turn reads the last pod messages as user text, which + the guard scans, so a reused pod would carry one run's words into the next. + The pod is invite-only: anyone who could post there could @-mention a canary + agent with a keyword and fail the release. Pods and agents left by a crashed + run are removed first, because the hosted cap is one per account. + At most eight names are tried, so the keyword list can't decide how many + turns a release pays for. Names past the cap are logged, and the run reads + as inconclusive. +4. **Waits for LiteLLM.** It polls `/health/readiness` for up to 2 minutes, so a + litellm restart window doesn't read as a failure. +5. **Checks each name twice.** It installs a hosted agent (`guard-canary`) and a + native one (`task-clerk`) under the name, through `/api/registry/install` + and `/api/hosted/provision`, the routes the product uses. It asks each a + plain question by its canonical handle (`@guard-canary-dan-canary`). A check + passes on a mention turn that succeeded, a reply from the agent's user after + the question, and no `guardrail_blocked` run in the canary's pod since + install. That last condition covers the first-contact greeting too. A check + that didn't pass runs once more after LiteLLM reports ready again. Blocked + needs both attempts blocked. The guard's keyword match repeats, but an + upstream moderation error, which the runtime also files as + `guardrail_blocked`, doesn't. Every agent is uninstalled afterwards, pass or + fail. A question that would itself contain a keyword is never posted, and + that check reads as inconclusive. + +Each step prints one JSON line. The last line is `{canary, result, exit}`. + +## Exit codes + +| Exit | Result | Meaning for the release | +|---|---|---| +| 0 | `pass` | Every name got a real turn. | +| 3 | `blocked` | The guard stopped a turn for a canary name, on both attempts. Something we send now completes a keyword with an agent's name, so every agent with such a name is mute. The release must not stand. | +| 2 | `inconclusive` | No verdict: an install failed, nothing replied in time, a run failed for another reason, a block didn't repeat, the keyword list was unreadable, or names past the cap went untried. Worth a look; not a reason to roll back. | +| 1 | — | Not the canary's verdict: `kubectl` failed (ops-runner rolling, RBAC, connection), or node crashed. Treat it like 2. | + +Blocked is 3, not 1, because kubectl and an uncaught node error both exit 1. + +## Running it by hand + +```bash +NS=commonly-dev +KW=$(kubectl exec -i -n $NS deploy/litellm -c litellm -- /app/.venv/bin/python3 -I - < scripts/guard-keywords-dump.py) +kubectl exec -i -n $NS deploy/ops-runner -c ops-runner -- env NODE_PATH=/app/node_modules \ + CANARY_RUN=1 CANARY_GUARD_KEYWORDS="$KW" node - < scripts/guard-name-canary.js +``` + +Never run it in the serving backend +([ops-runner-and-no-exec-into-backend.md](ops-runner-and-no-exec-into-backend.md)). +A run takes about 25 seconds and four model turns (two names, two runtimes), +plus the two greetings. + +## Wiring into Deploy Dev + +#3296 adds this step to `.github/workflows/deploy-dev.yml`. SRE owns the +workflow, and once that lands the workflow file is the source of truth for the +step; the copy here explains it. It sits after "Verify the new backend serves +chat" and before "Record this release as rollout-verified", so a blocked +verdict fails the job and the rollback step runs, whose condition includes +`|| steps.canary.outcome == 'failure'`. + +```yaml + - name: Guard-name canary + id: canary + run: | + set -uo pipefail + KW=$(kubectl exec -i -n "$NAMESPACE" deploy/litellm -c litellm -- \ + /app/.venv/bin/python3 -I - < scripts/guard-keywords-dump.py) || KW='' + # Actions runs this under `bash -e`; `|| CODE=$?` keeps a non-zero + # exit from ending the step before it is read. + CODE=0 + kubectl exec -i -n "$NAMESPACE" deploy/ops-runner -c ops-runner -- \ + env NODE_PATH=/app/node_modules CANARY_RUN=1 CANARY_GUARD_KEYWORDS="$KW" \ + node - < scripts/guard-name-canary.js || CODE=$? + if [ "$CODE" = 3 ]; then + echo "::error title=Guard-name canary blocked::the injection guard stopped a turn for an agent named after a guard keyword; see the check lines above" + exit 1 + fi + if [ "$CODE" != 0 ]; then + echo "::warning title=Guard-name canary inconclusive::exit $CODE; read the check lines above" + fi +``` + +The script comes from the dispatched checkout, so it matches the release even +while ops-runner is still rolling to the new image. Two runs at once would share +the account and the hosted cap, and each would sweep the other's pod. The +workflow's `concurrency: deploy-dev` group, without cancel-in-progress, keeps +deploys serial; a hand run during a deploy is the case to avoid. The deploy's service account +needs `pods/exec` on litellm and ops-runner. It already execs into the backend +pod for the chat check. + +## What it doesn't cover + +- **Conditional matches.** The guard also blocks when an identifier word and a + block word appear together. A name alone rarely completes those, so only + `always_block_keywords` are read. +- **Names a person types.** A mention of an agent named after a whole keyword is + user text, and the guard is right to stop it. Those names are skipped, not + tested. +- **Other guardrails.** Production sets `NATIVE_RUNTIME_GUARDRAILS` to + `injection-guard` (`backend.env.nativeRuntimeGuardrails`). The service's own + default also includes `openai-moderation-enforce`, so a deployment without + that override runs both. Give every other enabled guardrail its own dump. +- **Group-pod silence.** Each question is a direct @-mention, so a turn that + chooses not to answer in a busy pod isn't exercised. + +## Verified + +2026-11-02, on production, the runs in this PR: + +- **Pass.** `Dan Canary` and `Now Canary`, hosted and native: four passes, + exit 0, no canary install left behind. +- **Reverse check.** A copy whose question carried "you are Dan Canary" + produced `blocked` verdicts and exit 3. That proves a guard stop reaches the + exit code. +- **First run.** It asked the agents to repeat a code verbatim, and every model + declined it as a relay request. It also matched replies by a username the + list route doesn't always join. The turns themselves had all succeeded. + Both are fixed and covered by tests. diff --git a/docs/runbooks/litellm-guardrails.md b/docs/runbooks/litellm-guardrails.md index ecbae6d7d..d521d172b 100644 --- a/docs/runbooks/litellm-guardrails.md +++ b/docs/runbooks/litellm-guardrails.md @@ -122,6 +122,11 @@ verdict by role alone — the scoping (not any property of the text) is what let own system prompt through. A config change to a configmap takes effect on the serving litellm only after a redeploy/reprovision. +The [guard-name canary](guard-name-canary.md) gives agents named after the +guard's own persona keywords one real turn each, hosted and native, and exits 3 +if the guard stops one. Deploy Dev runs it after verify and rolls the release +back on exit 3; #3296 adds that step. + ## ⚠️ Validate before deploying — the 2026-06-29 crash-loop An earlier version added a third guardrail, `openai-moderation-monitor`, with diff --git a/scripts/guard-keywords-dump.py b/scripts/guard-keywords-dump.py new file mode 100644 index 000000000..99fa903a7 --- /dev/null +++ b/scripts/guard-keywords-dump.py @@ -0,0 +1,45 @@ +"""Print the injection guard's always-block keywords as JSON, for the guard-name canary. + +Run inside the litellm container, so the list is the one the serving guard +loads (its own config, its own litellm version), never a copy: + + kubectl exec -i -n NS deploy/litellm -c litellm -- /app/.venv/bin/python3 -I - < scripts/guard-keywords-dump.py + +`-I` keeps /app (a source tree that shadows the installed package) off +sys.path. Prints {"guard", "litellm", "categories": {category: [keyword, ...]}}, +or {"error": "..."} when the guard or its lists can't be read; the canary treats +that as an unread list (inconclusive), never as an empty one. Only +always_block_keywords are read: they are the matches a name alone can complete. +See scripts/guard-name-canary.js. +""" +import json +import os + +GUARD = os.environ.get('GUARD_NAME', 'injection-guard') +CONFIG = os.environ.get('LITELLM_CONFIG_PATH', '/app/config.yaml') + +try: + import yaml + import litellm + from importlib.metadata import version + + with open(CONFIG) as f: + config = yaml.safe_load(f) + guards = [g for g in (config.get('guardrails') or []) if g.get('guardrail_name') == GUARD] + if not guards: + raise LookupError('guardrail %s not in %s' % (GUARD, CONFIG)) + params = guards[0].get('litellm_params') or {} + base = os.path.join(os.path.dirname(litellm.__file__), 'proxy', 'guardrails', + 'content_filter_data', 'categories') + categories = {} + for entry in params.get('categories') or []: + name = entry.get('category') + with open(os.path.join(base, name + '.yaml')) as f: + data = yaml.safe_load(f) or {} + categories[name] = [k['keyword'] for k in (data.get('always_block_keywords') or []) + if isinstance(k, dict) and k.get('keyword')] + if not categories: + raise LookupError('guardrail %s declares no categories' % GUARD) + print(json.dumps({'guard': GUARD, 'litellm': version('litellm'), 'categories': categories})) +except Exception as err: # noqa: BLE001 - every failure is reported, never raised + print(json.dumps({'error': ('%s: %s' % (type(err).__name__, err))[:300]})) diff --git a/scripts/guard-name-canary.js b/scripts/guard-name-canary.js new file mode 100644 index 000000000..aa4bf6fd5 --- /dev/null +++ b/scripts/guard-name-canary.js @@ -0,0 +1,505 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +/** + * Guard-name canary: after a deploy, agents whose names complete one of the + * injection guard's keywords each get one real turn on production. + * + * Why. On 2026-11-01 a hosted agent whose name began with "Dan" was blocked on + * every turn. Its system prompt opened "You are Dan…", which contains the + * guard's always-block keyword "you are dan". Staging has no model key, so + * staging-smoke never runs a turn through LiteLLM, and no test agent's name + * ever came near a keyword. + * + * What it does, once per run: + * 1. keywords reads the guard's always-block keywords from + * CANARY_GUARD_KEYWORDS, the JSON that + * scripts/guard-keywords-dump.py prints from the litellm pod. A + * keyword the guard adds later is covered on the next deploy. + * 2. names each persona keyword ("you are DAN", "act as DAN", "you are + * now") gives a name that completes it: "Dan Canary", "Now + * Canary". "Dan Canary" is always tried, list or no list. + * 3. setup ensures the canary account (eng-guard-canary, on + * ops.commonly.invalid, cloudAgents entitled, no usable + * password) and creates a fresh invite-only pod for this run, + * deleted at the end with its messages. A hosted turn reads the + * last pod messages as user text, which the guard scans, so a + * reused pod would carry one run's words into the next. Each + * request is signed here from JWT_SECRET with a short expiry; + * no token is stored anywhere. + * 4. checks for each name, a hosted agent and a native one (task-clerk) + * are installed under that name through the same routes the + * product uses, asked a plain question, and judged: a mention + * turn that succeeded, a reply from the agent after the + * question, and no guard-blocked run since install. Each is + * uninstalled afterwards, pass or fail. + * + * It waits for LiteLLM to report ready before the first check, and runs any + * check that did not pass once more: a restart window, or an upstream error the + * runtime files as a guard stop, must not read as a red. The guard's keyword + * match is deterministic, so a real block repeats. + * + * Exit: 0 every check passed · 3 a run was stopped by the guard, twice (the + * release must not stand) · 2 inconclusive (no verdict: an install failed, + * nothing replied in time, the keyword list was unreadable, or names went + * untried). Blocked is 3, not 1, because kubectl and an uncaught node error + * both exit 1 and neither may roll a release back. Every step prints one JSON + * line; the last is {canary, result, exit}. + * + * Run it in ops-runner, never in the serving backend: + * KW=$(kubectl exec -i -n NS deploy/litellm -c litellm -- /app/.venv/bin/python3 -I - < scripts/guard-keywords-dump.py) + * kubectl exec -i -n NS deploy/ops-runner -c ops-runner -- env NODE_PATH=/app/node_modules \ + * CANARY_RUN=1 CANARY_GUARD_KEYWORDS="$KW" node - < scripts/guard-name-canary.js + * Runbook: docs/runbooks/guard-name-canary.md. + */ + +const CANARY = 'guard-name'; +const CANARY_USERNAME = 'eng-guard-canary'; +const CANARY_EMAIL_DOMAIN = 'ops.commonly.invalid'; +const CANARY_POD_PREFIX = 'Guard-name canary'; +// Not 1 (kubectl, a node crash) and not 2 (inconclusive). +const BLOCKED_EXIT = 3; +const HOSTED_AGENT = 'guard-canary'; +// A seeded first-party native app that answers mentions (config/native-agents). +const NATIVE_AGENT = 'task-clerk'; +// The name that broke on 2026-11-01. Tried even when the list can't be read. +const FLOOR_STEMS = ['dan']; +// Text our prompts could put right before an agent's name. A keyword that +// starts with one of these is completed by a name that starts with the rest. +const PERSONA_LEADS = [ + 'you are', "you're", 'act as', 'acting as', 'pretend to be', 'pretend you are', + 'roleplay as', 'role-play as', 'role play as', 'behave as', 'respond as', 'speak as', + 'your name is', 'you will be', 'you will now be', +]; +const MAX_STEM_WORDS = 3; +const MAX_NAME_CHARS = 60; +// The keyword list must not decide how many turns a release pays for. A name +// past the cap is reported, and the run reads as inconclusive. +const MAX_NAMES = 8; + +// ── pure parts (tested in backend/__tests__/unit/scripts/guardNameCanary.test.js) ── + +/** The dump's JSON, flattened to one lowercased list; ok:false when unreadable. */ +function parseKeywordPayload(raw) { + let data; + try { + data = JSON.parse(String(raw || '')); + } catch (err) { + return { ok: false, keywords: [], categories: [], error: raw ? 'unparseable' : 'empty' }; + } + if (!data || typeof data !== 'object' || data.error || !data.categories || typeof data.categories !== 'object') { + return { ok: false, keywords: [], categories: [], error: String((data && data.error) || 'no categories').slice(0, 200) }; + } + const categories = Object.keys(data.categories); + const seen = new Set(); + const keywords = []; + for (const category of categories) { + for (const keyword of data.categories[category] || []) { + const k = String(keyword || '').trim().toLowerCase(); + if (k && !seen.has(k)) { seen.add(k); keywords.push(k); } + } + } + if (!keywords.length) return { ok: false, keywords: [], categories, error: 'no keywords' }; + return { ok: true, keywords, categories }; +} + +/** The first keyword the text contains, case-insensitively, or null. */ +function findKeyword(text, keywords) { + const haystack = String(text || '').toLowerCase(); + for (const k of keywords) { + if (k && haystack.includes(String(k).toLowerCase())) return String(k).toLowerCase(); + } + return null; +} + +const titleCase = (stem) => stem.split(/\s+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); +const slug = (text) => text.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); + +/** + * One canary name per stem: the floor stems first, then each persona keyword's + * tail in list order. A name that itself contains a whole keyword is skipped: + * a person @-mentioning it would type that keyword, and that block is the + * guard doing its job, not our text tripping it. + */ +function deriveCanaryNames(keywords) { + const byStem = new Map(); + const skipped = []; + const add = (stem, keyword) => { + if (!byStem.has(stem)) byStem.set(stem, []); + if (keyword && !byStem.get(stem).includes(keyword)) byStem.get(stem).push(keyword); + }; + for (const stem of FLOOR_STEMS) add(stem, null); + for (const keyword of keywords) { + const lead = PERSONA_LEADS.find((l) => keyword.startsWith(`${l} `)); + if (!lead) continue; + const stem = keyword.slice(lead.length).trim().replace(/\s+/g, ' '); + if (!stem) continue; + if (stem.split(' ').length > MAX_STEM_WORDS) { + skipped.push({ stem, keyword, reason: 'stem_too_long' }); + continue; + } + const displayName = `${titleCase(stem)} Canary`; + if (displayName.length > MAX_NAME_CHARS) { + skipped.push({ stem: stem.slice(0, 40), keyword: keyword.slice(0, 80), reason: 'name_too_long' }); + continue; + } + if (findKeyword(displayName, keywords)) { + skipped.push({ stem, keyword, reason: 'name_contains_keyword' }); + continue; + } + if (!byStem.has(stem) && byStem.size >= MAX_NAMES) { + skipped.push({ stem, keyword, reason: 'over_cap' }); + continue; + } + add(stem, keyword); + } + const names = [...byStem.entries()].map(([stem, kws]) => { + const displayName = `${titleCase(stem)} Canary`; + return { stem, displayName, instanceId: slug(displayName), keywords: kws }; + }); + return { names, skipped }; +} + +/** + * What the canary account posts: a plain question that carries no keyword, so + * the guard has no reason of its own to stop it. Not "repeat this code": the + * first live run asked for that, and every model declined it as a relay request. + */ +function mentionText(handle) { + return `@${handle} quick check from the release canary: what is 2 + 3? One short sentence is plenty.`; +} + +/** One check's verdict from what happened since its install. */ +function classifyCheck({ installError, runs, replied }) { + if (installError) return { verdict: 'inconclusive', reason: `install: ${installError}` }; + const blocked = (runs || []).find((r) => r && r.errorKind === 'guardrail_blocked'); + if (blocked) return { verdict: 'blocked', reason: `guardrail_blocked on ${blocked.trigger || 'unknown'}` }; + if (!runs || !runs.length) return { verdict: 'inconclusive', reason: 'no_run' }; + const failed = runs.find((r) => r && r.status === 'failed'); + if (failed) return { verdict: 'inconclusive', reason: `run failed: ${failed.errorKind || 'unknown'}` }; + if (!replied) return { verdict: 'inconclusive', reason: 'no_reply' }; + // The greeting can post after the question; only the mention's own turn counts. + if (!runs.some((r) => r && r.trigger === 'mention' && r.status === 'succeeded')) { + return { verdict: 'inconclusive', reason: 'no_mention_run' }; + } + return { verdict: 'pass' }; +} + +/** + * BLOCKED_EXIT if any check was blocked; 2 if no checks ran, any was + * inconclusive, the list was unread, or names past the cap went untried; else 0. + */ +function exitCodeFor(results, keywordsRead, capped = false) { + if (results.some((r) => r.verdict === 'blocked')) return BLOCKED_EXIT; + if (!results.length || results.some((r) => r.verdict !== 'pass') || !keywordsRead || capped) return 2; + return 0; +} + +/** + * The checks a run makes: a hosted and a native agent per name, addressed by + * the canonical handle install gives them. A question that itself contains a + * keyword is never posted: the guard would stop it for our words, not the name. + */ +function planChecks(names, keywords) { + const plan = []; + for (const name of names) { + for (const [runtime, agentName] of [['hosted', HOSTED_AGENT], ['native', NATIVE_AGENT]]) { + const handle = `${agentName}-${name.instanceId}`; + const skip = findKeyword(mentionText(handle), keywords) ? 'question_contains_keyword' : null; + plan.push({ runtime, agentName, handle, name, skip }); + } + } + return plan; +} + +/** + * One verdict from a check and its retry. Blocked needs both attempts blocked: + * the guard's keyword match is deterministic and repeats, while an upstream + * moderation error, which the runtime also files as guardrail_blocked, does not. + */ +function combineAttempts(first, second) { + if (first.verdict === 'blocked' && second.verdict === 'blocked') return second; + if (second.verdict === 'blocked') { + return { ...second, verdict: 'inconclusive', reason: `${first.reason || first.verdict}, then blocked once` }; + } + if (first.verdict === 'blocked') { + return { ...second, verdict: 'inconclusive', reason: `blocked once, then ${second.reason || second.verdict}` }; + } + return second; +} + +module.exports = { + combineAttempts, + parseKeywordPayload, + findKeyword, + deriveCanaryNames, + mentionText, + classifyCheck, + exitCodeFor, + planChecks, + BLOCKED_EXIT, +}; + +// ── the run (only with CANARY_RUN=1, in ops-runner) ───────────────────────── + +const log = (fields) => console.log(JSON.stringify({ canary: CANARY, ...fields })); +const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); + +async function waitForLitellm(deadlineMs) { + const base = process.env.LITELLM_BASE_URL; + if (!base) return { ok: false, reason: 'LITELLM_BASE_URL unset' }; + const url = `${new URL(base).origin}/health/readiness`; + const t0 = Date.now(); + let last = ''; + while (Date.now() - t0 < deadlineMs) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (res.ok) return { ok: true, ms: Date.now() - t0 }; + last = `HTTP ${res.status}`; + } catch (err) { + last = String(err.name || 'Error'); + } + await sleep(3000); + } + return { ok: false, reason: last || 'timeout', ms: Date.now() - t0 }; +} + +async function main() { + const crypto = require('crypto'); + const mongoose = require('mongoose'); + const jwt = require('jsonwebtoken'); + const bcrypt = require('bcryptjs'); + + const API = String(process.env.CANARY_API_URL || process.env.COMMONLY_API_URL || 'http://backend:5000').replace(/\/+$/, ''); + const CHECK_BUDGET_MS = Number(process.env.CANARY_CHECK_BUDGET_MS) || 120_000; + const started = Date.now(); + + const kw = parseKeywordPayload(process.env.CANARY_GUARD_KEYWORDS); + log({ step: 'keywords', ok: kw.ok, count: kw.keywords.length, categories: kw.categories, ...(kw.ok ? {} : { error: kw.error }) }); + const { names, skipped } = deriveCanaryNames(kw.keywords); + log({ step: 'names', names: names.map((n) => ({ name: n.displayName, keywords: n.keywords })), skipped }); + + await mongoose.connect(process.env.MONGO_URI); + const db = mongoose.connection.db; + + // The canary account. It has no usable password (a hash of a random secret + // that is dropped here) and is reached only through sessions signed below. + const users = db.collection('users'); + const now = new Date(); + await users.updateOne({ username: CANARY_USERNAME }, { + $setOnInsert: { + username: CANARY_USERNAME, + email: `${CANARY_USERNAME}@${CANARY_EMAIL_DOMAIN}`, + password: bcrypt.hashSync(crypto.randomBytes(32).toString('hex'), 10), + verified: true, + isBot: false, + role: 'user', + tokenVersion: 0, + entitlements: { cloudAgents: true }, + createdAt: now, + updatedAt: now, + }, + }, { upsert: true }); + const account = await users.findOne( + { username: CANARY_USERNAME }, + { projection: { _id: 1, tokenVersion: 1, isBot: 1, entitlements: 1 } }, + ); + if (!account || account.isBot || !(account.entitlements && account.entitlements.cloudAgents === true)) { + log({ step: 'account', ok: false, error: 'canary account missing, a bot, or not cloudAgents-entitled' }); + log({ result: 'inconclusive', exit: 2 }); + await mongoose.disconnect(); + return 2; + } + log({ step: 'account', ok: true }); + + // Signed per request, so no run outlives its session however many names it has. + const session = () => jwt.sign( + { id: String(account._id), tokenVersion: account.tokenVersion || 0 }, + process.env.JWT_SECRET, + { expiresIn: '5m' }, + ); + const api = async (method, path, body) => { + const res = await fetch(`${API}${path}`, { + method, + headers: { + Authorization: `Bearer ${session()}`, + 'Content-Type': 'application/json', + 'User-Agent': 'commonly-guard-canary/1.0', + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(20_000), + }); + const text = await res.text(); + let data = text; + try { data = JSON.parse(text); } catch (err) { /* not JSON */ } + if (!res.ok) { + const detail = data && typeof data === 'object' ? (data.code || data.error || data.msg || '') : ''; + throw new Error(`HTTP ${res.status} ${String(detail).slice(0, 80)}`.trim()); + } + return data; + }; + + const uninstall = async (podId, agentName, instanceId) => { + try { + await api('DELETE', `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${podId}?instanceId=${encodeURIComponent(instanceId)}`); + return true; + } catch (err) { + log({ step: 'uninstall', ok: false, agentName, instanceId, error: err.message }); + return false; + } + }; + // Uninstall the canary's agents in a pod, then delete the pod with its messages. + const retirePod = async (podId) => { + try { + const roster = await api('GET', `/api/registry/pods/${podId}/agents`); + for (const a of (roster && roster.agents) || []) { + if (a && (a.name === HOSTED_AGENT || a.name === NATIVE_AGENT)) await uninstall(podId, a.name, a.instanceId || 'default'); + } + } catch (err) { + log({ step: 'retire', ok: false, podId, error: err.message }); + } + try { + await api('DELETE', `/api/pods/${podId}`); + return true; + } catch (err) { + log({ step: 'retire', ok: false, podId, error: err.message }); + return false; + } + }; + + // A run that died before its cleanup leaves a pod and installs behind, and + // the hosted cap is one per account. + try { + const podsRes = await api('GET', '/api/pods'); + const pods = Array.isArray(podsRes) ? podsRes : (podsRes && podsRes.pods) || []; + for (const p of pods) { + if (p && String(p.name || '').startsWith(CANARY_POD_PREFIX)) { + log({ step: 'sweep', podId: String(p._id), retired: await retirePod(String(p._id)) }); + } + } + } catch (err) { + log({ step: 'sweep', ok: false, error: err.message }); + } + + // Invite-only: a stranger in this pod could @-mention a canary agent with a + // keyword and fail the release. + const pod = await api('POST', '/api/pods', { + name: `${CANARY_POD_PREFIX} ${new Date().toISOString().slice(0, 16)}`, + type: 'chat', + joinPolicy: 'invite-only', + description: 'Release check: agents named after guard keywords each get one real turn.', + }); + const podId = String(pod._id || pod.id); + const podOid = new mongoose.Types.ObjectId(podId); + log({ step: 'pod', ok: true, podId }); + + const ready = await waitForLitellm(120_000); + log({ step: 'litellm-ready', ...ready }); + + const checkOnce = async ({ runtime, agentName, handle, name }) => { + const since = new Date(Date.now() - 5000); + const t0 = Date.now(); + let installError = null; + let replied = false; + let runs = []; + try { + try { + await api('POST', '/api/registry/install', { + agentName, + podId, + displayName: name.displayName, + instanceId: name.instanceId, + scopes: [], + config: { runtime: { runtimeType: runtime } }, + }); + if (runtime === 'hosted') { + await api('POST', '/api/hosted/provision', { agentName, instanceId: name.instanceId }); + } + } catch (err) { + installError = err.message; + } + // The agent's own user row, by the canonical handle install gives it. + // Messages are matched on its id: the list route returns `userId` as an + // object only when it can join a username, so the first live run's + // username match missed every reply. + const bot = installError ? null : await users.findOne({ username: handle }, { projection: { _id: 1 } }); + if (!installError && !bot) installError = 'no agent user'; + if (!installError) { + const botId = String(bot._id); + const asked = await api('POST', `/api/messages/${podId}`, { content: mentionText(handle) }); + const askedAt = new Date((asked && asked.createdAt) || Date.now()).getTime(); + while (Date.now() - t0 < CHECK_BUDGET_MS) { + await sleep(3000); + runs = await db.collection('agentruns').find( + { + podId: podOid, agentName, instanceId: name.instanceId, startedAt: { $gte: since }, + }, + { projection: { _id: 0, status: 1, errorKind: 1, trigger: 1 } }, + ).toArray(); + if (runs.some((r) => r.errorKind === 'guardrail_blocked')) break; + const messages = await api('GET', `/api/messages/${podId}?limit=30`); + replied = (Array.isArray(messages) ? messages : []).some((m) => { + const author = m && (typeof m.userId === 'object' && m.userId ? m.userId._id : (m.user_id || m.userId)); + return String(author || '') === botId && new Date(m.createdAt).getTime() > askedAt; + }); + const mentionDone = runs.some((r) => r.trigger === 'mention' && ['succeeded', 'failed', 'interrupted'].includes(r.status)); + if (replied && mentionDone) break; + } + } + } catch (err) { + installError = installError || `check: ${err.message}`; + } finally { + await uninstall(podId, agentName, name.instanceId); + } + return { ...classifyCheck({ installError, runs, replied }), ms: Date.now() - t0 }; + }; + + const results = []; + try { + const plan = planChecks(names, kw.keywords); + for (const name of names) { + // The hosted cap is one per account, so names run one after another; the + // native check for the same name runs beside the hosted one. + const pair = await Promise.all(plan.filter((c) => c.name === name).map(async (check) => { + if (check.skip) { + const result = { step: 'check', runtime: check.runtime, name: name.displayName, verdict: 'inconclusive', reason: check.skip }; + log(result); + return result; + } + let outcome = await checkOnce(check); + let attempts = 1; + if (outcome.verdict !== 'pass') { + // Once more, after LiteLLM reports ready again. A restart window or a + // slow model must not stall a release, and an upstream moderation + // error is filed as guardrail_blocked too; a keyword match repeats. + await waitForLitellm(60_000); + await sleep(5000); + outcome = combineAttempts(outcome, await checkOnce(check)); + attempts = 2; + } + const result = { + step: 'check', runtime: check.runtime, name: name.displayName, keywords: name.keywords, attempts, ...outcome, + }; + log(result); + return result; + })); + results.push(...pair); + } + } finally { + log({ step: 'retire', podId, retired: await retirePod(podId) }); + } + + const code = exitCodeFor(results, kw.ok, skipped.some((x) => x.reason === 'over_cap')); + log({ + result: code === 0 ? 'pass' : code === BLOCKED_EXIT ? 'blocked' : 'inconclusive', exit: code, checks: results.length, ms: Date.now() - started, + }); + await mongoose.disconnect(); + return code; +} + +if (process.env.CANARY_RUN === '1') { + main().then((code) => process.exit(code)).catch((err) => { + // Name and code only: a driver error can quote its connection string. + log({ result: 'inconclusive', exit: 2, error: String((err && err.name) || 'Error'), code: (err && err.code) || null }); + process.exit(2); + }); +}