Skip to content

These pages describe PrivacyFence 7.0.2. Download it · What changed

Security and compliance

For operators and security reviewers: what PrivacyFence protects, where each protection stops, and how the pieces are laid out on disk. This is a technical control reference, not a certification statement. The reasons behind each decision are in the linked ADRs.

Deployment modes

Local mode (desktop; deprecated) Organization mode Single-user mode (organization mode on localhost)
Where it runs One person's own computer A server the organization runs One person's own computer, usually in Docker
Who signs in Nobody: one implicit user per OS account Each person, through the organization's OIDC identity provider One administrator: a setup code, then a passkey
How AI clients connect /mcp on 127.0.0.1, plain HTTP (ADR 0010) /mcp behind the organization's HTTPS reverse proxy, with OAuth 2.1 (ADR 0011) /mcp at http://localhost, with OAuth 2.1 and the person's consent
Privilege separation Always, on a packaged install (see Privilege separation) Not used: the AI client has no access to the server Not used; see Single-user mode
Built-in OAuth apps None Yes, through the relay Yes, through the relay

Local mode is deprecated in PrivacyFence 7 and removed in 8 (moving to organization mode). Claude Desktop and Claude Code work with every mode. claude.ai can reach PrivacyFence only through an organization deployment, because the others listen only on localhost.

Which PrivacyFence-operated services exist. In local mode there are none: every tool call goes from the machine running PrivacyFence straight to the provider (Google, Slack, Salesforce, Atlassian, Telegram, Grist or your Peek server). Organization mode and the Docker image have one, and only when a service uses a built-in app: the OAuth redirect relay at oauth.privacyfence.eu, which forwards a provider's redirect to your server. It is not in the path of any tool call, and an organization that uses its own apps for every provider needs it only for the opt-in built-in Google sign-in. What it sees and what it can do are in Built-in apps, the admin config and the relay.

A packaged install is the macOS .pkg (on the DMG), the Windows installer, or the .deb. A source checkout or a pip/pipx install privacyfence is not packaged. It is never privilege-separated, and it is how organization mode is deployed.

Trust boundary

The daemon, its policy, the approval UI, connector credentials, the organization configuration and the audit log are the trusted computing base. Invalid security configuration stops the daemon rather than weakening it.

Local-mode trust boundary

On a packaged install the daemon runs under its own service account. The files that decide what the AI client may do are out of reach of your own OS account, and so of any AI client running as you (see Privilege separation). What stays reachable from your account, by design, is starting a web session. The companion app and an AI client run as the same OS user, and no permission bit can tell them apart (ADR 0002, decision 6). So a session is made insufficient rather than unreachable. A session the AI client starts for itself cannot approve anything (Human and unattested sessions), and releasing a gated call needs a passkey (Passkey step-up).

The result on a packaged install:

  • Integrity is the strongest property. An AI client with shell access cannot approve its own request, add an always-allow rule, change the PII policy, enroll its own passkey, or rewrite the audit log without it showing.
  • Confidentiality of the review screen is weaker. A process running as you can open the approvals page and read what is pending. It cannot release it.
  • Root or a local Administrator is out of scope. sudo re-owns any file and reconfigures any service. Separation makes escalation need an authentication prompt you see; it does not stop it.

On a non-packaged install (no separation) the AI client runs as the same account as the daemon. It can read and write every file above, so the approval gate is a workflow control with an audit trail, not a boundary against local code. It still defends against an AI client acting only through /mcp, against mistakes, against remote attackers without code execution, and against a hostile web page in your browser.

Organization mode

The daemon runs on a server the AI client cannot reach except through /mcp. People sign in with the organization's identity provider. A browser session and an MCP client's OAuth token from the same sign-in resolve to the same person. Each person's connectors, approvals, staged downloads, passkeys and audit log are kept apart by principal, and one person never receives another's.

Installation-wide settings (which services are offered, sign-in restrictions, step-up, audit forwarding and the others) are changed on the Admin page, by an administrator the identity provider names. They are stored in the admin config described below.

Single-user mode

An organization-mode install without an identity provider is single-user mode: one administrator, served only at http://localhost or http://127.0.0.1. It is how the Docker image starts without any configuration. A server that ever had an identity provider, a pinned bundle key or org_server recorded in its admin config is refused in single-user mode, so removing the identity-provider settings cannot turn a production server into a setup-code server (ADR 0260).

