PacketBenchDocs

Security & credentials#

PacketBench holds API keys, git-host tokens and SSH passwords, spawns coding CLIs with your own shell authority, and hands language models file, shell and network tools. Each of those is a trust boundary, and they are enforced in different places — some in Rust, some in the Node sidecar, one only by a checkbox. This page collects every boundary in one account: where each secret lives, what an agent can reach, and which controls are enforced rather than advisory.

The short version: secrets go to the OS credential store and never to disk; file tools are confined to the workspace and bash is not; the shipped default permission mode prompts for nothing.

Secrets live in the OS credential store#

Every secret PacketBench owns is stored under the keyring service packetbench (src-tauri/src/core/brand.rs:29) — Windows Credential Manager, macOS Keychain, or the Secret Service on Linux. Nothing is written to disk in plaintext, and no token enters frontend state, persisted app state or a workspace record.

Entry Holds Written by
api-key-anthropic Anthropic key (Claude API and Claude Agent SDK rows) src-tauri/src/commands/api_keys.rs:27
api-key-openai OpenAI key (OpenAI API and OpenAI Agents SDK rows)
api-key-minimax MiniMax key
api-key-minimax-api MiniMax OpenAI-compatible endpoint key
api-key-openrouter OpenRouter key
api-key-ollama Ollama key, for a remote Ollama behind auth
github-token GitHub PAT or device-flow token src-tauri/src/commands/github.rs:63
git-host-token-<id> Token for a Gitea, Forgejo or other git host connection src-tauri/src/commands/github.rs:148
ssh-<serverId> SSH password for a host record using password auth src-tauri/src/commands/ssh_keys.rs:7
packet-agent-token Packet Agent service token src-tauri/src/commands/packet_agent.rs:41

The provider list is closed: set_api_key rejects any provider outside the six above (src-tauri/src/commands/api_keys.rs:6), so a malformed caller cannot create an arbitrary keyring account.

A keyring failure is a hard error, not a fallback. save_host_token returns "OS keyring unavailable" rather than writing anywhere else (src-tauri/src/commands/github.rs:166). That is deliberate: a silent file-based fallback would move the secret from an OS-protected store into a world-readable home directory without telling you.

Legacy entries under the pre-rename service packetade are read once, re-written to packetbench, and the legacy copy deleted (src-tauri/src/commands/api_keys.rs:40). Deleting an SSH host clears the entry under both services, so a reused server id cannot resurrect an old password (src-tauri/src/commands/ssh_keys.rs:98).

Note

On Linux the keyring needs an unlocked Secret Service provider — GNOME Keyring or KWallet. In a bare session with none running, key storage fails outright and the provider badges stay on missing_key. There is no degraded mode.

One file in the data directory looks like a secret and is not a live one. ~/.packetbench/github-token is a pre-keyring artefact: startup reads it, copies the value into the keyring and deletes the file (src-tauri/src/commands/github.rs:232-268). Nothing writes it now. See Where data lives.

What an agent can reach#

This is the question people actually have. The answer differs per tool, and the differences are load-bearing.

read_file, write_file, edit_file, list_directory and grep all resolve through resolve_workspace_path (src-tauri/src/core/tool_runtime.rs:26). Existing components are canonicalised before the prefix check, so a symlink pointing out of the project is caught rather than followed. For a path that does not exist yet, the nearest existing ancestor is canonicalised first and the remaining components appended — a file cannot be created through a symlink that escapes.

write_file checks the parent directory before creating it (src-tauri/src/core/tool_runtime.rs:435-458), because creating intermediate directories first and validating afterwards would have already written outside the workspace. A Rust test pins that ordering (write_file_rejects_escape_before_creating_parent_dirs, src-tauri/src/core/tool_runtime.rs:1036).

Reads are capped at 2 MB (MAX_FILE_SIZE, src-tauri/src/core/tool_runtime.rs:13).

bash is not confined#

The bash tool runs sh -c on Unix and cmd /C on Windows with the working directory set to the project path and nothing else (src-tauri/src/core/tool_runtime.rs:773-797). There is no path check, no command allowlist, and no sandbox. A command the shell accepts reaches whatever your user account reaches.

The bounds that do exist are on volume and time, not on reach: 256 KB of captured output (MAX_OUTPUT_SIZE, line 16), a 30-second default timeout capped at 120 seconds (lines 19 and 783), and — on Unix — its own process group so a timeout signals every descendant rather than only the sh that was spawned (line 807).

Warning

bash is the widest reach in the product and the only agent tool with no path boundary. If that is not acceptable for a given conversation, the control is the permission mode below, not the tool itself.

The default permission mode prompts for nothing#

