crewforth 3.1.0-rc.3 → 3.1.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +66 -9
  2. package/README.md +1 -1
  3. package/kit/AGENT_TEMPLATE.md +26 -17
  4. package/kit/CLAUDE.md +11 -0
  5. package/kit/agents/crew-backend-expert.md +5 -0
  6. package/kit/agents/crew-commit-agent.md +8 -3
  7. package/kit/agents/crew-database-expert.md +5 -0
  8. package/kit/agents/crew-devops-expert.md +5 -0
  9. package/kit/agents/crew-frontend-expert.md +5 -0
  10. package/kit/agents/crew-performance-expert.md +5 -0
  11. package/kit/agents/crew-planner.md +5 -0
  12. package/kit/agents/crew-privacy-agent.md +7 -2
  13. package/kit/agents/crew-review-agent.md +5 -0
  14. package/kit/agents/crew-security-expert.md +8 -4
  15. package/kit/agents/crew-session-manager.md +7 -3
  16. package/kit/agents/crew-test-expert.md +5 -0
  17. package/kit/eval/doctor.sh +23 -7
  18. package/kit/eval/smoke-test.sh +377 -27
  19. package/kit/hooks/context-usage.sh +1 -1
  20. package/kit/hooks/guard-agent-model.sh +392 -0
  21. package/kit/hooks/guard-bash.sh +61 -5
  22. package/kit/hooks/guard-schedule.sh +92 -0
  23. package/kit/hooks/prompt-approval.sh +68 -21
  24. package/kit/settings.json +22 -0
  25. package/kit/skills/crew-approve/SKILL.md +26 -0
  26. package/kit/studio/server/index.js +1 -1
  27. package/kit/studio/server/lib/graph.js +127 -10
  28. package/kit/studio/server/lib/usage.js +96 -25
  29. package/kit/studio/web/app.js +49 -6
  30. package/kit/studio/web/canvas.js +195 -30
  31. package/kit/studio/web/chat.js +8 -1
  32. package/kit/studio/web/convo.js +5 -1
  33. package/kit/studio/web/graph-plan.js +139 -6
  34. package/kit/studio/web/inspect.js +33 -1
  35. package/kit/studio/web/list.js +8 -2
  36. package/kit/studio/web/names.js +65 -0
  37. package/kit/studio/web/style.css +65 -6
  38. package/kit/studio/web/timeline-plan.js +8 -0
  39. package/kit/studio/web/timeline.js +10 -3
  40. package/kit/studio/web/usage.js +53 -12
  41. package/package.json +1 -1
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env bash
2
+ # guard-agent-model.sh - a crew agent is called with a model, and not below its floor.
3
+ #
4
+ # WHY. The model for a piece of work is chosen per call, by risk (the table in CLAUDE.md): haiku for mechanical
5
+ # work, sonnet for ordinary work, opus where a mistake is expensive. Left to the session's default, every agent ran
6
+ # on the session's model; left to a habit of "cheapest", an audit could run below the code it clears. Two things
7
+ # are held here, at the tool level:
8
+ # * a call to a crew-* agent NAMES its model, so the choice is made and can be read in the transcript;
9
+ # * some agents have a floor: crew-security-expert runs on opus; crew-privacy-agent, crew-review-agent,
10
+ # crew-planner and crew-database-expert on sonnet or above.
11
+ # It only refuses, with the reason and the model that is needed. It never rewrites a call. THE WORDING IS READ BY
12
+ # ANOTHER PROGRAM (the Studio panel): a refusal is one line that begins `GUARD (agent model):`, and a floor is said
13
+ # as ` runs on <model> or above`. The suite pins both. Measured on Claude Code
14
+ # 2.1.294: refused this way, the session repeated the same call with the model named, and a call with
15
+ # model: "haiku" ran its agent on Haiku (the agent's own transcript names the model).
16
+ #
17
+ # NOT TOUCHED: an agent that is not crew-* (Explore, general-purpose, a project's own), and any tool but Agent.
18
+ # SWITCHES, read from the session's environment:
19
+ # CREW_MODEL_ROUTING=off the gate does nothing, which is how it was before 3.1.0.
20
+ # CREW_ALLOW_FABLE=1 `fable` may be named; without it a call that names it is refused.
21
+ # HONEST SCOPE: which model fits the TASK is the caller's judgement and nothing here reads the task. The model is
22
+ # read from the call's own `model` field; an agent file's frontmatter is not read.
23
+ set -uo pipefail
24
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
25
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
26
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
27
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
28
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
29
+ # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
30
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
31
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
32
+ # staged key all passed.
33
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
34
+ # scan for private strings read that as "no match".
35
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
36
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
37
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
38
+ # Byte-identical in every gate; the suite pins it.
39
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
40
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
41
+ export LC_ALL=C
42
+ # ---- /CREW-LOCALE
43
+ # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
44
+ # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
45
+ # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
46
+ # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
47
+ # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
48
+ # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
49
+ # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
50
+ # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
51
+ # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
52
+ # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
53
+ # that is not found (status 127, the script continues inside the same command).
54
+ # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
55
+ _crew_stop(){ # $1 = what happened
56
+ declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
57
+ echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
58
+ echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
59
+ exit 2
60
+ }
61
+ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
62
+ # ---- /CREW-FAILCLOSED
63
+ _gate_main(){
64
+ local tn st m ti rank=0 floor=0 fname=""
65
+ [ "${CREW_MODEL_ROUTING:-}" = off ] && exit 0
66
+ IFS= read -r -d '' INPUT || true
67
+ # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
68
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
69
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
70
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
71
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
72
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
73
+ # counts, against 0.02 s for one `case`; a 6 MB Write to a gate file was refused after 636 s, past the 600 s
74
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
75
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
76
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
77
+ # taken from the whole text, and the small pieces from the block.
78
+ local LC_ALL=C
79
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
80
+ _JF=-1
81
+ while [ "$i" -lt "$n" ]; do
82
+ b="${1:i:B+kl-1}"
83
+ case "$b" in *"$2"*)
84
+ j=0; bn=${#b}
85
+ while [ "$j" -lt "$bn" ]; do
86
+ c="${b:j:C+kl-1}"
87
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
88
+ j=$((j+C))
89
+ done ;;
90
+ esac
91
+ i=$((i+B))
92
+ done
93
+ }
94
+ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
95
+ local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
96
+ # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
97
+ # them, the two never disagree about a unit. Walking bytes is safe because no UTF-8
98
+ # continuation byte can be 0x5C or 0x22, so a cut cannot land inside a character at a
99
+ # quote or backslash edge. What this line is worth in SPEED depends on the platform --
100
+ # read the table in _json_unescape below before quoting a number for it.
101
+ local pre rest seg w r n base=0 j=0 cl lim run=0 chunk C=4096 W=256 hay="$1" k="\"$2\""; local -a acc=("")
102
+ # It ALSO sets `_JS`, so a caller on the hot path can read the value without `$( )`, which is a fork. The
103
+ # printf stays because the existing callers compose it (`_json_unescape "$(_json_slice …)"`), but the one
104
+ # caller that used to do its own fork-free extraction — `_CWD` — reads `_JS` and keeps costing nothing.
105
+ _JS=""
106
+ # Every "step past X" here is arithmetic on a length, never `${s#"$literal"}`. That shape reads like a
107
+ # constant-time strip and is not one: bash retries the pattern at every prefix length, so stripping an
108
+ # n-byte literal costs O(n^2). It was in this function twice, and both were measured:
109
+ # * `${1#*"$2"}` found the key -- cheap when the key sits near the front, quadratic in the distance to it
110
+ # otherwise. `permission_mode` behind a 100 KB command took 7.94s on Git Bash, 0.27s in front of it.
111
+ # Captured from Claude Code 2.1.267, the real order puts `permission_mode` BEFORE `tool_input`, so on
112
+ # that version this cost was not reachable -- this file's header had shown the opposite order, and was
113
+ # wrong. The parse stays order-independent regardless: the order is not a documented contract, and the
114
+ # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
115
+ # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
116
+ # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
117
+ # _json_find gives the FIRST occurrence. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
118
+ # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
119
+ # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
120
+ # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
121
+ # {"tool_name":"Bash","a":"command","ls":1,"tool_input":{"command":"rm -rf /"}} -> read `ls`
122
+ # {"a":"permission_mode","default":1,"permission_mode":"bypassPermissions",…} -> read `default`
123
+ # {"a":"file_path","/tmp/ok":1,"tool_input":{"file_path":".claude/hooks/x.sh"}} -> read `/tmp/ok`
124
+ # In each case the gate judged a harmless string while a dangerous one was the actual argument, and jq read
125
+ # the real value -- so on a stock Windows desktop, where this was already the only parser, the gate could be
126
+ # aimed with one extra key. The loop below therefore accepts an occurrence only when optional JSON whitespace
127
+ # and then `:` follow it, which is the only place a key can appear; everything else keeps searching.
128
+ # Stepping by length removed the quadratic strip, but each escaped quote still sliced and re-assigned the
129
+ # whole remainder, so an escape-dense command stayed k*n here as well (2.2s of the 6.8s described in
130
+ # _json_unescape below, on Git Bash). The walk is chunked the same way: the payload is read 4096 bytes at a
131
+ # time and every per-quote operation stays inside the chunk. One thing is carried across edges on purpose
132
+ # -- the length of the backslash run in front of a quote -- because that run can straddle a window or a
133
+ # chunk, and its parity is what decides whether the quote ends the value. Isolated, escape-dense 44030 B:
134
+ # 2.61s -> 0.41s on Git Bash 5.3.15, 0.93s -> 0.09s on macOS/bash 3.2.
135
+ # Output is byte-identical to the previous shape across 378 cases at four chunk/window sizes down to 7 and 1
136
+ # bytes, and that shape to the one before it across a 27-case battery (escaped quotes, backslash runs before
137
+ # a quote, the key twice, the key appearing first as a value, glob metacharacters, UTF-8, no closing quote,
138
+ # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
139
+ # the battery sees both.
140
+ # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
141
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
142
+ # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
143
+ # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
144
+ # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
145
+ # 200 decoys 3344 B 19 ms 3200 decoys 56544 B 1839 ms
146
+ # Git Bash's parameter expansion is several times slower again, so the tail is where the timeout lives. A
147
+ # real payload carries ZERO decoys -- 64 is far above anything a producer can legitimately emit -- and going
148
+ # over the cap is reported SEPARATELY (`_KC_CAPPED`), not as a large count, so the caller can refuse with a
149
+ # reason the reader can act on instead of a sentinel dressed up as a measurement. Fail-closed either way.
150
+ # THE COST IS ONE THIS CHANGE INTRODUCES, and it is worth saying so plainly rather than implying the cap
151
+ # protects something pre-existing: before the colon requirement the scan STOPPED at the first occurrence, so
152
+ # decoys cost essentially nothing (measured on stock Windows: 0 decoys 0.18 ms, 3200 decoys 1.60 ms per call,
153
+ # roughly linear) -- and it read the decoy, which is the hole. The cap is load-bearing for the new cost, so
154
+ # raising or removing it later is not a tidy-up.
155
+ # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
156
+ # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
157
+ # both far inside the 60s timeout these hooks had when this was measured, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
158
+ # predates this change and belongs to the walk, not to the cap.
159
+ local _cap=64 _seen=0
160
+ while :; do
161
+ _json_find "$hay" "$k" # where the next `"key"` starts
162
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
163
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
164
+ rest="${hay:_JF+${#k}}" # past `"key"`
165
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
166
+ case "$rest" in
167
+ :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
168
+ *) hay="$rest" ;; # a value, or another token: keep looking
169
+ esac
170
+ done
171
+ # THE VALUE MUST BE A STRING. `"command":123}}` used to come back as the literal text `:123}}` -- the old
172
+ # shape skipped forward to the next quote wherever it was, so a non-string value handed the matchers the
173
+ # payload's own punctuation to judge. Emitting nothing instead lets the caller's "no readable command"
174
+ # refusal fire, which is the honest answer for a value these gates cannot read.
175
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
176
+ case "$rest" in '"'*) rest="${rest:1}" ;; *) return 0 ;; esac
177
+ # Walk to the closing quote that is NOT escaped. A `"` preceded by an odd number of backslashes is content.
178
+ n=${#rest}; chunk="${rest:0:C}"; cl=${#chunk}
179
+ while :; do
180
+ lim=$((cl - j))
181
+ if [ "$lim" -le 0 ]; then
182
+ [ $((base + cl)) -lt "$n" ] || break # no closing quote at all: everything is already taken
183
+ base=$((base + j)); chunk="${rest:base:C}"; cl=${#chunk}; j=0; continue
184
+ fi
185
+ [ "$lim" -le "$W" ] || lim=$W
186
+ w="${chunk:j:lim}"
187
+ seg="${w%%\"*}"
188
+ if [ "$seg" = "$w" ]; then # no quote in view: take all of it, carry the run
189
+ acc+=("$w"); j=$((j+lim))
190
+ r="${w##*[!\\]}"; case "$w" in *[!\\]*) run=${#r} ;; *) run=$((run+${#w})) ;; esac
191
+ continue
192
+ fi
193
+ acc+=("$seg"); j=$((j+${#seg}+1))
194
+ r="${seg##*[!\\]}"; case "$seg" in *[!\\]*) run=${#r} ;; *) run=$((run+${#seg})) ;; esac
195
+ if [ $((run % 2)) -eq 1 ]; then acc+=("\""); run=0; else break; fi
196
+ done
197
+ local IFS=''; _JS="${acc[*]}"; printf '%s' "$_JS"
198
+ }
199
+ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corrupt `\\"` (escaped backslash + quote)
200
+ # This is the tier-3 path below -- the one a stock Windows install actually runs on. It used to walk ONE
201
+ # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
202
+ # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
203
+ #
204
+ # That was not a comfort question. This hook's timeout was 60s then -- set in settings.json (600s since 3.1.0), NOT Claude Code's
205
+ # default, and reading the default instead is how a first pass at this got the consequence wrong. A
206
+ # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
207
+ # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
208
+ # not exotic: across 6791 Bash calls in 280 real transcripts, 2.49% of commands are bigger, and the largest
209
+ # is 46815 B, which the old shape needed roughly 39 minutes to decode. One Bash call in forty walked past
210
+ # §4.4 and §4.5 entirely, silently, on every stock Windows desktop.
211
+ #
212
+ # Two changes, and their wins are NOT the same shape -- writing them down as one number was the mistake
213
+ # this comment exists to avoid repeating:
214
+ # * Taking the whole run up to the next backslash in ONE expansion, and indexing with `${s:0:1}` instead
215
+ # of matching a pattern, is 47x on a 4.4 KB payload. This one holds on every machine.
216
+ # * `local LC_ALL=C` makes these expansions byte-oriented instead of re-decoding the string on every
217
+ # substring operation. What that is WORTH depends on the platform, and putting a single number here
218
+ # would have been wrong three separate ways -- it was written as "another 5x" twice before this:
219
+ # macOS / bash 3.2, a locale set .................. 5x
220
+ # Git Bash 5.3.15, a locale set ................... 1.23x (measured on the OLD shape; the new one
221
+ # touches the locale once per escape
222
+ # rather than once per character, so its
223
+ # ratio is smaller and unmeasured)
224
+ # Git Bash, LANG empty -- what Claude Code starts .. nothing at all
225
+ # So speed is not what keeps this line; CORRECTNESS is, and that half holds everywhere. `[0-9a-fA-F]`
226
+ # below is a collation-defined range outside the C locale: on a tr_TR desktop it is not 0-9a-f. That
227
+ # alone would justify the line, and it costs nothing. `local` restores the previous locale on return
228
+ # -- verified on bash 3.2 and on Git Bash 5.3.15, not assumed.
229
+ #
230
+ # Then the cost moved rather than went away. With the per-character walk gone, each escape still sliced the
231
+ # whole REMAINDER -- `s="${s:${#pre}+1}"` -- so an escape-dense command stayed k*n: on Git Bash a 44080 B
232
+ # command with 2755 escapes took 6.8s, 11% of the timeout. The input is now touched only a chunk at a time
233
+ # (`${s:base:C}`, once per 4096 bytes) and every per-escape operation stays inside that chunk, so no escape
234
+ # pays for the length of the whole command. That is the change that matters: bounding only the lookahead,
235
+ # while still indexing the full string, was measured too and gave about 4x on both machines, against 6-12x
236
+ # for the chunked walk. Five bytes are held back at a chunk's end whenever more input follows, so a
237
+ # `\uXXXX` that starts in a chunk ends in it.
238
+ # The output goes into an array joined once, not a string recopied at every append, and `\n`/`\t` are
239
+ # written `$'\n'`/`$'\t'`, so this file carries no raw TAB for an editor or a copy to turn into spaces.
240
+ #
241
+ # Measured, isolated, previous shape -> this one:
242
+ # macOS/bash 3.2 escape-dense 44030 B, 2590 escapes .. 1.44s -> 0.12s
243
+ # sparse 100014 B, 4 escapes ........... 0.09s -> 0.02s
244
+ # Git Bash 5.3.15 escape-dense 44030 B, 2590 escapes .. 3.92s -> 0.54s
245
+ # (LANG empty) sparse 99997 B, 4 escapes ............ 0.49s -> 0.07s
246
+ # These numbers do not travel, which is why each one names its machine.
247
+ #
248
+ # Output is byte-identical to the previous shape across 478 cases, each run at four chunk/window sizes
249
+ # down to 7 and 1 bytes so that an edge falls every few bytes; that shape was itself byte-identical to
250
+ # the original character walk across a 31-case battery (escaped quotes, `\\`, a lone trailing backslash, a
251
+ # truncated `\u`, Turkish, emoji). Deliberately broken twins -- the 5-byte margin cut to 4, the backslash
252
+ # not stepped over -- prove the battery sees both edges, and tier 1 and tier 3 return the same verdict on
253
+ # the gate cases.
254
+ local LC_ALL=C
255
+ local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
256
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
257
+ n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
258
+ while :; do
259
+ lim=$((cl - j))
260
+ if [ $((base + cl)) -lt "$n" ]; then # more input after this chunk: hold 5 bytes back, so
261
+ lim=$((lim - 5)) # a `\uXXXX` that starts in a chunk also ends in it
262
+ if [ "$lim" -le 0 ]; then base=$((base + j)); chunk="${s:base:C}"; cl=${#chunk}; j=0; continue; fi
263
+ else
264
+ [ "$lim" -gt 0 ] || break
265
+ fi
266
+ [ "$lim" -le "$W" ] || lim=$W
267
+ w="${chunk:j:lim}"
268
+ pre="${w%%\\*}" # the literal run before the next backslash in view
269
+ if [ "$pre" = "$w" ]; then acc+=("$w"); j=$((j+lim)); continue; fi
270
+ acc+=("$pre"); j=$((j+${#pre}+1))
271
+ if [ $((base + j)) -ge "$n" ]; then acc+=("\\"); break; fi # a lone trailing backslash stays literal, as before
272
+ c="${chunk:j:1}"; j=$((j+1))
273
+ case "$c" in
274
+ n) acc+=($'\n') ;;
275
+ t) acc+=($'\t') ;;
276
+ r) ;;
277
+ b|f) acc+=(" ") ;;
278
+ u) if [ $((n - base - j)) -ge 4 ]; then h="${chunk:j:4}"; j=$((j+4)); else h=""; fi # a short tail is left alone, as before
279
+ # A `\uXXXX` used to become a literal `?`. That is not a lossy nicety, it is a hole: `\u002e` is `.`,
280
+ # so `\u002eclaude/hooks/guard-bash.sh` decoded to `?claude/…` and matched no gate pattern, while jq
281
+ # decoded the same bytes to the real path — the two tiers disagreed on whether a payload was an
282
+ # attack. Printable ASCII is decoded properly (builtin printf, no fork); anything else still becomes
283
+ # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
284
+ case "$h" in
285
+ 00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
286
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
287
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
288
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
289
+ *) acc+=("?") ;;
290
+ esac ;;
291
+ *) acc+=("$c") ;;
292
+ esac
293
+ done
294
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
295
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
296
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
297
+ }
298
+ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
299
+ # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
300
+ # …)"` first, which is a command substitution, which is a FORK -- on a hook that runs before every Bash and
301
+ # every Write call. Measured on macOS/bash, typical payload, 200 reps: the substitution form cost 0.790 ms
302
+ # per call against 0.090 ms for the raw byte test it replaces, and the whole hook went 15.00 -> 16.58 ms.
303
+ # This project's own recorded Git Bash process cost is 62-135 ms, rising to ~400 ms under load, so three
304
+ # added forks per call would have been a freeze on the platform the change is meant to protect. Setting _KC
305
+ # keeps the count in the caller's own shell: expansion only, zero forks.
306
+ # The counter the ambiguity refusals need, and it exists because the hand-written byte test they used to do
307
+ # looked for a DIFFERENT token than the parser above: `"key":` compact, while the parser searches `"key"` and
308
+ # tolerates whitespace before the colon. Two consequences, both measured on the shipped hook:
309
+ # * `{…"meta":{"command":"ls"},"tool_input":{"command" : "rm -rf /"}}` -- ONE SPACE and the duplicate-key
310
+ # refusal went blind while the parser happily read the first value.
311
+ # * a key name appearing as a VALUE was counted as an occurrence by neither, which is the hole the parser
312
+ # above now closes; counting the same form here is what keeps the two from drifting apart again.
313
+ # Sharing ONE definition with the parser is the point: a guard that searches for something else than the
314
+ # thing it guards is the defect, not an implementation detail.
315
+ # WHAT THE TOKEN CAN BE, stated correctly. An earlier version of this comment said the bytes `"command"`
316
+ # with both quotes unescaped "can only be a key or a value equal to the key name". That is wrong, and the
317
+ # counter-example was found by review, not by reasoning: a KEY whose own name ends with a quote spells the
318
+ # token out of its escaped quote plus the string's terminator --
319
+ # {"tool_input":{"x\"command":"DECOY","command":"rm -rf /"}} slice -> DECOY, count -> 2
320
+ # which is refused BECAUSE the count sees two, not because the invariant held. The same shape as a VALUE is
321
+ # harmless (a string's terminator is followed by `,` `}` `]`, never `:`, so it is skipped and not counted:
322
+ # {"description":"ends with \"command","command":"rm -rf /"} slice -> rm -rf /, count -> 1 ).
323
+ # What IS true, and is what keeps ordinary work from being refused: content can contribute at most one
324
+ # occurrence per string and only as that string's tail, where the next byte is never a colon. Measured on 19
325
+ # payloads built by a real JSON encoder -- `grep -rn '"command":' .`, a heredoc writing a hooks.json, `sed`
326
+ # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
327
+ # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
328
+ # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
329
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
330
+ _KC=0; _KC_CAPPED=0
331
+ while :; do
332
+ _json_find "$hay" "$k"
333
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
334
+ # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
335
+ # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
336
+ # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
337
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
338
+ rest="${hay:_JF+${#k}}"
339
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
340
+ case "$rest" in :*) c=$((c+1)) ;; esac
341
+ hay="$rest"
342
+ done
343
+ }
344
+ # ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
345
+ _json_slice "$INPUT" tool_name >/dev/null; tn="$_JS"
346
+ case "$tn" in Agent|Task) ;; *) exit 0 ;; esac
347
+ # The call's own fields: everything from "tool_input" on, so a key of the same name in front of it is not read.
348
+ _json_find "$INPUT" '"tool_input"'
349
+ [ "$_JF" -ge 0 ] || exit 0
350
+ ti="${INPUT:_JF}"
351
+ _json_keycount "$ti" subagent_type
352
+ if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names subagent_type more than once, so which agent it starts cannot be read and the call is refused. Send one." >&2; exit 2; fi
353
+ _json_slice "$ti" subagent_type >/dev/null; _json_unescape "$_JS" >/dev/null; st="$_JU"
354
+ st="${st##*:}" # a plugin names its agents <plugin>:crew-…
355
+ case "$st" in crew-*) ;; *) exit 0 ;; esac
356
+ _json_keycount "$ti" model
357
+ if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names model more than once, so which model it asks for cannot be read and the call is refused. Send one." >&2; exit 2; fi
358
+ _json_slice "$ti" model >/dev/null; _json_unescape "$_JS" >/dev/null; m="$_JU"
359
+ shopt -s nocasematch
360
+ case "$m" in
361
+ '') rank=0 ;;
362
+ *haiku*) rank=1; m=haiku ;;
363
+ *sonnet*) rank=2; m=sonnet ;;
364
+ *opus*) rank=3; m=opus ;;
365
+ *fable*) rank=4; m=fable ;;
366
+ *) rank=-1 ;;
367
+ esac
368
+ shopt -u nocasematch
369
+ case "$st" in
370
+ crew-security-expert) floor=3; fname=opus ;;
371
+ crew-privacy-agent|crew-review-agent|crew-planner|crew-database-expert) floor=2; fname=sonnet ;;
372
+ esac
373
+ if [ "$rank" = 0 ]; then
374
+ echo "GUARD (agent model): a call to $st has to name its model. Choose haiku, sonnet or opus by the table in CLAUDE.md (risk decides, not size) and repeat the same call with model set.${fname:+ $st runs on $fname or above.}" >&2
375
+ exit 2
376
+ fi
377
+ if [ "$rank" = -1 ]; then
378
+ echo "GUARD (agent model): '$m' is not a model this gate knows. Repeat the call to $st with model haiku, sonnet or opus.${fname:+ $st runs on $fname or above.}" >&2
379
+ exit 2
380
+ fi
381
+ if [ "$rank" = 4 ] && [ "${CREW_ALLOW_FABLE:-}" != 1 ]; then
382
+ echo "GUARD (agent model): fable is not used for a crew agent unless the user has set CREW_ALLOW_FABLE=1 for the session.${fname:+ $st runs on $fname or above.} Repeat the call to $st with haiku, sonnet or opus, by the table in CLAUDE.md." >&2
383
+ exit 2
384
+ fi
385
+ if [ "$rank" -lt "$floor" ]; then
386
+ echo "GUARD (agent model): $st runs on $fname or above, and this call asks for $m. Repeat the same call with model $fname. An audit does not run below the work it clears." >&2
387
+ exit 2
388
+ fi
389
+ exit 0
390
+ }
391
+ _gate_main "$@"
392
+ _crew_stop "a command of the gate was abandoned"
@@ -1843,6 +1843,62 @@ _appr_named
1843
1843
  [ "$_APN" = 2 ] && block "the approval hook fed by a command (only Claude Code runs it, with the user's message)" "4.4" tamper
