mirror of https://github.com/garrytan/gstack.git
feat(cli): gstack-egress reader
bin/gstack-egress (bun) — the auditor's view of the receipts ledger: - list: one row per receipt (what gstack ATTEMPTED to send), with --since/--host/--sink filters and --json. - verify: recompute the hash chain; exit 3 on tamper naming the first broken line; prints the sizeWarning when the ledger passes 25MB. - grants: what CAN leave, built on the upstream config keys only (telemetry, artifacts_sync_mode, redact_repo_visibility, redact_prepush_hook via gstack-config get) — each grant names its file, key, and the exact revoke command. CLI smoke tests spawn the real bin against a temp GSTACK_HOME, including a broken-chain fixture asserting exit 3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 9e24eca0f1069fea2ea69e7df4e9b256e93d59a3)
This commit is contained in:
parent
3d2ea53357
commit
ba077165be
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* gstack-egress — the auditor's view of the receipts ledger.
|
||||
*
|
||||
* list what gstack ATTEMPTED to send off this machine (one row per receipt)
|
||||
* grants what CAN leave: every consent grant in force, where it lives,
|
||||
* and the exact command that revokes it (pure config reads)
|
||||
* verify recompute the hash chain; exit 3 on tamper
|
||||
*
|
||||
* THREAT MODEL: the ledger is forensic observability — it records ATTEMPTED
|
||||
* egress so accidents are auditable; it is not an exfiltration control.
|
||||
*
|
||||
* The ledger is written by lib/egress-receipt.ts at every enumerated sink
|
||||
* (see test/egress-receipt-wiring.test.ts for the pinned list).
|
||||
*
|
||||
* Home: GSTACK_HOME, legacy GSTACK_STATE_DIR, else ~/.gstack.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
egressLedgerPath,
|
||||
listReceipts,
|
||||
resolveEgressHome,
|
||||
verifyLedger,
|
||||
} from '../lib/egress-receipt';
|
||||
|
||||
const BIN_DIR = path.dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
function usage(message: string): never {
|
||||
process.stderr.write(`gstack-egress: ${message}\n`);
|
||||
process.stderr.write(
|
||||
'Usage: gstack-egress list [--since <ISO>] [--host <host>] [--sink <sink>] [--json]\n' +
|
||||
' gstack-egress verify [--json]\n' +
|
||||
' gstack-egress grants [--json]\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function configGet(key: string): string {
|
||||
const result = spawnSync(path.join(BIN_DIR, 'gstack-config'), ['get', key], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return (result.stdout || '').trim();
|
||||
}
|
||||
|
||||
function egressList(args: string[], home: string): number {
|
||||
const values = new Map<string, string>();
|
||||
let json = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (['--since', '--host', '--sink'].includes(arg)) {
|
||||
const value = args[++index];
|
||||
if (value == null || value.startsWith('--')) usage(`${arg} requires a value`);
|
||||
values.set(arg, value);
|
||||
} else if (arg === '--json') {
|
||||
json = true;
|
||||
} else {
|
||||
usage(`unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
const since = values.get('--since');
|
||||
if (since != null && !Number.isFinite(Date.parse(since))) usage('--since must be an ISO timestamp');
|
||||
|
||||
let receipts = listReceipts(home);
|
||||
if (since) receipts = receipts.filter((r) => Date.parse(r.ts) >= Date.parse(since));
|
||||
if (values.has('--host')) receipts = receipts.filter((r) => r.host === values.get('--host'));
|
||||
if (values.has('--sink')) receipts = receipts.filter((r) => r.sink === values.get('--sink'));
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(receipts, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (!receipts.length) {
|
||||
process.stdout.write(`no receipts\nledger: ${egressLedgerPath(home)}\n`);
|
||||
return 0;
|
||||
}
|
||||
for (const r of receipts) {
|
||||
process.stdout.write(
|
||||
`${r.ts} ${r.sink} -> ${r.host} ${r.payload_class} ${r.bytes}B ` +
|
||||
`sha256=${r.sha256 ?? '(subprocess-owned)'} consent=${r.consent} status=${r.status ?? '-'}\n`,
|
||||
);
|
||||
}
|
||||
process.stdout.write(`${receipts.length} receipt(s) ledger: ${egressLedgerPath(home)}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function egressVerify(args: string[], home: string): number {
|
||||
for (const arg of args) if (arg !== '--json') usage(`unknown option: ${arg}`);
|
||||
const result = verifyLedger(home);
|
||||
if (args.includes('--json')) {
|
||||
process.stdout.write(`${JSON.stringify({ ...result, ledger: egressLedgerPath(home) }, null, 2)}\n`);
|
||||
} else {
|
||||
if (result.ok) {
|
||||
process.stdout.write(`chain intact: ${result.count} line(s) verified\n`);
|
||||
} else {
|
||||
process.stdout.write(`TAMPER: chain broken at line ${result.brokenLine} (${result.reason})\n`);
|
||||
}
|
||||
if (result.sizeWarning) process.stdout.write(`${result.sizeWarning}\n`);
|
||||
}
|
||||
return result.ok ? 0 : 3;
|
||||
}
|
||||
|
||||
interface Grant {
|
||||
grant: string;
|
||||
value: string;
|
||||
granted: boolean;
|
||||
detail: string;
|
||||
file: string;
|
||||
key: string;
|
||||
revoke: string;
|
||||
}
|
||||
|
||||
function egressGrants(args: string[], home: string): number {
|
||||
for (const arg of args) if (arg !== '--json') usage(`unknown option: ${arg}`);
|
||||
const configFile = path.join(home, 'config.yaml');
|
||||
|
||||
const telemetry = configGet('telemetry') || 'off';
|
||||
const syncMode = configGet('artifacts_sync_mode') || 'off';
|
||||
const repoVisibility = configGet('redact_repo_visibility') || 'unknown';
|
||||
const prepushHook = configGet('redact_prepush_hook') || 'false';
|
||||
|
||||
const grants: Grant[] = [
|
||||
{
|
||||
grant: 'telemetry',
|
||||
value: telemetry,
|
||||
granted: telemetry !== 'off',
|
||||
detail: 'anonymous and community tiers upload usage events to Supabase; off stays local-only',
|
||||
file: configFile,
|
||||
key: 'telemetry',
|
||||
revoke: 'gstack-config set telemetry off',
|
||||
},
|
||||
{
|
||||
grant: 'brain-sync',
|
||||
value: syncMode,
|
||||
granted: syncMode !== 'off' && syncMode !== '',
|
||||
detail: 'git push of curated allowlisted memory to the user-configured artifacts remote',
|
||||
file: configFile,
|
||||
key: 'artifacts_sync_mode',
|
||||
revoke: 'gstack-config set artifacts_sync_mode off',
|
||||
},
|
||||
{
|
||||
grant: 'redact_repo_visibility',
|
||||
value: repoVisibility,
|
||||
granted: repoVisibility === 'public',
|
||||
detail: 'redaction strictness assumption for external sinks; public gets per-finding confirmation',
|
||||
file: configFile,
|
||||
key: 'redact_repo_visibility',
|
||||
revoke: 'gstack-config set redact_repo_visibility unknown (unknown = public-strict)',
|
||||
},
|
||||
{
|
||||
grant: 'redact_prepush_hook',
|
||||
value: prepushHook,
|
||||
granted: prepushHook === 'true',
|
||||
detail: 'opt-in git pre-push redaction scan; granted here means the guard is ON',
|
||||
file: configFile,
|
||||
key: 'redact_prepush_hook',
|
||||
revoke: 'gstack-config set redact_prepush_hook false (disables the guard)',
|
||||
},
|
||||
];
|
||||
|
||||
if (args.includes('--json')) {
|
||||
process.stdout.write(`${JSON.stringify(grants, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
for (const grant of grants) {
|
||||
process.stdout.write(
|
||||
`${grant.granted ? '[GRANTED]' : '[off] '} ${grant.grant}: ${grant.value}\n` +
|
||||
` ${grant.detail}\n` +
|
||||
` lives in: ${grant.file} (${grant.key})\n` +
|
||||
` revoke: ${grant.revoke}\n`,
|
||||
);
|
||||
}
|
||||
process.stdout.write("What was ATTEMPTED: 'gstack-egress list'. Chain check: 'gstack-egress verify'.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [action, ...rest] = process.argv.slice(2);
|
||||
const home = resolveEgressHome();
|
||||
|
||||
let code: number;
|
||||
if (action === 'list') code = egressList(rest, home);
|
||||
else if (action === 'verify') code = egressVerify(rest, home);
|
||||
else if (action === 'grants') code = egressGrants(rest, home);
|
||||
else usage(`unknown subcommand: ${action ?? '(none)'}`);
|
||||
process.exit(code);
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* gstack-egress CLI — list | verify | grants smoke tests. Free tier.
|
||||
*
|
||||
* Spawns the real bin against a temp GSTACK_HOME: list filters, verify
|
||||
* exit-3-on-tamper (naming the first broken line), sizeWarning surfacing,
|
||||
* and grants against the upstream config keys (telemetry,
|
||||
* artifacts_sync_mode, redact_repo_visibility, redact_prepush_hook).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
LEDGER_WARN_BYTES,
|
||||
egressLedgerPath,
|
||||
sha256Hex,
|
||||
writeReceipt,
|
||||
} from '../lib/egress-receipt';
|
||||
|
||||
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-egress');
|
||||
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-egress-cli-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(args: string[]) {
|
||||
const result = spawnSync(BIN, args, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
});
|
||||
return { code: result.status ?? -1, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||
}
|
||||
|
||||
describe('gstack-egress list', () => {
|
||||
test('fresh home prints "no receipts" and the ledger path, exit 0', () => {
|
||||
const r = run(['list']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('no receipts');
|
||||
expect(r.stdout).toContain(egressLedgerPath(home));
|
||||
});
|
||||
|
||||
test('--json returns receipts and honors --sink/--host/--since filters', () => {
|
||||
writeReceipt({ home, sink: 'telemetry-sync', host: '10.0.0.1:8399', payloadClass: 'telemetry-events', bytes: 2, sha256: sha256Hex('[]'), consent: 'telemetry=community' });
|
||||
writeReceipt({ home, sink: 'design-openai', host: 'api.openai.com', payloadClass: 'generate-image-request', consent: 'user ran design command' });
|
||||
const all = run(['list', '--json']);
|
||||
expect(all.code).toBe(0);
|
||||
expect(JSON.parse(all.stdout).length).toBe(2);
|
||||
const filtered = run(['list', '--json', '--sink', 'telemetry-sync']);
|
||||
const rows = JSON.parse(filtered.stdout);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].host).toBe('10.0.0.1:8399');
|
||||
expect(rows[0].sha256).toBe(sha256Hex('[]'));
|
||||
const none = run(['list', '--json', '--since', '2999-01-01T00:00:00Z']);
|
||||
expect(JSON.parse(none.stdout).length).toBe(0);
|
||||
});
|
||||
|
||||
test('unknown option exits 2 with usage', () => {
|
||||
const r = run(['list', '--bogus']);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('Usage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-egress verify', () => {
|
||||
test('exits 0 on an intact chain and 3 naming the first broken line on tamper', () => {
|
||||
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'telemetry=community' });
|
||||
writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'telemetry=community' });
|
||||
const ok = run(['verify']);
|
||||
expect(ok.code).toBe(0);
|
||||
expect(ok.stdout).toContain('chain intact: 2');
|
||||
|
||||
const ledger = egressLedgerPath(home);
|
||||
const lines = fs.readFileSync(ledger, 'utf-8').trim().split('\n');
|
||||
lines[0] = lines[0].replace('community', 'communitX');
|
||||
fs.writeFileSync(ledger, `${lines.join('\n')}\n`);
|
||||
const tampered = run(['verify']);
|
||||
expect(tampered.code).toBe(3);
|
||||
expect(tampered.stdout).toContain('line 2');
|
||||
});
|
||||
|
||||
test('prints the sizeWarning when the ledger exceeds the threshold', () => {
|
||||
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
|
||||
const ledger = egressLedgerPath(home);
|
||||
// verify keys the warning off file size only — pad with a trailing
|
||||
// comment-free blank region by appending to a side channel is not
|
||||
// possible in JSONL, so grow via many valid-shaped junk lines and
|
||||
// assert on sizeWarning presence (chain will break; both surface).
|
||||
const filler = `${JSON.stringify({ type: 'junk', pad: 'x'.repeat(1024) })}\n`.repeat(1024);
|
||||
while (fs.statSync(ledger).size <= LEDGER_WARN_BYTES) fs.appendFileSync(ledger, filler);
|
||||
const r = run(['verify', '--json']);
|
||||
expect(r.code).toBe(3); // filler breaks the chain — expected
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.sizeWarning).toContain('egress ledger is large');
|
||||
expect(parsed.sizeWarning).toContain('gstack-egress list');
|
||||
const human = run(['verify']);
|
||||
expect(human.stdout).toContain('egress ledger is large');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-egress grants', () => {
|
||||
test('fresh home shows the four upstream grants off, each naming file and revoke command', () => {
|
||||
const r = run(['grants']);
|
||||
expect(r.code).toBe(0);
|
||||
for (const grant of ['telemetry', 'brain-sync', 'redact_repo_visibility', 'redact_prepush_hook']) {
|
||||
expect(r.stdout).toContain(grant);
|
||||
}
|
||||
expect(r.stdout).not.toContain('[GRANTED]');
|
||||
expect(r.stdout).toContain(path.join(home, 'config.yaml'));
|
||||
expect(r.stdout).toContain('revoke:');
|
||||
});
|
||||
|
||||
test('--json flips granted=true when telemetry and sync mode are enabled', () => {
|
||||
const config = spawnSync(path.join(ROOT, 'bin', 'gstack-config'), ['set', 'telemetry', 'community'], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
});
|
||||
expect(config.status).toBe(0);
|
||||
spawnSync(path.join(ROOT, 'bin', 'gstack-config'), ['set', 'artifacts_sync_mode', 'full'], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
});
|
||||
const r = run(['grants', '--json']);
|
||||
expect(r.code).toBe(0);
|
||||
const grants = JSON.parse(r.stdout);
|
||||
const telemetry = grants.find((g: any) => g.grant === 'telemetry');
|
||||
expect(telemetry.granted).toBe(true);
|
||||
expect(telemetry.revoke).toContain('telemetry off');
|
||||
const sync = grants.find((g: any) => g.grant === 'brain-sync');
|
||||
expect(sync.granted).toBe(true);
|
||||
expect(sync.value).toBe('full');
|
||||
const hook = grants.find((g: any) => g.grant === 'redact_prepush_hook');
|
||||
expect(hook.granted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-egress usage', () => {
|
||||
test('no subcommand exits 2 with usage', () => {
|
||||
const r = run([]);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('Usage');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue