Skip to content

Commit 4ad55e2

Browse files
committed
Merge remote-tracking branch 'origin/main' into time-attack/wave-test-infra-isolated-review
2 parents 7efb596 + 2beb636 commit 4ad55e2

23 files changed

Lines changed: 681 additions & 96 deletions

bin/gstack-config

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,12 @@ lookup_default() {
134134
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
135135
redact_prepush_hook) echo "false" ;;
136136
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
137-
# brain_trust_policy@<hash> — unset on fresh install; setup-gbrain
137+
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
138138
# writes 'personal' for local engines,
139139
# asks the user for remote-ambiguous.
140140
# salience_allowlist — empty falls through to
141141
# SALIENCE_DEFAULT_ALLOWLIST (D9).
142-
# user_slug_at_<hash> — empty triggers resolve-user-slug
142+
# user_slug_at_<endpoint-id> — empty triggers resolve-user-slug
143143
# fallback chain (D4 A3) on first call.
144144
brain_trust_policy*) echo "unset" ;;
145145
salience_allowlist) echo "" ;;
@@ -260,14 +260,16 @@ resolve_user_slug() {
260260
case "${1:-}" in
261261
get)
262262
KEY="${2:?Usage: gstack-config get <key>}"
263-
# Validate key (alphanumeric + underscore + optional @<hash> suffix for
264-
# endpoint-namespaced keys introduced by the brain-aware planning layer)
265-
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then
266-
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<hex-hash> suffix" >&2
263+
# Validate key (alphanumeric + underscore + optional @<endpoint-id> suffix for
264+
# endpoint-namespaced keys introduced by the brain-aware planning layer).
265+
# Endpoint ids are sha8/sha16 hex for remote MCP URLs, or the literal
266+
# "local" for stdio/PGLite engines (see endpoint_hash).
267+
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
268+
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<endpoint-id> suffix" >&2
267269
exit 1
268270
fi
269-
# Use literal match for keys containing @ (sha hashes), regex otherwise
270-
VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-f0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
271+
# Use literal match for keys containing @ (endpoint ids), regex otherwise
272+
VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-zA-Z0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
271273
if [ -z "$VALUE" ]; then
272274
VALUE=$(lookup_default "$KEY")
273275
fi
@@ -276,9 +278,10 @@ case "${1:-}" in
276278
set)
277279
KEY="${2:?Usage: gstack-config set <key> <value>}"
278280
VALUE="${3:?Usage: gstack-config set <key> <value>}"
279-
# Validate key (alphanumeric + underscore + optional @<hash> suffix)
280-
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then
281-
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<hex-hash> suffix" >&2
281+
# Validate key (alphanumeric + underscore + optional @<endpoint-id> suffix).
282+
# Accepts hex hashes and the literal "local" from endpoint_hash.
283+
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
284+
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<endpoint-id> suffix" >&2
282285
exit 1
283286
fi
284287
# Validate brain_trust_policy value domain (D4 / D11)

bin/gstack-gbrain-detect

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* "gstack_brain_sync_mode": "off"|"artifacts-only"|"full",
1919
* "gstack_brain_git": true|false,
2020
* "gstack_artifacts_remote": "https://..." | "",
21-
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"timeout",
21+
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"engine-locked"|"timeout",
2222
* "gbrain_pooler_mode": "transaction"|"session"|null
2323
* }
2424
*

