Skip to content
Merged

opsec #1535

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
7 changes: 6 additions & 1 deletion config/claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,12 @@
},
{
"type": "command",
"command": "$HOME/dotfiles/config/shared/hooks/block-push-main.sh",
"command": "$HOME/dotfiles/config/shared/hooks/block-git-push.sh",
"timeout": 5
},
{
"type": "command",
"command": "$HOME/dotfiles/config/shared/hooks/block-gh-settings.sh",
"timeout": 5
},
{
Expand Down
2 changes: 1 addition & 1 deletion config/codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
},
{
"type": "command",
"command": "$HOME/dotfiles/config/shared/hooks/block-push-main.sh",
"command": "$HOME/dotfiles/config/shared/hooks/block-git-push.sh",
"timeout": 5
},
{
Expand Down
32 changes: 32 additions & 0 deletions config/shared/hooks/block-gh-settings.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# block-gh-settings.sh — Shared hook for Claude Code + Codex
# Blocks gh CLI commands that modify GitHub repository settings.
# Exit 2 = block (Codex), JSON decision output (Claude).
set -euo pipefail

# Read tool input from stdin
input=$(cat)

# Extract command
command=$(echo "$input" | jq -r '.tool_input.command // .command // empty' 2>/dev/null)
[[ -z $command ]] && exit 0

# Block: gh repo <destructive-subcommand>
if echo "$command" | grep -qE 'gh\s+repo\s+(delete|rename|archive|transfer|edit)\b'; then
subcommand=$(echo "$command" | grep -oE 'gh\s+repo\s+(delete|rename|archive|transfer|edit)' | awk '{print $3}')

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 extraction of the subcommand can be simplified. Using head -n1 ensures that only the first match is captured if multiple keywords appear in the string (e.g., in comments), and it avoids the overhead of spawning awk.

Suggested change
subcommand=$(echo "$command" | grep -oE 'gh\s+repo\s+(delete|rename|archive|transfer|edit)' | awk '{print $3}')
subcommand=$(echo "$command" | grep -oE '\b(delete|rename|archive|transfer|edit)\b' | head -n1)

msg="'gh repo $subcommand' is blocked. Repo settings must be changed manually."
echo "BLOCKED by block-gh-settings.sh: $msg" >&2
exit 2
fi

# Block: gh api -X PATCH|DELETE|PUT targeting /repos/
if echo "$command" | grep -qE 'gh\s+api'; then
if echo "$command" | grep -qE '\-X\s+(PATCH|DELETE|PUT)' && echo "$command" | grep -qE '/repos/'; then

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026

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.

P1: The mutation filter is bypassable because it only detects -X ... and /repos/ with a leading slash. gh api --method PATCH repos/... will not be blocked.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At config/shared/hooks/block-gh-settings.sh, line 24:

<comment>The mutation filter is bypassable because it only detects `-X ...` and `/repos/` with a leading slash. `gh api --method PATCH repos/...` will not be blocked.</comment>

<file context>
@@ -0,0 +1,32 @@
+
+# Block: gh api -X PATCH|DELETE|PUT targeting /repos/
+if echo "$command" | grep -qE 'gh\s+api'; then
+  if echo "$command" | grep -qE '\-X\s+(PATCH|DELETE|PUT)' && echo "$command" | grep -qE '/repos/'; then
+    method=$(echo "$command" | grep -oE '\-X\s+(PATCH|DELETE|PUT)' | awk '{print $2}')
+    msg="'gh api -X $method /repos/...' is blocked. Repo API mutations must be done manually."
</file context>
Fix with Cubic

method=$(echo "$command" | grep -oE '\-X\s+(PATCH|DELETE|PUT)' | awk '{print $2}')
Comment on lines +24 to +25

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-high high

The gh api block logic is susceptible to bypasses. It currently only checks for the -X flag with a mandatory space, missing --method, --method=, and -X without a space (e.g., -XPATCH). The regex should be updated to handle these variations to ensure the security hook is effective.

Suggested change
if echo "$command" | grep -qE '\-X\s+(PATCH|DELETE|PUT)' && echo "$command" | grep -qE '/repos/'; then
method=$(echo "$command" | grep -oE '\-X\s+(PATCH|DELETE|PUT)' | awk '{print $2}')
if echo "$command" | grep -qE '(\s-X\s*|\s--method[=\s]+)(PATCH|DELETE|PUT)\b' && echo "$command" | grep -qE '/repos/'; then
method=$(echo "$command" | grep -oE '\b(PATCH|DELETE|PUT)\b' | head -n1)

msg="'gh api -X $method /repos/...' is blocked. Repo API mutations must be done manually."
echo "BLOCKED by block-gh-settings.sh: $msg" >&2
exit 2
fi
fi
Comment on lines +22 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does gh CLI gh apiaccept both--method DELETEand paths without leading slash likerepos/owner/repo?

💡 Result:

Yes, the gh CLI gh api command accepts both --method DELETE (or the short form -X DELETE) and paths without a leading slash like repos/owner/repo. The official documentation shows examples using -X for methods like GET and PATCH, confirming --method DELETE works similarly as it overrides the default method. A GitHub CLI maintainer example explicitly uses gh api -X DELETE repos/${username}/${reponame} for deleting a repository, demonstrating DELETE with a path starting repos/owner/repo without a leading slash. Multiple sources confirm gh api accepts paths both with and without a leading slash (e.g., /repos/... or repos/...), prepending the base API URL appropriately. Examples in the manual consistently use paths without leading slashes like repos/{owner}/{repo}/releases. While Git Bash on Windows may misinterpret leading slashes due to path expansion, this is a shell issue, not a limitation of gh CLI, and paths without leading slashes work universally.

Citations:


Tighten regex patterns to close bypass vectors in gh api mutation detection.

The script blocks destructive mutations via -X DELETE /repos/..., but two real bypasses exist:

  1. --method DELETE flag works equivalently to -X DELETE per official gh CLI documentation, but the regex only matches -X
  2. Paths work without leading slash (repos/owner/repo not just /repos/owner/repo) per documentation and GitHub maintainer examples

Both bypasses should be closed. Update the regex patterns to match:

  • -X and --method forms (with spacing/delimiter variations: -X DELETE, --method=DELETE, --method DELETE)
  • /repos/ and repos/ path formats (with optional leading slash)
Proposed fix
 # Block: gh api -X PATCH|DELETE|PUT targeting /repos/
 if echo "$command" | grep -qE 'gh\s+api'; then
-  if echo "$command" | grep -qE '\-X\s+(PATCH|DELETE|PUT)' && echo "$command" | grep -qE '/repos/'; then
-    method=$(echo "$command" | grep -oE '\-X\s+(PATCH|DELETE|PUT)' | awk '{print $2}')
+  if echo "$command" | grep -qE '(-X|--method)[= ]+(PATCH|DELETE|PUT)\b' \
+     && echo "$command" | grep -qE '(^|[[:space:]=])/?repos/'; then
+    method=$(echo "$command" | grep -oE '(-X|--method)[= ]+(PATCH|DELETE|PUT)' | grep -oE '(PATCH|DELETE|PUT)')
     msg="'gh api -X $method /repos/...' is blocked. Repo API mutations must be done manually."
     echo "BLOCKED by block-gh-settings.sh: $msg" >&2
     exit 2
   fi
 fi

Add test cases in spec/block_gh_settings_spec.sh:

It 'blocks gh api --method DELETE /repos/...'
Data '{"tool_input": {"command": "gh api --method DELETE /repos/owner/repo"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

It 'blocks gh api -X DELETE repos/... (no leading slash)'
Data '{"tool_input": {"command": "gh api -X DELETE repos/owner/repo"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/shared/hooks/block-gh-settings.sh` around lines 22 - 30, The current
gh api mutation detection only matches "-X" and a leading "/repos/", so update
the two grep checks and the method extraction to also handle the long form
"--method" (with either space or "=" delimiter) and repo paths without a leading
slash. Specifically, replace the grep that detects '-X' with one that matches
'(-X|--method)(=|[[:space:]]+)(PATCH|DELETE|PUT)' and replace the '/repos/'
check with a pattern that allows an optional leading slash like '(/?repos/)';
update the method assignment (the method= line) to extract the HTTP verb from
the combined '-X|--method' match (e.g., capture the PATCH|DELETE|PUT token from
the matched string) so the msg and detection remain correct.


exit 0
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# block-push-main.sh Shared hook for Claude Code + Codex
# block-git-push.sh - Shared hook for Claude Code + Codex
# Blocks git push to main/master unless repo is in the allowlist.
# Exit 2 = block (Codex), JSON decision output (Claude).
set -euo pipefail
Expand Down Expand Up @@ -37,5 +37,5 @@ done

# Block the push
msg="Push to main/master blocked in '$repo'. Use a feature branch + PR."
echo "BLOCKED by block-push-main.sh: $msg" >&2
echo "BLOCKED by block-git-push.sh: $msg" >&2
exit 2
29 changes: 28 additions & 1 deletion named-hosts/matic/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,34 @@ import ../../hosts/nixos {
];

# Networking
networking.networkmanager.wifi.powersave = true;
networking.networkmanager.wifi = {
powersave = true;
scanRandMacAddress = true;
macAddress = "stable-ssid";
};

networking.firewall = {
enable = true;
trustedInterfaces = [ "tailscale0" ];
allowedTCPPorts = [ ];
allowedUDPPorts = [ ];
logRefusedConnections = true;
};
Comment on lines +80 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether tailscale is actually enabled on the matic host (directly or transitively).
rg -nP -C2 '\b(services\.tailscale|modules\.tailscale)\b' named-hosts/matic/ hosts/ modules/ 2>/dev/null
echo '---'
# Also check if any imported module pulls in tailscale for matic.
fd -t f . named-hosts/matic hosts | xargs rg -nP '\btailscale\b' 2>/dev/null

Repository: shunkakinoki/dotfiles

Length of output: 70


Remove dead firewall config or enable Tailscale on this host.

trustedInterfaces = [ "tailscale0" ] references an interface that isn't enabled anywhere in matic's configuration (unlike named-hosts/kyber/default.nix, which enables modules.tailscale with installSystemService = true). If tailscale0 never comes up, this is dead firewall config.

Combined with allowedTCPPorts = [ ] and allowedUDPPorts = [ ], the default-deny stance may also block local service discovery you rely on (mDNS/Avahi for printers, CUPS, KDEConnect/GSConnect, Steam in-home streaming, Spotify Connect, etc.). The logRefusedConnections = true setting will surface these denials in the journal, but that's reactive rather than preventive. Either enable Tailscale on this host or remove the tailscale0 trust rule.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@named-hosts/matic/default.nix` around lines 80 - 86, The firewall config
references a non-existent interface and may enforce unintended default-deny
behavior; either remove the dead rule or enable Tailscale: either delete or
comment out the "trustedInterfaces = [ \"tailscale0\" ]" entry in the
networking.firewall block (and consider removing empty
allowedTCPPorts/allowedUDPPorts or adding needed service ports) OR enable
Tailscale by adding modules.tailscale with installSystemService = true (so the
tailscale0 interface is created), and keep logRefusedConnections true only if
you want denials logged. Ensure you update the networking.firewall block and/or
add modules.tailscale so the configuration is consistent.


# Audit logging
security.auditd.enable = true;
security.audit = {
enable = true;
rules = [
"-a exit,always -F arch=b64 -S execve -k exec"
"-a exit,always -F arch=b32 -S execve -k exec"
"-a exit,always -F arch=b64 -S setuid,setgid,setresuid,setresgid -k priv_esc"

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026

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.

P2: Missing b32 counterpart for the privilege escalation audit rule. The execve rules above correctly cover both arch=b64 and arch=b32, but this setuid/setgid rule only monitors 64-bit syscalls. Any 32-bit setuid/setgid binary (e.g., from multilib, Wine, or Steam) will silently evade this audit rule. Add a matching b32 rule for completeness.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At named-hosts/matic/default.nix, line 95:

<comment>Missing `b32` counterpart for the privilege escalation audit rule. The `execve` rules above correctly cover both `arch=b64` and `arch=b32`, but this `setuid`/`setgid` rule only monitors 64-bit syscalls. Any 32-bit setuid/setgid binary (e.g., from multilib, Wine, or Steam) will silently evade this audit rule. Add a matching `b32` rule for completeness.</comment>

<file context>
@@ -71,7 +71,34 @@ import ../../hosts/nixos {
+          rules = [
+            "-a exit,always -F arch=b64 -S execve -k exec"
+            "-a exit,always -F arch=b32 -S execve -k exec"
+            "-a exit,always -F arch=b64 -S setuid,setgid,setresuid,setresgid -k priv_esc"
+            "-w /etc/sudoers -p wa -k sudoers"
+            "-w /etc/passwd -p wa -k identity"
</file context>
Suggested change
"-a exit,always -F arch=b64 -S setuid,setgid,setresuid,setresgid -k priv_esc"
"-a exit,always -F arch=b64 -S setuid,setgid,setresuid,setresgid -k priv_esc"
"-a exit,always -F arch=b32 -S setuid,setgid,setresuid,setresgid -k priv_esc"
Fix with Cubic

"-w /etc/sudoers -p wa -k sudoers"
"-w /etc/passwd -p wa -k identity"
"-w /etc/shadow -p wa -k identity"
"-w /etc/ssh -p wa -k ssh"
];
Comment on lines +92 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

setuid/setgid audit rule only covers arch=b64 — 32-bit binaries bypass it.

The execve rule is correctly duplicated for both b64 and b32, but the privilege-escalation rule is only installed for b64. On x86_64, any 32-bit setuid/setgid syscall (e.g. invoked by a multilib binary from Steam, Wine, or a compiled-in-32-bit tool) will silently evade the priv_esc key. Add the matching b32 rule for parity with the execve coverage.

Also consider whether unconditional execve auditing is desired here: on a desktop running Hyprland + Steam + Docker this will emit a very high volume of AUDIT_EXECVE records and, without auditd log rotation tuned, can fill /var/log/audit quickly. At minimum verify security.auditd defaults or add an explicit rotation/size cap.

🔒 Proposed fix: add b32 priv-esc rule
           rules = [
             "-a exit,always -F arch=b64 -S execve -k exec"
             "-a exit,always -F arch=b32 -S execve -k exec"
             "-a exit,always -F arch=b64 -S setuid,setgid,setresuid,setresgid -k priv_esc"
+            "-a exit,always -F arch=b32 -S setuid,setgid,setresuid,setresgid -k priv_esc"
             "-w /etc/sudoers -p wa -k sudoers"
             "-w /etc/passwd -p wa -k identity"
             "-w /etc/shadow -p wa -k identity"
             "-w /etc/ssh -p wa -k ssh"
           ];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@named-hosts/matic/default.nix` around lines 92 - 100, The priv_esc audit rule
in the rules array only applies to "arch=b64" so 32-bit setuid/setgid syscalls
can bypass it; add a matching "-a exit,always -F arch=b32 -S
setuid,setgid,setresuid,setresgid -k priv_esc" entry alongside the existing b64
priv_esc rule (keeping the existing duplicated execve b64/b32 entries
unchanged), and while editing consider verifying or configuring auditd log
rotation/size to avoid excessive AUDIT_EXECVE noise.

};

# Docker
virtualisation.docker.enable = true;
Expand Down
132 changes: 132 additions & 0 deletions spec/block_gh_settings_spec.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# shellcheck disable=SC2329

Describe 'block-gh-settings.sh'
SCRIPT="$PWD/config/shared/hooks/block-gh-settings.sh"

Describe 'non-modifying commands'

It 'allows gh pr list'
Data '{"tool_input": {"command": "gh pr list"}}'
When run bash "$SCRIPT"
The status should be success
End

It 'allows gh repo view'
Data '{"tool_input": {"command": "gh repo view"}}'
When run bash "$SCRIPT"
The status should be success
End

It 'allows gh repo clone'
Data '{"tool_input": {"command": "gh repo clone owner/repo"}}'
When run bash "$SCRIPT"
The status should be success
End

It 'allows gh api GET'
Data '{"tool_input": {"command": "gh api /repos/owner/repo"}}'
When run bash "$SCRIPT"
The status should be success
End

It 'allows gh api -X POST to non-repo path'
Data '{"tool_input": {"command": "gh api -X POST /gists"}}'
When run bash "$SCRIPT"
The status should be success
End

End

Describe 'blocked gh repo subcommands'

It 'blocks gh repo delete'
Data '{"tool_input": {"command": "gh repo delete owner/repo"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

It 'blocks gh repo rename'
Data '{"tool_input": {"command": "gh repo rename new-name"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

It 'blocks gh repo archive'
Data '{"tool_input": {"command": "gh repo archive owner/repo"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

It 'blocks gh repo transfer'
Data '{"tool_input": {"command": "gh repo transfer owner/repo new-owner"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

It 'blocks gh repo edit'
Data '{"tool_input": {"command": "gh repo edit --description new-desc"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

End

Describe 'blocked gh api mutations on /repos/'

It 'blocks gh api -X PATCH /repos/...'
Data '{"tool_input": {"command": "gh api -X PATCH /repos/owner/repo"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

It 'blocks gh api -X DELETE /repos/...'
Data '{"tool_input": {"command": "gh api -X DELETE /repos/owner/repo/branches/main/protection"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

It 'blocks gh api -X PUT /repos/...'
Data '{"tool_input": {"command": "gh api -X PUT /repos/owner/repo/collaborators/user"}}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

End

Describe 'codex input format'

It 'blocks codex-style input with .command key'
Data '{"command": "gh repo delete owner/repo"}'
When run bash "$SCRIPT"
The status should eq 2
The stderr should include 'BLOCKED'
End

End

Describe 'edge cases'

It 'passes with empty input'
Data '{}'
When run bash "$SCRIPT"
The status should be success
End

It 'passes with empty command'
Data '{"tool_input": {"command": ""}}'
When run bash "$SCRIPT"
The status should be success
End

End

End
4 changes: 2 additions & 2 deletions spec/block_push_main_spec.sh → spec/block_git_push_spec.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# shellcheck disable=SC2329

Describe 'block-push-main.sh'
SCRIPT="$PWD/config/shared/hooks/block-push-main.sh"
Describe 'block-git-push.sh'
SCRIPT="$PWD/config/shared/hooks/block-git-push.sh"

setup() {
TEMP_REPO=$(mktemp -d)
Expand Down
3 changes: 2 additions & 1 deletion spec/coverage_spec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,8 @@ config/codex/hooks/notify.sh
config/codex/hooks/pushover.sh
config/codex/hooks/rtk-rewrite.sh
config/codex/hooks/security.sh
config/shared/hooks/block-push-main.sh
config/shared/hooks/block-gh-settings.sh
config/shared/hooks/block-git-push.sh
config/cursor/activate.sh
config/gemini/activate.sh
config/git-ai/activate.sh
Expand Down
Loading