Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
# Changelog

## [0.15.15.0] - 2026-04-06

Community security wave: 8 PRs from 4 contributors, every fix credited as co-author.

### Added
- Cookie value redaction for tokens, API keys, JWTs, and session secrets in `browse cookies` output. Your secrets no longer appear in Claude's context.
- IPv6 ULA prefix blocking (fc00::/7) in URL validation. Covers the full unique-local range, not just the literal `fd00::`. Hostnames like `fcustomer.com` are not false-positived.
- Per-tab cancel signaling for sidebar agents. Stopping one tab's agent no longer kills all tabs.
- Parent process watchdog for the browse server. When Claude Code exits, orphaned browser processes now self-terminate within 15 seconds.
- Uninstall instructions in README (script + manual removal steps).
- CSS value validation blocks `url()`, `expression()`, `@import`, `javascript:`, and `data:` in style commands, preventing CSS injection attacks.
- Queue entry schema validation (`isValidQueueEntry`) with path traversal checks on `stateFile` and `cwd`.
- Viewport dimension clamping (1-16384) and wait timeout clamping (1s-300s) prevent OOM and runaway waits.
- Cookie domain validation in `cookie-import` prevents cross-site cookie injection.
- DocumentFragment-based tab switching in sidebar (replaces innerHTML round-trip XSS vector).
- `pollInProgress` reentrancy guard prevents concurrent chat polls from corrupting state.
- 750+ lines of new security regression tests across 4 test files.
- Supabase migration 003: column-level GRANT restricts anon UPDATE to (last_seen, gstack_version, os) only.

### Fixed
- Windows: `extraEnv` now passes through to the Windows launcher (was silently dropped).
- Windows: welcome page serves inline HTML instead of `about:blank` redirect (fixes ERR_UNSAFE_REDIRECT).
- Headed mode: auth token returned even without Origin header (fixes Playwright Chromium extensions).
- `frame --url` now escapes user input before constructing RegExp (ReDoS fix).
- Annotated screenshot path validation now resolves symlinks (was bypassable via symlink traversal).
- Auth token removed from health broadcast, delivered via targeted `getToken` handler instead.
- `/health` endpoint no longer exposes `currentUrl` or `currentMessage`.
- Session ID validated before use in file paths (prevents path traversal via crafted active.json).
- SIGTERM/SIGKILL escalation in sidebar agent timeout handler (was bare `kill()`).

### For contributors
- Queue files created with 0o700/0o600 permissions (server, CLI, sidebar-agent).
- `escapeRegExp` utility exported from meta-commands.
- State load filters cookies from localhost, .internal, and metadata domains.
- Telemetry sync logs upsert errors from installation tracking.

## [0.15.14.0] - 2026-04-05

### Fixed
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,59 @@ gstack skills have voice-friendly trigger phrases. Say what you want naturally
"run a security check", "test the website", "do an engineering review" — and the
right skill activates. You don't need to remember slash command names or acronyms.

## Uninstall

### Option 1: Run the uninstall script

If gstack is installed on your machine:

```bash
~/.claude/skills/gstack/bin/gstack-uninstall
```

This handles skills, symlinks, global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics. Use `--force` to skip confirmation.

### Option 2: Manual removal (no local repo)

If you don't have the repo cloned (e.g. you installed via a Claude Code paste and later deleted the clone):

```bash
# 1. Stop browse daemons
pkill -f "gstack.*browse" 2>/dev/null || true

# 2. Remove per-skill symlinks pointing into gstack/
find ~/.claude/skills -maxdepth 1 -type l 2>/dev/null | while read -r link; do
case "$(readlink "$link" 2>/dev/null)" in gstack/*|*/gstack/*) rm -f "$link" ;; esac
done

# 3. Remove gstack
rm -rf ~/.claude/skills/gstack

# 4. Remove global state
rm -rf ~/.gstack

# 5. Remove integrations (skip any you never installed)
rm -rf ~/.codex/skills/gstack* 2>/dev/null
rm -rf ~/.factory/skills/gstack* 2>/dev/null
rm -rf ~/.kiro/skills/gstack* 2>/dev/null
rm -rf ~/.openclaw/skills/gstack* 2>/dev/null

# 6. Remove temp files
rm -f /tmp/gstack-* 2>/dev/null

# 7. Per-project cleanup (run from each project root)
rm -rf .gstack .gstack-worktrees .claude/skills/gstack 2>/dev/null
rm -rf .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null
```

### Clean up CLAUDE.md

The uninstall script does not edit CLAUDE.md. In each project where gstack was added, remove the `## gstack` and `## Skill routing` sections.

### Playwright

`~/Library/Caches/ms-playwright/` (macOS) is left in place because other tools may share it. Remove it if nothing else needs it.

---

