Skip to content

feat(workspace-studio): Roux Ingest HTTP-mode add-on endpoint - #112

Merged
chitcommit merged 10 commits into
mainfrom
feat/workspace-studio-roux-ingest
Jun 4, 2026
Merged

chitcommit merged 10 commits into
mainfrom
feat/workspace-studio-roux-ingest

Conversation

@chitcommit

@chitcommit chitcommit commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 2 of the ChittyRoux × Workspace Studio integration. chittycommand IS the Workspace Add-on backend (HTTP mode, GA — no Apps Script project). Google's Workspace Studio invokes our endpoints directly when a workflow author drops the Roux Ingest custom step into a Gmail-triggered routine.

Stack diagram

Gmail-triggered workflow (Workspace Studio)
        │
        ▼ POST /workspace/studio/roux-ingest/execute
        │   { authorizationEventObject: { systemIdToken, userIdToken, userOAuthToken },
        │     channel_id, event.workflow.actionInvocation.inputs.* }
        │
chittycommand worker (this PR)
  ├─ workspaceAuth() ───── verifyWorkspaceSystemIdToken (SA pin)
  │                        verifyWorkspaceUserIdToken  (aud pin)
  │                          JWKS cached in COMMAND_KV (gcp:jwks, 3600s)
  ├─ verifyRegisteredChannel(channel_id, env)
  │     v1: REGISTERED_CHANNELS_JSON env-var allowlist
  │     v2: agent.chitty.cc/api/v1/channels/{id}  (TODO)
  ├─ idempotency: cc_intents.payload->'source'->>'message_id'
  ├─ deriveRouxFromType(classification || dispute_type)
  ├─ createGoal → createPlan → createIntent (privilege + space at insert)
  ├─ Roux gate decision attached to payload.gate_outcome
  └─ executionCtx.waitUntil(...) fan-out:
       ├─ storage_ingest (SVC_STORAGE) per attachment
       ├─ evidenceClient.addCustodyEntry  (if privileged/legalink)
       └─ routerClient.classifyDispute     (second pass)

