import type { IncomingMessage } from "node:http"; import { type SessionStore, type SessionTreeEntry } from "@sema-agent/core"; import type { ServiceConfig } from "./config-types.js"; import type { TaskRequestBody } from "./http/wire-types.js"; import type { StagingHandle } from "./session-sync-kernel.js"; /** One row of the session-list projection (CC /resume picker) — the durable session abstraction's enumeration * output (§0.5). Mirrors {@link RunStore}'s shipped `SessionSummary` shape so the wire response is unchanged. */ export interface SessionListItem { sessionId: string; owner: string | null; lastActivityAt: string; firstActivityAt: string; runCount: number; objectivePreview: string | null; lastStatus: string; /** K-5c (shell §K): the latest run's task_id — the shell's one-hop live-tail re-attach anchor on resume. */ lastRunId: string | null; /** [ref]([ref]/[ref]):the LATEST run's **engine run id**(`TaskResult.runId`)when known —— OMIT-when-absent, * 恒不铸 null。runs-ledger listers 从 latest run 的终局 `result` JSON 提取(⇒ 在跑 latest 键诚实缺席); * local lister 走 `noteTaskRun` 席实时记录(⇒ 在跑也在场)——两面缺席集合不完全相等,读法一致: * 缺席 = 「此行无已知 engine runId」,禁读成其它含义。⚠️ 与 `lastRunId`(= latest run 的 **task_id**) * 是两条身份轴,命名对齐 core `SessionStoreSummary.lastTaskRunId`。wire:additive 键零投影透传 * (sessions-list.ts);sema-sdk spec `SessionSummary` 闭集候 SDK 半场开键([ref]/.82 班车)。 */ lastTaskRunId?: string; /** Auto-generated session title (null = untitled / lister face has no session_meta). */ title: string | null; } /** A SessionStore that may additionally expose tenant-ownership ops (the TiDB store does) AND the * `SessionRepo` enumeration/lifecycle seam (§0.5: list/fork/delete homed on the session abstraction's own * tables, not the `task_run` runs ledger). All optional — an in-memory dev store implements none. */ /** * E6 fail-closed 守卫的辖域判据:**这个请求是不是一次「破坏性会话写」**。 * * 🔴 2026-08-01 复审逮到:守卫原本是**逐条路由白名单**(`PUT …/policy`、`POST …/sync/import`), * 而 `DELETE /v1/sessions/:id`(删整条会话)不在其中 —— 同一风险形、爆炸半径**更大**,却没门。 * * 风险形逐字同源(守卫自己的注释):「无 service token + 未开 `ALLOW_UNAUTHED_WRITES`」这一形下 * `sessionOwnerScopeForWrite` 判 fleet-wide ⇒ 集群内**任意**调用方可对**任意** session 动手。 * policy 腿担心的是「forged principal 能 tighten/DoS 一个会话的工具」;DELETE 直接把会话删掉。 * * ⇒ 枚举改**族判定**:新增一条破坏性会话路由时自动落进门内,不必记得回来加白名单。 * 这是本仓「同一条论证只在枚举到的路径上执行」这一族的第 N 次,修法统一为把判据提成函数。 * * 辖域**只含**对**既有会话**的破坏性写:改规则 / 整段替换 / 删除 / 分叉(读源会话全历史)。 * 不含读面,也不含 `POST /v1/tasks` 那条提交腿 —— 它有自己的同族门(别重复拦,双拦会让错误归因变糊)。 */ export declare function isDestructiveSessionWrite(method: string, url: string): boolean; /** [ref] 窄互认第二臂的租户键字面量 —— 单机形下 core 写侧 `spec.principal ?? "default"` 铸出的那一个, * 也是壳 [ref]G1 翻面后随请求带的那一个。**不是哨兵**([ref] 措辞):它是单机形的真租户键。 */ export declare const LEGACY_UNOWNED_AMNESTY_TENANT = "default"; /** * [ref] 窄互认**第二臂**(clay 裁 A / [ref] 下达;cli [ref] 表态即发)—— `legacyShellOwner` * (本文件 createAuthorizer 内 + http/server.ts `runOwnerOk`)的对称孪生。 * * 病灶:7.8.0 第一臂承接的是 `owner === "anon:shell-live"` 哨兵行;此后壳把无主表示法归一为 * **缺席**,于是 1.0.82 及更早留下的存量会话 owner 列是 **null**。壳 1.0.83 起随请求带 * `default` 主([ref] G1 闸口),`null !== "default"` ⇒ 存量会话在 session 面 * **列表不出现 / fork 404 / delete 静默 {deleted:false} / workspace 404**(三面矩阵 [ref] 面3; * fleet 面与 run 读面天然连续,面1/面2 零施工)。 * * 🔒 **三条件缺一不放行**(与第一臂同源,不是兼容臂): * ① 单机形 `!requirePrincipal` —— 多租部署本臂**死路**(header 必到、键必具名); * ② 存量行恰为无主 `owner === null` —— 任何具名 owner 的行不受影响; * ③ 调用方恰为 `default` 主 —— 非 default 的具名主对无主行仍走原门(404 / deleted:false)。 * 反向风险有界:①限定的单机形本就单用户,把无主行判给 default 主不产生跨租外泄。 * * 🪦 墓碑注(与第一臂同源):这是两个表示法之间的**过渡承接**。存量随会话生命周期消亡后,本臂 * 连同 {@link LEGACY_UNOWNED_AMNESTY_TENANT} 与几处调用点可**整删**,不留兼容层。 * * ── 逐面判过、**刻意不放行**的三处(免得后人当遗漏再补一次)──────────────────────────────── * · `runOwnerOk` 三读面(head / audit 单读 / events):`owner === null` 那一支**本来就放行** * (判据是 `owner !== null && principal !== owner`),不需要本臂。[ref] 帖文里「单读 404」一句 * 经亲读实现行**不成立**,已加现状钉(session-head-probe.test.ts)锁住这个事实。 * · `PUT /v1/sessions/:id/policy` 的 `owner === null ⇒ 409 conflict.session_ownerless`:那是**有意的 * fail-closed** —— 无主会话没有稳定的 run-principal,存下去的规则读不到、静默失效。放宽它等于制造 * 一条 fail-open,不在本臂辖域。 * · `…/sync/*`(PULL/PUSH/import):PULL 虽是只读、形同 workspace,但这一族**带一条破坏性 import 腿** * (`isDestructiveSessionWrite` 明列),而 [ref]/[ref] 裁的是会话**读面与生命周期**四门。放宽一个 * 写面族超出裁定射程 —— 要做另立一次表态,不在本臂里顺手带过。 * * @param requirePrincipal `config.requirePrincipal`(条件①) * @param gateOwner `sessionOwnerScope(ForWrite)` 判出的**已验证**主(条件③;fleet-wide 面不经本臂) * @param owner 存量行的 owner 列(条件②) */ export declare function legacyUnownedSessionOk(requirePrincipal: boolean, gateOwner: string | null, owner: string | null): boolean; /** * {@link legacyUnownedSessionOk} 的**条件①③**半支 —— 只判「这个部署形 × 这个调用方」是否落在窄臂辖域内。 * * 🔴 单独存在的唯一理由是**列表面**:`GET /v1/sessions` 的第三条件(`owner === null`)不在 JS 里判, * 它是 store 侧 `owner = ? OR owner IS NULL` 这一句 SQL 谓词 —— 三条件仍然齐,只是第三条件由 SQL 承载 * (`listSessions({ owner, includeUnowned })` 的 `includeUnowned` 位即本函数的返回值)。 * 单实体读/写点(fork/delete/workspace)**一律**用三条件齐的 {@link legacyUnownedSessionOk},不要用本函数。 */ export declare function legacyUnownedAmnestyOpen(requirePrincipal: boolean, gateOwner: string | null): boolean; export type OwnerAwareSessionStore = SessionStore & { ownerOf?: (sessionId: string) => Promise; register?: (sessionId: string, owner: string | null) => Promise; /** E16 — enumerate the caller's sessions (owner-filtered on the session store's OWN owner column). * `includeUnowned` = [ref] 窄互认第二臂的**第三条件**(`owner IS NULL`)的 SQL 承载位:置真时 * 谓词放宽为 `owner = ? OR owner IS NULL`。只在 `owner` 在场时有意义(路由已判①③);`owner` 缺席 * (fleet-wide)时本位无效——那一面本来就不钉 owner。 */ listSessions?: (opts: { owner?: string; includeUnowned?: boolean; cursor?: { lastActivityAt: string; sessionId: string; }; limit: number; q?: string; }) => Promise; /** E17 — fork a session's whole history to a NEW session owned by `owner`; null if the source is unknown. */ fork?: (sourceId: string, owner: string | null) => Promise; /** E18 — the session's current leaf `SessionTreeEntry.id` (a cache-bypassing single read), so the resume-at * turn-capture can pair it with the turn's message eventId. null when the session has no leaf / does not exist. */ getLeafId?: (sessionId: string) => Promise; /** [ref] 六轮复审:owner+leafId 单行原子快照(SSE watch 探针租户围栏读;两独立读在 delete/reclaim * 缝里可拼出混世代组合)。undefined=无此会话。 */ getHead?: (sessionId: string) => Promise<{ owner: string | null; leafId: string | null; } | undefined>; /** E21 — purge a session's conversation history (session_meta/session_event); idempotent (false if absent). * `owner` is the single-DB-fleet null-safe SQL owner guard on `session_meta.owner` (defense-in-depth — the * route already owner-gates; this pins the DELETE to the authenticated tenant so even a buggy gate can't purge * another tenant's history). */ deleteSession?: (sessionId: string, owner: string | null) => Promise; /** 2c session-sync — EXPORT a session's full durable entry log (compaction floor bypassed) for * cross-backend migration; `null` when the source is unknown (the read half of fork, as `SessionTreeEntry[]`). */ exportEntries?: (sessionId: string) => Promise; /** 2c session-sync P1d-α (PULL streaming) — the IDS-ONLY projection of a session's full durable log, oldest-first * (`SELECT entry_id … ORDER BY seq ASC`). `null` when the source is unknown (mirrors {@link exportEntries}' null). * Small even for a huge session (no payloads) → the PULL `GET /sync/manifest` carries this instead of the entries, * and the local peer feeds it to `classifySyncRelationshipByIds` to decide fast-forward / fork before pulling. */ listEntryIds?: (sessionId: string) => Promise; /** 2c session-sync P1d-α (PULL streaming) — STREAM a session's full durable entry log (compaction floor bypassed), * oldest-first, as an async iterable of {@link SessionTreeEntry} so the `GET /sync/entries` NDJSON route holds * BOUNDED memory regardless of session size (mysql2/pg buffer a plain SELECT → this KEYSET-pages internally, * `WHERE seq > cursor … LIMIT batchSize`, advancing the cursor each batch, ONE pooled query per batch — a slow * consumer never pins a connection). `opts.afterSeq` resumes after a given 0-based dense seq (default -1 = from 0); * `opts.batchSize` is the page size (default 500). Resolves to `null` (NOT an empty iterable) when the session does * not exist (the existence probe is async — `session_meta` first, like {@link exportEntries} — so the method returns * a Promise of the iterable-or-null; the iterable itself then pages lazily). The read half of exportEntries, paged. */ exportEntriesStream?: (sessionId: string, opts?: { afterSeq?: number; batchSize?: number; }) => Promise | null>; /** 2c session-sync — IMPORT a verbatim entry log into `sessionId`, re-stamping `owner` to the * authenticated importing principal (never the bundle's). The log is passed through core's * `validateEntriesForImport` (the single fail-closed invariant gate) before any write. */ importEntries?: (sessionId: string, owner: string | null, entries: SessionTreeEntry[]) => Promise; /** 2c session-sync (§7/§8) — IDEMPOTENT replace: atomically purge any existing conversation log for `sessionId` * then import `entries` verbatim under `owner`. Unlike importEntries (plain INSERT, fresh-only) this is safe to * re-run / apply over an existing session (fast-forward, retry). owner-guarded delete (defense-in-depth). */ replaceEntries?: (sessionId: string, owner: string | null, entries: SessionTreeEntry[]) => Promise; /** 2c session-sync P1d-β (PUSH streaming) — begin a STAGED import into `realSessionId` under a shadow id * `${realSessionId}#stg-${token}` (the route mints the uuidv7 `token`). Returns a {@link StagingHandle} the NDJSON * Phase-B route streams batches into (`appendBatch`) then commits (`commit`, the atomic swap) / aborts. The shadow * rows have NO session_meta → invisible to wake until commit writes meta LAST = the sole commit point. */ beginImportStaging?: (realSessionId: string, token: string) => StagingHandle; /** 2c session-sync P1d-β (staged-row inspection) — read the rows STAGED under a `${realSessionId}#stg-${token}` id * (NO session_meta probe — `exportEntries` returns null for an unregistered id), oldest-first. The import gate is now * the per-line `StreamingImportValidator` (core 1.143.0) driven during the stream, so this is no longer the * validation path; it remains the staged-row inspection seam (idempotency/dedup tests, diagnostics). Returns `[]` for * an empty/unknown staging id. */ readStagedEntries?: (stagingId: string) => Promise; /** 2c session-sync P1d-β (§4) — reaper for ABANDONED staging sessions: drop staging-id session_event rows with NO * session_meta whose oldest row is older than the grace window (an in-flight stream stays fresh → never reaped). * Returns the affected row count. Durable twins only (local stages in memory → no durable rows to reap → absent). */ sweepStagingSessions?: () => Promise; }; /** True iff `s` matches the canonical uuidv7 shape core mints (see {@link UUIDV7_RE}). */ export declare function isUuidV7(s: string): boolean; export declare function isUuidShape(s: string): boolean; /** An HTTP error the server maps to a status code (instead of a generic 500). `code` (optional) is a stable * machine-readable identifier the HTTP layer echoes as `{code}` beside the human `{error}` message — the * "typed rejection" posture (structured code on the wire, prose stays with the shell/web). * `extra` (optional) is folded into the error body verbatim (e.g. the scenario allowlist on a reject). */ export declare class HttpError extends Error { readonly status: number; readonly code?: string; readonly extra?: Record; constructor(status: number, message: string, opts?: { code?: string; extra?: Record; }); } /** * 带机读码的 {@link HttpError} 的**构造点**。出门时这只码落在 wire 错误体的 `errorCode` 键上(`http/send.ts`), * 从不以裸 `code` 键出现 —— 3.0.0 起 `errorCode` 是错误体唯一的机器判别键。`http/` 树里的模块经它抛带码错误, * 不手写 `{ code }` 选项字面量(那一层有一道按行扫裸 `code:` 键的门,手写字面量与「真发了 legacy 键」同形)。 */ export declare function createCodedHttpError(status: number, errorCode: string, message: string, extra?: Record): HttpError; /** * Authorization context resolved per request, BEFORE building the TaskSpec. The service owns this — * identity and tenant scope come from the authenticated channel, **never from the request body**. */ export interface AuthContext { /** Authenticated principal, e.g. `user:42` / `org:7`. Undefined when principal auth is off (dev). */ principal?: string; /** The session to run against (resolved + ownership-checked here, then returned to the caller). */ sessionId: string; /** Long-term memory scope, derived from the principal (not the body). Undefined disables memory. */ memoryScope?: string; /** 142-S4:the request's SHAPE-VALIDATED **且已归一(小写)** 的 projectId(body.projectId 过 core * `PROJECT_ID_REGEX` 门后折小写回传;[ref]/[ref] 宽读严写 —— 词法带 /i 收下大写形,但派生 scope 键 * 与 config.projects 查表都只认这一个归一形,否则同一个项目会按大小写分成两只记忆盘)。 * 身份半场(scope 的 tenant 段)永远来自 verified principal — projectId 只选「哪个项目盘/哪条登记」 * (config.projects 查表键 + proj: 键的 projectId 段),不是 capability。main.ts 拿它查 defaultScopes。 */ resolvedProjectId?: string; } /** * S6 — close the "sessionId / scope are bearer capabilities" hole. * * The principal is read from a trusted header set by the authenticated caller (e.g. the OA backend * that already holds the service token). It is NOT taken from the request body, so a caller cannot * forge identity by passing `scope`/`sessionId` for another tenant. * * Then: * - a supplied `sessionId` is ownership-checked against `session_meta.owner` (403 on mismatch); * an unknown id is claimed for this principal; a missing id mints a fresh owned session; * - the memory scope is derived from the principal. * * Returns a function shaped for `ServiceDeps.authorize`. When `sessionStore` is not the TiDB store * (e.g. in-memory dev backend) ownership can't be enforced, so it only derives principal + sessionId. */ export declare function createAuthorizer(config: ServiceConfig, sessionStore: OwnerAwareSessionStore): (ctx: { req: IncomingMessage; body: TaskRequestBody; }) => Promise; /** F1/F3 (codex scope-ledger audit 2026-07-14) — the auth-boundary principal SHAPE gate, applied to every * verified identity source (trusted header, SSO-bridge sub, direct-door JWT sub) so no downstream consumer * ever sees an ambiguous or over-wide principal: * - RESERVED internal identifiers are REJECTED (修1, 三路复审 A 组 HIGH — see {@link RESERVED_PRINCIPALS} / * {@link RESERVED_PRINCIPAL_PREFIXES} below): a REAL tenant whose principal string-equals an internal scope * sentinel would pass the plain string-compare owner/scope gates downstream and merge with internal rows — * e.g. `"_"` merges with the anonymous scope family (the checkpoint scope column `auth?.principal ?? "_"` * at suspend, and the decide owner-gate treats `cp.scope === "_"` as anonymous-OPEN, server.ts ~L3710; the * SendUserFile ledger scope + the /v1/sendfile-links fleet-wide default; the resource-suspend isolation * key), `"_leader"` merges with the leader durable-approval owner scope (a tenant so named could list/ * decide internal leader approvals), `"_leader-…"` with the leader resource-suspend scope family, and * `"__none__"` with the missing-principal placeholder scope on the decide/pending gates. * - length > 190 is REJECTED: every scope/owner column the principal lands in VERBATIM is VARCHAR(190) * (sendfile_link.scope, approval.owner/scope, memory-engine scope, rate-limit keys, …) — an over-long * principal would truncate/500 at the INSERT and split one identity into two. * Throws a typed 400 `HttpError` (the HTTP layer maps it); the message names the rule (a reserved identifier / * the length cap), never a secret and never the internal scope's semantics. */ export declare const PRINCIPAL_MAX_LENGTH = 190; /** 修1 — the SINGLE registry of internal sentinel/scope identifiers a tenant principal must never equal. * 🔴 新增内部 sentinel scope 必须在此登记(精确值加 RESERVED_PRINCIPALS,前缀族加 * RESERVED_PRINCIPAL_PREFIXES),否则 assertPrincipalShape 会放行同名租户 = 与内部 scope 行合流的超范围面。 * Current entries (where each is minted): `"_"` = single-user/anonymous sentinel (checkpoint/sendfile/ * resource-suspend scope family); `"__none__"` = missing-principal placeholder on the decide/pending gates * (http/server.ts `principal ?? "__none__"`); `"_leader"` = leader durable-approval scope * (src/leader/wire.ts); prefix `"_leader-"` = the leader resource-suspend scope family * (`_leader-resource-`, src/leader/wire.ts). NB the prefix REQUIRES the hyphen — a tenant named * `_leaderX` collides with nothing internal and stays valid (boundary pinned by test). */ export declare const RESERVED_PRINCIPALS: readonly string[]; export declare const RESERVED_PRINCIPAL_PREFIXES: readonly string[]; export declare function assertPrincipalShape(principal: string): void; /** The checkpoint `scope` column's single-user/anonymous sentinel — the same "_" documented above * ({@link RESERVED_PRINCIPALS}) as the checkpoint/sendfile/resource-suspend scope family's shared value. * Single attested constant + codec pair (naming precedent: {@link SENDFILE_SINGLE_USER_SCOPE} in * ./plugins/send-file-ledger.js) so every checkpoint scope read/write goes through one place instead of * each call site re-deriving the "_" ⇄ no-principal mapping inline. */ export declare const CHECKPOINT_PUBLIC_SCOPE: "_"; /** * 🔴 [ref](2026-08-19)—— **后台子代注册簿的匿名租户键**,与上面那只 checkpoint 哨兵是 * 「同一件事(没有 principal)的两套写法」。 * * 写侧不是本仓能单方面改的:core 的 `treeScope = reviveClaim?.row.scope ?? ctx.principal ?? * opts.background?.scope` 同时喂**进程内** `defaultTaskRegistry` 与 **durable** 行,而本仓/`core` 全域的 * 读侧是一整族 `principal ?? "default"`(`runs.ts` markStopSourceForOwner、`boot/session-faces.ts` * reapSessionBackground、`http/routes/runs.ts` 的 subagent output/stream 三口、`agents-roster.ts`、 * `fleet.ts`)。⇒ **把铸点改成 `"_"` 会把这一整族读者一起打瞎**(本车实测:`subagent-tail-content-supply` * 的 tail 建连当场 404)。所以归一只能发生在**读侧**:checkpoint 侧拿着 `"_"` 去查 bg 分区时, * 显式把这两个键当同一格。 */ export declare const BACKGROUND_REGISTRY_ANON_SCOPE: "default"; /** * checkpoint 的 scope → 该在哪些 **bg 注册簿分区**里找它的 parked 子代([ref])。 * * 匿名(`"_"`)⇒ 两个都查:`"_"`(将来若统一了写侧)与 `"default"`(今天 core 真写下去的那个)。 * 其余 ⇒ 原样单查:有 principal 时两套约定折出**同一个**串(`encodeCheckpointScope` 只在 null/undefined * 时才替换,core 那侧也只在 `ctx.principal` 缺席时才落兜底),不存在第二个候选;而多租户部署里一个 * **真名叫 `default`** 的租户必须只匹配自己那格 —— 所以别把别名做成双向。 */ export declare function backgroundScopesForCheckpointScope(scope: string): readonly string[]; /** Encode a principal (or none) as the checkpoint `scope` column value. */ export declare const encodeCheckpointScope: (principal: string | null | undefined) => string; /** Decode a checkpoint `scope` column value back to a principal — {@link CHECKPOINT_PUBLIC_SCOPE} maps to * `undefined` (no principal), everything else passes through verbatim. */ export declare const decodeCheckpointScope: (scope: string) => string | undefined; /** Read the authenticated principal from the trusted header. Never from the body. */ export declare function principalFrom(req: IncomingMessage, config: ServiceConfig): string | undefined; /** Header carrying the crypto-verified principal JWT on a direct-door worker (the issuer signs it; the worker only * verifies). The trusted twin of `config.principalHeader` (the latter is client-self-asserted on a direct door). */ export declare const PRINCIPAL_TOKEN_HEADER = "x-approval-principal-token"; /** * direct-door 决策证明三件套的另外两个头名(JWT 在上面那个常量里)。 * * 为什么也具名(2026-08-08,[ref] 二轮扫描 finding):这三个头名此前在 * `http/routes/approvals-assistant.ts` 的两个 `verifyDirectDoorProof` 调用点、以及 `http/server.ts` 的 * CORS `access-control-allow-headers` 串里**各手抄一份**——而 CORS 那一行同一句里已经在用 * `${principalHeader}` 插值另一个头名,证明具名写法在该处本就可用。改任一头名时漏改 CORS 串的后果 * 是**静默的**:浏览器预检把该头挡下,请求到不了 handler ⇒ 表现成「direct-door 上审批面莫名失效」, * 而不是任何一处报错。低危(改头名本身即 wire BREAKING、不太可能单边发生),但零成本可消。 */ export declare const APPROVAL_MAC_HEADER = "x-approval-mac"; export declare const APPROVAL_MAC_KID_HEADER = "x-approval-mac-kid"; export declare function setSsoPrincipal(req: IncomingMessage, principal: string): void; export declare function ssoVerifiedPrincipal(req: IncomingMessage): string | undefined; export declare function setSsoScope(req: IncomingMessage, scope: string): void; export declare function ssoVerifiedScope(req: IncomingMessage): string | undefined; export declare function verifiedPrincipal(req: IncomingMessage, config: ServiceConfig): string | undefined; //# sourceMappingURL=security.d.ts.map