Files
gstack/test/gstack-slug-sanitize.test.ts
T
Garry Tan c43c850cae v1.55.1.0 fix: telemetry consent accuracy + gstack-slug cache sanitization (#1848)
* fix(gstack-slug): sanitize cached slug before eval

The compute and fallback paths filter slug output to [a-zA-Z0-9._-], but a
value read straight from ~/.gstack/slug-cache was echoed into eval output
unsanitized. A locally-planted cache file could inject shell into
eval "$(gstack-slug)". Re-sanitize on every path so the invariant the file
header promises actually holds, and heal a poisoned cache on the next write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(telemetry): accurate consent copy + JSON-safe repo basename

The telemetry consent prompt promised "no repo names" while the preamble
epilogue records the repo basename in the local skill-usage.jsonl. It is
already stripped before any remote upload, so it never left the machine, but
the copy was unqualified. Reword it to state repo name is local-only and
stripped before upload.

Also sanitize the basename to [a-zA-Z0-9._-] before it goes into the
hand-built JSON, so a repo directory name containing quotes or newlines can
neither break the JSON nor leak a fragment past the regex stripper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(docs): regenerate SKILL.md + ship goldens for telemetry change

Generated output of the preceding resolver change: the corrected consent copy
and sanitized repo basename now appear in every skill preamble. Golden ship
fixtures refreshed to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(telemetry): enforce no-repo-identity-egress invariant

Pins the contract that repo/branch identity in the synced skill-usage.jsonl is
stripped before the remote POST. Three checks: a floor (the three known fields),
coverage (every repo/branch field a producer writes into skill-usage.jsonl is
stripped, so a future producer rename can't silently leak), and behavior (runs
the actual sed strip expressions over a sample event). Scoped to the synced
file, so the local-only timeline branch field is correctly excluded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(gstack-slug): regression test for cached-slug eval injection

Proves a poisoned ~/.gstack/slug-cache file cannot inject shell metacharacters
into gstack-slug output (the value consumed by eval). Verified red when the
cache-read sanitization is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.55.1.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:36:34 -07:00

66 lines
2.5 KiB
TypeScript

/**
* gstack-slug cache-read sanitization.
*
* `eval "$(gstack-slug)"` is how callers load SLUG/BRANCH. The compute and
* fallback paths filter to [a-zA-Z0-9._-], but a value read straight from the
* cache file used to be echoed unsanitized — a planted cache file could inject
* shell. This pins the fix: a poisoned cache must never produce shell
* metacharacters in the SLUG= output line.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'bun';
import fs from 'fs';
import os from 'os';
import path from 'path';
const ROOT = path.resolve(__dirname, '..');
const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug');
/** Reproduce the script's cache-key derivation: absolute path with / -> _. */
function cacheKeyFor(dir: string): string {
return dir.replace(/\//g, '_');
}
function runSlug(cwd: string, home: string) {
return spawnSync([SLUG_BIN], {
cwd,
env: { ...process.env, HOME: home },
});
}
describe('gstack-slug cache-read sanitization', () => {
test('a poisoned cache file cannot inject shell metacharacters into output', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gslug-home-'));
const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'gslug-proj-'));
try {
const cacheDir = path.join(home, '.gstack', 'slug-cache');
fs.mkdirSync(cacheDir, { recursive: true });
// realpath: macOS tmpdir is a symlink (/var -> /private/var); the script
// runs in the resolved cwd, so key off the resolved path.
const realProj = fs.realpathSync(proj);
const payload = 'evil"; touch ' + path.join(home, 'pwned') + '; echo "x';
fs.writeFileSync(path.join(cacheDir, cacheKeyFor(realProj)), payload);
const out = runSlug(realProj, home);
const stdout = out.stdout.toString();
const slugLine = stdout.split('\n').find((l) => l.startsWith('SLUG='));
expect(slugLine).toBeDefined();
const slugValue = slugLine!.slice('SLUG='.length);
// The value must be sanitized: only [a-zA-Z0-9._-], no quotes/semicolons/spaces.
expect(slugValue).toMatch(/^[a-zA-Z0-9._-]*$/);
expect(slugLine).not.toContain('"');
expect(slugLine).not.toContain(';');
expect(slugLine).not.toContain(' ');
// And the injection must not have fired during the script's own run.
expect(fs.existsSync(path.join(home, 'pwned'))).toBe(false);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(proj, { recursive: true, force: true });
}
});
});