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
19 changes: 18 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,24 @@

| Version | Supported |
|---------|-----------|
| 0.1.x | Yes |
| 0.4.x | Yes |
| < 0.4 | No |

## Enforcement coverage

GuardRail enforces on **both** mutation surfaces the agent can use:

- **Bash commands** — `PreToolUse` on `Bash` (deny-capable).
- **File writes** — `PreToolUse` on `Write` / `Edit` / `MultiEdit` / `NotebookEdit` (deny-capable),
added in 0.4.0.

Before 0.4.0, deny-capable guards ran on Bash only; file-tool writes were seen
by a `PostToolUse` (advisory) hook after the write had already happened. An
agent could therefore create the disable file, overwrite a privileged path, or
write secrets to disk without a blocking guard ever inspecting it. 0.4.0 closes
this by inspecting `file_path` and content **before** the write. General rule:
every deny-capable guard set must cover all mutation primitives of the runtime,
not just the shell.

## Reporting a Vulnerability

Expand Down
120 changes: 120 additions & 0 deletions dispatchers/pre-edit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/bin/bash
# GuardRail Pre-Edit Dispatcher
# License: MIT
# PreToolUse hook for Write / Edit / MultiEdit. Can DENY (unlike post-edit,
# which only adds context). Closes the gap where deny-capable guards ran on
# Bash only, letting file-tool writes bypass enforcement entirely.
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GUARDS_DIR="$SCRIPT_DIR/../guards/core"
CUSTOM_DIR="${GUARDRAIL_CUSTOM_GUARDS_DIR:-$SCRIPT_DIR/../guards/custom}"
LIB_DIR="$SCRIPT_DIR/../lib"
INPUT=$(cat)
ALLOW='{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'

deny() {
local reason="$1"
local rj
if declare -F guardrail_audit >/dev/null 2>&1; then
guardrail_audit "PreEditDispatcher" "$reason" "${FILE_PATH:-unavailable}" "blocked"
fi
rj=$(printf "%s" "$reason" | jq -Rs .)
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":${rj}}}"
exit 0
}

# Load audit early so even a malformed-payload deny is logged (malformed-input
# probing must be visible in the audit trail, not just blocked silently).
[ -f "$LIB_DIR/guardrail-common.sh" ] && source "$LIB_DIR/guardrail-common.sh" 2>/dev/null

