hallpass-browser 0.0.0-stage → 1.5.0
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/.agents/plugins/marketplace.json +12 -0
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-mcp.json +9 -0
- package/.codex-plugin/plugin.json +27 -0
- package/.mcp.json +9 -0
- package/CHANGELOG.md +89 -0
- package/GEMINI.md +10 -0
- package/LICENSE +21 -0
- package/README.md +113 -2
- package/ROADMAP.md +74 -0
- package/SECURITY.md +104 -0
- package/bin/hallpass +3 -0
- package/bin/hallpass.mjs +349 -0
- package/bin/postinstall.mjs +12 -0
- package/gemini-extension.json +13 -0
- package/package.json +72 -4
- package/skills/hallpass/SKILL.md +160 -0
- package/src/ab.mjs +216 -0
- package/src/act.mjs +226 -0
- package/src/audit.mjs +57 -0
- package/src/install.mjs +96 -0
- package/src/keystore.mjs +88 -0
- package/src/login.mjs +480 -0
- package/src/mcp.mjs +173 -0
- package/src/origin.mjs +61 -0
- package/src/paths.mjs +17 -0
- package/src/policy.mjs +95 -0
- package/src/portable.mjs +41 -0
- package/src/probe.mjs +73 -0
- package/src/prompt.mjs +42 -0
- package/src/redact.mjs +30 -0
- package/src/refs.mjs +53 -0
- package/src/request.mjs +150 -0
- package/src/sessions.mjs +65 -0
- package/src/setup.mjs +133 -0
- package/src/sysexe.mjs +22 -0
- package/src/totp.mjs +51 -0
- package/src/ui.mjs +124 -0
- package/src/upload.mjs +97 -0
- package/src/vault.mjs +85 -0
package/src/ab.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Thin, hardened wrapper around the agent-browser CLI.
|
|
2
|
+
// - secrets only ever travel over stdin (JSON `batch`), never argv, so they are not visible in process listings
|
|
3
|
+
// - every byte coming back is passed through the Redactor before anything can print or log it
|
|
4
|
+
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { home } from './paths.mjs';
|
|
11
|
+
|
|
12
|
+
export const runtimeDir = () => path.join(home(), 'runtime');
|
|
13
|
+
|
|
14
|
+
// The agent-browser binary receives every typed secret on stdin, so WHICH binary it is must not be steerable from the
|
|
15
|
+
// agent's shell. There is no env override, and once resolved the absolute path is pinned in config.json: a later
|
|
16
|
+
// `PATH=/tmp/fake:$PATH hallpass login x` cannot redirect it. Re-pinning (after moving/reinstalling) is a human action:
|
|
17
|
+
// `hallpass doctor --repin`.
|
|
18
|
+
const cfgFile = () => path.join(home(), 'config.json');
|
|
19
|
+
const readCfg = () => { try { return JSON.parse(fs.readFileSync(cfgFile(), 'utf8')); } catch { return {}; } };
|
|
20
|
+
const pinned = () => { const p = readCfg().agentBrowser; return typeof p === 'string' && fs.existsSync(p) ? p : null; };
|
|
21
|
+
const pin = (p, { human = false } = {}) => { try { fs.writeFileSync(cfgFile(), JSON.stringify({ ...readCfg(), agentBrowser: p, agentBrowserPinnedBy: human ? 'person' : 'auto' }), { mode: 0o600 }); } catch { /* best effort */ } };
|
|
22
|
+
export const pinnedByHuman = () => readCfg().agentBrowserPinnedBy === 'person';
|
|
23
|
+
|
|
24
|
+
// The agent-browser release line this version of hallpass was developed and tested against. Newer lines may change CLI output.
|
|
25
|
+
export const TESTED_AGENT_BROWSER = '0.41';
|
|
26
|
+
|
|
27
|
+
let binCache;
|
|
28
|
+
export function findBinary({ repin = false, human = false } = {}) {
|
|
29
|
+
if (!repin) {
|
|
30
|
+
if (binCache) return binCache;
|
|
31
|
+
const p = pinned();
|
|
32
|
+
// Our own copy (this package's dependency, or ~/.hallpass/runtime) is the one hallpass was tested with and is not steerable
|
|
33
|
+
// from a shell, so it replaces an older pin to some other install (typically a global agent-browser from before hallpass
|
|
34
|
+
// shipped its own). A pin to anything else stays: choosing a different agent-browser is a human decision (`doctor --repin`).
|
|
35
|
+
const own = ownBinary();
|
|
36
|
+
if (p && (!own || p === own)) return (binCache = p);
|
|
37
|
+
if (own && p && !pinnedByHuman()) { pin(own); return (binCache = own); }
|
|
38
|
+
if (p) return (binCache = p);
|
|
39
|
+
}
|
|
40
|
+
const found = resolveFromPath();
|
|
41
|
+
pin(found, { human });
|
|
42
|
+
return (binCache = found);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The native binary inside an agent-browser package folder, or null. Linux has glibc and musl builds; take whichever exists.
|
|
46
|
+
function nativeIn(pkgDir) {
|
|
47
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
48
|
+
const names = { win32: ['agent-browser-win32-x64.exe'], darwin: [`agent-browser-darwin-${arch}`], linux: [`agent-browser-linux-${arch}`, `agent-browser-linux-musl-${arch}`] }[process.platform] || [];
|
|
49
|
+
for (const n of names) {
|
|
50
|
+
const p = path.join(pkgDir, 'bin', n);
|
|
51
|
+
if (!fs.existsSync(p)) continue;
|
|
52
|
+
// npm run with --ignore-scripts leaves the binary without its execute bit: fix it rather than fail later with "permission denied"
|
|
53
|
+
if (process.platform !== 'win32') { try { fs.accessSync(p, fs.constants.X_OK); } catch { try { fs.chmodSync(p, 0o755); } catch { continue; } } }
|
|
54
|
+
return p;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Hallpass's own agent-browser: the package dependency, else the copy installed under ~/.hallpass/runtime. Never PATH. */
|
|
60
|
+
export function ownBinary() {
|
|
61
|
+
try { const own = nativeIn(path.dirname(createRequire(import.meta.url).resolve('agent-browser/package.json'))); if (own) return own; } catch { /* not a dependency here */ }
|
|
62
|
+
return nativeIn(path.join(runtimeDir(), 'node_modules', 'agent-browser'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** "0.39.2" for a binary, or null. */
|
|
66
|
+
export function versionOf(bin) {
|
|
67
|
+
const r = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 15_000, windowsHide: true });
|
|
68
|
+
return /(\d+\.\d+\.\d+)/.exec(r.stdout || '')?.[1] || null;
|
|
69
|
+
}
|
|
70
|
+
/** true when a version is on an older minor line than the one hallpass is tested with. */
|
|
71
|
+
export const olderThanTested = (v) => { const a = String(v || '').split('.').map(Number), b = TESTED_AGENT_BROWSER.split('.').map(Number); return a.length >= 2 && !a.some(Number.isNaN) && (a[0] < b[0] || (a[0] === b[0] && a[1] < b[1])); };
|
|
72
|
+
|
|
73
|
+
// Where agent-browser may come from, in trust order. Our own dependency and our own runtime folder come BEFORE anything an
|
|
74
|
+
// environment variable or PATH could steer; the result is then pinned (see above).
|
|
75
|
+
function resolveFromPath() {
|
|
76
|
+
const own = ownBinary();
|
|
77
|
+
if (own) return (binCache = own);
|
|
78
|
+
for (const d of (process.env.PATH || '').split(path.delimiter)) {
|
|
79
|
+
for (const n of ['agent-browser.cmd', 'agent-browser.exe', 'agent-browser']) {
|
|
80
|
+
const p = path.join(d, n);
|
|
81
|
+
if (!fs.existsSync(p)) continue;
|
|
82
|
+
// npm global layout: <prefix>/agent-browser(.cmd) beside <prefix>/node_modules/agent-browser/bin/<native>
|
|
83
|
+
const native = nativeIn(path.join(d, 'node_modules', 'agent-browser'));
|
|
84
|
+
if (native) return (binCache = native);
|
|
85
|
+
if (!p.endsWith('.cmd')) return (binCache = p);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', shell: process.platform === 'win32' }).trim();
|
|
90
|
+
const native = nativeIn(path.join(root, 'agent-browser'));
|
|
91
|
+
if (native) return (binCache = native);
|
|
92
|
+
} catch { /* fall through */ }
|
|
93
|
+
throw new Error('agent-browser not found. Run: hallpass setup (or: npm i -g agent-browser && agent-browser install)');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Google refuses sign-in ("Couldn't sign you in / This browser or app may not be secure") in Chrome for Testing, the build
|
|
97
|
+
// agent-browser downloads. A person's own installed Chrome or Edge is accepted, so prefer it. HALLPASS_BROWSER=<path> forces one.
|
|
98
|
+
// Also supports config.json "browser": "edge" | "chrome" | "/path/to/browser".
|
|
99
|
+
export function systemBrowser() {
|
|
100
|
+
const cfgBrowser = readCfg().browser;
|
|
101
|
+
const env = process.env.HALLPASS_BROWSER || (cfgBrowser && fs.existsSync(cfgBrowser) ? cfgBrowser : null);
|
|
102
|
+
if (env) return fs.existsSync(env) ? env : null;
|
|
103
|
+
const pf = [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean);
|
|
104
|
+
const cands = {
|
|
105
|
+
win32: pf.flatMap((d) => [path.join(d, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(d, 'Microsoft', 'Edge', 'Application', 'msedge.exe')]),
|
|
106
|
+
darwin: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
|
|
107
|
+
linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable'],
|
|
108
|
+
}[process.platform] || [];
|
|
109
|
+
const preferEdge = /edge/i.test(process.env.HALLPASS_BROWSER || cfgBrowser || '');
|
|
110
|
+
const rank = (p) => (preferEdge ? (/edge/i.test(p) ? 0 : 1) : (/edge/i.test(p) ? 1 : 0));
|
|
111
|
+
return cands.filter((p) => fs.existsSync(p)).sort((a, b) => rank(a) - rank(b))[0] || null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Google will not accept a sign-in typed into a browser that is being remote-controlled. So for that one step the person signs in
|
|
115
|
+
// in a plain, un-automated Chrome/Edge window that uses the same profile folder; closing the window saves the cookies, and the
|
|
116
|
+
// automated session then reopens on that profile already signed in. Resolves true once the window was closed by the person.
|
|
117
|
+
export function signInManually(profile, url, { timeoutMs = 15 * 60_000 } = {}) {
|
|
118
|
+
const exe = systemBrowser();
|
|
119
|
+
if (!exe) return Promise.resolve(false);
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
const p = spawn(exe, [`--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', url], { stdio: 'ignore' });
|
|
122
|
+
const t = setTimeout(() => { try { p.kill(); } catch { /* already gone */ } }, timeoutMs);
|
|
123
|
+
p.on('error', () => { clearTimeout(t); resolve(false); });
|
|
124
|
+
p.on('exit', () => { clearTimeout(t); setTimeout(() => resolve(true), 1500); }); // let Chrome flush its cookie store
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class Browser {
|
|
129
|
+
constructor({ session, profile, headed = false, redactor, allowedDomains = null }) {
|
|
130
|
+
Object.assign(this, { session, profile, headed, redactor, allowedDomains });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_flags(json = true) {
|
|
134
|
+
const f = ['--session', this.session, '--profile', this.profile];
|
|
135
|
+
if (json) f.push('--json');
|
|
136
|
+
if (this.headed) f.push('--headed');
|
|
137
|
+
if (this.allowedDomains?.length) f.push('--allowed-domains', this.allowedDomains.join(','));
|
|
138
|
+
return f;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Launch settings for the browser daemon (read when the session's first command starts it). A default only: a user-set
|
|
142
|
+
// AGENT_BROWSER_EXECUTABLE_PATH wins because process.env is spread after this.
|
|
143
|
+
_launchEnv() {
|
|
144
|
+
const exe = systemBrowser();
|
|
145
|
+
return exe ? { AGENT_BROWSER_EXECUTABLE_PATH: exe } : {};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
run(args, { input, timeout = 60_000, json: wantJson = true, env: extraEnv = {}, bare = false } = {}) {
|
|
149
|
+
// Output goes to temp files, not pipes: the first command of a session spawns a long-lived daemon that
|
|
150
|
+
// inherits our stdio, and a pipe would never reach EOF (spawnSync would hang until its timeout).
|
|
151
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hp-'));
|
|
152
|
+
const fo = fs.openSync(path.join(dir, 'out'), 'w');
|
|
153
|
+
const fe = fs.openSync(path.join(dir, 'err'), 'w');
|
|
154
|
+
let r;
|
|
155
|
+
try {
|
|
156
|
+
r = spawnSync(findBinary(), [...(bare ? ['--json'] : this._flags(wantJson)), ...args], {
|
|
157
|
+
input, timeout, windowsHide: false, stdio: [input === undefined ? 'ignore' : 'pipe', fo, fe],
|
|
158
|
+
env: { AGENT_BROWSER_IDLE_TIMEOUT_MS: '7200000', ...this._launchEnv(), ...process.env, AGENT_BROWSER_HEADED: this.headed ? 'true' : 'false', ...extraEnv },
|
|
159
|
+
});
|
|
160
|
+
} finally { fs.closeSync(fo); fs.closeSync(fe); }
|
|
161
|
+
const read = (f) => { try { return fs.readFileSync(path.join(dir, f), 'utf8'); } catch { return ''; } };
|
|
162
|
+
const stdout = read('out'), stderr = read('err');
|
|
163
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
const scrub = (s) => this.redactor.scrub(s || '');
|
|
165
|
+
let json = null;
|
|
166
|
+
try { json = JSON.parse(stdout); } catch { /* non-json output */ }
|
|
167
|
+
return {
|
|
168
|
+
ok: r.status === 0 && json?.success !== false,
|
|
169
|
+
json, stdout: scrub(stdout), stderr: scrub(stderr),
|
|
170
|
+
error: r.error ? scrub(String(r.error.message)) : null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Plain-text pass-through for an agent's own commands (used by the MCP server).
|
|
175
|
+
// Page output is wrapped in boundary markers so injected page text is clearly delimited from tool output.
|
|
176
|
+
text(args, { timeout = 60_000, boundaries = true } = {}) {
|
|
177
|
+
const r = this.run(args, { timeout, json: false, env: boundaries ? { AGENT_BROWSER_CONTENT_BOUNDARIES: 'true' } : {} });
|
|
178
|
+
return { ok: r.ok, stdout: r.stdout, stderr: r.stderr, error: r.error };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
open(url) { return this.run(['open', url], { timeout: 120_000 }); }
|
|
182
|
+
|
|
183
|
+
// commands: array of string arrays. May contain secrets; they go over stdin only.
|
|
184
|
+
batch(commands) {
|
|
185
|
+
const r = this.run(['batch', '--bail'], { input: JSON.stringify(commands) });
|
|
186
|
+
const results = Array.isArray(r.json) ? r.json : [];
|
|
187
|
+
return {
|
|
188
|
+
ok: r.ok && results.length === commands.length && results.every((x) => x.success),
|
|
189
|
+
results: this.redactor.scrubDeep(results),
|
|
190
|
+
error: r.error || r.stderr,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Runs a script in the page and parses its JSON result.
|
|
195
|
+
evalJson(script) {
|
|
196
|
+
const r = this.run(['eval', '--stdin'], { input: script });
|
|
197
|
+
if (!r.ok) return null;
|
|
198
|
+
const raw = r.json?.data?.result;
|
|
199
|
+
try { return typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// `close` returns before the daemon has finished exiting, and for a second or two `session list` still shows the session. A
|
|
203
|
+
// relaunch (headless to headed) or a quick logout-then-open in that window would talk to the dying daemon, so wait it out.
|
|
204
|
+
close() {
|
|
205
|
+
const r = this.run(['close'], { timeout: 30_000 });
|
|
206
|
+
for (let i = 0; i < 24 && this.alive(); i++) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
|
|
207
|
+
return r;
|
|
208
|
+
}
|
|
209
|
+
// Is there a live session by this name? Must NOT use a page command: on a missing session those silently
|
|
210
|
+
// launch a new blank browser, which would make "closed" look like "open".
|
|
211
|
+
alive() {
|
|
212
|
+
// bare: no --session flag, because with one the listing marks the current session as "-> name"
|
|
213
|
+
const r = this.run(['session', 'list'], { timeout: 15_000, bare: true });
|
|
214
|
+
return r.ok && Array.isArray(r.json?.data?.sessions) && r.json.data.sessions.includes(this.session);
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/act.mjs
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Running browser commands on behalf of an agent, with three safety layers:
|
|
2
|
+
// 1. guard a fixed allowlist of page commands; hallpass sessions only; http(s) URLs only
|
|
3
|
+
// 2. read-only HALLPASS_READONLY=1 (set by the PERSON in the MCP client config) permits observation only
|
|
4
|
+
// 3. gate clicks that look irreversible/outward-facing (Delete, Publish, Pay, Submit application, ...) need a
|
|
5
|
+
// native yes/no dialog that only the person can answer. No GUI available = denied.
|
|
6
|
+
// HALLPASS_GATE=off|risky|all (default risky)
|
|
7
|
+
// The gate is a HEURISTIC on the visible label, not a proof: it catches the common dangerous buttons, not every one.
|
|
8
|
+
// `upload` (files from disk into a page) is vetted by upload.mjs and approved per batch; HALLPASS_UPLOAD=ask|allow|off.
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { Browser } from './ab.mjs';
|
|
11
|
+
import { Redactor } from './redact.mjs';
|
|
12
|
+
import { profilesDir } from './paths.mjs';
|
|
13
|
+
import { ask } from './ui.mjs';
|
|
14
|
+
import { audit } from './audit.mjs';
|
|
15
|
+
import { safeUrl, parseOrigin } from './origin.mjs';
|
|
16
|
+
import { vetUploads, describeFiles, screenshotCommand } from './upload.mjs';
|
|
17
|
+
|
|
18
|
+
export const ALLOWED = new Set(['open', 'click', 'activate', 'dblclick', 'fill', 'type', 'press', 'keyboard', 'hover', 'focus', 'check', 'uncheck', 'select', 'scroll', 'scrollintoview', 'wait', 'snapshot', 'get', 'is', 'find', 'tab', 'back', 'forward', 'reload', 'dialog', 'drag', 'highlight', 'upload', 'screenshot']);
|
|
19
|
+
const LONG_FLAGS = new Set(['--full', '--annotate', '--exact', '--name', '--new-tab', '--text', '--url', '--load', '--interactive', '--compact', '--depth', '--selector']);
|
|
20
|
+
const SHORT_FLAGS = new Set(['-i', '-c', '-u', '-d', '-s']);
|
|
21
|
+
const BAD_SCHEME = /^\s*(file|javascript|data|chrome|chrome-extension|view-source|devtools|ftp):/i;
|
|
22
|
+
const SESSION_RE = /^hp-[a-z0-9][a-z0-9._-]{0,62}$/;
|
|
23
|
+
// `activate <target> [Enter|Space]`: focus the control and press the key a keyboard user would. Some pages (Play Console's sticky
|
|
24
|
+
// Save bar, Material buttons in dialogs) swallow or mis-aim a pointer click while the same control works from the keyboard.
|
|
25
|
+
const ACTIVATE_KEYS = new Set(['Enter', 'Space']);
|
|
26
|
+
|
|
27
|
+
// Positions in the argv that are text TO TYPE (release notes, a store description), not flags/selectors/URLs. Such text may
|
|
28
|
+
// legitimately start with "-" or "data:"; it never reaches the CLI's flag parser (see execCommand).
|
|
29
|
+
function payloadStart(command) {
|
|
30
|
+
const [verb] = command;
|
|
31
|
+
if (verb === 'fill' || verb === 'type' || verb === 'select') return 2;
|
|
32
|
+
if (verb === 'keyboard') return 2;
|
|
33
|
+
if (verb === 'find') { const i = command.findIndex((a, n) => n >= 3 && (a === 'fill' || a === 'type')); return i > 0 ? i + 1 : Infinity; }
|
|
34
|
+
return Infinity;
|
|
35
|
+
}
|
|
36
|
+
const payloadArgs = (command) => { const at = payloadStart(command); const idx = new Set(); command.forEach((a, n) => { if (n >= at && !(command[0] === 'find' && a.startsWith('--') && LONG_FLAGS.has(a.split('=')[0]))) idx.add(n); }); return idx; };
|
|
37
|
+
|
|
38
|
+
export function guardBrowser({ session, command }) {
|
|
39
|
+
if (!SESSION_RE.test(session || '')) return 'session must be a hallpass session (hp-...) returned by hallpass_open';
|
|
40
|
+
if (!Array.isArray(command) || !command.length || command.length > 24 || command.some((a) => typeof a !== 'string' || a.length > 4000)) return 'command must be a non-empty array of strings';
|
|
41
|
+
if (!ALLOWED.has(command[0])) return `"${command[0]}" is not available through this tool (allowed: ${[...ALLOWED].join(' ')})`;
|
|
42
|
+
if (command[0] === 'activate' && (command.length < 2 || command.length > 3 || (command[2] !== undefined && !ACTIVATE_KEYS.has(command[2])))) return 'usage: activate <@ref|selector> [Enter|Space]';
|
|
43
|
+
const text = payloadArgs(command);
|
|
44
|
+
for (let n = 1; n < command.length; n++) {
|
|
45
|
+
const a = command[n];
|
|
46
|
+
if (text.has(n)) continue;
|
|
47
|
+
if (a.startsWith('--') && !LONG_FLAGS.has(a.split('=')[0])) return `flag ${a.split('=')[0]} is not allowed`;
|
|
48
|
+
if (/^-[A-Za-z]$/.test(a) && !SHORT_FLAGS.has(a)) return `flag ${a} is not allowed`;
|
|
49
|
+
if (BAD_SCHEME.test(a)) return 'only http(s) URLs are allowed';
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- read-only ----
|
|
55
|
+
export const readOnly = () => process.env.HALLPASS_READONLY === '1';
|
|
56
|
+
const WRITE_VERBS = new Set(['click', 'activate', 'dblclick', 'fill', 'type', 'press', 'keyboard', 'check', 'uncheck', 'select', 'drag', 'dialog', 'upload']);
|
|
57
|
+
const FIND_ACTIONS_THAT_WRITE = new Set(['click', 'dblclick', 'fill', 'type', 'check', 'uncheck', 'select', 'hover-click']);
|
|
58
|
+
export function writesState(command) {
|
|
59
|
+
if (WRITE_VERBS.has(command[0])) return true;
|
|
60
|
+
if (command[0] === 'find') return command.slice(1).some((a) => FIND_ACTIONS_THAT_WRITE.has(a));
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- gate ----
|
|
65
|
+
export const gateMode = () => { const m = (process.env.HALLPASS_GATE || 'risky').toLowerCase(); return ['off', 'risky', 'all'].includes(m) ? m : 'risky'; };
|
|
66
|
+
export const RISKY = /\b(submit|apply(?! (filter|filters|changes to view))|publish|release|roll ?out|deploy|promote|delete|remove|erase|destroy|terminate|drop|pay|purchase|buy|checkout|place order|order now|subscribe|upgrade|transfer|invite|revoke|disable|deactivate|send\b[^.\n]{0,40}\bfor (review|approval)|go live)\b/i;
|
|
67
|
+
// Labels that contain a risky word but only open an editor or name a field (Play Console: "Create new release", "Release notes").
|
|
68
|
+
// The final, outward-facing buttons ("Start rollout", "Publish", "Send for review") still match. Applied to the visible label only.
|
|
69
|
+
const BENIGN = /\b(create new|review|view|edit)\s+releases?\b|\breleases?\s+(notes?|name|dashboard|history|type|summary|details|track)\b/gi;
|
|
70
|
+
export const isRiskyLabel = (label) => RISKY.test(String(label).replace(BENIGN, ' '));
|
|
71
|
+
// Stems matched as SUBSTRINGS in id/class/name/data-testid (catches deleteBtn, btn-remove, publish_release, ...).
|
|
72
|
+
export const RISKY_ATTR = /(delet|remov|destroy|erase|terminat|publish|release|rollout|deploy|promote|purchase|checkout|subscribe|upgrade|invite|revoke|deactivat|transfer|submit|pay(?!load)|buy)/i;
|
|
73
|
+
const CLICKY = new Set(['click', 'activate', 'dblclick', 'check', 'uncheck', 'select', 'drag']);
|
|
74
|
+
const FIND_BY_SELECTOR = new Set(['first', 'last', 'nth']);
|
|
75
|
+
|
|
76
|
+
function clickTarget(command) {
|
|
77
|
+
if (command[0] === 'find') {
|
|
78
|
+
const loc = command[1];
|
|
79
|
+
if (FIND_BY_SELECTOR.has(loc)) { // find first|last|nth <selector> <action>: no label in the command itself
|
|
80
|
+
const sel = loc === 'nth' ? command[3] : command[2];
|
|
81
|
+
const action = loc === 'nth' ? command[4] : command[3];
|
|
82
|
+
return CLICKY.has(action) ? { ref: sel, uncertain: loc !== 'first' } : null; // last/nth: we cannot tell which element, so ask
|
|
83
|
+
}
|
|
84
|
+
const action = command[3];
|
|
85
|
+
if (!CLICKY.has(action)) return null;
|
|
86
|
+
const nameIdx = command.indexOf('--name');
|
|
87
|
+
return { find: [command[2], nameIdx > 0 ? command[nameIdx + 1] : ''].join(' ') };
|
|
88
|
+
}
|
|
89
|
+
if (CLICKY.has(command[0])) return { ref: command[1] };
|
|
90
|
+
if (command[0] === 'press' || command[0] === 'dialog') return { ref: null, keystroke: true }; // can submit/confirm; only gated in "all" mode
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Returns { label, attrs }: what a person would read on the control, and the id/class/name/testid a developer gave it.
|
|
95
|
+
function labelFor(browser, t) {
|
|
96
|
+
if (t.find !== undefined) return { label: t.find.toLowerCase().slice(0, 300), attrs: '' };
|
|
97
|
+
if (!t.ref) return { label: '', attrs: '' };
|
|
98
|
+
// one process, several lookups: visible text, accessible name, button value, title, and developer-chosen attributes
|
|
99
|
+
const q = [['get', 'text', t.ref], ['get', 'attr', t.ref, 'aria-label'], ['get', 'attr', t.ref, 'value'], ['get', 'attr', t.ref, 'title'],
|
|
100
|
+
['get', 'attr', t.ref, 'id'], ['get', 'attr', t.ref, 'class'], ['get', 'attr', t.ref, 'name'], ['get', 'attr', t.ref, 'data-testid']];
|
|
101
|
+
const r = browser.run(['batch'], { input: JSON.stringify(q), timeout: 25_000 });
|
|
102
|
+
if (!Array.isArray(r.json)) return { label: '', attrs: '', unreadable: true };
|
|
103
|
+
// Each result is { text } / { value } (plus, in newer agent-browser releases, a long "lifecycle" block): take the field that holds
|
|
104
|
+
// the text itself. Stringifying the whole object would let the bookkeeping push the real label past the length cap.
|
|
105
|
+
const pick = (i) => { const x = r.json[i]?.result; const v = x && typeof x === 'object' ? (x.text ?? x.value ?? x.attr ?? '') : (x ?? ''); return JSON.stringify(String(v)).toLowerCase(); };
|
|
106
|
+
const label = [0, 1, 2, 3].map(pick).join(' ').slice(0, 300), attrs = [4, 5, 6, 7].map(pick).join(' ').slice(0, 300);
|
|
107
|
+
// None of the eight lookups worked (selector matched nothing, page changed, CLI output not understood): we cannot tell what
|
|
108
|
+
// this click does, so the caller asks the person instead of assuming it is harmless.
|
|
109
|
+
return { label, attrs, unreadable: !r.json.some((x) => x?.success) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- uploads ----
|
|
113
|
+
export const uploadMode = () => { const m = (process.env.HALLPASS_UPLOAD || 'ask').toLowerCase(); return ['off', 'ask', 'allow'].includes(m) ? m : 'ask'; };
|
|
114
|
+
const UPLOAD_GRANT_MS = 60 * 60 * 1000;
|
|
115
|
+
const uploadGrants = new Map(); // origin -> expiry; lives in this process only (the long-running MCP server), never on disk
|
|
116
|
+
|
|
117
|
+
// Returns { error } or { command } with every file replaced by its vetted, symlink-resolved absolute path.
|
|
118
|
+
function authorizeUpload(b, session, command, approver) {
|
|
119
|
+
if (uploadMode() === 'off') return { error: 'Uploads are disabled on this hallpass server (HALLPASS_UPLOAD=off). Ask the user to upload the file themselves.' };
|
|
120
|
+
if (!command[1] || command[1].startsWith('-')) return { error: 'usage: upload <selector> <file> [file...]' };
|
|
121
|
+
const v = vetUploads(command.slice(2));
|
|
122
|
+
if (v.error) { audit('upload', { session, decision: 'rejected', reason: v.error.slice(0, 120) }); return { error: 'Upload refused: ' + v.error }; }
|
|
123
|
+
const where = safeUrl((b.run(['get', 'url'], { json: false, timeout: 15_000 }).stdout || '').trim());
|
|
124
|
+
const origin = parseOrigin(where) || where;
|
|
125
|
+
const names = v.files.map((f) => path.basename(f.path)).join(', ').slice(0, 200);
|
|
126
|
+
if (uploadMode() === 'ask' && !((uploadGrants.get(origin) || 0) > Date.now())) {
|
|
127
|
+
const choice = approver({
|
|
128
|
+
title: 'Hallpass: confirm file upload',
|
|
129
|
+
message: `Your AI tool wants to upload ${v.files.length} file${v.files.length > 1 ? 's' : ''} to:\n\n ${where || 'the current page'}\n\n${describeFiles(v.files)}\n\nApprove only if you asked for this. "Allow for 1 hour" skips this question for ${origin || 'this site'} until then.`,
|
|
130
|
+
buttons: [{ id: 'deny', label: 'Deny' }, { id: 'hour', label: 'Allow for 1 hour' }, { id: 'once', label: 'Allow once' }],
|
|
131
|
+
timeoutSec: process.env.HALLPASS_ACTOR === 'mcp' ? 45 : 120,
|
|
132
|
+
});
|
|
133
|
+
audit('upload', { session, files: names, url: where, decision: choice || 'deny' });
|
|
134
|
+
if (choice === 'hour') uploadGrants.set(origin, Date.now() + UPLOAD_GRANT_MS);
|
|
135
|
+
else if (choice !== 'once') return { error: 'Blocked: the user did not approve this upload (denied, timed out, or no desktop dialog was available). Do not retry. Tell the user which file you wanted to upload and where.' };
|
|
136
|
+
} else audit('upload', { session, files: names, url: where, decision: 'allowed' });
|
|
137
|
+
return { command: ['upload', command[1], ...v.files.map((f) => f.path)] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Text to type that starts with "-" must not go through the CLI's own flag parser ("--json" or "--headed" would be eaten as
|
|
141
|
+
// options). JSON over stdin (agent-browser `batch`) carries it literally.
|
|
142
|
+
function execCommand(b, command) {
|
|
143
|
+
const at = payloadStart(command);
|
|
144
|
+
if (command.slice(at).some((a) => a.startsWith('-')) && ['fill', 'type', 'keyboard', 'select', 'find'].includes(command[0])) {
|
|
145
|
+
const r = b.batch([command]);
|
|
146
|
+
const one = r.results[0];
|
|
147
|
+
return { ok: r.ok, stdout: one?.success ? JSON.stringify(one.result ?? 'ok') : '', stderr: one?.success ? '' : String(one?.error || r.error || 'command failed') };
|
|
148
|
+
}
|
|
149
|
+
return b.text(command);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const RETRY_COVERED = new Set(['click', 'dblclick', 'check', 'uncheck']);
|
|
153
|
+
|
|
154
|
+
// One command, with the two recoveries a pointer-driven page needs. The gate has already approved this exact action, so both
|
|
155
|
+
// recoveries repeat it rather than doing anything new.
|
|
156
|
+
// activate -> focus + keypress (a keyboard "click")
|
|
157
|
+
// click etc. -> "covered by <sticky footer>": scroll the target into view once and retry
|
|
158
|
+
function runAction(b, command) {
|
|
159
|
+
if (command[0] === 'activate') {
|
|
160
|
+
const f = execCommand(b, ['focus', command[1]]);
|
|
161
|
+
return f.ok ? execCommand(b, ['press', command[2] || 'Enter']) : f;
|
|
162
|
+
}
|
|
163
|
+
let r = execCommand(b, command);
|
|
164
|
+
if (!r.ok && RETRY_COVERED.has(command[0]) && /is covered by/i.test(`${r.stdout}\n${r.stderr}`)) {
|
|
165
|
+
if (execCommand(b, ['scrollintoview', command[1]]).ok) r = execCommand(b, command);
|
|
166
|
+
}
|
|
167
|
+
return r;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const browserFor = (session) => new Browser({ session, profile: path.join(profilesDir(), session.slice(3)), redactor: new Redactor() });
|
|
171
|
+
|
|
172
|
+
/** Returns { isError, text }. `approver` is injectable for tests only. */
|
|
173
|
+
export function runBrowser({ session, command }, { approver = ask } = {}) {
|
|
174
|
+
const bad = guardBrowser({ session, command });
|
|
175
|
+
if (bad) return { isError: true, text: bad };
|
|
176
|
+
if (readOnly() && writesState(command)) {
|
|
177
|
+
audit('browser', { session, verb: command[0], decision: 'blocked-readonly' });
|
|
178
|
+
return { isError: true, text: `This hallpass server is read-only (HALLPASS_READONLY=1), so "${command[0]}" is not permitted. You can still snapshot, read, scroll and navigate.` };
|
|
179
|
+
}
|
|
180
|
+
const b = browserFor(session);
|
|
181
|
+
|
|
182
|
+
// Never read a password back out of the page: whatever hallpass (or the user) typed there would come back in the clear.
|
|
183
|
+
if (command[0] === 'get' && command[1] === 'value' && command[2]) {
|
|
184
|
+
const t = b.run(['get', 'attr', command[2], 'type'], { timeout: 15_000 });
|
|
185
|
+
if (String(t.json?.data?.value ?? '').trim().toLowerCase() === 'password') { // the attribute's value only: the JSON also carries the page URL, which may contain the word
|
|
186
|
+
audit('browser', { session, verb: 'get value', decision: 'blocked-password-field' });
|
|
187
|
+
return { isError: true, text: 'Reading the value of a password field is not permitted. (Snapshots show it masked.)' };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const mode = gateMode();
|
|
192
|
+
const target = mode === 'off' ? null : clickTarget(command);
|
|
193
|
+
if (target) {
|
|
194
|
+
const { label, attrs, unreadable } = labelFor(b, target);
|
|
195
|
+
// "all" gates every click-like action and keystroke-submits; otherwise: risky words in what the person sees, risky stems
|
|
196
|
+
// in what the developer named it, or a target we cannot identify (find last/nth) which we ask about rather than guess.
|
|
197
|
+
const risky = mode === 'all' || target.uncertain === true || unreadable === true || isRiskyLabel(label) || RISKY_ATTR.test(attrs);
|
|
198
|
+
if (risky) {
|
|
199
|
+
const where = safeUrl((b.run(['get', 'url'], { json: false, timeout: 15_000 }).stdout || '').trim());
|
|
200
|
+
const shown = (label.trim() ? label : attrs).replace(/\s+/g, ' ').replace(/["{}\[\]\\]/g, '').replace(/\bresult\b|\btext\b/g, '').trim().slice(0, 90) || '(unlabelled control)';
|
|
201
|
+
const choice = approver({
|
|
202
|
+
title: 'Hallpass: confirm action',
|
|
203
|
+
message: `Your AI tool wants to ${command[0]}:\n\n ${shown}\n\non ${where || 'the current page'}\n\n${mode === 'all' ? 'Every click and keystroke action is gated.' : 'This looks irreversible or outward-facing (publish, delete, pay, submit, ...).'}\nApprove only if this is exactly what you asked for.`,
|
|
204
|
+
buttons: [{ id: 'deny', label: 'Deny' }, { id: 'once', label: 'Allow this action' }],
|
|
205
|
+
timeoutSec: process.env.HALLPASS_ACTOR === 'mcp' ? 45 : 120,
|
|
206
|
+
});
|
|
207
|
+
audit('gate', { session, verb: command[0], label: shown, url: where, decision: choice === 'once' ? 'allowed' : 'denied' });
|
|
208
|
+
if (choice !== 'once') return { isError: true, text: 'Blocked: the user did not approve this action (denied, timed out, or no desktop dialog was available). Do not retry it. Explain to the user what you wanted to do and ask them to do it or to approve it.' };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let toRun = command, shot = null;
|
|
213
|
+
if (command[0] === 'upload') {
|
|
214
|
+
const u = authorizeUpload(b, session, command, approver);
|
|
215
|
+
if (u.error) return { isError: true, text: u.error };
|
|
216
|
+
toRun = u.command;
|
|
217
|
+
} else if (command[0] === 'screenshot') {
|
|
218
|
+
({ command: toRun, file: shot } = screenshotCommand(command));
|
|
219
|
+
}
|
|
220
|
+
if (writesState(command)) audit('browser', { session, verb: command[0], decision: 'allowed' });
|
|
221
|
+
const r = runAction(b, toRun);
|
|
222
|
+
let body = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() || (r.ok ? 'ok' : 'command failed');
|
|
223
|
+
if (body.length > 40_000) body = body.slice(0, 40_000) + '\n[truncated]';
|
|
224
|
+
if (shot && r.ok) body += `\nSaved to ${shot}`;
|
|
225
|
+
return { isError: !r.ok, text: body };
|
|
226
|
+
}
|
package/src/audit.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Append-only, hash-chained audit log of everything an agent did with your logins. Never contains secrets:
|
|
2
|
+
// callers pass only names, origin+path, verbs and outcomes. Best-effort: logging can never break a login.
|
|
3
|
+
// Tamper-EVIDENT, not tamper-proof: a same-user process can still delete the file, but cannot edit history unnoticed.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { home } from './paths.mjs';
|
|
8
|
+
|
|
9
|
+
const file = () => path.join(home(), 'audit.log');
|
|
10
|
+
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
11
|
+
const GENESIS = '0'.repeat(64);
|
|
12
|
+
const trim = (v) => (typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 200) : v);
|
|
13
|
+
|
|
14
|
+
function lastHash(f) {
|
|
15
|
+
try {
|
|
16
|
+
const fd = fs.openSync(f, 'r');
|
|
17
|
+
const { size } = fs.fstatSync(fd);
|
|
18
|
+
const len = Math.min(size, 8192);
|
|
19
|
+
const buf = Buffer.alloc(len);
|
|
20
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
21
|
+
fs.closeSync(fd);
|
|
22
|
+
const lines = buf.toString('utf8').trimEnd().split('\n');
|
|
23
|
+
return JSON.parse(lines[lines.length - 1]).hash || GENESIS;
|
|
24
|
+
} catch { return GENESIS; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function audit(event, fields = {}) {
|
|
28
|
+
try {
|
|
29
|
+
const f = file();
|
|
30
|
+
const clean = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, trim(v)]));
|
|
31
|
+
const rec = { ts: new Date().toISOString(), actor: process.env.HALLPASS_ACTOR || 'cli', event, ...clean, prev: lastHash(f) };
|
|
32
|
+
fs.appendFileSync(f, JSON.stringify({ ...rec, hash: sha(JSON.stringify(rec)) }) + '\n', { mode: 0o600 });
|
|
33
|
+
} catch { /* never break the caller */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readLog({ tail = 50 } = {}) {
|
|
37
|
+
const f = file();
|
|
38
|
+
if (!fs.existsSync(f)) return [];
|
|
39
|
+
const rows = fs.readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return { event: 'corrupt-line' }; } });
|
|
40
|
+
return rows.slice(-tail);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function verifyLog() {
|
|
44
|
+
const f = file();
|
|
45
|
+
if (!fs.existsSync(f)) return { ok: true, entries: 0 };
|
|
46
|
+
let prev = GENESIS, n = 0;
|
|
47
|
+
for (const line of fs.readFileSync(f, 'utf8').split('\n').filter(Boolean)) {
|
|
48
|
+
n++;
|
|
49
|
+
let r;
|
|
50
|
+
try { r = JSON.parse(line); } catch { return { ok: false, entries: n, broken_at: n, reason: 'unparseable line' }; }
|
|
51
|
+
const { hash, ...rec } = r;
|
|
52
|
+
if (rec.prev !== prev) return { ok: false, entries: n, broken_at: n, reason: 'chain broken (entry removed or reordered)' };
|
|
53
|
+
if (sha(JSON.stringify(rec)) !== hash) return { ok: false, entries: n, broken_at: n, reason: 'entry modified' };
|
|
54
|
+
prev = hash;
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, entries: n };
|
|
57
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Teaches each AI tool how to use hallpass (instruction files) and prints the MCP config for each client.
|
|
2
|
+
// Instruction files are written only inside a clearly marked block (or a file hallpass owns); nothing else is touched.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const BEGIN = '<!-- hallpass:begin (managed by `hallpass install`; edits inside are overwritten) -->';
|
|
10
|
+
const END = '<!-- hallpass:end -->';
|
|
11
|
+
|
|
12
|
+
const skillFile = () => path.join(ROOT, 'skills', 'hallpass', 'SKILL.md');
|
|
13
|
+
const stripFrontmatter = (t) => t.replace(/^---[\s\S]*?\n---\n/, '');
|
|
14
|
+
|
|
15
|
+
const MCP_NOTE = `
|
|
16
|
+
## If hallpass is available as MCP tools
|
|
17
|
+
|
|
18
|
+
Use the tools instead of the shell commands above: \`hallpass_open\` (= \`hallpass open\`), \`hallpass_resume\`, \`hallpass_status\`, \`hallpass_logout\`, \`hallpass_list\`, and \`hallpass_request_credential\` (= \`hallpass request\`: the user types a login into a form, you never see it), then drive the page with the \`browser\` tool (\`command\` is the argv array you would pass to \`agent-browser --session <session>\`; risky clicks are confirmed by the user) and \`browser_screenshot\`. The rules above apply unchanged.
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export function instructionBody() {
|
|
22
|
+
return `# hallpass: open any site and log in safely\n` + stripFrontmatter(fs.readFileSync(skillFile(), 'utf8')).replace(/^# hallpass[^\n]*\n/, '') + MCP_NOTE;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function upsertBlock(file, body) {
|
|
26
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
27
|
+
const block = `${BEGIN}\n${body.trim()}\n${END}\n`;
|
|
28
|
+
let cur = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
29
|
+
const re = new RegExp(`${BEGIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n?`);
|
|
30
|
+
let action;
|
|
31
|
+
if (re.test(cur)) { cur = cur.replace(re, block); action = 'updated'; }
|
|
32
|
+
else { cur = (cur && !cur.endsWith('\n') ? cur + '\n' : cur) + (cur ? '\n' : '') + block; action = cur === block ? 'created' : 'appended'; }
|
|
33
|
+
fs.writeFileSync(file, cur);
|
|
34
|
+
return action;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ownFile(file, content) {
|
|
38
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
39
|
+
const action = fs.existsSync(file) ? 'updated' : 'created';
|
|
40
|
+
fs.writeFileSync(file, content);
|
|
41
|
+
return action;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const TARGETS = {
|
|
45
|
+
claude: { desc: 'Claude Code / Claude Desktop skill', run: ({ home }) => { const f = path.join(home, '.claude', 'skills', 'hallpass', 'SKILL.md'); fs.mkdirSync(path.dirname(f), { recursive: true }); const a = fs.existsSync(f) ? 'updated' : 'created'; fs.copyFileSync(skillFile(), f); return { file: f, action: a }; } },
|
|
46
|
+
codex: { desc: 'OpenAI Codex CLI / IDE (global AGENTS.md)', run: ({ home }) => { const f = path.join(home, '.codex', 'AGENTS.md'); return { file: f, action: upsertBlock(f, instructionBody()) }; } },
|
|
47
|
+
gemini: { desc: 'Gemini CLI (global GEMINI.md)', run: ({ home }) => { const f = path.join(home, '.gemini', 'GEMINI.md'); return { file: f, action: upsertBlock(f, instructionBody()) }; } },
|
|
48
|
+
// Antigravity reads real skills (progressive disclosure: only the description is loaded until it is relevant).
|
|
49
|
+
// Locations confirmed against the docs AND a working Antigravity IDE install: ~/.gemini/config/skills/<name>/SKILL.md (IDE),
|
|
50
|
+
// ~/.gemini/antigravity-cli/skills/<name>/SKILL.md (CLI, written only if the CLI home exists).
|
|
51
|
+
antigravity: { desc: 'Google Antigravity IDE/CLI (global skill)', run: ({ home }) => {
|
|
52
|
+
const put = (f) => { fs.mkdirSync(path.dirname(f), { recursive: true }); const a = fs.existsSync(f) ? 'updated' : 'created'; fs.copyFileSync(skillFile(), f); return a; };
|
|
53
|
+
const ide = path.join(home, '.gemini', 'config', 'skills', 'hallpass', 'SKILL.md');
|
|
54
|
+
const out = { file: ide, action: put(ide) };
|
|
55
|
+
if (fs.existsSync(path.join(home, '.gemini', 'antigravity-cli'))) { const cli = path.join(home, '.gemini', 'antigravity-cli', 'skills', 'hallpass', 'SKILL.md'); out.also = [{ file: cli, action: put(cli) }]; }
|
|
56
|
+
return out;
|
|
57
|
+
} },
|
|
58
|
+
'antigravity-project': { desc: 'Google Antigravity (this workspace: .agents/skills/hallpass)', run: ({ cwd }) => {
|
|
59
|
+
const f = path.join(cwd, '.agents', 'skills', 'hallpass', 'SKILL.md'); fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
60
|
+
const a = fs.existsSync(f) ? 'updated' : 'created'; fs.copyFileSync(skillFile(), f); return { file: f, action: a };
|
|
61
|
+
} },
|
|
62
|
+
cursor: { desc: 'Cursor (project rule .cursor/rules/hallpass.mdc)', run: ({ cwd }) => { const f = path.join(cwd, '.cursor', 'rules', 'hallpass.mdc'); return { file: f, action: ownFile(f, `---\ndescription: Open websites and log in safely with hallpass (use when a task needs a logged-in browser or a site login)\nalwaysApply: false\n---\n\n${instructionBody()}`) }; } },
|
|
63
|
+
windsurf: { desc: 'Windsurf (project rule .windsurf/rules/hallpass.md)', run: ({ cwd }) => { const f = path.join(cwd, '.windsurf', 'rules', 'hallpass.md'); return { file: f, action: ownFile(f, `---\ntrigger: model_decision\ndescription: Open websites and log in safely with hallpass (use when a task needs a logged-in browser or a site login)\n---\n\n${instructionBody()}`) }; } },
|
|
64
|
+
copilot: { desc: 'GitHub Copilot (project .github/copilot-instructions.md)', run: ({ cwd }) => { const f = path.join(cwd, '.github', 'copilot-instructions.md'); return { file: f, action: upsertBlock(f, instructionBody()) }; } },
|
|
65
|
+
agents: { desc: 'Any tool that reads AGENTS.md (project AGENTS.md)', run: ({ cwd }) => { const f = path.join(cwd, 'AGENTS.md'); return { file: f, action: upsertBlock(f, instructionBody()) }; } },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function install(target, { home = process.env.HALLPASS_TARGET_HOME || os.homedir(), cwd = process.cwd() } = {}) {
|
|
69
|
+
const names = target === 'all' ? Object.keys(TARGETS) : [target];
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const n of names) {
|
|
72
|
+
if (!TARGETS[n]) throw new Error(`unknown target "${n}". Choose: ${Object.keys(TARGETS).join(', ')}, all`);
|
|
73
|
+
results.push({ target: n, ...TARGETS[n].run({ home, cwd }) });
|
|
74
|
+
}
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- MCP client configuration (printed, never written: we do not edit your tools' config files) ----
|
|
79
|
+
export function mcpConfig(target, { npx = false } = {}) {
|
|
80
|
+
const script = path.join(ROOT, 'bin', 'hallpass.mjs');
|
|
81
|
+
// --npx: for people who installed from npm (`npx -y hallpass-browser mcp`); default points at this checkout's script
|
|
82
|
+
const server = npx ? { command: 'npx', args: ['-y', 'hallpass-browser', 'mcp'] } : { command: 'node', args: [script, 'mcp'] };
|
|
83
|
+
const std = (file, extra = '') => ({ file, format: 'json', snippet: { mcpServers: { hallpass: server } }, note: `Merge into ${file}.${extra}` });
|
|
84
|
+
const cfg = {
|
|
85
|
+
claude: { command: npx ? 'claude mcp add hallpass -- npx -y hallpass-browser mcp' : `claude mcp add hallpass -- node "${script}" mcp`, note: 'Run once in a terminal (Claude Code). For Claude Desktop use the JSON in claude_desktop_config.json: { "mcpServers": { "hallpass": { "command": "node", "args": ["<script>", "mcp"] } } }' },
|
|
86
|
+
cursor: std('~/.cursor/mcp.json (or .cursor/mcp.json in a project)'),
|
|
87
|
+
gemini: std('~/.gemini/settings.json', ' Or install it as a Gemini CLI extension: gemini extensions install <repo-url> (bundles the skill and this server).'),
|
|
88
|
+
antigravity: std('~/.gemini/config/mcp_config.json (global, IDE and CLI) or .agents/mcp_config.json (this workspace)', ' Same file Settings > Customizations > Installed MCP Servers edits. Use absolute paths: variable expansion is not documented.'),
|
|
89
|
+
windsurf: std('~/.codeium/windsurf/mcp_config.json'),
|
|
90
|
+
vscode: { file: '.vscode/mcp.json (VS Code / GitHub Copilot agent mode)', format: 'json', snippet: { servers: { hallpass: { type: 'stdio', ...server } } }, note: 'Create .vscode/mcp.json with this content.' },
|
|
91
|
+
codex: { file: '~/.codex/config.toml', format: 'toml', snippet: `[mcp_servers.hallpass]\ncommand = ${JSON.stringify(server.command)}\nargs = ${JSON.stringify(server.args)}\ntool_timeout_sec = 180\n`, note: 'Append to ~/.codex/config.toml. tool_timeout_sec raises the per-call limit (a login can take ~60s).' },
|
|
92
|
+
};
|
|
93
|
+
if (!cfg[target]) throw new Error(`unknown target "${target}". Choose: ${Object.keys(cfg).join(', ')}`);
|
|
94
|
+
return { target, ...cfg[target] };
|
|
95
|
+
}
|
|
96
|
+
export const MCP_TARGETS = ['claude', 'cursor', 'gemini', 'antigravity', 'windsurf', 'vscode', 'codex'];
|