mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-26 22:51:47 +02:00
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
This commit is contained in:
@@ -116,6 +116,49 @@ describe('buildHermeticEnv allowlist', () => {
|
||||
expect(e.GH_TOKEN).toBeUndefined(); // not in extraAllow
|
||||
});
|
||||
|
||||
test('prefixes keep CI metadata but do not admit credential-shaped operator names', () => {
|
||||
const base = {
|
||||
...CONTAMINATED,
|
||||
GITHUB_TOKEN: 'synthetic-token',
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'synthetic-pat',
|
||||
GITHUB_APP_PRIVATE_KEY: 'synthetic-private-key',
|
||||
GITHUB_CLIENT_SECRET: 'synthetic-client-secret',
|
||||
GITHUB_PAT: 'synthetic-pat-short',
|
||||
EVALS_API_KEY: 'synthetic-eval-key',
|
||||
GITHUB_SHA: 'abc123',
|
||||
GITHUB_PATH: '/tmp/actions-path',
|
||||
GITHUB_TOKENIZER: 'metadata-tokenizer',
|
||||
GITHUB_KEYRING: 'metadata-keyring',
|
||||
EVALS_RUN_ID: 'run-123',
|
||||
EVALS_SELECTION_JSON: '{}',
|
||||
};
|
||||
const result = buildHermeticEnv(base, HERMETIC_VARS);
|
||||
for (const name of [
|
||||
'GITHUB_TOKEN', 'GITHUB_PERSONAL_ACCESS_TOKEN', 'GITHUB_APP_PRIVATE_KEY',
|
||||
'GITHUB_CLIENT_SECRET', 'GITHUB_PAT', 'EVALS_API_KEY', 'GH_TOKEN',
|
||||
]) expect(result[name]).toBeUndefined();
|
||||
for (const name of [
|
||||
'GITHUB_ACTIONS', 'GITHUB_SHA', 'GITHUB_PATH', 'GITHUB_TOKENIZER',
|
||||
'GITHUB_KEYRING', 'EVALS_MODEL', 'EVALS_RUN_ID', 'EVALS_SELECTION_JSON',
|
||||
]) expect(result[name]).toBe(base[name]);
|
||||
});
|
||||
|
||||
test('explicit provider auth, runner admissions, and overrides still win', () => {
|
||||
const base = {
|
||||
...CONTAMINATED,
|
||||
GITHUB_TOKEN: 'synthetic-token',
|
||||
GEMINI_API_KEY: 'synthetic-gemini',
|
||||
};
|
||||
const result = buildHermeticEnv(base, HERMETIC_VARS, {
|
||||
GITHUB_APP_PRIVATE_KEY: 'synthetic-override',
|
||||
}, { extraAllow: ['GEMINI_*', 'GITHUB_TOKEN'] });
|
||||
expect(result.ANTHROPIC_API_KEY).toBe(base.ANTHROPIC_API_KEY);
|
||||
expect(result.GEMINI_API_KEY).toBe(base.GEMINI_API_KEY);
|
||||
expect(result.GITHUB_TOKEN).toBe(base.GITHUB_TOKEN);
|
||||
expect(result.GITHUB_APP_PRIVATE_KEY).toBe('synthetic-override');
|
||||
expect(buildHermeticEnv(base, HERMETIC_VARS).GITHUB_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
test('TERM falls back when base omits it', () => {
|
||||
const base = { ...CONTAMINATED } as NodeJS.ProcessEnv;
|
||||
delete base.TERM;
|
||||
|
||||
@@ -63,12 +63,18 @@ const ALLOW_EXACT = new Set([
|
||||
/** Prefix rules: eval-harness knobs + CI metadata. Deliberately NOT here:
|
||||
* CONDUCTOR_* / CLAUDE_* (incl. CLAUDECODE, CLAUDE_CODE_ENTRYPOINT) /
|
||||
* GSTACK_* / MCP_* / GBRAIN_* — session-context contamination; and operator
|
||||
* credentials (GH_TOKEN, SSH_AUTH_SOCK, GIT_*, OPENAI_API_KEY,
|
||||
* credentials (GH_TOKEN, GITHUB_*_TOKEN, SSH_AUTH_SOCK, GIT_*, OPENAI_API_KEY,
|
||||
* VOYAGE_API_KEY) — CI doesn't have them and eval children have no business
|
||||
* using them. A test that legitimately needs one opts in via its own env
|
||||
* override; a provider runner (codex/gemini) re-admits its auth vars via
|
||||
* opts.extraAllow. */
|
||||
* opts.extraAllow. Prefix matches reject credential-shaped suffixes; exact
|
||||
* and explicit runner admissions still win. */
|
||||
const ALLOW_PREFIXES = ['EVALS_', 'GITHUB_'];
|
||||
const CREDENTIAL_SUFFIXES = new Set([
|
||||
'KEY', 'KEYS', 'TOKEN', 'TOKENS', 'SECRET', 'SECRETS', 'PASSWORD', 'PASSWD',
|
||||
'PASS', 'CREDENTIAL', 'CREDENTIALS', 'AUTH', 'PAT', 'DSN', 'COOKIE',
|
||||
'SESSION', 'PRIVATE',
|
||||
]);
|
||||
|
||||
export interface HermeticEnvOpts {
|
||||
/** Per-runner additional allowed names (exact match) or prefixes (entries
|
||||
@@ -115,7 +121,8 @@ export function buildHermeticEnv(
|
||||
const allowed =
|
||||
ALLOW_EXACT.has(k) ||
|
||||
extraExact.has(k) ||
|
||||
ALLOW_PREFIXES.some((p) => k.startsWith(p)) ||
|
||||
(ALLOW_PREFIXES.some((p) => k.startsWith(p)) &&
|
||||
!CREDENTIAL_SUFFIXES.has(k.slice(k.lastIndexOf('_') + 1).toUpperCase())) ||
|
||||
extraPrefixes.some((p) => k.startsWith(p));
|
||||
if (allowed) out[k] = v;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { parseNDJSON } from './session-runner';
|
||||
|
||||
test('runSkillTest launches a child without operator credentials', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hermetic-session-'));
|
||||
try {
|
||||
const bin = path.join(root, 'claude');
|
||||
fs.writeFileSync(bin, `#!/usr/bin/env node
|
||||
const names = ['GITHUB_TOKEN', 'GITHUB_PERSONAL_ACCESS_TOKEN', 'GITHUB_APP_PRIVATE_KEY', 'GH_TOKEN', 'GITHUB_ACTIONS', 'GITHUB_PATH', 'GITHUB_TOKENIZER', 'EVALS_RUN_ID'];
|
||||
const present = Object.fromEntries(names.map(name => [name, Object.hasOwn(process.env, name)]));
|
||||
console.log(JSON.stringify({type: 'result', subtype: 'success', result: JSON.stringify(present)}));
|
||||
`, { mode: 0o700 });
|
||||
const script = `import { runSkillTest } from ${JSON.stringify(pathToFileURL(path.join(import.meta.dir, 'session-runner.ts')).href)};
|
||||
const result = await runSkillTest({prompt: 'synthetic fixture', workingDirectory: ${JSON.stringify(root)}, model: 'fixture', timeout: 5000, startupGraceMs: 5000, allowedTools: []});
|
||||
console.log(JSON.stringify({exitReason: result.exitReason, child: JSON.parse(result.output)}));`;
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
cwd: path.resolve(import.meta.dir, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
PATH: `${root}${path.delimiter}${process.env.PATH ?? '/usr/bin:/bin'}`,
|
||||
HOME: root,
|
||||
TMPDIR: os.tmpdir(),
|
||||
GITHUB_TOKEN: 'synthetic-token',
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'synthetic-pat',
|
||||
GITHUB_APP_PRIVATE_KEY: 'synthetic-private-key',
|
||||
GH_TOKEN: 'synthetic-gh-token',
|
||||
GITHUB_ACTIONS: 'true',
|
||||
GITHUB_PATH: '/tmp/actions-path',
|
||||
GITHUB_TOKENIZER: 'metadata-tokenizer',
|
||||
EVALS_RUN_ID: 'synthetic-run',
|
||||
},
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
exitReason: 'success',
|
||||
child: {
|
||||
GITHUB_TOKEN: false,
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: false,
|
||||
GITHUB_APP_PRIVATE_KEY: false,
|
||||
GH_TOKEN: false,
|
||||
GITHUB_ACTIONS: true,
|
||||
GITHUB_PATH: true,
|
||||
GITHUB_TOKENIZER: true,
|
||||
EVALS_RUN_ID: true,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Fixture: minimal NDJSON session (system init, assistant with tool_use, tool result, assistant text, result)
|
||||
const FIXTURE_LINES = [
|
||||
'{"type":"system","subtype":"init","session_id":"test-123"}',
|
||||
|
||||
Reference in New Issue
Block a user