diff --git a/bin/gstack-config b/bin/gstack-config index 01176c44c..0eca9ed49 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -264,7 +264,7 @@ case "${1:-}" in # endpoint-namespaced keys introduced by the brain-aware planning layer). # Endpoint ids are sha8/sha16 hex for remote MCP URLs, or the literal # "local" for stdio/PGLite engines (see endpoint_hash). - if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then + if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi @@ -280,7 +280,7 @@ case "${1:-}" in VALUE="${3:?Usage: gstack-config set }" # Validate key (alphanumeric + underscore + optional @ suffix). # Accepts hex hashes and the literal "local" from endpoint_hash. - if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then + if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi diff --git a/test/gstack-config-key-locale.test.ts b/test/gstack-config-key-locale.test.ts new file mode 100644 index 000000000..6fdbd5db1 --- /dev/null +++ b/test/gstack-config-key-locale.test.ts @@ -0,0 +1,76 @@ +/** + * Locale-independent key validation tests for bin/gstack-config. + * + * POSIX bracket ranges such as a-z follow the active collation order. Under + * GNU grep with tr_TR.UTF-8, that excludes the ASCII letter i and silently + * breaks most stored preferences. macOS BSD grep does not reproduce the bug, + * so the source-level tripwire pins the C-locale boundary on every platform. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const CONFIG = path.join(ROOT, "bin", "gstack-config"); + +let stateRoot: string; + +function run(args: string[]) { + const result = spawnSync(CONFIG, args, { + encoding: "utf8", + env: { ...process.env, GSTACK_STATE_ROOT: stateRoot }, + }); + + return { + status: result.status ?? -1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +beforeEach(() => { + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-config-locale-")); +}); + +afterEach(() => { + fs.rmSync(stateRoot, { recursive: true, force: true }); +}); + +describe("gstack-config key validation is locale-independent", () => { + test("both get and set validate ASCII ranges under the C locale", () => { + const source = fs.readFileSync(CONFIG, "utf8"); + const guardedValidators = source.match( + /LC_ALL=C grep -qE '\^\[a-zA-Z0-9_\]\+\(@\[a-zA-Z0-9\]\+\)\?\$'/g, + ); + + expect(guardedValidators).toHaveLength(2); + }); + + test("round-trips existing keys that contain i", () => { + expect(run(["set", "skill_prefix", "true"]).status).toBe(0); + + const get = run(["get", "skill_prefix"]); + expect(get.status).toBe(0); + expect(get.stdout).toBe("true"); + }); + + test("accepts an existing endpoint-scoped ASCII key", () => { + expect(run(["set", "brain_trust_policy@local", "personal"]).status).toBe(0); + + const get = run(["get", "brain_trust_policy@local"]); + expect(get.status).toBe(0); + expect(get.stdout).toBe("personal"); + }); + + test("continues to reject non-ASCII keys", () => { + const set = run(["set", "skïll_prefix", "true"]); + expect(set.status).toBe(1); + expect(set.stderr).toContain("key must contain only alphanumeric characters"); + + const get = run(["get", "skïll_prefix"]); + expect(get.status).toBe(1); + expect(get.stderr).toContain("key must contain only alphanumeric characters"); + }); +});