Signing in. The server logs a setup code (Setup code: XXXX-XXXX) at start, while no passkey is enrolled. Entering it at /login signs you in as the administrator and leads to Passkeys. The code works once, five wrong attempts discard it until the next start, and it stops working for good once a passkey is enrolled; every later sign-in uses the passkey. Step-up is on and a passkey is required by default, so approvals and every sensitive Admin action ask for the passkey even with a valid session cookie.

What it does not protect against. Single-user mode is no protection against an agent that can run docker on that computer. Whoever can run docker can read docker logs (and so the setup code), read or replace the data volume, or open a page on another localhost port, and takes over the install. The AI clients you connect run as you. Run the container under a separate operating-system account (rootless Docker) or in a virtual machine, publish the port only on 127.0.0.1, or use an organization server. A lost passkey is recovered by stopping the container and deleting the passkey credentials file in the volume; the next start prints a new code.

Built-in apps, the admin config and the relay

Built-in apps. The organization image and the PyPI package carry PrivacyFence's own OAuth apps for Google, Slack, Salesforce, Atlassian and Miro; the desktop installers carry none. A server uses one when a service is offered and no connector config replaces it. Their client secrets are public in effect: anyone can read them from the image. A refresh token is bound to the app that issued it, so a built-in app's public secret adds no protection to a stolen token; protect tokens as you would with your own app. Secrets are rotated by adding the new one beside the old where the provider allows two (Google and Salesforce do), releasing, and retiring the old one once the release is adopted (ADR 0255). Do not mark the built-in app as trusted or pre-approved in a provider's admin console: that trust would extend to every PrivacyFence server and to anyone using the public app. To trust an app, register your own and give it to PrivacyFence as a connector config.

The relay. A provider accepts only fixed redirect URLs for an app, so a built-in app redirects to https://oauth.privacyfence.eu/callback/<provider>, and the relay forwards that browser redirect to your server (ADR 0259).

  • What it sees. The query of each redirect (the one-time authorization code and your state) and the host name your server registered, its registration dates and, for the built-in Google sign-in, the verified sign-in domains. It writes no logs, stores no authorization code, and holds no client secret, access token or refresh token: it never exchanges a code. A localhost server needs no registration; its redirect goes straight back to your own browser. The privacy statement and the relay terms are the legal text.
  • Domain verification. The relay forwards only to a host that proved it controls its name: the server sends the relay a secret and serves its SHA-256 at /.well-known/privacyfence-relay (the route answers 404 unless a relayed service is in use), and renews daily. Only the holder of the secret can register a host or change its registration; the public hash alone does nothing. The relay refuses hosts under private suffixes such as github.io and pages.dev, where anyone can create a name. The built-in Google sign-in also needs each sign-in domain proven in DNS (a TXT record at _privacyfence-relay.<domain> whose value is a hash bound to the server's own host name, so it cannot be reused by another server), and forwards a code only for a verified domain. A verified domain is dropped only when its DNS record goes away.
  • Maintainer controls. Each new registration emails the maintainers, and a reported host is revoked within one working day.
  • What a compromised relay could do, per provider. It sees the code in transit and could send it elsewhere, or substitute its own. For Google, Atlassian and Salesforce, which PrivacyFence signs in with PKCE, a code without the server's verifier is useless, and a substituted code fails at the exchange. For Slack and Miro, which send no PKCE, a compromised relay could exchange a code before your server does, or sign a person in to an attacker's account; the Admin page says "Sign-in codes for this service pass through oauth.privacyfence.eu without PKCE. Use your own app to avoid this." on those rows. A compromised relay never gets a token that is already stored on your server, and none of this applies to a service that uses your organization's own app. A compromised deploy path could change the relay; the deploy environment requires reviewers other than the author.
  • Consent phishing. Anyone who owns a public domain can run a server and show "PrivacyFence" on a provider's consent screen. Domain proof, the notification and revocation bound this, which is why the provider guides tell you not to trust the built-in app.

The admin config. Installation-wide settings live in org/admin_config.json, written only by the daemon with owner-only permissions, and changed on the Admin page by the install owner (desktop) or an IdP administrator (organization). It has the trust of settings.yaml: whoever can write the install directory can change it (ADR 0257). The addresses a Peek or Grist provisioning key is sent to come only from the server's environment (or an upgraded server's locked address), so a hijacked Admin session cannot point the key at another host; changing audit forwarding to another URL clears its bearer-token variable.

