/** * Which local process is on the other end of a TCP connection, and does it * belong to an agent? (#999, specs/2026-10-03-owner-auth.md §4) * * Agents run as the owner's OS user, so they can read `~/.crewly/api-token` * and could ask for a dashboard session over loopback. File permissions * cannot stop that. What does distinguish an agent is how it was started: * every agent process descends from this backend (PTY shells, crewly-agent * children, Codex daemons started from a PTY), runs on an agent PTY's * terminal, or carries an agent's environment. The owner's browser, terminal, * Vite dev server and Electron app have none of these. * * For a connection from this host, this service finds the client process * (`lsof`, or `ss` on Linux) and checks those signals against one `ps` * snapshot (the environment signal is Linux-only: macOS does not expose * another process's environment). A lookup that cannot run (tool missing) * or times out answers `unknown`, and the caller fails open with a warning * (residual risk 3 in the spec). A lookup that ran but found no client * process — it exited first, which an agent can arrange by writing the * request over a raw socket — answers `gone`, and the caller fails CLOSED. * * A determined process can still shed all three signals (double-fork, * `setsid`, `env -i`). That is the same-OS-user limit the spec documents. * * @module services/core/peer-process.service */ import * as os from 'os'; /** One row of the process table. */ export interface PeerProcessEntry { ppid: number; /** Controlling terminal (`??` / `?` when none) */ tty: string; } /** The verdict on a connection's client process. */ export type PeerVerdict = { kind: 'remote'; } | { kind: 'self'; } | { kind: 'agent'; pid: number; signal: 'ancestry' | 'tty' | 'env'; session: string | null; } | { kind: 'not-agent'; pid: number; } /** * The lookup ran but the client process is not there (it exited before it * could be looked up, or no socket matched). Fails CLOSED: an agent can * write a request over a raw socket and exit at once (#1010 review). */ | { kind: 'gone'; reason: string; } /** The lookup could not run: the tool is missing or it timed out. Fails open. */ | { kind: 'unknown'; reason: string; }; /** The parts of a socket the lookup needs. */ export interface PeerSocketLike { remoteAddress?: string; remotePort?: number; localPort?: number; } /** Injectable collaborators (tests). */ export interface PeerProcessDeps { /** Pid of the client end of `clientPort → serverPort`, or null */ findPeerPid?: (clientPort: number, serverPort: number) => Promise; /** pid → {ppid, tty} for every process */ readTable?: () => Promise>; /** The environment of a process, or null when unreadable */ readEnv?: (pid: number) => Promise | null>; /** PTY shell pid → session name for the live agent sessions */ listSessionPids?: () => Map; /** This backend's pid */ selfPid?: number; /** Whether an address belongs to this host */ isHostAddress?: (address: string) => boolean; /** Lookup budget */ timeoutMs?: number; } /** * Whether an address belongs to this host: loopback, or any of its own * interface addresses (an agent can connect to the machine's LAN IP too). * * @param address - Remote address of a connection * @param interfaces - `os.networkInterfaces()` (tests) * @returns True for a connection from this machine */ export declare function isHostAddress(address: string, interfaces?: NodeJS.Dict): boolean; /** * Parse `lsof -nP -iTCP: -Fpn` output: the pid whose socket's LOCAL * end is `clientPort` and remote end is `serverPort`. * * @param output - lsof field output (`p` / `n:

->:` lines) * @param clientPort - The client's ephemeral port * @param serverPort - This backend's port * @returns The client pid, or null */ export declare function parseLsofPeer(output: string, clientPort: number, serverPort: number): number | null; /** * Parse `ss -Htnp '( sport = : )'` output: the first pid listed * for a connection to `serverPort`. * * @param output - ss output * @param serverPort - This backend's port * @returns The client pid, or null */ export declare function parseSsPeer(output: string, serverPort: number): number | null; /** * Parse `ps -Ao pid=,ppid=,tty=` output. * * @param output - ps output * @returns pid → entry */ export declare function parsePsTable(output: string): Map; /** * Decide whether a process is an agent's, from a process-table snapshot. * * @param pid - The client process * @param table - pid → {ppid, tty} * @param selfPid - This backend's pid * @param sessionPids - PTY shell pid → session name * @returns `agent` with the signal that matched, or null */ export declare function agentSignalFromTable(pid: number, table: ReadonlyMap, selfPid: number, sessionPids: ReadonlyMap): { signal: 'ancestry' | 'tty'; session: string | null; } | null; /** * Whether a lookup error means "could not look" (fail open) rather than * "looked and it is not there" (fail closed): the tool is missing, or it * was killed for running past its budget. * * @param error - The error * @returns True for a missing tool or a timeout */ export declare function isLookupUnavailable(error: unknown): boolean; /** * Classifies the client process of local connections, once per socket. */ export declare class PeerProcessService { private readonly cache; private readonly deps; /** * @param deps - Injectable collaborators */ constructor(deps?: PeerProcessDeps); /** * Classify the client of a connection. Cached per socket object (a * keep-alive dashboard or CLI pays for one lookup). * * @param socket - The request's socket * @returns The verdict */ classify(socket: PeerSocketLike | null | undefined): Promise; /** * Uncached classification. * * @param socket - The request's socket * @returns The verdict */ private lookup; } //# sourceMappingURL=peer-process.service.d.ts.map