Skip to main content

Command Palette

Search for a command to run...

Building a Guardrail System for Kiro

Published
6 min read
Building a Guardrail System for Kiro

AI coding agents can write code, run shell commands, and modify your file system. That's the value proposition. It's also the risk surface.

Kiro CLI supports hooks — scripts that intercept agent actions before they execute. Here are four security hooks you should adopt.

How hooks work

Hooks are shell scripts that fire before an agent uses a tool. They receive JSON context via stdin and control behavior through exit codes: 0 to allow, 2 to block. When a hook exits 2, its stderr is returned to the agent as the reason.

You define them in your agent's JSON config under hooks.preToolUse. Each hook specifies a command (the script path), a description (shown to the agent when blocked), and a matcher that scopes the hook to a specific tool (fs_write, execute_bash, etc.).

Dependency pinning

LiteLLM gets 3.4 million PyPI downloads per day. On March 24, two versions shipped with a credential stealer targeting SSH keys, IAM keypairs, and Kubernetes secrets. Pinning to exact versions is the standard mitigation, but agents don't do it by default.

This hook blocks any write to package.json, requirements.txt, pyproject.toml, or Cargo.toml if versions aren't pinned. boto3>=1.40 gets rejected. boto3==1.40.0 passes.

Hook config:

{
  "matcher": "fs_write",
  "command": ".kiro/hooks/check-dependency-pins.sh",
  "description": "Block writes to dependency files with unpinned versions"
}

Script preview (full source):

#!/bin/bash
# Hook: Validate dependency files have pinned versions before writing.
# Trigger: preToolUse on fs_write
# Exit 0 = allow, Exit 2 = block (returns STDERR to LLM)
set -euo pipefail

EVENT=$(cat)
_HOOK_EVENT="$EVENT" python3 << 'PYEOF'
import json, sys, re, os

def check_package_json(content, filename):
    try:
        data = json.loads(content)
    except Exception:
        return []
    bad = []
    for section in ('dependencies', 'devDependencies', 'peerDependencies'):
        deps = data.get(section, {})
        for name, ver in deps.items():
            if re.search(r'[\^~*x]|>=|>|latest', str(ver)):
                bad.append(f'  {section}.{name}: {ver}')
    return bad

def check_requirements_txt(content, filename):
    bad = []
    for line in content.splitlines():
        line = line.strip()
        if not line or line.startswith('#') or line.startswith('-'):
            continue
        if re.match(r'^[a-zA-Z0-9_.-]+\s*(\[.*\])?\s*==\s*[0-9]', line):
            continue
        bad.append(f'  {line}')
    return bad
# ... checkers for pyproject.toml and Cargo.toml, then dispatch logic
PYEOF

Each ecosystem gets its own checker. package.json catches ^, ~, *, and latest. requirements.txt requires ==. Cargo catches bare versions (which Cargo treats as ^).

Secrets scanning

The hook scans every file write for patterns matching AWS keys, private keys, GitHub tokens, Slack tokens, and generic API key shapes. Match found, write blocked. The agent gets told to use environment variables or a secrets manager instead.

Hook config:

{
  "matcher": "fs_write",
  "command": ".kiro/hooks/check-secrets.sh",
  "description": "Block writes containing secrets or API keys"
}

Script preview (full source):

#!/bin/bash
# Hook: Block writes containing secrets (API keys, private keys, tokens).
# Trigger: preToolUse on fs_write
# Exit 0 = allow, Exit 2 = block (returns STDERR to LLM)
set -euo pipefail

EVENT=$(cat)
_HOOK_EVENT="$EVENT" python3 << 'PYEOF'
import json, sys, re, os

ALLOWLISTED_EXTENSIONS = {'.md', '.example', '.sample', '.template'}
ALLOWLISTED_LINE_PATTERNS = re.compile(r'EXAMPLE|PLACEHOLDER|<[A-Z_]+>')

SECRET_PATTERNS = [
    ('AWS Access Key ID',     re.compile(r'AKIA[0-9A-Z]{16}')),
    ('AWS Secret Access Key', re.compile(r'(?<![A-Za-z0-9/+])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])')),
    ('RSA/EC Private Key',    re.compile(r'-----BEGIN\s+(RSA|EC|DSA|OPENSSH)?\s*PRIVATE KEY-----')),
    ('GitHub Token',          re.compile(r'gh[ps]_[A-Za-z0-9_]{36,}')),
    ('Slack Token',           re.compile(r'xox[bpors]-[A-Za-z0-9-]+')),
    ('Generic API Key',       re.compile(r'(?i)(api[_-]?key|api[_-]?secret|auth[_-]?token)\s*[:=]\s*["\']?[A-Za-z0-9_\-]{20,}')),
]
# ... scans each line of each file write, reports findings, exits 2 if blocked
PYEOF

It allowlists markdown files and lines containing "EXAMPLE" or "PLACEHOLDER" so documentation passes through. Not a replacement for git-secrets or truffleHog — it's the layer that catches things before they enter git history at all.

Destructive command blocking

Parses every shell command before execution and blocks the irreversible ones. rm -rf /, DROP TABLE, terraform destroy without -target, git push --force to main, kubectl delete namespace kube-system.

Hook config:

{
  "matcher": "execute_bash",
  "command": ".kiro/hooks/guard-destructive-commands.sh",
  "description": "Block dangerous shell commands"
}

Script preview (full source):