Signed connector configs. An organization's own app for Google, Slack, Salesforce, Atlassian, Miro or Grist is a small text file signed with an Ed25519 key. The first upload pins the key only after an administrator confirms its fingerprint, a different key is refused (rotation is deleting org/connector_config_signing_pubkey.txt on the server), each provider's serial must exceed any accepted before, also after the config is removed, and the file is verified again at every start (ADR 0269). Signing now covers only startup settings (on a desktop bundle) and app credentials; operational settings are not signed, they are the admin config (ADR 0256). Uploading or removing one always asks for a passkey, is audited without the secret, and makes everyone who connected that service sign in again.

Sign-in by verified email. An administrator can be named by email (PRIVACYFENCE_ADMIN_EMAILS), and sign-in can be limited to allowed email domains. Both now trust an address only when the identity provider reports it verified (email_verified exactly true), and with Google the account's Workspace domain (hd) must match the address, so a personal account registered with a work address cannot get in. A provider that never sends email_verified, such as Microsoft Entra ID, needs PRIVACYFENCE_IDP_TRUST_UNVERIFIED_EMAIL=true for allowed domains to keep working; it never applies to administrators named by email. See ADR 0265.

Human and unattested sessions

Every local-mode web session records how it was started:

Session Started by Can view Can approve
human The companion's Open Approvals or Open Settings (the daemon calls the companion back to confirm the request came from it); clicking PrivacyFence itself (app icon, Start Menu, applications menu), after the companion's confirmation dialog; privacyfence-app --print-sign-in-link, after the companion's confirmation dialog Yes Yes
unattested Anything else running as your OS user that asks the control channel for a sign-in code; --print-sign-in-link when no companion confirms it Yes No

A human session is needed to approve a call (singly or in a batch), to confirm a rule the AI client proposed, to use a recovery code, to upload an organization config bundle, and for every sensitive settings change listed under Passkey step-up. Denying and cancelling need nothing. An unattested session shows what is pending and says it cannot approve.

This is enforced on privilege-separated installs, where a companion is guaranteed to be installed and running. No MCP tool can obtain a sign-in link (ADR 0013).

Every sign-in code the daemon issues, and every attested one it refuses, is written to the audit log as sign_in_code_minted. The code's source and whether its session can approve are included. The most recent ones are listed under Recent sign-ins on the /security page. If one appears that you did not ask for, treat the install as compromised.

Sign-in codes are single-use and expire after 10 minutes. A session expires after 30 minutes idle or 24 hours after it started, whichever comes first.

Limits: the companion signs you in by opening a URL carrying the code in your browser. A process running as you can read that URL from the browser's command line and race the browser to use it. The companion's own socket is also reachable by your OS user, so a process that binds it before the companion starts can impersonate it. Both are visible (a missing companion breaks connector sign-in), and neither can be done at a time of the AI client's choosing without you.

Passkey step-up

