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
45 changes: 45 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!--
gstack is AI-coded and proud of it. The bar is EVIDENCE OF REAL USE, not lines
of code. A PR with no proof behind it gets closed, no matter how clean it looks.
Fill every section below. See CONTRIBUTING.md → "The evidence bar".
-->

## Why (in your own words)

<!-- One paragraph: what breaks for a user today, and what this change does about
it. Not a restatement of the diff. -->

## Live evidence

<!-- REQUIRED. Paste the command(s) you ran and their real output — before and
after. For a bug: the reproduction, failing then fixed. For a skill change: the
actual transcript / `claude -p` output. For anything visual: before/after
screenshots. "bun test passes" alone is not enough — show the behavior you
changed. -->

```
# what you ran + what it produced
```

## Scope

- **Changed:**
- **Verified live by:**
- **Did NOT test:**

## Liveness proof (required)

<!-- Attach a screenshot of your own machine with the text `GSTACK PR` typed LIVE

@cubic-dev-ai cubic-dev-ai Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The liveness proof is replayable across PRs, so it does not establish that a human exercised this submission. Including a per-change value such as the current short commit SHA would tie the screenshot to the PR's actual revision.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/PULL_REQUEST_TEMPLATE.md, line 32:

<comment>The liveness proof is replayable across PRs, so it does not establish that a human exercised this submission. Including a per-change value such as the current short commit SHA would tie the screenshot to the PR's actual revision.</comment>

<file context>
@@ -0,0 +1,45 @@
+
+## Liveness proof (required)
+
+<!-- Attach a screenshot of your own machine with the text `GSTACK PR` typed LIVE
+into a real surface — terminal prompt, a shell command, your browser
+address/search bar, an editor buffer. It must be TYPED INTO A LIVE UI, not drawn,
</file context>
Fix with cubic

into a real surface — terminal prompt, a shell command, your browser
address/search bar, an editor buffer. It must be TYPED INTO A LIVE UI, not drawn,
overlaid, or edited onto the image. A painted-on `GSTACK PR` is an automatic
close. This confirms a human opened this PR. -->

## Checklist

- [ ] Liveness screenshot attached: `GSTACK PR` typed live into a real surface (not edited onto the image)
- [ ] Commits are signed off (`git commit -s`) — DCO
- [ ] 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: #

@cubic-dev-ai cubic-dev-ai Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The "Linked issue or reproduction: #" checklist item mixes a fill-in field with a checkbox, creating ambiguity. The contributor must replace # with an issue number AND decide whether to check the box off. The previous item clearly uses "(or N/A)" to mark something as optional, but this item omits that pattern. As written, if a contributor replaces # with "42", the checkbox text becomes "Linked issue or reproduction: 42" but the unchecked - [ ] syntax remains, leaving it unclear whether checking the checkbox is also expected. Consider moving this outside the checklist as a fill-in section (e.g., "Linked issue: #") like the Scope section does, or adding "(or N/A)" to match the pattern used on the previous row.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/PULL_REQUEST_TEMPLATE.md, line 45:

<comment>The "Linked issue or reproduction: #" checklist item mixes a fill-in field with a checkbox, creating ambiguity. The contributor must replace `#` with an issue number AND decide whether to check the box off. The previous item clearly uses "(or N/A)" to mark something as optional, but this item omits that pattern. As written, if a contributor replaces `#` with "42", the checkbox text becomes "Linked issue or reproduction: 42" but the unchecked `- [ ]` syntax remains, leaving it unclear whether checking the checkbox is also expected. Consider moving this outside the checklist as a fill-in section (e.g., "**Linked issue:** #") like the Scope section does, or adding "(or N/A)" to match the pattern used on the previous row.</comment>

<file context>
@@ -0,0 +1,45 @@
+- [ ] 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: #
</file context>
Fix with cubic

37 changes: 15 additions & 22 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ When a user runs `pair-agent --client`, the daemon starts an ngrok tunnel so a r

The fix is **two HTTP listeners**, not one:

- **Local listener** (`127.0.0.1:LOCAL_PORT`) — always bound. Serves bootstrap (`/health` with token delivery), `/cookie-picker`, `/inspector/*`, `/welcome`, `/refs`, the sidebar-agent API, and the full command surface. Never forwarded.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`) — bound lazily on `/tunnel/start`, torn down on `/tunnel/stop`. Serves a locked allowlist: `/connect` (pairing ceremony, unauth + rate-limited), `/command` (scoped tokens only, further restricted to a browser-driving command allowlist), and `/sidebar-chat`. Everything else 404s.
- **Local listener** (`127.0.0.1:LOCAL_PORT`) — always bound. Serves bootstrap (`/health` with token delivery), `/cookie-picker`, `/inspector/*`, `/welcome`, `/refs`, and the full command surface. Never forwarded.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`) — bound lazily on `/tunnel/start`, torn down on `/tunnel/stop`. Serves a locked allowlist: `/connect` (pairing ceremony, unauth + rate-limited) and `/command` (scoped tokens only, further restricted to a browser-driving command allowlist). Everything else 404s.

ngrok forwards only the tunnel port. The security property comes from **physical port separation**: a tunnel caller cannot reach `/health` or `/cookie-picker` because those paths don't exist on that TCP socket. Header inference (check `x-forwarded-for`, check origin) is unreliable (ngrok header behavior changes; local proxies can add these headers); socket separation isn't.

Expand All @@ -178,7 +178,6 @@ ngrok forwards only the tunnel port. The security property comes from **physical
| `GET /connect` | public (`{alive:true}`) | public (`{alive:true}`) | Probe path for tunnel liveness |
| `POST /connect` | public (rate-limited 300/min) | public (rate-limited) | Setup-key exchange for pair-agent |
| `POST /command` | auth (Bearer root OR scoped) | auth (scoped only, allowlisted commands) | Root token on tunnel = 403 |
| `POST /sidebar-chat` | auth | auth | Lets remote agent post into local sidebar |
| `POST /pair` | root-only | 404 | Pairing mint — local operator action |
| `POST /tunnel/{start,stop}` | root-only | 404 | Daemon configuration |
| `POST /token`, `DELETE /token/:id` | root-only | 404 | Scoped token mint/revoke |
Expand All @@ -190,7 +189,7 @@ ngrok forwards only the tunnel port. The security property comes from **physical
| `GET /inspector/events` | Bearer OR HttpOnly `gstack_sse` cookie | 404 | SSE. Same cookie as /activity/stream |
| `POST /sse-session` | auth (Bearer) | 404 | Mints the view-only 30-min SSE session cookie |

**Tunnel surface denial logs.** Every rejection on the tunnel listener (`path_not_on_tunnel`, `root_token_on_tunnel`, `missing_scoped_token`, `disallowed_command:*`) is recorded asynchronously to `~/.gstack/security/attempts.jsonl` with timestamp, source IP (from `x-forwarded-for`), path, and method. Rate-capped at 60 writes/min globally to prevent log-flood DoS. Shares the attempt log with the prompt-injection scanner.
**Tunnel surface denial logs.** Every rejection on the tunnel listener (`path_not_on_tunnel`, `root_token_on_tunnel`, `missing_scoped_token`, `disallowed_command:*`) is recorded asynchronously to `~/.gstack/security/attempts.jsonl` with timestamp, source IP (from `x-forwarded-for`), path, and method. Rate-capped at 60 writes/min globally to prevent log-flood DoS. Shares the attempt log with the page-content security layers (canary detection).

**SSE session cookies.** EventSource can't send Authorization headers, so the extension POSTs `/sse-session` once at bootstrap with the root Bearer and receives a 30-minute view-only cookie (`gstack_sse`, HttpOnly, SameSite=Strict). The cookie is valid ONLY for `/activity/stream` and `/inspector/events` — it is NOT a scoped token and cannot be used on `/command`. Scope isolation is enforced by the module boundary: `sse-session-cookie.ts` has no imports from `token-registry.ts`.

Expand Down Expand Up @@ -235,31 +234,25 @@ Page content harvested by CDP can contain lone UTF-16 surrogate halves (orphaned

**Architectural invariant.** Every new SSE/WebSocket writer or HTTP response that ships page-content-derived strings MUST go through one of two paths: `JSON.stringify(payload, sanitizeReplacer)` for object payloads, or `sanitizeLoneSurrogates(body)` for text bodies. New surfaces that bypass both will desync the system. Inline comments at both SSE producers in `server.ts` say so; `browse/test/server-sanitize-surrogates.test.ts` pins wiring with bug-repro + invariant tests (`handleCommandInternalImpl` rename, central sanitization line, replacer existence, SSE producers stringify with replacer).

### Prompt injection defense (sidebar agent)
### Page-content security layers

The Chrome sidebar agent has tools (Bash, Read, Glob, Grep, WebFetch) and reads hostile web pages, so it's the part of gstack most exposed to prompt injection. Defense is layered, not single-point.
The browser reads hostile web pages, so page content is the part of gstack most exposed to prompt injection. Defense is layered, not single-point, and every retained layer is pure-string — no ML model, no native runtime.

> **GStack 2 production boundary:** the ML sidecar described below is retained
> as 1.x source and test material only. The managed runtime does not build or
> copy the sidecar, keeps `@huggingface/transformers` development-only, installs
> no ONNX runtime or model weights, and reports L4 unavailable. The generator
> preserves this historical judgment without promoting it into 2.0 setup.
| Layer | Module | Notes |
|-------|--------|-------|
| L1-L3 | `content-security.ts` | datamarking, hidden element strip, ARIA regex, URL blocklist, envelope wrapping |
| L5 | `security.ts` (canary) | inject + check |
| L6 | `security.ts` (combineVerdict) | threshold aggregation |

1. **L1-L3 content security (`browse/src/content-security.ts`).** Runs on every page-content command and every tool output: datamarking, hidden-element strip, ARIA regex, URL blocklist, and a trust-boundary envelope wrapper. Applied at both the server and the agent.
1. **L1-L3 content security (`browse/src/content-security.ts`).** Runs on every page-content command and every tool output: datamarking, hidden-element strip, ARIA regex, URL blocklist, and a trust-boundary envelope wrapper.

@cubic-dev-ai cubic-dev-ai Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Root-token page-content reads do not receive the documented L1-L3 stack, and there is no remaining sidebar tool-output path. Please scope this guarantee to paired/scoped-token commands so the authoritative security model does not overstate protection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ARCHITECTURE.md, line 247:

<comment>Root-token page-content reads do not receive the documented L1-L3 stack, and there is no remaining sidebar tool-output path. Please scope this guarantee to paired/scoped-token commands so the authoritative security model does not overstate protection.</comment>

<file context>
@@ -235,31 +234,25 @@ Page content harvested by CDP can contain lone UTF-16 surrogate halves (orphaned
+| L6 | `security.ts` (combineVerdict) | threshold aggregation |
 
-1. **L1-L3 content security (`browse/src/content-security.ts`).** Runs on every page-content command and every tool output: datamarking, hidden-element strip, ARIA regex, URL blocklist, and a trust-boundary envelope wrapper. Applied at both the server and the agent.
+1. **L1-L3 content security (`browse/src/content-security.ts`).** Runs on every page-content command and every tool output: datamarking, hidden-element strip, ARIA regex, URL blocklist, and a trust-boundary envelope wrapper.
 
-2. **L4 ML classifier — TestSavantAI (`browse/src/security-classifier.ts`).** A 22MB BERT-small ONNX model (int8 quantized) bundled with the agent. Runs locally, no network. Scans every user message and every Read/Glob/Grep/WebFetch tool output before Claude sees it. Opt-in 721MB DeBERTa-v3 ensemble via `GSTACK_SECURITY_ENSEMBLE=deberta`.
</file context>
Suggested change
1. **L1-L3 content security (`browse/src/content-security.ts`).** Runs on every page-content command and every tool output: datamarking, hidden-element strip, ARIA regex, URL blocklist, and a trust-boundary envelope wrapper.
1. **L1-L3 content security (`browse/src/content-security.ts`).** For scoped-token page-content commands, applies datamarking, hidden-element stripping, ARIA checks, the URL blocklist, and a trust-boundary envelope. Root-token page content receives the basic untrusted-content wrapper only.
Fix with cubic


2. **L4 ML classifier — TestSavantAI (`browse/src/security-classifier.ts`).** A 22MB BERT-small ONNX model (int8 quantized) bundled with the agent. Runs locally, no network. Scans every user message and every Read/Glob/Grep/WebFetch tool output before Claude sees it. Opt-in 721MB DeBERTa-v3 ensemble via `GSTACK_SECURITY_ENSEMBLE=deberta`.
2. **L5 canary token (`browse/src/security.ts`).** A random token injected into the system prompt at session start. Rolling-buffer detection across `text_delta` and `input_json_delta` streams catches the token if it shows up anywhere in Claude's output, tool arguments, URLs, or file writes. Deterministic BLOCK — if the token leaks, the attacker convinced Claude to reveal the system prompt, and the session ends.

3. **L4b transcript classifier.** A Claude Haiku pass that looks at the full conversation shape (user message, tool calls, tool output), not just text. Gated by `LOG_ONLY: 0.40` so most clean traffic skips the paid call.
3. **L6 verdict combiner (`combineVerdict`).** Aggregates per-layer verdicts against the thresholds in `security.ts`. Canary leak always BLOCKs.

4. **L5 canary token (`browse/src/security.ts`).** A random token injected into the system prompt at session start. Rolling-buffer detection across `text_delta` and `input_json_delta` streams catches the token if it shows up anywhere in Claude's output, tool arguments, URLs, or file writes. Deterministic BLOCK — if the token leaks, the attacker convinced Claude to reveal the system prompt, and the session ends.
`security.ts` is pure-string (canary, verdict combiner, attack log, status) and safe to import from the compiled `browse/dist/browse` binary — it loads no native modules. The prompt-injection ML classifier (TestSavantAI/DeBERTa ONNX, `security-classifier.ts`) and its in-browser sidebar/terminal caller were removed; there is no L4 layer.

5. **L6 ensemble combiner (`combineVerdict`).** BLOCK requires agreement from two ML classifiers at >= `WARN` (0.75), not a single confident hit. This is the Stack Overflow instruction-writing false-positive mitigation. On tool-output scans, single-layer high confidence BLOCKs directly — the content wasn't user-authored, so the FP concern doesn't apply.

**Critical constraint:** `security-classifier.ts` runs only in the sidebar-agent process, never in the compiled browse binary. `@huggingface/transformers` v4 requires `onnxruntime-node`, which fails `dlopen` from Bun compile's temp extract directory. Only the pure-string pieces (canary inject/check, verdict combiner, attack log, status) are in `security.ts`, which is safe to import from `server.ts`.

**Env knobs:** `GSTACK_SECURITY_OFF=1` is a real kill switch (skips ML scan, canary still injects). Model cache at `~/.gstack/models/testsavant-small/` (112MB, first run) and `~/.gstack/models/deberta-v3-injection/` (721MB, opt-in only). Attack log at `~/.gstack/security/attempts.jsonl` (salted sha256 + domain, rotates at 10MB, 5 generations). Per-device salt at `~/.gstack/security/device-salt` (0600), cached in-process to survive FS-unwritable environments.

**Visibility.** The sidebar header shows a shield icon (green/amber/red) polled via `/sidebar-chat`. A centered banner appears on canary leak or BLOCK verdict with the exact layer scores. `bin/gstack-security-dashboard` aggregates local attempts; `supabase/functions/community-pulse` aggregates opt-in community telemetry across users.
**Env knobs:** `GSTACK_SECURITY_OFF=1` is a kill switch for the content-security scan path (the canary is still injected regardless). Attack log at `~/.gstack/security/attempts.jsonl` (salted sha256 + domain, rotates at 10MB, 5 generations). Per-device salt at `~/.gstack/security/device-salt` (0600), cached in-process to survive FS-unwritable environments. `bin/gstack-security-dashboard` aggregates local attempts.

## The ref system

Expand Down
Loading
Loading