From 019c05546a1fb6a7e613a0413ca8cc5d57be9108 Mon Sep 17 00:00:00 2001 From: Nicolas Wormser Date: Sun, 5 Jul 2026 20:43:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(packs):=20=F0=9F=94=90=20carve=20change-2?= =?UTF-8?q?=20secret=20blocking=20into=20its=20own=20slice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✦ Six secret packs off main: env, sops, private-key, encryption-tools, secret-managers, hardening ✦ Hardening gap fixes ride along: redirect/tee blocks, ssh-dir predicate, nonOverridable packs ✦ Steering packs, user-input rules, and the Claude Code adapter stay behind in the steering-first PR ✦ Pack gallery regenerated for the six shipped packs Co-authored-by: Claude --- .github/workflows/ci.yml | 2 + docs/packs.md | 72 +++++++++++++++ docs/rule-pack-guide.md | 3 + package.json | 3 +- scripts/generate-packs-gallery.mjs | 50 +++++++++++ src/core/types.ts | 2 + src/engine/engine.ts | 19 ++-- src/index.ts | 10 +++ src/infrastructure/yaml-pack-loader.ts | 5 +- src/matcher/matchers.ts | 4 +- src/packs/encryption-tools.yaml | 37 ++++++++ src/packs/env.yaml | 39 ++++++++ src/packs/hardening.yaml | 71 +++++++++++++++ src/packs/index.ts | 20 +++++ src/packs/packs.test.ts | 118 +++++++++++++++++++++++++ src/packs/predicates.ts | 22 +++++ src/packs/private-key.yaml | 37 ++++++++ src/packs/secret-managers.yaml | 48 ++++++++++ src/packs/sops.yaml | 27 ++++++ 19 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 docs/packs.md create mode 100644 scripts/generate-packs-gallery.mjs create mode 100644 src/packs/encryption-tools.yaml create mode 100644 src/packs/env.yaml create mode 100644 src/packs/hardening.yaml create mode 100644 src/packs/index.ts create mode 100644 src/packs/packs.test.ts create mode 100644 src/packs/predicates.ts create mode 100644 src/packs/private-key.yaml create mode 100644 src/packs/secret-managers.yaml create mode 100644 src/packs/sops.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 651e872..4ca0197 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: - run: npm run lint - run: npm run format:check - run: npm run typecheck + - name: Verify pack gallery is up to date + run: npm run docs:packs && git diff --exit-code docs/packs.md test: name: Test diff --git a/docs/packs.md b/docs/packs.md new file mode 100644 index 0000000..29ab64e --- /dev/null +++ b/docs/packs.md @@ -0,0 +1,72 @@ +# Rule Pack Gallery + + + +6 packs, 21 rules, generated from `src/packs/`. + +## Steering (quality of life) + +## Security + +### `encryption-tools` — Encryption Tool Protection + +Block decrypt operations from generic encryption CLIs + +| Rule | Behavior | Phase | What it does | +| ---- | -------- | ----- | ------------- | +| `encryption-tools.age` | `block` | `before-tool` | Catches age -d / --decrypt | +| `encryption-tools.gpg` | `block` | `before-tool` | Catches gpg -d / --decrypt | +| `encryption-tools.openssl` | `block` | `before-tool` | Catches openssl enc -d | + +### `env` — Env File Protection + +Steer .env reads to redacted forms so keys stay visible and values stay out of the model's context + +| Rule | Behavior | Phase | What it does | +| ---- | -------- | ----- | ------------- | +| `env.cat` | `suggest` | `before-tool` | Catches cat/less/more/head/tail on .env files and suggests a values-redacted read | +| `env.read` | `suggest` | `before-tool` | Catches direct read tool calls on .env files | +| `env.source` | `block` | `before-tool` | Catches source/dot-sourcing of .env files, which exports secrets into the shell | + +### `hardening` — Adversarial Wrapper Hardening + +Force-block shell constructs that hide risky operations from structural matching + +| Rule | Behavior | Phase | What it does | +| ---- | -------- | ----- | ------------- | +| `hardening.eval` | `block` | `before-tool` | eval hides its payload from structural matchers | +| `hardening.shell-c` | `block` | `before-tool` | Nested shell invocations hide their payload from structural matchers | +| `hardening.substitution` | `block` | `before-tool` | $() and backticks hide their payload from structural matchers | +| `hardening.redirect-read-sensitive` | `block` | `before-tool` | Detects < redirect from .env/.pem/.key/.p12/.pfx/.p8 files | +| `hardening.redirect-write-sensitive` | `block` | `before-tool` | Detects > or >> redirect to .env/.pem/.key files | +| `hardening.redirect-tee-sensitive` | `block` | `before-tool` | Detects tee writing to .env/.pem/.key files | + +### `private-key` — Private Key Protection + +Block reads of private key material — there is nothing safe to transform a key read into + +| Rule | Behavior | Phase | What it does | +| ---- | -------- | ----- | ------------- | +| `private-key.cat` | `block` | `before-tool` | Catches cat/less/head/tail on .pem/.key/id_* files | +| `private-key.read` | `block` | `before-tool` | Catches direct read tool calls on private key files by extension | +| `private-key.read-ssh-dir` | `block` | `before-tool` | Blocks any .ssh/ file except public keys, config, known_hosts, and authorized_keys | + +### `secret-managers` — Secret Manager Protection + +Block secret retrieval from password managers — a fetched secret has no safe partial form + +| Rule | Behavior | Phase | What it does | +| ---- | -------- | ----- | ------------- | +| `secret-managers.pass` | `block` | `before-tool` | Catches pass show and pass find output of stored secrets | +| `secret-managers.op` | `block` | `before-tool` | Catches op read and op item get | +| `secret-managers.gopass` | `block` | `before-tool` | Catches gopass show | +| `secret-managers.bitwarden` | `block` | `before-tool` | Catches bw get password/item | + +### `sops` — SOPS Decrypt Protection + +Steer SOPS decrypt operations to redacted output instead of raw secrets + +| Rule | Behavior | Phase | What it does | +| ---- | -------- | ----- | ------------- | +| `sops.decrypt` | `suggest` | `before-tool` | Catches sops -d / --decrypt and suggests piping through a values-redacting sed | +| `sops.exec-env` | `block` | `before-tool` | sops exec-env injects decrypted values into a child process environment the agent controls | diff --git a/docs/rule-pack-guide.md b/docs/rule-pack-guide.md index b567766..f5dafb6 100644 --- a/docs/rule-pack-guide.md +++ b/docs/rule-pack-guide.md @@ -38,6 +38,7 @@ EOF id: my-pack # Unique identifier (kebab-case) name: My Rule Pack # Human-readable name description: What this protects # Explains the pack's purpose +nonOverridable: true # Optional; user config cannot disable this pack's rules # ─── Rules ─── rules: @@ -210,6 +211,8 @@ You can disable or change any built-in rule in `agent-guardrails.json`: } ``` +Packs marked `nonOverridable: true` (currently only `hardening`) ignore these overrides — their rules always fire with their default action ([ADR-007](adrs/007-trust-and-self-protection.md)). Adversarial wrapper patterns are guilty until proven innocent. + ## Defense in Depth Tip For maximum coverage, pair `bash-command` and `file-path` matchers on the same concern: diff --git a/package.json b/package.json index a1a80a2..e5408fe 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "format:check": "oxfmt --check .", "check": "oxlint --type-aware . && oxfmt --check . && tsc --noEmit", "docs": "typedoc", - "docs:check": "typedoc --emit none" + "docs:check": "typedoc --emit none", + "docs:packs": "node scripts/generate-packs-gallery.mjs" }, "devDependencies": { "@types/node": "^20.12.0", diff --git a/scripts/generate-packs-gallery.mjs b/scripts/generate-packs-gallery.mjs new file mode 100644 index 0000000..03ada35 --- /dev/null +++ b/scripts/generate-packs-gallery.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Regenerates docs/packs.md from src/packs/*.yaml. Run via `npm run docs:packs`. +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse } from 'yaml' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const packsDir = join(root, 'src', 'packs') + +const STEERING = new Set(['modern-cli', 'package-manager', 'git-safety']) + +const packs = readdirSync(packsDir) + .filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')) + .map((f) => parse(readFileSync(join(packsDir, f), 'utf-8'))) + .sort((a, b) => a.id.localeCompare(b.id)) + +function renderGroup(title, group) { + const lines = [`## ${title}`, ''] + for (const pack of group) { + lines.push(`### \`${pack.id}\` — ${pack.name}`, '', pack.description, '') + lines.push('| Rule | Behavior | Phase | What it does |') + lines.push('| ---- | -------- | ----- | ------------- |') + for (const rule of pack.rules) { + lines.push( + `| \`${rule.id}\` | \`${rule.defaultAction.type}\` | \`${rule.phase}\` | ${rule.description} |` + ) + } + lines.push('') + } + return lines +} + +const steering = packs.filter((p) => STEERING.has(p.id)) +const security = packs.filter((p) => !STEERING.has(p.id)) +const ruleCount = packs.reduce((n, p) => n + p.rules.length, 0) + +const out = [ + '# Rule Pack Gallery', + '', + '', + '', + `${packs.length} packs, ${ruleCount} rules, generated from \`src/packs/\`.`, + '', + ...renderGroup('Steering (quality of life)', steering), + ...renderGroup('Security', security), +].join('\n') + +writeFileSync(join(root, 'docs', 'packs.md'), out) +process.stdout.write(`docs/packs.md: ${packs.length} packs, ${ruleCount} rules\n`) diff --git a/src/core/types.ts b/src/core/types.ts index 5f59df6..5d6346e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -131,6 +131,8 @@ export interface RulePack { name: string description: string rules: GuardrailRule[] + /** When true, user configuration cannot override or disable this pack's rules (ADR-007). */ + nonOverridable?: boolean } /** diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 3c7aa3e..0e56006 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -5,7 +5,6 @@ import type { HarnessCapabilities, MatchResult, DomainEvent, - BeforeToolAction, } from '../core/types.js' import type { PredicateRegistry } from '../core/predicate-registry.js' import type { StatsTracker } from './stats-tracker.js' @@ -49,7 +48,9 @@ export function processMatch( return handleMissingTargetsTraced(ctx, stats) } - const commands = command ? splitCommands(command) : [''] + // user-input contexts carry prompt text, which is never command-split (ADR-010) + const commands = + ctx.toolName === 'user-input' ? [command ?? ''] : command ? splitCommands(command) : [''] const result = findFirstMatchTraced(ctx, commands, packs, capabilities, registry) stats.record(result.action) return result @@ -100,11 +101,12 @@ function matchPackRulesTraced( cmd: string, registry: PredicateRegistry ): MatchResult | undefined { + const ctxPhase = ctx.toolName === 'user-input' ? 'user-input' : 'before-tool' for (const rule of pack.rules) { // eslint-disable-next-line no-warning-comments // TODO(after-tool): Evaluate after-tool rules in a second pass for redact behavior. // Currently unimplemented — after-tool phase is reserved for output redaction. - if (rule.phase !== 'before-tool') continue + if (rule.phase !== ctxPhase) continue if (!matchesMatcher(rule.match, matchCtx, registry)) continue const matchedValue = cmd || ctx.filePath || '' @@ -117,7 +119,14 @@ function matchPackRulesTraced( matched: matchedValue, } - const resolved = resolveAction(rule.defaultAction, capabilities, { + // In the user-input phase, prompt rewriting is gated on the dedicated + // redactUserInput capability rather than tool-output redact (ADR-010). + const effectiveCapabilities: HarnessCapabilities = + ctxPhase === 'user-input' + ? { ...capabilities, redact: capabilities.redactUserInput === true } + : capabilities + + const resolved = resolveAction(rule.defaultAction, effectiveCapabilities, { matched: matchedValue, replacement, }) @@ -137,7 +146,7 @@ function matchPackRulesTraced( * the rule's declared action type against the resolved action type. */ function detectFallback( - original: BeforeToolAction, + original: GuardrailAction, resolved: GuardrailAction ): DomainEvent | undefined { if (original.type === resolved.type) return undefined diff --git a/src/index.ts b/src/index.ts index 285dd10..7cf4606 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,16 @@ export { claudeCodeCapabilities, } from './core/harness-capabilities.js' +// ── Built-in Rule Packs ───────────────────────────────── +export { + /** Register built-in predicates and load every shipped rule pack — the zero-config path. */ + loadBuiltInRulePacks, + /** Register the predicates the built-in packs reference. */ + registerBuiltInPredicates, + /** Directory containing the shipped YAML rule packs. */ + BUILTIN_PACKS_DIR, +} from './packs/index.js' + // ── Core Types ────────────────────────────────────────── export type { HarnessCapabilities } from './core/types.js' export type { diff --git a/src/infrastructure/yaml-pack-loader.ts b/src/infrastructure/yaml-pack-loader.ts index 576d781..f1ca2e8 100644 --- a/src/infrastructure/yaml-pack-loader.ts +++ b/src/infrastructure/yaml-pack-loader.ts @@ -79,7 +79,10 @@ export function loadYamlRulePack(filePath: string, predicateRegistry: PredicateR } }) - const pack: unknown = { id, name, description, rules } + const pack: unknown = + raw.nonOverridable === true + ? { id, name, description, rules, nonOverridable: true } + : { id, name, description, rules } if (!validateRulePack(pack)) { const errors = getRulePackErrors(pack) diff --git a/src/matcher/matchers.ts b/src/matcher/matchers.ts index e5769cb..1d16cf8 100644 --- a/src/matcher/matchers.ts +++ b/src/matcher/matchers.ts @@ -33,7 +33,9 @@ export function matchesMatcher( ): boolean { switch (matcher.type) { case 'bash-command': { - if (ctx.toolName !== 'bash' || !ctx.command) return false + // user-input contexts carry the prompt text in `command`, so + // bash-command patterns apply to it unchanged (ADR-010). + if ((ctx.toolName !== 'bash' && ctx.toolName !== 'user-input') || !ctx.command) return false if (ctx.command.length > MAX_MATCH_INPUT_LENGTH) return true // Local copy defends against shared-state regex (global / sticky flags). const re = new RegExp(matcher.pattern.source, matcher.pattern.flags) diff --git a/src/packs/encryption-tools.yaml b/src/packs/encryption-tools.yaml new file mode 100644 index 0000000..400a4ac --- /dev/null +++ b/src/packs/encryption-tools.yaml @@ -0,0 +1,37 @@ +id: encryption-tools +name: Encryption Tool Protection +description: Block decrypt operations from generic encryption CLIs + +rules: + - id: encryption-tools.age + title: Block age decrypt + description: Catches age -d / --decrypt + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)age\\s+(.*\\s)?(-d|--decrypt)\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` would put decrypted content into context.' + + - id: encryption-tools.gpg + title: Block gpg decrypt + description: Catches gpg -d / --decrypt + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)gpg\\s+(.*\\s)?(-d|--decrypt)\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` would put decrypted content into context.' + + - id: encryption-tools.openssl + title: Block openssl decrypt + description: Catches openssl enc -d + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)openssl\\s+enc\\s+(.*\\s)?-d\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` would put decrypted content into context.' diff --git a/src/packs/env.yaml b/src/packs/env.yaml new file mode 100644 index 0000000..1ae93d3 --- /dev/null +++ b/src/packs/env.yaml @@ -0,0 +1,39 @@ +id: env +name: Env File Protection +description: Steer .env reads to redacted forms so keys stay visible and values stay out of the model's context + +rules: + - id: env.cat + title: Redacted read for .env files via shell + description: Catches cat/less/more/head/tail on .env files and suggests a values-redacted read + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)(cat|less|more|head|tail)\\s+[^;&|]*\\.env(\\.[\\w.-]+)?\\b" + defaultAction: + type: suggest + replacement: "sed 's/=.*/=[REDACTED]/' .env" + message: '`{matched}` would put secret values into context. Read the keys without the values instead: `{replacement}`' + + - id: env.read + title: Redacted read for .env files via read tool + description: Catches direct read tool calls on .env files + phase: before-tool + match: + type: file-path + pattern: "(^|/)\\.env(\\.[\\w.-]+)?$" + defaultAction: + type: suggest + replacement: "sed 's/=.*/=[REDACTED]/' .env" + message: 'Reading `{matched}` would put secret values into context. Read the keys without the values instead: `{replacement}`' + + - id: env.source + title: Block sourcing env files + description: Catches source/dot-sourcing of .env files, which exports secrets into the shell + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)(source|\\.)\\s+[^;&|]*\\.env(\\.[\\w.-]+)?\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` exports secrets into the shell environment.' diff --git a/src/packs/hardening.yaml b/src/packs/hardening.yaml new file mode 100644 index 0000000..407737e --- /dev/null +++ b/src/packs/hardening.yaml @@ -0,0 +1,71 @@ +id: hardening +name: Adversarial Wrapper Hardening +description: Force-block shell constructs that hide risky operations from structural matching +nonOverridable: true + +rules: + - id: hardening.eval + title: Block eval around sensitive operations + description: eval hides its payload from structural matchers + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)eval\\s+.*(\\.env|\\.pem|\\.key|sops|decrypt|pass\\s+show|op\\s+read)" + defaultAction: + type: block + message: 'Blocked: `{matched}` — eval can hide dangerous commands from guardrails.' + + - id: hardening.shell-c + title: Block bash -c / sh -c around sensitive operations + description: Nested shell invocations hide their payload from structural matchers + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)(bash|sh|zsh)\\s+-c\\s+.*(\\.env|\\.pem|\\.key|sops|decrypt|pass\\s+show|op\\s+read)" + defaultAction: + type: block + message: 'Blocked: `{matched}` — nested shells can hide dangerous commands from guardrails.' + + - id: hardening.substitution + title: Block command substitution around sensitive operations + description: $() and backticks hide their payload from structural matchers + phase: before-tool + match: + type: bash-command + pattern: "(\\$\\(|`).*(\\.env|\\.pem|\\.key|sops\\s|decrypt|pass\\s+show|op\\s+read)" + defaultAction: + type: block + message: 'Blocked: `{matched}` — command substitution can hide dangerous commands from guardrails.' + + - id: hardening.redirect-read-sensitive + title: Block redirects reading from sensitive files + description: Detects < redirect from .env/.pem/.key/.p12/.pfx/.p8 files + phase: before-tool + match: + type: bash-command + pattern: "<\\s*\\S*\\.(env|pem|key|p12|pfx|p8)\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` — redirecting from sensitive files may leak secrets.' + + - id: hardening.redirect-write-sensitive + title: Block redirects writing to sensitive files + description: Detects > or >> redirect to .env/.pem/.key files + phase: before-tool + match: + type: bash-command + pattern: ">>?\\s*\\S*\\.(env|pem|key)\\s*$" + defaultAction: + type: block + message: 'Blocked: `{matched}` — writing to sensitive files via redirect.' + + - id: hardening.redirect-tee-sensitive + title: Block tee to sensitive files + description: Detects tee writing to .env/.pem/.key files + phase: before-tool + match: + type: bash-command + pattern: "\\btee\\s+[^|;&]*\\.(env|pem|key)\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` — tee can write secrets to sensitive files.' diff --git a/src/packs/index.ts b/src/packs/index.ts new file mode 100644 index 0000000..cec010e --- /dev/null +++ b/src/packs/index.ts @@ -0,0 +1,20 @@ +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { RulePack } from '../core/types.js' +import type { PredicateRegistry } from '../core/predicate-registry.js' +import { loadAllRulePacks } from '../infrastructure/yaml-pack-loader.js' +import { registerBuiltInPredicates } from './predicates.js' + +/** Directory containing the built-in YAML rule packs shipped with the package. */ +export const BUILTIN_PACKS_DIR = dirname(fileURLToPath(import.meta.url)) + +/** + * Register the built-in predicates and load every shipped rule pack. + * The zero-config path: `createEngine(loadBuiltInRulePacks(registry), capabilities, { registry })`. + */ +export function loadBuiltInRulePacks(registry: PredicateRegistry): RulePack[] { + registerBuiltInPredicates(registry) + return loadAllRulePacks(BUILTIN_PACKS_DIR, registry) +} + +export { registerBuiltInPredicates } from './predicates.js' diff --git a/src/packs/packs.test.ts b/src/packs/packs.test.ts new file mode 100644 index 0000000..d847e79 --- /dev/null +++ b/src/packs/packs.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { PredicateRegistry } from '../core/predicate-registry.js' +import { loadBuiltInRulePacks } from './index.js' +import { createEngine, PI_CAPABILITIES } from '../index.js' +import type { ToolCallContext } from '../core/types.js' + +function makeEngine() { + const registry = new PredicateRegistry() + const packs = loadBuiltInRulePacks(registry) + return { packs, engine: createEngine(packs, PI_CAPABILITIES, { registry }) } +} + +const bash = (command: string): ToolCallContext => ({ toolName: 'bash', command }) + +describe('built-in packs', () => { + it('loads all six packs without validation errors', () => { + const { packs } = makeEngine() + const ids = packs.map((p) => p.id).sort() + expect(ids).toEqual([ + 'encryption-tools', + 'env', + 'hardening', + 'private-key', + 'secret-managers', + 'sops', + ]) + }) + + it('suggests a redacted read for cat .env', () => { + const { engine } = makeEngine() + const action = engine.evaluate(bash('cat .env')) + expect(action?.type).toBe('suggest') + }) + + it('suggests a redacted read for read tool on .env', () => { + const { engine } = makeEngine() + const action = engine.evaluate({ toolName: 'read', filePath: '/repo/.env.production' }) + expect(action?.type).toBe('suggest') + }) + + it('suggests a redacted pipe for sops decrypt', () => { + const { engine } = makeEngine() + expect(engine.evaluate(bash('sops -d secrets.yaml'))?.type).toBe('suggest') + expect(engine.evaluate(bash('sops --decrypt secrets.yaml'))?.type).toBe('suggest') + }) + + it('does not fire sops rule on sops --help', () => { + const { engine } = makeEngine() + expect(engine.evaluate(bash('sops --help'))).toBeNull() + }) + + it('blocks private key reads via bash and read tool', () => { + const { engine } = makeEngine() + expect(engine.evaluate(bash('cat ~/.ssh/id_rsa'))?.type).toBe('block') + expect(engine.evaluate({ toolName: 'read', filePath: '/home/u/.ssh/id_ed25519' })?.type).toBe( + 'block' + ) + }) + + it('allows reading SSH public keys', () => { + const { engine } = makeEngine() + expect( + engine.evaluate({ toolName: 'read', filePath: '/home/u/.ssh/id_ed25519.pub' }) + ).toBeNull() + }) + + it('blocks non-public files in the SSH directory', () => { + const { engine } = makeEngine() + expect( + engine.evaluate({ toolName: 'read', filePath: '/home/u/.ssh/my_custom_key' })?.type + ).toBe('block') + }) + + it('allows SSH config, known_hosts, and authorized_keys', () => { + const { engine } = makeEngine() + expect(engine.evaluate({ toolName: 'read', filePath: '/home/u/.ssh/config' })).toBeNull() + expect(engine.evaluate({ toolName: 'read', filePath: '/home/u/.ssh/known_hosts' })).toBeNull() + expect( + engine.evaluate({ toolName: 'read', filePath: '/home/u/.ssh/authorized_keys' }) + ).toBeNull() + }) + + it('blocks secret manager retrieval', () => { + const { engine } = makeEngine() + expect(engine.evaluate(bash('pass show work/aws'))?.type).toBe('block') + expect(engine.evaluate(bash('op read op://vault/item/password'))?.type).toBe('block') + expect(engine.evaluate(bash('bw get password github'))?.type).toBe('block') + }) + + it('blocks decrypt commands from encryption tools', () => { + const { engine } = makeEngine() + expect(engine.evaluate(bash('gpg --decrypt secrets.gpg'))?.type).toBe('block') + expect(engine.evaluate(bash('age -d -i key.txt file.age'))?.type).toBe('block') + expect(engine.evaluate(bash('openssl enc -d -aes256 -in file.enc'))?.type).toBe('block') + }) + + it('force-blocks adversarial wrappers around sensitive operations', () => { + const { engine } = makeEngine() + expect(engine.evaluate(bash('eval "sops -d secrets.yaml"'))?.type).toBe('block') + expect(engine.evaluate(bash('bash -c "cat .env"'))?.type).toBe('block') + }) + + it('blocks redirects that read from or write to sensitive files', () => { + const { engine } = makeEngine() + expect(engine.evaluate(bash('grep DB_HOST < .env'))?.type).toBe('block') + expect(engine.evaluate(bash('echo AWS_KEY=x > .env'))?.type).toBe('block') + expect(engine.evaluate(bash('echo x >> server.key'))?.type).toBe('block') + expect(engine.evaluate(bash('curl https://x.example | tee .env'))?.type).toBe('block') + expect(engine.evaluate(bash('cat file.txt > output.log'))).toBeNull() + }) + + it('marks only the hardening pack nonOverridable', () => { + const { packs } = makeEngine() + for (const pack of packs) { + expect(pack.nonOverridable ?? false).toBe(pack.id === 'hardening') + } + }) +}) diff --git a/src/packs/predicates.ts b/src/packs/predicates.ts new file mode 100644 index 0000000..7c1c515 --- /dev/null +++ b/src/packs/predicates.ts @@ -0,0 +1,22 @@ +import type { ToolCallContext } from '../core/types.js' +import type { PredicateRegistry } from '../core/predicate-registry.js' + +const SSH_ALLOWLIST = new Set(['known_hosts', 'config', 'authorized_keys']) + +/** Any file in a `.ssh/` directory that is not a public key or a known-safe config file. */ +export function sshPrivateKey(ctx: ToolCallContext): boolean { + const filePath = 'filePath' in ctx && ctx.filePath ? ctx.filePath : '' + if (!/\.ssh\//.test(filePath)) return false + const filename = filePath.split('/').pop() ?? '' + if (SSH_ALLOWLIST.has(filename)) return false + if (/\.(pub|pubkey)$/.test(filename)) return false + return true +} + +/** + * Register the built-in predicates the shipped packs reference. + * Adapters call this before `loadAllRulePacks()` on the built-in pack directory. + */ +export function registerBuiltInPredicates(registry: PredicateRegistry): void { + registry.register('ssh-private-key', sshPrivateKey) +} diff --git a/src/packs/private-key.yaml b/src/packs/private-key.yaml new file mode 100644 index 0000000..ac2788b --- /dev/null +++ b/src/packs/private-key.yaml @@ -0,0 +1,37 @@ +id: private-key +name: Private Key Protection +description: Block reads of private key material — there is nothing safe to transform a key read into + +rules: + - id: private-key.cat + title: Block shell reads of key files + description: Catches cat/less/head/tail on .pem/.key/id_* files + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)(cat|less|more|head|tail)\\s+[^;&|]*(\\.pem|\\.key|id_(rsa|ed25519|ecdsa|dsa))\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` would expose private key material.' + + - id: private-key.read + title: Block read tool calls on key files + description: Catches direct read tool calls on private key files by extension + phase: before-tool + match: + type: file-path + pattern: "\\.(pem|key|p12|pfx|p8)$" + defaultAction: + type: block + message: 'Blocked: reading `{matched}` would expose private key material.' + + - id: private-key.read-ssh-dir + title: Block reads of non-public SSH directory files + description: Blocks any .ssh/ file except public keys, config, known_hosts, and authorized_keys + phase: before-tool + match: + type: predicate + predicateName: ssh-private-key + defaultAction: + type: block + message: 'Blocked: reading `{matched}` would expose private key material.' diff --git a/src/packs/secret-managers.yaml b/src/packs/secret-managers.yaml new file mode 100644 index 0000000..f6f558d --- /dev/null +++ b/src/packs/secret-managers.yaml @@ -0,0 +1,48 @@ +id: secret-managers +name: Secret Manager Protection +description: Block secret retrieval from password managers — a fetched secret has no safe partial form + +rules: + - id: secret-managers.pass + title: Block pass show + description: Catches pass show and pass find output of stored secrets + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)pass\\s+(show\\b|\\S*/)" + defaultAction: + type: block + message: 'Blocked: `{matched}` retrieves a stored secret — no safe partial exists.' + + - id: secret-managers.op + title: Block 1Password CLI reads + description: Catches op read and op item get + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)op\\s+(read|item\\s+get)\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` retrieves a stored secret — no safe partial exists.' + + - id: secret-managers.gopass + title: Block gopass show + description: Catches gopass show + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)gopass\\s+(show|cat)\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` retrieves a stored secret — no safe partial exists.' + + - id: secret-managers.bitwarden + title: Block Bitwarden CLI secret retrieval + description: Catches bw get password/item + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)bw\\s+get\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` retrieves a stored secret — no safe partial exists.' diff --git a/src/packs/sops.yaml b/src/packs/sops.yaml new file mode 100644 index 0000000..4a4ee83 --- /dev/null +++ b/src/packs/sops.yaml @@ -0,0 +1,27 @@ +id: sops +name: SOPS Decrypt Protection +description: Steer SOPS decrypt operations to redacted output instead of raw secrets + +rules: + - id: sops.decrypt + title: Redacted pipe for sops decrypt + description: Catches sops -d / --decrypt and suggests piping through a values-redacting sed + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)sops\\s+(.*\\s)?(-d|--decrypt)\\b" + defaultAction: + type: suggest + replacement: "sops -d | sed -E 's/(: ).+/\\1[REDACTED]/'" + message: '`{matched}` would put decrypted secrets into context. Pipe through a redactor to inspect structure: `{replacement}`' + + - id: sops.exec-env + title: Block sops exec-env + description: sops exec-env injects decrypted values into a child process environment the agent controls + phase: before-tool + match: + type: bash-command + pattern: "(^|[;&|]\\s*)sops\\s+exec-env\\b" + defaultAction: + type: block + message: 'Blocked: `{matched}` exposes decrypted secrets to an agent-controlled process.'