#!/bin/bash
# Hook: Block dangerous shell commands that could cause irreversible damage.
# Trigger: preToolUse on execute_bash
# Exit 0 = allow, Exit 2 = block (returns STDERR to LLM)
set -euo pipefail

EVENT=$(cat)
_HOOK_EVENT="$EVENT" python3 << 'PYEOF'
import json, sys, re, os

RULES = [
    # (description, pattern that BLOCKS, pattern that ALLOWS as exception)
    (
        'Recursive delete of root/home',
        re.compile(r'rm\s+.*-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+(/\s|/\s*\(|~\s|~\s*\)|\\(HOME\s|\\)HOME\s*$)', re.MULTILINE),
        None,
    ),
    (
        'SQL destructive operation',
        re.compile(r'\b(DROP\s+(TABLE|DATABASE)|TRUNCATE\s+TABLE)\b', re.IGNORECASE),
        None,
    ),
    (
        'Terraform destroy without target',
        re.compile(r'terraform\s+destroy\b'),
        re.compile(r'terraform\s+destroy\s+.*-target\b'),
    ),
    (
        'Docker system prune',
        re.compile(r'docker\s+system\s+prune\b'),
        None,
    ),
    (
        'Disk format or raw write',
        re.compile(r'\b(mkfs\b|dd\s+if=.*/dev/)'),
        None,
    ),
    (
        'Force push to protected branch',
        re.compile(r'git\s+push\s+.*--force.*\b(main|master|production)\b'),
        None,
    ),
    (
        'Delete critical Kubernetes namespace',
        re.compile(r'kubectl\s+delete\s+namespace\s+(kube-system|production|default)\b'),
        None,
    ),
]
# ... parses command from event, checks each rule, exits 2 if blocked
PYEOF

Safe versions pass through. rm -rf ./node_modules is fine. terraform destroy -target=aws_instance.foo is fine. Each rule has a block pattern and an optional allow pattern for the safe variant.

Config drift protection

This one blocks writes to the steering rules, skills, and agent config directories. The agent can't modify the rules it operates under without explicit user approval.

Hook config:

{
  "matcher": "fs_write",
  "command": ".kiro/hooks/config-drift-guard.sh",
  "description": "Block writes to steering/skills/agents without approval"
}

Full script (source):

#!/bin/bash
# Hook: Block writes to agent config files unless explicitly approved.
# Trigger: preToolUse on fs_write
# Exit 0 = allow, Exit 2 = block (returns STDERR to LLM)
#
# Protected paths: ~/.kiro/steering/, ~/.kiro/skills/, ~/.kiro/agents/
# To allow config writes, set KIRO_ALLOW_CONFIG_WRITES=1
set -euo pipefail

[ "${KIRO_ALLOW_CONFIG_WRITES:-}" = "1" ] && exit 0

EVENT=$(cat)
_HOOK_EVENT="$EVENT" python3 << 'PYEOF'
import json, sys, os

KIRO_DIR = os.path.realpath(os.path.expanduser("~/.kiro"))
PROTECTED = [
    os.path.join(KIRO_DIR, "steering"),
    os.path.join(KIRO_DIR, "skills"),
    os.path.join(KIRO_DIR, "agents"),
]

try:
    event = json.loads(os.environ['_HOOK_EVENT'])
except (KeyError, json.JSONDecodeError) as e:
    print(f"Hook error: failed to parse event: {e}", file=sys.stderr)
    sys.exit(1)

inp = event.get('tool_input', {})
ops = inp.get('ops', [inp])
for op in ops:
    path = op.get('path', '')
    if not path:
        continue
    resolved = os.path.realpath(os.path.expanduser(path))
    for protected in PROTECTED:
        if resolved.startswith(protected + os.sep) or resolved == protected:
            print("BLOCKED: Writing to Kiro configuration files requires explicit approval.", file=sys.stderr)
            print(f"File: {resolved}", file=sys.stderr)
            print("Ask the user to approve this change before proceeding.", file=sys.stderr)
            sys.exit(2)
sys.exit(0)
PYEOF
exit $?

An agent optimizing for task completion has an incentive to relax its own constraints. This hook removes that option. Set KIRO_ALLOW_CONFIG_WRITES=1 when you intentionally want to update config.

Putting it all together

Here's the complete hooks config. All four hooks fire on preToolUse — three on fs_write, one on execute_bash:

"hooks": {
  "preToolUse": [
    {
      "matcher": "fs_write",
      "command": ".kiro/hooks/check-dependency-pins.sh",
      "description": "Block writes to dependency files with unpinned versions"
    },
    {
      "matcher": "fs_write",
      "command": ".kiro/hooks/check-secrets.sh",
      "description": "Block writes containing secrets or API keys"
    },
    {
      "matcher": "fs_write",
      "command": ".kiro/hooks/config-drift-guard.sh",
      "description": "Block writes to steering/skills/agents without approval"
    },
    {
      "matcher": "execute_bash",
      "command": ".kiro/hooks/guard-destructive-commands.sh",
      "description": "Block dangerous shell commands"
    }
  ]
}

The design principle

None of these hooks reduce what the agent can do. They validate actions between intent and execution. The agent writes code, runs commands, modifies files — it just clears a checkpoint before doing the irreversible, insecure, or self-modifying ones.

This and more posted to AWS Samples: github.com/aws-samples/sample-kiro-cli-multiagent-development

What hooks are you running? I'd like to hear what others are catching — drop a comment or send me a message.

3 views