@ccoalm/ccl-skills 0.18.1 → 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.
- package/README.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-policy.md +50 -0
- package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-start.md +30 -40
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-delegation-owner.sh +9 -122
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-edit-isolation.sh +43 -9
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-merge-authorization.sh +95 -2
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +32 -1
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/host-input.py +544 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/merge-authorization-prompt.sh +103 -26
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/owner-dispatch-guard.sh +17 -8
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/proposed-next-stop.sh +14 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/session-start.sh +31 -2
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-context-compact.sh +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-extraction-gate-stop.sh +13 -4
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-loading.py +277 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_delegation_owner.sh +61 -55
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_merge_authorization.sh +140 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_host_input.py +597 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_merge_authorization_prompt.sh +56 -3
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_proposed_next.py +260 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_session_start.sh +85 -3
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_skill_loading.py +278 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +27 -6
- package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/README.md +23 -15
- package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/owner-dispatch.sh +41 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/pre-final-continuation-gate.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/hook-authorization.md +13 -0
- package/dist/assets/release.json +80 -25
- package/dist/codex-hooks.d.ts +15 -0
- package/dist/codex-hooks.js +186 -0
- package/dist/opencode-adapter.js +8 -3
- package/dist/operations.js +13 -9
- 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
|
|
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
|
-
# -
|
|
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
|
-
|
|
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
|
|
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" =
|
|
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 ->
|
|
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
|
-
|
|
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
|
-
# ---
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
assert_quiet "bounded cold retry #$n"
|
|
133
136
|
done
|
|
134
137
|
|
|
135
|
-
# --- fan-out:
|
|
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
|
|
138
|
-
#
|
|
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
|
-
#
|
|
145
|
-
#
|
|
146
|
-
|
|
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)" =
|
|
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
|
|
158
|
-
[ "$asked" -eq
|
|
158
|
+
[ "$asked" -eq 1 ] || note "fan-out: expected one bounded repair, got $asked/5"
|
|
159
|
+
[ "$asked" -eq 1 ] && ok
|
|
159
160
|
|
|
160
|
-
# ---
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
248
|
+
assert_diagnostic "missing transcript"
|
|
243
249
|
run "$td" Task "" s-empty
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|