@ccoalm/ccl-skills 0.18.0 → 0.18.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.
Files changed (36) hide show
  1. package/README.md +2 -0
  2. package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-policy.md +50 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-start.md +30 -40
  4. package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-delegation-owner.sh +9 -122
  5. package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-edit-isolation.sh +43 -9
  6. package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-merge-authorization.sh +95 -2
  7. package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +32 -1
  8. package/dist/assets/marketplace/plugins/ccl-skills/hooks/host-input.py +544 -0
  9. package/dist/assets/marketplace/plugins/ccl-skills/hooks/merge-authorization-prompt.sh +103 -26
  10. package/dist/assets/marketplace/plugins/ccl-skills/hooks/owner-dispatch-guard.sh +17 -8
  11. package/dist/assets/marketplace/plugins/ccl-skills/hooks/proposed-next-stop.sh +14 -0
  12. package/dist/assets/marketplace/plugins/ccl-skills/hooks/session-start.sh +31 -2
  13. package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-context-compact.sh +10 -0
  14. package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-extraction-gate-stop.sh +13 -4
  15. package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-loading.py +277 -0
  16. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_delegation_owner.sh +61 -55
  17. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_merge_authorization.sh +140 -0
  18. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_host_input.py +597 -0
  19. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_merge_authorization_prompt.sh +56 -3
  20. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_proposed_next.py +260 -0
  21. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_session_start.sh +85 -3
  22. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_skill_loading.py +278 -0
  23. package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +28 -7
  24. package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/README.md +23 -15
  25. package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/owner-dispatch.sh +41 -2
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/pre-final-continuation-gate.md +1 -1
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +4 -0
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +5 -0
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/SKILL.md +1 -1
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/hook-authorization.md +13 -0
  31. package/dist/assets/release.json +80 -25
  32. package/dist/codex-hooks.d.ts +15 -0
  33. package/dist/codex-hooks.js +186 -0
  34. package/dist/opencode-adapter.js +8 -3
  35. package/dist/operations.js +13 -9
  36. package/package.json +1 -1
@@ -3,13 +3,13 @@
3
3
  #
4
4
  # Mutation-resistant by construction: an earlier version of this suite passed
5
5
  # 13/13 against five separately broken hooks, so every probe now pins the full
6
- # observable contract, not just "did the word ask appear":
6
+ # observable contract, not just "did the word deny appear":
7
7
  # - exit status is always 0 (a PreToolUse hook must never fail the tool call)
8
8
  # - stdout is either empty or a single valid JSON object of the exact shape
9
9
  # - quiet means ZERO bytes on stdout, so a "deny" mutant cannot read as quiet
10
10
  # - stderr is always empty (the transcript may hold prompt payloads or secrets)
11
11
  # - each probe runs in its own TMPDIR, so one probe cannot satisfy another
12
- # - only the VERIFIED-loaded state is cached; the ask is stateless by design
12
+ # - one atomic repair attempt is capped; it never represents a verified load
13
13
  # Registered in the Makefile `test` target and GitHub Actions; requires jq.
14
14
  set -u
15
15
 
