export interface HmacAttempt { ts: string; nonce: string; sig: string; } /** * Canonical "binding" string mixed into the signed payload so a captured HMAC * header can't be replayed against a different route or port. Without it, a * single set of `X-Botmux-Cli-*` headers signed only over `ts:nonce` is valid * for ANY `/__cli/*` route on ANY dashboard — which lets a malicious local * server, handed a discovery probe, forward the headers to the real dashboard * (e.g. a `/__cli/current` probe relayed to `/__cli/ensure` or `/__cli/rotate` * to mint a token). * Binding `method + path + bound-port` makes the credential single-purpose: * - the verifier uses the port IT actually bound (not the attacker-controlled * Host header), so a forward from port X to the dashboard on port Y mismatches; * - a `/__cli/current` capture can't be replayed to either token-writing route * (path differs). */ export declare function cliAuthBind(method: string, path: string, port: number | string): string; /** Mint the three `X-Botmux-Cli-*` header values for a loopback request. * Pass the same `bind` (see {@link cliAuthBind}) the verifier will reconstruct; * omit it only for the legacy daemon-IPC scheme that signs bare `ts:nonce`. */ export declare function signCliAuth(secretB64Url: string, bind?: string): HmacAttempt; /** * Verify a CLI rotation HMAC attempt. * - Source IP must be loopback (127.0.0.1 / ::1 / IPv4-mapped form). * - Timestamp must be within ±TS_WINDOW_S seconds of now. * - Nonce must not have been seen in the last NONCE_TTL_MS. * - HMAC-SHA256(secret, `${ts}:${nonce}` [+ `:${bind}`]) must match `sig` * (timing-safe). Pass `bind` (method+path+port) to scope the credential to a * single route/port; omit it for the legacy bare daemon-IPC scheme. */ export declare function verifyHmac(secretB64Url: string, attempt: HmacAttempt, remoteAddr: string, bind?: string): { ok: boolean; reason?: string; }; /** 32 random bytes base64url-encoded (43 characters, no padding). */ export declare function generateToken(): string; /** * Load a dashboard HMAC secret from disk. Empty / whitespace-only files are * treated as missing so callers never sign requests with an empty key. * * Goes through the same strict host-authority primitives as the persisted * token: the leaf must be a regular 0600 file owned by the current user and * must not be a symlink, and its directory (`~/.botmux`) must not be * group/other-writable. An unsafe shape throws * {@link UnsafeHostAuthorityFileError} (fail-closed) instead of being followed: * a symlinked or loose-perms secret could be planted by a local attacker who * can replace the credential directory, letting them forge CLI HMAC headers * and mint dashboard tokens. */ export declare function loadDashboardSecret(secretPath: string): string | null; /** * Load the dashboard HMAC secret, creating a fresh 0600 secret when absent or * empty. The credential directory is pinned once (Linux: via an open directory * descriptor) and the lock, the read, and the write all resolve through that * same anchor — so a symlinked HOME / shared-drive ancestor still works while * an ancestor rename mid-section cannot redirect the secret into a substituted * directory, and a leaf symlink is refused. The file lock makes get-or-create * linearizable across dashboard processes. */ export declare function loadOrCreateDashboardSecret(secretPath: string): string; /** * Load the persisted active dashboard token from `tokenPath`, or `null` when * the file is genuinely absent or empty. Unsafe credential-file shapes fail * closed instead of being treated as a missing token. * * Persisting the active token lets a previously-issued dashboard URL survive a * `botmux restart` and keeps multiple dashboard processes on one authority. * Only `botmux dashboard rotate` replaces the file, which invalidates the old * link for every process on its next request. */ export declare function loadPersistedToken(tokenPath: string): string | null; /** Durably persist the active dashboard token without following a leaf symlink. */ export declare function persistToken(tokenPath: string, token: string): void; /** * Load the active token, creating and persisting the first one when absent. * The file lock makes get-or-create linearizable across dashboard processes: * every concurrent caller returns the same durable token. * * The credential directory is pinned once (Linux: via an open directory * descriptor) and the lock, the read, and the write all resolve through that * same anchor. This keeps the whole critical section on one directory inode — * so a symlinked HOME whose target sits under a shared-drive / 0777 ancestor * still succeeds, while an ancestor rename mid-section cannot redirect the lock * or the token write into a substituted directory. `~/.botmux` itself must * still be 0700 and owned by the current user; a leaf symlink is still refused. */ export declare function loadOrCreatePersistedToken(tokenPath: string): string; /** Generate and durably replace the token while serialized with first creation. */ export declare function rotatePersistedToken(tokenPath: string): string; /** * Diagnostic body for a dashboard token 500. The `/__cli/*` HTTP layer used to * return a bare `{ error: 'token_persist_failed' | 'token_unavailable' }`, which * the CLI printed verbatim — so a user whose `~/.botmux` (or `.dashboard-token`) * has loose perms only saw an opaque code and had to be diagnosed remotely. The * real, actionable cause is already on the thrown {@link * UnsafeHostAuthorityFileError} (`message` is a precise reason like * "宿主凭证目录可被组内或其它用户写入" / "宿主凭证文件权限必须严格为 0600" / * "宿主凭证拒绝符号链接"). This surfaces that reason plus a one-line remediation * hint WITHOUT changing any validation — every fail-closed check still fails * closed; we only make the failure legible. * * `error` keeps the stable machine code (unchanged for programmatic callers); * `reason`/`hint` are additive human-facing fields. */ export declare function describeDashboardTokenError(code: 'token_persist_failed' | 'token_unavailable', err: unknown, tokenPath: string): { error: string; reason?: string; hint?: string; }; /** Extract `botmux_dashboard_token` value from a Cookie header. */ export declare function parseCookie(header: string | undefined): string | undefined; /** Build the `Set-Cookie` header value for a fresh dashboard token. */ export declare function buildSetCookie(token: string): string; /** * The dashboard splits incoming requests into three categories before the * route handlers run: * * - `allow` — request can proceed (auth succeeded OR endpoint * is public) * - `allow+set-cookie` — `?t=` query: the cookie is set * and we redirect to a clean URL. This is the * only branch that mints a Set-Cookie header. * - `deny401` — endpoint requires an authenticated session and * none was presented. * * Public surfaces today (codex review v0.1.2 → canary.3): * - `GET/HEAD /`, `/assets/*`, root icons — static SPA shell * - `GET /api/workflows/*` — zero-I/O legacy retirement * tombstone (HTTP 410). * - `GET /workbench-ticket/` — 短时票据兑换(票据即凭证, * 处理器自行验票,P2-1)。 * * Outside those always-public surfaces, the explicit `publicReadOnly` * allow-list controls tokenless observation. Mutations and private reads still * require the active session token, matching the chat web terminal's * capability boundary. */ export type AuthDecision = { kind: 'allow'; } | { kind: 'allow+set-cookie'; token: string; redirectTo: string; } | { kind: 'deny401'; }; /** * Feishu/Lark H5 sessions are deliberately narrower than the legacy owner * cookie. They can render the Workbench, observe its session stream, and use * the two explicitly leased interaction surfaces; they can never fall through * into Dashboard administration merely because a new route was added. * * Keep this as a positive capability map. In particular, broad rules such as * "all GETs" would expose config/secrets, while "all POSTs under /api/sessions" * would turn an H5 viewer into a host operator. */ export type WorkbenchH5Capability = 'workbench.view' | 'terminal.view' | 'terminal.operate' | 'preview.view' | 'preview.operate'; export declare function workbenchH5Capability(method: string, pathname: string): WorkbenchH5Capability | null; /** Fail-closed auth decision for an already-authenticated H5/readonly-platform * identity. Static shell reads retain the ordinary public behavior; everything * else must name one of the Workbench capabilities above. */ export declare function decideWorkbenchH5Auth(opts: { method: string; pathname: string; }): AuthDecision; export declare function decideDashboardAuth(opts: { method: string; pathname: string; hasTokenParam: boolean; presentedToken: string | undefined; activeToken: string; /** When true (config.dashboard.publicReadOnly), the GET/HEAD paths in * PUBLIC_READ_PATHS (the "watch work" board surfaces) are readable WITHOUT * a token — a tokenless (or stale-token) visitor gets a read-only dashboard * instead of a 401 wall. Everything else (management/config reads, raw PTY, * all writes) still requires the active token. */ publicReadOnly?: boolean; }): AuthDecision; /** * 前端可见的"最小操作能力集"。`workbenchAuthed` 只能证明可进工作台,不代表任何 * 一项操作权限;三布尔各自对应一条真实路由的门禁: * * - `canLocate` → `POST /api/sessions/:id/locate`(话题定位)。 * - `canControl` → `POST /api/sessions/:id/control/takeover|release` * (终端接管/释放)。 * - `canInteract` → `POST /api/sessions/:id/preview-interaction/unlock| * activity|lock`(Preview 交互解锁)。 * * 边界(P1-6 write token):`canControl` 只描述该身份**不带显式 token 时**在 * Dashboard control API 上的默认能力。显式 `?token=`(write token)走终端前置 * 代理的独立授权链——它既不经过这三条 API,也不受本投影影响;一个 teammate 被 * 递了显式 write token 仍可直接写终端,本投影不试图(也不可能)描述那条通道。 */ export interface WorkbenchOperationCapabilities { canLocate: boolean; canControl: boolean; canInteract: boolean; } /** 匿名 / 解析失败时的 fail-closed 值:三项全 false。 */ export declare const WORKBENCH_NO_OPERATION_CAPABILITIES: Readonly; /** * 处理器级角色门禁:谁能对预览交互做写操作(unlock / activity / lock)。 * * 这一条判据同时被三处消费,必须只有一份实现,否则「画不画解锁按钮」和「POST 会 * 不会 403」会各走各的: * 1. `dashboard.ts` 的 `/api/sessions/:id/preview-interaction/*` 路由(唯一权威, * false → 403 `preview_operation_forbidden`); * 2. 下面 {@link projectWorkbenchOperationCapabilities} 的 `canInteract`; * 3. 由 2 驱动的工作台「开启交互」按钮与 Preview guard 壳里的解锁按钮。 * * 身份缺失或 `previewCapability` 不是明确的 `'operate'` 一律 false(fail closed)。 */ export declare function previewInteractionWriteAllowed(identity: { previewCapability?: 'operate' | 'readonly'; } | null | undefined): boolean; /** 投影所需的最小身份切面——与 dashboard.ts 的 DashboardRequestIdentity 结构兼容 * (kind 枚举 + terminal-control.ts 的角色能力字段)。 */ export interface WorkbenchCapabilityActor { kind: 'legacy-dashboard' | 'platform-dashboard' | 'feishu-h5'; terminalCapability?: 'controlled' | 'owner' | 'readonly'; previewCapability: 'operate' | 'readonly'; } /** * 由身份投影三布尔能力集。**不是一张平行的权限表**:每一项都通过复算真实路由 * 的两层门禁得出—— * * 1. 路由级 auth 决策:workbench-only 身份(H5 / platform)走 * {@link decideWorkbenchH5Auth}(capability 表里没有 /locate,所以 H5 与 * platform 全员 canLocate=false);legacy owner 走 * {@link decideDashboardAuth}(cookie == active token,等价于 allow)。 * 2. 处理器级角色检查:dashboard.ts 对 control/preview-interaction 写操作分别 * 用 `terminalCapability === 'readonly'` / `previewCapability === 'readonly'` * 403,这里逐字复用同一判据。 * * 因此「投影为 true 而路由 401/403」或反向漂移只可能来自这两层规则本身的改动, * 而 test/dashboard-auth.test.ts 的矩阵测试把两边钉在一起。 */ export declare function projectWorkbenchOperationCapabilities(identity: WorkbenchCapabilityActor | null): WorkbenchOperationCapabilities; //# sourceMappingURL=auth.d.ts.map