Files
gbrain/test/minions-shell-inherit.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

121 lines
4.8 KiB
TypeScript

/**
* Tests for `src/core/minions/handlers/shell-inherit.ts` — free-form helpers
* for `inherit:` secret resolution (v0.36.5.0).
*
* Two pure functions and one regex. Properties under test:
* - `INHERIT_NAME_RE` matches snake_case shapes, rejects everything else.
* - `deriveEnvKey` returns the right ENV-key name per convention.
* - `resolveInheritValue` uses `Object.hasOwn` (prototype-pollution defense),
* returns undefined for missing / non-string / empty-string values, and
* handles `null` config.
*/
import { describe, test, expect } from 'bun:test';
import {
INHERIT_NAME_RE,
deriveEnvKey,
resolveInheritValue,
} from '../src/core/minions/handlers/shell-inherit.ts';
import type { GBrainConfig } from '../src/core/config.ts';
describe('INHERIT_NAME_RE', () => {
test.each([
'database_url',
'anthropic_api_key',
'openai_api_key',
'voyage_api_key',
'groq_api_key',
'zeroentropy_api_key',
'remote_mcp_oauth_client_secret',
'field2',
'a',
'a_b_c_d_e',
])('accepts snake_case shape: %s', (name) => {
expect(INHERIT_NAME_RE.test(name)).toBe(true);
});
test.each([
'__proto__', // leading underscore (prototype pollution)
'_underscore_first', // leading underscore
'CamelCase', // uppercase letters
'UPPER_CASE', // all uppercase
'0_leading_digit', // leading digit
'has-dash', // hyphen
'has.dot', // dot
'../traversal', // path-traversal shape
'has space', // whitespace
'', // empty
'has\nnewline', // newline
])('rejects non-snake_case shape: %s', (name) => {
expect(INHERIT_NAME_RE.test(name)).toBe(false);
});
});
describe('deriveEnvKey', () => {
test('database_url → GBRAIN_DATABASE_URL (override, less ambiguous)', () => {
expect(deriveEnvKey('database_url')).toBe('GBRAIN_DATABASE_URL');
});
test('anthropic_api_key → ANTHROPIC_API_KEY (provider-standard uppercase)', () => {
expect(deriveEnvKey('anthropic_api_key')).toBe('ANTHROPIC_API_KEY');
});
test('openai_api_key → OPENAI_API_KEY', () => {
expect(deriveEnvKey('openai_api_key')).toBe('OPENAI_API_KEY');
});
test('voyage_api_key → VOYAGE_API_KEY', () => {
expect(deriveEnvKey('voyage_api_key')).toBe('VOYAGE_API_KEY');
});
test('groq_api_key → GROQ_API_KEY', () => {
expect(deriveEnvKey('groq_api_key')).toBe('GROQ_API_KEY');
});
test('arbitrary_field → ARBITRARY_FIELD (default uppercase)', () => {
expect(deriveEnvKey('arbitrary_field')).toBe('ARBITRARY_FIELD');
});
});
describe('resolveInheritValue', () => {
test('returns string when field exists and is a non-empty string', () => {
const cfg: GBrainConfig = { engine: 'postgres', database_url: 'postgresql://x' };
expect(resolveInheritValue(cfg, 'database_url')).toBe('postgresql://x');
});
test('returns undefined when field is unset', () => {
expect(resolveInheritValue({ engine: 'postgres' }, 'database_url')).toBeUndefined();
});
test('returns undefined when field is empty string', () => {
const cfg: GBrainConfig = { engine: 'postgres', database_url: '' };
expect(resolveInheritValue(cfg, 'database_url')).toBeUndefined();
});
test('returns undefined for null config', () => {
expect(resolveInheritValue(null, 'database_url')).toBeUndefined();
});
test('returns undefined when field is non-string (e.g. object)', () => {
const cfg = { engine: 'postgres', remote_mcp: { issuer_url: 'x' } } as unknown as GBrainConfig;
expect(resolveInheritValue(cfg, 'remote_mcp')).toBeUndefined();
});
test('prototype-pollution defense: __proto__ returns undefined even though Object.prototype has the property', () => {
expect(resolveInheritValue({ engine: 'postgres' }, '__proto__')).toBeUndefined();
});
test('prototype-pollution defense: constructor returns undefined', () => {
expect(resolveInheritValue({ engine: 'postgres' }, 'constructor')).toBeUndefined();
});
test('prototype-pollution defense: toString returns undefined', () => {
expect(resolveInheritValue({ engine: 'postgres' }, 'toString')).toBeUndefined();
});
});
describe('integration: deriveEnvKey + resolveInheritValue work together', () => {
const cfg: GBrainConfig = {
engine: 'postgres',
database_url: 'postgresql://x',
anthropic_api_key: 'sk-ant-x',
openai_api_key: 'sk-x',
};
test.each([
['database_url', 'GBRAIN_DATABASE_URL', 'postgresql://x'],
['anthropic_api_key', 'ANTHROPIC_API_KEY', 'sk-ant-x'],
['openai_api_key', 'OPENAI_API_KEY', 'sk-x'],
])('name %s resolves to envKey %s with value %s', (name, expectedEnvKey, expectedValue) => {
expect(deriveEnvKey(name)).toBe(expectedEnvKey);
expect(resolveInheritValue(cfg, name)).toBe(expectedValue);
});
});