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).
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.
File tools are confined to the workspace, symlinks included#
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).
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 '
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).
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):
- It binds
127.0.0.1only, never0.0.0.0, so nothing on the LAN reaches it (src-tauri/src/mcp_server/transport.rs:42). - A host allowlist of
127.0.0.1:<port>andlocalhost:<port>acts as a DNS-rebinding guard (line 91). - Any present non-loopback
Originheader 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). - 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. |
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.
Related#
- 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.