# Tenant, authentication, and request security audit ## Verdict The current Hub has a sound application-level foundation for ordinary org-admin CRUD isolation, but it is **not safe to deploy as a multi-tenant SaaS**. Several agent and asynchronous-action paths can cross the Organization/Project boundary, and the current process-global credential design directly contradicts the org-scoped encrypted-secret decision in ADR-0021. The answer to the audit question is therefore **yes**: current filesystem, Feishu-context, deferred authorization, card-action, and credential paths can cross or bypass the intended boundary. These are implementation divergences, not unspecified product behavior. ## Contract baseline The audit used these upstream requirements: - `Spec.System.Organization`: PROJECT and TEAM each have one Organization; TEAM→PROJECT grants are same-org only. - `Spec.System.ProjectWorkspace`: folders are transparent; folder/project placement must remain inside one Organization. - `Spec.System.Permission`: agent trigger requires EDIT, normal cancel requires MANAGE, and force release is outside the project role lattice. - `Spec.System.Memory`: a run may read Feishu context only from its project's bound chat. - `Spec.System.AgentSurface`: every agent file operation must stay inside that run's project workspace. - ADR-0019: authorization is evaluated against principals resolved at request time so membership changes take effect immediately. - ADR-0021: customer Feishu and model-provider credentials are org-scoped secrets, encrypted at rest, and accessed through a resolver. ## Confirmed protections The following paths are correctly fail-closed today: - Every `/api/org/:orgSlug/*` route calls `requireOrgRole`; the guard reloads the user, active membership, allowed OWNER/ADMIN role, and ACTIVE org for each request. - Project/session/folder/team service functions compare the requested object's Organization before returning or mutating it. Cross-org lookups return not found instead of revealing the foreign object. - `PermissionAuthorizer` derives the Organization from the resource before expanding team and external principals. `grantTeamProjectAccess` refuses a TEAM whose Organization differs from the Project and detects corrupt active grants when listing. - Feishu messages with no sender `open_id` are denied. Bound-chat triggers run through `agent.trigger` and `role.trigger` gates before creating an AgentRun. - OAuth state is signed, expiry-checked, and paired with an HttpOnly nonce cookie. `returnTo` is constrained to same-origin `/admin` paths. Session signatures use HMAC-SHA256 with constant-time comparison, and session users are reloaded on every request. - Fastify's current default body limit is 1 MiB, so HTTP bodies are not completely unbounded even though field-level and rate limits are absent. Executed evidence: ```text 5 integration files passed 28 tests passed ``` Those tests include unauthenticated access, member-vs-admin roles, cross-org project IDs, Organization-scoped team slugs, and cross-org TEAM grant refusal. ## Critical findings ### 1. The agent receives service secrets and can read other tenant data `hub/src/agent/runner.ts` builds the SDK environment as: ```text { ...process.env, ...providerEnv } ``` The sandboxed agent and its Bash subprocess therefore inherit `DATABASE_URL`, `FEISHU_APP_SECRET`, `HUB_SESSION_SECRET`, the model-provider token, and every other service variable. A model or prompt injection can run `env`; network egress is intentionally open, so exposed values can leave the host. The same sandbox config has only `allowWrite: [workspaceDir]`. Reads outside the workspace are allowed except for a small deny list. All organizations run as the same service user, and sibling project workspaces are not denied. This violates `AgentFileOp.Authorized`, which covers reads as well as writes. The installed Claude Agent SDK already exposes two mechanisms that are not used: sandbox credential rules can `deny` or proxy-`mask` environment variables, and filesystem rules can deny a workspace root while re-allowing the current workspace. Production still needs an actual Linux test proving that SDK/Bash cannot read another project or service credential. ### 2. MCP file and Feishu-context tools escape their run objects `resolveDeliverableFile` intentionally adds the repository Git root to allowed roots, even when it is above the project workspace. It also checks lexical paths with `stat`, which follows symlinks. A deterministic reproduction created `workspace/leak.txt` as a symlink to another tenant's file: ```json {"symlinkEscapeAccepted":true,"outsideContentReadable":true} ``` The checked-in unit test currently treats sending a repo-root `README.md` from a nested project as desired behavior. That expectation is a direct divergence from the AgentSurface contract and must be reversed. `readFeishuContext` ignores its `ToolContext`. The MCP wrapper closes over the bound `chatId`, but the model supplies an arbitrary message/thread ID and the reader never verifies the returned message's `chat_id`. A stub Feishu API returning a message from `chat-other` produced: ```json {"returnedChatId":"chat-other","crossChatContentReturned":true} ``` The same validation is required for thread parents and every returned item. Inbound downloads have the inverse symlink problem: the unsandboxed Hub writes to `workspace/.cph/inbox`. A prior agent command can replace a path component with a symlink, turning the Hub into a confused deputy unless writes use a verified real path/no-follow boundary. ### 3. SaaS credentials are process-global instead of org-scoped `server.ts` creates one process-global Feishu client/listener and passes one global app ID/secret to OAuth. `feishuOAuth.ts` explicitly calls per-org encrypted credentials "deferred", although ADR-0021 says they **must** be org-scoped, encrypted at rest, and accessed through a resolver. `EnvRuntimeSettings.provider` similarly discards its `scope` argument and uses one global OpenRouter token for every project. This both leaks tenant ownership of credentials and makes the accepted "customers supply their own provider keys" accounting model false. The schema has no encrypted credential store, key version, rotation state, or resolver seam. This is also a product reachability blocker: one customer-owned Feishu app cannot receive bot/OAuth traffic for unrelated customer tenants under the ADR-0021 model. ## High findings ### 4. Delayed and card actions are not bound to the authorized object - `onMessage` checks `agent.trigger` before enqueueing/batching, but `startAgentRun` checks only `role.trigger`. If a grant, team membership, or tenant status changes while a request waits, it still starts. This contradicts ADR-0019's request-time effectiveness rule. - Interrupt handling authorizes `agent.cancel` against the project bound to the card action's current chat, then calls `activeRuns.get(runId)` without proving that `runId` belongs to that project. A stale, forwarded, or otherwise mismatched action can abort another project's active controller. - Approval handling stores the expected `chatId` but never compares it to the action context before resolving the pending approval. Execution-time authorization must resolve one immutable tuple `(organization, project, run/action, actor, chat)` and reject any mismatch before mutation. ### 5. SUSPENDED organizations can still execute bound projects HTTP org-admin guards and unbound-chat onboarding reject non-ACTIVE orgs, but `PermissionAuthorizer.organizationForResource` returns only `organizationId` and never checks status. A real database reproduction created a SUSPENDED org, project, and EDIT grant: ```json {"organizationStatus":"SUSPENDED","agentTriggerAllowed":true} ``` Project onboarding helpers also check membership existence without checking Organization status. The same status must be enforced at the shared resource authorization/onboarding seam, not independently in selected transports. ### 6. Production dependency graphs contain known vulnerabilities `npm audit --omit=dev` exits 1 with two high findings: - `@larksuiteoapi/node-sdk@1.68.0` - transitive `axios@1.13.6` The current latest Feishu SDK (`1.70.0` at audit time) still pins Axios `~1.13.3`, while the audited fixed Axios line is newer. The relevant advisories include request/credential manipulation, proxy credential leakage, ReDoS, and unbounded resource allocation; for example [GHSA-3g43-6gmg-66jw](https://github.com/advisories/GHSA-3g43-6gmg-66jw), [GHSA-hfxv-24rg-xrqf](https://github.com/advisories/GHSA-hfxv-24rg-xrqf), and [GHSA-777c-7fjr-54vf](https://github.com/advisories/GHSA-777c-7fjr-54vf). An override is outside the SDK's declared semver range and therefore requires integration testing rather than a blind lockfile edit. `cargo audit` exits 1 with five vulnerability records: - `crossbeam-epoch@0.9.18`, patched in `>=0.9.20`; - `quick-xml@0.38.4` and `0.39.4`, each affected by namespace-allocation and quadratic-attribute DoS advisories, patched in `>=0.41.0`. It also reports `memmap2@0.9.10` as unsound (patched in `>=0.9.11`) and four unmaintained transitive crates. All enter through the Typst 0.15 dependency graph. References: [RUSTSEC-2026-0204](https://rustsec.org/advisories/RUSTSEC-2026-0204.html), [RUSTSEC-2026-0194](https://rustsec.org/advisories/RUSTSEC-2026-0194.html), [RUSTSEC-2026-0195](https://rustsec.org/advisories/RUSTSEC-2026-0195.html), and [RUSTSEC-2026-0186](https://rustsec.org/advisories/RUSTSEC-2026-0186.html). No `npm audit` or `cargo audit` gate exists in CI. ## Browser/session and availability gaps These do not currently prove a cross-org data read, but they are required before exposing the service publicly: - state-changing cookie-auth routes have no CSRF token or Origin/Sec-Fetch-Site enforcement; SameSite=Lax is the only browser-side barrier; - there is no session revocation/version store, so a stolen cookie remains valid for up to seven days unless the global secret rotates; - startup accepts any non-empty session secret rather than enforcing generated entropy, and there is no key rotation scheme; - no security-header policy is registered, and Fastify request timeout is zero; - OAuth, login, and org APIs have no per-IP/user/tenant rate limit; - Feishu file/post download count, response size, MIME, archive shape, and total workspace quota are unbounded; uploads read entire files into memory. The exact abuse quotas and file limits remain with `Define initial abuse and capacity controls`; they must not be guessed here. ## Secret scan All 377 tracked files were scanned for private-key blocks, common cloud/API-key shapes, and non-empty assignments to the Hub's principal secret variables. No private key or real credential was found. The only assignment hits were the documented `` placeholder and local/test PostgreSQL URLs. `hub/.env` is ignored and untracked. This is positive repository hygiene, but it does not mitigate the runtime environment leak into the agent process. ## Required security release evidence Production readiness requires automated proof of all of the following: 1. An agent/Bash process cannot read service credentials, another project workspace, repo-root files, or a symlink target outside its workspace. 2. Feishu context and file delivery validate the real target after resolution, not only a caller-supplied lexical ID/path. 3. Every delayed action reauthorizes at execution and binds actor, chat, organization, project, run, and pending action consistently. 4. ACTIVE Organization status is enforced in the shared authorization seam. 5. Feishu and provider credentials resolve by Organization, are encrypted at rest with versioned keys, never enter logs/agent tools, and can rotate. 6. Cross-org HTTP and Prisma service tests remain green, while new negative tests cover every finding above. 7. Production Node and Rust dependency audits pass with no ignored vulnerability/unsound advisory unless a time-bounded, reviewed exception is explicitly recorded. 8. Browser/session, request, and file-ingestion limits are verified against the accepted production threat and capacity policy.