Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e1574b6
Merge pull request #9 from berdanakyurek/claude-developer-1
berdanakyurek Mar 15, 2026
ad2dde6
feat: add Shannon entropy-based redaction
claude-developer-1[bot] Mar 15, 2026
01055db
fix: align entropy config with per-provider nested format
claude-developer-1[bot] Mar 15, 2026
a9c48f8
refactor: restructure redaction config to nested format per review
claude-developer-1[bot] Mar 15, 2026
9267067
fix: prevent duplicate shell integration entries on repeated install
claude-developer-1[bot] Mar 15, 2026
e96bd0d
fix: skip config overwrite if file already exists on reinstall
claude-developer-1[bot] Mar 15, 2026
1c88207
feat: always show per-provider redaction status in debug mode
claude-developer-1[bot] Mar 15, 2026
aff1e36
feat: show per-stage redaction result in debug output
claude-developer-1[bot] Mar 15, 2026
4ca822b
Merge pull request #11 from berdanakyurek/claude-developer-1
berdanakyurek Mar 15, 2026
bfc703c
fix: preserve custom prompt.txt on reinstall
claude-developer-1[bot] Mar 15, 2026
f8dd49a
Merge pull request #18 from berdanakyurek/claude-1/issue-14-prompt-ov…
berdanakyurek Mar 15, 2026
001ea7b
feat: per-provider cache keys to prevent cross-provider cache hits
claude-developer-1[bot] Mar 15, 2026
dc7e595
feat: include working directory in LLM evaluation and cache key
claude-developer-1[bot] Mar 15, 2026
f30cc56
fix: handle multiline commands and history expansion in zsh integration
claude-developer-1[bot] Mar 15, 2026
ecd3c5d
Merge branch 'develop' into claude-1/issue-15-zsh-edge-cases
berdanakyurek Mar 15, 2026
464b542
Merge pull request #21 from berdanakyurek/claude-1/issue-15-zsh-edge-…
berdanakyurek Mar 15, 2026
fcde981
Merge branch 'develop' into claude-1/issue-13-provider-aware-cache
berdanakyurek Mar 15, 2026
58491fb
Merge pull request #19 from berdanakyurek/claude-1/issue-13-provider-…
berdanakyurek Mar 15, 2026
dc4a60b
feat: add fish shell integration (#17)
claude-developer-1[bot] Mar 15, 2026
6ef70c7
Merge branch 'develop' into claude-1/issue-17-fish-integration
berdanakyurek Mar 15, 2026
8cb80f0
docs: update status, README, CLAUDE.md and add WD tests for #16
claude-developer-1[bot] Mar 15, 2026
91baa3c
docs: update healthcheck, README, CLAUDE.md for fish integration (#17)
claude-developer-1[bot] Mar 15, 2026
7d6666f
Merge pull request #22 from berdanakyurek/claude-1/issue-17-fish-inte…
berdanakyurek Mar 15, 2026
85631fd
fix: resolve merge conflicts with develop (#16)
claude-developer-1[bot] Mar 15, 2026
eecb1e4
Merge pull request #20 from berdanakyurek/claude-1/issue-16-working-d…
berdanakyurek Mar 15, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
guard-sh
.claude/
.claude-identity
.github-app-private-key.pem
61 changes: 54 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,48 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
At the start of every session, read `.claude-identity` from the repo root if it exists. It contains:
- `DEVELOPER_ID` — your identity (e.g. `claude-developer-1`); use this when referring to yourself
- `BRANCH_PREFIX` — prefix for all branches you create (e.g. `claude-1/feature-name`)
- `GITHUB_APP_ID` — GitHub App ID used for authenticated pushes
- `GITHUB_APP_CLIENT_ID` — GitHub App Client ID
- `GITHUB_APP_CLIENT_SECRET` — GitHub App Client Secret
- `GITHUB_APP_PRIVATE_KEY_PATH` — path to the App's RSA private key (relative to repo root, gitignored)
- `GITHUB_REPO` — target repo in `owner/repo` format

If `.claude-identity` does not exist, you are working in the base repo as the human owner.

## Pushing with GitHub App Authentication

Never use the human owner's git credentials. Always authenticate as the GitHub App defined in `.claude-identity`.

Steps to push:

```bash
# 1. Source identity
source .claude-identity

# 2. Generate JWT (valid 10 min)
PRIVATE_KEY=$(cat "$GITHUB_APP_PRIVATE_KEY_PATH")
NOW=$(date +%s)
EXP=$((NOW + 600))
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl base64 -e | tr -d '=' | tr '/+' '_-' | tr -d '\n')
PAYLOAD=$(echo -n "{\"iat\":$NOW,\"exp\":$EXP,\"iss\":$GITHUB_APP_ID}" | openssl base64 -e | tr -d '=' | tr '/+' '_-' | tr -d '\n')
SIG=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign "$GITHUB_APP_PRIVATE_KEY_PATH" | openssl base64 -e | tr -d '=' | tr '/+' '_-' | tr -d '\n')
JWT="${HEADER}.${PAYLOAD}.${SIG}"

# 3. Get installation ID
INSTALLATION_ID=$(curl -s -H "Authorization: Bearer $JWT" -H "Accept: application/vnd.github+json" \
https://api.github.com/app/installations | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])")

# 4. Get installation access token
TOKEN=$(curl -s -X POST -H "Authorization: Bearer $JWT" -H "Accept: application/vnd.github+json" \
https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")

# 5. Push using token
git push https://x-access-token:${TOKEN}@github.com/${GITHUB_REPO}.git HEAD
```

## What This Project Does

**guard-sh** is a shell safety layer. It hooks into bash/zsh to intercept commands before execution, queries an LLM to assess risk, and prompts the user for confirmation if the command is deemed risky. Safe commands are whitelisted or cached to skip the LLM call.
**guard-sh** is a shell safety layer. It hooks into bash/zsh/fish to intercept commands before execution, queries an LLM to assess risk, and prompts the user for confirmation if the command is deemed risky. Safe commands are whitelisted or cached to skip the LLM call.

## Commands

Expand Down Expand Up @@ -40,7 +76,7 @@ There is no Makefile.
```
guard-sh on / off enable/disable for current session (handled by shell hook)
guard-sh on --global / off --global auto-enable/disable in every new terminal
guard-sh status show session/global state, config, timeout, cache, providers, whitelist
guard-sh status show session/global state, config, timeout, work dir, cache, providers, whitelist
guard-sh check "<cmd>" core check — exit 0 if safe, exit 1 with warning if risky
guard-sh check "<cmd>" --debug trace whitelist hit, cache hit, provider attempts, LLM response
guard-sh healthcheck validate API keys, models, latency, shell integration
Expand All @@ -66,9 +102,10 @@ guard-sh version print version

```
shell command typed
→ shell hook (shell/guard.bash or shell/guard.zsh)
→ shell hook (shell/guard.bash or shell/guard.zsh or shell/guard.fish)
→ guard-sh check <command>
→ internal/guard: whitelist check → cache check → LLM query
→ main.go: prepend "Working directory: <wd>\nCommand: " if send_working_directory is enabled
→ internal/guard: whitelist check (rawCmd) → cache check → LLM query (with WD prefix)
→ internal/llm/multi.go: try each provider in order, first success wins
→ response printed; shell prompts [Y/n] if not "OK"
```
Expand All @@ -85,8 +122,9 @@ shell command typed
### Shell integration

- **Bash**: `DEBUG` trap with `extdebug` — intercepts before execution
- **Zsh**: custom widget bound to `^M`/`^J` (Enter key)
- Both call `guard-sh check` and check exit code: 0 = allow, 1 = block
- **Zsh**: custom widget bound to `^M`/`^J` (Enter key); handles multiline (`$CONTEXT == "cont"`) and history expansion
- **Fish**: `bind \n`/`bind \r` mapped to `_guard_execute`; uses `commandline` widget
- All call `guard-sh check` and check exit code: 0 = allow, 1 = block

### System prompt

Expand All @@ -96,4 +134,13 @@ Provider-specific prompts can be placed at `~/.config/guard-sh/prompt_PROVIDERNA

### Runtime config location

`~/.config/guard-sh/config.yaml` — providers, API keys, whitelist, cache settings, timeout. See `config.default.yaml` (embedded in binary) for all options.
`~/.config/guard-sh/config.yaml` — providers, API keys, whitelist, cache settings, timeout, `send_working_directory`. See `config.default.yaml` (embedded in binary) for all options.

### Working directory context

When `send_working_directory: true` (default), `main.go` reads `os.Getwd()` and builds:
```
Working directory: /path/to/cwd
Command: <cmd>
```
This is passed as the `query` to `g.Check(ctx, rawCmd, query)`. The `rawCmd` (bare command) is used for whitelist matching only.
119 changes: 92 additions & 27 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Commands in your *whitelist* skip the LLM entirely.
:END:

- Go 1.22+
- bash or zsh
- bash, zsh, or fish
- API key(s) for one or more of the cloud LLMs listed above, *or* a running [[https://ollama.com][Ollama]] instance for fully local operation.

* Installation
Expand All @@ -58,7 +58,7 @@ This will:
- Build the ~guard-sh~ binary and install it to =~/.local/bin/=
- Copy the default config to =~/.config/guard-sh/config.yaml=
- Copy the default prompt to =~/.config/guard-sh/prompt.txt=
- Add shell integration to your =.bashrc= or =.zshrc=
- Add shell integration to your =.bashrc=, =.zshrc=, or =~/.config/fish/config.fish=

** From binary

Expand All @@ -69,7 +69,7 @@ chmod +x ~/.local/bin/guard-sh
guard-sh setup
#+end_src

This will create the config dir, write the shell integration scripts, and add them to your =.bashrc= / =.zshrc=.
This will create the config dir, write the shell integration scripts, and add them to your =.bashrc=, =.zshrc=, or =~/.config/fish/config.fish= depending on your current shell.

** Install without shell integration

Expand Down Expand Up @@ -132,6 +132,12 @@ command_whitelist:
# How long to wait for an LLM response, in seconds. Defaults to 10 if not set.
timeout_seconds: 10

# Include the working directory in the command sent to the LLM.
# Allows the LLM to assess risk based on context (e.g. chmod in /etc vs ~/projects).
# Cache keys include the working directory when this is enabled.
# Set to false to send only the command string.
send_working_directory: true

# Cache LLM responses to avoid repeated API calls for the same command.
cache_enabled: true
cache_max_size: 1000
Expand All @@ -141,52 +147,108 @@ cache_max_size: 1000

The LLM prompt is stored at =~/.config/guard-sh/prompt.txt=. Edit it to adjust what guard-sh considers risky, change the output format, or add your own rules. Changes take effect immediately — no rebuild needed.

** Working directory context

By default, guard-sh includes the current working directory when querying the LLM. This gives the LLM context to make better decisions — for example, =chmod 777 config.yaml= in =/etc= is riskier than in =~/projects/=.

The LLM receives a structured two-line query:

#+begin_example
Working directory: /etc
Command: chmod 777 config.yaml
#+end_example

The working directory is also included in cache keys, so the same command in different directories is evaluated independently.

To disable:

#+begin_src yaml
send_working_directory: false
#+end_src

Or via config file: set =send_working_directory: false= under the root level.

** Redaction
:PROPERTIES:
:CUSTOM_ID: redaction
:END:

guard-sh can redact sensitive values from commands before sending them to any LLM provider. Patterns are Go regular expressions stored in =redact_patterns= in your config. Matches are replaced with =[REDACTED]=.
guard-sh can redact sensitive values from commands before sending them to any LLM provider. Two methods are supported: pattern-based and Shannon entropy-based. Matches are replaced with =[REDACTED]=.

Default patterns cover common cases:
Redaction is configured globally under =redaction:= in your config, and can be overridden per-provider under =providers.NAME.redaction=.

#+begin_src yaml
redact_patterns:
- '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+'
- '(?i)--password\s+\S+'
- 'AKIA[0-9A-Z]{16}'
redaction:
pattern_based:
enabled: true
patterns:
- '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+'
- '(?i)--password\s+\S+'
- 'AKIA[0-9A-Z]{16}'
shannon_entropy_based:
enabled: false
threshold: 4.5 # bits/char — typical English ~3.5, secrets typically >4.5
min_length: 20 # ignore tokens shorter than this
#+end_src

You can add your own patterns. For example:
*** Pattern-based redaction

Uses Go regular expressions. Any token matching a pattern is replaced with =[REDACTED]=. On by default for all providers.

You can add your own patterns:

#+begin_src yaml
redact_patterns:
- '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+'
- '(?i)--password\s+\S+'
- 'AKIA[0-9A-Z]{16}'
- 'ghp_[a-zA-Z0-9]{36}' # GitHub personal access tokens
- 'xox[baprs]-[a-zA-Z0-9-]+' # Slack tokens
redaction:
pattern_based:
enabled: true
patterns:
- '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+'
- '(?i)--password\s+\S+'
- 'AKIA[0-9A-Z]{16}'
- 'ghp_[a-zA-Z0-9]{36}' # GitHub personal access tokens
- 'xox[baprs]-[a-zA-Z0-9-]+' # Slack tokens
#+end_src

Redaction is enabled per provider. The default is on for all providers. Toggle it interactively:
Toggle per-provider:

#+begin_src bash
guard-sh redact pattern on # interactively enable for a provider
guard-sh redact pattern off # interactively disable for a provider
#+end_src

*** Shannon entropy-based redaction

Automatically detects high-entropy strings (API keys, random tokens, private key material) without requiring explicit patterns. Any whitespace-delimited token above the entropy threshold is replaced with =[REDACTED]=. Off by default.

Toggle per-provider:

#+begin_src bash
guard-sh redact pattern on # interactively enable pattern redaction for a provider
guard-sh redact pattern off # interactively disable pattern redaction for a provider
guard-sh redact entropy on # interactively enable for a provider
guard-sh redact entropy off # interactively disable for a provider
#+end_src

Or set it directly in the config:
*** Per-provider overrides

A provider can override any part of the global redaction config:

#+begin_src yaml
providers:
gemini:
api_key: YOUR_KEY
pattern_based_redaction_enabled: true # default, can omit
ollama:
host: http://localhost:11434/api/chat
pattern_based_redaction_enabled: false # disable for local model
redaction: # optional — overrides global for this provider
pattern_based:
enabled: false # disable pattern redaction for local model
gemini:
api_key: YOUR_KEY
redaction:
shannon_entropy_based:
enabled: true
threshold: 4.0 # more sensitive than global default
min_length: 16
#+end_src

Fields not specified in the per-provider block fall back to the global value.

** Provider-specific prompts

To use a different prompt for a specific provider, create =prompt_PROVIDERNAME.txt= in the same directory:
Expand Down Expand Up @@ -262,8 +324,9 @@ Checks all configured providers without spending any tokens. Reports:
claude claude-haiku-4-5-20251001 ● ok (510ms)

shell
bash ~/.bashrc ● present
zsh ~/.zshrc ○ not found
bash ~/.bashrc ● present
zsh ~/.zshrc ○ not found
fish ~/.config/fish/config.fish ○ not found
#+end_example

** Status
Expand All @@ -282,6 +345,7 @@ Shows session and global state, prompt path, timeout, cache stats, active provid
config ~/.config/guard-sh/config.yaml
prompt ~/.config/guard-sh/prompt.txt
timeout 10s
work dir ● on
cache ● on
1000 max entries
42 entries, 8.3 KB
Expand Down Expand Up @@ -355,7 +419,7 @@ If a provider fails, guard-sh moves on to the next and shows why:
guard-sh setup
#+end_src

Creates =~/.config/guard-sh/= with the default config and prompt, writes shell integration scripts, and adds them to your =.bashrc= / =.zshrc=. Existing files are not overwritten. This is the only step needed after downloading a pre-built binary.
Creates =~/.config/guard-sh/= with the default config and prompt, writes shell integration scripts (bash, zsh, fish), and adds them to your rc file based on your current shell. Existing files are not overwritten. This is the only step needed after downloading a pre-built binary.

** Version

Expand Down Expand Up @@ -439,6 +503,7 @@ guard-sh cache clear # delete all cached responses
|-------+------------------------------------|
| bash | ~shopt -s extdebug~ + ~DEBUG~ trap |
| zsh | ZLE widget bound to Enter |
| fish | ~bind \n~/~\r~ mapped to custom function |

* License
:PROPERTIES:
Expand Down
28 changes: 21 additions & 7 deletions config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,27 @@ cache_enabled: true
# Maximum number of cached responses to keep. Oldest entries are evicted when full.
cache_max_size: 1000

# Patterns to redact from commands before sending to LLM providers.
# Uses Go regular expressions. Matches are replaced with [REDACTED].
# Per-provider redaction can be toggled with: guard-sh redact on/off
redact_patterns:
- '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+'
- '(?i)--password\s+\S+'
- 'AKIA[0-9A-Z]{16}'
# Include the working directory in the command sent to the LLM.
# This allows the LLM to assess risk based on context (e.g. running chmod in /etc vs ~/projects).
# Cache keys include the working directory when this is enabled.
# Set to false to send only the command string.
send_working_directory: true

# Redaction: sensitive values are replaced with [REDACTED] before sending to LLM providers.
# Per-provider overrides can be added under providers.NAME.redaction (optional).
# Toggle per-provider with: guard-sh redact pattern on/off
# guard-sh redact entropy on/off
redaction:
pattern_based:
enabled: true
patterns:
- '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+'
- '(?i)--password\s+\S+'
- 'AKIA[0-9A-Z]{16}'
shannon_entropy_based:
enabled: false
threshold: 4.5 # bits/char — typical English ~3.5, secrets typically >4.5
min_length: 20 # ignore tokens shorter than this (reduces false positives)

# Commands that are always considered safe — API is never called for these.
# Only the base command name is needed (e.g. "ls" covers "ls -la", "ls /tmp", etc.).
Expand Down
5 changes: 5 additions & 0 deletions healthcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,14 @@ func runHealthcheck() {
// --- Shell integration ---
fmt.Printf("\n %sshell%s\n", bold, reset)
home, _ := os.UserHomeDir()
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
if xdgConfig == "" {
xdgConfig = filepath.Join(home, ".config")
}
shellChecks := []struct{ shell, rc, marker string }{
{"bash", filepath.Join(home, ".bashrc"), "guard.bash"},
{"zsh", filepath.Join(home, ".zshrc"), "guard.zsh"},
{"fish", filepath.Join(xdgConfig, "fish", "config.fish"), "guard.fish"},
}
for _, s := range shellChecks {
shellCol := fmt.Sprintf("%-6s", s.shell)
Expand Down
4 changes: 3 additions & 1 deletion help.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ func runHelp() {
// Redact
fmt.Printf(" %s\n", b("redact"))
fmt.Printf(" %s %s\n", c("guard-sh redact pattern on"), d("interactively enable pattern-based redaction for a provider"))
fmt.Printf(" %s %s\n\n", c("guard-sh redact pattern off"), d("interactively disable pattern-based redaction for a provider"))
fmt.Printf(" %s %s\n", c("guard-sh redact pattern off"), d("interactively disable pattern-based redaction for a provider"))
fmt.Printf(" %s %s\n", c("guard-sh redact entropy on"), d("interactively enable entropy-based redaction for a provider"))
fmt.Printf(" %s %s\n\n", c("guard-sh redact entropy off"), d("interactively disable entropy-based redaction for a provider"))

// Cache
fmt.Printf(" %s\n", b("cache"))
Expand Down
Loading
Loading