Dedicated ChatGPT Federation MCP publisher with isolated Nostr signing-key custody, OAuth-scoped publishing, and signed public-channel coordination over the…

The ChatGPT Federation connector's publisher. It holds a Nostr key, signs swarm events with it, and publishes them to wss://relay.ruv.io over a connection it authenticated itself.
buzz-relay refuses any EVENT whose pubkey differs from the NIP-42 identity that authenticated the connection:
invalid: event pubkey does not match authenticated identity
So "sign it here, let the gateway relay it for you" cannot work — not as a policy choice, as a protocol one. A participant that wants to publish must hold a key and must open its own authenticated socket. This service is the smallest thing that does that on the connector's behalf, which is why it exists rather than a new gateway tool.
It is also why the x.ruv.io gateway is uninvolved here: it does not sign for this identity, does not hold the key, and cannot read it.
Three tools, no resources.
| Tool | Auth | Purpose |
|---|---|---|
federation_identity | open | The public key this connector signs with, and its relay |
channel_sync | open | Read recent messages from a channel |
channel_publish | caller token | Sign locally, publish to a pub: channel |
channel_publish returns eventId, pubkey and authenticatedAs so a caller can check the identity binding rather than trust it.
Publishing is restricted to public (pub:) channels. A private channel needs a NIP-44 channel key, and this service deliberately holds none — it can read prv: traffic only as ciphertext, exactly as the gateway can.
The signing key is a Secret Manager secret, mounted read-only as a file:
| Project | ruv-dev |
| Secret | chatgpt-federation-nostr-sk |
| Mount | /secrets/nostr/signing-key |
| Runtime SA | chatgpt-federation-runtime@ruv-dev.iam.gserviceaccount.com |
| Public identity | a29fbf2f7299d13e1f1049f829e0d7036949133de4226d728b2f181458d56890 |
ruv-dev rather than cognitum-20260110 for one specific reason: cognitum grants roles/secretmanager.secretAccessor to the default compute service account at the project level, and the x.ruv.io gateway runs as that account. Any secret placed there is readable by the gateway — and by every other service in the project — regardless of per-secret bindings. ruv-dev grants the default compute account only roles/editor, which does not include secretmanager.versions.access.
Three deliberate omissions in signing-key.mjs, each one load-bearing:
CGF_SIGNING_KEY_PATH). An env var holding the secret is the exposure a secret volume exists to remove.loadSigner() returns { pubkey, sign }. The bytes stay in the closure; there is no path from an MCP tool to them. A test asserts this structurally.Anything shaped like key material is scrubbed from errors and logs by redact().
gcloud run deploy chatgpt-federation \
--project=ruv-dev --region=us-central1 --source=. \
--service-account=chatgpt-federation-runtime@ruv-dev.iam.gserviceaccount.com \
--set-secrets=/secrets/nostr/signing-key=chatgpt-federation-nostr-sk:1,CGF_CALLER_TOKEN=chatgpt-federation-caller-token:1 \
--set-env-vars=CGF_RELAY_URL=wss://relay.ruv.io,CGF_EXPECTED_PUBKEY=a29fbf2f7299d13e1f1049f829e0d7036949133de4226d728b2f181458d56890 \
--allow-unauthenticated
Pin the secret version. Never mount :latest. A versions add under a :latest mount silently becomes an identity change on the next cold start — the connector comes back as a pubkey the relay has not admitted, every publish fails, and readers tracking the old identity just see it go quiet. CGF_EXPECTED_PUBKEY is the backstop: the service refuses to start if the mounted key derives anything else, so a deploy that forgets to pin fails loudly instead of rolling the identity over.
--allow-unauthenticated is correct here: the service is reached by a ChatGPT connector that cannot mint a Google ID token. Authority to publish comes from the caller token, not from Cloud Run IAM.
Authorization carries the OAuth access token (see below). The transitional x-caller-token header is a separate, temporary door that closes when CGF_OAUTH_REQUIRED=true.
An earlier version of this file claimed Cloud Run strips or rejects
Authorizationon public services. That was wrong. Retested across every header shape — absent, garbage, JWT-shaped, non-Bearer scheme, and the real token — on bothGET /andPOST /mcp: all 200, header delivered. The single 401 that produced the claim never reproduced.
OAuth 2.1 against Cognitum's authorization server, which is public-client + PKCE — token_endpoint_auth_methods_supported: ["none"], so there is no client secret in this design.
| Issuer | https://auth.cognitum.one |
| Authorize / Token | /oauth/authorize, /oauth/token |
| JWKS | /.well-known/jwks.json (single ES256 P-256 key) |
| Client id | chatgpt-federation (console migration 0029) |
| Resource metadata | <service>/.well-known/oauth-protected-resource (RFC 9728) |
| Scopes | federation:read for the reads, federation:publish for the write |
Expected aud | the client id, not the resource URL — see below |
An unauthenticated or unverifiable request to /mcp gets 401 with WWW-Authenticate: Bearer resource_metadata="…", which is what starts discovery.
This is client-audience binding, not standards-style resource-audience binding (RFC 8707). It is a deliberate, documented deviation, accepted for this dedicated integration because the connector is a single-tenant resource with its own OAuth client. It is not a pattern to copy into a multi-resource service, and it stops being safe the moment federation:read/federation:publish is granted to a second client.
If auth.cognitum.one later implements resource indicators (console ADR-038, open), switch CGF_OAUTH_CLIENT_ID to the resource identifier and this paragraph goes away.
auth.cognitum.one binds aud to the requesting OAuth client (issue_oauth_access_token in console services/identity/src/jwt.rs), not to a resource — RFC 8707 resource indicators remain an open question in that repo's ADR-038. Client-audience binding closes the same confused-deputy hole here only because this connector is the only resource its client id is registered for. That assumption is load-bearing: if federation:read/federation:publish were ever added to another client's allowed_scopes, that client's tokens would authenticate here and it would inherit the federation identity.
Two related traps, both real and both cost time:
iss nor aud. That is true of browser-session tokens, which auth_web.rs mints through the unbound issue_access_token. It is not true of the OAuth flow. Check which minting path a token came from before concluding anything about its claims.CGF_OAUTH_REQUIRED=true without CGF_OAUTH_CLIENT_ID would accept every token this issuer ever minted, for any Cognitum app — worse than the transitional header it replaces. The service refuses to start in that configuration rather than let it pass quietly.CGF_OAUTH_REQUIRED is the switch. Unset, reads stay open and x-caller-token still authorises publish. Set to true, both transitional doors close. Flip it only once OAuth has passed end to end — that is the last step, not the first.
Secret Manager versions are immutable, so rotation is add-then-disable and every step is auditable. Rotation mints a new federation identity, so the order matters: the relay must admit the new pubkey before any traffic reaches it, and the old version stays enabled until the new one is proven.
P=ruv-dev
SVC=chatgpt-federation
NEW_PK=<derived in step 1>
# 1. Generate the replacement locally and derive its pubkey. The secret is written to
# a 0600 file; only the public half is ever printed.
umask 077; WORK=$(mktemp -d)
node -e "const{generateSecretKey,getPublicKey}=require('nostr-tools/pure');
const fs=require('fs');const sk=generateSecretKey();
fs.writeFileSync(process.argv[1],Buffer.from(sk).toString('hex'),{mode:0o600});
console.error(getPublicKey(sk));" "$WORK/sk.hex"
# 2. Add the version. The MOUNTED version does not change — the running revision is
# pinned, so nothing rolls over here.
gcloud secrets versions add chatgpt-federation-nostr-sk --project=$P --data-file="$WORK/sk.hex"
find "$WORK" -type f -exec shred -u {} \; && rmdir "$WORK"
# 3. Admit the new pubkey on the relay, BEFORE it can publish.
# (federation_admit on https://x.ruv.io/mcp, admin-gated)
# 4. Deploy a revision pinned to the new version, with no traffic yet.
gcloud run deploy $SVC --project=$P --region=us-central1 --source=. --no-traffic \
--set-secrets=/secrets/nostr/signing-key=chatgpt-federation-nostr-sk:<N>,CGF_CALLER_TOKEN=chatgpt-federation-caller-token:1 \
--set-env-vars=CGF_RELAY_URL=wss://relay.ruv.io,CGF_EXPECTED_PUBKEY=$NEW_PK
# 5. Publish a probe against that revision's own URL and verify it independently —
# a different client, a different key, reading the relay directly. Confirm the
# event id binds to its content and the pubkey is $NEW_PK.
# 6. Only then route traffic.
gcloud run services update-traffic $SVC --project=$P --region=us-central1 --to-latest
# 7. Disable the old version, and revoke the old relay identity when appropriate.
gcloud secrets versions disable <old> --secret=chatgpt-federation-nostr-sk --project=$P
Steps 3 and 5 are not optional. Admitting after traffic moves means every publish fails with restricted: in the gap; disabling the old version before step 5 passes strands the connector with no way back.
npm install && npm test
The suite runs a local NIP-42 relay that enforces the same identity binding as buzz-relay, so the publish path is exercised end to end without touching production. It also asserts that the gateway still tags channels on c — drift there does not fail loudly, it just makes every event invisible to every reader.
Function hooks (ADR-445 pattern, hooks/register.ts) that need no model call, no network and no process:
channel_publish (on a server named for the connector) unless the channel is a public pub:<name>, the message holds no secret or credential-named field, and the payload fits the cap. It mirrors the publisher's own channel rule so a bad call is refused before it leaves the machine. A refusal never echoes the offending value./chatgpt-mod answers locally: status, scan <text> and channel <id>. (A distinct name from the plugin's own commands/skills, which no hook can answer.).claude-flow/chatgpt-mod/status.json (version, updatedMs, checked, blocked, byRule) is written at session start and after each refusal; the console reads it.Options (userConfig): guard (on/off, default on), maxPayloadBytes (default 8192, 256 to 65536).
Test: claude plugin validate plugins/ruflo-chatgpt-federation && claude plugin test plugins/ruflo-chatgpt-federation && bash plugins/ruflo-chatgpt-federation/scripts/smoke.sh.
hooks/register.ts 59 lines1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { verdict } from './guard'
5import { modeOf, readOptions, type ModOptions } from './options'
6import { newStats, noteBlocked, STATUS_PATH, statusText, type Stats } from './status'
7
8type Dollar = Parameters<Hook<'session.start'>>[0]
9
10/** Everything one session of the mod keeps: its settings, counters and the project root. */
11type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
12
13async function flush($: Dollar, s: Session): Promise<void> {
14 if (s.root === undefined) return
15 try {
16 await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, modeOf(s.opts), await $.clock.now()))
17 } catch {
18 /* the status file is a courtesy */
19 }
20}
21
22/**
23 * ruflo-chatgpt-federation as a mod (ADR-445 pattern): a guard on the connector's channel_publish (public channels only, no secrets, bounded size), `/chatgpt-mod`, and a status file the console reads. No network, no process: only tools already connected.
24 */
25export const register: Register = (on, options) => {
26 const s: Session = { opts: readOptions(options), stats: newStats() }
27
28 on('session.start', async ($, e, next) => {
29 const result = await next(e)
30 s.root = (await $.session.root()) as string | undefined
31 try {
32 await $.command.register({ name: 'chatgpt-mod', description: 'chatgpt federation mod: status, scan <text>, channel <id>' })
33 } catch {
34 /* a name taken by another plugin must not stop the mod */
35 }
36 await flush($, s)
37 return result
38 })
39
40 if (s.opts.guard) {
41 on('tool.call', async ($, e, next) => {
42 const v = verdict(e.tool, e, s.opts)
43 if (v === undefined) return next(e)
44 if (v === 'pass') {
45 s.stats.checked++
46 return next(e)
47 }
48 noteBlocked(s.stats, v.rule)
49 await flush($, s)
50 return { deny: v.reason }
51 })
52 }
53
54 /** `/chatgpt-mod`: answered locally, no model turn. */
55 on('command.run', { command: 'chatgpt-mod' }, async (_$, e) => {
56 return { text: answer(typeof e.args === 'string' ? e.args : '', { opts: s.opts, stats: s.stats }) }
57 })
58}
59hooks/command.ts 30 lines1import { PUBLIC_CHANNEL } from './guard'
2import type { ModOptions } from './options'
3import { secretsIn } from './screen'
4import type { Stats } from './status'
5
6export type CommandDeps = { readonly opts: ModOptions; readonly stats: Stats }
7
8const HELP = ['/chatgpt-mod status', '/chatgpt-mod scan <text>', '/chatgpt-mod channel <id>'].join('\n')
9
10/** `/chatgpt-mod` is answered locally and takes no model turn. Read-only: runs the guard's checks on what you give it. */
11export function answer(args: string, { opts, stats }: CommandDeps): string {
12 const [verb = '', ...rest] = args.trim().split(/\s+/)
13 const arg = rest.join(' ')
14 if (verb === '' || verb === 'help') return HELP
15 if (verb === 'status') {
16 const rules = Object.entries(stats.byRule).map(([k, n]) => `${k} ${n}`).join(', ')
17 return [`guard ${opts.guard ? 'on' : 'off'} · payload cap ${opts.maxPayloadBytes} bytes`, `checked ${stats.checked} · blocked ${stats.blocked}${rules ? ` (${rules})` : ''}`].join('\n')
18 }
19 if (verb === 'scan') {
20 if (arg === '') return 'usage: /chatgpt-mod scan <text>'
21 const found = secretsIn(arg)
22 return found.length ? `The guard would refuse publishing that (${found.join(', ')}).` : 'Nothing found: the guard would let that be published.'
23 }
24 if (verb === 'channel') {
25 if (arg === '') return 'usage: /chatgpt-mod channel <id>'
26 return PUBLIC_CHANNEL.test(arg) ? 'That is a public channel: publishing is allowed.' : 'Not a public pub:<name> channel: the guard would refuse a publish there.'
27 }
28 return `Unknown: ${verb}\n${HELP}`
29}
30hooks/guard.ts 43 lines1import type { ModOptions } from './options'
2import { hasCredentialField, hasSecret, splitName, textsOf } from './screen'
3
4export type Verdict = { readonly rule: string; readonly reason: string }
5
6/** The publisher's own rule (src/publisher.mjs): public channels only. Checking it here refuses a bad call before it leaves the machine. */
7export const PUBLIC_CHANNEL = /^pub:[a-z0-9][a-z0-9._-]{0,63}$/
8
9const SERVER = /chatgpt|federation/i
10
11/** True for the connector's own tool: `channel_publish` on a server named for the connector (not ruflo-core's `x_federation_channel_publish`). */
12export function isPublish(tool: string): boolean {
13 const parts = splitName(tool)
14 return parts !== undefined && parts.tool === 'channel_publish' && SERVER.test(parts.server)
15}
16
17const refuse = (rule: string, reason: string): Verdict => ({ rule, reason: `ruflo-chatgpt-federation: ${reason}` })
18
19const bytes = (v: unknown): number => {
20 try {
21 return new TextEncoder().encode(JSON.stringify(v) ?? '').length
22 } catch {
23 return Number.POSITIVE_INFINITY
24 }
25}
26
27/** Tighten-only verdict for the connector's channel_publish: undefined for other tools, 'pass' when allowed, else the refusal. Reasons never echo input. */
28export function verdict(tool: string, input: unknown, opts: ModOptions): Verdict | 'pass' | undefined {
29 if (!isPublish(tool)) return undefined
30 const args = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
31 if (typeof args.channel === 'string' && !PUBLIC_CHANNEL.test(args.channel)) {
32 return refuse('channel', 'publishing is for public pub:<name> channels only; this connector holds no private channel keys.')
33 }
34 const body = [args.msgType, args.payload]
35 if (hasCredentialField(args.payload) || textsOf(body).some(hasSecret)) {
36 return refuse('secret in message', 'this message holds what looks like a secret or a credential field. Channel content is readable by every relay member; send a reference, not the value.')
37 }
38 if (bytes(args.payload) > opts.maxPayloadBytes) {
39 return refuse('oversize', `the payload is larger than the ${opts.maxPayloadBytes}-byte cap. Publish a summary and link the detail.`)
40 }
41 return 'pass'
42}
43hooks/options.ts 22 lines1import type { PluginOptions } from 'claude-code'
2
3/** The plugin's `userConfig`, validated: a bad value is the safe default (guard on, 8 KB payload cap). */
4export type ModOptions = { readonly guard: boolean; readonly maxPayloadBytes: number }
5
6// BEGIN SHARED FLAG (generated by scripts/sync-mod-screen.mjs; edit plugins/ruflo-agentdb/hooks/options.ts)
7export const flag = (value: unknown, fallback: boolean) =>
8 value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
9// END SHARED FLAG
10
11const clamp = (value: unknown, min: number, max: number, fallback: number) => {
12 const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN
13 return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : fallback
14}
15
16export const readOptions = (options: PluginOptions | undefined): ModOptions => ({
17 guard: flag(options?.guard, true),
18 maxPayloadBytes: clamp(options?.maxPayloadBytes, 256, 65_536, 8192),
19})
20
21export const modeOf = (o: ModOptions) => ({ guard: o.guard, maxPayloadBytes: o.maxPayloadBytes })
22hooks/status.ts 26 lines1/** Counters the mod keeps for the session and writes to `.claude-flow/chatgpt-mod/status.json` for the console. */
2export type Stats = {
3 /** Calls to this plugin's tools the guard looked at. */
4 checked: number
5 /** Calls it refused. */
6 blocked: number
7 /** Refusals by rule name (never by value). */
8 byRule: Record<string, number>
9 lastRule?: string
10}
11
12export const newStats = (): Stats => ({ checked: 0, blocked: 0, byRule: {} })
13
14export const STATUS_PATH = '.claude-flow/chatgpt-mod/status.json'
15
16export function noteBlocked(stats: Stats, rule: string): void {
17 stats.blocked++
18 stats.byRule[rule] = (stats.byRule[rule] ?? 0) + 1
19 stats.lastRule = rule
20}
21
22/** The file's text; `version` lets the console refuse a shape it does not know. */
23export function statusText(stats: Stats, mode: Record<string, unknown>, nowMs: number): string {
24 return `${JSON.stringify({ version: 1, updatedMs: nowMs, ...mode, ...stats }, null, 2)}\n`
25}
26hooks/screen.ts 279 lines1/**
2 * Pure text screening for the ruflo-chatgpt-federation mod (ADR-445 pattern, copied from ruflo-agentdb). Finds secret shapes so none is sent out.
3 * Findings are NAMES only: the matched text is never returned, logged or counted by value.
4 */
5
6// BEGIN SHARED SCREEN (generated from plugins/ruflo-agentdb/hooks/screen.ts by scripts/sync-mod-screen.mjs; do not edit in a copy)
7export type Rules = readonly (readonly [string, RegExp])[]
8
9/** The secret shapes every mod screens for. A plugin adds its own after these, outside the markers. */
10export const COMMON_SECRETS: Rules = [
11 ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
12 ['aws access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
13 ['github token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
14 ['slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
15 ['slack webhook', /\bhooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{16,}/],
16 ['google api key', /\bAIza[0-9A-Za-z_-]{35}\b/],
17 ['anthropic or openai key', /\bsk-(?:(?:ant|proj|svcacct|admin)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z]{0,40}\d)[A-Za-z0-9]{32,})/],
18 ['stripe key', /\b[rs]k_live_[A-Za-z0-9]{16,}/],
19 ['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
20 ['huggingface token', /\bhf_[A-Za-z0-9]{30,}\b/],
21 ['sendgrid key', /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/],
22 ['twilio key', /\bSK[0-9a-f]{32}\b/],
23 ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
24 ['bearer token', /\bBearer\s+([A-Za-z0-9._~+/=-]{24,})/],
25 ['database url with credentials', /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:@/]+:[^\s@/]{3,}@[^\s/]+/i],
26]
27
28export const INJECTION: Rules = [
29 ['override instructions', /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|system)\b[^.\n]{0,30}\b(?:instructions?|rules?|prompts?|guidelines?)\b/i],
30 ['role reassignment', /\byou are (?:now|no longer)\b|\bact as (?:an? )?(?:unrestricted|jailbroken)\b/i],
31 ['new instructions', /\b(?:new|updated|real) (?:system )?instructions?\s*:/i],
32 ['fake role tags', /<\/?\s*(?:system|assistant|developer|instructions?)\s*>|^\s*(?:system|assistant)\s*:/im],
33 ['concealment', /\bdo not (?:tell|inform|mention|reveal)[^.\n]{0,30}\b(?:user|human|operator)\b/i],
34 ['exfiltration', /\b(?:exfiltrate|send|post|upload)\b[^.\n]{0,50}\b(?:secrets?|credentials?|tokens?|api keys?|\.env)\b/i],
35 ['shell pipe', /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i],
36]
37
38// C0/C1 controls (keeping tab and newline), DEL, soft hyphen, combining grapheme joiner, Arabic letter mark, Hangul and Mongolian fillers/separators,
39// zero-width, bidi (overrides and isolates) and invisible-format characters, variation selectors; built with escapes, never raw.
40const INVISIBLE = new RegExp(
41 '[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180e\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u206f\\u3164\\ufe00-\\ufe0f\\ufeff\\uffa0\\ufff9-\\ufffb]',
42 'g',
43)
44
45/** Longest input scanned in one pass; a longer one keeps its head and tail halves. One regex pass per rule, so cost stays linear. */
46const MAX_SCAN = 200_000
47
48/** Input bounded to MAX_SCAN characters with invisible characters removed, so none can hide a secret or a phrase. */
49export const bare = (text: string) =>
50 (text.length > MAX_SCAN ? text.slice(0, MAX_SCAN / 2) + '\n' + text.slice(-MAX_SCAN / 2) : text).replace(INVISIBLE, '')
51
52// A value is a secret CANDIDATE only when it is not a reference (env var, call, identifier path, placeholder, secret-manager path) and its
53// shape is random enough: at least two character classes, one of them a digit or symbol, and Shannon entropy of at least 2.5 bits per character.
54const PLACEHOLDER = /placeholder|your[-_ ]|example|changeme|change[-_]?me|redacted|dummy|replace[-_]?me|insert[-_]|\*{3,}|x{5,}|\.{3}|^(?:none|null|undefined|true|false)$/i
55const REFERENCE =
56 /^(?:\$(?:\{[^}]*\}|\(|[A-Za-z_]\w*$)|%[^%]*%$|<[^>]*>$|\{\{|process\.env|os\.environ|env[.[]|import\.meta|System\.getenv|secrets?\.|vault:|op:\/\/|ref\+|arn:|projects\/[^/]+\/secrets\/|gcp:|kms:|aws:|file:)/i
57const CALL = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\(|^[A-Za-z_$][\w$]*\[/
58const IDENT_PATH = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/
59const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
60const NAME_LIKE = /^[a-z][a-z0-9]*(?:[-_./][a-z0-9]+){2,}$/
61
62function entropy(v: string): number {
63 const counts = new Map<string, number>()
64 for (const ch of v) counts.set(ch, (counts.get(ch) ?? 0) + 1)
65 let h = 0
66 for (const n of counts.values()) h -= (n / v.length) * Math.log2(n / v.length)
67 return h
68}
69
70/** True when `v` is a name, call, path or placeholder rather than a literal credential. */
71function isReference(v: string): boolean {
72 if (PLACEHOLDER.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v)) return true
73 return NAME_LIKE.test(v) && v.replace(/\D/g, '').length / v.length < 0.15
74}
75
76export function plausibleSecret(v: string): boolean {
77 if (v.length < 8 || v.length > 256 || /\s/.test(v) || UUID.test(v) || isReference(v)) return false
78 const symbol = /[^A-Za-z0-9]/.test(v)
79 const digit = /\d/.test(v)
80 const classes = [/[a-z]/.test(v), /[A-Z]/.test(v), digit, symbol].filter(Boolean).length
81 return classes >= 2 && (digit || symbol) && entropy(v) >= 2.5
82}
83
84/** Under a secret-named key a literal this long is a secret even with one character class or a UUID shape, unless it is a clear reference. */
85const KEYED_MIN = 20
86const PLACEHOLDER_WORD = /(?:^|[^a-z])(?:your|placeholder|changeme|change[-_]?me|example|redacted|dummy|replace[-_]?me|insert)(?:[^a-z]|$)|\*{3,}|x{5,}|\.{3}|^(?:none|null|undefined|true|false)$/i
87const URL_NO_CREDS = /^[a-z][a-z0-9+.-]{1,20}:\/\/[^\s@]*$/i
88
89/** Three or more lowercase hyphen-separated words (no hex or digit-only run of 8+, few digits), such as my-k8s-secret-name-for-database. */
90function hyphenName(v: string): boolean {
91 const parts = v.split('-')
92 return parts.length >= 3 && parts.every(p => /^[a-z0-9]{2,}$/.test(p) && !/^[0-9a-f]{8,}$/.test(p)) && v.replace(/\D/g, '').length / v.length < 0.15
93}
94
95function keyedSecret(v: string): boolean {
96 if (v.length < KEYED_MIN || v.length > 256 || /\s/.test(v)) return false
97 return !(PLACEHOLDER_WORD.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v) || (!UUID.test(v) && hyphenName(v)))
98}
99
100const KEY_NAME = /(?:api[_-]?key|secret|token|passw(?:or)?d|passwd|pwd|credential|private[_-]?key|auth(?!or))s?[A-Za-z0-9_-]{0,40}["']?\s*[:=]\s*/gi
101const QUOTED = /(["'\x60])((?:(?!\1)[^\n]){1,256})\1/y
102const BARE_VALUE = /[^\s"'\x60,;]{1,256}/y
103const QUERY_VALUE = /[^\s"'\x60,;&]{1,256}/y
104
105/** A secret-named key assigned a literal value: env style, JSON, YAML, code. Values that are calls, references or placeholders do not count. */
106function assignmentSecret(text: string): boolean {
107 let valueEnd = 0
108 for (const m of text.matchAll(KEY_NAME)) {
109 if (m.index < valueEnd) continue // a key-looking word inside the previous value, such as secretsmanager in an ARN
110 const at = m.index + m[0].length
111 let back = m.index
112 while (back > 0 && m.index - back < 64 && /[A-Za-z0-9_.-]/.test(text.charAt(back - 1))) back--
113 const re = /["'\x60]/.test(text.charAt(at)) ? QUOTED : /[?&]/.test(text.charAt(back - 1)) ? QUERY_VALUE : BARE_VALUE
114 re.lastIndex = at
115 const hit = re.exec(text)
116 const v = hit && (hit[2] ?? hit[0])
117 valueEnd = hit ? at + hit[0].length : at
118 if (v && (plausibleSecret(v) || keyedSecret(v))) return true
119 }
120 return false
121}
122
123/** A password in a URL's userinfo that is not a placeholder such as user:password or ${DB_PASSWORD}. */
124function urlCredential(url: string): boolean {
125 const pass = /^[^:]+:\/\/[^\s:@/]+:([^\s@/]+)@/.exec(url)?.[1]
126 if (!pass || /\$\{|\{\{|%\(|%s/.test(pass)) return false
127 return !/^(?:password|passwd|pass|pwd|secret|changeme|dbpassword|db_password|\$\w*|<.*>|\{.*\}|\*+|x+)$/i.test(pass) && !PLACEHOLDER.test(pass)
128}
129
130const CHECKS: Readonly<Record<string, (m: RegExpMatchArray) => boolean>> = {
131 'bearer token': m => !isReference(m[1] ?? ''),
132 'database url with credentials': m => urlCredential(m[0]),
133 'database url with password': m => urlCredential(m[0]),
134}
135const globals = new WeakMap<RegExp, RegExp>()
136
137function matches(name: string, re: RegExp, text: string): boolean {
138 if (name === 'key assignment') return assignmentSecret(text)
139 const check = CHECKS[name]
140 if (!check) return re.test(text)
141 let g = globals.get(re)
142 if (!g) globals.set(re, (g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g')))
143 for (const m of text.matchAll(g)) if (check(m)) return true
144 return false
145}
146
147/**
148 * The text textsOf appends when it had to drop input (a node, character or per-string budget ran out). It is never matched against a rule:
149 * `names` reports it as a finding of its own, so every guard that asks "is there a secret in these texts" refuses what it could not read in full.
150 */
151export const TRUNCATED = 'ruflo-screen: input exceeded the screening budget'
152export const TRUNCATED_NAME = 'input too large to screen'
153
154/** Names of the rules that match `text` (already bare'd). A rule named 'key assignment' is judged by assignmentSecret, whatever its regex. */
155export const names = (rules: Rules, text: string) => text === TRUNCATED ? [TRUNCATED_NAME] : rules.filter(([name, re]) => matches(name, re, text)).map(([name]) => name)
156
157export type Findings = { readonly secrets: readonly string[]; readonly injection: readonly string[] }
158
159/** Names of every secret shape in `secrets` and every injection phrase found in `text`. Cost is linear in the capped input. */
160export function screenWith(secrets: Rules, text: string): Findings {
161 const bounded = bare(text)
162 return { secrets: names(secrets, bounded), injection: names(INJECTION, bounded) }
163}
164
165export const hasSecretIn = (secrets: Rules, text: string) => names(secrets, bare(text)).length > 0
166
167/** Makes stored text safe to show: no control or bidi characters, whitespace collapsed, at most `max` characters. */
168export function tidy(text: string, max: number): string {
169 const flat = text.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim()
170 return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat
171}
172/** Bounds for textsOf: nodes visited, characters returned, the longest string read in full, the size of one returned chunk, chunk overlap. */
173export type TextLimits = { readonly nodes?: number; readonly chars?: number; readonly perString?: number }
174const NODES = 20_000
175const CHARS = 2_000_000
176const PER_STRING = 1_500_000
177const OVERLAP = 2_048
178const BARE_KEY_MIN = 8
179
180/** A string as texts the screen can read whole: one text up to MAX_SCAN, else overlapping MAX_SCAN windows so a secret anywhere is inside one. */
181function windows(text: string, out: string[]): void {
182 if (text.length <= MAX_SCAN) {
183 out.push(text)
184 return
185 }
186 for (let at = 0; ; at += MAX_SCAN - OVERLAP) {
187 out.push(text.slice(at, at + MAX_SCAN))
188 if (at + MAX_SCAN >= text.length) return
189 }
190}
191
192/**
193 * Every string in a tool input, for the screen to read: iterative (no recursion, so nesting 5000 deep cannot overflow the stack) and
194 * breadth-first (siblings before depth, so a long list cannot hide a nested value). A string under an object key comes back as `key=value`,
195 * so a secret-named key is judged with its value; a key whose value is not a string is returned bare. Strings longer than the screen window
196 * come back as overlapping windows; one over `perString` keeps its head and tail. Work is bounded by `nodes` slots and `chars` characters.
197 * Anything dropped (slots or characters ran out, or a string lost its middle) is reported by a final TRUNCATED text, which `names` and
198 * `hasSecretIn` count as a finding, so the screen fails closed instead of passing what it did not read.
199 */
200export function textsOf(input: unknown, limits: TextLimits = {}): string[] {
201 const out: string[] = []
202 let slots = limits.nodes ?? NODES
203 let chars = limits.chars ?? CHARS
204 const perString = limits.perString ?? PER_STRING
205 let truncated = false
206 const take = (text: string): void => {
207 if (chars <= 0) {
208 truncated = true
209 return
210 }
211 if (text.length <= Math.min(perString, chars)) {
212 chars -= text.length
213 windows(text, out)
214 return
215 }
216 truncated = true
217 const half = Math.floor(Math.min(perString, chars) / 2)
218 chars -= 2 * half
219 windows(text.slice(0, half), out)
220 windows(text.slice(-half), out)
221 }
222 const queue: unknown[] = [input]
223 let head = 0
224 for (; head < queue.length && chars > 0; head++) {
225 const node = queue[head]
226 if (typeof node === 'string') take(node)
227 else if (Array.isArray(node)) {
228 let i = 0
229 for (; i < node.length && slots > 0; i++, slots--) if (i in node) queue.push(node[i])
230 if (i < node.length) truncated = true
231 } else if (typeof node === 'object' && node !== null) {
232 for (const k in node) {
233 if (slots-- <= 0) {
234 truncated = true
235 break
236 }
237 if (!Object.prototype.hasOwnProperty.call(node, k)) continue
238 const v = (node as Record<string, unknown>)[k]
239 if (typeof v === 'string') queue.push(k + '=' + v)
240 else {
241 if (k.length >= BARE_KEY_MIN) take(k)
242 queue.push(v)
243 }
244 }
245 }
246 }
247 if (queue.length > head) truncated = true
248 if (truncated) out.push(TRUNCATED)
249 return out
250}
251// END SHARED SCREEN
252
253const SECRETS: Rules = [
254 ...COMMON_SECRETS,
255 ['nostr secret key', /\bnsec1[02-9ac-hj-np-z]{50,}/],
256 ['key assignment', /\b(?:api[_-]?key|secret|token|passw(?:or)?d|credential)s?["']?\s*[:=]\s*["']?[A-Za-z0-9/+=_.-]{16,}/i],
257]
258
259/** Names of every secret shape found in `text`. Cost is linear in the capped input. */
260export const secretsIn = (text: string): string[] => names(SECRETS, bare(text))
261
262export const hasSecret = (text: string) => hasSecretIn(SECRETS, text)
263
264const CRED_KEY = /^(?:pass(?:word|wd)?|secret(?:_?key)?|api_?key|(?:access_?|auth_?|bearer_?)?token|private_?key|nsec|authorization|credentials?)$/i
265
266/** True when any object key (to a bounded depth) names a credential and holds a non-empty string. The value is never read out. */
267export function hasCredentialField(input: unknown, depth = 0): boolean {
268 if (depth > 6 || typeof input !== 'object' || input === null) return false
269 if (Array.isArray(input)) return input.slice(0, 200).some(v => hasCredentialField(v, depth + 1))
270 return Object.entries(input).slice(0, 200).some(([k, v]) => (CRED_KEY.test(k) && typeof v === 'string' && v.trim() !== '') || hasCredentialField(v, depth + 1))
271}
272
273/** `mcp__<server>__<tool>` into its two halves; tool names never hold a double underscore. Undefined for a non-MCP tool. */
274export function splitName(name: string): { server: string; tool: string } | undefined {
275 if (!name.startsWith('mcp__')) return undefined
276 const at = name.lastIndexOf('__')
277 return at > 5 ? { server: name.slice(5, at), tool: name.slice(at + 2) } : undefined
278}
279