crewforth 3.1.1 → 3.2.0-rc.2
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.
- package/CHANGELOG.md +171 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +56 -5
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +14 -13
- package/kit/agents/crew-commit-agent.md +7 -2
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +117 -6
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +98 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +163 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/scan.mjs +214 -0
- package/kit/eval/lib/crew-map/stacks.mjs +441 -0
- package/kit/eval/lib/crew-map/surface.mjs +457 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +15 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/smoke-test.sh +576 -41
- package/kit/hooks/agent-outcome.sh +26 -12
- package/kit/hooks/guard-agent-model.sh +109 -9
- package/kit/hooks/guard-bash.sh +240 -182
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +21 -0
- package/kit/hooks/prompt-approval.sh +320 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +33 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +44 -0
- package/kit/skills/crew-review/SKILL.md +2 -0
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +12 -0
- package/kit/studio/server/index.js +125 -11
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +223 -15
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/chat.js +82 -17
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +341 -0
- package/kit/studio/web/map.js +503 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/style.css +131 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +10 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# guard-question.sh - the approval question reaches the person, not a hook (§4.4, 3.2.0).
|
|
3
|
+
#
|
|
4
|
+
# WHY. In `auto` the user can approve a commit or a push by answering one fixed question (AskUserQuestion), and
|
|
5
|
+
# hooks/prompt-approval.sh records the answer on PostToolUse. An answer is only worth recording if a person gave it.
|
|
6
|
+
# Two things can give it instead: another PreToolUse hook that answers `allow` with the answers filled in, and a
|
|
7
|
+
# PermissionRequest hook that answers the prompt before the person does (both measured on Claude Code 2.1.294).
|
|
8
|
+
#
|
|
9
|
+
# WHAT. Wired on PreToolUse for AskUserQuestion. Any question but the approval question gets no decision. The
|
|
10
|
+
# approval question must be the fixed text with no answers, asked in `auto` in an attended session; then this
|
|
11
|
+
# hook answers `ask`, which beats the first kind of hook in every mode, and only when no PermissionRequest hook
|
|
12
|
+
# may be wired (the second kind; _crew_prh_scan). It also notes the call it asked, and prompt-approval.sh records
|
|
13
|
+
# an answer to that call only. Everything it decides is defined in prompt-approval.sh (_crew_q_pre), sourced.
|
|
14
|
+
#
|
|
15
|
+
# A GATE, so an error of its own refuses the call (CREW-FAILCLOSED): a question let through with no `ask` is one a
|
|
16
|
+
# hook can answer.
|
|
17
|
+
# COST. Builtins on every AskUserQuestion; on the approval question the scan reads files and starts no process.
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
# ---- CREW-LOCALE -----------------------------------------------------------------------------------------
|
|
20
|
+
# Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
|
|
21
|
+
# the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
|
|
22
|
+
# match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
|
|
23
|
+
# GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
|
|
24
|
+
# bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
|
|
25
|
+
# turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
|
|
26
|
+
# Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
|
|
27
|
+
# staged key all passed.
|
|
28
|
+
# GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
|
|
29
|
+
# scan for private strings read that as "no match".
|
|
30
|
+
# macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
|
|
31
|
+
# The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
|
|
32
|
+
# words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
|
|
33
|
+
# Byte-identical in every gate; the suite pins it.
|
|
34
|
+
_CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
|
|
35
|
+
case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
|
|
36
|
+
export LC_ALL=C
|
|
37
|
+
# ---- /CREW-LOCALE
|
|
38
|
+
# ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
|
|
39
|
+
# A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
|
|
40
|
+
# on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
|
|
41
|
+
# one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
|
|
42
|
+
# it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
|
|
43
|
+
# `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
|
|
44
|
+
# * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
|
|
45
|
+
# * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
|
|
46
|
+
# _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
|
|
47
|
+
# Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
|
|
48
|
+
# that is not found (status 127, the script continues inside the same command).
|
|
49
|
+
# Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
|
|
50
|
+
_crew_stop(){ # $1 = what happened
|
|
51
|
+
declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
|
|
52
|
+
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
|
|
53
|
+
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
|
|
54
|
+
exit 2
|
|
55
|
+
}
|
|
56
|
+
trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
|
|
57
|
+
# ---- /CREW-FAILCLOSED
|
|
58
|
+
_gate_main(){
|
|
59
|
+
local here="${BASH_SOURCE%/*}"
|
|
60
|
+
[ "$here" = "${BASH_SOURCE}" ] && here=.
|
|
61
|
+
IFS= read -r -d '' INPUT || true
|
|
62
|
+
case "$INPUT" in *'Crewforth approval'*) ;; *) exit 0 ;; esac # not the approval question: nothing to decide
|
|
63
|
+
if [ ! -f "$here/prompt-approval.sh" ]; then
|
|
64
|
+
echo "GUARD (§4.4): prompt-approval.sh is not beside this gate, so the approval question cannot be checked and is not asked. Update Crewforth; the user can type /crew-approve instead." >&2
|
|
65
|
+
exit 2
|
|
66
|
+
fi
|
|
67
|
+
. "$here/prompt-approval.sh"
|
|
68
|
+
declare -F _crew_q_pre >/dev/null 2>&1 || { echo "GUARD (§4.4): prompt-approval.sh beside this gate is an older one, so the approval question is not asked. Update Crewforth; the user can type /crew-approve instead." >&2; exit 2; }
|
|
69
|
+
_crew_q_pre
|
|
70
|
+
exit 0
|
|
71
|
+
}
|
|
72
|
+
_gate_main "$@"
|
|
73
|
+
_crew_stop "a command of the gate was abandoned"
|
package/kit/hooks/guard-write.sh
CHANGED
|
@@ -388,6 +388,7 @@ WHY_SCRIPT="This file is a gate script — rewriting it would disarm the trace/s
|
|
|
388
388
|
WHY_DISC="This file is Crewforth's discipline document — it IS the text of §4.1-§4.5, so editing it empties the rules the gates enforce."
|
|
389
389
|
WHY_LINK="A parent directory of this path is a symlink and it resolves into a gate directory, so the write would land on a gate file."
|
|
390
390
|
WHY_MODEL="This file is what model routing decides from: the user's rules, a class's floor, the critical paths, or the record of a call and its result. Only the user and Crewforth's own hooks write it, so a session cannot lower its own floor or erase its own failure."
|
|
391
|
+
WHY_REFUTER="This file is the refuter ledger: what crew-refuter concluded about a finding, written by Crewforth's hook from the agent's own task and answer. A findings document is held to it, so a session that writes it would verify its own findings."
|
|
391
392
|
WHY_APPR="This file records the user's own approval for a commit or a push (§4.4). Only the user's message writes it, so a session cannot approve its own commit."
|
|
392
393
|
WHY_GITCFG="This file holds core.hooksPath: writing it can switch the git hooks off without touching one of them. Settings go through git config, which the Bash guard reads."
|
|
393
394
|
WHY_LONG="The path in this payload is longer than any filesystem accepts. It is refused rather than parsed, because parsing it is the slow path an attacker would aim at."
|
|
@@ -545,6 +546,10 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
|
|
|
545
546
|
# Matched by name wherever it is: a linked worktree keeps its git directory elsewhere.
|
|
546
547
|
*/[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll]|[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll])
|
|
547
548
|
GATE_RULE="approval-record edit (Write/Edit tools)"; GATE_WHY="$WHY_APPR"; return 0 ;;
|
|
549
|
+
# The refuter ledger (hooks/refuter-ledger.sh): what crew-refuter concluded, as the hook read it from the agent's
|
|
550
|
+
# own task and answer. findings.mjs --ledger trusts it; a session that writes it verifies its own findings.
|
|
551
|
+
*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Cc][Rr][Ee][Ww]-[Rr][Ee][Ff][Uu][Tt][Ee][Rr].[Tt][Ss][Vv]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Cc][Rr][Ee][Ww]-[Rr][Ee][Ff][Uu][Tt][Ee][Rr].[Tt][Ss][Vv])
|
|
552
|
+
GATE_RULE="gate-file edit (refuter ledger)"; GATE_WHY="$WHY_REFUTER"; return 0 ;;
|
|
548
553
|
# What model routing decides from (hooks/guard-agent-model.sh, hooks/agent-outcome.sh): the user's own rules,
|
|
549
554
|
# the floors raised from the record of outcomes, the list of critical paths, and the records of calls and
|
|
550
555
|
# results. A session that writes one lowers its own floor or wipes its own failure.
|
|
@@ -631,6 +636,22 @@ case "$INPUT" in *'"agent_id"'*)
|
|
|
631
636
|
esac
|
|
632
637
|
fi
|
|
633
638
|
fi ;;
|
|
639
|
+
*)
|
|
640
|
+
# The session's own write to a critical path (no agent_id: the main thread). Not refused (K7: the user may be
|
|
641
|
+
# doing exactly this, and an agent is a choice, not a rule); recorded, so it is seen: a NOTE line in the gate log,
|
|
642
|
+
# which Studio's Gates tab and the status line read. The same definition of "critical path" the agents are held
|
|
643
|
+
# to; a file outside the project is not the project's critical path.
|
|
644
|
+
if [ "${CREW_MODEL_ROUTING:-}" != off ] && [ -n "${FP:-}" ]; then
|
|
645
|
+
_gwm="${BASH_SOURCE%/*}"; [ "$_gwm" = "${BASH_SOURCE}" ] && _gwm=.
|
|
646
|
+
if [ -f "$_gwm/guard-agent-model.sh" ]; then
|
|
647
|
+
. "$_gwm/guard-agent-model.sh"
|
|
648
|
+
if declare -F _am_crit_path >/dev/null 2>&1 && _am_crit_path "$FP"; then
|
|
649
|
+
case "$_AMRP" in /*|[A-Za-z]:*) ;; *)
|
|
650
|
+
declare -F _am_log >/dev/null 2>&1 && _am_log "note: the session wrote a critical path itself, not through an agent: $_AMRP ($_AMW)" ;;
|
|
651
|
+
esac
|
|
652
|
+
fi
|
|
653
|
+
fi
|
|
654
|
+
fi ;;
|
|
634
655
|
esac
|
|
635
656
|
|
|
636
657
|
# ---- team board: you may not start work nobody knows you started -----------------------------------------------
|
|
@@ -31,6 +31,24 @@
|
|
|
31
31
|
#
|
|
32
32
|
# COST. This runs on every prompt, so the ordinary one is builtins only: no process is started unless the
|
|
33
33
|
# message is an approval (then git is asked what is staged). eval/smoke-test.sh pins both.
|
|
34
|
+
#
|
|
35
|
+
# THE SAME APPROVAL BY A CLICK (3.2.0). In `auto` the user can also answer ONE fixed question, asked with
|
|
36
|
+
# AskUserQuestion: "Commit only" / "Commit + push" / "Push only" / "No". Two hooks on that tool share what is below:
|
|
37
|
+
# PreToolUse guard-question.sh (_crew_q_pre): the question must be the fixed one (_CREW_Q_JSON), carry no
|
|
38
|
+
# answers, and be asked in `auto`; then it answers `ask`. A hook's `ask` beats another PreToolUse
|
|
39
|
+
# hook's `allow` that comes with filled-in answers, from every source and in every mode (measured:
|
|
40
|
+
# 34 runs on Claude Code 2.1.294, and in the terminal by hand), so the question reaches the user.
|
|
41
|
+
# PostToolUse this hook: the answer is read from the payload and recorded exactly as a typed approval would
|
|
42
|
+
# be, with the same proofs (an attended session, a transcript on disk, the session id) - and only
|
|
43
|
+
# when guard-question.sh asked THIS call (.claude/state/crew-approval-asked carries its tool_use_id).
|
|
44
|
+
# WHAT `ask` DOES NOT STOP: a `PermissionRequest` hook. It answers the permission prompt that `ask` raises, before
|
|
45
|
+
# the person does (measured, with `-p` and in the terminal), and it can do the same to the Bash prompt of the
|
|
46
|
+
# §4.4 gate in `default` / `acceptEdits`. So both halves, and guard-bash.sh before it asks, first look for one
|
|
47
|
+
# (_crew_prh_scan). Found, the click is closed and so is the gate's own prompt: the one approval left is the
|
|
48
|
+
# typed one, which no hook can type. HONEST SCOPE: the scan reads the sources a hook can read (user, project and
|
|
49
|
+
# local settings, the file-based managed settings, skill, agent and command front matter, installed plugins).
|
|
50
|
+
# It cannot read settings handed over with `--settings`, managed settings delivered by a server, or a plugin
|
|
51
|
+
# loaded with `--plugin-dir`: a PermissionRequest hook there is past it.
|
|
34
52
|
set -uo pipefail
|
|
35
53
|
# ---- CREW-LOCALE -----------------------------------------------------------------------------------------
|
|
36
54
|
# Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
|
|
@@ -56,7 +74,7 @@ export LC_ALL=C
|
|
|
56
74
|
_PA_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _PA_LIB=1
|
|
57
75
|
if [ "$_PA_LIB" = 0 ]; then
|
|
58
76
|
IFS= read -r -d '' INPUT || true
|
|
59
|
-
case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*) ;; *) exit 0 ;; esac
|
|
77
|
+
case "$INPUT" in *'"hook_event_name"'*UserPromptSubmit*|*'"hook_event_name"'*'"AskUserQuestion"'*|*'"AskUserQuestion"'*'"hook_event_name"'*) ;; *) exit 0 ;; esac
|
|
60
78
|
fi
|
|
61
79
|
# ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
|
|
62
80
|
_json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
|
|
@@ -403,8 +421,301 @@ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | pe
|
|
|
403
421
|
fi
|
|
404
422
|
}
|
|
405
423
|
|
|
424
|
+
# ---- THE APPROVAL QUESTION: one fixed text, asked by the model and answered by the user (3.2.0) ----------------
|
|
425
|
+
# What the user is asked is never the model's wording: PreToolUse refuses any other, so the person always reads the
|
|
426
|
+
# same four answers and what each one does. guard-bash.sh prints it from here for the model to ask.
|
|
427
|
+
_CREW_Q_TEXT='Crewforth approval: may Claude commit what is staged now, push this branch, or both?'
|
|
428
|
+
_CREW_Q_JSON='{"question":"'"$_CREW_Q_TEXT"'","header":"Approval","options":[{"label":"Commit only","description":"Commit exactly what is staged now. No push."},{"label":"Commit + push","description":"Commit what is staged now, then push that commit on this branch."},{"label":"Push only","description":"Push this branch as it is now. No new commit."},{"label":"No","description":"Neither commit nor push."}],"multiSelect":false}'
|
|
429
|
+
_crew_q_op(){ # $1 = the label the user chose -> OP: commit | commit+push | push | no, or "" for anything else
|
|
430
|
+
case "$1" in
|
|
431
|
+
'Commit only') OP=commit ;; 'Commit + push') OP=commit+push ;; 'Push only') OP=push ;; 'No') OP=no ;; *) OP="" ;;
|
|
432
|
+
esac
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
# ---- IS A PermissionRequest HOOK WIRED WHERE THIS CAN SEE? -------------------------------------------------------
|
|
436
|
+
# A PermissionRequest hook answers a permission prompt before the person does: the one `ask` raises for the approval
|
|
437
|
+
# question, and the one guard-bash.sh raises for a commit in default / acceptEdits (both measured on 2.1.294, with
|
|
438
|
+
# `-p` and in the terminal). Nothing in the answer tells it from a click. So the question is closed, and the gate's
|
|
439
|
+
# prompt with it, whenever such a hook may be wired; the typed /crew-approve stays, because no hook can type.
|
|
440
|
+
# Read here, as text (not parsed, so it errs towards "found"):
|
|
441
|
+
# settings user (CLAUDE_CONFIG_DIR or ~/.claude: settings.json, settings.local.json), project (.claude/
|
|
442
|
+
# settings.json, settings.local.json), the managed-settings.json files where guard-agent-model.sh
|
|
443
|
+
# reads them, and Crewforth's own plugin hooks.json. FOUND: the event's name in any letter case, or a
|
|
444
|
+
# `\u` escape in a file that has hooks (an escaped name is still the name).
|
|
445
|
+
# front matter SKILL.md, agents/*.md and commands/*.md of the project, the user and every installed plugin.
|
|
446
|
+
# Only the block between the first two `---` lines is read. FOUND: the event's name, or a backslash
|
|
447
|
+
# in a front matter that has hooks (YAML spells a letter as \x52 or \u0052 in double quotes).
|
|
448
|
+
# plugins every installed plugin (plugins/cache/<marketplace>/<plugin>/<version>): the .json files at its top,
|
|
449
|
+
# in hooks/ and in .claude-plugin/ (hooks.json, a plugin.json with inline hooks). Same rule as settings.
|
|
450
|
+
# NOT READ, and written as the limit in the docs: `--settings` given on the command line, managed settings a server
|
|
451
|
+
# delivers, a plugin loaded with --plugin-dir. A hook added while the session runs is read at the next question:
|
|
452
|
+
# the scan runs at every question and every commit, never cached.
|
|
453
|
+
# _PRH names the first file found, for the message. No process is started; this runs only when a question or a
|
|
454
|
+
# commit is decided, never on an ordinary prompt.
|
|
455
|
+
_crew_prh_text(){ # $1 = json | fm, $2 = text -> 0 when it may wire a PermissionRequest hook
|
|
456
|
+
local t="$2" nc=0 r=1
|
|
457
|
+
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
458
|
+
case "$t" in *permissionrequest*) r=0 ;; esac
|
|
459
|
+
if [ "$r" = 1 ]; then
|
|
460
|
+
case "$t" in *hooks*)
|
|
461
|
+
if [ "$1" = json ]; then
|
|
462
|
+
t="${t//\\\\/}" # an escaped backslash is not the start of an escape
|
|
463
|
+
case "$t" in *'\u'*|*'\U'*) r=0 ;; esac
|
|
464
|
+
else
|
|
465
|
+
case "$t" in *\\*) r=0 ;; esac
|
|
466
|
+
fi ;;
|
|
467
|
+
esac
|
|
468
|
+
fi
|
|
469
|
+
[ "$nc" = 0 ] && shopt -u nocasematch
|
|
470
|
+
return "$r"
|
|
471
|
+
}
|
|
472
|
+
_crew_prh_fm(){ # $1 = a markdown file -> _FM: its front matter, "" when it has none
|
|
473
|
+
local l n=0
|
|
474
|
+
_FM=""
|
|
475
|
+
{ IFS= read -r l || [ -n "$l" ]; l="${l%$'\r'}"; [ "$l" = --- ] || return 0
|
|
476
|
+
while IFS= read -r l || [ -n "$l" ]; do
|
|
477
|
+
l="${l%$'\r'}"; [ "$l" = --- ] && return 0
|
|
478
|
+
_FM="$_FM$l"$'\n'; n=$((n+1)); [ "$n" -le 400 ] || return 0
|
|
479
|
+
done; } < "$1"
|
|
480
|
+
}
|
|
481
|
+
_crew_prh_scan(){ # -> 0 when a PermissionRequest hook may be wired; _PRH names where
|
|
482
|
+
local p="${CLAUDE_PROJECT_DIR:-${CWD:-${_CWD:-.}}}" u="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}" f t list
|
|
483
|
+
local LC_ALL=C
|
|
484
|
+
p="${p//\\//}"; p="${p%/}"; u="${u//\\//}"; u="${u%/}"
|
|
485
|
+
_PRH=""
|
|
486
|
+
# The managed files are the ones guard-agent-model.sh reads (_am_settings_files), CREW_MANAGED_SETTINGS included:
|
|
487
|
+
# it can only add a file to look in, so it can only close the question. smoke-test.sh pins the two lists equal.
|
|
488
|
+
for f in "$u/settings.json" "$u/settings.local.json" "$p/.claude/settings.json" "$p/.claude/settings.local.json" \
|
|
489
|
+
"/Library/Application Support/ClaudeCode/managed-settings.json" "/etc/claude-code/managed-settings.json" \
|
|
490
|
+
"/c/Program Files/ClaudeCode/managed-settings.json" "/c/ProgramData/ClaudeCode/managed-settings.json" \
|
|
491
|
+
"${CREW_MANAGED_SETTINGS:-}" "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}"; do
|
|
492
|
+
[ -n "$f" ] && [ -f "$f" ] || continue
|
|
493
|
+
t=""; IFS= read -r -d '' t < "$f" || true
|
|
494
|
+
_crew_prh_text json "$t" && { _PRH="$f"; return 0; }
|
|
495
|
+
done
|
|
496
|
+
# Globs, not `find`: no process is started, and this runs before every commit prompt (on Git Bash a process is
|
|
497
|
+
# 62-135 ms). A plugin is cache/<marketplace>/<plugin>/<version>/; a command or an agent may sit one folder down.
|
|
498
|
+
local ng=0 r=1
|
|
499
|
+
shopt -q nullglob && ng=1; shopt -s nullglob
|
|
500
|
+
for f in "$u"/plugins/cache/*/*/*/*.json "$u"/plugins/cache/*/*/*/hooks/*.json "$u"/plugins/cache/*/*/*/.claude-plugin/*.json; do
|
|
501
|
+
t=""; IFS= read -r -d '' t < "$f" || true
|
|
502
|
+
_crew_prh_text json "$t" && { _PRH="$f"; r=0; break; }
|
|
503
|
+
done
|
|
504
|
+
if [ "$r" = 1 ]; then
|
|
505
|
+
for f in "$p"/.claude/skills/*/SKILL.md "$p"/.claude/agents/*.md "$p"/.claude/agents/*/*.md "$p"/.claude/commands/*.md "$p"/.claude/commands/*/*.md \
|
|
506
|
+
"$u"/skills/*/SKILL.md "$u"/agents/*.md "$u"/agents/*/*.md "$u"/commands/*.md "$u"/commands/*/*.md \
|
|
507
|
+
"$u"/plugins/cache/*/*/*/skills/*/SKILL.md "$u"/plugins/cache/*/*/*/agents/*.md "$u"/plugins/cache/*/*/*/agents/*/*.md \
|
|
508
|
+
"$u"/plugins/cache/*/*/*/commands/*.md "$u"/plugins/cache/*/*/*/commands/*/*.md; do
|
|
509
|
+
_crew_prh_fm "$f"
|
|
510
|
+
[ -n "$_FM" ] && _crew_prh_text fm "$_FM" && { _PRH="$f"; r=0; break; }
|
|
511
|
+
done
|
|
512
|
+
fi
|
|
513
|
+
[ "$ng" = 0 ] && shopt -u nullglob
|
|
514
|
+
return "$r"
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# ---- the approval question's context, and its PreToolUse half -----------------------------------------------
|
|
518
|
+
# Shared by prompt-approval.sh (PostToolUse: it records the answer) and guard-question.sh (PreToolUse: it asks). The
|
|
519
|
+
# PreToolUse half is a GATE and lives in a gate's file, so an error of its own refuses the call (CREW-FAILCLOSED)
|
|
520
|
+
# instead of letting the question through with no `ask`; this file also answers UserPromptSubmit, where a refusal
|
|
521
|
+
# would erase the user's message.
|
|
522
|
+
_crew_q_ctx(){ # $INPUT -> CWD REC PM SID _QTU _QAD ASKED _QNOW; exits 0 (no decision) when the call is not the approval question
|
|
523
|
+
_json_keycount "$INPUT" hook_event_name; [ "$_KC" = 1 ] || exit 0
|
|
524
|
+
_json_keycount "$INPUT" tool_name; [ "$_KC" = 1 ] || exit 0
|
|
525
|
+
_json_slice "$INPUT" tool_name >/dev/null; [ "$_JS" = AskUserQuestion ] || exit 0
|
|
526
|
+
case "$INPUT" in *'Crewforth approval'*) ;; *) exit 0 ;; esac
|
|
527
|
+
_json_slice "$INPUT" cwd >/dev/null; _json_unescape "$_JS" >/dev/null; CWD="$_JU"
|
|
528
|
+
[ -n "$CWD" ] || CWD="${CLAUDE_PROJECT_DIR:-.}"
|
|
529
|
+
CWD="${CWD//\\//}"
|
|
530
|
+
_crew_appr_path "$CWD"; REC="$_AP"
|
|
531
|
+
_json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
|
|
532
|
+
_json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
|
|
533
|
+
_json_slice "$INPUT" tool_use_id >/dev/null; _QTU="$_JS"
|
|
534
|
+
_QAD="${CLAUDE_PROJECT_DIR:-$CWD}"; _QAD="${_QAD//\\//}"; _QAD="${_QAD%/}/.claude/state"; ASKED="$_QAD/crew-approval-asked"
|
|
535
|
+
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _QNOW '%(%s)T' -1; else _QNOW="$(date +%s)"; fi
|
|
536
|
+
}
|
|
537
|
+
_crew_q_pre(){ # the PreToolUse decision: `ask`, or a refusal (exit 2) that says why
|
|
538
|
+
_crew_q_ctx
|
|
539
|
+
qno(){ # $1 = why, for the model. The call does not run; where it applies, the typed approval is named as the way left.
|
|
540
|
+
echo "GUARD (§4.4): the approval question was not asked: $1" >&2
|
|
541
|
+
[ "${2:-}" = nohint ] || echo "The way that always works is the user's own: the user can type /crew-approve (or /crew-approve commit, push, commit+push) as their whole message." >&2
|
|
542
|
+
exit 2
|
|
543
|
+
}
|
|
544
|
+
# The answers are the person's to give. The model's own are ignored by Claude Code (measured), and a call that
|
|
545
|
+
# carries some is not the plain question this hook asks.
|
|
546
|
+
_json_keycount "$INPUT" answers; [ "$_KC" = 0 ] || qno "the call carries answers; ask it with none."
|
|
547
|
+
_json_keycount "$INPUT" questions; [ "$_KC" = 1 ] || qno "the call carries more than one list of questions."
|
|
548
|
+
case "$INPUT" in *'"questions":['"$_CREW_Q_JSON"']'*) ;;
|
|
549
|
+
*) qno "it is not the fixed question. Ask exactly this AskUserQuestion input, nothing added or changed: {\"questions\":[$_CREW_Q_JSON]}" ;;
|
|
550
|
+
esac
|
|
551
|
+
case "$PM" in
|
|
552
|
+
auto) ;;
|
|
553
|
+
dontAsk) qno "in dontAsk Claude Code refuses a question that needs a person, so it would never reach the user." ;;
|
|
554
|
+
default|acceptEdits) _crew_prh_scan || qno "in $PM the commit or push itself asks the user: run the git command and the gate's own prompt appears." nohint
|
|
555
|
+
qno "in $PM, with a PermissionRequest hook that may be wired ($_PRH), only the typed approval counts." ;;
|
|
556
|
+
*) qno "in ${PM:-an unknown mode} this approval is not open (bypassPermissions and plan fail closed)." ;;
|
|
557
|
+
esac
|
|
558
|
+
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) qno "this session has nobody in front of it (it was started with -p, or by a program)." ;; esac
|
|
559
|
+
case "$SID$_QTU" in *[!A-Za-z0-9._-]*) qno "the call carries no session or tool id this hook can bind the answer to." ;; esac
|
|
560
|
+
{ [ -n "$SID" ] && [ -n "$_QTU" ]; } || qno "the call carries no session or tool id this hook can bind the answer to."
|
|
561
|
+
_crew_prh_scan && qno "a PermissionRequest hook may be wired ($_PRH). Such a hook can answer the question before the user does, and the answer cannot be told from a click."
|
|
562
|
+
mkdir -p "$_QAD" 2>/dev/null && { printf 'sid=%s\ntool=%s\nts=%s\n' "$SID" "$_QTU" "$_QNOW" > "$ASKED"; } 2>/dev/null \
|
|
563
|
+
|| qno "the state folder could not be written, so the answer could not be bound to this question."
|
|
564
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' \
|
|
565
|
+
"Crewforth §4.4: only the person in front of this session answers the approval question."
|
|
566
|
+
exit 0
|
|
567
|
+
}
|
|
568
|
+
|
|
406
569
|
[ "$_PA_LIB" = 1 ] && return 0
|
|
407
570
|
|
|
571
|
+
# Whatever records an approval says why when it does not - to the user and to the model - instead of leaving a
|
|
572
|
+
# commit to fail later with no explanation. EVN is the event this hook answers, SRC what the user gave.
|
|
573
|
+
EVN=UserPromptSubmit; SRC="message"; SRC_BY="in their own message"
|
|
574
|
+
say(){ # $1 = for the user, $2 = for the model. Both are built from fixed text and values checked below.
|
|
575
|
+
printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}\n' "$1" "$EVN" "$2"
|
|
576
|
+
exit 0
|
|
577
|
+
}
|
|
578
|
+
no(){ say "Crewforth: approval NOT recorded - $1" "The user's $SRC is an approval for $OP, but Crewforth could not record it: $1 Tell the user exactly that; the commit/push gate stays closed until they approve again."; }
|
|
579
|
+
|
|
580
|
+
# ---- THE RECORD: what was approved, measured from git now, never taken from the message or the answer ---------
|
|
581
|
+
# Every route ends here (the typed approval, the bare /crew-approve, the approval question); each one has already
|
|
582
|
+
# checked the permission mode it records in, and sets OP, CWD, REC and SRC_BY.
|
|
583
|
+
_pa_record(){
|
|
584
|
+
[ -n "$REC" ] || no "this directory is not inside a git repository."
|
|
585
|
+
|
|
586
|
+
# A PERSON, IN A SESSION THAT HAS SHOWN THEM SOMETHING. A session can start another one and hand it the approval as
|
|
587
|
+
# its prompt: `claude -p "/crew-approve commit"`, or an interactive one driven through a pseudo-terminal. That
|
|
588
|
+
# session's only message is the approval, this hook would record it, and the session would then commit: the first
|
|
589
|
+
# session has approved for the user. Measured on Claude Code 2.1.294 (macOS), from a hook that printed what it saw:
|
|
590
|
+
# started how CLAUDE_CODE_SESSION_ATTENDED the transcript file when this hook runs
|
|
591
|
+
# claude -p, from a session's Bash 0 (set to 1 by the caller: still 0) not there (first message)
|
|
592
|
+
# claude -p, from an emptied environment 0 not there (first message)
|
|
593
|
+
# interactive, in a pty, from a session 1 not there, and never written: a
|
|
594
|
+
# session started inside another keeps none
|
|
595
|
+
# interactive, in a pty, emptied environment 1 not there at the first message; there after
|
|
596
|
+
# the desktop app, a conversation under way 1 there
|
|
597
|
+
# Nothing else told them apart: the payload has the same fields, CLAUDECODE and CLAUDE_CODE_CHILD_SESSION are 1 in
|
|
598
|
+
# all of them, CLAUDE_CODE_ENTRYPOINT is inherited from the outer session, and no hook has a terminal on stdin.
|
|
599
|
+
# So two things are asked. Neither is a documented contract, and both fail towards NOT recording:
|
|
600
|
+
# 1. the session is not a headless one;
|
|
601
|
+
# 2. the session's transcript is already on disk when the approval arrives, so the approval is not the session's
|
|
602
|
+
# first message and the session is not one that keeps no transcript.
|
|
603
|
+
# HONEST SCOPE: a session started with an emptied environment and driven through a pseudo-terminal for a second
|
|
604
|
+
# message passes both. guard-bash.sh refuses the forms of that it can read; a program that types is past it.
|
|
605
|
+
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in
|
|
606
|
+
0) no "this session has nobody in front of it (it was started with -p, or by a program), and an approval is what a person types into their own session." ;;
|
|
607
|
+
esac
|
|
608
|
+
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
|
|
609
|
+
{ [ -n "$TP" ] && [ -s "$TP" ]; } || no "this is the first message of this session, or the session keeps no transcript (one started from inside another session keeps none). An approval answers a commit message the session has shown: have it shown, then approve."
|
|
610
|
+
|
|
611
|
+
# The session the user is writing in. guard-bash.sh accepts the record only from that one: a session started by a
|
|
612
|
+
# command can be handed any prompt.
|
|
613
|
+
_json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
|
|
614
|
+
case "$SID" in ''|*[!A-Za-z0-9._-]*) no "this prompt carries no session id to bind the approval to." ;; esac
|
|
615
|
+
|
|
616
|
+
HEAD_NOW="$(git -C "$CWD" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
|
|
617
|
+
case "$HEAD_NOW" in NONE) ;; *[!0-9a-f]*|'') no "git could not name HEAD here." ;; esac
|
|
618
|
+
TREE=""; BR=""; RM=""; URL=""
|
|
619
|
+
case "$OP" in commit*)
|
|
620
|
+
# THE TREE THE INDEX WRITES, not the text of the staged diff: what `git diff` prints is configurable
|
|
621
|
+
# (`diff.external`, a textconv filter), and with it two different staged changes can print the same text.
|
|
622
|
+
TREE="$(git -C "$CWD" write-tree 2>/dev/null)"
|
|
623
|
+
case "$TREE" in *[!0-9a-f]*|'') no "git could not write the tree of what is staged (an unresolved merge?)." ;; esac
|
|
624
|
+
if [ "$HEAD_NOW" = NONE ]; then _ht=4b825dc642cb6eb9a060e54bf8d69288fbee4904 # the empty tree
|
|
625
|
+
else _ht="$(git -C "$CWD" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"; fi
|
|
626
|
+
[ "$TREE" != "$_ht" ] || no "nothing is staged, so there is nothing to approve. Stage the change with git add, show the commit message, then ask again." ;;
|
|
627
|
+
esac
|
|
628
|
+
case "$OP" in *push)
|
|
629
|
+
[ "$OP" = push ] && [ "$HEAD_NOW" = NONE ] && no "there is no commit to push yet."
|
|
630
|
+
BR="$(git -C "$CWD" symbolic-ref --short -q HEAD 2>/dev/null || true)"
|
|
631
|
+
[ -n "$BR" ] || no "HEAD is detached, so there is no branch to bind the push to."
|
|
632
|
+
RM="$(git -C "$CWD" config --get "branch.$BR.pushRemote" 2>/dev/null)" \
|
|
633
|
+
|| RM="$(git -C "$CWD" config --get remote.pushDefault 2>/dev/null)" \
|
|
634
|
+
|| RM="$(git -C "$CWD" config --get "branch.$BR.remote" 2>/dev/null)" || RM=""
|
|
635
|
+
case "$RM" in ''|.)
|
|
636
|
+
RM="$(git -C "$CWD" remote 2>/dev/null)"
|
|
637
|
+
case "$RM" in ''|*$'\n'*) no "this branch has no upstream and the repository does not have exactly one remote, so the push target is not known. Set the upstream, or approve the commit alone." ;; esac ;;
|
|
638
|
+
esac
|
|
639
|
+
# Both values go into JSON and into a line-per-field record, so anything outside this set is refused, not escaped.
|
|
640
|
+
case "$BR$RM" in *[!A-Za-z0-9._/@+-]*) no "the branch or remote name has a character this record does not carry." ;; esac
|
|
641
|
+
# Where that remote pushes TO, with pushurl and pushInsteadOf applied. The name alone can be pointed elsewhere
|
|
642
|
+
# after the approval (`git config remote.origin.pushurl …`), so the address is part of what was approved.
|
|
643
|
+
URL="$(git -C "$CWD" remote get-url --push "$RM" 2>/dev/null)"
|
|
644
|
+
case "$URL" in ''|*$'\n'*) no "the remote $RM does not push to exactly one address." ;; esac ;;
|
|
645
|
+
esac
|
|
646
|
+
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then
|
|
647
|
+
printf -v TS '%(%s)T' -1
|
|
648
|
+
else TS="$(date +%s)"; fi
|
|
649
|
+
case "$TS" in ''|*[!0-9]*) no "the clock could not be read." ;; esac
|
|
650
|
+
|
|
651
|
+
{ printf 'v=1\nop=%s\ntree=%s\nhead=%s\nbranch=%s\nremote=%s\nurl=%s\nsid=%s\nts=%s\n' "$OP" "$TREE" "$HEAD_NOW" "$BR" "$RM" "$URL" "$SID" "$TS" > "$REC"; } 2>/dev/null \
|
|
652
|
+
|| no "the record could not be written in the git directory."
|
|
653
|
+
# What was waiting has been approved: it is not approved a second time. Only here, after every proof and the record
|
|
654
|
+
# itself: a bare command that recorded nothing (a headless session, a first message) leaves it for the user.
|
|
655
|
+
[ "$BARE" = 1 ] && { : > "$_bp"; } 2>/dev/null
|
|
656
|
+
|
|
657
|
+
_h="${HEAD_NOW:0:7}"; _d="${TREE:0:7}"
|
|
658
|
+
# THE ADDRESS IS SHOWN TO THE USER, not only compared: a rewrite set BEFORE the approval (`url.<x>.insteadOf`) is
|
|
659
|
+
# already in what git reports, so the record and the push agree and only the person can tell that "origin" is not
|
|
660
|
+
# where they think (review). Shown to the user alone, with any `user:secret@` taken out, and never put in front of
|
|
661
|
+
# the model.
|
|
662
|
+
_u="$URL"
|
|
663
|
+
case "$_u" in *://*@*) _ua="${_u#*://}"; case "${_ua%%/*}" in *@*) _u="${_u%%://*}://${_ua#*@}" ;; esac ;; esac
|
|
664
|
+
case "$_u" in *[\"\\]*|*[![:print:]]*) _u="an address this line cannot print; check it with: git remote get-url --push $RM" ;; esac
|
|
665
|
+
case "$OP" in
|
|
666
|
+
commit) WHAT="commit of what is staged now (tree $_d) on HEAD $_h"
|
|
667
|
+
HOW="Run git commit -m with the message, alone in its call: no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained. Single-quote the message, or read it from a here-document with a quoted delimiter. The review record of §4.6 is still required." ;;
|
|
668
|
+
push) WHAT="push of $BR at $_h to $RM"
|
|
669
|
+
HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained)." ;;
|
|
670
|
+
commit+push) WHAT="commit of what is staged now (tree $_d) on HEAD $_h, then push of that commit on $BR to $RM"
|
|
671
|
+
HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR" ;;
|
|
672
|
+
esac
|
|
673
|
+
[ -n "$_u" ] && _u=" ($RM pushes to $_u)"
|
|
674
|
+
_bw=""; [ "$BARE" = 1 ] && _bw="approved: $OP (what this session was refused). "
|
|
675
|
+
say "Crewforth: ${_bw}approval recorded - $WHAT$_u. Valid for 30 minutes; your next message ends it." \
|
|
676
|
+
"The user approved this $SRC_BY and Crewforth recorded it: $WHAT. $HOW The approval covers what was staged and where HEAD was at that moment, in this session: if either changes it no longer applies and the user approves again. It ends in 30 minutes or at the user's next message. Force-push and the rest of §4.5 are not opened by it."
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
# ---- THE APPROVAL QUESTION (PreToolUse and PostToolUse of AskUserQuestion) -----------------------------------
|
|
680
|
+
# Only the fixed question is this hook's business: any other question gets no decision from it, here or later.
|
|
681
|
+
_pa_question(){
|
|
682
|
+
local _as="" _at="" _au="" _al _an
|
|
683
|
+
_crew_q_ctx
|
|
684
|
+
# PostToolUse: the user has answered. Recorded only if THIS call was asked by the half above.
|
|
685
|
+
SRC="answer to the approval question"; SRC_BY="by answering the approval question"; OP="a commit or a push"
|
|
686
|
+
[ -n "$SID" ] && [ -n "$_QTU" ] || no "the answer carries no session or tool id."
|
|
687
|
+
if [ -f "$ASKED" ]; then
|
|
688
|
+
while IFS= read -r _al || [ -n "$_al" ]; do
|
|
689
|
+
case "$_al" in sid=*) _as="${_al#sid=}" ;; tool=*) _au="${_al#tool=}" ;; ts=*) _at="${_al#ts=}" ;; esac
|
|
690
|
+
done < "$ASKED"
|
|
691
|
+
fi
|
|
692
|
+
{ [ "$_as" = "$SID" ] && [ "$_au" = "$_QTU" ]; } || no "Crewforth did not ask this question (guard-question.sh, its PreToolUse half, is not wired or did not pass it), so the answer cannot be shown to have come from the user."
|
|
693
|
+
: > "$ASKED" 2>/dev/null # one question, one answer
|
|
694
|
+
case "$_at$_QNOW" in *[!0-9]*|'') no "the time of the question could not be read." ;; esac
|
|
695
|
+
[ $((_QNOW - _at)) -ge 0 ] && [ $((_QNOW - _at)) -le 1800 ] || no "the question was asked more than 30 minutes ago."
|
|
696
|
+
case "$INPUT" in *'"questions":['"$_CREW_Q_JSON"']'*) ;; *) no "the question answered is not the fixed one." ;; esac
|
|
697
|
+
[ "$PM" = auto ] || no "the approval question is recorded in auto only, and this session is in ${PM:-an unknown mode}."
|
|
698
|
+
_crew_prh_scan && no "a PermissionRequest hook may be wired ($_PRH), and it can answer the question before you do. The approval that still works is typed: the user can type /crew-approve instead."
|
|
699
|
+
_json_keycount "$INPUT" "$_CREW_Q_TEXT"; _an=$_KC
|
|
700
|
+
case "$_an" in 1|2) ;; *) no "the answer could not be read." ;; esac
|
|
701
|
+
_json_slice "$INPUT" "$_CREW_Q_TEXT" >/dev/null
|
|
702
|
+
_crew_q_op "$_JS"
|
|
703
|
+
case "$OP" in
|
|
704
|
+
'') OP="a commit or a push"; no "the answer was none of the four the question offers." ;;
|
|
705
|
+
no) [ -n "$REC" ] && { : > "$REC"; } 2>/dev/null
|
|
706
|
+
say "Crewforth: not approved - you answered No." "The user answered No to the approval question. Do not commit or push; ask what they want changed." ;;
|
|
707
|
+
esac
|
|
708
|
+
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) no "this session has nobody in front of it (it was started with -p, or by a program)." ;; esac
|
|
709
|
+
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
|
|
710
|
+
{ [ -n "$TP" ] && [ -s "$TP" ]; } || no "the session keeps no transcript on disk (one started from inside another session keeps none)."
|
|
711
|
+
BARE=0
|
|
712
|
+
_pa_record
|
|
713
|
+
}
|
|
714
|
+
case "$INPUT" in *'"AskUserQuestion"'*)
|
|
715
|
+
_json_slice "$INPUT" hook_event_name >/dev/null
|
|
716
|
+
case "$_JS" in PostToolUse) EVN="$_JS"; _pa_question ;; esac ;;
|
|
717
|
+
esac
|
|
718
|
+
|
|
408
719
|
# A second "prompt" key cannot come from the text of a prompt (inside a JSON string every quote is escaped), so
|
|
409
720
|
# more than one means the payload is not the shape this hook knows, and nothing is read from it.
|
|
410
721
|
_json_keycount "$INPUT" prompt; _n_prompt=$_KC
|
|
@@ -443,12 +754,7 @@ _crew_appr_op "$_JU"
|
|
|
443
754
|
[ -n "$OP" ] || exit 0
|
|
444
755
|
|
|
445
756
|
# From here on the message IS an approval, so every way of not recording it says why - to the user and to the
|
|
446
|
-
# model - instead of leaving a commit to fail later with no explanation.
|
|
447
|
-
say(){ # $1 = for the user, $2 = for the model. Both are built from fixed text and values checked below.
|
|
448
|
-
printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}\n' "$1" "$2"
|
|
449
|
-
exit 0
|
|
450
|
-
}
|
|
451
|
-
no(){ say "Crewforth: approval NOT recorded - $1" "The user's message is an approval for $OP, but Crewforth could not record it: $1 Tell the user exactly that; the commit/push gate stays closed until they approve again."; }
|
|
757
|
+
# model - instead of leaving a commit to fail later with no explanation (say / no, above).
|
|
452
758
|
|
|
453
759
|
# ---- /crew-loosen: a floor model routing raised is lowered by the user, and by nobody else ----------------------
|
|
454
760
|
# hooks/agent-outcome.sh raises a class's floor when its first try fails too often, and nothing lowers it again but
|
|
@@ -498,7 +804,7 @@ BARE=0
|
|
|
498
804
|
if [ "$OP" = pending ]; then
|
|
499
805
|
BARE=1
|
|
500
806
|
_json_slice "$INPUT" permission_mode >/dev/null
|
|
501
|
-
case "$_JS" in auto|dontAsk) ;; *) exit 0 ;; esac
|
|
807
|
+
case "$_JS" in auto|dontAsk) ;; default|acceptEdits) _crew_prh_scan || exit 0 ;; *) exit 0 ;; esac # elsewhere the gate asks the user itself, unless a PermissionRequest hook may answer it
|
|
502
808
|
_json_slice "$INPUT" session_id >/dev/null; _bsid="$_JS"
|
|
503
809
|
_bp="${CLAUDE_PROJECT_DIR:-$CWD}"; _bp="${_bp//\\//}"; _bp="${_bp%/}/.claude/state/crew-approval-pending"
|
|
504
810
|
_bs=""; _bo=""; _bt=""
|
|
@@ -519,100 +825,11 @@ fi
|
|
|
519
825
|
_json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
|
|
520
826
|
case "$PM" in
|
|
521
827
|
auto|dontAsk) ;;
|
|
522
|
-
# Where a prompt reaches the user the gate asks them itself
|
|
523
|
-
#
|
|
828
|
+
# Where a prompt reaches the user the gate asks them itself, and nothing is recorded or said here. Except where a
|
|
829
|
+
# PermissionRequest hook may be wired (3.2.0): it can answer that prompt before the person does, so guard-bash.sh
|
|
830
|
+
# does not raise it and the typed approval is the one way left, in these modes too. In plan and bypassPermissions
|
|
831
|
+
# this route is not open (a decision, 3.1.0): guard-bash.sh explains when it refuses.
|
|
832
|
+
default|acceptEdits) _crew_prh_scan || exit 0 ;;
|
|
524
833
|
*) exit 0 ;;
|
|
525
834
|
esac
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
# A PERSON, IN A SESSION THAT HAS SHOWN THEM SOMETHING. A session can start another one and hand it the approval as
|
|
529
|
-
# its prompt: `claude -p "/crew-approve commit"`, or an interactive one driven through a pseudo-terminal. That
|
|
530
|
-
# session's only message is the approval, this hook would record it, and the session would then commit: the first
|
|
531
|
-
# session has approved for the user. Measured on Claude Code 2.1.294 (macOS), from a hook that printed what it saw:
|
|
532
|
-
# started how CLAUDE_CODE_SESSION_ATTENDED the transcript file when this hook runs
|
|
533
|
-
# claude -p, from a session's Bash 0 (set to 1 by the caller: still 0) not there (first message)
|
|
534
|
-
# claude -p, from an emptied environment 0 not there (first message)
|
|
535
|
-
# interactive, in a pty, from a session 1 not there, and never written: a
|
|
536
|
-
# session started inside another keeps none
|
|
537
|
-
# interactive, in a pty, emptied environment 1 not there at the first message; there after
|
|
538
|
-
# the desktop app, a conversation under way 1 there
|
|
539
|
-
# Nothing else told them apart: the payload has the same fields, CLAUDECODE and CLAUDE_CODE_CHILD_SESSION are 1 in
|
|
540
|
-
# all of them, CLAUDE_CODE_ENTRYPOINT is inherited from the outer session, and no hook has a terminal on stdin.
|
|
541
|
-
# So two things are asked. Neither is a documented contract, and both fail towards NOT recording:
|
|
542
|
-
# 1. the session is not a headless one;
|
|
543
|
-
# 2. the session's transcript is already on disk when the approval arrives, so the approval is not the session's
|
|
544
|
-
# first message and the session is not one that keeps no transcript.
|
|
545
|
-
# HONEST SCOPE: a session started with an emptied environment and driven through a pseudo-terminal for a second
|
|
546
|
-
# message passes both. guard-bash.sh refuses the forms of that it can read; a program that types is past it.
|
|
547
|
-
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in
|
|
548
|
-
0) no "this session has nobody in front of it (it was started with -p, or by a program), and an approval is what a person types into their own session." ;;
|
|
549
|
-
esac
|
|
550
|
-
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
|
|
551
|
-
{ [ -n "$TP" ] && [ -s "$TP" ]; } || no "this is the first message of this session, or the session keeps no transcript (one started from inside another session keeps none). An approval answers a commit message the session has shown: have it shown, then approve."
|
|
552
|
-
|
|
553
|
-
# The session the user is writing in. guard-bash.sh accepts the record only from that one: a session started by a
|
|
554
|
-
# command can be handed any prompt.
|
|
555
|
-
_json_slice "$INPUT" session_id >/dev/null; SID="$_JS"
|
|
556
|
-
case "$SID" in ''|*[!A-Za-z0-9._-]*) no "this prompt carries no session id to bind the approval to." ;; esac
|
|
557
|
-
|
|
558
|
-
HEAD_NOW="$(git -C "$CWD" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
|
|
559
|
-
case "$HEAD_NOW" in NONE) ;; *[!0-9a-f]*|'') no "git could not name HEAD here." ;; esac
|
|
560
|
-
TREE=""; BR=""; RM=""; URL=""
|
|
561
|
-
case "$OP" in commit*)
|
|
562
|
-
# THE TREE THE INDEX WRITES, not the text of the staged diff: what `git diff` prints is configurable
|
|
563
|
-
# (`diff.external`, a textconv filter), and with it two different staged changes can print the same text.
|
|
564
|
-
TREE="$(git -C "$CWD" write-tree 2>/dev/null)"
|
|
565
|
-
case "$TREE" in *[!0-9a-f]*|'') no "git could not write the tree of what is staged (an unresolved merge?)." ;; esac
|
|
566
|
-
if [ "$HEAD_NOW" = NONE ]; then _ht=4b825dc642cb6eb9a060e54bf8d69288fbee4904 # the empty tree
|
|
567
|
-
else _ht="$(git -C "$CWD" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"; fi
|
|
568
|
-
[ "$TREE" != "$_ht" ] || no "nothing is staged, so there is nothing to approve. Stage the change with git add, show the commit message, then ask again." ;;
|
|
569
|
-
esac
|
|
570
|
-
case "$OP" in *push)
|
|
571
|
-
[ "$OP" = push ] && [ "$HEAD_NOW" = NONE ] && no "there is no commit to push yet."
|
|
572
|
-
BR="$(git -C "$CWD" symbolic-ref --short -q HEAD 2>/dev/null || true)"
|
|
573
|
-
[ -n "$BR" ] || no "HEAD is detached, so there is no branch to bind the push to."
|
|
574
|
-
RM="$(git -C "$CWD" config --get "branch.$BR.pushRemote" 2>/dev/null)" \
|
|
575
|
-
|| RM="$(git -C "$CWD" config --get remote.pushDefault 2>/dev/null)" \
|
|
576
|
-
|| RM="$(git -C "$CWD" config --get "branch.$BR.remote" 2>/dev/null)" || RM=""
|
|
577
|
-
case "$RM" in ''|.)
|
|
578
|
-
RM="$(git -C "$CWD" remote 2>/dev/null)"
|
|
579
|
-
case "$RM" in ''|*$'\n'*) no "this branch has no upstream and the repository does not have exactly one remote, so the push target is not known. Set the upstream, or approve the commit alone." ;; esac ;;
|
|
580
|
-
esac
|
|
581
|
-
# Both values go into JSON and into a line-per-field record, so anything outside this set is refused, not escaped.
|
|
582
|
-
case "$BR$RM" in *[!A-Za-z0-9._/@+-]*) no "the branch or remote name has a character this record does not carry." ;; esac
|
|
583
|
-
# Where that remote pushes TO, with pushurl and pushInsteadOf applied. The name alone can be pointed elsewhere
|
|
584
|
-
# after the approval (`git config remote.origin.pushurl …`), so the address is part of what was approved.
|
|
585
|
-
URL="$(git -C "$CWD" remote get-url --push "$RM" 2>/dev/null)"
|
|
586
|
-
case "$URL" in ''|*$'\n'*) no "the remote $RM does not push to exactly one address." ;; esac ;;
|
|
587
|
-
esac
|
|
588
|
-
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then
|
|
589
|
-
printf -v TS '%(%s)T' -1
|
|
590
|
-
else TS="$(date +%s)"; fi
|
|
591
|
-
case "$TS" in ''|*[!0-9]*) no "the clock could not be read." ;; esac
|
|
592
|
-
|
|
593
|
-
{ printf 'v=1\nop=%s\ntree=%s\nhead=%s\nbranch=%s\nremote=%s\nurl=%s\nsid=%s\nts=%s\n' "$OP" "$TREE" "$HEAD_NOW" "$BR" "$RM" "$URL" "$SID" "$TS" > "$REC"; } 2>/dev/null \
|
|
594
|
-
|| no "the record could not be written in the git directory."
|
|
595
|
-
# What was waiting has been approved: it is not approved a second time. Only here, after every proof and the record
|
|
596
|
-
# itself: a bare command that recorded nothing (a headless session, a first message) leaves it for the user.
|
|
597
|
-
[ "$BARE" = 1 ] && { : > "$_bp"; } 2>/dev/null
|
|
598
|
-
|
|
599
|
-
_h="${HEAD_NOW:0:7}"; _d="${TREE:0:7}"
|
|
600
|
-
# THE ADDRESS IS SHOWN TO THE USER, not only compared: a rewrite set BEFORE the approval (`url.<x>.insteadOf`) is
|
|
601
|
-
# already in what git reports, so the record and the push agree and only the person can tell that "origin" is not
|
|
602
|
-
# where they think (review). Shown to the user alone, with any `user:secret@` taken out, and never put in front of
|
|
603
|
-
# the model.
|
|
604
|
-
_u="$URL"
|
|
605
|
-
case "$_u" in *://*@*) _ua="${_u#*://}"; case "${_ua%%/*}" in *@*) _u="${_u%%://*}://${_ua#*@}" ;; esac ;; esac
|
|
606
|
-
case "$_u" in *[\"\\]*|*[![:print:]]*) _u="an address this line cannot print; check it with: git remote get-url --push $RM" ;; esac
|
|
607
|
-
case "$OP" in
|
|
608
|
-
commit) WHAT="commit of what is staged now (tree $_d) on HEAD $_h"
|
|
609
|
-
HOW="Run git commit -m with the message, alone in its call: no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained. Single-quote the message, or read it from a here-document with a quoted delimiter. The review record of §4.6 is still required." ;;
|
|
610
|
-
push) WHAT="push of $BR at $_h to $RM"
|
|
611
|
-
HOW="Run exactly: git push $RM $BR (alone in its call: the remote and the branch written out, no cd, no pipe, nothing chained)." ;;
|
|
612
|
-
commit+push) WHAT="commit of what is staged now (tree $_d) on HEAD $_h, then push of that commit on $BR to $RM"
|
|
613
|
-
HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR" ;;
|
|
614
|
-
esac
|
|
615
|
-
[ -n "$_u" ] && _u=" ($RM pushes to $_u)"
|
|
616
|
-
_bw=""; [ "$BARE" = 1 ] && _bw="approved: $OP (what this session was refused). "
|
|
617
|
-
say "Crewforth: ${_bw}approval recorded - $WHAT$_u. Valid for 30 minutes; your next message ends it." \
|
|
618
|
-
"The user approved this in their own message and Crewforth recorded it: $WHAT. $HOW The approval covers what was staged and where HEAD was at that moment, in this session: if either changes it no longer applies and the user approves again. It ends in 30 minutes or at the user's next message. Force-push and the rest of §4.5 are not opened by it."
|
|
835
|
+
_pa_record
|