Skip to content

Commit d048051

Browse files
authored
fix(hint): re-fire bootstrap hint until all settings entries imported (#12)
1 parent 51b0a07 commit d048051

11 files changed

Lines changed: 864 additions & 224 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.4.0",
3+
"version": "0.4.1",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"owner": {
66
"name": "nnemirovsky"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.4.0",
3+
"version": "0.4.1",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"license": "MIT"
66
}

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ hooks/
3131
handlers/
3232
pre-tool-use.sh main hook: loads rules, matches, emits allow/deny/passthrough
3333
post-tool-use.sh classifies native-dialog outcomes into asked_* events (audit only)
34-
session-start.sh one-time bootstrap hint. Gated by ~/.claude/passthru.bootstrap-hint-shown
35-
marker. Prints to stderr only when the user has no passthru files yet
36-
AND has importable entries in ~/.claude/settings.json.
34+
session-start.sh bootstrap hint. Re-fires every session while importable entries in
35+
settings.json / settings.local.json are not yet covered by
36+
_source_hash values in passthru.imported.json. Hash diff replaces
37+
the old one-shot marker. Auto-silences when migration is complete.
3738
scripts/
3839
bootstrap.sh one-time importer from native permissions.allow into passthru.imported.json.
3940
Supported shapes: Bash(prefix:*) | Bash(exact) | mcp__* | WebFetch(domain:X)

docs/plans/20260415-overlay-and-ask-support.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -304,21 +304,21 @@ No marker file needed. Hint auto-silences when all settings entries are imported
304304
- Modify: `docs/rule-format.md` (document `_source_hash` field on imported rules)
305305
- Modify: `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` (0.4.0 -> 0.4.1)
306306

307-
- [ ] extract `is_importable_entry <raw>` predicate from `bootstrap.sh` into `common.sh`. Both converter and hint helper use it. Single source of truth for "can bootstrap convert this entry"
308-
- [ ] add `normalize_settings_entry <entry>` helper in `common.sh`: trim leading/trailing whitespace only. No lowercasing, no path collapsing. Match CC's native parser exactly
309-
- [ ] add `hash_settings_entry <entry>` that emits sha256 of normalized form
310-
- [ ] add `settings_importable_hashes` that scans all settings files, uses `is_importable_entry` to filter, emits hash set one per line
311-
- [ ] add `imported_hashes` that reads all passthru.imported.json files and emits every present `_source_hash` value (missing fields contribute no hash)
312-
- [ ] modify `scripts/bootstrap.sh` to embed `_source_hash` in each rule it writes during `--write`
313-
- [ ] modify `session-start.sh`: replace the marker-file gate with a diff (`settings_importable_hashes - imported_hashes`). Fire the hint with the un-imported count. Remove marker touch entirely. Legacy migration: rules without `_source_hash` contribute nothing to imported_hashes, so the hint fires until the user re-runs `/passthru:bootstrap` which rewrites the file with hashes
314-
- [ ] remove the marker-touch logic from `session-start.sh` (dead code after this change)
315-
- [ ] add bats: bootstrap run produces rules with `_source_hash`; re-running bootstrap is idempotent (hashes stable); settings with no matching imported entries -> hint fires with correct count; settings fully covered -> no hint; legacy imported file (rules without `_source_hash`) + settings with entries -> hint fires (honest migration); post-bootstrap run -> hint silences
316-
- [ ] run `bats tests/*.bats` - must pass
317-
- [ ] bump version to 0.4.1 in both manifests
318-
- [ ] update CHANGELOG or release notes text in README if it has one; otherwise rely on gh release --generate-notes
319-
- [ ] commit + open PR: `fix(hint): re-fire bootstrap hint until all settings entries imported`
320-
- [ ] **PROMPT USER** to test locally BEFORE merge: `claude --plugin-dir /Users/nemirovsky/Developer/claude-passthru` in a session with importable settings entries. Verify hint fires with correct count, re-running bootstrap silences it. User confirms or flags issues
321-
- [ ] after user-confirmed local verification + CI green: merge PR, tag v0.4.1, release
307+
- [x] extract `is_importable_entry <raw>` predicate from `bootstrap.sh` into `common.sh`. Both converter and hint helper use it. Single source of truth for "can bootstrap convert this entry"
308+
- [x] add `normalize_settings_entry <entry>` helper in `common.sh`: trim leading/trailing whitespace only. No lowercasing, no path collapsing. Match CC's native parser exactly
309+
- [x] add `hash_settings_entry <entry>` that emits sha256 of normalized form
310+
- [x] add `settings_importable_hashes` that scans all settings files, uses `is_importable_entry` to filter, emits hash set one per line
311+
- [x] add `imported_hashes` that reads all passthru.imported.json files and emits every present `_source_hash` value (missing fields contribute no hash)
312+
- [x] modify `scripts/bootstrap.sh` to embed `_source_hash` in each rule it writes during `--write`
313+
- [x] modify `session-start.sh`: replace the marker-file gate with a diff (`settings_importable_hashes - imported_hashes`). Fire the hint with the un-imported count. Remove marker touch entirely. Legacy migration: rules without `_source_hash` contribute nothing to imported_hashes, so the hint fires until the user re-runs `/passthru:bootstrap` which rewrites the file with hashes
314+
- [x] remove the marker-touch logic from `session-start.sh` (dead code after this change)
315+
- [x] add bats: bootstrap run produces rules with `_source_hash`; re-running bootstrap is idempotent (hashes stable); settings with no matching imported entries -> hint fires with correct count; settings fully covered -> no hint; legacy imported file (rules without `_source_hash`) + settings with entries -> hint fires (honest migration); post-bootstrap run -> hint silences
316+
- [x] run `bats tests/*.bats` - must pass
317+
- [x] bump version to 0.4.1 in both manifests
318+
- [x] update CHANGELOG or release notes text in README if it has one; otherwise rely on gh release --generate-notes
319+
- [x] commit + open PR: `fix(hint): re-fire bootstrap hint until all settings entries imported`
320+
- [x] auto-merged, user to test post-release (policy: auto-merge after CI green)
321+
- [x] after user-confirmed local verification + CI green: merge PR, tag v0.4.1, release
322322

323323
### Task 2: PostToolUseFailure handler (ship as v0.4.2 patch)
324324

docs/rule-format.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ Absent `match` (or an empty object) matches any input, so the rule reduces to a
7979

8080
Human-readable note describing why the rule exists. The hook surfaces it in the `permissionDecisionReason` field Claude Code shows. Purely documentation for you. The verifier does not check it.
8181

82+
### `_source_hash` (string, optional)
83+
84+
SHA-256 hex digest of the original `permissions.allow` entry that a given rule was imported from. Present only on rules written by `scripts/bootstrap.sh`. You never need to set this by hand, and you should not edit it. The session-start bootstrap hint uses this field to compute the diff between entries in `settings.json` and rules already in `passthru.imported.json`: a rule carries `_source_hash` iff bootstrap has imported the corresponding native entry.
85+
86+
Legacy `passthru.imported.json` files from before this field existed have no hashes. In that case the hint re-fires every session until you re-run `/passthru:bootstrap`, which rewrites the file with hashes attached. After that the hint auto-silences as intended.
87+
8288
## Example rules
8389

8490
**Allow `gh api /repos/*/*/forks` across any owner/repo:**

hooks/common.sh

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,191 @@ audit_enabled() {
9191
[ -e "$sentinel" ]
9292
}
9393

94+
# ---------------------------------------------------------------------------
95+
# Settings entry helpers (used by bootstrap + session-start hint)
96+
# ---------------------------------------------------------------------------
97+
#
98+
# is_importable_entry <raw>
99+
# Returns 0 if bootstrap.sh's `convert_rule` would produce a rule for the
100+
# given native permission entry. Returns 1 otherwise. Single source of
101+
# truth so the session-start hash diff never drifts from what bootstrap
102+
# actually imports. Intentionally silent: no stdout, no stderr.
103+
#
104+
# Shapes accepted (must match convert_rule in scripts/bootstrap.sh):
105+
# Bash(<prefix>:*) -> importable when prefix non-empty
106+
# Bash(<exact command>) -> importable when no embedded newline
107+
# WebFetch(domain:<domain>) -> importable when domain non-empty
108+
# WebSearch -> importable (bare)
109+
# mcp__... -> importable when no parens
110+
# Read/Edit/Write(<path>[/**|/*]) -> importable when path passes
111+
# bootstrap's path-shape checks
112+
# Skill(<name>) -> importable when name non-empty
113+
#
114+
# Anything else returns 1.
115+
is_importable_entry() {
116+
local raw="$1"
117+
118+
# Trim whitespace (mirrors convert_rule).
119+
raw="${raw#"${raw%%[![:space:]]*}"}"
120+
raw="${raw%"${raw##*[![:space:]]}"}"
121+
122+
[ -z "$raw" ] && return 1
123+
124+
# Bash(...)
125+
if [[ "$raw" == Bash\(*\) ]]; then
126+
local inner="${raw#Bash(}"
127+
inner="${inner%)}"
128+
if [[ "$inner" == *:\* ]]; then
129+
local prefix="${inner%:\*}"
130+
[ -z "$prefix" ] && return 1
131+
return 0
132+
fi
133+
# Exact Bash command: reject embedded newline.
134+
[[ "$inner" == *$'\n'* ]] && return 1
135+
return 0
136+
fi
137+
138+
# WebFetch(domain:...)
139+
if [[ "$raw" == WebFetch\(domain:*\) ]]; then
140+
local domain="${raw#WebFetch(domain:}"
141+
domain="${domain%)}"
142+
domain="${domain#"${domain%%[![:space:]]*}"}"
143+
domain="${domain%"${domain##*[![:space:]]}"}"
144+
[ -z "$domain" ] && return 1
145+
return 0
146+
fi
147+
148+
# WebFetch(...) other than domain form: unsupported.
149+
if [[ "$raw" == WebFetch\(*\) ]]; then
150+
return 1
151+
fi
152+
153+
# mcp__... (no parens)
154+
if [[ "$raw" == mcp__* ]]; then
155+
[[ "$raw" == *"("* ]] && return 1
156+
[[ "$raw" == *")"* ]] && return 1
157+
return 0
158+
fi
159+
160+
# WebSearch (bare)
161+
if [ "$raw" = "WebSearch" ]; then
162+
return 0
163+
fi
164+
165+
# Read/Edit/Write(<path>)
166+
if [[ "$raw" == Read\(*\) ]] || [[ "$raw" == Edit\(*\) ]] || [[ "$raw" == Write\(*\) ]]; then
167+
local tool_name="${raw%%(*}"
168+
local inner="${raw#${tool_name}(}"
169+
inner="${inner%)}"
170+
[ -z "$inner" ] && return 1
171+
# Shell / env expansion syntax: $, ${}, $(), %VAR%.
172+
[[ "$inner" == *'$'* ]] && return 1
173+
[[ "$inner" == *'%'* ]] && return 1
174+
# Leading = (zsh equals expansion).
175+
[[ "$inner" == =* ]] && return 1
176+
# UNC path.
177+
[[ "$inner" == '\\'* ]] && return 1
178+
# Tilde variants other than `~/` and bare `~`.
179+
if [ "$inner" = '~' ]; then
180+
return 0
181+
fi
182+
if [ "${inner:0:2}" = "~/" ]; then
183+
return 0
184+
fi
185+
if [ "${inner:0:1}" = "~" ]; then
186+
return 1
187+
fi
188+
return 0
189+
fi
190+
191+
# Skill(<name>)
192+
if [[ "$raw" == Skill\(*\) ]]; then
193+
local name="${raw#Skill(}"
194+
name="${name%)}"
195+
name="${name#"${name%%[![:space:]]*}"}"
196+
name="${name%"${name##*[![:space:]]}"}"
197+
[ -z "$name" ] && return 1
198+
return 0
199+
fi
200+
201+
return 1
202+
}
203+
204+
# normalize_settings_entry <entry>
205+
# Emits the entry with leading/trailing whitespace stripped. No lowercasing
206+
# (Claude Code's permission parser is case-sensitive - `Bash` != `bash`),
207+
# no path collapsing, no reformatting. The single contract: two entries
208+
# that differ only by surrounding whitespace hash identically.
209+
normalize_settings_entry() {
210+
local s="$1"
211+
s="${s#"${s%%[![:space:]]*}"}"
212+
s="${s%"${s##*[![:space:]]}"}"
213+
printf '%s' "$s"
214+
}
215+
216+
# hash_settings_entry <entry>
217+
# Emits sha256 hex of normalize_settings_entry(<entry>) on stdout.
218+
# Empty on error (missing hashing tools). Uses the same shasum/sha256sum
219+
# detection as passthru_sha256 but hashes stdin content instead of a path.
220+
hash_settings_entry() {
221+
local normalized
222+
normalized="$(normalize_settings_entry "$1")"
223+
if command -v shasum >/dev/null 2>&1; then
224+
printf '%s' "$normalized" | shasum -a 256 2>/dev/null | awk '{print $1}'
225+
elif command -v sha256sum >/dev/null 2>&1; then
226+
printf '%s' "$normalized" | sha256sum 2>/dev/null | awk '{print $1}'
227+
fi
228+
}
229+
230+
# settings_importable_hashes
231+
# Scans every settings file (user + project shared/local) and emits one
232+
# hash per line for each `permissions.allow[]` string that passes
233+
# `is_importable_entry`. No output for missing files or empty allow
234+
# arrays. Malformed JSON files are silently skipped - the session-start
235+
# handler's job is nudging, not fault-reporting.
236+
settings_importable_hashes() {
237+
local user_home project_dir
238+
user_home="$(passthru_user_home)"
239+
project_dir="${PASSTHRU_PROJECT_DIR:-$PWD}"
240+
241+
local files=(
242+
"$user_home/.claude/settings.json"
243+
"$project_dir/.claude/settings.json"
244+
"$project_dir/.claude/settings.local.json"
245+
)
246+
247+
local f entry
248+
for f in "${files[@]}"; do
249+
[ -f "$f" ] || continue
250+
# Parse check is implicit: jq's error path returns no rows.
251+
# Only string entries count (matches bootstrap's filter).
252+
while IFS= read -r entry || [ -n "$entry" ]; do
253+
[ -z "$entry" ] && continue
254+
if is_importable_entry "$entry"; then
255+
hash_settings_entry "$entry"
256+
fi
257+
done < <(jq -r '(.permissions.allow // []) | map(select(type == "string")) | .[]' "$f" 2>/dev/null)
258+
done
259+
}
260+
261+
# imported_hashes
262+
# Scans every passthru.imported.json file (user + project) and emits each
263+
# present `_source_hash` value on its own line. Rules without the field
264+
# contribute nothing (legacy pre-hash files silently force the hint to
265+
# re-fire until bootstrap rewrites them).
266+
imported_hashes() {
267+
local user_imported project_imported
268+
user_imported="$(passthru_user_imported_path)"
269+
project_imported="$(passthru_project_imported_path)"
270+
271+
local f
272+
for f in "$user_imported" "$project_imported"; do
273+
[ -f "$f" ] || continue
274+
jq -r '(.allow // []) | map(select(._source_hash != null and (._source_hash | type == "string"))) | .[]._source_hash' \
275+
"$f" 2>/dev/null
276+
done
277+
}
278+
94279
# audit_log_path: path to ~/.claude/passthru-audit.log (may not exist).
95280
audit_log_path() {
96281
printf '%s/.claude/passthru-audit.log\n' "$(passthru_user_home)"

0 commit comments

Comments
 (0)