PermissionMode::Auto is the shipped default on both sides — the Rust default (src-tauri/src/commands/api_agent.rs:46) and the value the frontend sends for a new conversation and for every built-in agent profile (src/stores/agentTaskStore.ts:1174, src/stores/profileStore.ts:35). Under Auto no tool call is gated.

Mode Effect on bash, write_file, edit_file
auto Default. Runs with the model's implicit authority; no prompt.
ask_for_risky Prompts before each call, unless the tool is on the conversation's auto-allow list.
allow_all Runs without prompts, stated explicitly rather than by default.
deny_all Refused with "Permissions: all risky tools are denied."

The risky set is exactly bash, write_file, edit_file (RISKY_TOOLS, src-tauri/src/commands/api_agent.rs:2005). Plan mode is a separate, narrower gate: only read_file, list_directory and grep run while it is active (PLAN_MODE_ALLOWED, line 2007). A test asserts that every file-mutating tool also sits in RISKY_TOOLS, so a new edit tool cannot be added past the permission gate by omission (edit_file_is_gated_like_write_file, line 495).

web_fetch cannot reach private addresses#

web_fetch always runs from the PacketBench process and is never tunnelled through SSH (src-tauri/src/core/tool_web.rs:3). The URL is model-supplied, so it is guarded in two layers (src-tauri/src/core/tool_web.rs:71-88):

  • An IP-literal host is rejected up front, because reqwest skips a custom resolver for literals.
  • A custom DNS resolver drops every resolved address in a blocked range at connect time (SsrfGuardResolver, line 310). Validating at connect rather than at parse closes the DNS-rebinding window — the client only ever connects to an address the guard approved.

Blocked ranges are loopback, RFC 1918 private, link-local (which covers the 169.254.169.254 cloud-metadata address), and the IPv6 equivalents including embedded-IPv4 transition forms (src-tauri/src/core/tool_web.rs:271-292). Redirects are capped at eight hops, re-validated for scheme and IP literal on every hop (line 105). Responses are bounded at 10 MiB before truncation and the request times out at 15 seconds (lines 20 and 15).

Terminal panes take an allowlist of programs#

A PTY pane can only spawn a program whose basename is on a hard-coded list: claude, codex, opencode, packetcode, bash, sh, zsh, powershell, pwsh, cmd, wsl, fish, nu, xonsh, ssh (src-tauri/src/commands/pty.rs:30). Anything else is refused with "Command '' is not allowed. Allowed commands: [...]". The allowlist bounds what PacketBench will start, not what those programs then do — every one of them is a shell or a coding agent, and they inherit your full user authority.

A pane opened with no project path gets ~/.packetbench/scratch as its working directory rather than $HOME (src-tauri/src/commands/pty.rs:212), so a CLI that scans its cwd for context does not walk your home folder.

The SSH boundary#

Unpinned hosts have a documented MITM window#

Saving a host requires verifying its key. Verify runs ssh-keyscan, Trust appends the chosen key to the app-managed file at ~/.packetbench/ssh/known_hosts (src-tauri/src/core/execution.rs:12), and on Unix the parent directory is created with mode 0700 (src-tauri/src/core/execution.rs:22). Once a fingerprint is stored, every SSH invocation carries StrictHostKeyChecking=yes plus UserKnownHostsFile=<that path> (src-tauri/src/core/execution.rs:113-118).

A host record saved before pinning existed has no fingerprint, and those fall back to StrictHostKeyChecking=accept-new — trust-on-first-use, with a warning logged Rust-side (src-tauri/src/core/execution.rs:119-124). That is a silent MITM window on the first connect. Open the host in Settings → Remote Hosts and press Verify to close it.

Interactive use tolerates TOFU because a person is watching. A non-interactive fan-out is not, so a Flight attempt against an unpinned SSH host is refused before any connection or worktree provisioning: "Refusing to launch against <host>: host key not verified. Pin it on the Servers page first." The Flight Deck also renders such a server as a disabled amber chip so it cannot be selected in the first place. See Flight Deck and SSH remote workspaces.

Remote tool paths cannot leave the workspace#

An API conversation on a remote workspace runs its file and bash tools over ssh, and every path is validated in Rust before a byte reaches the remote shell (resolve_remote_path, src-tauri/src/core/execution.rs:238):

  • An absolute path is rejected — "Remote tool paths must be relative to the workspace".
  • A .. component is rejected, before normalisation rather than after.
  • A NUL byte is rejected.
  • Every argument is POSIX single-quote escaped by sh_quote (src-tauri/src/core/execution.rs:230).

The remote side sources its MCP configuration from the remote filesystem. No local command, argument, environment value or secret crosses the connection.

Passwords never enter argv#

