From 842bc81a084d1e1a418fd53f6cfb26f1fe72dd7d Mon Sep 17 00:00:00 2001 From: Nicolas Wormser Date: Sun, 5 Jul 2026 20:29:44 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=94=90=20close=20the=20change-2?= =?UTF-8?q?=20secret-blocking=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ssh-private-key predicate (allowlisting config, known_hosts, authorized_keys — the old regex blocked them), hardening redirect/tee rules, and pack-level nonOverridable metadata on the hardening pack. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XLCHSd4NWR45fF5atExsbf --- docs/packs.md | 8 ++++-- docs/rule-pack-guide.md | 3 +++ src/core/types.ts | 2 ++ src/infrastructure/yaml-pack-loader.ts | 5 +++- src/packs/hardening.yaml | 34 ++++++++++++++++++++++++++ src/packs/packs.test.ts | 32 ++++++++++++++++++++++++ src/packs/predicates.ts | 13 ++++++++++ src/packs/private-key.yaml | 15 ++++++++++-- 8 files changed, 107 insertions(+), 5 deletions(-) diff --git a/docs/packs.md b/docs/packs.md index 568027c..34a6aef 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -2,7 +2,7 @@ -9 packs, 25 rules, generated from `src/packs/`. +9 packs, 29 rules, generated from `src/packs/`. ## Steering (quality of life) @@ -66,6 +66,9 @@ Force-block shell constructs that hide risky operations from structural matching | `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 @@ -74,7 +77,8 @@ Block reads of private key material — there is nothing safe to transform a key | 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, including the SSH directory | +| `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 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/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/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/packs/hardening.yaml b/src/packs/hardening.yaml index 6a84a76..407737e 100644 --- a/src/packs/hardening.yaml +++ b/src/packs/hardening.yaml @@ -1,6 +1,7 @@ id: hardening name: Adversarial Wrapper Hardening description: Force-block shell constructs that hide risky operations from structural matching +nonOverridable: true rules: - id: hardening.eval @@ -35,3 +36,36 @@ rules: 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/packs.test.ts b/src/packs/packs.test.ts index c9ea9df..97c2810 100644 --- a/src/packs/packs.test.ts +++ b/src/packs/packs.test.ts @@ -71,6 +71,22 @@ describe('built-in packs', () => { ).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') @@ -91,6 +107,22 @@ describe('built-in packs', () => { 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') + } + }) + it('steers recursive grep to rg but leaves plain grep alone', () => { const { engine } = makeEngine() expect(engine.evaluate(bash('grep -r TODO src/'))?.type).toBe('suggest') diff --git a/src/packs/predicates.ts b/src/packs/predicates.ts index d03ef7e..23dea51 100644 --- a/src/packs/predicates.ts +++ b/src/packs/predicates.ts @@ -44,6 +44,18 @@ export function catLargeFile(ctx: ToolCallContext, cwd = process.cwd()): boolean } } +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. @@ -52,4 +64,5 @@ export function registerBuiltInPredicates(registry: PredicateRegistry): void { registry.register('npm-in-pnpm-repo', (ctx) => npmInPnpmRepo(ctx)) registry.register('on-protected-branch', (ctx) => onProtectedBranch(ctx)) registry.register('cat-large-file', (ctx) => catLargeFile(ctx)) + registry.register('ssh-private-key', sshPrivateKey) } diff --git a/src/packs/private-key.yaml b/src/packs/private-key.yaml index 1e2299e..ac2788b 100644 --- a/src/packs/private-key.yaml +++ b/src/packs/private-key.yaml @@ -16,11 +16,22 @@ rules: - id: private-key.read title: Block read tool calls on key files - description: Catches direct read tool calls on private key files, including the SSH directory + description: Catches direct read tool calls on private key files by extension phase: before-tool match: type: file-path - pattern: "(\\.pem|\\.key)$|/\\.ssh/(?!.*\\.pub$)" + 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.'