1844
1844
  [ "$_APN" = 3 ] && block "a Claude Code session started from a command to continue this one (it could approve for it)" "4.4" tamper
1845
1845
 
1846
+ # §4.4: A SESSION STARTED FROM A COMMAND IS NOT HANDED AN APPROVAL. `claude -p "/crew-approve commit"` starts a session
1847
+ # whose only message is the approval; that session would record it and commit, and this one would have approved for
1848
+ # the user. hooks/prompt-approval.sh refuses to record in a headless session and at a session's first message, which
1849
+ # is the protection; this is the second line, for the forms a command spells out. A command that names claude (so
1850
+ # also `npx @anthropic-ai/claude-code`, `bunx`, `node …/claude-code/cli.js`) is read word by word, the way the shell
1851
+ # splits it, and refused when one WORD is an approval: a quoted argument, an escaped one, the value of an option
1852
+ # (`--prompt=…`) or of a variable (`p='…'`), a text echoed into it. Which text is an approval is asked of the one
1853
+ # function that reads the user's prompt, _crew_appr_op, sourced from prompt-approval.sh. The prompt of an ordinary
1854
+ # `claude -p "…"` is not an approval and nothing here touches it.
1855
+ # HONEST SCOPE: a prompt read from a file, built by printf, or written with an escape the shell decodes (`$'\x2f…'`)
1856
+ # is not spelled in the command, and is past this rule.
1857
+ _appr_word(){ # $1 = a word of the command -> 0 when it, or what follows its first `=`, is an approval
1858
+ local w="$1"
1859
+ [ "${#w}" -le 160 ] || return 1
1860
+ _crew_appr_op "$w"; [ -n "$OP" ] && return 0
1861
+ case "$w" in *=*) _crew_appr_op "${w#*=}"; [ -n "$OP" ] && return 0 ;; esac
1862
+ return 1
1863
+ }
1864
+ _appr_in_claude_call(){ # 0 = a word of this command is an approval (OP names it)
1865
+ # The command AS IT WAS SENT: the prompt of a plain `claude -p "…"` has been taken out of CMD by now (it is text for
1866
+ # another session, not a command of this one), and it is exactly the word this rule has to read.
1867
+ local s="${CMD_REAL:-$CMD}" w="" pre c body inw=0 nc=0 here="${BASH_SOURCE%/*}"
1868
+ [ "$here" = "${BASH_SOURCE}" ] && here=.
1869
+ shopt -q nocasematch && nc=1; shopt -s nocasematch
1870
+ case "$s" in *claude*) [ "$nc" = 0 ] && shopt -u nocasematch ;; *) [ "$nc" = 0 ] && shopt -u nocasematch; return 1 ;; esac
1871
+ [ -f "$here/prompt-approval.sh" ] || return 1 # no recording hook: no approval can be recorded at all
1872
+ declare -F _crew_appr_op >/dev/null 2>&1 || . "$here/prompt-approval.sh"
1873
+ declare -F _crew_appr_op >/dev/null 2>&1 || return 1
1874
+ OP=""
1875
+ while :; do
1876
+ pre="${s%%[\"\'\\$' \t\n;|&()<>']*}"
1877
+ [ -n "$pre" ] && { w="$w$pre"; inw=1; }
1878
+ [ "$pre" = "$s" ] && break
1879
+ c="${s:${#pre}:1}"; s="${s:${#pre}+1}"
1880
+ case "$c" in
1881
+ \\) case "$s" in $'\n'*) s="${s:1}" ;; *) w="$w${s:0:1}"; s="${s:1}"; inw=1 ;; esac ;;
1882
+ \') [ "${w%\$}" != "$w" ] && w="${w%\$}" # $'…': the text is kept as written
1883
+ body="${s%%\'*}"; if [ "$body" = "$s" ]; then s=""; else s="${s:${#body}+1}"; fi
1884
+ w="$w$body"; inw=1 ;;
1885
+ \") inw=1
1886
+ while :; do
1887
+ body="${s%%[\"\\]*}"; w="$w$body"
1888
+ [ "$body" = "$s" ] && { s=""; break; }
1889
+ c="${s:${#body}:1}"; s="${s:${#body}+1}"
1890
+ [ "$c" = '"' ] && break
1891
+ w="$w${s:0:1}"; s="${s:1}"
1892
+ done ;;
1893
+ *) if [ "$inw" = 1 ]; then _appr_word "$w" && return 0; fi
1894
+ w=""; inw=0 ;;
1895
+ esac
1896
+ done
1897
+ [ "$inw" = 1 ] && _appr_word "$w" && return 0
1898
+ return 1
1899
+ }
1900
+ _appr_in_claude_call && block "a command that hands a Claude Code session an approval as its prompt (only the user's own message approves)" "4.4" tamper
1901
+
1846
1902
  # §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