bin/gstack-gbrain-sync.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,7 @@ function dreamMarkerPid(): number | null {
717717
* missing-config → "no local engine; run /setup-gbrain to add local PGLite"
718718
* broken-config → "config file at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5"
719719
* broken-db → "config points at unreachable DB; see /setup-gbrain Step 1.5"
720+
* engine-locked → PGLite is busy; stop its holder or sync outside the live session
720721
* timeout → kept for Record totality; stages PROCEED on timeout (#1964)
721722
* via the gate's warnProbeTimeout path, never this skip.
722723
*/
@@ -733,6 +734,8 @@ function skipStageForLocalStatus(
733734
"config at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5",
734735
"broken-db":
735736
"config points at unreachable DB; see /setup-gbrain Step 1.5",
737+
"engine-locked":
738+
"PGLite is busy (often held by gbrain serve); stop the holding process or run /sync-gbrain outside the live Claude session, then retry",
736739
"timeout":
737740
"engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow",
738741
};

bin/gstack-repo-mode

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,20 @@
1212
set -euo pipefail
1313

1414
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15-
# Compute SLUG directly (avoid eval of gstack-slug — branch names can contain shell metacharacters)
15+
# Keep the no-remote early exit before consulting gstack-slug. gstack-slug falls
16+
# back to the directory basename when origin is missing, which would incorrectly
17+
# make local-only repos eligible for classification.
1618
REMOTE_URL=$(git remote get-url origin 2>/dev/null || true)
1719
if [ -z "$REMOTE_URL" ]; then
1820
echo "REPO_MODE=unknown"
1921
exit 0
2022
fi
21-
SLUG=$(echo "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-')
23+
# Consume the canonical cached slug from gstack-slug (sed, never eval — branch
24+
# names can contain shell metacharacters). Invoke from the git root so the
25+
# PWD-keyed slug cache matches other gstack project-state writers.
26+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
27+
[ -z "${REPO_ROOT:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
28+
SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" | sed -n 's/^SLUG=//p' | head -1)
2229
[ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
2330

2431
# Validate: only allow known values (prevent shell injection via source <(...))

browse/src/content-security.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,19 +338,28 @@ const BLOCKLIST_DOMAINS = [
338338
export function urlBlocklistFilter(content: string, url: string, _command: string): ContentFilterResult {
339339
const warnings: string[] = [];
340340

341+
// Matching is case-insensitive. Hostnames and URL schemes are
342+
// case-insensitive (RFC 3986), so an uppercased sink like
343+
// https://WEBHOOK.SITE/x must not slip past the blocklist. Normalize the
344+
// haystack to lowercase; BLOCKLIST_DOMAINS entries are lowercase by
345+
// construction, so a direct compare then holds.
346+
341347
// Check page URL
348+
const normalizedUrl = url.toLowerCase();
342349
for (const domain of BLOCKLIST_DOMAINS) {
343-
if (url.includes(domain)) {
350+
if (normalizedUrl.includes(domain)) {
344351
warnings.push(`Page URL matches blocklisted domain: ${domain}`);
345352
}
346353
}
347354

348-
// Check for blocklisted URLs in content (links, form actions)
349-
const urlPattern = /https?:\/\/[^\s"'<>]+/g;
355+
// Check for blocklisted URLs in content (links, form actions). The `i` flag
356+
// keeps an uppercased scheme (HTTPS://) from evading URL extraction.
357+
const urlPattern = /https?:\/\/[^\s"'<>]+/gi;
350358
const contentUrls = content.match(urlPattern) || [];
351359
for (const contentUrl of contentUrls) {
360+
const normalizedContentUrl = contentUrl.toLowerCase();
352361
for (const domain of BLOCKLIST_DOMAINS) {
353-
if (contentUrl.includes(domain)) {
362+
if (normalizedContentUrl.includes(domain)) {
354363
warnings.push(`Content contains blocklisted URL: ${contentUrl.slice(0, 100)}`);
355364
break;
356365
}

browse/test/content-security.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,44 @@ describe('Content filter hooks', () => {
151151
expect(result.warnings.length).toBe(0);
152152
});
153153

154+
// Regression: issue #2190 — case-sensitive matching let an uppercased
155+
// exfiltration domain bypass the blocklist.
156+
test('URL blocklist is case-insensitive on the page URL', () => {
157+
const result = urlBlocklistFilter('', 'https://WEBHOOK.SITE/steal', 'text');
158+
expect(result.safe).toBe(false);
159+
expect(result.warnings.some(w => w.includes('webhook.site'))).toBe(true);
160+
});
161+
162+
test('URL blocklist is case-insensitive on content URLs', () => {
163+
const result = urlBlocklistFilter(
164+
'<a href="https://WEBHOOK.SITE/a1b2c3-steal">click</a>',
165+
'https://docs.example.com/article',
166+
'links',
167+
);
168+
expect(result.safe).toBe(false);
169+
expect(result.warnings.some(w => w.includes('WEBHOOK.SITE'))).toBe(true);
170+
});
171+
172+
test('URL blocklist catches an uppercased scheme in content', () => {
173+
const result = urlBlocklistFilter(
174+
'Exfil via HTTPS://Requestbin.com/r/abc please',
175+
'https://example.com',
176+
'text',
177+
);
178+
expect(result.safe).toBe(false);
179+
expect(result.warnings.some(w => w.toLowerCase().includes('requestbin.com'))).toBe(true);
180+
});
181+
182+
test('URL blocklist does not over-block legitimate uppercase domains', () => {
183+
const result = urlBlocklistFilter(
184+
'<a href="https://GitHub.com/acme/project">source</a>',
185+
'https://DOCS.EXAMPLE.COM/help',
186+
'links',
187+
);
188+
expect(result.safe).toBe(true);
189+
expect(result.warnings.length).toBe(0);
190+
});
191+
154192
test('custom filter can be registered and runs', () => {
155193
registerContentFilter((content, url, cmd) => {
156194
if (content.includes('SECRET')) {

browse/test/gstack-config.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ describe('gstack-config', () => {
108108
expect(existsSync(join(nestedDir, 'config.yaml'))).toBe(true);
109109
});
110110

111+
test('brain trust policy accepts local endpoint suffix', () => {
112+
const { exitCode, stderr } = run(['set', 'brain_trust_policy@local', 'personal']);
113+
expect(exitCode).toBe(0);
114+
expect(stderr).toBe('');
115+
expect(run(['get', 'brain_trust_policy@local']).stdout).toBe('personal');
116+
});
117+
111118
// ─── list ─────────────────────────────────────────────────
112119
test('list shows all keys', () => {
113120
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\nupdate_check: false\n');
@@ -139,6 +146,12 @@ describe('gstack-config', () => {
139146
expect(stderr).toContain('alphanumeric');
140147
});
141148

149+
test('set rejects endpoint suffix with punctuation', () => {
150+
const { exitCode, stderr } = run(['set', 'brain_trust_policy@local-dev', 'personal']);
151+
expect(exitCode).toBe(1);
152+
expect(stderr).toContain('endpoint-id');
153+
});
154+
142155
test('set preserves value with sed special chars', () => {
143156
run(['set', 'test_special', 'a/b&c\\d']);
144157
const { stdout } = run(['get', 'test_special']);

bun.lock

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/gbrain-local-status.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
* Broken-config → config exists but `gbrain sources list` fails with config parse error
2020
* (or any non-recognized error — defensive default per codex #8).
2121
* Broken-db → config exists, DB unreachable per stderr classification.
22+
* Engine-locked → PGLite probe hit gbrain's own connect timeout, usually
23+
* because another `gbrain serve` process owns the embedded DB.
2224
* Timeout → probe exceeded GSTACK_GBRAIN_PROBE_TIMEOUT_MS (default 15s) with no
2325
* recognized error — engine is likely healthy but slow (e.g. a cold
2426
* pooler connection, #1964). Consumers treat this as usable.
@@ -47,6 +49,7 @@ export type LocalEngineStatus =
4749
| "missing-config"
4850
| "broken-config"
4951
| "broken-db"
52+
| "engine-locked"
5053
| "timeout";
5154

5255
export interface ClassifyOptions {
@@ -118,6 +121,15 @@ function gbrainConfigPath(env?: NodeJS.ProcessEnv): string {
118121
return join(gbrainHome, "config.json");
119122
}
120123

124+
function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null {
125+
try {
126+
const parsed = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as { engine?: string };
127+
return parsed.engine === "pglite" || parsed.engine === "postgres" ? parsed.engine : null;
128+
} catch {
129+
return null;
130+
}
131+
}
132+
121133
function hashPath(p: string): string {
122134
return createHash("sha256").update(p).digest("hex").slice(0, 16);
123135
}
@@ -281,6 +293,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
281293
stderr?: Buffer | string;
282294
killed?: boolean;
283295
signal?: NodeJS.Signals | null;
296+
status?: number | null;
284297
};
285298
const stderr = (e.stderr ? e.stderr.toString() : "") || "";
286299

@@ -292,6 +305,14 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
292305
if (stderr.includes("Cannot connect to database")) return "broken-db";
293306
if (stderr.includes("config.json")) return "broken-config";
294307

308+
// PGLite is single-process. A long-lived `gbrain serve` can own the
309+
// embedded database, causing the CLI to finish with its own exit 124 and
310+
// "connect timed out" message. This is neither our watchdog timeout nor
311+
// evidence that the valid config is malformed (#2194).
312+
if (stderr.includes("connect timed out") || e.status === 124) {
313+
return configuredEngine(env) === "pglite" ? "engine-locked" : "broken-db";
314+
}
315+
295316
// Probe killed by the timeout with no recognized error: the engine is
296317
// most likely healthy but slow (cold pooler connections measured at
297318
// 6.9-10.7s in #1964). Don't tell the user their config is malformed.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,8 @@
7676
"@anthropic-ai/sdk": "^0.78.0",
7777
"xterm": "5",
7878
"xterm-addon-fit": "^0.8.0"
79+
},
80+
"overrides": {
81+
"basic-ftp": "5.3.1"
7982
}
8083
}

0 commit comments

Comments
 (0)