Unix OpenSSH will not read a password from stdin. Rather than put the secret on a command line, PacketBench writes it to a mode-0600 file inside a mode-0700 temporary directory and re-invokes its own executable as SSH_ASKPASS (src-tauri/src/core/ssh_askpass.rs:1-7). A Drop guard removes both the file and the directory (line 45). The secret is never in argv and never in an environment value — only the path to it is.

Windows OpenSSH does read the password from a non-TTY stdin, so on Windows it is piped there instead (src-tauri/src/core/tool_runtime_ssh.rs:151). Doing the same on Unix would be worse than useless: ssh reads from /dev/tty, and the stdin bytes would be forwarded straight through to the remote command, leaking the password into it. The code says so and closes stdin instead (src-tauri/src/core/tool_runtime_ssh.rs:219-236). The consequence on Windows is that a password-auth session cannot also pass stdin_data to the remote command, because stdin is already spoken for.

The MCP boundary#

Trust is frozen at session start#

Each configured MCP server has a trust profile keyed by scope:name. A snapshot of the profiles for the servers a conversation may use is captured when the session begins and sent with it, so an edit in Settings cannot silently broaden a session that is already running. Applying a change to a live conversation requires Reconnect selected in the Hub. New servers default to reads on, writes off, roots limited to the active project path, and an allowed tool list containing only the diagnosed tools that do not look mutating.

Three denial floors are always present and are not editable: credentials, outside_workspace, protected_publish (agent-sidecar/src/mcp-trust.ts:131).

Enforcement is an allowlist, not a denylist#

For sidecar-backed sessions the check runs at tool-call time in mcpToolDenial (agent-sidecar/src/mcp-trust.ts:243), in this order:

Check Denied when
Server grant The server is absent from the snapshot, or allowReads is off.
Frozen capability allowlist The server was probed and the tool is not in allowedToolNames.
credentials floor The tool name matches the credential pattern.
protected_publish floor The tool name matches publish, merge or deploy.
Mutation floor The session is read-only and the name looks mutating.
Read-only allowlist The session is read-only and the tool is neither annotated readOnlyHint: true by its server nor granted by you.
outside_workspace floor Any path-shaped argument resolves outside the frozen roots.

The last read-only check is the important one. A tool the session has never heard of is denied, not permitted — in the source's words, "cannot show it is safe" is a denial (agent-sidecar/src/mcp-trust.ts:272). The verb denylist beneath it is an extra floor, not the mechanism.

The path floor walks the whole argument object for keys that look like paths, resolves each against the frozen roots, and denies if any lands outside — an empty root list denies everything rather than allowing everything (agent-sidecar/src/mcp-trust.ts:229-239).

Warning

For a PacketCode (ACP) session only the server-level half of the snapshot is enforceable. The engine owns its own MCP client and dispatches every tool call itself, so per-tool allowlists, readOnlyHint probes, root checks and the three denial floors do not cross that boundary. Granting a server to an ACP session means trusting the whole server.

The sidecar protocol floor is a security control#

MINIMUM_PROTOCOL_VERSION is 11 and equal to EXPECTED_PROTOCOL_VERSION (src-tauri/src/commands/agent_sidecar/mod.rs:112 and :122). A sidecar advertising anything lower is marked Incompatible and every start_session against it is rejected.

The reason it is a floor rather than a warning is spelled out at src-tauri/src/commands/agent_sidecar/mod.rs:104: v11 added a field to an existing request, and an older sidecar does not reject an unknown JSON field — it ignores it and then runs every forwarded MCP server with no filtering at all. The session looks like it is working. The degradation is silent and it is a security downgrade.

A ready event carrying no protocolVersion fails the floor too: protocol_meets_floor(None) returns false (src-tauri/src/commands/agent_sidecar/mod.rs:184), because "we could not tell" is the same answer as "no" when the thing you could not tell is whether MCP trust is enforced. Four Rust tests pin this. See Agent event contract.

The MCP provider is a localhost service#

When PacketBench publishes its own resources to other MCP clients, it opens an HTTP listener. Four independent layers guard it (src-tauri/src/mcp_server/transport.rs:4-8):

  1. It binds 127.0.0.1 only, never 0.0.0.0, so nothing on the LAN reaches it (src-tauri/src/mcp_server/transport.rs:42).
  2. A host allowlist of 127.0.0.1:<port> and localhost:<port> acts as a DNS-rebinding guard (line 91).
  3. Any present non-loopback Origin header is rejected with 403, so a browser page cannot reach it even after resolving a name to 127.0.0.1. An absent Origin is allowed, because CLI clients omit it (line 133).
  4. The bearer token is the actual access control. A fresh 128-bit token is minted on every start, so restarting invalidates the old one (src-tauri/src/mcp_server/mod.rs:108).