1847
1903
  # `cat .env` would surface them. Block the direct-file readers/copiers and a `< .env` input redirect on a
1848
1904
  # .env / .env.<env> file; the templates (.env.example/.sample/.template/.dist) stay readable. Arg-taking readers
@@ -2155,7 +2211,7 @@ _crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktre
2155
2211
  }
2156
2212
  # ---- /CREW-APPROVAL-PATH ---------------------------------------------------------------------------------
2157
2213
  # §4.4 IN `auto` AND `dontAsk`: THE USER'S OWN MESSAGE IS THE APPROVAL. hooks/prompt-approval.sh records it when the
2158
- # whole message is `approve: commit` / `push` / `commit+push` (or `onay: …`), with what git reported at that moment.
2214
+ # whole message is `/crew-approve commit` / `push` / `commit+push` (or the older `approve: …`), with what git reported at that moment.
2159
2215
  # This reads the record back and answers one question: does it cover THIS call, and nothing else?
2160
2216
  # commit the index writes the recorded tree and HEAD is the recorded one. A commit moves HEAD, so the same
2161
2217
  # record cannot allow a second one; a commit that FAILED (a hook refused it) moved nothing and may be retried.
@@ -3167,7 +3223,7 @@ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you
3167
3223
  if ! _appr_wired; then
