cc-toolgate 0.6.3

PreToolUse hook for Claude Code that gates Bash commands with compound-command-aware validation
Documentation
# Path-based command matching design

## Current behavior (v3)

`base_command()` extracts the basename: `/usr/bin/ls` → `ls`. Registry lookup
is always by basename. No path-aware matching, no symlink resolution.

## Proposed behavior

Three match levels, evaluated in order of specificity. Most specific match wins.
Within the same level, deny > ask > allow.

### Match levels (most → least specific)

1. **Resolved path**: symlink-resolved canonical path (`std::fs::canonicalize`)
2. **Exact path**: literal path string as given in config/command
3. **Basename**: filename component only (current behavior)

### Path resolution for bare commands

When a command has no `/` (e.g. bare `ls`), we resolve it via PATH lookup
(`which`-equivalent: search each PATH directory for an executable with that
name). This ensures that a path-based deny on `/usr/bin/ls` cannot be bypassed
by simply typing `ls`.

If PATH resolution fails (command not found on disk), only basename matching
applies. This is the common case for shell builtins and functions.

### Lookup algorithm

```
given command word W (e.g. "/usr/local/bin/ls", "ls", "./script.sh"):

1. determine full path P:
   - if W contains '/': P = W
   - else: search PATH for executable W → P (may fail)

2. if P is known:
   a. check P in deny             → DENY
   b. resolve symlinks(P) → R
   c. if R != P, check R in deny  → DENY
   d. check P in allow            → ALLOW
   e. check P in ask              → ASK
   f. if R != P, check R in allow → ALLOW
   g. if R != P, check R in ask   → ASK

3. basename fallback (basename of W):
   a. check in deny  → DENY
   b. check in allow → ALLOW
   c. check in ask   → ASK

4. fallthrough → ASK (unrecognized)
```

Deny checks always happen before allow/ask at each level. This ensures that a
deny on the resolved path cannot be circumvented by any alias — symlink,
basename, or bare command.

### Case table

Assume `/usr/local/bin/ls` is a symlink → `/usr/bin/ls`.
Assume bare `ls` resolves via PATH to `/usr/bin/ls`.

| # | Allow list | Deny list | Command | PATH resolves to | Resolved (canonical) | Decision | Reasoning |
|---|-----------|-----------|---------|-----------------|---------------------|----------|-----------|
| 1 | `ls` | — | `ls` | `/usr/bin/ls` | `/usr/bin/ls` | ALLOW | No path entries match; basename `ls` in allow (step 3b) |
| 2 | `ls` | — | `/usr/bin/ls` | (has `/`) | `/usr/bin/ls` | ALLOW | No path entries match; basename `ls` in allow (step 3b) |
| 3 | `/usr/bin/ls` | — | `/usr/bin/ls` | (has `/`) | `/usr/bin/ls` | ALLOW | Exact path allow (step 2d) |
| 4 | `/usr/bin/ls` | — | `ls` | `/usr/bin/ls` | `/usr/bin/ls` | ALLOW | PATH resolves to `/usr/bin/ls` → path allow (step 2d) |
| 5 | `/usr/bin/ls` | — | `/usr/local/bin/ls` | (has `/`) | `/usr/bin/ls` | ALLOW | Resolved path allow (step 2f) |
| 6 | `ls` | `/usr/bin/ls` | `/usr/bin/ls` | (has `/`) | `/usr/bin/ls` | DENY | Exact path deny (step 2a) |
| 7 | `ls` | `/usr/bin/ls` | `ls` | `/usr/bin/ls` | `/usr/bin/ls` | DENY | PATH resolves → path deny (step 2a) |
| 8 | `ls` | `/usr/bin/ls` | `/usr/local/bin/ls` | (has `/`) | `/usr/bin/ls` | DENY | Resolved path deny (step 2c) |
| 9 | `/usr/local/bin/ls` | `/usr/bin/ls` | `/usr/local/bin/ls` | (has `/`) | `/usr/bin/ls` | DENY | Resolved deny (step 2c) before exact allow (step 2d) |
| 10 | — | `ls` | `/usr/bin/ls` | (has `/`) | `/usr/bin/ls` | DENY | No path entries; basename `ls` in deny (step 3a) |
| 11 | `/usr/bin/ls` | `ls` | `/usr/bin/ls` | (has `/`) | `/usr/bin/ls` | ALLOW | Exact path allow (step 2d) before basename deny (step 3a) |
| 12 | `/usr/bin/ls` | `ls` | `ls` | `/usr/bin/ls` | `/usr/bin/ls` | ALLOW | PATH resolves → path allow (step 2d) before basename deny (step 3a) |
| 13 | `ls` | `ls` | `ls` | `/usr/bin/ls` | `/usr/bin/ls` | DENY | No path entries; basename deny > allow (step 3a) |
| 14 | `/usr/bin/ls` | `/usr/bin/ls` | `/usr/bin/ls` | (has `/`) | `/usr/bin/ls` | DENY | Same path, deny > allow (step 2a) |
| 15 | — | — | `/usr/bin/ls` | (has `/`) | `/usr/bin/ls` | ASK | No matches → fallthrough |
| 16 | `ls` | `/usr/bin/ls` | `ls` | not found | N/A | ALLOW | PATH resolution fails → basename only → `ls` in allow |
| 17 | — | `/usr/bin/ls` | `ls` | not found | N/A | ASK | PATH resolution fails → basename not in any list → fallthrough |

