diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..bfcc4a2e2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,44 @@ + + +## Why (in your own words) + + + +## Live evidence + + + +``` +# what you ran + what it produced +``` + +## Scope + +- **Changed:** +- **Verified live by:** +- **Did NOT test:** + +## Liveness proof (required) + + + +## Checklist + +- [ ] Liveness screenshot attached: `GSTACK PR` typed live into a real surface (not edited onto the image) +- [ ] This is not a generated-file-only diff (I edited the source/template and regenerated) +- [ ] No ETHOS.md edits, and no changes to voice / founder perspective / YC references +- [ ] New public command / external service / host adapter has an accepted issue linked (or N/A) +- [ ] Linked issue or reproduction: # diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d6b53e48d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 + +updates: + - package-ecosystem: "bun" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + groups: + production-dependencies: + dependency-type: "production" + development-dependencies: + dependency-type: "development" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + groups: + github-actions: + patterns: + - "*" + open-pull-requests-limit: 2 diff --git a/.github/scripts/gate-secret-scan.mjs b/.github/scripts/gate-secret-scan.mjs new file mode 100644 index 000000000..11a139f30 --- /dev/null +++ b/.github/scripts/gate-secret-scan.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; + +const child = spawn("bun", [ + "bin/gstack-redact", + "--repo-visibility", "public", + "--json", + "--max-bytes", "16000000", +], { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "inherit"] }); +let diff = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { diff += chunk; }); +process.stdin.once("end", () => { + const additions = diff + .split(/\r?\n/) + .filter((line) => line.startsWith("+") && !line.startsWith("+++")) + .map((line) => line.slice(1)) + .join("\n"); + // The scanner may exit before consuming an oversize payload (it refuses + // stdin over --max-bytes and reports oversize:true). EPIPE here is that + // refusal in flight, not a failure — the report + exit code carry the verdict. + child.stdin.on("error", (error) => { + if (error.code !== "EPIPE") throw error; + }); + child.stdin.end(additions); +}); +let stdout = ""; +child.stdout.setEncoding("utf8"); +child.stdout.on("data", (chunk) => { stdout += chunk; }); +child.once("error", (error) => { throw error; }); +child.once("close", (code) => { + let report; + try { + report = JSON.parse(stdout); + } catch { + // No parseable report: the oversize refusal prints only to stderr and + // exits 3, and a crashed scanner emits nothing. Both fail closed. + console.log(`credential scan: 1 high, 0 advisory (scanner emitted no report, exit ${code} — fail-closed)`); + process.exitCode = 1; + return; + } + const high = Number(report.counts?.HIGH ?? 0); + const medium = Number(report.counts?.MEDIUM ?? 0); + console.log(`credential scan: ${high} high, ${medium} advisory`); + process.exitCode = high > 0 || report.oversize || ![0, 2, 3].includes(code) ? 1 : 0; +}); diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..c69dad8d1 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,32 @@ +name: Dependency Review + +on: + pull_request: + paths: + - 'package.json' + - 'bun.lock' + - '**/package.json' + - '**/bun.lock' + - '.github/workflows/**' + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubicloud-standard-8 + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + fail-on-severity: high + fail-on-scopes: runtime, development + comment-summary-in-pr: on-failure diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index 3b30271e6..d90ad365f 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -15,6 +15,12 @@ env: jobs: # Build Docker image with pre-baked toolchain (cached — only rebuilds on Dockerfile/lockfile change) build-image: + # Dependabot-triggered pull_request runs get a read-only GITHUB_TOKEN, so + # a lockfile bump = new hash = failed ghcr push = permanently red check + # (EV6, fork port wave 2). Skip the build for dependabot; the evals job's + # needs-chain tolerates it because no eval test selects on a lockfile-only + # diff — a maintainer's next push rebuilds the image with real perms. + if: github.actor != 'dependabot[bot]' runs-on: ubicloud-standard-8 permissions: contents: read diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml new file mode 100644 index 000000000..22b8ae6eb --- /dev/null +++ b/.github/workflows/osv-scanner.yml @@ -0,0 +1,26 @@ +name: OSV Scanner + +on: + schedule: + - cron: '23 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: osv-scanner + cancel-in-progress: true + +jobs: + scan: + permissions: + actions: read + contents: read + security-events: write + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@3adb4b14a2b0623876d18d863a498b785fb3752d # v2.3.8 + with: + scan-args: |- + --include-git-root + --recursive + ./ diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml new file mode 100644 index 000000000..af1c6fb12 --- /dev/null +++ b/.github/workflows/quality-gate.yml @@ -0,0 +1,77 @@ +# Quality gate (fork port wave 2, adapted from time-attack/gstack GStack 2). +# +# Three generic hygiene checks the repo previously had nowhere in CI: +# 1. Credential scan of the PR diff's ADDED lines through our own +# bin/gstack-redact (HIGH fails the check; MEDIUM is an advisory count — +# there is no human in CI to confirm, so it never fails here). +# 2. bun audit at critical severity. +# 3. ShellCheck (errors only) on the setup/build shell boundary. +# +# Trigger is `pull_request`, NEVER `pull_request_target`: fork PRs must not +# get secret-bearing contexts. Diff excludes cover the planted-bug fixtures +# and eval baselines that intentionally contain credential-shaped strings. +name: Quality gate + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: quality-gate-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubicloud-standard-8 + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Install frozen dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Scan changed text for credentials (added lines, own redact engine) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + BASE_SHA=$(git rev-parse HEAD^) + fi + git diff --unified=0 --no-color "$BASE_SHA" "$HEAD_SHA" -- \ + . \ + ':(exclude)test/fixtures/**' \ + ':(exclude)browse/test/fixtures/**' \ + ':(exclude)docs/evals/**' \ + ':(exclude)test/helpers/security-bench*' \ + | node .github/scripts/gate-secret-scan.mjs + + - name: Gate critical dependency advisories + run: bun audit --audit-level=critical + + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + shellcheck --version + + - name: ShellCheck setup and build boundaries + run: >- + shellcheck --severity=error + setup + scripts/build.sh + scripts/build-app.sh + scripts/write-version-files.sh + browse/scripts/build-node-server.sh diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index c05a7d002..7435814cc 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -111,6 +111,8 @@ jobs: browse/test/claude-bin.test.ts \ test/test-free-shards.test.ts \ browse/test/file-permissions.test.ts \ + browse/test/bun-polyfill.test.ts \ + browse/test/windows-spawn-hide.test.ts \ browse/test/security.test.ts \ browse/test/server-sanitize-surrogates.test.ts \ test/setup-windows-fallback.test.ts \ diff --git a/.gitignore b/.gitignore index 5196c0d05..ee813182c 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,8 @@ docs/throughput-*.json # gbrain local source-staging dir (capability checks, source clones) — runtime artifact .sources/ + +# SPM build output from the gen-accessors tool (built in place by +# skill-e2e-ios-swift-build; regenerates on every run — never commit) +ios-qa/scripts/gen-accessors-tool/.build/ +ios-qa/scripts/gen-accessors-tool/Package.resolved diff --git a/.osv-scanner.toml b/.osv-scanner.toml new file mode 100644 index 000000000..56ca32123 --- /dev/null +++ b/.osv-scanner.toml @@ -0,0 +1,15 @@ +# OSV-Scanner configuration. +# Direct/transitive dependency versions are pinned to their fixed releases via +# the `overrides` block in package.json; this file only records advisories we +# have assessed as not-reachable or not-fixable without disproportionate risk. + +[[IgnoredVulns]] +id = "GHSA-frvp-7c67-39w9" +# @hono/node-server 1.19.x. Reachable only through @modelcontextprotocol/sdk, +# which is an unused transitive dependency (no source file imports it) and never +# starts a Hono HTTP server, so the advisory's request path is not exercised. +# The only fix is @hono/node-server 2.0.5, a major bump the MCP SDK pins against +# (^1.19.9); forcing it via override risks breaking the SDK at runtime for a +# vulnerability we do not expose. Re-evaluate if the MCP SDK becomes a direct, +# server-hosting dependency. +reason = "Unreachable transitive (unused @modelcontextprotocol/sdk); fix requires a risky major override on a pinned peer dep." diff --git a/BROWSER.md b/BROWSER.md index 046395080..1ab7a1e60 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -1091,6 +1091,19 @@ $B state load my-session # restore In-memory `load-html` content is intentionally NOT persisted (avoid leaking secrets to disk). +Manual save/load is one-shot. For state that survives daemon restarts +automatically, opt in with `BROWSE_PERSIST_STATE=1` in the daemon's +environment: the headless daemon snapshots cookies + per-tab +URL/localStorage/sessionStorage to `/session-state.json` (0600, +atomic writes) every 30 seconds and at clean shutdown, then restores it off +the boot path on the next launch. Default OFF — cookies on disk are a real +cost, so the user opts in. Headless only (headed mode's persistent Chromium +profile already owns its state). Loaded HTML and tab ownership are never +persisted, cookies for localhost, `.internal`, loopback IP literals +(127.0.0.0/8, `::1`), and link-local/cloud-metadata addresses +(169.254.0.0/16) are dropped on restore, and a corrupt snapshot is quarantined to +`session-state.json.corrupt` so persistence can never block a launch. + ### Watch ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index f30522fe4..ec29ae520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,195 @@ # Changelog +## [1.65.0.0] - 2026-08-14 + +**/autoplan, /codex on macOS, and memory ingest work again.** +**And every consent gate now means what it says.** + +This is the second and final wave of the GStack 2 fork port. Wave one (v1.63.0.0) +took the audit infrastructure; this wave takes the fixes and the features. Three +skills that failed silently on every run now work: /autoplan's task aggregation +emits real tasks instead of zero, /codex creates its temp files on macOS instead +of dying on BSD mktemp, and memory ingest actually imports pages on current +gbrain builds, and prints the count so you can see it. On top of that: your +browser auth can now survive a daemon restart, /ship can take an iOS app from +working tree to Submit for Review, and four supply-chain gates now run on every +PR. Nearly all of it traces back to Sina Matian's time-attack/gstack fork, ported +with tests and attribution. + +### The numbers that matter + +Source: this branch (`git log 1.63.0.0..HEAD`, `git diff main...HEAD --stat`, +`bun test`), plus the GitHub issues the release closes. + +| What | Before | After | +|------|--------|-------| +| /autoplan Phase 4 task output (#2018) | 0 tasks, every run | every task | +| /codex on macOS (#2091) | broken on every install | works | +| Memory ingest on gbrain 0.42+ (#2144) | 0 pages, reported success | full corpus, count printed | +| Headed browse on macOS 26 (#2242) | GPU crash, poisoned cache | launches, heals old caches | +| Auth after a browse daemon restart (#778) | logged out | restored (opt-in) | +| CI secret scanning on PR diffs | none | every PR, fail-closed | +| GitHub issues closed | | 24 | +| Community PRs landed with authorship | | 4 | + +The stark one is the first three rows: those aren't degraded features, they were +features returning empty results with a green checkmark. If you ran /autoplan in +the last two months, the task list it handed off was empty and nothing told you. + +### What this means for gstack users + +Run /autoplan and the pipeline hands real tasks to the executor. Run /codex on a +Mac and it just works. Set `BROWSE_PERSIST_STATE=1` and a daemon restart no +longer logs you out of every site. If you ship an iOS app, `/ship` now knows the +whole App Store journey, session-minted upload keys, the price-schedule API that +replaced the broken fastlane path, error -22938 triage, one authorization moment +instead of five. Upgrade with `/gstack-upgrade`; the migration cleans any +Chromium bundle an older gstack broke and verifies the replacement download +before it claims success. + +### Itemized changes + +#### Added + +- **Opt-in browser session persistence** (#778, #2193): `BROWSE_PERSIST_STATE=1` + snapshots cookies and tabs (atomic writes, 0600, never page HTML or + ownership), restores them off the boot path on the next start, and quarantines + a corrupt snapshot instead of crashing. Portions from time-attack/gstack. +- **Apple App Store release journey for /ship**: `ship/sections/apple-release.md` + loads before the repo-landing gates when the target is an Apple app. Encodes + session-minted App Store Connect keys, `appPriceSchedules` over the broken + fastlane `price_tier`, expanded age-rating attributes, -22938 classification, + and a one-authorization-moment flow. Refined across 21 live releases on the + fork. Portions copyright Sina Matian, MIT. +- **Code-intelligence provider contract, Phase 1**: `gstack-code-intelligence` + wraps GBrain, Sourcebot, and Graphify behind one interface with an ask-once + indexing offer for large repos (1,000+ tracked files, decline persisted). + Consent is explicit per repo (`consent yes|no`), the per-repo trust + policy's deny and read-only tiers veto write-class operations no matter what + consent was recorded, and every off-machine send writes an egress receipt that + records the consent state actually checked. Portions from time-attack/gstack. +- **Supply-chain CI**: a quality gate that runs `bin/gstack-redact` over every + PR diff (HIGH findings fail, MEDIUM annotates), dependency review on + lockfile changes, weekly OSV scans, grouped dependabot updates, and an + evidence-bar PR template. Every third-party action in the new workflows is + pinned to a commit SHA. +- **Third-party web-actions contract** in tier-2+ skills: when a workflow needs + a vendor-site step (API key signup, OAuth app), gstack offers to drive the + browser itself, hands credentials and CAPTCHAs to you, stores secrets + owner-only, and verifies with a read-only call before claiming success. +- **Design docs land in your repo** (#703, #2000): office-hours writes + `docs/designs/.md` as a concise decision record (one bullet per + decision with its why), redaction-scanned before anything touches your git + history. Plan reviews prefer the repo-local doc when both exist. +- **`gstack-verify-gate`** (opt-in Stop hook): blocks turn-end until the + CLAUDE.md-declared verify command passes. A command runs only after you trust + it once per repo (`--trust`), re-trust is required when it changes, every + grant is audit-logged, and re-entries re-run the check instead of waving it + through. +- **"Never show me these again"** for the founder-resources pitch (#538): the + opt-out verifies its own config write before promising anything. Re-enable + with `gstack-config set founder_resources true`. +- **Claimed limitations need evidence**: every tier-2+ skill now treats "the + API can't do this" as a material claim requiring the verbatim error, the + documented statement, or a live probe, and runs the ten-second check before + declaring anything blocked. + +#### Fixed + +- **/autoplan Phase 4 emitted zero tasks on every run** (#2018): a jq context + rebind dropped every aggregated task; the error was hidden by stderr + suppression. Six-fixture regression suite pins it. +- **/codex was broken on every macOS install** (#2091): BSD mktemp rejects + suffixed templates; all temp files now use portable templates and a static + test bans the pattern repo-wide. +- **Memory ingest imported nothing on gbrain 0.42+** (#2144): the staging dir + sits under a gitignored tree, so git-aware collectors saw zero files. Fixed + with `--include-gitignored` (community PR #2560) plus a `GIT_CEILING_DIRECTORIES` + second layer, Windows-safe, and a loud ingested-page count. +- **Headed mode on macOS 26** (#2242, #2138, #2139): gstack no longer rewrites + the signed Chrome-for-Testing bundle (the rebrand broke its code signature; + GPU processes refused to start). Launch self-heals poisoned caches, on both + headed entry points, by removing the whole revision directory so the re-fetch + actually re-downloads, and the upgrade migration does the same for existing + installs, verifying a working Chromium exists before recording success. + Branding stays on the GStack Browser wrapper app. +- **`browse stop` restarted the daemon it was told to stop**: the CLI now gets + an acknowledgment before shutdown, and the shutdown snapshot has a hard + deadline so a wedged page can never hold the port. +- **Session cookies from internal networks never reach a restored browser**: + the restore-time hygiene filter drops loopback and link-local IP literals + (127.0.0.1, ::1, 169.254.*) alongside localhost and *.internal, shared by + both the persistence path and `state load`. +- **ios-qa stopped handing out raw bearer tokens**: `/auth/sessions` returns + salted-hash token ids with revoke-by-id support, the boot token left os_log + entirely, and the IPv4 listener pins to loopback at the socket. +- **make-pdf's no-network promise holds against obfuscation**: ``; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("@import"); + expect(out).not.toContain("evil.example"); + expect(out).toContain("color: red"); + }); + + test("strips string-form @import (no url())", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("@import"); + expect(out).not.toContain("evil.example"); + }); + + test("neutralizes remote url() inside `; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("evil.example"); + expect(out).toContain("url(#)"); + expect(out).toContain("color: blue"); + }); + + test("neutralizes remote url() in inline style attributes", () => { + const input = `
x
`; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("evil.example"); + expect(out).toContain("url(#)"); + expect(out).toContain("padding:4px"); + }); + + test("neutralizes protocol-relative url(//…) in style attributes", () => { + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).not.toContain("evil.example"); + }); + + // ── Bypass regressions: unquoted style attributes ── + // HTML spec: an unquoted attribute value runs until whitespace or `>`, so + //
is live markup Chromium honors. + // The original neutralizer only rewrote quoted values. + + test("neutralizes remote url() in UNQUOTED style attributes", () => { + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).not.toContain("evil.example"); + expect(out).toContain("url(#)"); + }); + + test("keeps local url() in unquoted style attributes functional", () => { + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).toContain("url(local.png)"); + }); + + // ── Bypass regressions: CSS-escape obfuscation ── + // Chromium decodes CSS ident/string escapes before fetching, so \69 → i and + // \68 → h defeat literal-pattern matching. Untrusted styling has no + // legitimate need for escaped url schemes or at-rule names — fail closed. + + test("drops CSS-escaped @import (@\\69mport url(...)) in `); + expect(out).not.toContain("evil.example"); + expect(out).not.toMatch(/@\\/); // no escaped at-rule survives for Chromium to decode + }); + + test("drops CSS-escaped string-form @import (@\\69mport \"https://…\")", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("evil.example"); + expect(out).not.toMatch(/@\\/); + }); + + test("neutralizes CSS-escaped scheme inside url() (\\68ttps://…)", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("evil.example"); + }); + + test("neutralizes CSS-escaped function names (u\\72l(https://…))", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("evil.example"); + }); + + test("neutralizes HTML-entity-encoded backslash escapes in style attributes", () => { + // Attribute values are entity-decoded by the HTML parser before the CSS + // parser runs, so \68ttps reaches Chromium as \68ttps → https. + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).not.toContain("evil.example"); + }); + + // ── Bypass regressions: non-backslash entity obfuscation in style attrs ── + // The same attribute entity layer can hide ANY character of a fetch vector, + // not just backslashes: h → h, / → /. `; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("evil.example"); + expect(out).toContain("url(#)"); + expect(out).toContain("color: blue"); + }); + + test("neutralizes remote image-set(...) in style attributes", () => { + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).not.toContain("evil.example"); + }); + + test("neutralizes -webkit-image-set with a remote string argument", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("evil.example"); + }); + + test("keeps local image-set(...) functional", () => { + const input = ``; + expect(sanitizeUntrustedHtml(input)).toContain(`image-set("local.png" 1x, "local@2x.png" 2x)`); + }); + + test("neutralizes remote