3168
3224
  # The record is written by one hook. Where it is not wired, a message from the user records nothing, and
3169
3225
  # telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
3170
- echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A message 'approve: …' records nothing here." >&2
3226
+ echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A '/crew-approve' message records nothing here." >&2
3171
3227
  echo "Tell the user exactly that, in their language, and give them the two ways that work: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
3172
3228
  echo "Do not create an approval any other way." >&2
3173
3229
  exit 2
@@ -3175,9 +3231,9 @@ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you
3175
3231
  _aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
3176
3232
  echo "ASK THE USER NOW, in their language, in this shape and with nothing after it (stage first: the approval covers what is staged at that moment):" >&2
3177
3233
  echo " 1. the commit message, whole, in ONE code block;" >&2
3178
- echo " 2. under it ONE line: If you approve, write only this: approve: $_aps" >&2
3179
- echo " (in Turkish: Onaylıyorsan yalnız şunu yaz: onay: $_aps). Name the one that fits: commit, push, or commit+push when a push follows the commit." >&2
3180
- echo "Their reply has to be that text alone: a sentence such as 'go ahead and commit' is not an approval. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
3234
+ echo " 2. under it ONE line, the sentence in their language and the command as it is (only the user can type it): If you approve, send only this: /crew-approve $_aps" >&2
3235
+ echo " Name the one the user can type for this call: /crew-approve commit, /crew-approve push, or /crew-approve commit+push when a push follows the commit." >&2
3236
+ echo "Their message has to be that command alone, typed by them: a sentence such as 'go ahead and commit' is not an approval, and you cannot run the command for them. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
3181
3237
  echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
