Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions docs/packs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Rule Pack Gallery

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

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 |
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions scripts/generate-packs-gallery.mjs
Original file line number Diff line number Diff line change
@@ -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',
'',
'<!-- AUTO-GENERATED by scripts/generate-packs-gallery.mjs — do not edit by hand. -->',
'',
`${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`)
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
19 changes: 14 additions & 5 deletions src/engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
HarnessCapabilities,
MatchResult,
DomainEvent,
BeforeToolAction,
} from '../core/types.js'
import type { PredicateRegistry } from '../core/predicate-registry.js'
import type { StatsTracker } from './stats-tracker.js'
Expand Down Expand Up @@ -49,7 +48,9 @@
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) : ['']

Check warning on line 53 in src/engine/engine.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=nimser_agent-guardrails&issues=AZ8zpV1iAuCZqYs0RD5q&open=AZ8zpV1iAuCZqYs0RD5q&pullRequest=18
const result = findFirstMatchTraced(ctx, commands, packs, capabilities, registry)
stats.record(result.action)
return result
Expand Down Expand Up @@ -100,11 +101,12 @@
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 || ''
Expand All @@ -117,7 +119,14 @@
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,
})
Expand All @@ -137,7 +146,7 @@
* 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
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
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
4 changes: 3 additions & 1 deletion src/matcher/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions src/packs/encryption-tools.yaml
Original file line number Diff line number Diff line change
@@ -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.'
39 changes: 39 additions & 0 deletions src/packs/env.yaml
Original file line number Diff line number Diff line change
@@ -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.'
Loading