hallpass-browser 0.0.0-stage → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md ADDED
@@ -0,0 +1,104 @@
1
+ # hallpass security model
2
+
3
+ Goal: an AI agent can **use** a credential to log in, but has no path to **read** it.
4
+ This document says exactly what that does and does not protect against. Read it before trusting the tool.
5
+
6
+ ## What hallpass guarantees
7
+
8
+ | Property | How |
9
+ |---|---|
10
+ | No command prints a secret | The CLI has no `get`/`reveal`/`export`. `vault.unseal()` is called from one place (`login.mjs`) and its result never reaches stdout. |
11
+ | Entering secrets needs a human | `hallpass add` refuses to run without a real TTY and reads the password with echo off. The agent's shell has no TTY. (`--stdin-json` exists for piping from a password manager; whoever runs that already holds the secret.) |
12
+ | Secrets rest encrypted | AES-256-GCM per entry. The 256-bit master key is sealed by Windows DPAPI (CurrentUser), macOS Keychain, or libsecret, and is never written in the clear. |
13
+ | Entries cannot be quietly re-pointed | Approved origins and selectors are authenticated as GCM AAD. Editing `entries/<name>.json` to add an attacker origin makes the entry fail its integrity check. |
14
+ | Secrets only go to approved origins | Before each fill the page origin must match the origins approved at `add` time (https only, `localhost` exempt, wildcards are subdomain-only). A redirect to another origin (phishing, open redirect, compromised link) gets nothing: `failed: origin_not_allowed`. |
15
+ | No time-of-check/time-of-use gap | Fields are targeted through a per-run random attribute set during the probe. If the page navigates before the fill, the selector matches nothing and nothing is typed. A hostile page cannot pre-plant the attribute (unknown nonce). Hidden (honeypot) inputs are never selected. |
16
+ | Secrets do not appear in process listings | Passwords go to `agent-browser` over **stdin** (`batch`), not argv. |
17
+ | Output is scrubbed | All output from the browser is passed through a redactor that removes the password / TOTP seed in raw, JSON-escaped, URL-encoded and base64 forms. hallpass never returns page text; URLs are returned as origin + path only (no query/fragment, where tokens live). |
18
+ | No lockout loops | A rejected password is never retried. TOTP is retried once, and never submitted in the last 4 s of its window. |
19
+ | Human-only steps stay human | CAPTCHA and non-TOTP MFA return `needs_human`; hallpass does not try to solve or bypass them. |
20
+
21
+ Verified by `npm run test:e2e`, which logs in against a local site with a honeypot field, a phishing redirect, a wrong password, a TOTP step and a CAPTCHA, and then scans everything hallpass printed, every file in the vault, and agent-browser's own logs for the password and the TOTP seed.
22
+
23
+ ### MCP specifics
24
+
25
+ | Property | How |
26
+ |---|---|
27
+ | No credential tool | The MCP server exposes no tool that adds, reads or exports secrets; credentials enter only via `hallpass add` in a human terminal. |
28
+ | Confined to hallpass's browsers | `browser` / `browser_screenshot` accept only `hp-*` sessions, so a model cannot steer your other agent-browser sessions. |
29
+ | Narrow command set | An allowlist of page commands; long flags allowlisted (blocks `--cdp`, `--proxy`, `--headers`, `--init-script`, `--profile`, ...); no `eval`, `cookies`, `storage`, `state`, `download`, `network`. `upload` is allowed only with vetted files and a person's approval (below). `file:`/`javascript:`/`data:`/`chrome:` URLs rejected. This stops a prompt-injected page from turning the model into a local-file reader or cookie exfiltrator *via this tool*. |
30
+ | Injection marking | Page content is returned inside `AGENT_BROWSER_PAGE_CONTENT nonce=...` markers. |
31
+ | Clean protocol | stdout carries only JSON-RPC; tool errors come back in-band. |
32
+
33
+ An agent that also has a general shell is not constrained by these limits (it can run `agent-browser` directly). They protect MCP-only clients and add defence in depth.
34
+
35
+ ### Approval, policy, gate, entry, references
36
+
37
+ | Property | How |
38
+ |---|---|
39
+ | Human-only approval | `--require-approval` shows a native dialog (WinForms / osascript / zenity). The agent cannot click it; with no GUI or no answer the result is **deny**. Verified by a test that answers the real dialog with a physical click. 1-hour grants are HMAC-signed with a key derived from the vault master key. |
40
+ | Policy before decryption | Expiry, use limits and approval are checked before any secret is unsealed; they live in the entry's authenticated metadata. |
41
+ | Lockout protection | A rejected password/TOTP disables automatic login for that entry (`credential_locked`) across processes until a person clears it. |
42
+ | Action gate | In the MCP `browser` tool and `hallpass do`, controls whose label/aria-label/value/title match irreversible patterns (delete, publish, pay, submit, ...) need a native confirmation. Fail-closed. |
43
+ | Auto-installed browser | agent-browser is a pinned-range dependency (`~0.41.1`); if it is missing, hallpass installs that same range with `npm` into `~/.hallpass/runtime` (a folder agents are fenced off from) and pins the resulting absolute path. It is resolved from hallpass's own dependency or that folder BEFORE any PATH lookup, so a planted `agent-browser` earlier on PATH is ignored. Trust is in the npm package and its GitHub-release binary download, as for any npm dependency; `HALLPASS_NO_AUTO_INSTALL=1` disables all of it. `--with-deps` (apt as root) is never run automatically. |
44
+ | File upload | `upload` only takes files of a short allowlist of types (app bundles, images, video, documents, archives, plain text), resolved through symlinks, never inside a dot-folder or the hallpass home / OS config locations, with private-key blocks sniffed out of text files. Each batch needs a native dialog naming the files and the site ("Allow once" / "for 1 hour" for that site, held in memory of the MCP server only). `HALLPASS_UPLOAD=off` disables it; `=allow` skips the dialog but keeps the vetting. A shell agent that calls `agent-browser` directly bypasses this, as with the gate. |
45
+ | Screenshots | `screenshot` can only write a file name inside a private (0700) hallpass folder; the agent never chooses a path. |
46
+ | Read-only | `HALLPASS_READONLY=1` on the MCP server permits observation only; set by the person in the client config. |
47
+ | Just-in-time entry | `hallpass request` serves a form on 127.0.0.1 only, behind a 256-bit one-time path, with Host + Origin checks (DNS-rebinding / cross-site posts), a CSRF token, strict CSP, no caching, an 8 KB cap, 5-minute-class expiry and single use. It refuses to overwrite an existing entry. |
48
+ | References | `--password-ref` stores a pointer; the secret is fetched at login and never stored. Reference strings are validated against a strict character set before any CLI call. **Registering a reference is terminal-only**, otherwise an agent could pin a password-manager item to an attacker origin. |
49
+ | Audit | `hallpass log` is a hash chain (edits, deletions, reordering are detectable; a same-user process can still delete the whole file). Contains names, origin+path, verbs, outcomes; never secrets. |
50
+ | Export | The only path that serialises vault contents. Terminal-only, scrypt + AES-256-GCM under a passphrase the person chooses. |
51
+
52
+ ### Hardening added in the second audit
53
+
54
+ | Property | How |
55
+ |---|---|
56
+ | Secrets are used only when typed | Decryption, password-manager calls, approval dialogs and use-counting happen at the moment a credential must be typed. A still-valid saved session consumes no use, shows no dialog and touches no password manager. Expiry (metadata only) is checked up front. |
57
+ | The programs we trust cannot be swapped from the shell | The OS helpers that hold the master key or show approval dialogs (PowerShell, osascript, security, secret-tool, zenity, ...) are launched by **absolute system path**, never via PATH/cwd; a test forbids bare-name launches. There is **no** environment override for the agent-browser binary (it receives every typed secret), and its absolute path is **pinned** in `config.json` after first resolution; `hallpass doctor --repin` (terminal only) changes it. |
58
+ | Destructive/state-clearing commands need a person | `remove`, `unlock`, `add --force` over an existing entry, `export`, `import`, `doctor --repin` and registering a reference all require a real terminal. An agent can add a *new* login (`request`, or `add --stdin-json` for a new name) but cannot delete or overwrite yours, nor reset a lockout. |
59
+ | Typed secrets do not linger in the page | After typing, password / one-time-code fields are emptied (a single-page app otherwise keeps them after a rejected attempt, and `get value` returns them in the clear). The MCP `browser` tool also refuses `get value` on password fields. Snapshots mask them. |
60
+ | Approval dialog defaults to Deny | Focus starts on Deny and Esc denies, so a stray Enter or Space keypress while you type elsewhere cannot approve. Tested with real keypresses. |
61
+ | The one-time form URL is not handed to the agent | When a window could be opened for you, the agent is not told the URL (otherwise it could submit its own credential first and consume your form). One pending form per origin; at most 12 forms per 10 minutes. |
62
+ | Reserved names | Credential names starting with `site-` are refused: they would collide with the saved-session profile of credential-less sites. |
63
+ | Gate coverage | The action gate also reads `id`/`class`/`name`/`data-testid` (catches icon-only "trash" buttons), asks about `find last|nth` targets it cannot identify, and in `HALLPASS_GATE=all` gates `press` and `dialog` as well. |
64
+
65
+ ## What hallpass does NOT protect against
66
+
67
+ Be honest with yourself about these.
68
+
69
+ - **You are the last line of defence on two prompts.** The credential form displays the origin a password will be pinned to, but the *agent chooses that URL*: read it. Likewise the approval dialog shows the target; do not approve sign-ins you did not start.
70
+ - **The action gate is a label heuristic.** A dangerous control with an innocuous label ("OK", "Continue") passes. Shell agents can skip it by calling `agent-browser` directly. Treat it as a seatbelt, not a cage.
71
+ - **A shell agent can run `agent-browser get value` on a password field in the short window between typing and the scrub** (or while a person is mid-handoff in a visible window, when fields are deliberately left filled). The MCP tool refuses this; direct shell access cannot be fenced.
72
+ - **Preloading code into hallpass itself** (e.g. `NODE_OPTIONS=--require evil.js hallpass login x`) runs arbitrary code as you inside the process that holds decrypted secrets. That is the same-user limit below, not something hallpass can detect or stop. `hallpass doctor` warns about safety-relevant `HALLPASS_*` overrides it can see.
73
+ - **The credential form is plain HTTP on loopback.** Other local processes of your user could in principle observe loopback traffic; a hostile local process is already outside this threat model (see next point).
74
+ - **`HALLPASS_KEYSTORE=file`** keeps the master key in a file next to the vault. Use it only where no OS keystore exists (containers, CI).
75
+ 1. **A same-user process can decrypt the vault.** DPAPI/Keychain bind secrets to *your OS account*, not to "hallpass". An agent that is allowed to run arbitrary shell commands as you could, in principle, call the OS crypto API itself. hallpass removes the *easy and accidental* paths and the prompt-injection paths; it is not an OS sandbox. Mitigations, in order of strength:
76
+ - Run the agent as a different OS user, or in a container/VM, with only the `hallpass` binary and the sealed vault reachable via a socket/sudo wrapper (not provided).
77
+ - Add the deny rules from `hallpass harden` to Claude Code settings (best-effort string matching, not a boundary).
78
+ - Keep high-value accounts (banking, root cloud accounts, registrars) out of the vault.
79
+ 2. **The logged-in session is the agent's.** Once logged in, the agent can read cookies/localStorage (`agent-browser state save`, `eval`) and do anything the account can do. hallpass protects the *password and TOTP seed* (which allow persistent access from anywhere), not the *session*. Use least-privilege accounts, short-lived sessions (`hallpass logout --wipe`), and the confirmation rule in the skill for irreversible actions.
80
+ 3. **Browser profiles hold session cookies** under `~/.hallpass/profiles/<name>` in Chrome's own format. Treat that directory like a logged-in browser.
81
+ 4. **A TOTP seed in the vault weakens 2FA** into "something the agent's machine has". That is the trade-off for hands-free MFA; omit the TOTP secret for accounts where you want a human tap on every login.
82
+ 5. **Compromised approved origins.** If an origin you approved (or a subdomain under a wildcard) serves attacker content, it can receive the credential. Prefer exact origins over wildcards.
83
+ 6. **Login forms in iframes / shadow DOM / canvas** are not auto-detected (they fail closed: nothing is typed).
84
+ 7. **macOS/Linux backends are untested.** Only Windows DPAPI is exercised by the test-suite. On macOS the master key is briefly visible in `security`'s argv once, at vault creation.
85
+ 8. **Terms of service.** Some sites forbid automated logins. You are responsible for using this on accounts you own and where automation is permitted.
86
+
87
+ ## Reporting
88
+
89
+ Open an issue with a reproduction. Do not include real credentials.
90
+
91
+ ## Reporting a vulnerability
92
+
93
+ Please **do not open a public issue** for a security problem.
94
+
95
+ - Use GitHub's private vulnerability reporting: the repository's **Security → Report a vulnerability** button (maintainers: enable *Private vulnerability reporting* in the repository settings).
96
+ - Include: affected version (`hallpass --version`), OS, what an attacker or a careless agent can do, and a minimal reproduction. **Never include real credentials.**
97
+ - What counts: any path by which an agent (or a web page) can *read* a stored secret, make Hallpass type one into the wrong origin, bypass an approval dialog or the terminal-only rules, or read/forge the audit log undetectably. Weaknesses already listed under "What hallpass does NOT protect against" are known limits, not vulnerabilities, but improvements are welcome.
98
+ - Expect an acknowledgement within a few days and a coordinated fix and disclosure. This is a volunteer project; there is no bug bounty.
99
+
100
+ Supported versions: the latest release only (the project is pre-1.x-stable and moves quickly).
101
+
102
+ ## Using this software safely
103
+
104
+ Hallpass is a beta, single-author tool that has **not been independently audited**. Do not use it for accounts whose compromise would be severe (banking, domain registrars, root cloud accounts) until it has been reviewed by people other than its author.
package/bin/hallpass ADDED
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ # Puts `hallpass` on the Bash tool PATH when installed as a Claude Code plugin (bin/ is added to PATH).
3
+ exec node "$(dirname "$0")/hallpass.mjs" "$@"
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+ // hallpass - let an agent USE credentials without ever being able to READ them.
3
+ // stdout is always one JSON document. Exit codes: 0 ok / logged_in, 10 needs_human, 1 failed, 2 usage.
4
+ import { parseArgs } from 'node:util';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { spawnSync } from 'node:child_process';
10
+ import * as vault from '../src/vault.mjs';
11
+ import { backend, masterKey } from '../src/keystore.mjs';
12
+ import { normalizeAllowed, parseOrigin } from '../src/origin.mjs';
13
+ import { parseTotpInput, base32Decode } from '../src/totp.mjs';
14
+ import { isTTY, ask, askHidden, readAllStdin } from '../src/prompt.mjs';
15
+ import { login, openSite, resume, status, logout, clearState } from '../src/login.mjs';
16
+ import { findBinary, TESTED_AGENT_BROWSER } from '../src/ab.mjs';
17
+ import { home } from '../src/paths.mjs';
18
+ import { install, mcpConfig, TARGETS, MCP_TARGETS } from '../src/install.mjs';
19
+ import { parseExpiry, resetUses, revokeGrant } from '../src/policy.mjs';
20
+ import { audit, readLog, verifyLog } from '../src/audit.mjs';
21
+ import { startCredentialRequest, describeRequest } from '../src/request.mjs';
22
+ import { runBrowser } from '../src/act.mjs';
23
+ import { exportVault, importVault } from '../src/portable.mjs';
24
+ import { listSessions, pruneSessions } from '../src/sessions.mjs';
25
+ import { parseRef, resolveRef, SecretUnavailable } from '../src/refs.mjs';
26
+ import { serve } from '../src/mcp.mjs';
27
+ import { setup, inspect as inspectRuntime, withLock, recordFailure } from '../src/setup.mjs';
28
+
29
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
30
+ const out = (obj, code = 0) => { process.stdout.write(JSON.stringify(obj) + '\n'); process.exit(code); };
31
+ const usage = (msg) => out({ status: 'usage_error', message: msg }, 2);
32
+
33
+ const HELP = `hallpass - agent logins with credentials the agent can use but not read
34
+
35
+ Run these in YOUR OWN terminal (they need a real keyboard):
36
+ hallpass add <name> --url <login-url> [--origin <url>]... [options]
37
+ hallpass add <name> ... --force replace an existing entry
38
+ hallpass remove <name>
39
+ hallpass do <session> <command...> run a browser command through the same guard + irreversible-action gate
40
+ hallpass request <login-url> [--name n] a PERSON enters a login in a one-time local form; safe for agents to start
41
+ hallpass sessions saved browser sessions: age, size, running
42
+ hallpass prune [--older-than 30d] [--dry-run] forget saved sessions not used lately (credentials untouched)
43
+ hallpass export <file> / import <file> passphrase-encrypted backup to move machines (terminal only)
44
+ hallpass init [--yes] detect your AI tools and set them up
45
+ hallpass log [--tail n] [--verify] what agents did with your logins (hash-chained, no secrets)
46
+ hallpass unlock <name> re-enable auto-login after a rejected password
47
+
48
+ The agent runs these:
49
+ hallpass open <url> [--headed|--headless] [--as <name>]
50
+ open ANY site. Stored credential for its origin -> headless login.
51
+ None -> headless on a saved profile; if a login wall appears, a visible
52
+ window opens and the person logs in. --headed forces person-logs-in mode.
53
+ hallpass list names, URLs, approved origins (no secrets)
54
+ hallpass show <name> metadata for one entry
55
+ hallpass login <name> [--headed|--headless] [--wait <sec>] [--manual] [--timeout <sec>]
56
+ hallpass resume <name> [--timeout <sec>] continue after a person solved a CAPTCHA / MFA
57
+ hallpass status <name>
58
+ hallpass logout <name> [--wipe] close the browser (--wipe also deletes its profile)
59
+
60
+ Setup:
61
+ hallpass setup install what hallpass needs (agent-browser and Chrome); automatic on first use, safe to re-run
62
+ hallpass doctor
63
+ hallpass harden print Claude Code deny-rules that fence off the vault
64
+ hallpass install --for <tool> teach an AI tool how to use hallpass. tool = claude | codex | gemini |
65
+ antigravity | cursor | windsurf | copilot | agents | all
66
+ hallpass mcp-config --for <tool> print the MCP server config for: claude cursor gemini antigravity
67
+ windsurf vscode codex (then the tool gets hallpass_* and browser tools)
68
+ hallpass mcp run as an MCP server over stdio (clients launch this themselves)
69
+ hallpass install-skill [--dir <path>] Claude-only shortcut for: install --for claude
70
+
71
+ add options:
72
+ --origin <url> extra approved origin (repeatable), e.g. https://accounts.google.com or https://*.example.com
73
+ --username-selector / --password-selector / --otp-selector / --submit-selector <css>
74
+ --success-url <glob> e.g. "https://app.example.com/**" (strongly recommended)
75
+ --success-text <text> text that only appears when logged in
76
+ --require-approval every sign-in needs a native yes/no dialog that only the person can click (no GUI = deny)
77
+ --expires <12h|30d|2w|date> credential stops working after this
78
+ --max-uses <n> credential stops working after n sign-ins
79
+ --lock-navigation refuse navigation outside approved origins for the whole session
80
+ --password-ref <ref> use a password manager instead of storing the password: op://Vault/Item/field | bw:<item-id> | pass:<path>
81
+ --totp-ref <ref> same, for the one-time code; --username <u> to skip the username prompt (terminal only)
82
+ --stdin-json read {"username","password","totp"} from stdin (e.g. piped from a password manager)
83
+ `;
84
+
85
+ const [cmd, ...rest] = process.argv.slice(2);
86
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { process.stderr.write(HELP); process.exit(cmd ? 0 : 2); }
87
+
88
+ function opts(options, allowPositionals = true) {
89
+ try { return parseArgs({ args: rest, options, allowPositionals, strict: true }); }
90
+ catch (e) { return usage(e.message); }
91
+ }
92
+ const needName = (positionals) => { if (!positionals[0]) usage(`usage: hallpass ${cmd} <name>`); return positionals[0]; };
93
+ const mask = (u) => (u.includes('@') ? u[0] + '***@' + u.split('@')[1] : u.slice(0, 1) + '***');
94
+ const safe = (fn) => { try { return fn(); } catch (e) { return out({ status: 'failed', reason: 'error', detail: e.message }, 1); } };
95
+ const exitFor = (r) => out(r, r.status === 'logged_in' ? 0 : r.status === 'needs_human' ? 10 : 1);
96
+
97
+ switch (cmd) {
98
+ case '--version': case '-v': case 'version': out({ name: 'hallpass', version: JSON.parse(fs.readFileSync(path.join(HERE, '..', 'package.json'), 'utf8')).version }); break;
99
+ case 'add': {
100
+ const { values: v, positionals } = opts({
101
+ url: { type: 'string' }, origin: { type: 'string', multiple: true },
102
+ 'username-selector': { type: 'string' }, 'password-selector': { type: 'string' },
103
+ 'otp-selector': { type: 'string' }, 'submit-selector': { type: 'string' },
104
+ 'success-url': { type: 'string' }, 'success-text': { type: 'string' },
105
+ 'lock-navigation': { type: 'boolean' }, 'stdin-json': { type: 'boolean' }, force: { type: 'boolean' },
106
+ 'require-approval': { type: 'boolean' }, expires: { type: 'string' }, 'max-uses': { type: 'string' },
107
+ username: { type: 'string' }, 'password-ref': { type: 'string' }, 'totp-ref': { type: 'string' },
108
+ });
109
+ const name = needName(positionals);
110
+ if (!vault.validName(name)) usage('name must be lowercase letters, digits, . _ - (max 63)');
111
+ if (!v.url) usage('--url is required (the login page)');
112
+ const loginOrigin = parseOrigin(v.url);
113
+ if (!loginOrigin) usage('--url must be https:// (http:// only for localhost)');
114
+ if (vault.exists(name) && !v.force) usage(`"${name}" already exists. Use --force to replace it.`);
115
+ if (vault.exists(name) && !isTTY()) usage('replacing an existing credential needs a real terminal: it also clears lockout, approval grants and use limits, so a person must do it.');
116
+ if (name.startsWith('site-')) usage('names starting with "site-" are reserved');
117
+
118
+ const allowedSet = new Set([loginOrigin]);
119
+ for (const o of v.origin ?? []) {
120
+ const n = normalizeAllowed(o);
121
+ if (!n) usage(`invalid --origin "${o}" (https only; wildcards must be https://*.domain.tld)`);
122
+ allowedSet.add(n);
123
+ }
124
+ const allowed = [...allowedSet];
125
+
126
+ let username, password, totpRaw = '', passwordRef = null, totpRef = null;
127
+ if (v['password-ref'] || v['totp-ref']) {
128
+ // Registering a reference is as sensitive as typing a password: it decides where a vaulted secret can be sent.
129
+ if (!isTTY() || v['stdin-json']) usage('references (--password-ref/--totp-ref) can only be registered by a person in a real terminal.');
130
+ passwordRef = v['password-ref'] || null;
131
+ totpRef = v['totp-ref'] || null;
132
+ for (const r of [passwordRef, totpRef].filter(Boolean)) if (!parseRef(r)) usage('unsupported or unsafe reference. Use op://Vault/Item/field, bw:<item-id>, or pass:<path> (letters, digits, space and _ . @ : + / ? = ~ - only).');
133
+ try { if (passwordRef) resolveRef(passwordRef, 'password'); } catch (e) { usage('could not read the password reference: ' + (e instanceof SecretUnavailable ? e.message : 'unknown error')); }
134
+ username = v.username || await ask('Username / email: ');
135
+ password = 'reference-only'; // placeholder so the length check below is skipped for refs
136
+ } else if (v['stdin-json']) {
137
+ try { ({ username, password, totp: totpRaw = '' } = JSON.parse(await readAllStdin())); } catch { usage('--stdin-json expects {"username","password","totp"?}'); }
138
+ } else {
139
+ if (!isTTY()) usage('hallpass add needs a real terminal so the password is typed by you, not by the agent. Open your own terminal and run it there (or use --stdin-json to pipe from a password manager).');
140
+ process.stderr.write(`Adding "${name}" - approved origins: ${allowed.join(', ')}\n`);
141
+ username = await ask('Username / email: ');
142
+ password = await askHidden('Password (hidden): ');
143
+ if (password !== await askHidden('Confirm password: ')) usage('passwords did not match');
144
+ totpRaw = await askHidden('TOTP secret or otpauth:// URI (hidden, Enter to skip): ');
145
+ }
146
+ if (!username) usage('username is required');
147
+ if (!passwordRef && (!password || password.length < 6)) usage('password must be at least 6 characters');
148
+
149
+ let totpCfg;
150
+ if (totpRaw) {
151
+ try { totpCfg = parseTotpInput(totpRaw); base32Decode(totpCfg.secret); } catch (e) { usage('bad TOTP secret: ' + e.message); }
152
+ }
153
+ let expiresAt = null;
154
+ if (v.expires) { expiresAt = parseExpiry(v.expires); if (!expiresAt) usage('--expires must look like 12h, 30d, 2w or an ISO date'); }
155
+ const maxUses = v['max-uses'] ? Number(v['max-uses']) : null;
156
+ if (v['max-uses'] && !(Number.isInteger(maxUses) && maxUses > 0)) usage('--max-uses must be a positive integer');
157
+ const selectors = Object.fromEntries(Object.entries({
158
+ username: v['username-selector'], password: v['password-selector'], otp: v['otp-selector'], submit: v['submit-selector'],
159
+ }).filter(([, x]) => x));
160
+
161
+ safe(() => vault.put(name, {
162
+ url: v.url, allowed, userHint: mask(username), hasTotp: !!totpCfg || !!totpRef, source: passwordRef ? parseRef(passwordRef).kind : 'vault',
163
+ selectors, successUrl: v['success-url'] || null, successText: v['success-text'] || null,
164
+ lockNavigation: !!v['lock-navigation'], createdAt: new Date().toISOString(),
165
+ requireApproval: !!v['require-approval'], expiresAt, maxUses,
166
+ }, { username, ...(passwordRef ? { passwordRef } : { password }), ...(totpRef ? { totpRef } : {}), ...(totpCfg ? { totp: totpCfg } : {}) }));
167
+ clearState(name); // a fresh credential gets a fresh chance
168
+ resetUses(name); revokeGrant(name);
169
+ audit('add', { name, origins: allowed.join(' ') });
170
+ out({ status: 'saved', name, sealed_with: backend().name, user: mask(username), totp: !!totpCfg, allowed_origins: allowed,
171
+ note: 'Secrets are encrypted at rest and cannot be read back with hallpass.' });
172
+ break;
173
+ }
174
+ case 'list': out({ entries: safe(() => vault.list()) }); break;
175
+ case 'show': { const { positionals } = opts({}); out({ name: positionals[0], ...safe(() => vault.meta(needName(positionals))) }); break; }
176
+ case 'remove': {
177
+ const { positionals } = opts({});
178
+ const name = needName(positionals);
179
+ if (!isTTY()) usage('hallpass remove needs a real terminal: it permanently deletes a stored login.');
180
+ safe(() => vault.remove(name));
181
+ clearState(name);
182
+ try { logout(name, { wipe: true }); } catch { /* browser may not be running */ }
183
+ out({ status: 'removed', name });
184
+ break;
185
+ }
186
+ case 'login': {
187
+ const { values: v, positionals } = opts({ headed: { type: 'boolean' }, headless: { type: 'boolean' }, manual: { type: 'boolean' }, wait: { type: 'string' }, timeout: { type: 'string' } });
188
+ const name = needName(positionals);
189
+ if (v.headed && v.headless) usage('choose --headed or --headless, not both');
190
+ exitFor(await login(name, { headed: v.headless ? false : v.headed ? true : undefined, manual: v.manual, wait: v.wait && Number(v.wait), timeout: v.timeout && Number(v.timeout) }).catch((e) => ({ status: 'failed', reason: 'error', detail: e.message })));
191
+ break;
192
+ }
193
+ case 'open': {
194
+ const { values: v, positionals } = opts({ headed: { type: 'boolean' }, headless: { type: 'boolean' }, as: { type: 'string' }, wait: { type: 'string' }, timeout: { type: 'string' } });
195
+ if (!positionals[0]) usage('usage: hallpass open <url> [--headed|--headless] [--as <credential>]');
196
+ if (v.headed && v.headless) usage('choose --headed or --headless, not both');
197
+ exitFor(await openSite(positionals[0], { mode: v.headed ? 'headed' : v.headless ? 'headless' : 'auto', as: v.as, wait: v.wait && Number(v.wait), timeout: v.timeout && Number(v.timeout) }).catch((e) => ({ status: 'failed', reason: 'error', detail: e.message })));
198
+ break;
199
+ }
200
+ case 'resume': {
201
+ const { values: v, positionals } = opts({ timeout: { type: 'string' } });
202
+ exitFor(await resume(needName(positionals), { timeout: v.timeout && Number(v.timeout) }).catch((e) => ({ status: 'failed', reason: 'error', detail: e.message })));
203
+ break;
204
+ }
205
+ case 'status': { const { positionals } = opts({}); out(safe(() => status(needName(positionals)))); break; }
206
+ case 'logout': { const { values: v, positionals } = opts({ wipe: { type: 'boolean' } }); out(safe(() => logout(needName(positionals), { wipe: v.wipe }))); break; }
207
+
208
+ case 'unlock': {
209
+ const { positionals } = opts({});
210
+ const name = needName(positionals);
211
+ safe(() => vault.meta(name));
212
+ if (!isTTY()) usage('hallpass unlock needs a real terminal: it re-enables automatic logins after a rejected password, so a person must do it.');
213
+ if ((await ask(`Re-enable automatic login for "${name}"? Only do this if the password is now correct on the site. Type yes: `)).toLowerCase() !== 'yes') usage('cancelled');
214
+ clearState(name);
215
+ out({ status: 'unlocked', name });
216
+ break;
217
+ }
218
+ case 'request': {
219
+ // Safe for an agent to run: a PERSON types the credential into a one-time local form; the agent never sees it.
220
+ const { values: v, positionals } = opts({ name: { type: 'string' }, ttl: { type: 'string' } });
221
+ if (!positionals[0]) usage('usage: hallpass request <login-url> [--name <name>]');
222
+ let req;
223
+ try { req = await startCredentialRequest({ url: positionals[0], name: v.name, ttlSec: Number(v.ttl) || 600 }); }
224
+ catch (e) { out({ status: 'failed', reason: 'cannot_start', detail: e.message }, 1); }
225
+ process.stdout.write(JSON.stringify(describeRequest(req)) + '\n');
226
+ const r = await req.wait();
227
+ out(r, r.status === 'saved' ? 0 : 1);
228
+ break;
229
+ }
230
+ case 'do': {
231
+ // hallpass do <session> <agent-browser command...> same guard + irreversible-action gate as the MCP browser tool
232
+ const args = process.argv.slice(3);
233
+ const session = args.shift();
234
+ if (args[0] === '--') args.shift();
235
+ if (!session || !args.length) usage('usage: hallpass do <session> <command...> e.g. hallpass do hp-site-example.com click @e3');
236
+ const r = runBrowser({ session, command: args });
237
+ process.stdout.write(r.text + '\n'); // page output, like agent-browser itself (not JSON)
238
+ process.exit(r.isError ? 1 : 0);
239
+ break;
240
+ }
241
+ case 'sessions': out({ sessions: safe(() => listSessions()) }); break;
242
+ case 'prune': {
243
+ const { values: v } = opts({ 'older-than': { type: 'string' }, 'dry-run': { type: 'boolean' } }, false);
244
+ out(safe(() => pruneSessions({ olderThan: v['older-than'] || '30d', dryRun: !!v['dry-run'] })));
245
+ break;
246
+ }
247
+ case 'export': {
248
+ // The only path that turns vault contents into portable bytes: a person, in a terminal, with a passphrase only they know.
249
+ const { values: v, positionals } = opts({ force: { type: 'boolean' } });
250
+ const file = positionals[0];
251
+ if (!file) usage('usage: hallpass export <file>');
252
+ if (!isTTY()) usage('hallpass export needs a real terminal: it writes your credentials (encrypted) to a file, so a person must do it.');
253
+ if (fs.existsSync(file) && !v.force) usage(`${file} already exists (use --force)`);
254
+ const p1 = await askHidden('Export passphrase (12+ chars, hidden): ');
255
+ if (p1 !== await askHidden('Repeat passphrase: ')) usage('passphrases did not match');
256
+ const bundle = safe(() => exportVault(p1));
257
+ fs.writeFileSync(file, JSON.stringify(bundle), { mode: 0o600 });
258
+ out({ status: 'exported', file, note: 'Encrypted with your passphrase. Without it the file is useless; with it, it contains all your logins: store it carefully.' });
259
+ break;
260
+ }
261
+ case 'import': {
262
+ const { values: v, positionals } = opts({ force: { type: 'boolean' } });
263
+ const file = positionals[0];
264
+ if (!file) usage('usage: hallpass import <file> [--force]');
265
+ if (!isTTY()) usage('hallpass import needs a real terminal (it asks for the export passphrase).');
266
+ let bundle;
267
+ try { bundle = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { usage('cannot read that file'); }
268
+ const pass = await askHidden('Export passphrase (hidden): ');
269
+ out({ status: 'imported', ...safe(() => importVault(bundle, pass, { force: !!v.force })) });
270
+ break;
271
+ }
272
+ case 'init': {
273
+ // Detect installed AI tools and show (or, with --yes, apply) the user-level setup. Project-level rule files
274
+ // (Cursor, Windsurf, Copilot, AGENTS.md) are written by running `hallpass install --for <tool>` inside a project.
275
+ const { values: v } = opts({ yes: { type: 'boolean' } }, false);
276
+ const h = process.env.HALLPASS_TARGET_HOME || os.homedir();
277
+ const probes = { claude: '.claude', codex: '.codex', gemini: '.gemini', cursor: '.cursor', windsurf: '.codeium' };
278
+ const detected = Object.entries(probes).filter(([, d]) => fs.existsSync(path.join(h, d))).map(([k]) => k);
279
+ const userLevel = detected.filter((t) => ['claude', 'codex', 'gemini'].includes(t));
280
+ const plan = detected.map((t) => ({ tool: t, instructions: `hallpass install --for ${t}`, mcp: MCP_TARGETS.includes(t) ? `hallpass mcp-config --for ${t}` : null, scope: userLevel.includes(t) ? 'user' : 'project (run inside a project)' }));
281
+ const applied = v.yes ? userLevel.flatMap((t) => safe(() => install(t))) : [];
282
+ out({ status: 'ok', detected, plan, applied, applied_note: v.yes ? 'User-level instructions installed. MCP configs are printed by mcp-config; they are never written for you.' : 'Dry run. Re-run with --yes to install the user-level instructions.' });
283
+ break;
284
+ }
285
+ case 'log': {
286
+ const { values: v } = opts({ tail: { type: 'string' }, verify: { type: 'boolean' } }, false);
287
+ out(v.verify ? { chain: verifyLog() } : { entries: readLog({ tail: Number(v.tail) || 50 }), chain: verifyLog() });
288
+ break;
289
+ }
290
+ case 'doctor': {
291
+ const { values: dv } = opts({ repin: { type: 'boolean' } }, false);
292
+ if (dv.repin) { // changes WHICH agent-browser receives your typed secrets: a person must do this
293
+ if (!isTTY()) usage('hallpass doctor --repin needs a real terminal: it decides which program receives your typed secrets.');
294
+ out({ status: 'repinned', agent_browser: safe(() => findBinary({ repin: true, human: true })) });
295
+ }
296
+ const checks = [];
297
+ const add = (name, okk, detail) => checks.push({ name, ok: okk, detail });
298
+ add('node >= 20', Number(process.versions.node.split('.')[0]) >= 20, process.versions.node);
299
+ try { const bin = findBinary(); const rt = inspectRuntime(); add('chrome for agent-browser', rt.browser !== false, rt.browser === false ? 'not installed: run hallpass setup' : rt.browser ? 'installed' : 'could not tell (older/newer agent-browser)'); const r = spawnSync(bin, ['--version'], { encoding: 'utf8' }); const ver = /(\d+\.\d+)\.\d+/.exec(r.stdout || '')?.[1]; add('agent-browser', r.status === 0, `${(r.stdout || '').trim()} (pinned: ${bin})`); add('agent-browser compatibility', !(rt.outdated), ver === TESTED_AGENT_BROWSER ? `${ver}.x is the tested release line` : rt.outdated ? `agent-browser ${rt.version} is older than the ${TESTED_AGENT_BROWSER}.x line; on current Chrome its visible login window can lose the page. Run: hallpass setup` : `NOTE: hallpass was tested with ${TESTED_AGENT_BROWSER}.x; you have ${ver || 'unknown'}. It may work, but report anything odd`); } catch (e) { add('agent-browser', false, e.message); }
300
+ try { const b = backend(); const k = masterKey({ create: false }); add('keystore', true, `${b.name}; master key ${k ? 'present' : 'not created yet (created on first "hallpass add")'}`); } catch (e) { add('keystore', false, e.message); }
301
+ add('home dir', true, home());
302
+ try { const c = verifyLog(); add('audit log', c.ok, c.ok ? `${c.entries} entries, chain intact` : `TAMPERED or damaged at entry ${c.broken_at}: ${c.reason}`); } catch (e) { add('audit log', false, e.message); }
303
+ // env vars that weaken a safety control for whoever sets them (an agent can set them on its own command line)
304
+ const weak = [['HALLPASS_GATE', 'off', 'irreversible-action gate disabled'], ['HALLPASS_UPLOAD', 'allow', 'file uploads no longer need your approval (files are still vetted)'], ['HALLPASS_NO_UI', '1', 'no dialogs: approvals deny, notifications off'], ['HALLPASS_KEYSTORE', 'file', 'master key kept in a file, not the OS keystore']].filter(([k, v]) => process.env[k] === v);
305
+ add('environment', true, weak.length ? 'NOTE: ' + weak.map(([k, v, why]) => `${k}=${v} (${why})`).join('; ') : 'no safety-relevant overrides set');
306
+ out({ status: checks.every((c) => c.ok) ? 'ok' : 'problems', checks }, checks.every((c) => c.ok) ? 0 : 1);
307
+ break;
308
+ }
309
+ case 'harden': {
310
+ const h = home().replace(/\\/g, '/');
311
+ out({
312
+ note: 'Merge into ~/.claude/settings.json. Best-effort fence: stops the agent from casually opening the vault. The real guarantee is that hallpass has no command that prints a secret.',
313
+ permissions: { deny: [
314
+ `Read(${h}/**)`, `Edit(${h}/**)`, `Write(${h}/**)`, 'Read(~/.hallpass/**)', 'Edit(~/.hallpass/**)',
315
+ 'Bash(hallpass add:*)', 'Bash(*ProtectedData*)', 'Bash(*master.key*)', 'Bash(*.hallpass*)',
316
+ 'Bash(*find-generic-password*)', 'Bash(*secret-tool*)',
317
+ ] },
318
+ });
319
+ break;
320
+ }
321
+ case 'setup': {
322
+ const { values: v } = opts({ background: { type: 'boolean' } }, false);
323
+ try { out({ ...withLock(() => setup({ log: (m) => m && process.stderr.write(`hallpass setup: ${m}\n`) })), ...inspectRuntime() }); }
324
+ catch (e) { if (v.background) recordFailure(e.message); out({ status: 'failed', reason: 'setup_failed', detail: e.message }, 1); }
325
+ break;
326
+ }
327
+ case 'mcp': serve(); break; // stdio MCP server; stays alive until the client closes stdin
328
+ case 'install': {
329
+ const { values: v } = opts({ for: { type: 'string' } }, false);
330
+ if (!v.for) usage('usage: hallpass install --for <' + [...Object.keys(TARGETS), 'all'].join('|') + '>');
331
+ out({ status: 'installed', results: safe(() => install(v.for)), next: 'Also run: hallpass mcp-config --for <tool> to connect the MCP server (optional, recommended for IDE agents).' });
332
+ break;
333
+ }
334
+ case 'mcp-config': {
335
+ const { values: v } = opts({ for: { type: 'string' }, npx: { type: 'boolean' } }, false);
336
+ if (!v.for) usage('usage: hallpass mcp-config --for <' + MCP_TARGETS.join('|') + '> [--npx]');
337
+ out(safe(() => mcpConfig(v.for, { npx: !!v.npx })));
338
+ break;
339
+ }
340
+ case 'install-skill': {
341
+ const { values: v } = opts({ dir: { type: 'string' } }, false);
342
+ const dest = path.join(v.dir || path.join(os.homedir(), '.claude', 'skills'), 'hallpass');
343
+ fs.mkdirSync(dest, { recursive: true });
344
+ fs.copyFileSync(path.join(HERE, '..', 'skills', 'hallpass', 'SKILL.md'), path.join(dest, 'SKILL.md'));
345
+ out({ status: 'installed', path: path.join(dest, 'SKILL.md') });
346
+ break;
347
+ }
348
+ default: usage(`unknown command "${cmd}". Run: hallpass help`);
349
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // npm postinstall: fetch Chrome for agent-browser right away so the first `hallpass open` is instant.
3
+ // Best effort by design: this must never make `npm i -g hallpass-browser` fail (offline, proxy, locked-down CI).
4
+ // Anything missed here is installed on first use anyway (src/setup.mjs).
5
+ import { spawnSync } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ if (process.env.HALLPASS_NO_AUTO_INSTALL === '1' || process.env.CI || process.env.HALLPASS_SKIP_POSTINSTALL === '1') process.exit(0);
10
+ const r = spawnSync(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'hallpass.mjs'), 'setup'], { stdio: ['ignore', 'ignore', 'inherit'], timeout: 15 * 60_000 });
11
+ if (r.status !== 0) process.stderr.write('hallpass: could not finish browser setup now; it will be retried automatically on first use (or run: hallpass setup)\n');
12
+ process.exit(0);
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "hallpass-browser",
3
+ "version": "1.5.1",
4
+ "description": "Open any website in a browser your AI agent can drive, and log in safely: credentials the agent can use but never read, with a hand-off to you for CAPTCHA/MFA.",
5
+ "mcpServers": {
6
+ "hallpass": {
7
+ "command": "node",
8
+ "args": ["${extensionPath}${/}bin${/}hallpass.mjs", "mcp"],
9
+ "cwd": "${extensionPath}"
10
+ }
11
+ },
12
+ "contextFileName": "GEMINI.md"
13
+ }
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "hallpass-browser",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.5.1",
4
+ "description": "Open any website in a browser your AI agent can drive and log in safely: credentials the agent can use but never read, with a hand-off to you for CAPTCHA/MFA. CLI, MCP server, and plugins for Claude Code, Codex, Gemini CLI and Antigravity.",
5
+ "keywords": [
6
+ "ai-agent",
7
+ "browser",
8
+ "login",
9
+ "credentials",
10
+ "mcp",
11
+ "agent-browser",
12
+ "claude-code",
13
+ "codex",
14
+ "gemini",
15
+ "antigravity",
16
+ "security"
17
+ ],
18
+ "license": "MIT",
19
+ "author": {
20
+ "name": "techgniouss"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "hallpass": "bin/hallpass.mjs"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "src",
32
+ "skills",
33
+ ".claude-plugin",
34
+ ".codex-plugin",
35
+ ".codex-mcp.json",
36
+ ".agents/plugins",
37
+ ".mcp.json",
38
+ "gemini-extension.json",
39
+ "GEMINI.md",
40
+ "README.md",
41
+ "SECURITY.md",
42
+ "ROADMAP.md",
43
+ "CHANGELOG.md",
44
+ "LICENSE"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "provenance": true
49
+ },
50
+ "scripts": {
51
+ "test": "node --test test/unit.test.mjs test/audit.test.mjs test/refs.test.mjs test/request.test.mjs test/ops.test.mjs test/trust.test.mjs test/packaging.test.mjs test/setup.test.mjs",
52
+ "test:e2e": "node --test --test-concurrency=1 test/e2e.test.mjs",
53
+ "test:mcp": "node --test --test-concurrency=1 test/mcp.test.mjs",
54
+ "test:gate": "node --test --test-concurrency=1 test/gate.test.mjs",
55
+ "test:upload": "node --test --test-concurrency=1 test/upload.test.mjs",
56
+ "test:detect": "node --test --test-concurrency=1 test/detect.test.mjs",
57
+ "test:dialog": "node --test --test-concurrency=1 test/dialog.test.mjs",
58
+ "test:browser": "node --test --test-concurrency=1 test/e2e.test.mjs test/mcp.test.mjs test/gate.test.mjs test/upload.test.mjs test/detect.test.mjs",
59
+ "test:all": "npm test && npm run test:browser && npm run test:dialog",
60
+ "prepublishOnly": "npm test",
61
+ "postinstall": "node bin/postinstall.mjs"
62
+ },
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "git+https://github.com/techgniouss/hallpass-browser.git"
66
+ },
67
+ "homepage": "https://github.com/techgniouss/hallpass-browser#readme",
68
+ "bugs": {
69
+ "url": "https://github.com/techgniouss/hallpass-browser/issues"
70
+ },
71
+ "dependencies": {
72
+ "agent-browser": "~0.41.1"
73
+ }
74
+ }