3182
3238
  echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
3183
3239
  echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # guard-schedule.sh - a prompt a tool schedules is not the user's (§4.4).
3
+ #
4
+ # WHY. In `auto` and `dontAsk` the approval for a commit or a push is what the user types: `/crew-approve commit`,
5
+ # recorded by hooks/prompt-approval.sh from UserPromptSubmit. A prompt that a tool SCHEDULES raises the same event
6
+ # when it fires. So a session that scheduled `/crew-approve commit` would be approving its own commit.
7
+ #
8
+ # WHAT. Wired on PreToolUse for the tools that schedule a prompt (CronCreate, ScheduleWakeup, RemoteTrigger, and an
9
+ # MCP tool whose name holds trigger, schedule, cron or later). The call is refused when ANY string in its input is
10
+ # an approval, by the function that reads the user's own prompt: prompt-approval.sh is sourced for it, so there is
11
+ # one definition of what an approval is. An ordinary scheduled prompt holds no such string and passes untouched.
12
+ #
13
+ # COST. One awk on these few tools, to take the strings out of the payload; nothing on any other tool call.
14
+ # HONEST SCOPE. A tool this hook is not wired on, or a prompt put together only when it fires, is past it.
15
+ set -uo pipefail
16
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
17
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
18
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
19
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
20
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
21
+ # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
22
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
23
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
24
+ # staged key all passed.
25
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
26
+ # scan for private strings read that as "no match".
27
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
28
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
29
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
30
+ # Byte-identical in every gate; the suite pins it.
31
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
32
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
33
+ export LC_ALL=C
34
+ # ---- /CREW-LOCALE
35
+ # ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
36
+ # A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
37
+ # on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
38
+ # one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
39
+ # it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
40
+ # `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
41
+ # * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
42
+ # * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
43
+ # _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
44
+ # Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
45
+ # that is not found (status 127, the script continues inside the same command).
46
+ # Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
47
+ _crew_stop(){ # $1 = what happened
48
+ declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
49
+ echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
50
+ echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
51
+ exit 2
52
+ }
53
+ trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
54
+ # ---- /CREW-FAILCLOSED
55
+ _gate_main(){
56
+ local here="${BASH_SOURCE%/*}" strs sv tn sch=0
57
+ [ "$here" = "${BASH_SOURCE}" ] && here=.
58
+ IFS= read -r -d '' INPUT || true
59
+ if [ ! -f "$here/prompt-approval.sh" ]; then
60
+ echo "GUARD (§4.4): prompt-approval.sh is not beside this gate, so a scheduled prompt cannot be checked for an approval and the call is refused. Update Crewforth." >&2
61
+ exit 2
62
+ fi
63
+ . "$here/prompt-approval.sh"
64
+ declare -F _crew_appr_op >/dev/null 2>&1 || { echo "GUARD (§4.4): prompt-approval.sh did not give the approval check, so the call is refused. Update Crewforth." >&2; exit 2; }
65
+ _json_slice "$INPUT" tool_name >/dev/null; tn="$_JS"
66
+ shopt -s nocasematch
67
+ case "$tn" in *cron*|*schedul*|*trigger*|*later*|*wakeup*) sch=1 ;; esac
68
+ shopt -u nocasematch
69
+ [ "$sch" = 1 ] || exit 0
70
+ # Every JSON string of at most 120 bytes, still escaped, one per line (a JSON string holds no raw newline). An
71
+ # approval is a few words: a longer string is never one, and is not kept.
72
+ strs="$(printf '%s' "$INPUT" | LC_ALL=C awk '{ buf = buf $0 " " } END { n = length(buf); ins = 0; esc = 0; s = ""; big = 0
73
+ for (i = 1; i <= n; i++) { c = substr(buf, i, 1)
74
+ if (!ins) { if (c == "\"") { ins = 1; s = ""; big = 0 }; continue }
75
+ if (esc) { esc = 0; if (!big) s = s c; continue }
76
+ if (c == "\\") { esc = 1; if (!big) s = s c; continue }
77
+ if (c == "\"") { ins = 0; if (!big) print s; continue }
78
+ if (!big) { s = s c; if (length(s) > 120) big = 1 } } }')" || {
79
+ echo "GUARD (§4.4): the prompt this call schedules could not be read (awk did not run), so the call is refused." >&2; exit 2; }
80
+ while IFS= read -r sv; do
81
+ [ -n "$sv" ] || continue
82
+ _json_unescape "$sv" >/dev/null; _crew_appr_op "$_JU"
83
+ if [ -n "$OP" ]; then
84
+ echo "GUARD (§4.4): this call schedules a prompt that is an approval for a commit or a push ($OP). An approval is what the user types; a prompt a tool schedules would approve in their place, so the call is refused." >&2
85
+ echo "Schedule the work without it. For the approval itself, show the commit message and ask: the user can type /crew-approve $OP when they agree." >&2
86
+ exit 2
87
+ fi
88
+ done <<< "$strs"
89
+ exit 0
90
+ }
91
+ _gate_main "$@"
92
+ _crew_stop "a command of the gate was abandoned"