Free, MIT licensed, open source. No premium tier, no waitlist.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.15.14.0
0.15.15.0
13 changes: 7 additions & 6 deletions bin/gstack-learnings-search
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,14 @@ if [ ${#FILES[@]} -eq 0 ]; then
fi

# Process all files through bun for JSON parsing, decay, dedup, filtering
cat "${FILES[@]}" 2>/dev/null | bun -e "
GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" \
cat "${FILES[@]}" 2>/dev/null | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun -e "
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
const now = Date.now();
const type = '${TYPE}';
const query = '${QUERY}'.toLowerCase();
const limit = ${LIMIT};
const slug = '${SLUG}';
const type = process.env.GSTACK_SEARCH_TYPE || '';
const query = (process.env.GSTACK_SEARCH_QUERY || '').toLowerCase();
const limit = parseInt(process.env.GSTACK_SEARCH_LIMIT || '10', 10);
const slug = process.env.GSTACK_SEARCH_SLUG || '';

const entries = [];
for (const line of lines) {
Expand All @@ -67,7 +68,7 @@ for (const line of lines) {

// Determine if this is from the current project or cross-project
// Cross-project entries are tagged for display
e._crossProject = !line.includes(slug) && '${CROSS_PROJECT}' === 'true';
e._crossProject = !line.includes(slug) && process.env.GSTACK_SEARCH_CROSS === 'true';

entries.push(e);
} catch {}
Expand Down
5 changes: 5 additions & 0 deletions bin/gstack-telemetry-sync
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ case "$HTTP_CODE" in
# Advance by SENT count (not inserted count) because we can't map inserted back to
# source lines. If inserted==0, something is systemically wrong — don't advance.
INSERTED="$(grep -o '"inserted":[0-9]*' "$RESP_FILE" 2>/dev/null | grep -o '[0-9]*' || echo "0")"
# Check for upsert errors (installation tracking failures) — log but don't block cursor advance
UPSERT_ERRORS="$(grep -o '"upsertErrors"' "$RESP_FILE" 2>/dev/null || true)"
if [ -n "$UPSERT_ERRORS" ]; then
echo "[gstack-telemetry-sync] Warning: installation upsert errors in response" >&2
fi
if [ "${INSERTED:-0}" -gt 0 ] 2>/dev/null; then
NEW_CURSOR=$(( CURSOR + COUNT ))
echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true
Expand Down
8 changes: 4 additions & 4 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,11 +826,11 @@ export class BrowserManager {
// a tampered URL could navigate to cloud metadata endpoints or file:// URIs.
try {
await validateNavigationUrl(saved.url);
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
} catch {
// Invalid URL in saved state — skip navigation, leave blank page
console.log(`[browse] restoreState: skipping unsafe URL: ${saved.url}`);
} catch (err: any) {
console.warn(`[browse] Skipping invalid URL in state file: ${saved.url} — ${err.message}`);
continue;
}
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
}

if (saved.storage) {
Expand Down
6 changes: 6 additions & 0 deletions browse/src/cdp-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,12 @@ export async function modifyStyle(
throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`);
}

// Validate CSS value — block data exfiltration patterns
const DANGEROUS_CSS = /url\s*\(|expression\s*\(|@import|javascript:|data:/i;
if (DANGEROUS_CSS.test(value)) {
throw new Error('CSS value rejected: contains potentially dangerous pattern.');
}

let oldValue = '';
let source = 'inline';
let sourceLine = 0;
Expand Down
10 changes: 7 additions & 3 deletions browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,17 +232,18 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
// when the CLI exits, the server dies with it. Use Node's child_process.spawn
// with { detached: true } instead, which is the gold standard for Windows
// process independence. Credit: PR #191 by @fqueiro.
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...(extraEnv || {}) });
const launcherCode =
`const{spawn}=require('child_process');` +
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
`{BROWSE_STATE_FILE:${JSON.stringify(config.stateFile)}})}).unref()`;
`${extraEnvStr})}).unref()`;
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
} else {
// macOS/Linux: Bun.spawn + unref works correctly
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv },
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...extraEnv },
});
proc.unref();
}
Expand Down Expand Up @@ -587,7 +588,10 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
}
// Clear old agent queue
const agentQueue = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
try { fs.writeFileSync(agentQueue, ''); } catch {}
try {
fs.mkdirSync(path.dirname(agentQueue), { recursive: true, mode: 0o700 });
fs.writeFileSync(agentQueue, '', { mode: 0o600 });
} catch {}

// Resolve browse binary path the same way — execPath-relative
let browseBin = path.resolve(__dirname, '..', 'dist', 'browse');
Expand Down
61 changes: 51 additions & 10 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,40 @@ import { resolveConfig } from './config';
import type { Frame } from 'playwright';

// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
// Resolve safe directories through realpathSync to handle symlinks (e.g., macOS /tmp → /private/tmp)
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()].map(d => {
try { return fs.realpathSync(d); } catch { return d; }
});

export function validateOutputPath(filePath: string): void {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));

// Resolve real path of the parent directory to catch symlinks.
// The file itself may not exist yet (e.g., screenshot output).
let dir = path.dirname(resolved);
let realDir: string;
try {
realDir = fs.realpathSync(dir);
} catch {
try {
realDir = fs.realpathSync(path.dirname(dir));
} catch {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
}

const realResolved = path.join(realDir, path.basename(resolved));
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realResolved, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
}

/** Escape special regex metacharacters in a user-supplied string to prevent ReDoS. */
export function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** Tokenize a pipe segment respecting double-quoted strings. */
function tokenizePipeSegment(segment: string): string[] {
const tokens: string[] = [];
Expand Down Expand Up @@ -195,9 +219,10 @@ export async function handleMetaCommand(

for (const vp of viewports) {
await page.setViewportSize({ width: vp.width, height: vp.height });
const path = `${prefix}-${vp.name}.png`;
await page.screenshot({ path, fullPage: true });
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
const screenshotPath = `${prefix}-${vp.name}.png`;
validateOutputPath(screenshotPath);
await page.screenshot({ path: screenshotPath, fullPage: true });
results.push(`${vp.name} (${vp.width}x${vp.height}): ${screenshotPath}`);
}

// Restore original viewport
Expand Down Expand Up @@ -238,7 +263,11 @@ export async function handleMetaCommand(
try {
let result: string;
if (WRITE_COMMANDS.has(name)) {
result = await handleWriteCommand(name, cmdArgs, bm);
if (bm.isWatching()) {
result = 'BLOCKED: write commands disabled in watch mode';
} else {
result = await handleWriteCommand(name, cmdArgs, bm);
}
lastWasWrite = true;
} else if (READ_COMMANDS.has(name)) {
result = await handleReadCommand(name, cmdArgs, bm);
Expand Down Expand Up @@ -443,8 +472,8 @@ export async function handleMetaCommand(

for (const msg of messages) {
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
lines.push(`${ts} ${msg.url}`);
lines.push(` "${msg.userMessage}"`);
lines.push(`${ts} ${wrapUntrustedContent(msg.url, 'inbox-url')}`);
lines.push(` "${wrapUntrustedContent(msg.userMessage, 'inbox-message')}"`);
lines.push('');
}

Expand Down Expand Up @@ -495,6 +524,18 @@ export async function handleMetaCommand(
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
throw new Error('Invalid state file: expected cookies and pages arrays');
}
// Validate and filter cookies — reject malformed or internal-network cookies
const validatedCookies = data.cookies.filter((c: any) => {
if (typeof c !== 'object' || !c) return false;
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
if (typeof c.domain !== 'string' || !c.domain) return false;
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
return true;
});
if (validatedCookies.length < data.cookies.length) {
console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
}
// Warn on state files older than 7 days
if (data.savedAt) {
const ageMs = Date.now() - new Date(data.savedAt).getTime();
Expand All @@ -507,7 +548,7 @@ export async function handleMetaCommand(
bm.setFrame(null);
await bm.closeAllPages();
await bm.restoreState({
cookies: data.cookies,
cookies: validatedCookies,
pages: data.pages.map((p: any) => ({ ...p, storage: null })),
});
return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`;
Expand Down Expand Up @@ -535,7 +576,7 @@ export async function handleMetaCommand(
frame = page.frame({ name: args[1] });
} else if (target === '--url') {
if (!args[1]) throw new Error('Usage: frame --url <pattern>');
frame = page.frame({ url: new RegExp(args[1]) });
frame = page.frame({ url: new RegExp(escapeRegExp(args[1])) });
} else {
// CSS selector or @ref for the iframe element
const resolved = await bm.resolveRef(target);
Expand Down
13 changes: 12 additions & 1 deletion browse/src/read-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';

// Redaction patterns for sensitive cookie/storage values — exported for test coverage
export const SENSITIVE_COOKIE_NAME = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i;
export const SENSITIVE_COOKIE_VALUE = /^(eyJ|sk-|sk_live_|sk_test_|pk_live_|pk_test_|rk_live_|sk-ant-|ghp_|gho_|github_pat_|xox[bpsa]-|AKIA[A-Z0-9]{16}|AIza|SG\.|Bearer\s|sbp_)/;

/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
function hasAwait(code: string): boolean {
const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
Expand Down Expand Up @@ -300,7 +304,14 @@ export async function handleReadCommand(

case 'cookies': {
const cookies = await page.context().cookies();
return JSON.stringify(cookies, null, 2);
// Redact cookie values that look like secrets (consistent with storage redaction)
const redacted = cookies.map(c => {
if (SENSITIVE_COOKIE_NAME.test(c.name) || SENSITIVE_COOKIE_VALUE.test(c.value)) {
return { ...c, value: `[REDACTED — ${c.value.length} chars]` };
}
return c;
});
return JSON.stringify(redacted, null, 2);
}

case 'storage': {
Expand Down
Loading
Loading