Files

  • src/lib/workspace-jwt.ts — JWKS-cached RS256 verifier (injectable URL via GCP_JWKS_URL so tests don't mock fetch).
  • src/middleware/workspace-auth.ts — Hono middleware, parses authorizationEventObject, populates c.get('workspaceContext').
  • src/lib/channel-registry.ts — v1 env-var allowlist; v2 HTTP TODO; exports WORKSPACE_STUDIO_CHANNEL_ID = 'chitty:channel:workspace-studio-gmail'.
  • src/routes/workspace-studio.ts/config and /execute handlers.
  • src/index.ts — mount under /workspace/studio/roux-ingest (NOT /api/*); add new Env fields.
  • tests/routes/workspace-studio-ingest.spec.ts — real JWT round-trip + DB-backed integration tests.
  • package.json+jose@^6.2.3.

Deps (env / bindings)

Required at runtime; deliberately not added to wrangler.jsonc in this PR — the ChittyConnect concierge round-4 lands the binding sub-PR:

Var Source Notes
REGISTERED_CHANNELS_JSON Cloudflare var JSON object keyed by channel_id. Add chitty:channel:workspace-studio-gmail entry.
CHITTYROUX_GCP_SA_EMAIL Cloudflare var chittyclaw@chittyops.iam.gserviceaccount.com
CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID Cloudflare var 443939537625-a0un9jpol6gi53h7t53kbn4jic0o3c0m.apps.googleusercontent.com
CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_SECRET Secrets Store concierge provisioning in flight
GCP_JWKS_URL (optional) Cloudflare var defaults to https://www.googleapis.com/oauth2/v3/certs

Test evidence

$ npx vitest run tests/routes/workspace-studio-ingest.spec.ts
Test Files  1 passed (1)
     Tests  4 passed | 5 skipped (9)

The 4 passing tests cover JWT verification round-trip (correct SA + audience, wrong SA, wrong audience, user-token extraction) — these use a real RS256 keypair generated by jose and served from a local Node HTTP server. No mocked fetch, no mocked crypto.

The 5 skipped tests are the Neon-backed integration tests (intent round-trip, idempotency, gate suppression, defensive parsing, channel reject). They skip when DATABASE_URL is absent — matching the established pattern in tests/routes/triage-roux.spec.ts and tests/meta/intent-lifecycle.spec.ts. See "Deferred / not done" below for the Neon-branch autoprovision conflict.

Typecheck: tsc --noEmit clean.

Pre-existing failure in tests/mcp.test.ts (expects 54 tools, finds 50) — present on main before this PR, not introduced here.

Idempotency rationale

Gmail message_id is the natural dedup key — a single Gmail message can re-trigger a Workspace Studio workflow (retries, label-change loops, manual re-runs). We look up cc_intents WHERE payload->'source'->>'message_id' = $1 AND intent_type = 'roux_ingest'; on hit we return the cached intent_id and skip the entire fan-out. The Idempotency-Key header (default gmail-{message_id}) is logged on the intent metadata for observability but the DB lookup is the source of truth.

Channel ChittyID

Used literal: chitty:channel:workspace-studio-gmail. No ChittyID generator was found in src/lib/ or meta/. This value should be added to REGISTERED_CHANNELS_JSON with platform: 'google_workspace', capabilities: ['gmail.ingest'], status: 'active'.

Deferred / not done

  • Phase 2.5 (decay cron) — intent decay / re-triage on stale pending rows. Deferred — separate cron PR.
  • Phase 3 (bidirectional Gmail label sync) — writing Roux outcome back to Gmail labels on the user's behalf via stored userOAuthToken. Deferred — requires OAuth scope upgrade and label-mapping config.
  • Neon-branch auto-provision in tests — the task spec called for create_branch in beforeAll. This repo's established test pattern is skipIf(!DATABASE_URL) with TEST_TAG cleanup (see tests/routes/triage-roux.spec.ts). No autoprovision helper exists. Followed the established pattern; surfacing the conflict here so the team can decide whether to add a tests/_setup/neon-branch.ts helper as a follow-up.
  • wrangler.jsonc bindings — concierge round-4 sub-PR.

Test plan

  • Concierge round-4 lands the binding sub-PR with REGISTERED_CHANNELS_JSON, CHITTYROUX_GCP_SA_EMAIL, CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID, and the CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_SECRET Secrets Store binding.
  • Run DATABASE_URL=<neon-branch-url> npx vitest run tests/routes/workspace-studio-ingest.spec.ts against a dev branch and confirm all 9 tests pass.
  • Deploy to command.chitty.cc, register the Workspace Studio custom step, fire a test Gmail message, confirm cc_intents row appears with correct privilege+space and the Notion gate behaved as expected.

Summary by CodeRabbit

  • New Features

    • Workspace Studio Roux ingest: configuration and execute endpoints with Google Workspace authentication, channel capability checks, idempotent Gmail-message ingest, evidence custody handling, and automated dispute classification.
    • Consistent Roux classification merging to improve sensitivity/space decisions.
  • Tests

    • Added integration tests exercising JWT verification and the ingest flow for increased reliability.

Phase 2 of the ChittyRoux x Workspace Studio integration.
chittycommand IS the Workspace Add-on (HTTP mode, not Apps Script).

New endpoints under /workspace/studio/roux-ingest:
  POST /config   — single-card config (TextInputs for endpoint + default
                   privilege)
  POST /execute  — verifies systemIdToken + userIdToken, looks up channel
                   in REGISTERED_CHANNELS_JSON, derives Roux from
                   classification, creates Goal→Plan→Intent chain with
                   privilege+space tagged at creation, applies the
                   privileged/pii/legalink suppression gate, fans out
                   storage_ingest + addCustodyEntry + classifyDispute
                   under c.executionCtx.waitUntil to stay under the 30s
                   ceiling.

Idempotency by Gmail message_id via JSON-path lookup on
cc_intents.payload->'source'->>'message_id'.

JWKS verifier is injectable (env.GCP_JWKS_URL) so tests run against a
local RS256 keypair without mocking global fetch. JWKS responses cache
in COMMAND_KV under gcp:jwks with 3600s TTL.

Channel registry is v1 env-var lookup; v2 will hit
agent.chitty.cc/api/v1/channels/{id} — TODO inline. Canonical channel ID:
chitty:channel:workspace-studio-gmail.

Wrangler bindings (CHITTYROUX_GCP_SA_EMAIL,
CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID,
CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_SECRET, REGISTERED_CHANNELS_JSON) are
deliberately NOT pushed in this PR — concierge round-4 lands them
separately.

Tests: 4 JWT tests pass without DB; 5 route tests skip without
DATABASE_URL, matching the established pattern in
tests/routes/triage-roux.spec.ts. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 4, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
chittycommand c2d023e Jun 04 2026, 01:41 PM

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 88829c62-9e71-4683-8a27-fb5f94f3ebc0

📥 Commits

Reviewing files that changed from the base of the PR and between 0ac340c and c2d023e.

📒 Files selected for processing (8)
  • meta/intent.ts
  • migrations/0017_roux_ingest_idempotency.sql
  • src/lib/channel-registry.ts
  • src/lib/dispute-sync.ts
  • src/lib/workspace-jwt.ts
  • src/middleware/workspace-auth.ts
  • src/routes/workspace-studio.ts
  • tests/routes/workspace-studio-ingest.spec.ts

📝 Walkthrough

Walkthrough

This PR implements a complete Google Workspace Studio Roux ingest endpoint with JWT-based authentication, channel registry validation, idempotent intent creation, async side-effect scheduling, a DB migration, and comprehensive tests.

Changes

Workspace Studio Roux Ingest Integration

Layer / File(s) Summary
Dependencies and Environment Configuration
package.json, src/index.ts
jose dependency pinned to ^6.2.3; Env type extended with Workspace Studio integration fields (OAuth credentials, JWKS URL, registered channels JSON, service account email); workspaceStudioRoutes mounted at /workspace/studio/roux-ingest outside global auth middleware.
Channel Registry and Validation
src/lib/channel-registry.ts
ChannelMeta interface and WORKSPACE_STUDIO_CHANNEL_ID constant defined; verifyRegisteredChannel() resolves channel metadata from REGISTERED_CHANNELS_JSON, enforces status==='active', and validates required capabilities.
JWT Verification: JWKS Caching and Token Validation
src/lib/workspace-jwt.ts
WorkspaceJWTError class and WorkspaceTokenClaims interface; getJWKS() implements KV-backed caching (3600s TTL) with fallback HTTP fetch; verifyWithJWKS() decodes JWT header, selects/imports JWK by kid, and verifies audience; requireIssuer() enforces allowed issuers.
System and User ID Token Verification
src/lib/workspace-jwt.ts
verifyWorkspaceSystemIdToken() requires service account email and requestUrl audience and enforces email match; verifyWorkspaceUserIdToken() requires marketplace client ID as audience and non-empty email; both return typed claims.
Workspace Authentication Middleware
src/middleware/workspace-auth.ts
workspaceAuth() middleware parses Apps-Script-style body, validates presence of systemIdToken and userIdToken, performs verifications, and stashes workspaceContext and workspaceBody on Hono context; returns structured Google-style error cards on failure.
Workspace Studio Routes: Setup and Config
src/routes/workspace-studio.ts
workspaceStudioRoutes Hono app created; POST /config returns a single-step configuration card for "ChittyCommand — Roux Ingest".
Workspace Studio Routes: Execute and Handlers
src/routes/workspace-studio.ts
POST /execute guarded by workspaceAuth(): extracts inputs from nested and flat shapes, validates channel registration and capabilities, enforces message_id idempotency, derives roux privilege/space and gate_outcome, creates goal→plan→intent (idempotent), returns stepSuccess/stepError, and schedules best-effort async fan-out (attachment ingestion, custody recording, second-pass classification). Includes defensive input extractors and response builders.
Idempotent Intent Creation & Migration
meta/intent.ts, migrations/0017_roux_ingest_idempotency.sql
Adds createRouxIngestIntentIdempotent() performing INSERT ... ON CONFLICT DO NOTHING keyed by payload.source.message_id and returning created/fetched intent; adds partial unique index to enforce message_id uniqueness for roux_ingest.
Roux Classification Types
src/lib/dispute-sync.ts
Adds RouxPrivilege and RouxSpace types, rank maps, and mergeRouxClassification(); updates deriveRouxFromType() return type to use the new types.
Test Infrastructure and Integration Tests
tests/routes/workspace-studio-ingest.spec.ts
Local JWKS test server with RS256 keypair, signTestToken() helper, in-memory COMMAND_KV stub, lifecycle management, JWT verification unit tests, and integration tests for intent derivation, idempotency, privileged/legal behavior, input shapes, and unregistered-channel rejection (DB-backed tests skipped when DB unavailable).

Sequence Diagram(s)

sequenceDiagram
  participant AppsScript
  participant WorkspaceAuth
  participant ExecuteHandler
  participant ChannelRegistry
  participant DB as cc_intents
  participant GoalService as createGoal
  participant PlanService as createPlan
  participant IntentService as createRouxIngestIntentIdempotent
  participant ExecutionCtx as waitUntil

  AppsScript->>WorkspaceAuth: POST /execute with authorizationEventObject
  WorkspaceAuth->>ExecuteHandler: validated workspaceContext + body
  ExecuteHandler->>ChannelRegistry: verifyRegisteredChannel(channel_id)
  ChannelRegistry-->>ExecuteHandler: ChannelMeta | null
  ExecuteHandler->>DB: SELECT by message_id
  alt existing intent
    DB-->>ExecuteHandler: existing intent row
    ExecuteHandler-->>AppsScript: stepSuccess (idempotent_hit: true)
  else no existing
    ExecuteHandler->>GoalService: createGoal(...)
    GoalService-->>ExecuteHandler: goal_id
    ExecuteHandler->>PlanService: createPlan(goal_id,...)
    PlanService-->>ExecuteHandler: plan_id
    ExecuteHandler->>IntentService: createRouxIngestIntentIdempotent(plan_id,...)
    IntentService-->>ExecuteHandler: intent_id, created:true
    ExecuteHandler->>ExecutionCtx: waitUntil(ingestAttachment, recordCustody, classify)
    ExecuteHandler-->>AppsScript: stepSuccess (intent_id)
  end
Loading

Estimated code review effort:
🎯 4 (Complex) | ⏱️ ~45 minutes

"A workspace studio blooms with care,
Tokens verified through the air,
Channels registered, intents take flight,
JWT chains keep auth tight.
From Apps Script to the DB's keep,
Roux ingest runs swift and deep." 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a new Workspace Studio Roux Ingest HTTP endpoint. It is concise, specific, and clearly reflects the primary focus of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/workspace-studio-roux-ingest

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
  1. @coderabbitai review
  2. @copilot review
  3. @codex review
  4. @claude review
    Adversarial review request: evaluate security, policy bypass paths, regression risk, and merge-gating bypass attempts.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

chitcommit added a commit that referenced this pull request Jun 4, 2026
…113)

PR #104 round-3 (commit aa2f2d0) added scope-based filtering of triage_*
MCP tools from tools/list when the caller lacks chittytriage:write. The
test environment uses the mcpAuthMiddleware dev bypass which grants only
scope ['mcp'], so the 4 triage tools are filtered out and tools.length
is 50, not 54. The unconditional `expect(tools.length).toBe(54)` was
failing on main and blocking PR #112's CI build.

This commit:
- Renames the existing assertion to "exposes 50 tools to unscoped
  callers (triage tools hidden)" and adds explicit assertions that the
  4 triage_* tools are absent from the catalog.
- Adds a second test "exposes all 54 tools to callers with triage
  scope" that injects scopes=['chittytriage:write'] directly via a
  custom middleware (the dev bypass cannot grant triage scope, and the
  production code path would require mocking ChittyAuth fetch — direct
  scope injection is the deterministic way to exercise the scoped
  branch of tools/list).

Covers both code paths: hidden when unscoped, visible when scoped.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a4297cc97

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/workspace-jwt.ts Outdated
Comment thread src/routes/workspace-studio.ts Outdated
Comment thread src/routes/workspace-studio.ts
Comment thread src/routes/workspace-studio.ts
@chitcommit
chitcommit enabled auto-merge (squash) June 4, 2026 12:12
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
  1. @coderabbitai review
  2. @copilot review
  3. @codex review
  4. @claude review
    Adversarial review request: evaluate security, policy bypass paths, regression risk, and merge-gating bypass attempts.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ac340cdb5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/routes/workspace-studio.ts Outdated
Comment thread src/routes/workspace-studio.ts Outdated
Comment thread src/routes/workspace-studio.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
src/routes/workspace-studio.ts (3)

93-129: ⚖️ Poor tradeoff

Request body validation uses manual extraction instead of Zod.

Per coding guidelines: "Use @hono/zod-validator with zValidator('json', schema) for request body validation" and "All user input must be validated with Zod before use in route handlers."

The current defensive extraction approach handles undocumented payload shapes gracefully, but adding Zod schema validation would provide stronger type guarantees and consistent error responses.

If the Google payload shape stabilizes, consider defining a Zod schema in src/lib/validators.ts and applying it after workspaceAuth() middleware. As per coding guidelines: "Zod schemas for request validation... must be defined in src/lib/validators.ts and applied to route handlers."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/workspace-studio.ts` around lines 93 - 129, Replace the manual
extraction in the workspaceStudioRoutes.post('/execute', workspaceAuth(), ...)
handler with Zod validation via zValidator('json', schema): define a Zod schema
(in your validators module) that accepts the two observed shapes (flat and
Apps-Script-style nested) for fields message_id, subject, from, dispute_type,
classification, attachment_ids, drive_folder_url and sheet_row_url (use
unions/transformations to normalize into a single shape and provide defaults
like dispute_type='public' and empty attachment_ids), then apply
zValidator('json', yourSchema) as middleware before the handler and read
validated values from the parsed body instead of calling
extractScalar/extractInputScalar/extractList; remove the manual
extraction/verification code paths and keep the channel registration and
message_id presence checks but use the validated message_id.

134-140: Consider adding an index for JSON path idempotency query.

The query payload->'source'->>'message_id' = ${messageId} performs a JSON path extraction on every row. At scale, this will become slow without a functional index.

Consider adding a GIN or expression index in a migration:

CREATE INDEX CONCURRENTLY idx_cc_intents_source_message_id 
ON cc_intents ((payload->'source'->>'message_id'))
WHERE intent_type = 'roux_ingest';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/workspace-studio.ts` around lines 134 - 140, The SELECT in
workspace-studio.ts against cc_intents uses a JSON path
payload->'source'->>'message_id' for idempotency which will be slow at scale;
add a DB migration that creates an expression index (e.g., name it
idx_cc_intents_source_message_id) on the extracted message_id expression from
payload and include a WHERE clause limiting it to intent_type = 'roux_ingest',
creating it CONCURRENTLY so it doesn't block writes; update migrations and run
them so the query (referenced where messageId is used) benefits from the new
index.

31-31: Import path ../../meta/intent in src/routes/workspace-studio.ts resolves correctly
../../meta/intent points to meta/intent.ts in the repo root and exports createIntent, createGoal, and createPlan; there’s no module-resolution problem. Consider re-exporting/moving this under src/ only if the project’s import conventions require it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/workspace-studio.ts` at line 31, The import of createIntent,
createGoal, and createPlan from ../../meta/intent is valid (it resolves to
meta/intent.ts at repo root); no code change is required, but if your project's
convention mandates imports under src, either move meta/intent.ts into src/meta
or add a re-export from src (e.g., export { createIntent, createGoal, createPlan
} from '../../meta/intent' in src/meta/index.ts) and update the import in
workspace-studio.ts to import from the src-based path.
src/lib/workspace-jwt.ts (1)

114-114: 💤 Low value

Consider hardcoding algorithm to 'RS256' for defense in depth.

While the current implementation is secure (jose validates with algorithms: [alg] and keys come from trusted JWKS), trusting header.alg is generally discouraged to prevent algorithm confusion attacks. Since Google exclusively uses RS256 for these tokens, explicitly setting const alg = 'RS256'; would remove any theoretical risk.

🔒 Proposed hardening
-  const alg = header.alg || (match.alg as string) || 'RS256';
+  const alg = 'RS256'; // Google Workspace tokens always use RS256
   const key = await importJWK(match, alg);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/workspace-jwt.ts` at line 114, The code currently derives alg from
header or match before verifying JWT in the verifyWorkspaceJwt (or the function
that computes `alg` at the header handling) which can allow algorithm confusion;
change the assignment to hardcode `const alg = 'RS256';` and ensure any call to
jose.verify or JWKS verification that uses `alg` (e.g., the options `algorithms:
[alg]`) continues to use this constant so verification only allows RS256; update
references to `header.alg`/`match.alg` near the `alg` declaration (in the JWT
verification function) to remove reliance on header-provided values.
src/lib/channel-registry.ts (1)

58-63: ⚡ Quick win

Consider adding Zod schema validation for parsed channel metadata.

The type assertion at line 59 doesn't provide runtime validation. While the function safely returns null for malformed data, adding Zod validation would make the contract explicit and catch structural mismatches earlier.

♻️ Suggested enhancement with Zod validation
import { z } from 'zod';

const ChannelMetaSchema = z.object({
  channel_id: z.string(),
  chitty_id: z.string(),
  platform: z.string(),
  capabilities: z.array(z.string()),
  contact_endpoint: z.string().optional(),
  status: z.enum(['active', 'suspended', 'pending']),
});

// In verifyRegisteredChannel:
try {
  const parsed = z.record(ChannelMetaSchema).parse(JSON.parse(raw));
  const meta = parsed[channelId];
  // ...
} catch {
  console.warn('[channel-registry] REGISTERED_CHANNELS_JSON validation failed');
  return null;
}

As per coding guidelines, Zod schemas should be used for request validation across routes and are defined in src/lib/validators.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/channel-registry.ts` around lines 58 - 63, In verifyRegisteredChannel
replace the unchecked JSON type assertion by parsing and validating
REGISTERED_CHANNELS_JSON with a Zod schema: add a ChannelMetaSchema (or reuse
one from src/lib/validators.ts) describing channel_id, chitty_id, platform,
capabilities, optional contact_endpoint and status enum, then use
z.record(ChannelMetaSchema).parse(JSON.parse(raw)) to get a validated map,
lookup parsed[channelId], and keep the existing null-return path on
parse/validation failure while logging a more specific validation failure
message; reference verifyRegisteredChannel and ChannelMetaSchema when making the
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/routes/workspace-studio.ts`:
- Around line 208-216: The catch block for createIntent returns an error JSON
that exposes internal error details by including err.message; change it to
return a generic client-facing message (e.g., "Unable to create intent") in the
stepError call while logging the full error server-side via the existing logger
(or console.error) for diagnostics; update the catch around createIntent (the
catch that builds stepError with 'INTENT_CREATE_FAILED') to sanitize the
response and ensure only non-sensitive, retryability info is returned to the
client.

In `@tests/routes/workspace-studio-ingest.spec.ts`:
- Around line 114-117: The DB cleanup hooks currently only check DATABASE_URL
and still run when skip mode is enabled; update the conditional guards to also
respect the skip flag by checking process.env.SKIP_DB (or SKIP_INTEGRATION if
your project uses that) — e.g., change occurrences like if (DATABASE_URL) { ...
} to if (DATABASE_URL && !process.env.SKIP_DB) { ... } (and similarly for the
other hook), so functions referring to DATABASE_URL and the TEST_TAG cleanup
only run when skip mode is not set.
- Around line 37-38: Replace hardcoded credential constants SA_EMAIL and
CLIENT_ID (and any other credential-like constants around the 106-113 region) so
tests read values from the environment or injected secrets at runtime instead of
literal strings; modify the test setup to use process.env (or the test
framework's secret injection) to populate SA_EMAIL and CLIENT_ID and add a clear
fallback or test-only guard that fails if the env var is missing, ensuring no
secret literals remain in the file and that credentials are provided via
PLAID_CLIENT_ID/PLAID_SECRET-style env variables or the test runner's secret
mechanism.

---

Nitpick comments:
In `@src/lib/channel-registry.ts`:
- Around line 58-63: In verifyRegisteredChannel replace the unchecked JSON type
assertion by parsing and validating REGISTERED_CHANNELS_JSON with a Zod schema:
add a ChannelMetaSchema (or reuse one from src/lib/validators.ts) describing
channel_id, chitty_id, platform, capabilities, optional contact_endpoint and
status enum, then use z.record(ChannelMetaSchema).parse(JSON.parse(raw)) to get
a validated map, lookup parsed[channelId], and keep the existing null-return
path on parse/validation failure while logging a more specific validation
failure message; reference verifyRegisteredChannel and ChannelMetaSchema when
making the change.

In `@src/lib/workspace-jwt.ts`:
- Line 114: The code currently derives alg from header or match before verifying
JWT in the verifyWorkspaceJwt (or the function that computes `alg` at the header
handling) which can allow algorithm confusion; change the assignment to hardcode
`const alg = 'RS256';` and ensure any call to jose.verify or JWKS verification
that uses `alg` (e.g., the options `algorithms: [alg]`) continues to use this
constant so verification only allows RS256; update references to
`header.alg`/`match.alg` near the `alg` declaration (in the JWT verification
function) to remove reliance on header-provided values.

In `@src/routes/workspace-studio.ts`:
- Around line 93-129: Replace the manual extraction in the
workspaceStudioRoutes.post('/execute', workspaceAuth(), ...) handler with Zod
validation via zValidator('json', schema): define a Zod schema (in your
validators module) that accepts the two observed shapes (flat and
Apps-Script-style nested) for fields message_id, subject, from, dispute_type,
classification, attachment_ids, drive_folder_url and sheet_row_url (use
unions/transformations to normalize into a single shape and provide defaults
like dispute_type='public' and empty attachment_ids), then apply
zValidator('json', yourSchema) as middleware before the handler and read
validated values from the parsed body instead of calling
extractScalar/extractInputScalar/extractList; remove the manual
extraction/verification code paths and keep the channel registration and
message_id presence checks but use the validated message_id.
- Around line 134-140: The SELECT in workspace-studio.ts against cc_intents uses
a JSON path payload->'source'->>'message_id' for idempotency which will be slow
at scale; add a DB migration that creates an expression index (e.g., name it
idx_cc_intents_source_message_id) on the extracted message_id expression from
payload and include a WHERE clause limiting it to intent_type = 'roux_ingest',
creating it CONCURRENTLY so it doesn't block writes; update migrations and run
them so the query (referenced where messageId is used) benefits from the new
index.
- Line 31: The import of createIntent, createGoal, and createPlan from
../../meta/intent is valid (it resolves to meta/intent.ts at repo root); no code
change is required, but if your project's convention mandates imports under src,
either move meta/intent.ts into src/meta or add a re-export from src (e.g.,
export { createIntent, createGoal, createPlan } from '../../meta/intent' in
src/meta/index.ts) and update the import in workspace-studio.ts to import from
the src-based path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f068beb1-8404-49d5-b2ea-f50731342cb8

📥 Commits

Reviewing files that changed from the base of the PR and between 167de96 and 0ac340c.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (7)
  • package.json
  • src/index.ts
  • src/lib/channel-registry.ts
  • src/lib/workspace-jwt.ts
  • src/middleware/workspace-auth.ts
  • src/routes/workspace-studio.ts
  • tests/routes/workspace-studio-ingest.spec.ts

Comment thread src/routes/workspace-studio.ts
Comment thread tests/routes/workspace-studio-ingest.spec.ts
Comment thread tests/routes/workspace-studio-ingest.spec.ts
chitcommit and others added 7 commits June 4, 2026 13:28
…L (P1)

Per Google's Workspace HTTP add-on docs
(https://developers.google.com/workspace/add-ons/guides/alternate-runtimes#validate_requests),
the systemIdToken `aud` claim is the full endpoint URL Google was configured
to call, not the OAuth client_id. The OAuth client_id audience is used only
for userIdToken. Reusing CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID for
systemIdToken would have rejected every real Workspace invocation with
TOKEN_INVALID before the handler ran.

- verifyWorkspaceSystemIdToken now requires `requestUrl` and pins aud to it
- workspaceAuth middleware passes c.req.url
- userIdToken aud remains pinned to CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID
- Tests updated: systemIdToken signs with endpoint URL, new regressions
  reject (a) systemIdToken using CLIENT_ID and (b) userIdToken using URL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ctest (P2)

Prior code `deriveRouxFromType(classification || disputeType)` ignored the
caller's dispute_type whenever classification was non-empty. A Workspace
flow that sent classification="public" but dispute_type="legal" was stored
as public/business with gate_outcome=mirrored — leaking privileged content
to the public Notion bucket.

- New mergeRouxClassification(a, b) helper in dispute-sync.ts picks the
  more sensitive privilege AND space independently
  (public<hoa_evidentiary<pii<privileged; business<legalink)
- Route derives roux from both signals and merges

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Prior SELECT-then-INSERT was TOCTOU-racy: two concurrent Google retries of
the same Gmail event could both pass the SELECT pre-check before either
INSERT landed, producing duplicate roux_ingest intents and double-fanned
side effects.

- New migration 0017_roux_ingest_idempotency.sql: partial unique index
  on cc_intents ((payload->'source'->>'message_id'))
  WHERE intent_type='roux_ingest' AND ...->>'message_id' IS NOT NULL
- New createRouxIngestIntentIdempotent in meta/intent.ts:
  INSERT ... ON CONFLICT (...) WHERE ... DO NOTHING RETURNING *, falls
  back to re-SELECT when conflict fires
- Route uses the helper; loser of the race skips fanout (winner already
  dispatched it)
- Sanitized createIntent error to drop err.message from client response

Validated on disposable Neon branch (br-lingering-band-akqje5lm):
created index, ran double-insert with ON CONFLICT — second insert returned
empty result set (DO NOTHING fired), count remained 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Gmail's users.messages.attachments.get endpoint requires BOTH messageId and
attachment id. Prior fanout only passed attachment_id + OAuth token, so
chittystorage couldn't reliably hit the Gmail API path for attachments not
already mirrored to Drive.

- ingestAttachment now takes gmailMessageId and includes it in the
  storage_ingest payload as `gmail_message_id`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…act (P2)

Workspace Studio's onExecuteFunction reads step outputs from
hostAppAction.workflowAction.returnOutputVariablesAction.outputVariables[].
Returning bare `outputs` left downstream Studio steps with no variables to
reference (https://developers.google.com/workspace/add-ons/studio/output-variables).

- stepSuccess now wraps each output as {name, value} under
  returnOutputVariablesAction.outputVariables
- stepError uses returnElementErrorAction with errorActionability /
  errorRetryability / errorLog / errorMessage / errorCode
- Bare `outputs`/`error` retained as non-breaking shim for the existing
  integration tests that read them directly

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…y (P2)

Prior verifyRegisteredChannel only checked registration + active status, so
ANY active channel in REGISTERED_CHANNELS_JSON could be supplied as
channel_id for any operation — including ones that lacked the required
capability. A flow could submit an active-but-non-gmail channel and still
create a roux_ingest intent targeting it.

- verifyRegisteredChannel takes requiredCapabilities: string[] and rejects
  channels missing any one
- workspace-studio /execute asserts ['gmail.ingest'], with separate
  CHANNEL_NOT_REGISTERED vs CHANNEL_MISSING_CAPABILITY error codes so
  operators can diagnose which gate fired

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Workspace Studio's onConfigFunction expects the Card returned directly as
the response body. The renderActions.action.navigations.pushCard wrapper
used previously is for runtime cards in regular add-ons — Studio
configuration cards explicitly disallow pushCard navigation
(https://developers.google.com/workspace/add-ons/studio/configuration-cards#card_considerations_and_limitations).

The settings panel was likely failing to render with the prior shape;
return {sections:[...]} directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
  1. @coderabbitai review
  2. @copilot review
  3. @codex review
  4. @claude review
    Adversarial review request: evaluate security, policy bypass paths, regression risk, and merge-gating bypass attempts.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== src/lib/workspace-jwt.ts ===" && cat src/lib/workspace-jwt.ts
echo ""
echo "=== src/lib/channel-registry.ts ===" && cat src/lib/channel-registry.ts
echo ""
echo "=== src/middleware/workspace-auth.ts ===" && cat src/middleware/workspace-auth.ts
echo ""
echo "=== src/routes/workspace-studio.ts ===" && cat src/routes/workspace-studio.ts
echo ""
echo "=== src/index.ts ===" && cat src/index.ts
echo ""
echo "=== tests/routes/workspace-studio-ingest.spec.ts ===" && cat tests/routes/workspace-studio-ingest.spec.ts

Length of output: 54296



@chittyos — adversarial review below, covering security, policy bypass paths, regression risk, and merge-gate bypass. Issues are ranked by severity.


Adversarial Security Review — PR #112 (Phase 2 Workspace Studio Roux Ingest)


🔴 CRITICAL


1. Algorithm Confusion Attack — verifyWithJWKS (src/lib/workspace-jwt.ts, ~Line 92)

const alg = header.alg || (match.alg as string) || 'RS256';
const key = await importJWK(match, alg);
try {
  const { payload } = await jwtVerify(token, key, {
    audience: expectedAudience,
    algorithms: [alg],
  });

The alg is read from the untrusted JWT header first. This is the classic RS256→HS256 algorithm-confusion attack: an adversary crafts a token with alg: "HS256" in the header; importJWK is called on the RSA public JWK with algorithm HS256; and jwtVerify is restricted to ['HS256']. Whether jose permits this import and verification depends on the JWK's ktyjose v6 will likely throw on importJWK(rsaJwk, 'HS256'), but this path has not been tested, is undocumented behaviour, and must not be relied on.

Even if jose defends today, the pattern is wrong. Algorithm must come from the trusted JWK or be hardcoded; never from the token header.

Fix:

// Always use RS256 — Google Workspace tokens are RS256. Never trust header.alg.
const alg = (match.alg as string) || 'RS256';
const key = await importJWK(match, alg);
const { payload } = await jwtVerify(token, key, {
  audience: expectedAudience,
  algorithms: ['RS256'],   // ← hardcoded, not from header
});

2. JWKS Cache Poisoning via COMMAND_KV (src/lib/workspace-jwt.ts, Lines ~65–82)

The JWKS is cached under the fixed, unprefixed key gcp:jwks in COMMAND_KV — the same namespace used by every other route in this worker. Any other handler, scheduled job, or KV write path that can write to COMMAND_KV can replace gcp:jwks with an attacker-controlled public key. After the next cache miss/expiry is skipped by the TTL check, all subsequent token verifications use the attacker's key.

This is a lateral-movement vector: a lower-privilege code path (e.g., a compromised sync route that writes state to KV) can escalate to authentication bypass on the Workspace endpoint.

Mitigations (pick all three):

  1. Namespace the KV key: ws:gcp:jwks and document that it must be treated as security-sensitive.
  2. On cache hit, verify the cached keys array against a pinned key count (≥1) and validate that at least one key has alg: RS256 and kty: RSA.
  3. When GCP_JWKS_URL points to DEFAULT_JWKS_URL (Google), consider also pinning the HTTPS cert / domain rather than trusting the env var alone.

🟠 HIGH


3. kid-less Token Falls Back to jwks[0] — Attacker Controls Key Selection (src/lib/workspace-jwt.ts, Line ~89)

const match = jwks.find((k) => k.kid === header.kid) ?? jwks[0];

If the JWT header omits kid, or carries a kid that doesn't match any cached key, the code silently falls back to the first JWK in the array. Combined with finding #2 (cache poisoning), an attacker who inserts a poisoned JWKS where their key is first can forge tokens even with any kid — or simply craft tokens without a kid.

Fix: Require kid matching. If no match is found, reject with JWKS_NO_MATCH rather than falling back.

const match = jwks.find((k) => k.kid && k.kid === header.kid);
if (!match) {
  throw new WorkspaceJWTError('JWKS_NO_MATCH', `No JWK matching kid=${header.kid}`);
}

4. channel_id Defaults to Canonical ID — Policy Bypass by Omission (src/routes/workspace-studio.ts, Lines ~77–81)

const channelId =
  extractScalar(body, 'channel_id') ??
  extractInputScalar(body, 'channel_id') ??
  WORKSPACE_STUDIO_CHANNEL_ID;         // ← silently defaults

Any request bearer with a valid Google JWT (correctly signed systemIdToken + userIdToken) that omits channel_id automatically gets matched to the registered canonical channel, completely bypassing the channel registration gate. The channel check then passes, including the capability gate.

This means the channel verification provides no additional security boundary over JWT auth alone. A third party that obtains valid tokens (e.g. via a leaked service account credential or a phished user) and knows this endpoint can omit channel_id and proceed.

Fix: Remove the default; make channel_id a required field.

const channelId = extractScalar(body, 'channel_id') ?? extractInputScalar(body, 'channel_id');
if (!channelId) {
  return c.json(stepError('MISSING_INPUT', 'channel_id is required', 'NOT_RETRYABLE'), 400);
}

5. verifyRegisteredChannel Called Twice — Future TOCTOU (v2) (src/routes/workspace-studio.ts, Lines ~83–100)

const channelExists = await verifyRegisteredChannel(channelId, c.env);          // call 1
if (!channelExists) {  403  }
const channel = await verifyRegisteredChannel(channelId, c.env, REQUIRED_CAPS); // call 2
if (!channel) {  403  }

In v1 (env-var) these two calls are idempotent. In the planned v2 (HTTP fetch to agent.chitty.cc), two sequential network calls to the channel registry introduce a real TOCTOU window: a channel could be suspended between call 1 and call 2, or more dangerously, a cache entry could be modified in the KV store between calls.

Fix now, before v2 ships: Collapse into one call with capabilities required, and infer the error case:

const channel = await verifyRegisteredChannel(channelId, c.env, REQUIRED_CAPS);
if (!channel) {
  // Could be not-registered, suspended, or missing capability — surface all as 403
  return c.json(stepError('CHANNEL_REJECTED', `Channel ${channelId} is not authorized`, 'NOT_RETRYABLE'), 403);
}

If the error distinction between NOT_REGISTERED and MISSING_CAPABILITY is operationally required, add a separate channelExistsNoCapCheck flag to the registry return type instead of making two async calls.


🟡 MEDIUM


6. /config Endpoint Has No Authentication

workspaceStudioRoutes.post('/config', async (c) => {
  // Config endpoint does not require user auth
  return c.json({ sections: [  ] });
});

While the config card is currently static JSON, it reveals internal service names, endpoint URLs (https://command.chitty.cc), default values, and implementation details to any unauthenticated caller. There is also no CORS restriction on this sub-route. As the config endpoint evolves (e.g. if it starts reading channel state or user preferences), the absence of auth becomes a data-exposure risk.

Recommendation: Apply workspaceAuth() to /config as well, consistent with the execute endpoint.


7. Idempotency-Key Header Is Attacker-Controlled and Stored in Intent Metadata

const idempotencyKey = c.req.header('Idempotency-Key') ?? `gmail-${messageId}`;

This header is stored in intent.metadata.idempotency_key and surfaced in the success response. A caller can inject arbitrary values (log injection characters, SQLi-looking strings, PII). The field is not validated or sanitized. While not currently used for deduplication logic, if future code uses idempotency_key for lookups it could be exploited.

Fix: Validate or discard the header — if you accept it, constrain to [a-zA-Z0-9_-]{1,128}:

const raw = c.req.header('Idempotency-Key');
const idempotencyKey = (raw && /^[a-zA-Z0-9_\-]{1,128}$/.test(raw)) ? raw : `gmail-${messageId}`;

8. Real GCP Identifiers Committed in Test File (tests/routes/workspace-studio-ingest.spec.ts, Lines ~35–36)

const SA_EMAIL = 'chittyclaw@chittyops.iam.gserviceaccount.com';
const CLIENT_ID = '443939537625-a0un9jpol6gi53h7t53kbn4jic0o3c0m.apps.googleusercontent.com';

The GCP project number (443939537625) and the service account name (chittyclaw@chittyops) are now permanently in git history. Even if rotated later, this exposes the GCP project topology to anyone with repo read access. The OAuth client ID is especially sensitive — it is a stable identifier usable in phishing or scope-escalation attacks against users of this add-on.

Fix: Move to environment variables read from the test environment, never hardcoded.


9. content_hashes Is Always Empty — Fanout Never Updates Intent Payload

payload: {
  
  content_hashes: [] as string[],   // ← never populated
},

The ingestAttachment fanout fires asynchronously but never writes back to the intent's content_hashes field. The success response also returns content_hashes: []. This means the custody chain has no content hash anchors for Gmail attachments — breaking the evidentiary integrity guarantee for legalink/privileged content, which is the exact use-case this endpoint is designed to serve.

This is a data integrity regression against the custody/evidence requirements described in the PR objectives.


🟢 LOW / INFORMATIONAL


10. DB-Backed Integration Tests Always Skipped in CI — De-facto Merge-Gate Bypass

describe.skipIf(SKIP_DB)('workspace-studio route (real Neon)', () => {  });

Without a DATABASE_URL secret provisioned in CI, 5 of 9 tests never run. The PR can be merged with broken intent creation, broken idempotency, broken Roux derivation, or broken channel enforcement — none would be caught. The 4 passing tests are JWT-only (no DB).

Recommendation: Either provision a Neon branch in CI (as planned), or add a CI step that fails if SKIP_DB=1 and any skipIf tests are detected. At minimum, the PR description should note this as a blocking follow-up.


11. CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_SECRET Declared but Never Referenced

CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_SECRET?: string;  // in Env

This secret is declared in Env but never used anywhere in the implementation. If it's intended for future OAuth flows, document it. If it was supposed to be used for request signing or client credential validation, its absence is a missing security control.


12. User OAuth Token Forwarded in Plaintext JSON Body to Internal Storage

body: JSON.stringify({
  source: 'gmail',
  attachment_id: attachmentId,
  user_oauth_token: userOAuthToken,   // ← live OAuth token in request body
  intent_id: intentId,
}),

The live user_oauth_token (a Google OAuth bearer) is serialized into a JSON request body to SVC_STORAGE. Any logging, tracing, or error capture in the storage service that logs request bodies will capture a live OAuth token. Prefer passing it in a request header (Authorization: Bearer …) which is more likely to be redacted by logging middleware.


Summary Table

# Severity File Issue
1 🔴 Critical workspace-jwt.ts alg from untrusted header → algorithm confusion
2 🔴 Critical workspace-jwt.ts JWKS cached in shared KV → cache poisoning
3 🟠 High workspace-jwt.ts kid-less token falls back to jwks[0]
4 🟠 High workspace-studio.ts channel_id defaults to canonical ID → policy bypass
5 🟠 High workspace-studio.ts Double verifyRegisteredChannel → TOCTOU in v2
6 🟡 Medium workspace-studio.ts /config endpoint unauthenticated
7 🟡 Medium workspace-studio.ts Idempotency-Key header unsanitized, stored
8 🟡 Medium test file Real GCP SA email + client ID hardcoded
9 🟡 Medium workspace-studio.ts content_hashes never populated — evidentiary gap
10 🟡 Medium test file DB tests always skipped → merge-gate bypass
11 🟢 Low index.ts CLIENT_SECRET declared but unused
12 🟢 Low workspace-studio.ts OAuth token in request body instead of header

Issues #1, #2, #3, and #4 should be resolved before merge. #9 is a functional regression against the PR's own stated evidentiary integrity requirements and should also block merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chitcommit
chitcommit merged commit 85314e3 into main Jun 4, 2026
11 of 13 checks passed
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
  1. @coderabbitai review
  2. @copilot review
  3. @codex review
  4. @claude review
    Adversarial review request: evaluate security, policy bypass paths, regression risk, and merge-gating bypass attempts.

@chitcommit
chitcommit deleted the feat/workspace-studio-roux-ingest branch June 4, 2026 13:40
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

My adversarial security review for this PR is already posted above in this thread. Here's a quick summary for the other reviewers:

Merge-blocking issues (resolve before merge):

# Severity Issue
1 🔴 Critical workspace-jwt.tsalg taken from untrusted JWT header → algorithm confusion attack
2 🔴 Critical workspace-jwt.ts — JWKS cached under unprefixed key in shared COMMAND_KV → cache poisoning / auth bypass
3 🟠 High workspace-jwt.tskid-less token silently falls back to jwks[0] → attacker-controlled key selection
4 🟠 High workspace-studio.tschannel_id defaults to canonical channel ID when omitted → channel registration gate bypassed
9 🟡 Medium workspace-studio.tscontent_hashes always [] → evidentiary integrity broken for legalink/privileged intents (regression against PR's own stated goals)

Additional findings (non-blocking but should be tracked): double verifyRegisteredChannel call (TOCTOU in v2), unauthenticated /config endpoint, unsanitized Idempotency-Key header, real GCP SA email + OAuth client ID hardcoded in tests, DB-backed tests always skipped (merge-gate bypass), unused CLIENT_SECRET env var, OAuth token in request body instead of Authorization header.

Triggering a full review pass now.


🧠 Learnings used
Learnt from: CR
Repo: chittyos/chittycommand PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-04-06T22:38:24.770Z
Learning: Applies to src/middleware/auth.ts : The three auth middlewares (`authMiddleware`, `bridgeAuthMiddleware`, `mcpAuthMiddleware`) must not be bypassed or modified without security review

Learnt from: github-actions[bot]
Repo: chittyos/chittycommand PR: 0
File: :0-0
Timestamp: 2026-03-11T12:29:34.011Z
Learning: In chittyos/chittycommand src/middleware/auth.ts: The bridge service token comparison uses non-timing-safe string equality `token === serviceToken` instead of a constant-time compare function, creating a timing oracle for the bridge:service_token KV value.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4726829319

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

hostAppAction: {
workflowAction: {
returnOutputVariablesAction: {
outputVariables,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return variables as variableDataMap

In the current response shape this now wraps the data in returnOutputVariablesAction, but it still serializes the variables as an outputVariables array. Google's Workspace Studio step docs build the return action with setVariableDataMap(...) keyed by variable name, and the API reference describes setVariableDataMap(variables) / addVariableData(key, value) rather than an outputVariables list (see https://developers.google.com/workspace/add-ons/studio/build-a-step and https://developers.google.com/apps-script/reference/add-ons-response-service/return-output-variables-action). For any flow that consumes intent_id, gate_outcome, or another declared output in a later step, Studio can treat this successful HTTP response as having no output variables. Fresh evidence versus the earlier review comment is that the current code has added the host-app wrapper but still emits the undocumented outputVariables array.

Useful? React with 👍 / 👎.

// Google retries reach this point, exactly one wins the INSERT; the loser
// re-SELECTs and gets the winner's intent_id. The goal/plan rows from the
// losing race are orphaned but harmless.
const ownerChittyId = wsCtx.user_email; // user email is acceptable as owner anchor for now

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rejecting users with long emails

For Workspace users whose email address is longer than 64 characters, using wsCtx.user_email as ownerChittyId makes createGoal() insert it into cc_goals.owner_chitty_id, which is declared as varchar(64) in src/db/schema.ts. That insert happens before the intent is created, so those users get an INTENT_CREATE_FAILED 500 instead of a successful ingest; use a bounded internal identifier or hash for the owner anchor rather than the raw email.

Useful? React with 👍 / 👎.

Comment thread src/lib/workspace-jwt.ts
} catch {
throw new WorkspaceJWTError('TOKEN_MALFORMED', 'JWT header is not valid JSON');
}
const match = jwks.find((k) => k.kid === header.kid) ?? jwks[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refetch JWKS when the kid is absent

When Google starts signing tokens with a new kid while gcp:jwks still contains an older cached key set, this code falls back to jwks[0] and immediately verifies with the wrong key instead of treating the missing kid as a cache miss. Legitimate Workspace requests signed by the new key can be rejected for up to the one-hour KV TTL; require an exact kid match and refresh the JWKS before failing.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant