Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<!-- AUTO-GENERATED by scripts/generate-packs-gallery.mjs — do not edit by hand. -->

9 packs, 25 rules, generated from `src/packs/`.
9 packs, 29 rules, generated from `src/packs/`.

## Steering (quality of life)

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/rule-pack-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/infrastructure/yaml-pack-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions src/packs/hardening.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.'
32 changes: 32 additions & 0 deletions src/packs/packs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
Expand Down
13 changes: 13 additions & 0 deletions src/packs/predicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
15 changes: 13 additions & 2 deletions src/packs/private-key.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.'