# Validate payload shape; block on anything we cannot inspect.
# file_path for Write/Edit/MultiEdit, notebook_path for NotebookEdit.
if ! printf '%s' "$INPUT" | jq -e '
type == "object"
and (.tool_input | type == "object")
and ((.tool_input.file_path // .tool_input.notebook_path) | type == "string")
and ((.tool_input.file_path // .tool_input.notebook_path) | length > 0)
' >/dev/null 2>&1; then
deny "GUARDRAIL INPUT ERROR: malformed or empty Write/Edit/Notebook hook payload. The operation was blocked because it could not be inspected."
fi

FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.notebook_path')
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // "default"')
# Content across Write (content), Edit (new_string), MultiEdit (edits[].new_string)
# and NotebookEdit (new_source). Coerce non-string values to their JSON form so
# a secret hidden inside a non-string payload is still scanned instead of
# silently dropped (fail-closed: if jq itself errors, block).
CONTENT=$(printf '%s' "$INPUT" | jq -r '
[ .tool_input.content, .tool_input.new_string, .tool_input.new_source,
(.tool_input.edits // [] | .[]?.new_string) ]
| map(select(. != null) | if type == "string" then . else tojson end)
| join("\n")
' 2>/dev/null) || deny "GUARDRAIL INPUT ERROR: content could not be extracted for inspection. Blocked to fail closed."

# Respect the same HMAC-gated disable flag as pre-bash.
if [ -f "$SCRIPT_DIR/../.disabled" ]; then
DISABLE_LINE=$(head -1 "$SCRIPT_DIR/../.disabled" 2>/dev/null)
DISABLE_TS=$(printf '%s' "$DISABLE_LINE" | awk '{print $1}')
DISABLE_KEY_FILE="$HOME/.guardrail/disable.key"
if [ -f "$DISABLE_KEY_FILE" ] && [ -n "$DISABLE_TS" ]; then
NOW=$(date +%s 2>/dev/null || echo 0)
AGE=$(( NOW - DISABLE_TS ))
if [ "$AGE" -ge 0 ] && [ "$AGE" -lt 1800 ]; then
EXPECTED=$(printf '%s' "$DISABLE_TS" | openssl dgst -sha256 -hmac "$(cat "$DISABLE_KEY_FILE")" 2>/dev/null | awk '{print $NF}')
ACTUAL=$(printf '%s' "$DISABLE_LINE" | awk '{print $2}')
[ -n "$EXPECTED" ] && [ "$EXPECTED" = "$ACTUAL" ] && { echo "$ALLOW"; exit 0; }
fi
fi
fi

if [ ! -f "$LIB_DIR/guardrail-common.sh" ] || ! source "$LIB_DIR/guardrail-common.sh"; then
deny "GUARDRAIL INTEGRITY ERROR: common library could not be loaded."
fi

_guardrail_load_guard() {
local g="$1"
[ -f "$GUARDS_DIR/$g" ] || deny "GUARDRAIL INTEGRITY ERROR: required guard $g is missing."
source "$GUARDS_DIR/$g" || deny "GUARDRAIL INTEGRITY ERROR: guard $g could not be loaded."
}
# Fail-closed: a required core guard whose function is missing after sourcing
# is an integrity failure, not a skip. Mirrors pre-bash.sh. Silently running
# past a missing guard would fail open, exactly what this dispatcher prevents.
_guardrail_run_required() {
local fn="$1"
declare -F "$fn" >/dev/null || deny "GUARDRAIL INTEGRITY ERROR: required guard function $fn is missing. Blocking to fail closed."
"$fn" || deny "GUARDRAIL RUNTIME ERROR: guard $fn failed. Blocking to fail closed."
}

_guardrail_load_guard "edit_path_guard.sh"
_guardrail_load_guard "edit_secret_guard.sh"
_guardrail_run_required hook_edit_path_guard
_guardrail_run_required hook_edit_secret_guard

# Custom pre-edit guards: preedit_*.sh
if [ -d "$CUSTOM_DIR" ]; then
for cg in "$CUSTOM_DIR"/preedit_*.sh; do
[ -f "$cg" ] || continue
source "$cg" || deny "GUARDRAIL INTEGRITY ERROR: custom guard $(basename "$cg") could not be loaded."
lfn="hook_$(basename "$cg" .sh)"
declare -F "$lfn" >/dev/null || deny "GUARDRAIL INTEGRITY ERROR: custom guard function $lfn is missing."
"$lfn" || deny "GUARDRAIL RUNTIME ERROR: custom guard $lfn failed."
done
fi

# Pro pre-edit guards: preedit_*.sh (license-gated)
PRO_DIR="${SCRIPT_DIR}/../guards/pro"
if [ -d "$PRO_DIR" ]; then
source "$LIB_DIR/guardrail-license.sh" || deny "GUARDRAIL INTEGRITY ERROR: license module could not be loaded."
if _guardrail_check_pro_license; then
for pg in "$PRO_DIR"/preedit_*.sh; do
[ -f "$pg" ] || continue
source "$pg" || deny "GUARDRAIL INTEGRITY ERROR: Pro guard $(basename "$pg") could not be loaded."
pfn="hook_$(basename "$pg" .sh)"
declare -F "$pfn" >/dev/null || deny "GUARDRAIL INTEGRITY ERROR: Pro guard function $pfn is missing."
"$pfn" || deny "GUARDRAIL RUNTIME ERROR: Pro guard $pfn failed."
done
fi
fi

echo "$ALLOW"
exit 0
2 changes: 1 addition & 1 deletion guards/core/credential_leak_guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ hook_credential_leak_guard() {
echo "$head_out" | grep -qE '(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}' && detected="$detected github-token"

# Anthropic keys
echo "$head_out" | grep -qE 'sk-ant-[A-Za-z0-9]{20,}' && detected="$detected anthropic-key"
echo "$head_out" | grep -qE 'sk-ant-[A-Za-z0-9_-]{20,}' && detected="$detected anthropic-key"

# OpenAI keys
echo "$head_out" | grep -qE 'sk-[A-Za-z0-9]{40,}' && detected="$detected openai-key"
Expand Down
76 changes: 76 additions & 0 deletions guards/core/edit_path_guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/bin/bash
# GuardRail Core Guard: edit_path_guard
# License: MIT
# Runs in the PreToolUse pre-edit dispatcher (Write/Edit/MultiEdit).
# Shared vars: $FILE_PATH, $CONTENT, $SESSION_ID
# Shared fns: deny()
#
# Closes the "shell-only enforcement" gap: guards that only inspect Bash
# commands never see file writes made through the agent's native Write/Edit
# tools. This guard blocks the file-tool equivalents of the dangerous
# operations that the bash guards already stop.

hook_edit_path_guard() {
local fp="$FILE_PATH"
[ -z "$fp" ] && return 0

# Canonicalize before matching: /a/./b, /a//b and /a/../a/b all write to the
# same file but would slip past a literal regex. realpath -m -s normalizes
# . / .. / duplicate slashes WITHOUT resolving symlinks (so literal-path
# matches still hold). Falls back to the raw path if realpath is unavailable.
local canon
canon=$(realpath -m -s -- "$fp" 2>/dev/null) && [ -n "$canon" ] && fp="$canon"

# 1. Self-bypass: writing anything under GuardRail's own install tree via a
# file tool. Covers the disable mechanism AND every directory the
# dispatchers auto-source: guards/core, guards/pro, guards/custom (incl.
# preedit_*.sh / edit_*.sh), dispatchers/, and lib/. Writing a new
# custom guard would otherwise be auto-sourced and executed on the next
# tool call, bypassing the whole system.
if printf '%s' "$fp" | grep -qE 'guardrail/\.disabled$|/\.guardrail/disable\.key$|guardrail/(dispatchers|lib)/|guardrail/guards/(core|pro|custom)/'; then
guardrail_audit "edit_path_guard" "file-tool write to guardrail control path" "$fp" "blocked"
deny "Self-bypass blocked: AI agents must not create or modify GuardRail's own guards, dispatchers, or disable mechanism through file tools. This includes custom guards, which are auto-executed. Only a human operator may change these from an interactive terminal."
return 0
fi

# 1a. A custom-guards dir configured via env var. The dispatcher auto-sources
# preedit_*.sh from $GUARDRAIL_CUSTOM_GUARDS_DIR, so a write there is the
# same bypass as guards/custom/ but at an operator-configured path.
if [ -n "${GUARDRAIL_CUSTOM_GUARDS_DIR:-}" ]; then
case "$fp" in
"${GUARDRAIL_CUSTOM_GUARDS_DIR%/}"/*)
guardrail_audit "edit_path_guard" "file-tool write to configured custom-guards dir" "$fp" "blocked"
deny "Self-bypass blocked: AI agents must not write into the configured custom-guards directory (GUARDRAIL_CUSTOM_GUARDS_DIR). Those files are auto-executed. Only a human operator may change them from an interactive terminal."
return 0
;;
esac
fi

# 1b. The hook registry itself. .claude/settings(.local).json is where the
# PreToolUse hooks are wired in (install.sh). Rewriting it removes every
# guard at once, including this one, without touching any 'guardrail'
# path. This is the highest-value bypass and must be blocked.
if printf '%s' "$fp" | grep -qE '(^|/)\.claude/settings(\.local)?\.json$'; then
guardrail_audit "edit_path_guard" "file-tool write to Claude hook registry" "$fp" "blocked"
deny "Self-bypass blocked: AI agents must not modify .claude/settings.json through file tools. That file registers the guard hooks; rewriting it would disable all enforcement. A human operator must change hook configuration from an interactive terminal."
return 0
fi

# 2. Persistence / privilege paths: writing these via a file tool is how an
# agent would install a backdoor that outlives the session.
if printf '%s' "$fp" | grep -qE '(^|/)(etc/(passwd|shadow|sudoers|sudoers\.d/|cron\.d/|crontab)|\.ssh/authorized_keys|\.ssh/id_[a-z0-9]+$)'; then
guardrail_audit "edit_path_guard" "file-tool write to privileged system path" "$fp" "blocked"
deny "Blocked: writing to a privileged system path ($fp) through a file tool. This is a common persistence vector. Perform account/credential/cron changes as an explicit, reviewed human action."
return 0
fi

# 3. Auto-executed hooks and shell startup files: silent code-execution
# persistence (git hooks, shell rc, profile).
if printf '%s' "$fp" | grep -qE '(^|/)(\.git/hooks/[a-z-]+|\.(bashrc|zshrc|bash_profile|profile|zprofile)|\.config/(fish/config\.fish))$'; then
guardrail_audit "edit_path_guard" "file-tool write to auto-executed startup/hook file" "$fp" "blocked"
deny "Blocked: writing to an auto-executed file ($fp) through a file tool. Git hooks and shell startup files run code automatically. If this is intended, make it an explicit reviewed change."
return 0
fi

return 0
}
40 changes: 40 additions & 0 deletions guards/core/edit_secret_guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/bin/bash
# GuardRail Core Guard: edit_secret_guard
# License: MIT
# Runs in the PreToolUse pre-edit dispatcher (Write/Edit/MultiEdit).
# Shared vars: $FILE_PATH, $CONTENT, $SESSION_ID
# Shared fns: deny()
#
# Blocks writing high-confidence live secrets into files through the agent's
# file tools. Mirrors the bash secret detector for the write path. Kept
# deliberately narrow (known key formats only) to avoid false positives on
# placeholders and documentation.

hook_edit_secret_guard() {
local content="$CONTENT"
[ -z "$content" ] && return 0

# High-confidence live-credential formats. Placeholders (x's, <...>, YOUR_)
# are excluded by requiring realistic entropy/charset in the token body.
local hit=""
# AWS access key id
printf '%s' "$content" | grep -qE 'AKIA[0-9A-Z]{16}' && hit="AWS access key"
# Stripe live secret key
[ -z "$hit" ] && printf '%s' "$content" | grep -qE 'sk_live_[0-9a-zA-Z]{24,}' && hit="Stripe live secret key"
# Private key block
[ -z "$hit" ] && printf '%s' "$content" | grep -qE 'BEGIN (RSA|OPENSSH|EC|DSA|PGP) PRIVATE KEY' && hit="private key block"
# GitHub token
[ -z "$hit" ] && printf '%s' "$content" | grep -qE 'gh[pousr]_[0-9A-Za-z]{36,}' && hit="GitHub token"
# Slack token
[ -z "$hit" ] && printf '%s' "$content" | grep -qE 'xox[baprs]-[0-9A-Za-z-]{20,}' && hit="Slack token"
# Google API key
[ -z "$hit" ] && printf '%s' "$content" | grep -qE 'AIza[0-9A-Za-z_-]{35}' && hit="Google API key"

if [ -n "$hit" ]; then
guardrail_audit "edit_secret_guard" "live secret written to file ($hit)" "$FILE_PATH" "blocked"
deny "Secret blocked: a live $hit would be written to $FILE_PATH. Never commit real credentials to files. Use environment variables or a secret manager and reference them at runtime."
return 0
fi

return 0
}
2 changes: 1 addition & 1 deletion guards/core/self_correction_loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ hook_self_correction_loop() {
printf '%s' "$head_out" | grep -qiE '(0 errors|no errors|succeeded|success|passed|PASS)' && return 0

# Build errors
if printf '%s' "$head_out" | grep -qiE '(build failed|compilation error|tsc.*error|next build.*error|webpack.*error)'; then
if printf '%s' "$head_out" | grep -qiE '(build failed|compilation error|tsc.*error|error TS[0-9]{3,}|next build.*error|webpack.*error)'; then
add_context "BUILD ERROR detected. Self-correction required: read the error, open the file, apply the fix, rebuild. Do not continue without a green build."
guardrail_audit "self_correction_loop" "build error" "$CMD" "correction-required"
return 0
Expand Down
6 changes: 5 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,20 @@ fi

PRE_BASH="$INSTALL_DIR/dispatchers/pre-bash.sh"
POST_BASH="$INSTALL_DIR/dispatchers/post-bash.sh"
PRE_EDIT="$INSTALL_DIR/dispatchers/pre-edit.sh"
POST_EDIT="$INSTALL_DIR/dispatchers/post-edit.sh"

TEMP_SETTINGS=$(mktemp)
jq --arg pre "$PRE_BASH" --arg post "$POST_BASH" --arg edit "$POST_EDIT" '
jq --arg pre "$PRE_BASH" --arg post "$POST_BASH" --arg preedit "$PRE_EDIT" --arg edit "$POST_EDIT" '
.hooks //= {} |
.hooks.PreToolUse //= [] |
.hooks.PostToolUse //= [] |
(if (.hooks.PreToolUse | any(.hooks[]?.command == $pre)) | not
then .hooks.PreToolUse += [{"matcher": "Bash", "hooks": [{"type": "command", "command": $pre}]}]
else . end) |
(if (.hooks.PreToolUse | any(.hooks[]?.command == $preedit)) | not
then .hooks.PreToolUse += [{"matcher": "Write|Edit|MultiEdit|NotebookEdit", "hooks": [{"type": "command", "command": $preedit}]}]
else . end) |
(if (.hooks.PostToolUse | any(.hooks[]?.command == $post)) | not
then .hooks.PostToolUse += [{"matcher": "Bash", "hooks": [{"type": "command", "command": $post}]}]
else . end) |
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "guardrail-agent",
"version": "0.3.3",
"description": "Pre-execution security for AI coding agents. 20 guards that block dangerous commands before they run. Free 14-day Pro trial included.",
"version": "0.4.3",
"description": "Pre-execution security for AI coding agents. 22 guards that block dangerous commands before they run, on both Bash and file writes. Free 14-day Pro trial included.",
"bin": {
"guardrail": "./bin/guardrail.sh"
},
Expand Down
Loading
Loading