From a0927848a899703fcff0620c366b6535f958acc6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:58:12 -0800 Subject: [PATCH 1/5] feat(ops): a post-deploy canary gives agents named after guard keywords a real turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging has no model key, so nothing on the release path ran an agent turn through LiteLLM's injection guard, and no test agent's name came near a keyword. An agent whose name began with "Dan" was then blocked on every turn ("You are Dan…" contains "you are dan"). scripts/guard-name-canary.js runs in ops-runner after a deploy. It reads the serving guard's always-block keywords (scripts/guard-keywords-dump.py, run in the litellm container), derives a name for each persona keyword (Dan Canary, Now Canary), installs a hosted and a native agent under each name through the product's routes, asks each a plain question, and fails on any guard-blocked run. Exit 0 pass, 1 blocked, 2 inconclusive. It waits for LiteLLM readiness and retries an inconclusive check once. Agents are uninstalled after every check. Also review-checklist rule 60: an entitlement or cost decision reads the field the system acts on, never a label the caller wrote (#3287). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- .../unit/scripts/guardNameCanary.test.js | 141 ++++++ docs/development/review-checklist.md | 4 + docs/runbooks/guard-name-canary.md | 146 +++++++ docs/runbooks/litellm-guardrails.md | 4 + scripts/guard-keywords-dump.py | 45 ++ scripts/guard-name-canary.js | 405 ++++++++++++++++++ 6 files changed, 745 insertions(+) create mode 100644 backend/__tests__/unit/scripts/guardNameCanary.test.js create mode 100644 docs/runbooks/guard-name-canary.md create mode 100644 scripts/guard-keywords-dump.py create mode 100644 scripts/guard-name-canary.js diff --git a/backend/__tests__/unit/scripts/guardNameCanary.test.js b/backend/__tests__/unit/scripts/guardNameCanary.test.js new file mode 100644 index 0000000000..15aff8c331 --- /dev/null +++ b/backend/__tests__/unit/scripts/guardNameCanary.test.js @@ -0,0 +1,141 @@ +/** + * 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, +} = 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('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('exitCodeFor', () => { + it('1 when any check was blocked, 2 when any was inconclusive or the list unread, else 0', () => { + 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(1); + expect(exitCodeFor([{ verdict: 'pass' }], false)).toBe(2); + expect(exitCodeFor([{ verdict: 'blocked' }], false)).toBe(1); + expect(exitCodeFor([], true)).toBe(2); + }); +}); diff --git a/docs/development/review-checklist.md b/docs/development/review-checklist.md index 7b5e40dc30..c42ef9084f 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, not the `host` the install carries. 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-01. `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 0000000000..812d870fc6 --- /dev/null +++ b/docs/runbooks/guard-name-canary.md @@ -0,0 +1,146 @@ +# 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) and its pod, "Guard-name canary". It signs a 20-minute session for + that account from `JWT_SECRET` inside the pod. No token is stored. It + uninstalls any canary agent a crashed run left behind, because the hosted cap + is one per account. +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 since install. That last + condition covers the first-contact greeting too. An inconclusive check runs + once more after LiteLLM reports ready again. Every agent is uninstalled + afterwards, pass or fail. + +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. | +| 1 | `blocked` | The guard stopped a turn for a canary name. 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, or the keyword list was unreadable. Worth a look; not a reason to roll back. | + +## 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 + +Proposed step for `.github/workflows/deploy-dev.yml`. SRE owns the workflow. +It goes 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. That step's condition then needs +`|| 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='' + 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" = 1 ]; 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. 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.** Only `injection-guard` is on for native turns + (`NATIVE_RUNTIME_GUARDRAILS`). If another guardrail is enabled, give it 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 run in this PR: + +- **Pass.** `Dan Canary` and `Now Canary`, hosted and native: four passes in + 25.5s, exit 0, no canary install left in the pod. +- **Reverse check.** A copy whose question carried "you are Dan Canary" + produced four `blocked` verdicts and exit 1. 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 ecbae6d7de..dec8219ff0 100644 --- a/docs/runbooks/litellm-guardrails.md +++ b/docs/runbooks/litellm-guardrails.md @@ -122,6 +122,10 @@ 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. +After each deploy, 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 fails the release if the guard stops one. + ## ⚠️ 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 0000000000..99fa903a76 --- /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 0000000000..4a305c0b8e --- /dev/null +++ b/scripts/guard-name-canary.js @@ -0,0 +1,405 @@ +#!/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 its pod. It signs a 20-minute session for it + * here, from JWT_SECRET; 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 an + * inconclusive check once more: a restart window must not read as a red. + * + * Exit: 0 every check passed · 1 a run was stopped by the guard (the release + * must not stand) · 2 inconclusive (no verdict: an install failed, nothing + * replied in time, or the keyword list was unreadable). 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_NAME = 'Guard-name canary'; +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; + +// ── 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 (findKeyword(displayName, keywords)) { + skipped.push({ stem, keyword, reason: 'name_contains_keyword' }); + 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' }; +} + +/** 1 if any check was blocked; 2 if no checks ran, any was inconclusive, or the list was unread; else 0. */ +function exitCodeFor(results, keywordsRead) { + if (results.some((r) => r.verdict === 'blocked')) return 1; + if (!results.length || results.some((r) => r.verdict !== 'pass') || !keywordsRead) return 2; + return 0; +} + +module.exports = { + parseKeywordPayload, findKeyword, deriveCanaryNames, mentionText, classifyCheck, exitCodeFor, +}; + +// ── 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.message || err).slice(0, 80); + } + 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 the session 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; + } + const token = jwt.sign( + { id: String(account._id), tokenVersion: account.tokenVersion || 0 }, + process.env.JWT_SECRET, + { expiresIn: '20m' }, + ); + log({ step: 'account', ok: true }); + + const api = async (method, path, body) => { + const res = await fetch(`${API}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + '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; + }; + + // The canary's pod, reused across runs. + const podsRes = await api('GET', '/api/pods'); + const pods = Array.isArray(podsRes) ? podsRes : (podsRes && podsRes.pods) || []; + let pod = pods.find((p) => p && p.name === CANARY_POD_NAME); + if (!pod) { + pod = await api('POST', '/api/pods', { + name: CANARY_POD_NAME, + type: 'chat', + description: 'Release check: agents named after guard keywords each get one real turn.', + }); + } + const podId = String(pod._id || pod.id); + log({ step: 'pod', ok: true, podId }); + + const uninstall = async (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; + } + }; + + // A run that died before its cleanup leaves an install behind, and the + // hosted cap is one per account. Only the canary's own two agents are touched. + 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)) { + const removed = await uninstall(a.name, a.instanceId || 'default'); + log({ step: 'sweep', agentName: a.name, instanceId: a.instanceId || 'default', removed }); + } + } + } catch (err) { + log({ step: 'sweep', ok: false, error: err.message }); + } + + const ready = await waitForLitellm(120_000); + log({ step: 'litellm-ready', ...ready }); + + const checkOnce = async (runtimeType, name) => { + const agentName = runtimeType === 'hosted' ? HOSTED_AGENT : NATIVE_AGENT; + const handle = `${agentName}-${name.instanceId}`; + 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 } }, + }); + if (runtimeType === '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( + { 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(agentName, name.instanceId); + } + return { ...classifyCheck({ installError, runs, replied }), ms: Date.now() - t0 }; + }; + + const results = []; + 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(['hosted', 'native'].map(async (runtimeType) => { + let outcome = await checkOnce(runtimeType, name); + let attempts = 1; + if (outcome.verdict === 'inconclusive') { + // One retry, after LiteLLM reports ready again: a restart window or a + // slow model must not stall a release. + await waitForLitellm(60_000); + outcome = await checkOnce(runtimeType, name); + attempts = 2; + } + const result = { step: 'check', runtime: runtimeType, name: name.displayName, keywords: name.keywords, attempts, ...outcome }; + log(result); + return result; + })); + results.push(...pair); + } + + const code = exitCodeFor(results, kw.ok); + log({ result: code === 0 ? 'pass' : code === 1 ? '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) => { + log({ result: 'inconclusive', exit: 2, error: String((err && err.message) || err).slice(0, 300) }); + process.exit(2); + }); +} From 58d4b9249917ba6092c7585c8b3d3546fadb7e55 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:01:38 -0800 Subject: [PATCH 2/5] fix(ops): the canary judges only in an invite-only pod of its own, scoped to that pod Security review: the canary's pod was created open, so anyone could join it and @-mention a canary agent with a keyword to fail a release; and the run lookup matched any pod, so an install elsewhere under the same name could do the same. The pod is now created invite-only, and the canary refuses to judge (exit 2) when the pod is open or holds another person. Runs are matched on the canary's pod. Names are capped at eight and sixty characters, and the final error line prints the error's name and code only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- .../unit/scripts/guardNameCanary.test.js | 18 ++++++- docs/runbooks/guard-name-canary.md | 14 ++++-- scripts/guard-name-canary.js | 50 ++++++++++++++++--- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/backend/__tests__/unit/scripts/guardNameCanary.test.js b/backend/__tests__/unit/scripts/guardNameCanary.test.js index 15aff8c331..ce469265aa 100644 --- a/backend/__tests__/unit/scripts/guardNameCanary.test.js +++ b/backend/__tests__/unit/scripts/guardNameCanary.test.js @@ -77,6 +77,20 @@ describe('deriveCanaryNames', () => { 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) { @@ -130,7 +144,9 @@ describe('classifyCheck', () => { }); describe('exitCodeFor', () => { - it('1 when any check was blocked, 2 when any was inconclusive or the list unread, else 0', () => { + it('1 when any check was blocked, 2 when any was inconclusive, the list unread or names were capped, else 0', () => { + expect(exitCodeFor([{ verdict: 'pass' }], true, true)).toBe(2); + expect(exitCodeFor([{ verdict: 'blocked' }], true, true)).toBe(1); 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(1); diff --git a/docs/runbooks/guard-name-canary.md b/docs/runbooks/guard-name-canary.md index 812d870fc6..72c48fb900 100644 --- a/docs/runbooks/guard-name-canary.md +++ b/docs/runbooks/guard-name-canary.md @@ -45,9 +45,14 @@ runs real turns on production, and it picks names from the guard's own list. 3. **Sets up.** It ensures the account `eng-guard-canary` (on `ops.commonly.invalid`, `cloudAgents` entitled, verified, with no usable password) and its pod, "Guard-name canary". It signs a 20-minute session for - that account from `JWT_SECRET` inside the pod. No token is stored. It - uninstalls any canary agent a crashed run left behind, because the hosted cap - is one per account. + that account from `JWT_SECRET` inside the pod. No token is stored. The pod is + invite-only, and the canary refuses to judge (exit 2) if the pod is open or + any other person is a member. Anyone who could post there could @-mention a + canary agent with a keyword and fail the release. It uninstalls any canary + agent a crashed run left behind, 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 @@ -55,7 +60,8 @@ runs real turns on production, and it picks names from the guard's own list. 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 since install. That last + the question, and no `guardrail_blocked` run in the canary's pod since + install. That last condition covers the first-contact greeting too. An inconclusive check runs once more after LiteLLM reports ready again. Every agent is uninstalled afterwards, pass or fail. diff --git a/scripts/guard-name-canary.js b/scripts/guard-name-canary.js index 4a305c0b8e..6ccc592be7 100644 --- a/scripts/guard-name-canary.js +++ b/scripts/guard-name-canary.js @@ -61,6 +61,10 @@ const PERSONA_LEADS = [ '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) ── @@ -124,10 +128,18 @@ function deriveCanaryNames(keywords) { 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]) => { @@ -162,10 +174,13 @@ function classifyCheck({ installError, runs, replied }) { return { verdict: 'pass' }; } -/** 1 if any check was blocked; 2 if no checks ran, any was inconclusive, or the list was unread; else 0. */ -function exitCodeFor(results, keywordsRead) { +/** + * 1 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 1; - if (!results.length || results.some((r) => r.verdict !== 'pass') || !keywordsRead) return 2; + if (!results.length || results.some((r) => r.verdict !== 'pass') || !keywordsRead || capped) return 2; return 0; } @@ -279,10 +294,30 @@ async function main() { pod = await api('POST', '/api/pods', { name: CANARY_POD_NAME, type: 'chat', + // Invite-only: a stranger in this pod could @-mention a canary agent + // with a keyword and fail the release (security review, this PR). + joinPolicy: 'invite-only', description: 'Release check: agents named after guard keywords each get one real turn.', }); } const podId = String(pod._id || pod.id); + // Only the canary account may be a human in this pod. A pod someone else can + // reach, or did reach, yields no verdict: their message could be what the + // guard stops. + const podRow = await db.collection('pods').findOne( + { _id: new mongoose.Types.ObjectId(podId) }, + { projection: { joinPolicy: 1, members: 1 } }, + ); + const otherHumans = podRow ? await users.countDocuments({ + _id: { $in: (podRow.members || []).filter((m) => String(m) !== String(account._id)) }, + isBot: { $ne: true }, + }) : -1; + if (!podRow || podRow.joinPolicy !== 'invite-only' || otherHumans !== 0) { + log({ step: 'pod', ok: false, podId, joinPolicy: podRow ? podRow.joinPolicy || null : null, otherHumans }); + log({ result: 'inconclusive', exit: 2 }); + await mongoose.disconnect(); + return 2; + } log({ step: 'pod', ok: true, podId }); const uninstall = async (agentName, instanceId) => { @@ -349,7 +384,9 @@ async function main() { while (Date.now() - t0 < CHECK_BUDGET_MS) { await sleep(3000); runs = await db.collection('agentruns').find( - { agentName, instanceId: name.instanceId, startedAt: { $gte: since } }, + { + podId: new mongoose.Types.ObjectId(podId), 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; @@ -391,7 +428,7 @@ async function main() { results.push(...pair); } - const code = exitCodeFor(results, kw.ok); + const code = exitCodeFor(results, kw.ok, skipped.some((x) => x.reason === 'over_cap')); log({ result: code === 0 ? 'pass' : code === 1 ? 'blocked' : 'inconclusive', exit: code, checks: results.length, ms: Date.now() - started }); await mongoose.disconnect(); return code; @@ -399,7 +436,8 @@ async function main() { if (process.env.CANARY_RUN === '1') { main().then((code) => process.exit(code)).catch((err) => { - log({ result: 'inconclusive', exit: 2, error: String((err && err.message) || err).slice(0, 300) }); + // 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); }); } From d0b65a210c2aa23a416a33252e8262288d7c84db Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:06:45 -0800 Subject: [PATCH 3/5] fix(ops): a fresh pod per run, blocked is exit 3 and must repeat, the step reads the code Correctness review: - A hosted turn reads the last pod messages as user text, which the guard scans, so a reused pod carried one run's words into the next. Each run now creates an invite-only pod and deletes it with its messages at the end; pods a crashed run left are swept first. - kubectl and a node crash exit 1, the old "blocked" code. Blocked is now 3. - The runbook's step ran under bash -e and never read the exit code; it now uses `|| CODE=$?`, so 2 warns and only 3 fails the deploy. - An upstream moderation error is filed as guardrail_blocked too, so a check that didn't pass runs once more and blocked needs both attempts blocked (combineAttempts). - A question that would itself contain a keyword is never posted (planChecks), and each request is signed with its own short-lived session. Prod: pass 4/4 in 30s, exit 0; reverse copy (keyword question, skip disabled) blocked twice on every check, exit 3. 20/20 unit tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- .../unit/scripts/guardNameCanary.test.js | 49 +++- docs/development/review-checklist.md | 2 +- docs/runbooks/guard-name-canary.md | 53 ++-- scripts/guard-name-canary.js | 238 +++++++++++------- 4 files changed, 229 insertions(+), 113 deletions(-) diff --git a/backend/__tests__/unit/scripts/guardNameCanary.test.js b/backend/__tests__/unit/scripts/guardNameCanary.test.js index ce469265aa..1b096208d0 100644 --- a/backend/__tests__/unit/scripts/guardNameCanary.test.js +++ b/backend/__tests__/unit/scripts/guardNameCanary.test.js @@ -17,6 +17,9 @@ const { 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. @@ -143,15 +146,53 @@ describe('classifyCheck', () => { }); }); +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('1 when any check was blocked, 2 when any was inconclusive, the list unread or names were capped, else 0', () => { + 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(1); + 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(1); + expect(exitCodeFor([{ verdict: 'inconclusive' }, { verdict: 'blocked' }], true)).toBe(BLOCKED_EXIT); expect(exitCodeFor([{ verdict: 'pass' }], false)).toBe(2); - expect(exitCodeFor([{ verdict: 'blocked' }], false)).toBe(1); + 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 c42ef9084f..e7ba097648 100644 --- a/docs/development/review-checklist.md +++ b/docs/development/review-checklist.md @@ -257,4 +257,4 @@ And then note where the fix landed. Someone hit the audience gap, felt it, and b ## 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, not the `host` the install carries. 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-01. `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.)* +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, not the `host` the install carries. 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 index 72c48fb900..24761bc1e1 100644 --- a/docs/runbooks/guard-name-canary.md +++ b/docs/runbooks/guard-name-canary.md @@ -45,11 +45,13 @@ runs real turns on production, and it picks names from the guard's own list. 3. **Sets up.** It ensures the account `eng-guard-canary` (on `ops.commonly.invalid`, `cloudAgents` entitled, verified, with no usable password) and its pod, "Guard-name canary". It signs a 20-minute session for - that account from `JWT_SECRET` inside the pod. No token is stored. The pod is - invite-only, and the canary refuses to judge (exit 2) if the pod is open or - any other person is a member. Anyone who could post there could @-mention a - canary agent with a keyword and fail the release. It uninstalls any canary - agent a crashed run left behind, because the hosted cap is one per account. + 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. @@ -61,10 +63,13 @@ runs real turns on production, and it picks names from the guard's own list. 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. An inconclusive check runs - once more after LiteLLM reports ready again. Every agent is uninstalled - afterwards, pass or fail. + 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}`. @@ -73,8 +78,11 @@ Each step prints one JSON line. The last line is `{canary, result, exit}`. | Exit | Result | Meaning for the release | |---|---|---| | 0 | `pass` | Every name got a real turn. | -| 1 | `blocked` | The guard stopped a turn for a canary name. 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, or the keyword list was unreadable. Worth a look; not a reason to roll back. | +| 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 @@ -105,11 +113,13 @@ rollback step runs. That step's condition then needs 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" = 1 ]; then + 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 @@ -119,7 +129,10 @@ rollback step runs. That step's condition then needs ``` The script comes from the dispatched checkout, so it matches the release even -while ops-runner is still rolling to the new image. The deploy's service account +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. @@ -139,13 +152,13 @@ pod for the chat check. ## Verified -2026-11-02, on production, the run in this PR: +2026-11-02, on production, the runs in this PR: -- **Pass.** `Dan Canary` and `Now Canary`, hosted and native: four passes in - 25.5s, exit 0, no canary install left in the pod. +- **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 four `blocked` verdicts and exit 1. That proves a guard stop reaches - the exit code. + 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. diff --git a/scripts/guard-name-canary.js b/scripts/guard-name-canary.js index 6ccc592be7..aa4bf6fd52 100644 --- a/scripts/guard-name-canary.js +++ b/scripts/guard-name-canary.js @@ -20,8 +20,12 @@ * 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 its pod. It signs a 20-minute session for it - * here, from JWT_SECRET; no token is stored anywhere. + * 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 @@ -29,13 +33,17 @@ * 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 an - * inconclusive check once more: a restart window must not read as a red. + * 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 · 1 a run was stopped by the guard (the release - * must not stand) · 2 inconclusive (no verdict: an install failed, nothing - * replied in time, or the keyword list was unreadable). Every step prints one - * JSON line; the last is {canary, result, exit}. + * 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) @@ -47,7 +55,9 @@ const CANARY = 'guard-name'; const CANARY_USERNAME = 'eng-guard-canary'; const CANARY_EMAIL_DOMAIN = 'ops.commonly.invalid'; -const CANARY_POD_NAME = 'Guard-name canary'; +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'; @@ -175,17 +185,58 @@ function classifyCheck({ installError, runs, replied }) { } /** - * 1 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. + * 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 1; + 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 = { - parseKeywordPayload, findKeyword, deriveCanaryNames, mentionText, classifyCheck, exitCodeFor, + combineAttempts, + parseKeywordPayload, + findKeyword, + deriveCanaryNames, + mentionText, + classifyCheck, + exitCodeFor, + planChecks, + BLOCKED_EXIT, }; // ── the run (only with CANARY_RUN=1, in ops-runner) ───────────────────────── @@ -205,7 +256,7 @@ async function waitForLitellm(deadlineMs) { if (res.ok) return { ok: true, ms: Date.now() - t0 }; last = `HTTP ${res.status}`; } catch (err) { - last = String(err.message || err).slice(0, 80); + last = String(err.name || 'Error'); } await sleep(3000); } @@ -231,7 +282,7 @@ async function main() { 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 the session signed below. + // 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 }, { @@ -258,18 +309,19 @@ async function main() { await mongoose.disconnect(); return 2; } - const token = jwt.sign( + 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: '20m' }, + { expiresIn: '5m' }, ); - log({ step: 'account', ok: true }); - const api = async (method, path, body) => { const res = await fetch(`${API}${path}`, { method, headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${session()}`, 'Content-Type': 'application/json', 'User-Agent': 'commonly-guard-canary/1.0', }, @@ -286,41 +338,7 @@ async function main() { return data; }; - // The canary's pod, reused across runs. - const podsRes = await api('GET', '/api/pods'); - const pods = Array.isArray(podsRes) ? podsRes : (podsRes && podsRes.pods) || []; - let pod = pods.find((p) => p && p.name === CANARY_POD_NAME); - if (!pod) { - pod = await api('POST', '/api/pods', { - name: CANARY_POD_NAME, - type: 'chat', - // Invite-only: a stranger in this pod could @-mention a canary agent - // with a keyword and fail the release (security review, this PR). - joinPolicy: 'invite-only', - description: 'Release check: agents named after guard keywords each get one real turn.', - }); - } - const podId = String(pod._id || pod.id); - // Only the canary account may be a human in this pod. A pod someone else can - // reach, or did reach, yields no verdict: their message could be what the - // guard stops. - const podRow = await db.collection('pods').findOne( - { _id: new mongoose.Types.ObjectId(podId) }, - { projection: { joinPolicy: 1, members: 1 } }, - ); - const otherHumans = podRow ? await users.countDocuments({ - _id: { $in: (podRow.members || []).filter((m) => String(m) !== String(account._id)) }, - isBot: { $ne: true }, - }) : -1; - if (!podRow || podRow.joinPolicy !== 'invite-only' || otherHumans !== 0) { - log({ step: 'pod', ok: false, podId, joinPolicy: podRow ? podRow.joinPolicy || null : null, otherHumans }); - log({ result: 'inconclusive', exit: 2 }); - await mongoose.disconnect(); - return 2; - } - log({ step: 'pod', ok: true, podId }); - - const uninstall = async (agentName, instanceId) => { + const uninstall = async (podId, agentName, instanceId) => { try { await api('DELETE', `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${podId}?instanceId=${encodeURIComponent(instanceId)}`); return true; @@ -329,27 +347,55 @@ async function main() { 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 an install behind, and the - // hosted cap is one per account. Only the canary's own two agents are touched. + // A run that died before its cleanup leaves a pod and installs behind, and + // the hosted cap is one per account. 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)) { - const removed = await uninstall(a.name, a.instanceId || 'default'); - log({ step: 'sweep', agentName: a.name, instanceId: a.instanceId || 'default', removed }); + 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 (runtimeType, name) => { - const agentName = runtimeType === 'hosted' ? HOSTED_AGENT : NATIVE_AGENT; - const handle = `${agentName}-${name.instanceId}`; + const checkOnce = async ({ runtime, agentName, handle, name }) => { const since = new Date(Date.now() - 5000); const t0 = Date.now(); let installError = null; @@ -363,9 +409,9 @@ async function main() { displayName: name.displayName, instanceId: name.instanceId, scopes: [], - config: { runtime: { runtimeType } }, + config: { runtime: { runtimeType: runtime } }, }); - if (runtimeType === 'hosted') { + if (runtime === 'hosted') { await api('POST', '/api/hosted/provision', { agentName, instanceId: name.instanceId }); } } catch (err) { @@ -385,7 +431,7 @@ async function main() { await sleep(3000); runs = await db.collection('agentruns').find( { - podId: new mongoose.Types.ObjectId(podId), agentName, instanceId: name.instanceId, startedAt: { $gte: since }, + podId: podOid, agentName, instanceId: name.instanceId, startedAt: { $gte: since }, }, { projection: { _id: 0, status: 1, errorKind: 1, trigger: 1 } }, ).toArray(); @@ -402,34 +448,50 @@ async function main() { } catch (err) { installError = installError || `check: ${err.message}`; } finally { - await uninstall(agentName, name.instanceId); + await uninstall(podId, agentName, name.instanceId); } return { ...classifyCheck({ installError, runs, replied }), ms: Date.now() - t0 }; }; const results = []; - 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(['hosted', 'native'].map(async (runtimeType) => { - let outcome = await checkOnce(runtimeType, name); - let attempts = 1; - if (outcome.verdict === 'inconclusive') { - // One retry, after LiteLLM reports ready again: a restart window or a - // slow model must not stall a release. - await waitForLitellm(60_000); - outcome = await checkOnce(runtimeType, name); - attempts = 2; - } - const result = { step: 'check', runtime: runtimeType, name: name.displayName, keywords: name.keywords, attempts, ...outcome }; - log(result); - return result; - })); - results.push(...pair); + 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 === 1 ? 'blocked' : 'inconclusive', exit: code, checks: results.length, ms: Date.now() - started }); + log({ + result: code === 0 ? 'pass' : code === BLOCKED_EXIT ? 'blocked' : 'inconclusive', exit: code, checks: results.length, ms: Date.now() - started, + }); await mongoose.disconnect(); return code; } From bac12872415db33d15c016cb334b28fa272c48e2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:08:41 -0800 Subject: [PATCH 4/5] docs(ops): the canary signs one 5-minute session per request Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- docs/runbooks/guard-name-canary.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/runbooks/guard-name-canary.md b/docs/runbooks/guard-name-canary.md index 24761bc1e1..dc9ea40f6e 100644 --- a/docs/runbooks/guard-name-canary.md +++ b/docs/runbooks/guard-name-canary.md @@ -44,9 +44,8 @@ runs real turns on production, and it picks names from the guard's own list. working as intended. 3. **Sets up.** It ensures the account `eng-guard-canary` (on `ops.commonly.invalid`, `cloudAgents` entitled, verified, with no usable - password) and its pod, "Guard-name canary". It signs a 20-minute 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 + 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 From 733b09f342602314012ccbfa32f3394eab3cd1cf Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:20:33 -0800 Subject: [PATCH 5/5] docs(ops): name the Deploy Dev step, production's guardrail set, and codex's BYO case in rule 60 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- docs/development/review-checklist.md | 2 +- docs/runbooks/guard-name-canary.md | 16 +++++++++------- docs/runbooks/litellm-guardrails.md | 7 ++++--- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/development/review-checklist.md b/docs/development/review-checklist.md index e7ba097648..1c1c6d29a5 100644 --- a/docs/development/review-checklist.md +++ b/docs/development/review-checklist.md @@ -257,4 +257,4 @@ And then note where the fix landed. Someone hit the audience gap, felt it, and b ## 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, not the `host` the install carries. 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.)* +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 index dc9ea40f6e..bb6c5bd177 100644 --- a/docs/runbooks/guard-name-canary.md +++ b/docs/runbooks/guard-name-canary.md @@ -99,10 +99,11 @@ plus the two greetings. ## Wiring into Deploy Dev -Proposed step for `.github/workflows/deploy-dev.yml`. SRE owns the workflow. -It goes 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. That step's condition then needs +#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 @@ -143,9 +144,10 @@ pod for the chat check. - **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.** Only `injection-guard` is on for native turns - (`NATIVE_RUNTIME_GUARDRAILS`). If another guardrail is enabled, give it its own - dump. +- **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. diff --git a/docs/runbooks/litellm-guardrails.md b/docs/runbooks/litellm-guardrails.md index dec8219ff0..d521d172ba 100644 --- a/docs/runbooks/litellm-guardrails.md +++ b/docs/runbooks/litellm-guardrails.md @@ -122,9 +122,10 @@ 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. -After each deploy, 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 fails the release if the guard stops one. +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