A fifth control sits above the transport: the per-tool allowlist is frozen for the life of the run, and a tool outside it is not merely refused on call — it is absent from tools/list (src-tauri/src/mcp_server/mod.rs:1046). An empty allowlist is an answer meaning "serve nothing", not a missing one (line 1085). The exposed resources are read-only packetbench://… JSON — project, flights, issues, workspaces, reviews, memory patterns — so a client that gets through sees app state, not your filesystem. See MCP hub.

The Monitor window is a capability boundary#

A read-only Monitor window is a second native webview in the same process. Being in-process is not treated as being trusted. guarded_invoke_handler! (src-tauri/src/lib.rs:121) wraps the generated handler and consults command_allowed_for_window before dispatching (src-tauri/src/commands/monitor_windows.rs:21). A window whose label starts with monitor- may invoke exactly five commands — get_monitor_window_route, close_monitor_window, focus_monitor_route_in_main, load_persisted_state, load_conversations (line 10) — and everything else is rejected with "This read-only Monitor cannot invoke that application command."

The Tauri capability file is the other half: src-tauri/capabilities/monitor.json scopes to ["monitor-*"] and carries event and window-chrome permissions only, with none of shell:default, fs:default or process:default. A repository fence pins both halves — see Invariants & tripwires.

Dictation refuses secure fields#

Delivery of a transcript into another application is gated in Rust, not only in the UI. deliver_dictation_text re-reads the stored setting through paste_is_permitted and can only ever narrow what the caller asked for; an unreadable or corrupt dictation.json means clipboard-only (src-tauri/src/commands/dictation/delivery.rs:13-24). A caller reaching the command with paste: true cannot drive Ctrl+V into the foreground app unless you turned the setting on.

A field is refused as a delivery target when it is a password input, when its autocomplete is current-password, new-password or one-time-code, or when it sits inside a region marked data-dictation="off", data-dictation="secure" or data-sensitive="true" (src/lib/dictationTarget.ts:47). The Dictation view itself carries data-dictation="off", as does the Trust & Provenance settings card (src/components/views/tools/TrustProvenanceCard.tsx:32). Focusing a secure region also clears the remembered field, so a transcript spoken at a password prompt cannot land in the form behind it.

The clipboard deliberately keeps the transcript after a paste. Restoring a previous clipboard value could re-expose a password or one-time code that was sitting there (src-tauri/src/commands/dictation/delivery.rs:26-29).

Trust and provenance are local and bounded#

The Trust & Provenance audit in Settings → Security & Diagnostics records decision metadata only — never transcript or tool output. Retention is 7 days and 200 entries by default, or 30 days and 200 entries (src/stores/provenanceAuditStore.ts:35, :50). It lives in localStorage, not on the server, because there is no server. Nothing is uploaded.

The neighbouring migration-evidence card is content-free by construction: no prompts, transcripts, paths, files, diffs, repository URLs, tool arguments or ids are persisted. See Settings.

What is not protected#

Deliberate absences, so nobody spends a day looking for a control that is not there.

Gap Current state
Installer code signing Not configured. tauri.conf.json carries no bundle.macOS.signingIdentity, and the release gate only checks for signing credentials under the opt-in pnpm release:gate:strict. Beta builds are unsigned and installed manually.
Auto-updater Planned. A runbook exists at dev/updater-setup.md; tauri.conf.json declares no plugins.updater, so no update is ever fetched or verified.
bash path confinement Not implemented, and not planned as a sandbox. The permission mode is the control.
Per-tool MCP enforcement for ACP Not enforceable. The engine owns the dispatch; only the server-level grant crosses.
Encryption at rest Not implemented. state.v1.json, conversations, transcripts and the dictation database are plain files with default permissions. Secrets are not among them.
Telemetry Deliberately absent. There is no account, backend or upload path.
Note

The webview runs under a restrictive CSP declared in src-tauri/tauri.conf.json: default-src 'self', frame-src 'none', object-src 'none', and connect-src limited to the Tauri IPC endpoints plus https://api.github.com. style-src is the one relaxation, listed in dangerousDisableAssetCspModification so Tauri may inject asset styles.

  • Where data lives — every path each of these controls writes to, and what is safe to delete.
  • SSH remote workspaces — the host record, verification flow and remote execution model in full.
  • MCP hub — configuring servers, trust profiles and the provider card.
  • Agent event contract — the sidecar protocol version and how to raise the floor.
  • Settings — where each of these controls appears in the UI.
  • Dictation & analytics — capture, delivery and the analytics tab.
  • Release status — what is signed, published and proven today.