/** * How bytes leave a workspace for the chat: the model's own `send_file`, the * human's `/get`, and the one check both go through. * * This module exists in this shape because of HOW the bytes leave. The plugin * hands the transport a `Buffer` rather than a local path (ADR 0004), and * `MediaUploader.toBuffer` returns on its first line for a buffer — * `Buffer.isBuffer(source)` — so the SDK's own * `resolve → blacklist → realpath → re-check → allowlist` guard never runs for * anything this plugin sends. The `resolve → realpath → container check` order * in {@link resolveOutboundFile} IS that guard, put back by hand. Its two middle * steps are not interchangeable: canonicalize first, ask "inside the workspace?" * second, or `/link → /etc/shadow` answers yes. * * What the check clears is a file, not a permission. Whether a cleared file may * actually go out is the bridge's gate — direct message straight through, group * behind an approval card (ADR 0002) — and the bridge reads the bytes before it * asks, so what a room approves is the artifact that leaves. Nothing here knows * about that, on purpose: this module answers "may these bytes be sent at all", * once, for both callers. * @module dsh-lark-channel/outbound-file */ /** Why one outbound path cannot be sent. */ export type OutboundRefusal = { readonly code: 'outside_workspace'; } | { readonly code: 'not_found'; } | { readonly code: 'not_a_file'; } | { readonly code: 'too_large'; readonly bytes: number; readonly limit: number; }; /** One file cleared for sending. */ export interface OutboundFile { /** The canonical path the bytes come from. */ readonly path: string; /** The name the chat will show, i.e. the canonical path's basename. */ readonly fileName: string; readonly bytes: number; /** * Where the file sits inside the workspace: {@link OutboundFile.path} * expressed against the canonical workspace. * * Derived HERE rather than by whoever displays it, because this is the one * place both canonical forms exist at once — and relativizing anything but a * canonical path against a canonical container is how `/link → * /etc/shadow` would come back out as an innocent-looking `link`. What this * names is the object the container check cleared, minus a prefix that says * nothing about it. */ readonly pathInWorkspace: string; /** * The workspace's own name, i.e. the canonical workspace directory's basename. * Enough to tell one conversation's workspace from another's without printing * the operator's home directory. */ readonly workspaceName: string; } /** What one path turned out to be: a file cleared to leave, or a refusal. */ export type OutboundVerdict = { readonly ok: true; readonly file: OutboundFile; } | { readonly ok: false; readonly refusal: OutboundRefusal; }; /** * Whether one path may leave this workspace, and what it weighs. * * The order of the steps IS the check, and it is the order the SDK itself walks * for a local path — the one this plugin steps around by handing over a * `Buffer` (see the module note). Resolve, canonicalize, and only THEN ask * whether the result is inside the workspace: swap the last two and * `/link → /etc/shadow` clears the container check, because the * question would be about the link's own path and not about the bytes it points * at. **Do not reorder those two.** * * The workspace is canonicalized too, or macOS — where `/tmp` is a link into * `/private/var` — would judge every file in it an escape. * @param input - the path as its caller typed it; a relative one resolves against the workspace. * @param workspace - the conversation's workspace, the only directory bytes may come from. * @param maxBytes - the single-file ceiling. * @returns the cleared file, or why it is refused. */ export declare function resolveOutboundFile(input: string, workspace: string, maxBytes: number): OutboundVerdict; /** * Read the cleared file's bytes for `send({ file })`. * * The bytes come from the verdict's canonical path and never from the caller's * own input, so a link the check already followed cannot be followed a second * time to somewhere else. * * The size is checked again against the verdict, because `readFile` treats the * size it stats as a hint and reads to EOF regardless. A file still being * appended to when it was cleared — an agent's own background process writing * on — would otherwise come back BIGGER than the ceiling that just let it * through, and the ceiling is the whole reason this function exists. * * That check enforces the CEILING and closes no race: a rewrite to the same * length passes it unnoticed, and this function re-examines neither the * canonical path nor what it now points at. So it is not what makes a group's * approval mean anything — the caller's ORDER is. `deliverFile` reads the bytes * before it asks the room and sends the buffer it already holds, so the file the * card certified is the object that leaves. * @param file - a file {@link resolveOutboundFile} cleared. * @returns its contents. * @throws {Error} when the file vanished after it was cleared, or is no longer the size it cleared at. */ export declare function readOutboundFile(file: OutboundFile): Promise; /** * Why a cleared file could not be read, said without spelling the host's path. * * `readFile`'s own failures quote the absolute path they were handed — * `ENOENT: no such file or directory, open '/Users/…/project/out/a.md'` — so * passing one straight through undoes in the failure branch exactly what every * other branch here is careful about: the model gets a map of the filesystem it * never typed, and a chat reply hands the same map to a whole room. The failure * branch is if anything the likelier one to be read by a stranger, because it is * the branch an injected instruction can provoke on purpose. * * The workspace prefix is cut rather than the whole path, so what a reader gets * is the file's place inside the workspace and the reason it failed — `open * 'out/a.md'`, still enough to act on. Both audiences share this one sentence: * the technical detail is the same in either language, and a second copy of the * scrub is a second place to forget it. * @param error - the rejection from {@link readOutboundFile}. * @param file - the file it was reading. * @returns the reason, carrying no absolute path. */ export declare function describeReadFailure(error: unknown, file: OutboundFile): string; /** * The refusal as the model reads it — English, actionable, and naming no path * the model did not supply itself. A canonical path here would hand whoever * wrote the files this model is reading a map of the host's filesystem. * @param refusal - the verdict's reason. * @returns the sentence to throw as the tool's error. */ export declare function describeRefusalForModel(refusal: OutboundRefusal): string; /** * The refusal as the chat reads it — 中文, and short: the human typed the path * one line ago and does not need it read back. * @param refusal - the verdict's reason. * @returns the reason for the chat reply. */ export declare function describeRefusalForChat(refusal: OutboundRefusal): string; /** The tool that lets a model hand its own artifacts to the person who asked. */ export declare const SEND_FILE_TOOL = "send_file"; /** What the tool needs from the bridge to send one file. */ export interface SendFilePorts { /** * Deliver one cleared file to the agent's own chat, gate included. * @param sessionId - the agent's session, which names the chat. * @param file - the file the check cleared. * @param signal - the execution's cancellation. Passed on because a gate can * outlive the call that opened it: a group approval waits for a human, and * without this the card left behind by a cancelled turn stays live for the * whole approval timeout — someone pressing "allow" twenty minutes after the * turn was stopped would put the file in the group anyway, which is the leak * the gate exists to prevent (ADR 0002). * @returns undefined on success, or the reason the send did not happen. */ deliver(sessionId: string, file: OutboundFile, signal?: AbortSignal): Promise; /** * The conversation workspace one session runs in, or undefined when it has no chat. * @param sessionId - the agent's session. * @returns the workspace directory, or undefined. */ workspaceOf(sessionId: string): string | undefined; /** The single-file ceiling. */ readonly maxBytes: number; /** * Operator console line. A path this check refuses never reaches * {@link SendFilePorts.deliver}, so this is the only place an attempt to leave * the workspace can be reported — and an escape attempt is an operator's * event, not just the model's error. */ readonly report: (line: string) => void; } /** * Build the agent-scoped `send_file`. * * Every refusal is thrown rather than returned, as in `plan.ts`: a tool result * is what steers the model's next move, and "that file is too big" has to reach * it as a failure it must act on rather than a field it may ignore. * @param ports - how to look up the workspace and deliver the bytes. * @returns the definition, for `tools.register` on an agent's context. */ export declare function sendFileTool(ports: SendFilePorts): object; /** Send one workspace file to the chat. Channel-owned: it needs no agent. */ export declare const GET_COMMAND = "get"; /** * Run one `/get` line: parse the path, clear it, hand it to the caller's send. * * The same {@link resolveOutboundFile} the tool goes through, on purpose. A * human typing the command is not a reason to trust a path more — `/get` skips * the group approval card because the intent is explicit, not because the * boundary moved. * @param line - the complete line, slash included. * @param workspace - the conversation's workspace. * @param maxBytes - the single-file ceiling. * @param send - delivers the cleared file's bytes to the chat. * @returns the chat reply, or undefined when the file was sent and speaks for itself. */ export declare function runGetCommand(line: string, workspace: string, maxBytes: number, send: (file: OutboundFile, bytes: Buffer) => Promise): Promise; //# sourceMappingURL=outbound-file.d.ts.map