From f7959a5498d6a2faffb1c8b2a3309700d585a1ad Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Mon, 12 Oct 2026 12:33:14 +0300 Subject: [PATCH 001/128] fix(teams): register literal Graph subscription resources Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 4 ++++ FEATURES.md | 3 +++ TEST-PLAN.md | 6 +++++ docs/PLATFORMS.md | 8 +++++++ src/platforms/msteams/graph-events.js | 8 +++++-- test/platform-teams-graph-events.test.js | 30 +++++++++++++++++++++++- 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb715cf7..b6247d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ product overview. ## Unreleased +- Register Teams Graph subscriptions with literal conversation identifiers in the JSON resource + selector, while retaining URL encoding for message-fetch paths. Record the registered selector + separately so a successfully renewed subscription's provider scope is visible to operators. + - Remove container image references from reply statistics in Slack, Teams and Google Chat. - Fix Google Drive sync channels getting stuck after an interrupted pass. Passes now use rclone's diff --git a/FEATURES.md b/FEATURES.md index 5b2a38f4..603f6c80 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -218,6 +218,9 @@ control-plane status. The operational audit distinguishes Graph callback receipt, validation, authenticated target acceptance, rejection and retryable failures using bounded metadata. Credentials, validation tokens, message bodies and untrusted resource identifiers are excluded. + Graph subscription JSON uses literal Teams channel/chat identifiers; HTTP message-fetch paths + encode individual path segments. The subscription store records the registered selector without + changing activation cutoffs or event identities. ## Claude login for nested commands diff --git a/TEST-PLAN.md b/TEST-PLAN.md index fee3c9c0..79b207f9 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -158,6 +158,12 @@ example server behind HTTPS on a public host. Record the token's last four chara ### Teams history-free activation regression (2026-10-09) +- Subscription selector regression (2026-10-12), engine-independent: Graph subscription JSON + retains literal chat/channel IDs containing `:` and `@`, while notification GET paths remain + encoded. Recreation of a legacy encoded selector preserves start, alias, snapshot and current + activation cutoffs. `platform-teams-graph-events.test.js` covers both scopes and registration + metadata. Live provider delivery after a scoped registration replacement is observed separately + from live engine activation; the reaction and edited-mention Claude/Codex cases remain required. - Engine-independent callback observability: `platform-teams-graph-events.test.js` verifies fixed rejection reasons, credential/payload exclusion, retryable GET failures and unchanged callback decisions when diagnostics throw or reject. Audit observers run after the callback response is diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index 192b2648..0abd93ab 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -459,3 +459,11 @@ status. Validation challenges have their own `validated` event and do not prove delivery. Diagnostics exclude tokens, request headers, bodies and unverified identifiers. If no receipt exists, correlate provider delivery with ingress/security logs; an empty queue cannot distinguish a blocked POST from an absent provider notification. + +The subscription's JSON `resource` selector uses literal Teams identifiers, for example +`/teams//channels/19:example@thread.tacv2/messages`. Percent encoding belongs to the +message-fetch HTTP path, not to this selector. `subscriptionResource` records the registered +form; ordinary renewal cannot change a provider subscription's resource. A deployment with an +older encoded registration and validation-only audit evidence needs a scoped replacement, not +another renewal or restart. Preserve the conversation's activation cutoffs and actor checks; +confirm authenticated change-notification delivery before retiring the prior registration. diff --git a/src/platforms/msteams/graph-events.js b/src/platforms/msteams/graph-events.js index 20f45bf4..470e9405 100644 --- a/src/platforms/msteams/graph-events.js +++ b/src/platforms/msteams/graph-events.js @@ -98,7 +98,10 @@ export function createTeamsGraphEvents({ auth, notificationUrl, tenantId, store, } async function maintain(row) { row = await prepareReactionAliases(row); - const { resource, apiVersion } = baseResource(row.resource); + const { resource, apiVersion, parts } = baseResource(row.resource); + // The JSON subscription selector is an identifier, not an HTTP request path. Keep + // literal Teams IDs here; message GET paths still percent-encode each path segment. + const subscriptionResource = `/${parts.join('/')}`; const current = now(); const sameEndpoint = row.notificationUrl === endpoint.href; if (sameEndpoint && row.subscriptionId && Date.parse(row.expiresAt) > current && Number(row.renewedAt || 0) + RENEW_AFTER > current) return row; @@ -126,8 +129,9 @@ export function createTeamsGraphEvents({ auth, notificationUrl, tenantId, store, await store.put(row); result = await request(apiVersion, "/subscriptions", "POST", { changeType: "created,updated,deleted", notificationUrl: endpoint.href, - resource, includeResourceData: false, expirationDateTime: expiresAt, clientState, + resource: subscriptionResource, includeResourceData: false, expirationDateTime: expiresAt, clientState, }); + row = { ...row, subscriptionResource }; } if (!safeId(result?.id)) throw new Error("Teams Graph subscription response has no valid id"); row = { ...row, resource, apiVersion, subscriptionId: result.id, expiresAt: result.expirationDateTime || expiresAt, renewedAt: current }; diff --git a/test/platform-teams-graph-events.test.js b/test/platform-teams-graph-events.test.js index d4575a5c..a44eeaea 100644 --- a/test/platform-teams-graph-events.test.js +++ b/test/platform-teams-graph-events.test.js @@ -46,7 +46,8 @@ test("Graph creates scoped beta chat subscriptions, no encrypted data or lifecyc assert.equal(request.redirect, "error"); assert.equal(body.includeResourceData, false); assert.equal(body.expirationDateTime, "2026-09-09T10:55:00.000Z"); - assert.equal(body.resource, "/chats/19%3Achat%40thread.v2/messages"); + assert.equal(body.resource, "/chats/19:chat@thread.v2/messages"); + assert.equal(row.subscriptionResource, body.resource); await f.service.ensure(f.row); assert.equal(f.requests.length, 1); f.advance(21 * 60_000); @@ -65,6 +66,33 @@ test("Graph uses stable channel API and permits only replies inside subscribed c assert.equal(f.messages.length, 1); }); +test('subscription JSON preserves literal Teams IDs while message GET paths encode them', async () => { + for (const resource of ['/chats/19:example_A@thread.v2/messages', '/teams/team-1/channels/19:example_A@thread.tacv2/messages']) { + const f = fixture(); + const row = await f.service.ensure({ ...f.row, resource }); + assert.equal(JSON.parse(f.requests[0].body).resource, resource); + assert.equal(row.subscriptionResource, resource); + assert.equal(row.resource, resource.split('/').map(encodeURIComponent).join('/')); + const event = { ...f.notification(), resource: `${resource}/123` }; + assert.equal((await f.handle({ value: [event] })).code, 200); + assert.equal(f.requests[1].url, `https://graph.microsoft.com/${row.apiVersion}${row.resource}/123`); + assert.equal(f.messages.length, 1); + } +}); + +test('subscription recreation corrects a legacy encoded selector without resetting activation cutoffs', async () => { + const f = fixture(), first = await f.service.ensure(f.row); + const legacy = f.rows.get(f.row.conversationId); + legacy.subscriptionResource = legacy.resource; + f.rows.set(f.row.conversationId, legacy); + f.advance(56 * 60_000); + await f.service.renew(); + const recreated = f.rows.get(f.row.conversationId); + assert.equal(JSON.parse(f.requests.at(-1).body).resource, '/chats/19:chat@thread.v2/messages'); + assert.equal(recreated.subscriptionResource, '/chats/19:chat@thread.v2/messages'); + for (const key of ['startedAt', 'reactionSnapshotsStartedAt', 'activationReactionsStartedAt', 'activationReactionsVersion', 'alienReactionStartedAt']) assert.equal(recreated[key], first[key], key); +}); + test("Graph validation token is echoed plain text without authenticating or fetching", async () => { const f = fixture(); const response = await f.handle(null, { validationToken: "opaque decoded + token" }); From c99eb2d1a699dcefcf4c2937ea2cb22d55c3d6ae Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Thu, 22 Oct 2026 23:44:44 +0300 Subject: [PATCH 002/128] fix: honor explicit readable secrets in strict egress mode Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 5 ++ FEATURES.md | 13 +++-- TEST-PLAN.md | 27 +++++++++++ docs/OPERATIONS.md | 16 +++++-- public/index.html | 2 +- src/gateway/channel-credentials.js | 2 +- src/gateway/egress/grants.js | 11 +++-- src/mcp/tools/tokens.js | 47 ++++++++++-------- test/egress-grants.test.js | 77 ++++++++++++++++++++++++++++++ test/secrets-tool.test.js | 36 +++++++++++++- 10 files changed, 202 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b93637..3da8c4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ product overview. ## Unreleased +- Respect an explicit readable choice for an individual environment secret in strict egress + mode. CLIs that require the raw value can receive it after the existing scoped approval; + automatically readable secrets remain withheld. Secret listings now distinguish the two + cases and summarize the most specific scope. + - **Email accounts.** Connect an ordinary mailbox — IMAP for reading, SMTP for sending — to a conversation or to yourself by entering its server settings once on one form: in the admin UI (the conversation's Connections, or a person under Users), in Slack's ⚙️ Settings (MCP page for diff --git a/FEATURES.md b/FEATURES.md index 1b59c46f..74b96c5e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -3811,7 +3811,13 @@ are retired, bullet by bullet; everything else stands. configuration (…_ID, _REPO, _ORG, _REGION, _USER, _URL…), or its value is a URL or a file path; otherwise HIDDEN — STRIPE_SECRET_KEY, API_SECRET and *_CLIENT_SECRET included. A readable name is injected raw and flagged `unprotected` — or withheld under `containerEgressSecretsStrict` - ("Withhold readable variables"). A hidden one gets a placeholder behind an **approval rule**: a + ("Withhold automatically readable variables"). An explicit stored `exposure: readable` choice + permits raw injection even in strict mode, only for the winning scope's entry. Automatic or + hidden password classification does not grant this exception. `list_secrets` separates explicit + readable entries from automatic withholding and respects conversation → personal → organization + precedence in its summary; values remain redacted. Returning to hidden/auto takes effect at the + next spawn. Existing scope authorization and readable approval cards still apply. + → TEST-PLAN: Explicit readable secrets in strict mode. A hidden one gets a placeholder behind an **approval rule**: a credential-like header or query parameter (the known auth headers, or a name with auth, token, key, secret, session, signature…), the bearer/raw/Basic positions, and only the servers an admin approved for that secret (`approvedHosts` on its entry, kept across a rotation). Presented to any @@ -3927,8 +3933,9 @@ are retired, bullet by bullet; everything else stands. this release (read before the first-boot password is saved), so an upgrade never silently withholds a secret a working channel uses; a stored choice is never changed. `list_secrets` ends with a **Finding** line naming every listed secret that has no egress rule and what happens to it - (withheld under strict, raw otherwise, raw today under the legacy bridge) and how to declare its - hosts; the settings UI says the same. → TEST-PLAN: Codex login relay, strict secrets, static check + (withheld under strict unless explicitly made readable, raw otherwise, raw today under the legacy + bridge) and how to declare its hosts; explicit readable entries are reported separately and the + settings UI explains the exception. → TEST-PLAN: Codex login relay, strict secrets, static check (container-secrets P4). - **A static check keeps secrets out of the artifact dir (container-secrets P4).** `npm run check:static` runs `scripts/static-secret-writes.mjs` over `src/`: a `writeFile*` / diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 8775d3ff..04eb2f2e 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,32 @@ # ChannelGate — Test Plan +## Explicit readable secrets in strict mode (2026-10-22) + +- [x] Regression: `test/egress-grants.test.js` exercises organization, personal and conversation + entries in proxy + strict mode. Explicit readable API keys (including catalog, declared and + approved-host metadata) are raw and `unprotected`; auto passwords and hidden password-like + entries stay absent and `withheld`; hidden API keys remain placeholders. Every real value stays + in the redactor inputs. Hidden/auto reversals apply on the next resolve, and an untrusted + principal receives no personal secrets. A lower-scope readable entry cannot authorize a + higher-scope auto entry. +- [x] Regression: `test/secrets-tool.test.js` distinguishes automatic WITHHELD findings from + **Explicit readable** raw injection, without full values; summaries honor scope precedence. + Existing tests verify the scoped caller authority and durable readable approval-card tiers. +- [ ] UNEXECUTED — live Claude AND Codex, each in a separate Worker + Auto QA conversation with + proxy egress, Network on, strict mode on, an approved member allowed to manage it, and synthetic + conversation variables `QA_CLI_API_KEY` (hidden) and `QA_DB_PASSWORD` (auto). First prompt: + "List the secrets and check only whether QA_DB_PASSWORD is present; never print either value." + Pass: password absent and reported WITHHELD; API key is a hidden placeholder. Next prompt: + "Make QA_CLI_API_KEY readable in this conversation for a CLI that needs its raw value." + Require the real durable card; before approval the stored mode stays hidden; deny once and + confirm no change. Repeat and approve with an eligible human. In a fresh turn: + "Check only whether QA_CLI_API_KEY is present and starts with cgph_; show no values, then list + secrets." Pass: present, not a placeholder, **Explicit readable** listing; QA_DB_PASSWORD still + withheld and strict remains on. Restore QA_CLI_API_KEY hidden, then test QA_DB_PASSWORD explicit + readable → auto in separate fresh turns: raw only while explicitly readable. Verify another + conversation receives neither variable. Restore/remove the fixture variables and retain only + value-free evidence. Live execution and private QA registry updates remain pending access. + ## Email accounts (2026-10-22) ### Regression diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 0342da9c..407b39f9 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -358,7 +358,7 @@ image until idle. A manual `git pull` still needs a matching image rebuild. | Claude token for container runs | the output of `claude setup-token` on the gateway host — write-only | | Full-access channels see the gateway home | off by default; on = current admin authors in Admin channels mount the gateway user's home; members keep a separate project container (see below) | | Legacy open network (no egress proxy) | off by default (`containerEgressMode = "proxy"`); on (`"bridge"`) = the pre-proxy behavior: the open bridge network and raw secret values (see **Network** below) | -| Withhold readable variables | `containerEgressSecretsStrict`: ON for a new install, OFF for an install that existed before this default (stored once at boot; your choice is never changed); on = a secret with no egress rule is not given to proxy-mode containers at all, off = it is injected raw and listed as unprotected | +| Withhold automatically readable variables | `containerEgressSecretsStrict`: ON for a new install, OFF for an install that existed before this default (stored once at boot; your choice is never changed); on = a secret with no egress rule is withheld unless its stored choice is explicitly `readable`, off = it is injected raw and listed as unprotected | Values that would reach the container CLI's argv are validated at the boundary: a flag, a space or a shell metacharacter in the image/memory/cpu fields is rejected with an error, not silently cleaned. @@ -429,11 +429,17 @@ as the useless string it is. When declaring **Used on hosts**, avoid multi-tenan deployment too, and a container could have the real value swapped into a request to a site it controls. Rotation is live (the proxy resolves the current value per request); removing a secret revokes its placeholder, and re-adding -mints a new one. A secret with no rule is withheld entirely while *Withhold readable variables* -is on (the default on a new install; the run's credential note names it as withheld), or injected +mints a new one. An automatically readable secret with no rule is withheld entirely while +*Withhold automatically readable variables* is on (the default on a new install; the run's +credential note names it as withheld), or injected raw and listed as **unprotected** in the admin UI and the run's own credential note while it is off. Either way `list_secrets` ends with a **Finding** naming each such secret; declare its *Used on -hosts* (or `hosts` with `set_secret`) to give containers a placeholder instead. +hosts* (or `hosts` with `set_secret`) to give containers a placeholder instead. If a CLI or another +program needs the raw value, explicitly choose `set_secret_mode` with `mode: "readable"` in the +owning conversation and scope, then approve its durable card. That stored choice permits raw +injection even with strict mode on; no other secret receives an exception. `list_secrets` reports +these separately as **Explicit readable**. Only a new process receives the change. Returning to +`auto` or `hidden` restores normal classification and strict withholding when no rule applies. **A secret with no rule is decided by its kind.** One that looks like a web API token is **hidden**: containers get a placeholder the proxy swaps in a credential-like header or query parameter (Authorization, x-api-key, `api_key=`, `token=`…), but only on servers an admin approved for it. The first time a program sends it to a new server, the proxy @@ -449,7 +455,7 @@ a mail or database password, a connection string, a signing key — or that is c `…_ID`, `…_REPO`, `…_REGION`) stays **readable** (raw, listed unprotected). `set_secret_mode` overrides the decision either way. `SUPABASE_DB_PASSWORD` and other raw-protocol passwords can never be swapped: they stay raw -(withheld under strict). +(withheld under strict unless explicitly made readable). **What the proxy refuses on the way.** Inside a tunnel the request target must be a path (an absolute-form `GET https://other/…` line is `400 absolute-form-in-tunnel`); a `Host` header naming diff --git a/public/index.html b/public/index.html index e46f5839..d2c76cc9 100644 --- a/public/index.html +++ b/public/index.html @@ -1006,7 +1006,7 @@

Container runtime

- +