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
674 changes: 224 additions & 450 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions config/copilot/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
'';

home.file.".copilot/hooks/rtk-rewrite.sh" = {
source = ../codex/hooks/rtk-rewrite.sh;
source = ./hooks/rtk-rewrite.sh;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hook duplication invites drift. Before this PR, source = ../codex/hooks/rtk-rewrite.sh; made the codex and copilot hooks literally the same file, so they could never diverge. Pointing at ./hooks/rtk-rewrite.sh (and ./hooks/security.sh below) introduces a second copy that must be kept in sync via scripts/sync-{rtk-rewrite,codex-security}.sh. A few practical consequences:

  • Manual edits to config/codex/hooks/security.sh (e.g. ad-hoc deny-pattern tweaks) silently won't reach copilot until someone remembers to run scripts/sync-codex-security.sh.
  • Only rtk-rewrite.sh and security.sh are mirrored. Other codex hooks (atuin-history.sh, notify.sh, pushover.sh) won't be reachable from copilot at all if/when they're added to its config.
  • The previous Nix-level sharing was zero-maintenance.

If the intent is to let copilot's hooks evolve independently from codex, that's fine — otherwise consider either reverting the source paths to ../codex/hooks/... or moving the shared hooks under config/shared/hooks/ and pointing both config/codex/default.nix and config/copilot/default.nix at that single location.

executable = true;
force = true;
};

home.file.".copilot/hooks/security.sh" = {
source = ../codex/hooks/security.sh;
source = ./hooks/security.sh;
executable = true;
force = true;
};
Expand Down
107 changes: 107 additions & 0 deletions config/copilot/hooks/rtk-rewrite.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# rtk-hook-version: 3
# RTK auto-rewrite hook for Claude Code PreToolUse:Bash
# Transparently rewrites raw commands to their RTK equivalents.
# Uses `rtk rewrite` as single source of truth — no duplicate mapping logic here.
#
# To add support for new commands, update src/discover/registry.rs (PATTERNS + RULES).
#
# Exit code protocol for `rtk rewrite`:
# 0 + stdout Rewrite found, no deny/ask rule matched → auto-allow
# 1 No RTK equivalent → pass through unchanged
# 2 Deny rule matched → pass through (Claude Code native deny handles it)
# 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user

# --- Audit logging (opt-in via RTK_HOOK_AUDIT=1) ---
_rtk_audit_log() {
if [ "${RTK_HOOK_AUDIT:-0}" != "1" ]; then return; fi
local action="$1" original="$2" rewritten="${3:--}"
local dir="${RTK_AUDIT_DIR:-${HOME}/.local/share/rtk}"
mkdir -p "$dir"
printf '%s | %s | %s | %s\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$action" "$original" "$rewritten" \
>> "${dir}/hook-audit.log"
}

# Guards: skip silently if dependencies missing
if ! command -v rtk &>/dev/null || ! command -v jq &>/dev/null; then
_rtk_audit_log "skip:no_deps" "-"
exit 0
fi

set -euo pipefail

INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The script is currently fail-closed on invalid JSON input. If INPUT is not valid JSON, jq will exit with an error, and because set -e is active, the script will terminate with exit code 1. Following the pattern for execution scripts in this repo, it is safer to handle parsing errors gracefully and fail-open (allowing the command) as seen in security.sh. Additionally, using printf is safer than echo for arbitrary strings.

Suggested change
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) || CMD=""
References
  1. Do not use set -e in execution scripts that must handle failures gracefully to avoid blocking the process.


if [ -z "$CMD" ]; then
_rtk_audit_log "skip:empty" "-"
exit 0
fi

# Skip heredocs (rtk rewrite also skips them, but bail early)
case "$CMD" in
*'<<'*) _rtk_audit_log "skip:heredoc" "$CMD"; exit 0 ;;
esac

# Rewrite via rtk — single source of truth for all command mappings and permission checks.
# Use "|| EXIT_CODE=$?" to capture non-zero exit codes without triggering set -e.
EXIT_CODE=0
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || EXIT_CODE=$?