### Key invariants

- **Deny on resolved path is absolute**: No alias (symlink, bare name, PATH)
  can bypass a deny on the real binary (cases 7, 8, 9).
- **PATH resolution closes the bare-name loophole**: Typing `ls` instead of
  `/usr/bin/ls` does not bypass a path-based deny (case 7). Only if PATH
  resolution fails does it fall through to basename (case 16).
- **Path specificity overrides basename**: An explicit path allow/deny takes
  precedence over a basename entry (cases 6, 11, 12).
- **Same-level deny wins**: If the same entry appears in both allow and deny
  at the same specificity, deny wins (cases 13, 14).
- **Filesystem access required**: PATH lookup and symlink resolution both
  require filesystem access. If either fails, skip that phase and continue
  to the next level.

### Config format

Path entries in allow/ask/deny lists are distinguished by containing `/`:

```toml
[commands]
allow = [
    "ls",                    # basename: any ls
    "/usr/bin/approved-tool", # path: this specific binary
]
deny = [
    "shred",                 # basename: any shred
    "/opt/sketchy/binary",   # path: deny this specific binary
]
```

### Edge cases

- **`./script.sh`**: Has `/` so used as-is for path matching. `canonicalize()`
  resolves to absolute path based on CWD. Basename is `script.sh`.
- **Broken symlinks**: `canonicalize()` fails → skip resolved-path checks,
  use exact path + basename only.
- **PATH resolution cost**: One directory scan per PATH entry, stopping at
  first match. Same as what `which` does. Negligible for cc-toolgate's
  invocation frequency (once per command, not in a loop).
- **PATH manipulation**: If PATH is modified (e.g. attacker prepends a dir),
  PATH resolution finds the attacker's binary. But cc-toolgate evaluates the
  command Claude Code sends, not what the shell will actually execute. PATH
  manipulation is outside cc-toolgate's threat model (it's a hook, not a
  sandbox).
- **Shell builtins**: `cd`, `export`, `set`, etc. don't exist on disk. PATH
  resolution fails → basename matching only. This is correct behavior since
  builtins are in the basename allow list.
- **Dotted commands**: `mkfs.ext4` — basename extraction gives `mkfs.ext4`.
  The existing dotted prefix fallback (`mkfs.ext4` → check `mkfs`) applies
  after path matching, at the basename level only.
- **Tilde expansion**: `~/bin/tool` — the `~` is NOT expanded by cc-toolgate
  (it's a shell feature). If the command literally contains `~`, it won't
  match a path entry with the expanded home dir. The config should use
  absolute paths, not tildes.

### Implementation notes

- Path entries in the config are identified by containing `/` at parse time.
  They go into a separate lookup structure (e.g. `HashMap<PathBuf, Disposition>`)
  alongside the existing basename `HashMap<String, Box<dyn CommandSpec>>`.
- PATH resolution: iterate `std::env::var("PATH")`, split on `:`, check each
  `dir/command` with `std::fs::metadata()` for existence + executable bit.
- `canonicalize()` is a single syscall. Cache if needed but probably unnecessary.
- Complex command specs (git, cargo, kubectl, gh) are always basename-matched.
  Path matching applies to the simple allow/ask/deny lists only.