@@ -50,7 +50,7 @@ run() {
50
50
  RUN_TD="$td"
51
51
  MARKERS_BEFORE=$(markers_in "$td")
52
52
  jq -nc --arg t "$tool" --arg p "$tr" --arg s "$sess" \
53
- '{tool_name:$t,transcript_path:$p,session_id:$s,cwd:"/tmp"}' \
53
+ '{hook_event_name:"PreToolUse",tool_name:$t,transcript_path:$p,session_id:$s,cwd:"/tmp"}' \
54
54
  | TMPDIR="$td" bash "$HOOK" >"$so" 2>"$eo"
55
55
  RC=$?
56
56
  OUT=$(cat "$so" 2>/dev/null)
@@ -74,12 +74,12 @@ assert_universal() { # assert_universal <label>
74
74
  fi
75
75
  }
76
76
 
77
- assert_ask() { # assert_ask <label> <case-tmpdir>
77
+ assert_deny() { # assert_deny <label> <case-tmpdir>
78
78
  local label="$1" td="$2" d
79
79
  assert_universal "$label"
80
- [ -n "$OUT" ] || { note "$label: expected an ask, got no output"; return; }
80
+ [ -n "$OUT" ] || { note "$label: expected a deny, got no output"; return; }
81
81
  d=$(printf '%s' "$OUT" | jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null)
82
- [ "$d" = ask ] || { note "$label: permissionDecision=$d, expected ask"; return; }
82
+ [ "$d" = deny ] || { note "$label: permissionDecision=$d, expected deny"; return; }
83
83
  [ "$(printf '%s' "$OUT" | jq -r '.hookSpecificOutput.hookEventName // empty')" = PreToolUse ] \
84
84
  || note "$label: wrong or missing hookEventName"
85
85
  [ -n "$(printf '%s' "$OUT" | jq -r '.hookSpecificOutput.permissionDecisionReason // empty')" ] \
@@ -90,12 +90,6 @@ assert_ask() { # assert_ask <label> <case-tmpdir>
90
90
  [ "$(printf '%s' "$OUT" | jq -r '.hookSpecificOutput|keys|sort|join(",")')" \
91
91
  = "hookEventName,permissionDecision,permissionDecisionReason" ] \
92
92
  || note "$label: unexpected hookSpecificOutput keys"
93
- # The ask is intentionally STATELESS: this ask must add NO new marker, or a
94
- # fan-out's siblings slip through and a denial silences the rest of the session.
95
- # Counted as a delta, not an existence check — an unrelated warm session in the
96
- # same TMPDIR may legitimately have left one already.
97
- [ "$(markers_in "$td")" -eq "${MARKERS_BEFORE:-0}" ] \
98
- || note "$label: asking created a verified marker (would suppress later asks)"
99
93
  ok
100
94
  }
101
95
 
@@ -108,13 +102,23 @@ assert_quiet() { # assert_quiet <label>
108
102
  ok
109
103
  }
110
104
 
105
+ assert_diagnostic() {
106
+ local label="$1"
107
+ assert_universal "$label"
108
+ [ -n "$(printf '%s' "$OUT" | jq -r '.systemMessage // empty')" ] \
109
+ || note "$label: unavailable evidence needs a static diagnostic"
110
+ [ -z "$(printf '%s' "$OUT" | jq -r '.hookSpecificOutput.permissionDecision // empty')" ] \
111
+ || note "$label: unavailable evidence must not produce a permission decision"
112
+ ok
113
+ }
114
+
111
115
  case_dir() { local d="$ROOT/$1"; mkdir -p "$d"; printf '%s' "$d"; }
112
116
 
113
- # --- RED baseline: dispatch with the delegation owner never invoked -> ask ----
117
+ # --- RED baseline: dispatch with the delegation owner never invoked -> bounded deny ----
114
118
  for tool in Task Agent; do
115
119
  td=$(case_dir "cold-$tool"); mk_transcript "$td/t.jsonl" cold
116
120
  run "$td" "$tool" "$td/t.jsonl" "s-$tool"
117
- assert_ask "cold dispatch via $tool" "$td"
121
+ assert_deny "cold dispatch via $tool" "$td"
118
122
  done
119
123
 
120
124
  # --- GREEN: owner already invoked this session -> silent -------------------
@@ -122,50 +126,49 @@ td=$(case_dir warm); mk_transcript "$td/t.jsonl" warm
122
126
  run "$td" Task "$td/t.jsonl" s-warm
123
127
  assert_quiet "owner already invoked"
124
128
 
125
- # --- a cold session keeps asking: nothing about the ASK is cached ----------
126
- # Caching "already asked" inverted the gate twice (fan-out siblings sailed
127
- # through; a denial silenced the rest of the session), so every cold dispatch
128
- # must ask. Loading the owner is the only thing that stops the prompting.
129
+ # --- one bounded repair attempt, then quiet on a blind retry ---------------
129
130
  td=$(case_dir repeat); mk_transcript "$td/t.jsonl" cold
130
- for n in 1 2 3; do
131
+ run "$td" Task "$td/t.jsonl" s-rep
132
+ assert_deny "first cold dispatch" "$td"
133
+ for n in 2 3; do
131
134
  run "$td" Task "$td/t.jsonl" s-rep
132
- assert_ask "cold dispatch #$n in the same session" "$td"
135
+ assert_quiet "bounded cold retry #$n"
133
136
  done
134
137
 
135
- # --- fan-out: every concurrent sibling is gated, not just the first --------
138
+ # --- fan-out: exactly one replan; siblings can proceed (documented limit) ---
136
139
  # Each sibling captures into its OWN directory. Sharing one .stdout across the
137
- # five subprocesses made the observation itself racy: a regression that cached
138
- # the ask would let one process ask and four exit quietly, yet all five reads
139
- # could pick up the single ask and report a false 5/5.
140
+ # five subprocesses would make the observation itself racy: each worker must
141
+ # retain its own output while sharing one actor/context attempt cap.
140
142
  td=$(case_dir fanout); mk_transcript "$td/t.jsonl" cold
141
143
  for i in 1 2 3 4 5; do
142
144
  mkdir -p "$td/w$i"
143
145
  # ONE shared transcript path and one session id, as in a real fan-out: the
144
- # cache key includes transcript_path, so giving each worker its own copy would
145
- # hand them five different keys and a cached-ask regression would still show
146
- # 5/5. Only the capture files are per-worker.
147
- ( jq -nc --arg p "$td/t.jsonl" '{tool_name:"Task",transcript_path:$p,session_id:"s-fan",cwd:"/tmp"}' \
146
+ # state key includes transcript_path, so copies would create five different
147
+ # caps. Only the capture files are per-worker.
148
+ ( jq -nc --arg p "$td/t.jsonl" '{hook_event_name:"PreToolUse",tool_name:"Task",transcript_path:$p,session_id:"s-fan",cwd:"/tmp"}' \
148
149
  | TMPDIR="$td" bash "$HOOK" > "$td/w$i/out" 2>"$td/w$i/err" ) &
149
150
  done
150
151
  wait
151
152
  asked=0
152
153
  for i in 1 2 3 4 5; do
153
- [ "$(jq -r '.hookSpecificOutput.permissionDecision // empty' < "$td/w$i/out" 2>/dev/null)" = ask ] \
154
+ [ "$(jq -r '.hookSpecificOutput.permissionDecision // empty' < "$td/w$i/out" 2>/dev/null)" = deny ] \
154
155
  && asked=$((asked+1))
155
156
  [ -s "$td/w$i/err" ] && note "fan-out worker $i wrote to stderr"
156
157
  done
157
- [ "$asked" -eq 5 ] || note "fan-out: only $asked/5 concurrent dispatches were gated"
158
- [ "$asked" -eq 5 ] && ok
158
+ [ "$asked" -eq 1 ] || note "fan-out: expected one bounded repair, got $asked/5"
159
+ [ "$asked" -eq 1 ] && ok
159
160
 
160
- # --- the verified-loaded state IS cached, and only that state --------------
161
+ # --- completed loads are re-read; no lifetime verified shortcut ------------
161
162
  td=$(case_dir verified); mk_transcript "$td/t.jsonl" warm
162
163
  run "$td" Task "$td/t.jsonl" s-ver
163
164
  assert_quiet "warm dispatch stays quiet"
164
- ls "$td" 2>/dev/null | grep -q '^delegation-owner-loaded-' \
165
- && ok || note "warm dispatch did not record the verified marker"
166
- # with the marker present the transcript is not needed at all
165
+ if python3 -c 'import pathlib,sys; sys.exit(bool(list(pathlib.Path(sys.argv[1]).glob("ccl-skill-loading-*/*/*"))))' "$td"; then
166
+ ok
167
+ else
168
+ note "warm dispatch created a verification or attempt marker"
169
+ fi
167
170
  run "$td" Task "$td/gone.jsonl" s-ver
168
- assert_quiet "verified session skips the transcript scan"
171
+ assert_diagnostic "missing transcript stays unverified"
169
172
 
170
173
  # --- only a real Skill tool-use event counts (spoof resistance) ------------
171
174
  # Each spoof carries the skill NAME in a non-invocation position. The last two
@@ -188,13 +191,13 @@ spoof_line() {
188
191
  # denied, or failed when the hook runs.
189
192
  td=$(case_dir requested); mk_transcript "$td/t.jsonl" requested
190
193
  run "$td" Task "$td/t.jsonl" s-req
191
- assert_ask "Skill requested but never completed" "$td"
194
+ assert_deny "Skill requested but never completed" "$td"
192
195
 
193
196
  # A result belonging to a DIFFERENT call must not vouch for this one.
194
197
  td=$(case_dir mismatch); mk_transcript "$td/t.jsonl" requested
195
198
  printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_unrelated","content":"Launching skill: ccl-skills:multi-agent-delegation"}]}}' >> "$td/t.jsonl"
196
199
  run "$td" Task "$td/t.jsonl" s-mis
197
- assert_ask "unrelated tool_result does not complete the load" "$td"
200
+ assert_deny "unrelated tool_result does not complete the load" "$td"
198
201
 
199
202
  # The canonical ccl-scoped id is required; a bare basename could be another
200
203
  # locally installed skill.
@@ -202,13 +205,13 @@ td=$(case_dir bare)
202
205
  printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_b","name":"Skill","input":{"skill":"multi-agent-delegation"}}]}}' > "$td/t.jsonl"
203
206
  printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_b","content":"Launching skill: multi-agent-delegation"}]}}' >> "$td/t.jsonl"
204
207
  run "$td" Task "$td/t.jsonl" s-bare
205
- assert_ask "bare skill basename is not the CCL owner" "$td"
208
+ assert_deny "bare skill basename is not the CCL owner" "$td"
206
209
 
207
210
  for s in prose quoted other userside nested; do
208
211
  td=$(case_dir "spoof-$s"); mk_transcript "$td/t.jsonl" cold
209
212
  spoof_line "$s" >> "$td/t.jsonl"
210
213
  run "$td" Task "$td/t.jsonl" "s-spoof-$s"
211
- assert_ask "spoof/$s is not a Skill invocation" "$td"
214
+ assert_deny "spoof/$s is not a Skill invocation" "$td"
212
215
  done
213
216
 
214
217
  # --- one session must never vouch for another via a colliding cache key ----
@@ -220,14 +223,17 @@ mk_transcript "$td/cold.jsonl" cold
220
223
  run "$td" Task "$td/warm.jsonl" 'teama'
221
224
  assert_quiet "colliding-key: warm session is quiet"
222
225
  run "$td" Task "$td/cold.jsonl" 'team/a'
223
- assert_ask "colliding-key: cold session still asks" "$td"
226
+ assert_deny "colliding-key: cold session still replans" "$td"
224
227
 
225
228
  # --- a session with no usable id gets no shared cache bucket ---------------
226
229
  td=$(case_dir nosession); mk_transcript "$td/t.jsonl" warm
227
230
  run "$td" Task "$td/t.jsonl" ''
228
- assert_quiet "warm dispatch without session_id"
229
- ls "$td" 2>/dev/null | grep -q '^delegation-owner-loaded-' \
230
- && note "an id-less session wrote a shared cache marker" || ok
231
+ assert_diagnostic "warm dispatch without session_id"
232
+ if python3 -c 'import pathlib,sys; sys.exit(bool(list(pathlib.Path(sys.argv[1]).glob("ccl-skill-loading-*"))))' "$td"; then
233
+ ok
234
+ else
235
+ note "an id-less session created shared state"
236
+ fi
231
237
 
232
238
  # --- non-dispatch tools never fire -----------------------------------------
233
239
  for tool in Edit Bash Read; do
@@ -239,9 +245,9 @@ done
239
245
  # --- fail-open: absent, unreadable, or non-regular transcript --------------
240
246
  td=$(case_dir missing); mk_transcript "$td/t.jsonl" cold
241
247
  run "$td" Task "$td/nope.jsonl" s-miss
242
- assert_quiet "missing transcript"
248
+ assert_diagnostic "missing transcript"
243
249
  run "$td" Task "" s-empty
244
- assert_quiet "empty transcript path"
250
+ assert_diagnostic "empty transcript path"
245
251
 
246
252
  # a FIFO must not hang the hook. Needs both mkfifo and a timeout command; on a
247
253
  # platform lacking either, skip rather than fail on a missing tool (finding: an
@@ -250,7 +256,7 @@ td=$(case_dir fifo)
250
256
  TIMEOUT_BIN=""
251
257
  for c in timeout gtimeout; do command -v "$c" >/dev/null 2>&1 && { TIMEOUT_BIN="$c"; break; }; done
252
258
  if [ -n "$TIMEOUT_BIN" ] && mkfifo "$td/pipe" 2>/dev/null; then
253
- jq -nc --arg p "$td/pipe" '{tool_name:"Task",transcript_path:$p,session_id:"s-fifo",cwd:"/tmp"}' \
259
+ jq -nc --arg p "$td/pipe" '{hook_event_name:"PreToolUse",tool_name:"Task",transcript_path:$p,session_id:"s-fifo",cwd:"/tmp"}' \
254
260
  | TMPDIR="$td" "$TIMEOUT_BIN" 5 bash "$HOOK" >"$td/.stdout" 2>"$td/.stderr"
255
261
  RC=$?
256
262
  OUT=$(cat "$td/.stdout" 2>/dev/null); ERR=$(cat "$td/.stderr" 2>/dev/null)
@@ -259,7 +265,7 @@ if [ -n "$TIMEOUT_BIN" ] && mkfifo "$td/pipe" 2>/dev/null; then
259
265
  if [ "$RC" -eq 124 ]; then
260
266
  note "FIFO transcript hung the hook until timeout"
261
267
  else
262
- assert_quiet "FIFO transcript is not read"
268
+ assert_diagnostic "FIFO transcript is not read"
263
269
  fi
264
270
  else
265
271
  ok # no mkfifo or no timeout command on this platform: nothing to assert
@@ -269,18 +275,18 @@ fi
269
275
  td=$(case_dir unreadable); mk_transcript "$td/t.jsonl" cold
270
276
  if chmod 000 "$td/t.jsonl" 2>/dev/null && [ ! -r "$td/t.jsonl" ]; then
271
277
  run "$td" Task "$td/t.jsonl" s-unread
272
- assert_quiet "unreadable transcript fails open"
278
+ assert_diagnostic "unreadable transcript fails open"
273
279
  chmod 644 "$td/t.jsonl" 2>/dev/null
274
280
  else
275
281
  chmod 644 "$td/t.jsonl" 2>/dev/null
276
282
  ok # running as root or on a permissionless filesystem: not assertable
277
283
  fi
278
284
 
279
- # Without jq the hook must degrade to silence, never to a decision or an error.
285
+ # Parsing no longer depends on jq; the checkpoint still works without it.
280
286
  # The PATH must still carry a shell and coreutils — emptying it hides `bash` and
281
287
  # `cat` too, which tests the harness rather than the hook.
282
288
  td=$(case_dir nojq); mk_transcript "$td/t.jsonl" cold
283
- jq -nc --arg p "$td/t.jsonl" '{tool_name:"Task",transcript_path:$p,session_id:"s-nojq",cwd:"/tmp"}' > "$td/in.json"
289
+ jq -nc --arg p "$td/t.jsonl" '{hook_event_name:"PreToolUse",tool_name:"Task",transcript_path:$p,session_id:"s-nojq",cwd:"/tmp"}' > "$td/in.json"
284
290
  BASE_PATH="/usr/bin:/bin:/usr/sbin:/sbin"
285
291
  BASH_ABS=$(command -v bash)
286
292
  if [ -x "$BASH_ABS" ] && ! PATH="$BASE_PATH" command -v jq >/dev/null 2>&1 \
@@ -290,7 +296,7 @@ if [ -x "$BASH_ABS" ] && ! PATH="$BASE_PATH" command -v jq >/dev/null 2>&1 \
290
296
  OUT=$(cat "$td/.stdout" 2>/dev/null); ERR=$(cat "$td/.stderr" 2>/dev/null)
291
297
  OUTBYTES=$(wc -c < "$td/.stdout" 2>/dev/null | tr -d ' ')
292
298
  ERRBYTES=$(wc -c < "$td/.stderr" 2>/dev/null | tr -d ' ')
293
- assert_quiet "no jq on PATH degrades to silence"
299
+ assert_deny "no jq on PATH still permits the Python checkpoint" "$td"
294
300
  else
295
301
  ok # jq lives in a system dir here, so it cannot be hidden without hiding coreutils
296
302
  fi
@@ -301,12 +307,12 @@ printf 'not json' | TMPDIR="$td" bash "$HOOK" >"$td/.stdout" 2>"$td/.stderr"; RC
301
307
  OUT=$(cat "$td/.stdout" 2>/dev/null); ERR=$(cat "$td/.stderr" 2>/dev/null)
302
308
  OUTBYTES=$(wc -c < "$td/.stdout" 2>/dev/null | tr -d ' ')
303
309
  ERRBYTES=$(wc -c < "$td/.stderr" 2>/dev/null | tr -d ' ')
304
- assert_quiet "malformed stdin"
310
+ assert_diagnostic "malformed stdin"
305
311
 
306
312
  # --- hostile session_id stays inside TMPDIR --------------------------------
307
313
  td=$(case_dir hostile); mk_transcript "$td/t.jsonl" cold
308
314
  run "$td" Task "$td/t.jsonl" '../../escape'
309
- assert_ask "hostile session_id still asks" "$td"
315
+ assert_deny "hostile session_id still replans" "$td"
310
316
  if [ -e "$ROOT/escape" ] || [ -e "$td/../escape" ]; then
311
317
  note "marker escaped TMPDIR"
312
318
  else
@@ -317,7 +323,7 @@ fi
317
323
  td=$(case_dir leak); mk_transcript "$td/t.jsonl" cold
318
324
  printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"SENTINEL-SECRET-VALUE"}]}}' >> "$td/t.jsonl"
319
325
  run "$td" Task "$td/t.jsonl" s-leak
320
- assert_ask "cold dispatch with secret in transcript" "$td"
326
+ assert_deny "cold dispatch with secret in transcript" "$td"
321
327
  if printf '%s%s' "$OUT" "$ERR" | grep -q 'SENTINEL-SECRET-VALUE'; then
322
328
  note "transcript content leaked into hook output"
323
329
  else
@@ -895,6 +895,146 @@ reason_has '字面绝对路径' "$MAIN_CWD" 'git -C "$WT" merge origin/main --no
895
895
  reason_has '!展开不了' "$MAIN_CWD" 'git merge origin/main'
896
896
  reason_has '直接推进' "$MAIN_CWD" 'git merge origin/main'
897
897
 
898
+ # Target-goal scope is checked through the public prompt-to-guard path.
899
+ PROMPT_HOOK="${PROMPT_HOOK:-$SCRIPT_DIR/merge-authorization-prompt.sh}"
900
+ git -C "$MAIN_CWD" remote add origin https://forge.example.invalid/team/project.git
901
+ goal_prompt() {
902
+ jq -nc --arg p "$1" --arg s "$VSID" --arg w "$FEAT_CWD" '{prompt:$p,session_id:$s,cwd:$w}' | TMPDIR="$tmp" bash "$PROMPT_HOOK"
903
+ }
904
+ goal_arm() { goal_prompt '完成并合并 PR #123'; }
905
+ goal_command='gh pr merge 123 --repo forge.example.invalid/team/project --merge --match-head-commit 0123456789012345678901234567890123456789'
906
+ goal_arm
907
+ probe_sid allow "$FEAT_CWD" "$VSID" "$goal_command"
908
+ sentinel_state absent 'target-goal consumed once'
909
+ probe_sid deny "$FEAT_CWD" "$VSID" "$goal_command"
910
+ for bad in \
911
+ 'gh pr merge 124 --repo forge.example.invalid/team/project --merge' \
912
+ 'gh pr merge 123 --repo forge.example.invalid/team/other --merge' \
913
+ 'gh pr merge 123 --merge' \
914
+ 'gh pr merge 123 --repo forge.example.invalid/team/project --repo forge.example.invalid/team/other --merge' \
915
+ 'gh pr merge 123 --repo forge.example.invalid/team/project --merge --squash' \
916
+ 'gh pr merge 123 --repo forge.example.invalid/team/project --merge --match-head-commit abc' \
917
+ 'gh pr merge 123 --repo forge.example.invalid/team/project --merge --unknown' \
918
+ 'gh pr merge 123 --repo forge.example.invalid/team/project --auto' \
919
+ 'gh pr merge 123 --repo forge.example.invalid/team/project --merge --admin' \
920
+ 'gh api --method PUT repos/team/project/pulls/123/merge' \
921
+ 'curl -X PUT https://forge.example.invalid/repos/team/project/pulls/123/merge' \
922
+ 'gh pr merge 123 --repo forge.example.invalid/team/project --merge; echo done' \
923
+ 'git push origin main'; do
924
+ goal_arm
925
+ probe_sid deny "$FEAT_CWD" "$VSID" "$bad"
926
+ sentinel_state present 'scope or command mismatch must preserve goal grant'
927
+ done
928
+ goal_arm
929
+ goal_prompt '继续'
930
+ probe_sid allow "$FEAT_CWD" "$VSID" "$goal_command"
931
+ goal_arm
932
+ goal_prompt '改成另一个功能'
933
+ probe_sid deny "$FEAT_CWD" "$VSID" "$goal_command"
934
+ goal_prompt '继续'
935
+ probe_sid deny "$FEAT_CWD" "$VSID" "$goal_command"
936
+ goal_arm
937
+ old_ts=$(date -v-2H +%Y%m%d%H%M 2>/dev/null || date -d '-2 hours' +%Y%m%d%H%M)
938
+ touch -t "$old_ts" "$VAUTH_DIR/$VSID"
939
+ goal_prompt '状态'
940
+ probe_sid deny "$FEAT_CWD" "$VSID" "$goal_command"
941
+ goal_prompt '完成并合并 MR !123'
942
+ probe_sid deny "$FEAT_CWD" "$VSID" 'glab mr merge 123 --repo forge.example.invalid/team/project --auto-merge=false --yes'
943
+ probe_sid allow "$FEAT_CWD" "$VSID" 'glab mr merge 123 --repo https://forge.example.invalid/team/project --auto-merge=false --yes --sha 0123456789012345678901234567890123456789'
944
+
945
+ # A corrupted or lost invalidation epoch cannot become a valid goal grant.
946
+ goal_arm
947
+ printf 'newer-prompt\n' >"$VAUTH_DIR/$VSID.epoch"
948
+ probe_sid deny "$FEAT_CWD" "$VSID" "$goal_command"
949
+ goal_arm
950
+ rm -f "$VAUTH_DIR/$VSID.epoch"
951
+ probe_sid deny "$FEAT_CWD" "$VSID" "$goal_command"
952
+
953
+ # Two concurrent calls share one goal use, not two independent approvals.
954
+ goal_arm
955
+ for n in 1 2; do
956
+ jq -nc --arg s "$VSID" --arg w "$FEAT_CWD" --arg c "$goal_command" '{session_id:$s,cwd:$w,tool_input:{command:$c}}' \
957
+ | TMPDIR="$tmp" bash "$GUARD" >"$tmp/goal-contender-$n" &
958
+ if [ "$n" = 1 ]; then contender_one=$!; else contender_two=$!; fi
959
+ done
960
+ wait "$contender_one"; wait "$contender_two"
961
+ allowed=0; denied=0
962
+ for n in 1 2; do
963
+ if grep -q '"permissionDecision":"deny"' "$tmp/goal-contender-$n"; then denied=$((denied+1))
964
+ elif grep -q 'systemMessage' "$tmp/goal-contender-$n"; then allowed=$((allowed+1)); fi
965
+ done
966
+ if [ "$allowed" = 1 ] && [ "$denied" = 1 ] && [ ! -f "$VAUTH_DIR/$VSID" ]; then pass=$((pass+1)); else
967
+ fail=$((fail+1)); echo 'FAIL concurrent consumers must release exactly one goal use' >&2
968
+ fi
969
+
970
+ # Force revoke between consume and rearm. The wrapper only pauses the actual
971
+ # filesystem rename of this disposable grant; no platform merge is executed.
972
+ mkdir "$tmp/race-bin"
973
+ REAL_MV=$(command -v mv)
974
+ export REAL_MV
975
+ cat >"$tmp/race-bin/mv" <<'RACE_MV'
976
+ #!/usr/bin/env bash
977
+ "$REAL_MV" "$@" || exit $?
978
+ case "${2:-}" in
979
+ "$RACE_SENT".used.*)
980
+ : >"$RACE_REACHED"
981
+ for ((i=0;i<200;i++)); do
982
+ [ -f "$RACE_RESUME" ] && exit 0
983
+ sleep 0.05
984
+ done
985
+ exit 1
986
+ ;;
987
+ esac
988
+ RACE_MV
989
+ chmod +x "$tmp/race-bin/mv"
990
+ goal_prompt '批量合并 2'
991
+ export RACE_SENT="$VAUTH_DIR/$VSID" RACE_REACHED="$tmp/race-reached" RACE_RESUME="$tmp/race-resume"
992
+ jq -nc --arg s "$VSID" --arg w "$FEAT_CWD" '{session_id:$s,cwd:$w,tool_input:{command:"gh pr merge 123 --merge"}}' \
993
+ | PATH="$tmp/race-bin:$PATH" TMPDIR="$tmp" bash "$GUARD" >"$tmp/race-output" &
994
+ race_pid=$!
995
+ for ((i=0;i<200;i++)); do [ -f "$RACE_REACHED" ] && break; sleep 0.05; done
996
+ if [ -f "$RACE_REACHED" ]; then
997
+ goal_prompt '停止'
998
+ : >"$RACE_RESUME"
999
+ wait "$race_pid"
1000
+ if grep -q '"permissionDecision":"deny"' "$tmp/race-output" && [ ! -f "$RACE_SENT" ]; then
1001
+ pass=$((pass+1))
1002
+ else fail=$((fail+1)); echo 'FAIL revoke during consume must deny and prevent batch resurrection' >&2; fi
1003
+ else
1004
+ : >"$RACE_RESUME"; wait "$race_pid"
1005
+ fail=$((fail+1)); echo 'FAIL race harness never reached consume' >&2
1006
+ fi
1007
+
1008
+ # Goal grants take the same post-consume invalidation path without batch rearm.
1009
+ # A revoke or unresolved scope change arriving in that window must deny even
1010
+ # though the prompt hook sees no sentinel to remove or mark suspended.
1011
+ for invalidation in '停止' '改成另一个功能'; do
1012
+ goal_arm
1013
+ epoch_before=$(cat "$RACE_SENT.epoch")
1014
+ rm -f "$RACE_REACHED" "$RACE_RESUME"
1015
+ jq -nc --arg s "$VSID" --arg w "$FEAT_CWD" --arg c "$goal_command" '{session_id:$s,cwd:$w,tool_input:{command:$c}}' \
1016
+ | PATH="$tmp/race-bin:$PATH" TMPDIR="$tmp" bash "$GUARD" >"$tmp/goal-race-output" &
1017
+ race_pid=$!
1018
+ for ((i=0;i<200;i++)); do [ -f "$RACE_REACHED" ] && break; sleep 0.05; done
1019
+ if [ -f "$RACE_REACHED" ]; then
1020
+ goal_prompt "$invalidation"
1021
+ : >"$RACE_RESUME"
1022
+ wait "$race_pid"
1023
+ if [ "$epoch_before" != "$(cat "$RACE_SENT.epoch")" ] \
1024
+ && grep -q '"permissionDecision":"deny"' "$tmp/goal-race-output" \
1025
+ && [ ! -f "$RACE_SENT" ] \
1026
+ && ! compgen -G "$RACE_SENT.used.*" >/dev/null; then
1027
+ pass=$((pass+1))
1028
+ else
1029
+ fail=$((fail+1)); printf 'FAIL goal invalidation during consume must deny without residual grant: %s\n' "$invalidation" >&2
1030
+ fi
1031
+ else
1032
+ : >"$RACE_RESUME"; wait "$race_pid"
1033
+ fail=$((fail+1)); echo 'FAIL goal race harness never reached consume' >&2
1034
+ fi
1035
+ done
1036
+ unset RACE_SENT RACE_REACHED RACE_RESUME REAL_MV
1037
+
898
1038
  if [ "$fail" -ne 0 ]; then
899
1039
  echo "test_guard_merge_authorization: FAIL pass=$pass fail=$fail" >&2
900
1040
  exit 1