Files
gstack/test/gstack-settings-hook-symlink.test.ts
T
Garry Tan b9706f3635 v1.88.1.0 fix: harden credential boundaries and owned state (#2942)
* fix(settings): preserve symlinked settings targets

Resolve the selected target for locking, mutation, backup, and rollback; refuse target changes and preserve private modes. Addresses #2830.

* fix(redact): bind masking to original detected spans

Inspired by #2929's anchored-span diagnosis; independently implemented using normalization offsets. Addresses #2930 and the relocation portion of #2912 without changing detection sensitivity.

* fix(evals): exclude operator credentials from prefix admission

Adapts the credential-suffix screen proposed in #2636, with real launched-child regression coverage and deliberate provider-auth exceptions.

* fix(artifacts): retain custom allowlist rules on reinitialization

Preserve the exact user-owned suffix and publish only a successfully assembled replacement. Independently implements the repair reported in #2907.

* test(cso): verify exact masked reads and unmaskable payload refusal

* fix(cso): preserve exact filesystem identities through lease recovery

Preserve 64-bit device/inode identity and nanosecond race checks. Add native NTFS lifecycle coverage for #2927; retain ambiguous legacy-state refusal without claiming Windows PID-reuse recovery is resolved.

* fix(redact): bind pre-push scans to destination and preserve seam context

Uses #2935 (bd07318) as source evidence for push-target range and slice-overlap defects. Independently implemented; no cherry-pick or release metadata adoption.

* test(ci): gate native agent ownership and settings links on macOS

* fix(browse): bind agent lifetimes and cleanup to owned generations

Uses #2931 by Chris Hutton / Claude Fable 5.1 as attributed design input; independently implemented without broad sweeps or copied code. Keep uncertain children and locks rather than deleting foreign state.

* test(ci): include concurrent shutdown controls in the native macOS gate

* v1.88.1.0 fix: harden credential boundaries and owned state

* fix(redact): preserve target provenance and scan boundary semantics

* test(artifacts): read managed rules from atomic allowlist assembly

* fix: preserve native exit observations and fixture prerequisites

* fix: preserve UTF-16 offsets through redaction normalization
2026-09-23 08:54:53 -04:00

269 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { spawn, spawnSync } from 'node:child_process';
import { canRevokeReads } from './helpers/fs-caps';
const hook = path.resolve(import.meta.dir, '../bin/gstack-settings-hook');
let root: string;
let target: string;
let settings: string;
beforeEach(() => {
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-link-')));
fs.mkdirSync(path.join(root, 'config'));
fs.mkdirSync(path.join(root, 'dotfiles'));
target = path.join(root, 'dotfiles/settings.json');
settings = path.join(root, 'config/settings.json');
fs.writeFileSync(target, '{"theme":"dark","hooks":{}}\n', { mode: 0o600 });
fs.symlinkSync('../dotfiles/settings.json', settings);
expect(fs.realpathSync(settings)).toBe(target);
expect(target.startsWith(root + path.sep)).toBe(true);
});
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
function env(file = settings) {
return { ...process.env, HOME: root, GSTACK_HOME: path.join(root, 'state'), GSTACK_SETTINGS_FILE: file };
}
function addArgs(source = 'link-test') {
return ['add-event', '--event', 'PostToolUse', '--command', '/fixture/hook', '--source', source];
}
function run(args: string[], file = settings) {
return spawnSync('bash', [hook, ...args], { env: env(file), encoding: 'utf8', timeout: 10_000 });
}
function runWithRealpathFailure(args: string[], code: string, afterTemp = false) {
const fakeBin = path.join(root, 'bin');
const preload = path.join(root, 'realpath-permission.ts');
fs.mkdirSync(fakeBin);
fs.writeFileSync(preload, `const fs = require('fs');
const original = fs.realpathSync;
fs.realpathSync = (file, ...args) => {
if (String(file) === process.env.GSTACK_SETTINGS_INPUT && (!${afterTemp} || (process.env.GSTACK_TMP_PATH && fs.existsSync(process.env.GSTACK_TMP_PATH)))) {
throw Object.assign(new Error('synthetic realpath permission failure'), { code: ${JSON.stringify(code)} });
}
return original(file, ...args);
};
`);
fs.writeFileSync(path.join(fakeBin, 'bun'), '#!/bin/sh\nexec "$ACTUAL_BUN" --preload "$FS_PROBE" "$@"\n', { mode: 0o755 });
return spawnSync('bash', [hook, ...args], {
env: { ...env(), PATH: fakeBin + path.delimiter + process.env.PATH, ACTUAL_BUN: process.execPath, FS_PROBE: preload },
encoding: 'utf8', timeout: 10_000,
});
}
describe('settings hook preserves the resolved settings target', () => {
test('add, ensure and remove preserve the link, real target and private mode', () => {
const add = run(addArgs());
expect(add.status, add.stderr).toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(settings)).toBe('../dotfiles/settings.json');
expect(JSON.parse(fs.readFileSync(target, 'utf8')).hooks.PostToolUse[0]._gstack_source).toBe('link-test');
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
const before = fs.readFileSync(target, 'utf8');
const ensure = run(['ensure-event', ...addArgs().slice(1)]);
expect(ensure.status, ensure.stderr).toBe(0);
expect(fs.readFileSync(target, 'utf8')).toBe(before);
const remove = run(['remove-source', '--source', 'link-test']);
expect(remove.status, remove.stderr).toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(JSON.parse(fs.readFileSync(target, 'utf8')).theme).toBe('dark');
expect(fs.readFileSync(target, 'utf8')).not.toContain('link-test');
});
test('backup and rollback are siblings of the real target, not the link', () => {
const original = fs.readFileSync(target, 'utf8');
const add = run(addArgs());
expect(add.status, add.stderr).toBe(0);
expect(fs.existsSync(target + '.bak-latest')).toBe(true);
expect(fs.existsSync(settings + '.bak-latest')).toBe(false);
const rollback = run(['rollback']);
expect(rollback.status, rollback.stderr).toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
});
test('different aliases contend for the same target lock', () => {
fs.mkdirSync(target + '.lock');
fs.writeFileSync(target + '.lock/owner', 'fixture-owner');
const result = spawnSync('bash', [hook, ...addArgs()], {
env: { ...env(), GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '100' },
encoding: 'utf8', timeout: 10_000,
});
expect(result.status).toBe(5);
expect(result.stderr).toContain('could not acquire lock');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(target, 'utf8')).not.toContain('link-test');
expect(fs.existsSync(settings + '.lock')).toBe(false);
});
test('dangling and cyclic links are rejected without replacing them', () => {
fs.unlinkSync(target);
const dangling = run(addArgs());
expect(dangling.status).not.toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.existsSync(target)).toBe(false);
fs.symlinkSync('../config/settings.json', target);
const cyclic = run(addArgs());
expect(cyclic.status).not.toBe(0);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(target).isSymbolicLink()).toBe(true);
});
test('malformed linked settings stay unchanged', () => {
fs.writeFileSync(target, '{not json');
const result = run(addArgs());
expect(result.status).toBe(3);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(target, 'utf8')).toBe('{not json');
});
test('a missing ordinary settings file can still be created', () => {
const fresh = path.join(root, 'config/fresh.json');
const result = run(addArgs(), fresh);
expect(result.status, result.stderr).toBe(0);
expect(fs.lstatSync(fresh).isFile()).toBe(true);
expect(JSON.parse(fs.readFileSync(fresh, 'utf8')).hooks.PostToolUse).toHaveLength(1);
expect(fs.statSync(fresh).mode & 0o777).toBe(0o600);
});
test('read-only commands and absent-parent no-ops do not create files', () => {
expect(run(addArgs()).status).toBe(0);
const original = fs.readFileSync(target, 'utf8');
const siblings = fs.readdirSync(path.dirname(target));
expect(run(['list-sources']).stdout).toContain('link-test');
expect(run(['list-items', '--event', 'PostToolUse']).stdout).toContain('/fixture/hook');
expect(run(['diff-event', ...addArgs().slice(1)]).status).toBe(0);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
expect(fs.readdirSync(path.dirname(target))).toEqual(siblings);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
const missing = path.join(root, 'absent/config/settings.json');
expect(run(['list-sources'], missing).status).toBe(0);
expect(run(['remove-source', '--source', 'link-test'], missing).status).toBe(0);
expect(fs.existsSync(path.join(root, 'absent'))).toBe(false);
});
test('legacy add, remove and prune-stale use the real target', () => {
const command = path.join(root, 'missing/bin/gstack-session-update');
expect(run(['add', command]).status).toBe(0);
expect(fs.readFileSync(target, 'utf8')).toContain(command);
expect(run(['remove', command]).status).toBe(0);
expect(fs.readFileSync(target, 'utf8')).not.toContain(command);
expect(run(['add', command]).status).toBe(0);
expect(run(['prune-stale']).status).toBe(0);
expect(fs.readFileSync(target, 'utf8')).not.toContain(command);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(JSON.parse(fs.readFileSync(target, 'utf8')).theme).toBe('dark');
});
test.skipIf(!canRevokeReads())('an unreadable linked target stays untouched', () => {
const original = fs.readFileSync(target, 'utf8');
fs.chmodSync(target, 0o000);
try {
const result = run(addArgs());
expect(result.status, result.stderr).toBe(3);
expect(result.stderr).toContain('cannot read');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readdirSync(path.dirname(target))).toEqual(['settings.json']);
} finally {
fs.chmodSync(target, 0o600);
}
expect(fs.readFileSync(target, 'utf8')).toBe(original);
});
for (const code of ['EACCES', 'EPERM']) {
test(`a ${code} realpath failure keeps the unreadable-settings exit contract`, () => {
const original = fs.readFileSync(target, 'utf8');
const result = runWithRealpathFailure(addArgs(), code);
expect(result.status, result.stderr).toBe(3);
expect(result.stderr).toContain('cannot read');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readdirSync(path.dirname(target))).toEqual(['settings.json']);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
});
}
for (const action of ['add-event', 'rollback']) {
test(`a late permission failure during ${action} removes its temporary settings copy`, () => {
if (action === 'rollback') expect(run(addArgs()).status).toBe(0);
const original = fs.readFileSync(target, 'utf8');
const result = runWithRealpathFailure(action === 'rollback' ? ['rollback'] : addArgs(), 'EACCES', true);
expect(result.status, result.stderr).toBe(3);
expect(result.stderr).toContain('cannot read');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
expect(fs.readdirSync(path.dirname(target)).filter(name => name.startsWith('settings.json.tmp.'))).toEqual([]);
});
}
test('concurrent writes through file and directory aliases both survive', async () => {
const directoryAlias = path.join(root, 'linked-directory');
fs.symlinkSync('dotfiles', directoryAlias);
const directorySettings = path.join(directoryAlias, 'settings.json');
expect(fs.realpathSync(directorySettings)).toBe(target);
const first = Bun.spawn(['bash', hook, ...addArgs()], { env: env(), stdout: 'pipe', stderr: 'pipe' });
const second = Bun.spawn(['bash', hook, 'add-event', '--event', 'Stop', '--command', '/fixture/stop', '--source', 'second-source'], {
env: env(directorySettings), stdout: 'pipe', stderr: 'pipe',
});
try {
const statuses = await Promise.all([first.exited, second.exited]);
expect(statuses).toEqual([0, 0]);
const stored = JSON.parse(fs.readFileSync(target, 'utf8'));
expect(stored.hooks.PostToolUse[0]._gstack_source).toBe('link-test');
expect(stored.hooks.Stop[0]._gstack_source).toBe('second-source');
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(directoryAlias).isSymbolicLink()).toBe(true);
} finally {
if (first.exitCode === null) first.kill();
if (second.exitCode === null) second.kill();
await Promise.all([first.exited, second.exited]);
}
});
test('a link retargeted while waiting for its lock cannot mutate either target', async () => {
const other = path.join(root, 'dotfiles/other.json');
const original = fs.readFileSync(target, 'utf8');
fs.writeFileSync(other, original);
fs.mkdirSync(target + '.lock');
fs.writeFileSync(target + '.lock/owner', 'fixture-owner');
const bin = path.join(root, 'bin');
fs.mkdirSync(bin);
const realMkdir = spawnSync('which', ['mkdir'], { encoding: 'utf8', timeout: 5_000 }).stdout.trim();
expect(realMkdir).not.toBe('');
const observed = path.join(root, 'lock-observed');
fs.writeFileSync(path.join(bin, 'mkdir'), '#!/bin/bash\nif [ "$1" = "$LOCK_EXPECTED" ]; then printf seen > "$LOCK_OBSERVED"; fi\nexec "$REAL_MKDIR" "$@"\n', { mode: 0o755 });
const child = spawn('bash', [hook, ...addArgs()], {
env: { ...env(), PATH: `${bin}:${process.env.PATH}`, LOCK_EXPECTED: target + '.lock', LOCK_OBSERVED: observed, REAL_MKDIR: realMkdir },
stdio: ['ignore', 'pipe', 'pipe'], timeout: 5_000,
});
let stderr = '';
child.stdout.resume();
child.stderr.on('data', data => { stderr += data; });
const finished = new Promise<number | null>((resolve, reject) => {
child.once('error', reject);
child.once('close', resolve);
});
try {
const deadline = Date.now() + 2_000;
while (!fs.existsSync(observed) && Date.now() < deadline) await Bun.sleep(10);
expect(fs.existsSync(observed)).toBe(true);
fs.unlinkSync(settings);
fs.symlinkSync('../dotfiles/other.json', settings);
expect(fs.realpathSync(settings)).toBe(other);
fs.rmSync(target + '.lock', { recursive: true });
expect(await finished, stderr).not.toBe(0);
expect(fs.readFileSync(target, 'utf8')).toBe(original);
expect(fs.readFileSync(other, 'utf8')).toBe(original);
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
} finally {
if (child.exitCode === null) child.kill();
await finished;
}
});
});