case $EXIT_CODE in
0)
# Rewrite found, no permission rules matched — safe to auto-allow.
if [ "$CMD" = "$REWRITTEN" ]; then
_rtk_audit_log "skip:already_rtk" "$CMD"
exit 0
fi
;;
1)
# No RTK equivalent — pass through unchanged.
_rtk_audit_log "skip:no_match" "$CMD"
exit 0
;;
2)
# Deny rule matched — let Claude Code's native deny rule handle it.
_rtk_audit_log "skip:deny_rule" "$CMD"
exit 0
;;
3)
# Ask rule matched — rewrite the command but do NOT auto-allow so that
# Claude Code prompts the user for confirmation.
;;
*)
exit 0
;;
esac

_rtk_audit_log "rewrite" "$CMD" "$REWRITTEN"

# Build the updated tool_input with all original fields preserved, only command changed.
ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input')
UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd')
Comment on lines +82 to +83
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These two jq calls can be combined into a single invocation for better performance and to avoid unnecessary intermediate variables.

Suggested change
ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input')
UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd')
UPDATED_INPUT=$(printf '%s' "$INPUT" | jq -c --arg cmd "$REWRITTEN" '.tool_input | .command = $cmd')


if [ "$EXIT_CODE" -eq 3 ]; then
# Ask: rewrite the command, omit permissionDecision so Claude Code prompts.
jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": $updated
}
}'
else
# Allow: output the rewrite instruction in Claude Code hook format.
jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": $updated
}
}'
fi
62 changes: 62 additions & 0 deletions config/copilot/hooks/security.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env bash

# Codex/Copilot Security Hook
# Blocks dangerous Bash commands by checking against deny patterns.
# Returns exit code 2 to block, exit code 0 to allow.

set -euo pipefail

input=$(cat)

# Only process shell commands when the hook input includes a tool name.
tool_name=$(echo "$input" | jq -r '.tool.name // .tool_name // .toolName // empty' 2>/dev/null)
case "$tool_name" in
"" | Bash | bash | shell) ;;
*) exit 0 ;;
esac

command=$(echo "$input" | jq -r '
.tool.input.command
// .tool_input.command
// (.toolArgs | if type == "object" then .command else empty end)
// (.toolArgs | if type == "string" then (fromjson? | .command) else empty end)
// .toolInput.command
// .command
// empty
' 2>/dev/null)
[[ -z $command ]] && exit 0

# Hardcoded deny patterns (mirrors claude settings.json deny list)
deny_patterns=(
"chmod -R 777"
"dd if="
"docker system prune -a"
"docker system prune -f"
"git push --force origin main"
"git push --force origin master"
"git push --force-with-lease origin main"
"git push --force-with-lease origin master"
"git push -f origin main"
"git push -f origin master"
"mkfs"
"rm -rf /*"
"rm -rf ~/*"
"sudo"
)

# Split command at logical operators
IFS=$'\n' read -r -d '' -a segments < <(echo "$command" | sed -E 's/[;&|]+/\n/g' && printf '\0') || true
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Using echo "$command" is unsafe if the command string starts with a hyphen (e.g., -n), as echo will interpret it as an option. Use printf to safely handle arbitrary command strings. Additionally, to robustly parse command output, use a unique delimiter and read with a matching IFS as per repository standards.

Suggested change
IFS=$'\n' read -r -d '' -a segments < <(echo "$command" | sed -E 's/[;&|]+/\n/g' && printf '\0') || true
IFS=$'\n' read -r -d '' -a segments < <(printf '%s\n' "$command" | sed -E 's/[;&|]+/\n/g' && printf '\0') || true
References
  1. To robustly parse command output in shell scripts, use a unique delimiter (e.g., tab) and read with a matching IFS.


for segment in "${segments[@]}"; do
segment=$(echo "$segment" | xargs 2>/dev/null) || continue
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Using echo "$segment" is unsafe if the segment starts with a hyphen. Use printf instead.

Suggested change
segment=$(echo "$segment" | xargs 2>/dev/null) || continue
segment=$(printf '%s' "$segment" | xargs 2>/dev/null) || continue

[[ -z $segment ]] && continue

for pattern in "${deny_patterns[@]}"; do
if [[ $segment == $pattern* ]]; then
echo "BLOCKED by security.sh: Command '$segment' matches deny pattern '$pattern'" >&2
exit 2
fi
done
done

exit 0
Loading
Loading