mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-22 04:40:44 +02:00
fix(pair-agent): tunnel activation is consent-gated — and the receipt's consent claim is now real
The tunnel egress receipts have claimed consent: 'pair_agent=on' since v1.63 while no such key or gate existed — ngrok installed+authed was enough for the CLI to auto-start an internet-facing tunnel. isPairAgentEnabled() (fail- closed, env-overridable) now gates all three activation points: CLI auto-start, POST /tunnel/start (refuses with the enable hint), and the BROWSE_TUNNEL=1 startup bind. Consent-on-first-use, not silent breakage: the /pair-agent skill asks once (one-way-door posture), sets pair_agent via gstack-config (registered with on|off validation, default off), and never asks again; direct API callers get the same hint in the refusal. Adapted from the fork's gate: their reader targeted config.json, which on main would have made the gate silently un-enableable — ours reads the canonical ~/.gstack/config.yaml with the JSON shape as fallback, pinned by tests either way (11 cases, gate wiring tripwires included). Ported from time-attack/gstack (GStack 2), store adaptation ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Sina Matian
Claude Fable 5
parent
93d92c4585
commit
b141fb3511
@@ -133,6 +133,7 @@ lookup_default() {
|
|||||||
|
|
||||||
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
|
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
|
||||||
redact_prepush_hook) echo "false" ;;
|
redact_prepush_hook) echo "false" ;;
|
||||||
|
pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks
|
||||||
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
|
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
|
||||||
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
|
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
|
||||||
# writes 'personal' for local engines,
|
# writes 'personal' for local engines,
|
||||||
@@ -310,6 +311,10 @@ case "${1:-}" in
|
|||||||
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
|
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
|
||||||
VALUE="false"
|
VALUE="false"
|
||||||
fi
|
fi
|
||||||
|
if [ "$KEY" = "pair_agent" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
|
||||||
|
echo "Warning: pair_agent '$VALUE' not recognized. Valid values: on, off. Using off." >&2
|
||||||
|
VALUE="off"
|
||||||
|
fi
|
||||||
if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then
|
if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then
|
||||||
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
|
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
|
||||||
VALUE="prompt"
|
VALUE="prompt"
|
||||||
|
|||||||
+7
-3
@@ -14,7 +14,7 @@ import * as path from 'path';
|
|||||||
import { spawn as nodeSpawn } from 'child_process';
|
import { spawn as nodeSpawn } from 'child_process';
|
||||||
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
||||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled } from './config';
|
||||||
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
||||||
import { redactProxyUrl } from './proxy-redact';
|
import { redactProxyUrl } from './proxy-redact';
|
||||||
import { spawnTerminalAgent } from './terminal-agent-control';
|
import { spawnTerminalAgent } from './terminal-agent-control';
|
||||||
@@ -898,8 +898,12 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
|||||||
if (pairData.tunnel_url) {
|
if (pairData.tunnel_url) {
|
||||||
serverUrl = pairData.tunnel_url;
|
serverUrl = pairData.tunnel_url;
|
||||||
} else if (!localHost) {
|
} else if (!localHost) {
|
||||||
// No tunnel active. Check if ngrok is available and auto-start.
|
// No tunnel active. Remote tunneling (pair-agent) is opt-in — never
|
||||||
const ngrokAvailable = isNgrokAvailable();
|
// auto-start it unless the user explicitly enabled it, even if ngrok is
|
||||||
|
// installed and authed. First use goes through the /pair-agent skill's
|
||||||
|
// consent question, which sets the key.
|
||||||
|
const pairEnabled = isPairAgentEnabled();
|
||||||
|
const ngrokAvailable = pairEnabled && isNgrokAvailable();
|
||||||
if (ngrokAvailable) {
|
if (ngrokAvailable) {
|
||||||
console.log('[browse] ngrok detected. Starting tunnel...');
|
console.log('[browse] ngrok detected. Starting tunnel...');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -165,6 +165,41 @@ export function resolveGstackHome(): string {
|
|||||||
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the remote pair-agent (ngrok tunnel) surface opt-in enabled?
|
||||||
|
*
|
||||||
|
* Fail-closed: the tunnel exposes the local browser to the internet, so it
|
||||||
|
* stays OFF unless the user explicitly ran `gstack-config set pair_agent on`
|
||||||
|
* (the /pair-agent skill asks once on first use and sets it). Any read/parse
|
||||||
|
* failure (missing config, malformed JSON) also resolves OFF. The tunnel
|
||||||
|
* egress receipts cite this gate as their consent — it must exist and gate
|
||||||
|
* every activation point (#B6, fork port wave 2).
|
||||||
|
*
|
||||||
|
* Env override `GSTACK_PAIR_AGENT=on|off` wins (used by tests and as an
|
||||||
|
* emergency knob), mirroring the telemetry env-hint convention.
|
||||||
|
*/
|
||||||
|
export function isPairAgentEnabled(): boolean {
|
||||||
|
const env = process.env.GSTACK_PAIR_AGENT;
|
||||||
|
if (env === 'on') return true;
|
||||||
|
if (env === 'off') return false;
|
||||||
|
const home = resolveGstackHome();
|
||||||
|
// Canonical store: ~/.gstack/config.yaml (flat `key: value` lines, written
|
||||||
|
// by bin/gstack-config — which is what the /pair-agent consent step runs).
|
||||||
|
// The fork read config.json; porting that verbatim would have made the gate
|
||||||
|
// silently un-enableable on main. JSON kept as a fallback shape only.
|
||||||
|
try {
|
||||||
|
const yaml = fs.readFileSync(path.join(home, 'config.yaml'), 'utf-8');
|
||||||
|
const m = yaml.match(/^\s*pair_agent\s*:\s*['"]?(on|off)['"]?\s*(?:#.*)?$/m);
|
||||||
|
if (m) return m[1] === 'on';
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(path.join(home, 'config.json'), 'utf-8');
|
||||||
|
return JSON.parse(raw)?.pair_agent === 'on';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the Chromium profile directory.
|
* Resolve the Chromium profile directory.
|
||||||
*
|
*
|
||||||
|
|||||||
+14
-4
@@ -36,7 +36,7 @@ import {
|
|||||||
isRootToken, checkConnectRateLimit, type TokenInfo,
|
isRootToken, checkConnectRateLimit, type TokenInfo,
|
||||||
} from './token-registry';
|
} from './token-registry';
|
||||||
import { validateTempPath } from './path-security';
|
import { validateTempPath } from './path-security';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
|
||||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||||
import { createSseEndpoint } from './sse-helpers';
|
import { createSseEndpoint } from './sse-helpers';
|
||||||
import { initAuditLog, writeAuditEntry } from './audit';
|
import { initAuditLog, writeAuditEntry } from './audit';
|
||||||
@@ -2369,6 +2369,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
status: 403, headers: { 'Content-Type': 'application/json' },
|
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!isPairAgentEnabled()) {
|
||||||
|
// Consent-on-first-use: the /pair-agent skill asks once and sets the
|
||||||
|
// key; a direct API caller gets the same hint instead of a tunnel.
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'pair-agent is off (tunnel exposes this browser beyond the machine)',
|
||||||
|
hint: 'enable once with: gstack-config set pair_agent on — or run /pair-agent, which asks for consent and sets it',
|
||||||
|
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
if (tunnelActive && tunnelUrl && tunnelServer) {
|
if (tunnelActive && tunnelUrl && tunnelServer) {
|
||||||
// Verify tunnel is still alive before returning cached URL.
|
// Verify tunnel is still alive before returning cached URL.
|
||||||
// Probe GET /connect (the only unauth-reachable path on the tunnel
|
// Probe GET /connect (the only unauth-reachable path on the tunnel
|
||||||
@@ -2433,7 +2441,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
payloadClass: 'tunnel-session-open (scoped-token browser-command surface)',
|
payloadClass: 'tunnel-session-open (scoped-token browser-command surface)',
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
sha256: null,
|
sha256: null,
|
||||||
consent: 'pair_agent=on',
|
consent: 'pair_agent=on (isPairAgentEnabled gate at /tunnel/start)',
|
||||||
});
|
});
|
||||||
|
|
||||||
tunnelListener = await ngrok.forward(forwardOpts);
|
tunnelListener = await ngrok.forward(forwardOpts);
|
||||||
@@ -3125,7 +3133,9 @@ export async function start() {
|
|||||||
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
|
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
|
||||||
// pattern: bind a dedicated tunnel listener on an ephemeral port and
|
// pattern: bind a dedicated tunnel listener on an ephemeral port and
|
||||||
// point ngrok.forward() at IT, not the local daemon port.
|
// point ngrok.forward() at IT, not the local daemon port.
|
||||||
if (process.env.BROWSE_TUNNEL === '1') {
|
if (process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()) {
|
||||||
|
console.error('[browse] BROWSE_TUNNEL=1 ignored: pair-agent is off. Enable once with: gstack-config set pair_agent on');
|
||||||
|
} else if (process.env.BROWSE_TUNNEL === '1') {
|
||||||
const authtoken = resolveNgrokAuthtoken();
|
const authtoken = resolveNgrokAuthtoken();
|
||||||
if (!authtoken) {
|
if (!authtoken) {
|
||||||
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
|
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
|
||||||
@@ -3153,7 +3163,7 @@ export async function start() {
|
|||||||
payloadClass: 'tunnel-session-open (scoped-token browser-command surface)',
|
payloadClass: 'tunnel-session-open (scoped-token browser-command surface)',
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
sha256: null,
|
sha256: null,
|
||||||
consent: 'pair_agent=on (BROWSE_TUNNEL=1)',
|
consent: 'pair_agent=on (isPairAgentEnabled gate, BROWSE_TUNNEL=1)',
|
||||||
});
|
});
|
||||||
|
|
||||||
tunnelListener = await ngrok.forward(forwardOpts);
|
tunnelListener = await ngrok.forward(forwardOpts);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Pair-agent opt-in gate.
|
||||||
|
*
|
||||||
|
* The remote pair-agent (ngrok tunnel) is OFF by default. All three activation
|
||||||
|
* points — CLI auto-start, the /tunnel/start route, and the BROWSE_TUNNEL=1
|
||||||
|
* startup path — route through the single `isPairAgentEnabled()` guard. This
|
||||||
|
* test pins the guard's behavior (the root cause) plus a source-level tripwire
|
||||||
|
* that each call site actually consults it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, afterEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { isPairAgentEnabled } from '../src/config';
|
||||||
|
|
||||||
|
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
|
||||||
|
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
|
||||||
|
|
||||||
|
const savedEnv = { GSTACK_HOME: process.env.GSTACK_HOME, GSTACK_PAIR_AGENT: process.env.GSTACK_PAIR_AGENT };
|
||||||
|
const tmpHomes: string[] = [];
|
||||||
|
|
||||||
|
function tmpHomeWith(config: Record<string, string> | null): string {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pair-'));
|
||||||
|
tmpHomes.push(dir);
|
||||||
|
if (config !== null) {
|
||||||
|
// Canonical store: flat YAML lines, the shape bin/gstack-config writes.
|
||||||
|
const yaml = Object.entries(config).map(([k, v]) => `${k}: ${v}`).join('\n') + '\n';
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.yaml'), yaml);
|
||||||
|
}
|
||||||
|
process.env.GSTACK_HOME = dir;
|
||||||
|
delete process.env.GSTACK_PAIR_AGENT;
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const k of ['GSTACK_HOME', 'GSTACK_PAIR_AGENT'] as const) {
|
||||||
|
if (savedEnv[k] === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = savedEnv[k];
|
||||||
|
}
|
||||||
|
while (tmpHomes.length) fs.rmSync(tmpHomes.pop()!, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPairAgentEnabled — fail-closed default', () => {
|
||||||
|
test('OFF when no config store exists', () => {
|
||||||
|
tmpHomeWith(null);
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFF when config has no pair_agent key', () => {
|
||||||
|
tmpHomeWith({ telemetry: 'off' });
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ON via the config.json fallback shape too', () => {
|
||||||
|
const dir = tmpHomeWith(null);
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ pair_agent: 'on' }));
|
||||||
|
expect(isPairAgentEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFF when pair_agent is explicitly "off"', () => {
|
||||||
|
tmpHomeWith({ pair_agent: 'off' });
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ON only when pair_agent is exactly "on"', () => {
|
||||||
|
tmpHomeWith({ pair_agent: 'on' });
|
||||||
|
expect(isPairAgentEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFF when the store is malformed (fail-closed)', () => {
|
||||||
|
const dir = tmpHomeWith(null);
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.yaml'), 'pair_agent: banana\n');
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.json'), '{ not json');
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env override wins: GSTACK_PAIR_AGENT=on forces ON even with config off', () => {
|
||||||
|
tmpHomeWith({ pair_agent: 'off' });
|
||||||
|
process.env.GSTACK_PAIR_AGENT = 'on';
|
||||||
|
expect(isPairAgentEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env override wins: GSTACK_PAIR_AGENT=off forces OFF even with config on', () => {
|
||||||
|
tmpHomeWith({ pair_agent: 'on' });
|
||||||
|
process.env.GSTACK_PAIR_AGENT = 'off';
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gate wiring — every tunnel activation point consults the guard', () => {
|
||||||
|
test('CLI auto-start is gated (never auto-starts when disabled)', () => {
|
||||||
|
// pairEnabled short-circuits the ngrok probe so the tunnel can't auto-start.
|
||||||
|
expect(CLI_SRC).toContain('const pairEnabled = isPairAgentEnabled();');
|
||||||
|
expect(CLI_SRC).toContain('const ngrokAvailable = pairEnabled && isNgrokAvailable();');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/tunnel/start refuses with the enable hint when disabled', () => {
|
||||||
|
const startIdx = SERVER_SRC.indexOf("url.pathname === '/tunnel/start'");
|
||||||
|
const block = SERVER_SRC.slice(startIdx, startIdx + 1200);
|
||||||
|
expect(block).toContain('if (!isPairAgentEnabled())');
|
||||||
|
expect(block).toContain('gstack-config set pair_agent on');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BROWSE_TUNNEL=1 startup skips tunnel bind when disabled', () => {
|
||||||
|
expect(SERVER_SRC).toContain("process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -125,7 +125,27 @@ using the generic remote flow instead.
|
|||||||
|
|
||||||
### If different machine (option B):
|
### If different machine (option B):
|
||||||
|
|
||||||
First, detect ngrok status:
|
**Consent gate (once per machine).** The tunnel exposes this browser beyond
|
||||||
|
the machine, so it is OFF until the user opts in — the daemon refuses
|
||||||
|
`/tunnel/start` and `BROWSE_TUNNEL=1` otherwise. Check the standing consent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-config get pair_agent 2>/dev/null || echo "unset"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the value is not `on`, ask via AskUserQuestion (one-way-door posture —
|
||||||
|
this opens a path from the internet to the local browser):
|
||||||
|
|
||||||
|
> "Remote pairing runs an ngrok tunnel from the internet to this machine's
|
||||||
|
> browser (locked to a 26-command allowlist + scoped token, but still an
|
||||||
|
> exposure). Enable pair-agent on this machine?"
|
||||||
|
|
||||||
|
Options: A) Enable — run `~/.claude/skills/gstack/bin/gstack-config set pair_agent on`, confirm it reads back `on`, and continue. B) No — stop here; local pairing (option A above) still works.
|
||||||
|
|
||||||
|
If the value is already `on`, say nothing and continue — consent stands until
|
||||||
|
`gstack-config set pair_agent off`.
|
||||||
|
|
||||||
|
Then detect ngrok status:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
which ngrok 2>/dev/null && echo "NGROK_INSTALLED" || echo "NGROK_NOT_INSTALLED"
|
which ngrok 2>/dev/null && echo "NGROK_INSTALLED" || echo "NGROK_NOT_INSTALLED"
|
||||||
|
|||||||
Reference in New Issue
Block a user