Files
gbrain/test/e2e/minions-shell-pglite.test.ts
T
e227965024 v0.36.5.0 feat: secure DATABASE_URL access for shell jobs (inherit: ["database_url"]) (#1192)
* v0.36.5.0 feat: secure DATABASE_URL access for shell jobs (inherit: ["database_url"])

Replaces PR #1137's plaintext-config / plaintext-env workarounds with code.
Shell-job params gain `inherit: ["database_url"]`, validated pre-enqueue in
both the CLI (`gbrain jobs submit`) and `submit_job` MCP op handler. Worker
resolves the value from its own loadConfig() at child-spawn time; the
persisted `minion_jobs.data` row stores only the name. Plain
`env: { GBRAIN_DATABASE_URL: ... }` / `env: { DATABASE_URL: ... }` /
`env: { GBRAIN_DIRECT_DATABASE_URL: ... }` are rejected pre-enqueue with a
paste-ready hint pointing at `inherit:`.

Codex pre-landing review caught two bypasses + one missing shadow name:
- H1: cmd/argv inline-secret regex scan (cmd:"GBRAIN_DATABASE_URL=... gbrain
  sync" was a clean bypass — fixed)
- H3: GBRAIN_DIRECT_DATABASE_URL added to shadowKeys
- H2: honest docs about output-side leakage (stdout_tail/stderr_tail can still
  carry the value if the script prints it; that's the script author's
  responsibility, not gbrain's)

Also: gbrain doctor learns home_dir_in_worktree (warns when ~/.gbrain lives
inside a git worktree); ~/.gbrain/.gitignore retroactive via saveConfig +
post-upgrade.

New canonical guide: docs/guides/agent-to-gbrain.md (two-domain framing for
downstream agent authors: MCP ops via OAuth vs localOnly admin ops via
shell-job inherit:).

Closes #1137. Tests: +53 new (21 validator + 12 inherit-record + 6
ensureGitignore + 5 doctor + 2 PGLite E2E + 7 codex-driven H1/H3 cases).

Credit: @wintermute filed PR #1137 which made the env-stripping gap visible
enough to fix in code. Thank you.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* v0.36.5.0 redesign: free-form inherit:, drop closed enum

User feedback: "agent spawning minions should have agency to do what it wants
with secrets and pass only the ones that it needs. don't be a security nazi
please."

Replaces the closed INHERITABLE enum (database_url only) with three small
helpers in shell-inherit.ts:

- INHERIT_NAME_RE: snake_case shape guard. Rejects __proto__, leading
  underscore, uppercase, path-traversal. Prototype-pollution defense.
- deriveEnvKey(name): config-key → child-env-key. Uppercase by default with
  one override: database_url → GBRAIN_DATABASE_URL.
- resolveInheritValue(cfg, name): value lookup with Object.hasOwn.

inherit: now accepts any snake_case config-key the worker has. Agent picks
what it needs per-job (database_url, anthropic_api_key, voyage_api_key, or
any custom field). Validator does NOT police WHICH keys — single-uid trust
model treats agent as peer of worker.

Drops the v0.36.5.0-RC rules that were paternalistic for the actual threat
model:
- closed-enum check
- env-shadow rejection
- cmd/argv inline-secret scan

Keeps the parts that defend real problems:
- pre-enqueue validation (closes the persistence-before-throw window)
- snake_case regex (prototype-pollution + audit-log readability)
- fail-fast on missing config value (UX guardrail, not security)

Tests: shell-validate (existing rules + new free-form + prototype-pollution
defense + T1 regression guard) and shell-inherit (regex matrix, deriveEnvKey
per-name, resolveInheritValue with hasOwn defense). E2E case now exercises
inherit:["anthropic_api_key"] to prove genuinely free-form.

Docs and CHANGELOG rewritten to reflect the open design + the design-arc
story (closed → cut → free-form). Migration file too.

7653 unit tests green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* v0.36.5.0 add: redact_secrets opt-in for stdout/stderr scrubbing

Honest defense for the documented output-side leakage. When a script prints
an inherited secret, the value lands plaintext in
result.stdout_tail / result.stderr_tail / error_text. v0.36.5.0 adds:

- `redact_secrets: true` ShellJobParams field
- `--redact-secrets` CLI convenience flag on `gbrain jobs submit shell`
- shell-redact.ts: pure `redactSecretsInText(text, secrets)` helper
  (string-mode replaceAll; regex metachars in values stay literal)
- Handler post-processes both tails before throw/return, so the persisted
  row carries `<REDACTED:name>` tokens instead of values

Only inherit-resolved values are scrubbed. env: values are not (those are
the agent's "fine in the row" channel by design). Heuristic — defeats
accidental `echo "$GBRAIN_DATABASE_URL"`, not adversarial encode-then-print.
Default false for back-compat.

Tests:
- test/minions-shell-redact.test.ts (9 cases): pure-function behavior,
  regex-metachar safety, multi-secret independent redaction, substring
  overlap, empty-input/map edge cases
- test/minions-shell-validate.test.ts: +4 cases for redact_secrets shape
- test/e2e/minions-shell-pglite.test.ts: +2 cases proving redact_secrets:
  true scrubs persisted row AND redact_secrets:false preserves plaintext
  (back-compat regression guard)

Docs + CHANGELOG + migration file + CLAUDE.md updated.

7667 unit tests green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:12:40 -07:00

394 lines
17 KiB
TypeScript

/**
* E2E Minions Shell Handler — PGLite / --follow inline execution path
*
* Closes the T4 gap surfaced during PR #381 eng review. The sibling file
* test/e2e/minions-shell.test.ts covers the Postgres + persistent-worker-daemon
* path. This file covers the PGLite path documented in the minion-orchestrator
* skill: `gbrain jobs submit shell ... --follow` runs inline because
* `gbrain jobs work` (daemon) is not available on PGLite (exclusive file lock).
*
* Mirrors the Postgres test's structure but runs in-memory against PGLiteEngine.
* No DATABASE_URL required, no Docker — runs in CI unconditionally.
*
* Run: bun test test/e2e/minions-shell-pglite.test.ts
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { MinionQueue } from '../../src/core/minions/queue.ts';
import { MinionWorker } from '../../src/core/minions/worker.ts';
import { registerBuiltinHandlers } from '../../src/commands/jobs.ts';
let engine: PGLiteEngine;
let originalAllowShellJobs: string | undefined;
async function waitTerminal(queue: MinionQueue, id: number, timeoutMs = 15000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const j = await queue.getJob(id);
if (j && ['completed', 'failed', 'dead', 'cancelled'].includes(j.status)) return j.status;
await new Promise((r) => setTimeout(r, 50));
}
const j = await queue.getJob(id);
throw new Error(`job ${id} did not reach terminal state in ${timeoutMs}ms; last status=${j?.status}`);
}
beforeAll(async () => {
// registerBuiltinHandlers gates shell handler on GBRAIN_ALLOW_SHELL_JOBS=1.
// Mirror the real --follow path by setting the env var; restore on cleanup
// so other tests see their original environment.
originalAllowShellJobs = process.env.GBRAIN_ALLOW_SHELL_JOBS;
process.env.GBRAIN_ALLOW_SHELL_JOBS = '1';
engine = new PGLiteEngine();
await engine.connect({}); // in-memory PGLite
await engine.initSchema(); // installs pages, minion_jobs, config, etc.
}, 30000);
afterAll(async () => {
await engine.disconnect();
if (originalAllowShellJobs === undefined) {
delete process.env.GBRAIN_ALLOW_SHELL_JOBS;
} else {
process.env.GBRAIN_ALLOW_SHELL_JOBS = originalAllowShellJobs;
}
});
describe('E2E: Minions shell handler on PGLite (--follow inline path)', () => {
// Mirror the Postgres sibling's per-test reset. The engine is shared across
// both tests via beforeAll; without this, completed jobs from one test leak
// into minion_jobs and future test additions hit order-dependency.
beforeEach(async () => {
const db = (engine as any).db;
await db.exec(`DELETE FROM minion_attachments; DELETE FROM minion_inbox; DELETE FROM minion_jobs;`);
});
test('submit → worker registered via registerBuiltinHandlers → shell runs → completes', async () => {
const queue = new MinionQueue(engine);
const job = await queue.add(
'shell',
{ cmd: 'echo hello', cwd: '/tmp' },
{},
{ allowProtectedSubmit: true },
);
expect(job.name).toBe('shell');
expect(job.status).toBe('waiting');
// This is the exact dispatch path --follow takes (src/commands/jobs.ts:207).
// Gates shell on GBRAIN_ALLOW_SHELL_JOBS=1 (set in beforeAll above).
const worker = new MinionWorker(engine, { pollInterval: 100, lockDuration: 30000 });
await registerBuiltinHandlers(worker, engine);
expect(worker.registeredNames).toContain('shell');
const runPromise = worker.start();
try {
const status = await waitTerminal(queue, job.id, 20000);
expect(status).toBe('completed');
const final = await queue.getJob(job.id);
expect((final!.result as any).exit_code).toBe(0);
expect((final!.result as any).stdout_tail).toBe('hello\n');
} finally {
worker.stop();
await runPromise;
}
}, 30000);
test('v0.35.8.0: inherit:["database_url"] resolves DATABASE_URL into child env, names-only in row + audit', async () => {
// Hermetic — drive `inherit` directly against the PGLite path. The
// production submit flow runs `validateShellJobParams` pre-enqueue, but
// here we exercise the handler-side resolution by submitting a row that
// already passed pre-enqueue validation upstream. Validates that:
// 1. The child env carries GBRAIN_DATABASE_URL with the resolved value.
// 2. The persisted row's `data.inherit` is ["database_url"] (names only).
// 3. The persisted row JSON does NOT contain the URL substring anywhere.
const { writeFileSync, mkdirSync, rmSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const { tmpdir } = await import('node:os');
const tmpHome = join(tmpdir(), `gbrain-inh-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
const testDbUrl = 'postgresql://test:T0P_5ECR3T@localhost:5432/inherit_e2e_db';
writeFileSync(
join(tmpHome, '.gbrain', 'config.json'),
JSON.stringify({ engine: 'postgres', database_url: testDbUrl }) + '\n',
);
const savedHome = process.env.GBRAIN_HOME;
const savedGbrainUrl = process.env.GBRAIN_DATABASE_URL;
const savedDbUrl = process.env.DATABASE_URL;
process.env.GBRAIN_HOME = tmpHome;
// loadConfig() merges env vars OVER config.json, so we must drop both env
// names while the worker reads its own config. When the E2E suite runs
// with a real DATABASE_URL set in the parent process, the inherited value
// would otherwise be the suite's postgres URL, not the test's.
delete process.env.GBRAIN_DATABASE_URL;
delete process.env.DATABASE_URL;
try {
const queue = new MinionQueue(engine);
const job = await queue.add(
'shell',
// `printenv` reflects the child env to stdout — proves the inherited
// secret reached the child without us having to leak it via the test.
{ cmd: 'printenv GBRAIN_DATABASE_URL', cwd: '/tmp', inherit: ['database_url'] },
{},
{ allowProtectedSubmit: true },
);
// T7 regression assertion: the persisted row carries names only, NEVER values.
const persisted = await queue.getJob(job.id);
expect((persisted!.data as Record<string, unknown>).inherit).toEqual(['database_url']);
// T1 + T7 negative-shape: the URL substring must not appear ANYWHERE in
// the persisted row's data JSON. Pinpoint the load-bearing R1 invariant.
const rowJson = JSON.stringify(persisted!.data);
expect(rowJson).not.toContain('T0P_5ECR3T');
expect(rowJson).not.toContain(testDbUrl);
const worker = new MinionWorker(engine, { pollInterval: 100, lockDuration: 30000 });
await registerBuiltinHandlers(worker, engine);
const runPromise = worker.start();
try {
const status = await waitTerminal(queue, job.id, 20000);
expect(status).toBe('completed');
const final = await queue.getJob(job.id);
// The child saw GBRAIN_DATABASE_URL = the configured URL.
expect((final!.result as Record<string, unknown>).stdout_tail).toBe(testDbUrl + '\n');
} finally {
worker.stop();
await runPromise;
}
} finally {
if (savedHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = savedHome;
if (savedGbrainUrl === undefined) delete process.env.GBRAIN_DATABASE_URL;
else process.env.GBRAIN_DATABASE_URL = savedGbrainUrl;
if (savedDbUrl === undefined) delete process.env.DATABASE_URL;
else process.env.DATABASE_URL = savedDbUrl;
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
}
}, 30000);
test('v0.36.5.0: inherit:["anthropic_api_key"] also resolves (free-form, any config key)', async () => {
// v0.36.5.0 free-form design: inherit accepts ANY snake_case config-key
// name, not a closed enum. Same single-uid trust model — the agent
// decides what to pass. This test exercises the non-database_url path
// to prove the mechanism is genuinely free-form.
const { writeFileSync, mkdirSync, rmSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const { tmpdir } = await import('node:os');
const tmpHome = join(tmpdir(), `gbrain-anth-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
const fakeKey = 'sk-ant-test-FAKE-KEY-FOR-E2E';
writeFileSync(
join(tmpHome, '.gbrain', 'config.json'),
JSON.stringify({
engine: 'postgres',
database_url: 'postgresql://x:y@h/d',
anthropic_api_key: fakeKey,
}) + '\n',
);
const savedHome = process.env.GBRAIN_HOME;
const savedAnth = process.env.ANTHROPIC_API_KEY;
process.env.GBRAIN_HOME = tmpHome;
delete process.env.ANTHROPIC_API_KEY;
try {
const queue = new MinionQueue(engine);
const job = await queue.add(
'shell',
{ cmd: 'printenv ANTHROPIC_API_KEY', cwd: '/tmp', inherit: ['anthropic_api_key'] },
{},
{ allowProtectedSubmit: true },
);
// Row carries name only — not value.
const persisted = await queue.getJob(job.id);
expect((persisted!.data as Record<string, unknown>).inherit).toEqual(['anthropic_api_key']);
const rowJson = JSON.stringify(persisted!.data);
expect(rowJson).not.toContain('sk-ant-test-FAKE-KEY-FOR-E2E');
const worker = new MinionWorker(engine, { pollInterval: 100, lockDuration: 30000 });
await registerBuiltinHandlers(worker, engine);
const runPromise = worker.start();
try {
const status = await waitTerminal(queue, job.id, 20000);
expect(status).toBe('completed');
const final = await queue.getJob(job.id);
// Child saw ANTHROPIC_API_KEY = configured key (derived env-name from snake_case)
expect((final!.result as Record<string, unknown>).stdout_tail).toBe(fakeKey + '\n');
} finally {
worker.stop();
await runPromise;
}
} finally {
if (savedHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = savedHome;
if (savedAnth === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = savedAnth;
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
}
}, 30000);
test('v0.36.5.0: redact_secrets:true scrubs inherit values from stdout_tail', async () => {
// Honest defense for the documented output-side leakage: when the script
// echoes the inherited value, redact_secrets:true ensures the persisted
// result.stdout_tail carries the <REDACTED:name> token instead of the
// plaintext value. The agent opts in per-job; default is false (back-compat).
const { writeFileSync, mkdirSync, rmSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const { tmpdir } = await import('node:os');
const tmpHome = join(tmpdir(), `gbrain-redact-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
const fakeKey = 'sk-ant-FAKE-REDACT-E2E';
const fakeUrl = 'postgresql://user:R3DACT_ME@host:5432/redactdb';
writeFileSync(
join(tmpHome, '.gbrain', 'config.json'),
JSON.stringify({
engine: 'postgres',
database_url: fakeUrl,
anthropic_api_key: fakeKey,
}) + '\n',
);
const savedHome = process.env.GBRAIN_HOME;
const savedDbUrl = process.env.GBRAIN_DATABASE_URL;
const savedAnth = process.env.ANTHROPIC_API_KEY;
process.env.GBRAIN_HOME = tmpHome;
delete process.env.GBRAIN_DATABASE_URL;
delete process.env.DATABASE_URL;
delete process.env.ANTHROPIC_API_KEY;
try {
const queue = new MinionQueue(engine);
const job = await queue.add(
'shell',
{
cmd: 'printenv GBRAIN_DATABASE_URL && printenv ANTHROPIC_API_KEY',
cwd: '/tmp',
inherit: ['database_url', 'anthropic_api_key'],
redact_secrets: true,
},
{},
{ allowProtectedSubmit: true },
);
const worker = new MinionWorker(engine, { pollInterval: 100, lockDuration: 30000 });
await registerBuiltinHandlers(worker, engine);
const runPromise = worker.start();
try {
const status = await waitTerminal(queue, job.id, 20000);
expect(status).toBe('completed');
const final = await queue.getJob(job.id);
const stdoutTail = (final!.result as Record<string, unknown>).stdout_tail as string;
// The actual values must NOT appear in the persisted row.
expect(stdoutTail).not.toContain('R3DACT_ME');
expect(stdoutTail).not.toContain(fakeUrl);
expect(stdoutTail).not.toContain(fakeKey);
// Redaction tokens point at WHICH inherit name was scrubbed.
expect(stdoutTail).toContain('<REDACTED:database_url>');
expect(stdoutTail).toContain('<REDACTED:anthropic_api_key>');
} finally {
worker.stop();
await runPromise;
}
} finally {
if (savedHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = savedHome;
if (savedDbUrl === undefined) delete process.env.GBRAIN_DATABASE_URL;
else process.env.GBRAIN_DATABASE_URL = savedDbUrl;
if (savedAnth === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = savedAnth;
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
}
}, 30000);
test('v0.36.5.0: redact_secrets:false (default) does NOT scrub — back-compat', async () => {
// The previous tests already prove default behavior (no scrubbing) by
// asserting stdout_tail equals the literal URL when redact_secrets is
// absent. This case is the explicit-false twin: passing false should
// be identical to omitting.
const { writeFileSync, mkdirSync, rmSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const { tmpdir } = await import('node:os');
const tmpHome = join(tmpdir(), `gbrain-rd-off-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
const fakeUrl = 'postgresql://u:NO_REDACT@h:5432/nrdb';
writeFileSync(
join(tmpHome, '.gbrain', 'config.json'),
JSON.stringify({ engine: 'postgres', database_url: fakeUrl }) + '\n',
);
const savedHome = process.env.GBRAIN_HOME;
const savedDbUrl = process.env.GBRAIN_DATABASE_URL;
const savedPlainDbUrl = process.env.DATABASE_URL;
process.env.GBRAIN_HOME = tmpHome;
delete process.env.GBRAIN_DATABASE_URL;
delete process.env.DATABASE_URL;
try {
const queue = new MinionQueue(engine);
const job = await queue.add(
'shell',
{
cmd: 'printenv GBRAIN_DATABASE_URL',
cwd: '/tmp',
inherit: ['database_url'],
redact_secrets: false,
},
{},
{ allowProtectedSubmit: true },
);
const worker = new MinionWorker(engine, { pollInterval: 100, lockDuration: 30000 });
await registerBuiltinHandlers(worker, engine);
const runPromise = worker.start();
try {
const status = await waitTerminal(queue, job.id, 20000);
expect(status).toBe('completed');
const final = await queue.getJob(job.id);
const stdoutTail = (final!.result as Record<string, unknown>).stdout_tail as string;
// Plaintext URL DOES appear when redact is off — back-compat with
// earlier inherit:["database_url"] tests in this file.
expect(stdoutTail).toBe(fakeUrl + '\n');
expect(stdoutTail).not.toContain('<REDACTED:');
} finally {
worker.stop();
await runPromise;
}
} finally {
if (savedHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = savedHome;
if (savedDbUrl === undefined) delete process.env.GBRAIN_DATABASE_URL;
else process.env.GBRAIN_DATABASE_URL = savedDbUrl;
if (savedPlainDbUrl === undefined) delete process.env.DATABASE_URL;
else process.env.DATABASE_URL = savedPlainDbUrl;
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
}
}, 30000);
test('GBRAIN_ALLOW_SHELL_JOBS unset → shellHandler rejects at execution time', async () => {
// v0.20.3+: shell handler is always registered (so claimed jobs emit a clear
// rejection log), but the runtime env guard lives inside the handler itself.
// Prove the guard rejects when the env var is unset.
const { shellHandler } = await import('../../src/core/minions/handlers/shell.ts');
const saved = process.env.GBRAIN_ALLOW_SHELL_JOBS;
delete process.env.GBRAIN_ALLOW_SHELL_JOBS;
try {
const ctx: any = {
id: 1,
name: 'shell',
data: { cmd: 'echo hi', cwd: '/tmp' },
attempt: 1,
engine,
};
await expect(shellHandler(ctx)).rejects.toThrow(/GBRAIN_ALLOW_SHELL_JOBS=1/);
} finally {
process.env.GBRAIN_ALLOW_SHELL_JOBS = saved;
}
});
});