diff --git a/src/lib/components/provider-icons/ProtonPass.svelte b/src/lib/components/provider-icons/ProtonPass.svelte
new file mode 100644
index 0000000..2e79755
--- /dev/null
+++ b/src/lib/components/provider-icons/ProtonPass.svelte
@@ -0,0 +1,84 @@
+
+
+
diff --git a/src/lib/server/secretproviders/proton.ts b/src/lib/server/secretproviders/proton.ts
new file mode 100644
index 0000000..d75a8f0
--- /dev/null
+++ b/src/lib/server/secretproviders/proton.ts
@@ -0,0 +1,449 @@
+/**
+ * Proton Pass provider.
+ *
+ * Like the Bitwarden provider, this is only an adapter around an
+ * operator-installed official `pass-cli` executable. Dockhand does not
+ * distribute the client and does not implement Proton's API or cryptography.
+ *
+ * pass-cli is STATEFUL: a session is created by `login`, used by subsequent
+ * commands, and torn down by `logout`. Each Dockhand call runs the whole
+ * login/operate/logout cycle inside a private, ephemeral session directory
+ * (PROTON_PASS_SESSION_DIR) so concurrent calls never share credentials and no
+ * session state survives the call.
+ *
+ * Two resolution modes:
+ * - Bulk pull: `item list --vault-name --output json --show-secrets`,
+ * mapping each item to one env var (title -> primary secret).
+ * - Inline references: `pass://SHARE_ID/ITEM_ID[/FIELD]`, resolved one field at
+ * a time via `item view --output json`.
+ *
+ * The access token is passed via the PROTON_PASS_PERSONAL_ACCESS_TOKEN
+ * environment variable (pass-cli's own env channel for the PAT), never on argv,
+ * so it is not exposed on the login process's /proc//cmdline. `login` with
+ * no --pat routes to PAT login when that env var is set.
+ */
+
+import { spawn } from 'node:child_process';
+import { chmod, mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { isAbsolute, join } from 'node:path';
+import type { ProtonConfig, SecretProvider, TestConnectionResult } from './shared';
+
+const DEFAULT_PASS_CLI_PATH = '/usr/local/bin/pass-cli';
+const LOGIN_TIMEOUT_MS = 30_000;
+const COMMAND_TIMEOUT_MS = 30_000;
+const LOGOUT_TIMEOUT_MS = 10_000;
+const KILL_GRACE_MS = 2_000;
+const LIST_OUTPUT_LIMIT = 10 * 1024 * 1024;
+const VIEW_OUTPUT_LIMIT = 1 * 1024 * 1024;
+const LOGIN_OUTPUT_LIMIT = 64 * 1024;
+const STDERR_OUTPUT_LIMIT = 64 * 1024;
+
+// pass://SHARE_ID/ITEM_ID/FIELD. Share/item ids are opaque base64url-ish tokens
+// (Proton uses url-safe base64 with '=' padding). The FIELD segment is required:
+// with a field, `item view` prints the bare field value, which is unambiguous;
+// without one it would print the whole item and there is no single "the secret".
+const PASS_REF_RE = /^pass:\/\/[A-Za-z0-9_=-]+\/[A-Za-z0-9_=-]+\/[^/\s]+$/;
+const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
+const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
+
+const CHILD_ENV_ALLOWLIST = [
+ 'HTTP_PROXY',
+ 'HTTPS_PROXY',
+ 'NO_PROXY',
+ 'http_proxy',
+ 'https_proxy',
+ 'no_proxy',
+ 'SSL_CERT_FILE',
+ 'SSL_CERT_DIR'
+] as const;
+
+class PassCliError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'PassCliError';
+ }
+}
+
+interface CommandLimits {
+ timeoutMs: number;
+ stdoutBytes: number;
+ stderrBytes: number;
+ /** Set only on the `login` call; passed to pass-cli via its own env channel. */
+ accessToken?: string;
+}
+
+function executablePath(): string {
+ const override = process.env.DOCKHAND_PASS_CLI_PATH?.trim();
+ if (!override) return DEFAULT_PASS_CLI_PATH;
+ if (!isAbsolute(override)) {
+ throw new PassCliError('Proton Pass pass-cli executable path must be absolute');
+ }
+ return override;
+}
+
+function childEnvironment(sessionDir: string, accessToken?: string): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = {
+ HOME: sessionDir,
+ XDG_CONFIG_HOME: sessionDir,
+ PROTON_PASS_SESSION_DIR: sessionDir,
+ PROTON_PASS_NO_UPDATE_CHECK: '1'
+ };
+ for (const key of CHILD_ENV_ALLOWLIST) {
+ const value = process.env[key];
+ if (value !== undefined) env[key] = value;
+ }
+ if (accessToken !== undefined) env.PROTON_PASS_PERSONAL_ACCESS_TOKEN = accessToken;
+ return env;
+}
+
+function spawnFailure(error: unknown): PassCliError {
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
+ if (code === 'ENOENT') return new PassCliError('Proton Pass pass-cli executable was not found');
+ if (code === 'EACCES')
+ return new PassCliError('Proton Pass pass-cli executable is not executable');
+ return new PassCliError('Proton Pass pass-cli executable could not be started');
+}
+
+/** Execute one pass-cli process without a shell while bounding lifetime and output. */
+async function executePassCli(
+ sessionDir: string,
+ args: string[],
+ limits: CommandLimits
+): Promise {
+ const stdoutChunks: Buffer[] = [];
+ try {
+ return await new Promise((resolve, reject) => {
+ let stdoutBytes = 0;
+ let stderrBytes = 0;
+ let failure: PassCliError | undefined;
+ let settled = false;
+ let killTimer: ReturnType | undefined;
+
+ const child = spawn(executablePath(), args, {
+ env: childEnvironment(sessionDir, limits.accessToken),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ shell: false
+ });
+
+ const terminate = (error: PassCliError) => {
+ if (failure) return;
+ failure = error;
+ try {
+ child.kill('SIGTERM');
+ } catch {
+ // The close/error handler below still settles the command.
+ }
+ killTimer = setTimeout(() => {
+ if (child.exitCode === null && child.signalCode === null) {
+ try {
+ child.kill('SIGKILL');
+ } catch {
+ // The close/error handler below still settles the command.
+ }
+ }
+ }, KILL_GRACE_MS);
+ };
+
+ const timeout = setTimeout(
+ () => terminate(new PassCliError('Proton Pass pass-cli command timed out')),
+ limits.timeoutMs
+ );
+
+ child.stdout?.on('data', (chunk: Buffer | string) => {
+ if (failure) return;
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ stdoutBytes += data.length;
+ if (stdoutBytes > limits.stdoutBytes) {
+ terminate(new PassCliError('Proton Pass pass-cli command exceeded the stdout limit'));
+ return;
+ }
+ stdoutChunks.push(Buffer.from(data));
+ });
+
+ // stderr is deliberately never retained: only its byte count is observed.
+ child.stderr?.on('data', (chunk: Buffer | string) => {
+ if (failure) return;
+ stderrBytes += Buffer.byteLength(chunk);
+ if (stderrBytes > limits.stderrBytes) {
+ terminate(new PassCliError('Proton Pass pass-cli command exceeded the stderr limit'));
+ }
+ });
+
+ child.once('error', (error) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ if (killTimer) clearTimeout(killTimer);
+ reject(failure ?? spawnFailure(error));
+ });
+
+ child.once('close', (code) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ if (killTimer) clearTimeout(killTimer);
+ if (failure) {
+ reject(failure);
+ return;
+ }
+ if (code !== 0) {
+ reject(new PassCliError('Proton Pass pass-cli command failed'));
+ return;
+ }
+ resolve(Buffer.concat(stdoutChunks, stdoutBytes));
+ });
+ });
+ } finally {
+ for (const chunk of stdoutChunks) chunk.fill(0);
+ }
+}
+
+/**
+ * Runs `fn` inside a fresh, private pass-cli session: login, then the callback,
+ * then an unconditional logout, then the session dir is removed. The PAT never
+ * survives the call.
+ */
+async function withSession(
+ token: string,
+ fn: (session: string) => Promise
+): Promise {
+ let sessionDir: string | undefined;
+ try {
+ sessionDir = await mkdtemp(join(tmpdir(), 'dockhand-pass-'));
+ await chmod(sessionDir, 0o700);
+ } catch {
+ if (sessionDir) await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
+ throw new PassCliError('Proton Pass pass-cli session state could not be created');
+ }
+
+ try {
+ const loginOutput = await executePassCli(sessionDir, ['login'], {
+ timeoutMs: LOGIN_TIMEOUT_MS,
+ stdoutBytes: LOGIN_OUTPUT_LIMIT,
+ stderrBytes: STDERR_OUTPUT_LIMIT,
+ accessToken: token
+ });
+ loginOutput.fill(0);
+ try {
+ return await fn(sessionDir);
+ } finally {
+ // Best-effort teardown of the remote session; a failure here must not
+ // mask the real result. The session dir is removed regardless below.
+ await executePassCli(sessionDir, ['logout', '--force'], {
+ timeoutMs: LOGOUT_TIMEOUT_MS,
+ stdoutBytes: LOGIN_OUTPUT_LIMIT,
+ stderrBytes: STDERR_OUTPUT_LIMIT
+ }).catch(() => undefined);
+ }
+ } finally {
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
+ }
+}
+
+function accessToken(config: ProtonConfig): string {
+ const token = typeof config?.token === 'string' ? config.token.trim() : '';
+ if (!token) throw new PassCliError('Proton Pass access token is empty');
+ if (token.includes('\0') || /\s/.test(token)) {
+ throw new PassCliError('Proton Pass access token is malformed');
+ }
+ return token;
+}
+
+function vaultName(selector: string): string {
+ const name = typeof selector === 'string' ? selector.trim() : '';
+ if (!name) throw new PassCliError('Proton Pass vault selector is empty');
+ if (name.includes('\0') || name.startsWith('-')) {
+ throw new PassCliError('Proton Pass vault selector is invalid');
+ }
+ return name;
+}
+
+function passReference(value: string): string {
+ const ref = value.trim();
+ if (!PASS_REF_RE.test(ref)) {
+ throw new PassCliError('Proton Pass reference must be pass://SHARE_ID/ITEM_ID[/FIELD]');
+ }
+ return ref;
+}
+
+function sanitizedError(error: unknown): string {
+ return error instanceof PassCliError ? error.message : 'Proton Pass pass-cli operation failed';
+}
+
+function parseJson(output: Buffer): unknown {
+ try {
+ return JSON.parse(output.toString('utf8'));
+ } catch {
+ throw new PassCliError('Proton Pass pass-cli returned invalid JSON');
+ }
+}
+
+function asObject(v: unknown): Record | null {
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null;
+}
+
+/**
+ * Reads the primary injectable secret from one item of `item list --show-secrets`.
+ * The item's payload is pass-cli's own `Item` serde (see pass-domain): the
+ * content lives under `content` (an ItemData), whose `content` is an
+ * externally-tagged ItemContent enum (`{ "Login": { password, ... } }`). A
+ * Login item yields its password; otherwise the item's note text is used, else
+ * the first hidden extra field. Items with no readable secret are reported as
+ * null so the caller can skip them.
+ */
+function primarySecret(item: Record): string | null {
+ const data = asObject(item.content);
+ if (!data) return null;
+
+ const inner = asObject(data.content);
+ if (inner) {
+ const login = asObject(inner.Login);
+ if (login && typeof login.password === 'string' && login.password) {
+ return login.password.includes('\0') ? null : login.password;
+ }
+ }
+
+ if (typeof data.note === 'string' && data.note && !data.note.includes('\0')) {
+ return data.note;
+ }
+
+ const extras = Array.isArray(data.extra_fields) ? data.extra_fields : [];
+ for (const field of extras) {
+ const f = asObject(field);
+ const content = f && asObject(f.content);
+ const hidden = content?.Hidden;
+ if (typeof hidden === 'string' && hidden && !hidden.includes('\0')) return hidden;
+ }
+ return null;
+}
+
+/**
+ * Maps a bulk `item list --show-secrets` payload to env vars. Each item
+ * contributes one entry keyed by its title (which must already be a valid
+ * env-var name; other items are skipped) with its primary secret as the value.
+ */
+function bulkRecord(payload: unknown): Record {
+ const wrapper = asObject(payload);
+ const items = wrapper && Array.isArray(wrapper.items) ? wrapper.items : null;
+ if (!items) throw new PassCliError('Proton Pass pass-cli returned an invalid item list');
+
+ const result = Object.create(null) as Record;
+ const seen = new Set();
+
+ for (const entry of items) {
+ const item = asObject(entry);
+ if (!item) throw new PassCliError('Proton Pass pass-cli returned an invalid item');
+ const data = asObject(item.content);
+ const title = data && typeof data.title === 'string' ? data.title.trim() : '';
+ if (!ENV_NAME_RE.test(title) || DANGEROUS_KEYS.has(title)) {
+ // A title that is not a valid env-var name cannot be injected; skip it
+ // rather than fail the whole pull.
+ continue;
+ }
+ const value = primarySecret(item);
+ if (value === null) continue; // no injectable secret on this item
+ if (seen.has(title)) {
+ throw new PassCliError('Proton Pass vault has duplicate item titles');
+ }
+ seen.add(title);
+ result[title] = value;
+ }
+
+ return result;
+}
+
+export const protonProvider: SecretProvider = {
+ type: 'proton',
+ label: 'Proton Pass',
+ supportsReferences: true,
+ supportsBulk: true,
+
+ isReference(value: unknown): value is string {
+ return typeof value === 'string' && value.trim().startsWith('pass://');
+ },
+
+ async testConnection(config: ProtonConfig): Promise {
+ try {
+ const token = accessToken(config);
+ await withSession(token, async (session) => {
+ const output = await executePassCli(session, ['info', '--output', 'json'], {
+ timeoutMs: COMMAND_TIMEOUT_MS,
+ stdoutBytes: LOGIN_OUTPUT_LIMIT,
+ stderrBytes: STDERR_OUTPUT_LIMIT
+ });
+ output.fill(0);
+ });
+ return { ok: true };
+ } catch (error: unknown) {
+ return { ok: false, error: sanitizedError(error) };
+ }
+ },
+
+ async resolveSecretReferences(
+ config: ProtonConfig,
+ refs: string[],
+ logPrefix = ''
+ ): Promise