Step-up asks for a fresh WebAuthn passkey assertion (Touch ID, Windows Hello, a security key, or a phone over the browser's QR-code flow) before a decision is released. The step_up settings (in config/settings.yaml for local mode, org/admin_config.json for organization mode; see Configuration reference) default to:

Key Packaged local install Non-packaged local install Organization mode
enabled true false false (true on a single-user install)
require_passkey true false (setting true is refused at startup) false (true on a single-user install)
scope writes_and_pii_reads writes_and_pii_reads writes_and_pii_reads
batch single_assertion single_assertion single_assertion

An explicit value always wins. On an organization server an administrator makes step-up stricter on Admin → Step-up (the page only offers stricter choices); relaxing it is an edit of org/admin_config.json followed by a restart (ADR 0068), and rp_id, rp_name and batch have no control and are set the same way.

scope decides which approving decisions need a passkey:

scope Write Read flagged as personal data Any other read
writes Passkey — —
writes_and_pii_reads Passkey Passkey —
writes_and_reads Passkey Passkey Passkey

A read an always-allow rule covers never becomes an approval, so no scope asks about it.

With require_passkey on, the following also need a passkey in both modes (ADR 0034), whatever scope says:

  • adding or removing an always-allow rule; changing the default or a category policy; turning PII detection or a PII category on or off; the calendar free/busy setting; re-enabling a connector that was switched off; turning step-up on;
  • uploading an organization config bundle;
  • every sensitive Admin action (offering a service, uploading or removing a connector config, changing a server address, the Salesforce login URL, sign-in restrictions, step-up, scoped tokens, audit forwarding, the privacy policy, uploading rooms or a bundle): these ask for a passkey whatever the step-up configuration says, in every mode, and a person with no passkey is told to add one on /security first (ADR 0257);
  • confirming a rule an AI client proposed with privacyfence_propose_policy_change (see below).

Adding a passkey, and removing your last one, are gated whatever require_passkey says (see Enrolling a passkey). Denying or cancelling never needs a passkey.

How the settings combine:

  • require_passkey on, nothing enrolled: the daemon starts, every page shows a banner, and every approving decision scope covers and every sensitive change is refused (403, naming /security) until a passkey is added. On a packaged install the companion opens /security at its next start so you can enroll one.
  • enabled on, require_passkey off: a passkey is asked for only if one is enrolled. With none enrolled, local mode lets decisions, batches included, go through. Organization mode accepts a fresh sign-in at the identity provider instead of a passkey for a single decision, and refuses a batch that needs step-up (400, nothing applied): approve each item from its card, or add a passkey (ADR 0066). That sign-in starts only from the card's own button, by a request the page checks, never from a link (ADR 0179).
  • Batch approval: one assertion covers a selected set of approvals and is bound to that exact set and each item's result. It cannot be replayed for a larger, smaller or altered set. batch: per_item sends every item that needs step-up back to its own card. PII confirmations and choice dialogs are never batched.

Turning step-up on from the Settings page (the Security card) is immediate and audited (step_up_requirement_enabled). There is no control that turns it off. That takes an edit to config/settings.yaml and a restart. The change is audited (step_up_requirement_disabled), and a banner stays on every page, with a warning in the daemon log, until it is turned back on.

Passkeys for proposed rules

privacyfence_propose_policy_change lets an AI client ask for an always-allow rule. No approval card is involved, so the confirmation dialog is the whole gate. Confirming it needs a human session and, wherever require_passkey is on, a passkey, in both modes and regardless of scope. The dialogs that follow an approval card you already answered (the PII confirmation, Always allow) need neither again.

Enrolling a passkey

Registration accepts any authenticator: built in, a roaming security key, or a phone. It requires user verification (a PIN, fingerprint or face), and asks for no particular attachment, because nothing in a passkey's signed data says how it is attached, so the server could not check it (ADR 0055).

Registration uses none attestation, so the server cannot prove a person or real authenticator created a new passkey, and the user-verification flag is the authenticator's own claim. Enrollment itself is therefore gated:

  • A passkey is already enrolled: adding another needs an assertion with an existing one. Same in both modes.
  • Nothing enrolled yet (local mode): the companion shows a system dialog asking you to confirm. No answer, a denial, or no companion running is a refusal. On Linux this dialog needs zenity or kdialog; with neither installed, the first enrollment is refused with a message naming them.
  • Nothing enrolled yet (organization mode): there is no companion, so the first enrollment rests on the identity-provider sign-in. Enroll everyone before relying on require_passkey against a stolen session, and treat an unexpected webauthn_credential_enrolled entry as an incident.

Refused enrollments are audited as webauthn_enrollment_refused. A first enrollment's audit entry says "First passkey enrolled".

Recovery code

A recovery code lets you back in when every enrolled passkey is lost. It is 16 hexadecimal characters in four groups (A1B2-C3D4-E5F6-1789). It is issued when you enroll a passkey and no unused code is on file. Only a salted SHA-256 hash is stored, under authority/.

Where it is shown. On a packaged local install the companion shows it in a desktop dialog, and the daemon stores it only after the dialog was shown. If the companion cannot be reached, the passkey is still enrolled, no code is issued, and /security says why. In organization mode and on a non-packaged install it is shown once in the browser.

Getting a new one. Nothing keeps the plain text, so a lost code is replaced, not shown again: New Recovery Code… in the companion's menu (macOS menu bar, Windows tray), or the New PrivacyFence recovery code action on the PrivacyFence entry in the Linux applications menu. The companion asks you to confirm first, because a new code invalidates the old one.

Using it. On /security, choose Use your recovery code. The attempt:

  • needs a human session on a privilege-separated install; an unattested one is refused;
  • is rate-limited to 5 attempts per session and 20 across all sessions in any 15 minutes (the count resets when the daemon restarts);
  • is audited, whatever the outcome: webauthn_recovery_refused with the reason (unattested session, too many attempts, wrong or used code) but never the code, or webauthn_recovery_code_used on success.

A wrong code consumes nothing: the stored code stays valid for the next attempt. A correct code is marked used and removes every passkey enrolled for you, so you can enroll a new one straight away.

Scoped tokens

A scoped token is a bearer credential for /mcp that a person creates in Settings, in both modes, for one client or job. It has a name, an expiry (90 days by default) and a scope.

  • What a scope limits. The tools the client sees and may call, and the token's own rules. The rules are a copy kept on the token (added on the New token form or copied from an Auto-accept rule) and a scoped call never consults the person's Auto-accept rules (ADR 0236). A call that no rule of the token auto-accepts is denied at once (denied_scoped_token in the audit log). Nothing outside the scope can be reached.
  • Deny-only. A scoped call never gets an approval card and never leaves a pending approval, and the temporary "Allow once" window does not apply to it. It cannot change rules, read the full rule list or wait for an approval.
  • Stored as a hash. The secret is pfst_ plus random characters; only its SHA-256 is kept, in scoped_tokens.json under authority/. A malformed file fails closed: the tokens stop working. It is accepted only on /mcp. An older version cannot read a token file written by this one: the tokens stop working there, and its next token change deletes them.
  • Attested agent. The audit log records the call as the agent scoped-token:<id>, named after the token, with source override. It is attested because a person created the credential, and it outranks a redirect-URI attestation for that call (ADR 0164). Renaming a token changes the name on later entries only; earlier entries keep the name it had.
  • Personal data in reads. Two separate boxes on the New token form lift the personal-data scan for a token's covered calls: exports, downloads and page uploads, and Gmail and Slack message reads. The scan still runs and the audit log records what it found; every other call still stops for review, or is denied for a token, when personal data is found (ADR 0173, ADR 0219).
  • One token per routine. Give each routine its own token with its own rules, so a rule for one routine never reaches another routine or your interactive clients (ADR 0220, ADR 0236).
  • Deleting a rule. A token's rules cannot be edited. Deleting or changing an Auto-accept rule, in Settings or through privacyfence_propose_policy_change, does not change any token; revoke the token to take its access away (ADR 0236).
  • AI-proposed tokens. A client can call privacyfence_propose_scoped_token; it creates nothing and returns only a review link (ADR 0250, ADR 0251).
  • The tool is not offered to a scoped token, and is refused in an unattended session. Locally only the computer's owner can use it.
  • A proposal is single use and lasts one hour. The review can only narrow it (rules, verbs, tools, days); the personal-data boxes start unticked and only the person can tick them.
  • Creating needs the token's name typed exactly. Passkey step-up, where in force, is bound to the request and the proposal, so it cannot be replayed for another.
  • The client never receives the token or the outcome. The review page names the proposing client and says that name is the client's own claim.
  • from_session builds the proposal from a ledger of the session's approved calls, kept in memory only and dropped when the session ends (ADR 0252).
  • Every step is audited (scoped_token_proposed, _accepted, _declined, _expired).
  • Local mode copies the secret to the clipboard through the companion once. Organization mode shows it once in the browser, and only after the administrator turns scoped tokens on (scoped_tokens.enabled) and sets the longest lifetime (scoped_tokens.max_days). Token proposals are a further opt-in, scoped_tokens.allow_ai_proposals, off by default and only effective when scoped tokens are on (ADR 0253).
  • Revoking. A person revokes one token, or all, in Settings (with a passkey wherever the install requires one). An administrator revokes every token of one person with privacyfence-app --revoke-scoped-tokens PRINCIPAL_ID. A scoped token outlives an OAuth sign-in (which ends within 30 days), so offboarding a person includes running that command (ADR 0165). When Peek auto-provisioning is on, also remove the person from the identity provider and run privacyfence-app --peek-release PRINCIPAL_ID, which revokes the Peek token PrivacyFence minted for them and stops renewing it (Peek setup). When Grist auto-provisioning is on, offboarding also includes privacyfence-app --grist-release PRINCIPAL_ID, after removing the person from the identity provider. It deletes a key the provisioner created; a key the person made in Grist was adopted and stays in Grist, so delete it there or disable the account.

Privilege separation

Every packaged install runs the daemon under a dedicated service account and keeps its data in a system directory that account owns. The installer does this; a packaged daemon that finds itself unseparated refuses to serve /mcp or approvals and names the command that fixes it (ADR 0003).

macOS Linux (.deb) Windows
Separation tool /Applications/PrivacyFenceApp.app/Contents/Resources/scripts/macos_privilege_separation.sh /usr/sbin/privacyfence-privilege-separation %ProgramFiles%\PrivacyFence\privilege-separation.ps1
Service account _privacyfence privacyfence (system user) NT SERVICE\PrivacyFence (virtual account)
Group your account joins _privacyfence privacyfence PrivacyFenceUsers
System root /Library/Application Support/PrivacyFence /var/lib/privacyfence %ProgramData%\PrivacyFence
Daemon runs as LaunchDaemon com.privacyfence.daemon systemd unit privacyfence-daemon.service Windows service PrivacyFence
Companion runs as LaunchAgent com.privacyfence.companion (menu-bar app) /etc/xdg/autostart/privacyfence-companion.desktop (privacyfence-companion --serve, no tray) Scheduled task PrivacyFenceCompanion (tray app)
Daemon program Root-owned copy in /Library/PrivacyFence/image /opt/privacyfence (owned by the package) %ProgramFiles%\PrivacyFence (admin-only install)

Each tool takes enable [--for-user <name>], status and uninstall [--purge] (-ForUser, -Purge on Windows). Run it with sudo, or from an elevated PowerShell with -ExecutionPolicy Bypass -File. uninstall keeps the data; --purge also deletes the data, the marker, and the service account and group (ADR 0042). status checks the modes and owners below, your group membership (PENDING USER while nobody has been added to the group yet; a new membership takes effect at your next login), and that the daemon and companion are registered. The daemon repeats the same checks at every start, and refuses to run as the wrong account.

Layout under the system root (POSIX modes; Windows expresses the same with NTFS ACLs):

Path Holds macOS / Linux Windows
(root) Everything below service account, 0711: anyone may traverse, nobody else may list Owner Administrators; inheritance removed; service account, SYSTEM, Administrators full; Users traverse only
privilege-separation.json The marker: platform, account names, recorded owner 0644 Adds Users read
authority/ config/settings.yaml (policy, rules, PII settings); webauthn_credentials.json; webauthn_recovery_code.json; step_up_state.json; mcp_token; scoped_tokens.json (hashes of scoped tokens); scoped_token_activity.json (per token id, the time of its last use and last denial and the denied tool's name; no secrets); logs/audit/ (audit log and its .audit_chain.key) service account, 0700 Service account, SYSTEM, Administrators only
credentials/ Connector OAuth tokens and the Telegram session service account, 0700 As root
org/, logs/, downloads/, uploads/, caches Organization config, daemon logs, staged files service account, 0700 As root
users/os-<id>/ Another OS account's own data, with its own authority/ (ADR 0008) service account, 0700 As root
handoff/ mcp_url, web_base_url; on macOS and Linux also control.sock and each account's companion*.sock group, 3770 (setgid and sticky); files 0640, control socket 0660 Adds PrivacyFenceUsers read (both channels are named pipes, so nothing is created here)

The recorded owner in the marker keeps the install's original data. Adding another account with enable --for-user gives that account its own principal and never changes the owner (ADR 0043). One group member cannot take over another's companion socket (ADR 0027).

On Linux the script refuses to separate an install whose daemon program, or any directory above it, a non-root account can write. Run through pkexec, it accepts only the companion's own requests: starting, stopping or restarting the service, and setting it up for the account that ran pkexec (ADR 0183).

What this takes away from a process running as you:

  • editing config/settings.yaml: the always-allow rules, policy and PII settings;
  • writing webauthn_credentials.json or the recovery-code file;
  • reading the MCP token file or the audit log's key. An MCP client gets its token from the control channel, which identifies the calling OS account from the kernel; the .mcpb shim does this itself, and for other clients privacyfence-app --print-mcp-token prints it and the companion's Copy AI Client Token copies it to the clipboard. Settings' Copy token asks the daemon to have the companion copy it, so no web response carries it; rotating it from Settings takes a passkey when passkeys are required (ADR 0104);
  • reading connector credentials;
  • asking the companion to open an arbitrary URL. The companion accepts that only from the daemon's service account. From your own account it accepts only "show Approvals/Settings", which asks you first.

On a separated install, a tool that reads or writes a local file you named goes through the .mcpb shim, which runs as you and acts only on a path that appears in that tool call's arguments (ADR 0007).

A non-packaged install is never separated. PRIVACYFENCE_DEV_ALLOW_UNSEPARATED=1 exists for development against a source checkout and must never be set in a real deployment.

Audit log integrity

Each gate decision, approval and security event is written to a weekly JSON Lines file (YYYY-Www.jsonl). Local mode's log is authority/logs/audit/ under the data directory; other principals have their own. Every entry is chained to the previous one with HMAC-SHA256, keyed by .audit_chain.key in the same directory, on every install. Check a log from a source checkout with its dependencies installed:

sudo python3 scripts/verify_audit_log.py "/var/lib/privacyfence/authority/logs/audit"
python3 scripts/verify_audit_log.py <dir> --week 2026-W38     # one ISO week

Exit status: 0 intact, 1 a week failed (or no files found), 2 directory missing.

The script also checks that each week's first entry continues the previous week's last one, and that the newest entry matches .audit_chain_state.json, so a removed week or a cut-off tail is reported, unless the .audit_chain_state.json file was edited too (it is not keyed). Lines written before chaining existed are accepted only at the top of a file (ADR 0176).

The chain detects edits, insertions and deletions made without the key. Anyone who can read the key can rebuild a consistent chain. On a separated install that means root or the service account. On a non-packaged install it includes any process running as you. For evidence that survives that, send a copy off the machine. Organization mode can forward every entry to syslog or an HTTP endpoint (Admin → Audit forwarding, stored as audit_forwarding in org/admin_config.json); local mode has no forwarding.

Security events have their own decision values: sign_in_code_minted, webauthn_credential_enrolled, webauthn_credential_removed, webauthn_enrollment_refused, webauthn_recovery_code_used, webauthn_recovery_refused, step_up_requirement_enabled and step_up_requirement_disabled. Treat the audit log as sensitive. It shows which services, tools and resources were used even when no content was released.

Which AI system the audit log names

Each gated call records the calling AI system in agent_id, agent_name, agent_version and agent_source. In organization mode the approval card, the approvals list and the Audit Log page show it as Verified, Not verified ("Says it is …"), or Unrecognised AI system with the name sent. In local mode they show every request as Undetected, with no name and no badge; the fields are still recorded (ADR 0088).

agent_source Meaning Shown as (organization mode)
oauth_client Organization mode: the call's access token belongs to an OAuth client that registered only with a vendor's own redirect URI (claude.ai, ChatGPT, Gemini Enterprise) Verified
client_info The name the client sent (MCP clientInfo, or an organization-mode client's registered name), including one relabelled by a local agent_overrides: entry Not verified, or Unrecognised if it matches no known AI system
"" (empty) No usable name Unrecognised

The only verified identity is a redirect-URI attestation: the authorization code can only be delivered to a redirect URI the client registered, so a registration whose every redirect URI is one vendor's own callback got its tokens through that vendor. It needs no administrator step, and it applies to one OAuth registration only. Claude Code and any client with a loopback redirect stay Not verified, because any local program can register one. A member, or a program using their signed-in browser session, can still register under a vendor's redirect URI and have their own calls shown as that vendor; it never reaches another member's calls (AI systems, ADR 0224, ADR 0035). In local mode the AI clients of one OS account share that account's MCP token, so nothing can tell them apart, except a client that holds its own scoped token: its calls are recorded as the attested agent scoped-token:<id>. An agent_overrides: entry only relabels the recorded name and never makes it verified (ADR 0037).

No name, verified or not, changes an outcome: it selects no rule and releases nothing. The card names the AI system and shows its logo only when it is verified; otherwise it says "the AI system" (ADR 0036).

What the AI sees before approval

Before a gated call is approved, the AI client has only what it already had: the tool schema, the arguments it sent, and a status it can act on. PrivacyFence may fetch and scan provider data first to build the card, detect personal data or check a rule. That data stays inside PrivacyFence.

  • Reads: the result is held until the approval resolves. The card may show you a bounded preview. On deny or cancel the client gets nothing from it. Where policy requires redaction, the client gets the redacted version.
  • Writes: approval decides whether the external change happens. Connector credentials and internal tokens never appear in a tool result.
  • Rules: an always-allow rule changes only whether you are asked, inside its stored scope. It adds no tools and reveals no extra data.
  • Errors returned over MCP carry no credentials, session or sign-in codes, secret paths, or unreleased content; details go to the daemon log.
  • Notifications follow notifications.detail: minimal and standard (the default) carry no gated content; detailed adds the approval's summary line and can put it on a lock screen. An organization deployment's push notifications always carry the count only, encrypted and padded to a fixed size; the browser's push service (Apple, Google, Mozilla or Microsoft) sees when one was sent, and an administrator can turn them off on the Admin page (ADR 0081).

A deny note is the one thing a person can add to what the AI sees. Whoever denies a request can pick an intent (for example "Wrong target") and type up to 500 characters of their own on why, or what to do instead. Only the signed-in person deciding that approval can write it, behind the same session, CSRF and origin checks as a plain Deny; the AI client has no way to decide an approval, and nothing from the request or the connector data is ever put into the field. The note goes only to the AI client that made the request, in its denial message and in privacyfence_await_approval, with control and invisible characters stripped, secrets redacted and the text quoted after a fixed label so it cannot pass for PrivacyFence's own words. It is not screened for content: any "injection" in it is the user instructing their own agent, and a note approves nothing, so the agent's next call still goes through the gate (ADR 0083). It is not stored: the audit log records only the chosen intent and the note's length, and the text never reaches a log line, the approvals stream or a push notification (ADR 0084).

See Approvals and policy for the approval list, cards and PII handling.

Other controls

  • Web UI: HttpOnly session cookie, CSRF token and same-origin checks on every change, a host allowlist, and a Content Security Policy with per-response nonces. Every custom route must be classified or the app refuses to start (ADR 0014).
  • Organization OAuth server: at most 2,000 registered clients, 8 KiB of metadata per registration, and unused registrations pruned after 180 days. Access tokens last 1 hour and refresh tokens at most 30 days.
  • Organization config: validated at startup, which fails instead of weakening. A desktop bundle or a server's legacy bundle has its hash audited and can be signed (ADR 0016). An organization server's own apps are signed connector configs, each verified again at every start. Protect who can replace them and the data directory, which holds the admin config.
  • Downloads in organization mode: staged encrypted, served once through a short-lived link (default 300 seconds) (ADR 0017).
  • Client secrets belong to the deployment, not a person. Rotate a leaked one with the provider.
  • Supply chain: each release build produces CycloneDX SBOMs, and installers are signed as described in Platform support. Release and QA-runner jobs install every build tool from hash-pinned locks (ADR 0182).
  • Operations: one daemon per data directory (a file lock enforces it); organization mode is not clustered. Back up and restore the data directory with its owners and permissions intact.

What PrivacyFence does not claim

  • No certification, business-continuity plan or SLA. The only PrivacyFence-operated service is the optional redirect relay, offered as is under its terms. If it is down, built-in apps cannot start a new sign-in until it is back; tool calls, connections that already exist and servers using their own apps are unaffected, and installed copies keep running without the maintainer. Reports are handled best-effort. Treat this as a risk acceptance: approve PrivacyFence through an exception process, pin a reviewed release, and assign an internal owner to track releases (ADR 0025).
  • No protection against root or a local Administrator, or against local code on a non-packaged install, or against an agent that can run docker on a single-user install (Single-user mode).
  • No confidentiality of the local review screen against a process running as you.
  • No verified AI-system identity in local mode.
  • No hard stop from the unattended-session flag. It is advisory (ADR 0015).
  • No compliance by itself. Whether a deployment meets a regulatory or contractual requirement depends on its configuration, identity provider, retention and independent assessment.

To report a vulnerability, see SECURITY.md.