@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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
set -u
|
|
8
8
|
|
|
9
9
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
10
|
-
HOOK="$SCRIPT_DIR/merge-authorization-prompt.sh"
|
|
10
|
+
HOOK="${HOOK:-$SCRIPT_DIR/merge-authorization-prompt.sh}"
|
|
11
11
|
[ -f "$HOOK" ] || { echo "FAIL: hook not found: $HOOK" >&2; exit 1; }
|
|
12
12
|
command -v jq >/dev/null 2>&1 || { echo "FAIL: jq required for this suite" >&2; exit 1; }
|
|
13
13
|
|
|
@@ -18,11 +18,14 @@ SID="sess-prompt-test"
|
|
|
18
18
|
AUTH_DIR="$tmp/ccl-skills-merge-auth-$(id -u)"
|
|
19
19
|
SENT="$AUTH_DIR/$SID"
|
|
20
20
|
|
|
21
|
+
git -C "$tmp" init -q repo
|
|
22
|
+
git -C "$tmp/repo" remote add origin https://example.invalid/team/project.git
|
|
23
|
+
|
|
21
24
|
pass=0; fail=0
|
|
22
25
|
|
|
23
26
|
send() { # send <prompt> [sid]
|
|
24
27
|
local p="$1" s="${2-$SID}"
|
|
25
|
-
jq -nc --arg p "$p" --arg s "$s" '{prompt:$p,session_id:$s}' | TMPDIR="$tmp" bash "$HOOK"
|
|
28
|
+
jq -nc --arg p "$p" --arg s "$s" --arg w "$tmp/repo" '{prompt:$p,session_id:$s,cwd:$w}' | TMPDIR="$tmp" bash "$HOOK"
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
expect_armed() { # expect_armed <prompt> [expected sentinel content]
|
|
@@ -160,8 +163,9 @@ fi
|
|
|
160
163
|
rm -f "$SENT"
|
|
161
164
|
|
|
162
165
|
# --- degrade / hostile inputs: no session_id, path-traversal session_id ---
|
|
166
|
+
before_missing_sid=$(ls -A "$AUTH_DIR" 2>/dev/null)
|
|
163
167
|
jq -nc '{prompt:"合并"}' | TMPDIR="$tmp" bash "$HOOK"
|
|
164
|
-
if [
|
|
168
|
+
if [ "$(ls -A "$AUTH_DIR" 2>/dev/null)" = "$before_missing_sid" ]; then pass=$((pass+1)); else
|
|
165
169
|
fail=$((fail+1)); echo 'FAIL missing session_id must be a no-op' >&2
|
|
166
170
|
fi
|
|
167
171
|
send '合并' '../evil'
|
|
@@ -171,6 +175,55 @@ fi
|
|
|
171
175
|
out=$(printf 'not-json' | TMPDIR="$tmp" bash "$HOOK")
|
|
172
176
|
if [ -z "$out" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL bad json -> $out" >&2; fi
|
|
173
177
|
|
|
178
|
+
# Target goals have a closed grammar and retain their original deadline.
|
|
179
|
+
expect_goal() {
|
|
180
|
+
send "$1"
|
|
181
|
+
if sed -n '1p' "$SENT" 2>/dev/null | jq -e '.kind == "target-goal" and .state == "active" and .id == "123" and .repo == "example.invalid/team/project"' >/dev/null 2>&1; then
|
|
182
|
+
pass=$((pass+1))
|
|
183
|
+
else fail=$((fail+1)); echo "FAIL target goal not armed: $1" >&2; fi
|
|
184
|
+
}
|
|
185
|
+
expect_goal '完成并合并 PR #123'
|
|
186
|
+
expect_goal 'finish and merge PR #123'
|
|
187
|
+
for bad in '完成并合并 PR #123?' '不要完成并合并 PR #123' '如果通过,完成并合并 PR #123' '“完成并合并 PR #123”' '完成并合并 PR #123 然后发布' '完成并合并 PR #0123' '发布下个 npm 补丁'; do
|
|
188
|
+
expect_not_armed "$bad"
|
|
189
|
+
done
|
|
190
|
+
for neutral in '继续' '继续吧' '进度' '状态' 'continue' 'status' 'progress'; do
|
|
191
|
+
rm -f "$SENT"
|
|
192
|
+
send "$neutral"
|
|
193
|
+
if [ ! -f "$SENT" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL neutral created authority: $neutral" >&2; fi
|
|
194
|
+
expect_goal '完成并合并 PR #123'
|
|
195
|
+
old_ts=$(date -v-30M +%Y%m%d%H%M 2>/dev/null || date -d '-30 minutes' +%Y%m%d%H%M)
|
|
196
|
+
touch -t "$old_ts" "$SENT"
|
|
197
|
+
cp -p "$SENT" "$tmp/before-neutral"
|
|
198
|
+
send "$neutral"
|
|
199
|
+
if cmp -s "$SENT" "$tmp/before-neutral" && [ ! "$SENT" -nt "$tmp/before-neutral" ]; then
|
|
200
|
+
pass=$((pass+1))
|
|
201
|
+
else fail=$((fail+1)); echo "FAIL neutral must preserve scope and original expiry: $neutral" >&2; fi
|
|
202
|
+
done
|
|
203
|
+
expect_goal '完成并合并 PR #123'
|
|
204
|
+
send '先改 README'
|
|
205
|
+
if sed -n '1p' "$SENT" 2>/dev/null | jq -e '.state == "suspended"' >/dev/null 2>&1; then pass=$((pass+1)); else fail=$((fail+1)); echo 'FAIL unresolved message must suspend goal' >&2; fi
|
|
206
|
+
send '继续'
|
|
207
|
+
if sed -n '1p' "$SENT" 2>/dev/null | jq -e '.state == "suspended"' >/dev/null 2>&1; then pass=$((pass+1)); else fail=$((fail+1)); echo 'FAIL neutral must not reactivate suspended goal' >&2; fi
|
|
208
|
+
for revoke in '停止' '停一下' '不要合并' '撤销合并授权' 'stop' 'pause' 'cancel merge'; do
|
|
209
|
+
expect_goal '完成并合并 PR #123'
|
|
210
|
+
send "$revoke"
|
|
211
|
+
if [ ! -f "$SENT" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL explicit revocation: $revoke" >&2; fi
|
|
212
|
+
done
|
|
213
|
+
send '批量合并 3'
|
|
214
|
+
send '继续'
|
|
215
|
+
if [ ! -f "$SENT" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo 'FAIL legacy batch still clears on neutral prompt' >&2; fi
|
|
216
|
+
git -C "$tmp/repo" remote set-url origin 'https://user:password@example.invalid/team/project.git'
|
|
217
|
+
expect_not_armed '完成并合并 PR #123'
|
|
218
|
+
git -C "$tmp/repo" remote set-url origin 'git@example.invalid:team/project.git'
|
|
219
|
+
expect_goal '完成并合并 PR #123'
|
|
220
|
+
git -C "$tmp/repo" remote set-url origin 'ssh://git@example.invalid/team/project.git'
|
|
221
|
+
expect_goal '完成并合并 PR #123'
|
|
222
|
+
git -C "$tmp/repo" remote set-url origin $'https://example.invalid/team/project.git\nhttps://user:password@example.org/team/other'
|
|
223
|
+
expect_not_armed '完成并合并 PR #123'
|
|
224
|
+
git -C "$tmp/repo" remote remove origin
|
|
225
|
+
expect_not_armed '完成并合并 PR #123'
|
|
226
|
+
|
|
174
227
|
if [ "$fail" -ne 0 ]; then
|
|
175
228
|
echo "test_merge_authorization_prompt: FAIL pass=$pass fail=$fail" >&2
|
|
176
229
|
exit 1
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Synthetic native Stop payloads; no real host state or conversations."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProposedNextTests(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
17
|
+
self.addCleanup(self.tmp.cleanup)
|
|
18
|
+
self.root = Path(self.tmp.name)
|
|
19
|
+
self.hooks = self.root / 'hooks'
|
|
20
|
+
self.hooks.mkdir()
|
|
21
|
+
for name in ('host-input.py', 'proposed-next-stop.sh'):
|
|
22
|
+
source = ROOT / 'hooks' / name
|
|
23
|
+
if source.exists():
|
|
24
|
+
shutil.copyfile(source, self.hooks / name)
|
|
25
|
+
self.skill = self.root / 'skills/product-rd-workflow/SKILL.md'
|
|
26
|
+
self.skill.parent.mkdir(parents=True)
|
|
27
|
+
source = (ROOT / 'skills/product-rd-workflow/SKILL.md').read_text()
|
|
28
|
+
self.contract = next(p for p in source.split('\n\n') if p.startswith(
|
|
29
|
+
'**Continuation-proposal output contract (session-wide for product delivery).**'))
|
|
30
|
+
self.skill.write_text('---\nname: product-rd-workflow\n---\n\n' + self.contract + '\n')
|
|
31
|
+
self.path = self.root / 'synthetic.jsonl'
|
|
32
|
+
self.path.write_text('')
|
|
33
|
+
self.payload = {'session_id': 'synthetic', 'cwd': str(self.root),
|
|
34
|
+
'transcript_path': str(self.path), 'hook_event_name': 'Stop',
|
|
35
|
+
'stop_hook_active': False, 'last_assistant_message': 'Checks passed.'}
|
|
36
|
+
|
|
37
|
+
def events(self, events):
|
|
38
|
+
self.path.write_text(''.join(json.dumps(e) + '\n' for e in events))
|
|
39
|
+
|
|
40
|
+
def run_hook(self, payload=None):
|
|
41
|
+
value = self.payload if payload is None else payload
|
|
42
|
+
raw = value if isinstance(value, str) else json.dumps(value)
|
|
43
|
+
result = subprocess.run(['bash', str(self.hooks / 'proposed-next-stop.sh')],
|
|
44
|
+
input=raw, text=True, capture_output=True, cwd=self.root)
|
|
45
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
46
|
+
self.assertEqual(result.stderr, '')
|
|
47
|
+
return json.loads(result.stdout) if result.stdout else {}
|
|
48
|
+
|
|
49
|
+
def claude_load(self, name='product-rd-workflow', error=False):
|
|
50
|
+
return [
|
|
51
|
+
{'type': 'assistant', 'message': {'content': [{'type': 'tool_use', 'id': 'load',
|
|
52
|
+
'name': 'Skill', 'input': {'skill': 'ccl-skills:' + name}}]}},
|
|
53
|
+
{'type': 'user', 'message': {'content': [{'type': 'tool_result',
|
|
54
|
+
'tool_use_id': 'load', 'is_error': error, 'content': 'Skill loaded'}]}}]
|
|
55
|
+
|
|
56
|
+
def codex_read(self, command=None, body=None, code=0, call_id='load', output_id='load'):
|
|
57
|
+
return [
|
|
58
|
+
{'type': 'response_item', 'payload': {'type': 'function_call', 'name': 'exec_command',
|
|
59
|
+
'call_id': call_id, 'arguments': json.dumps({'cmd': command or f'cat {self.skill}'})}},
|
|
60
|
+
{'type': 'response_item', 'payload': {'type': 'function_call_output', 'call_id': output_id,
|
|
61
|
+
'output': f'Process exited with code {code}\nOutput:\n' + (
|
|
62
|
+
self.skill.read_text() if body is None else body)}}]
|
|
63
|
+
|
|
64
|
+
def assistant(self, text, host='codex', phase='final_answer'):
|
|
65
|
+
if host == 'claude':
|
|
66
|
+
return {'type': 'assistant', 'message': {'content': [{'type': 'text', 'text': text}]}}
|
|
67
|
+
return {'type': 'response_item', 'payload': {'type': 'message', 'role': 'assistant',
|
|
68
|
+
'phase': phase, 'content': [{'type': 'output_text', 'text': text}]}}
|
|
69
|
+
|
|
70
|
+
def assert_block(self, value):
|
|
71
|
+
self.assertEqual(value.get('decision'), 'block', value)
|
|
72
|
+
self.assertEqual(set(value), {'decision', 'reason'})
|
|
73
|
+
self.assertIn('proposed-next:', value['reason'])
|
|
74
|
+
|
|
75
|
+
def test_native_claude_and_codex_completed_owner(self):
|
|
76
|
+
for host, events in [('claude', self.claude_load()), ('codex', self.codex_read())]:
|
|
77
|
+
with self.subTest(host=host):
|
|
78
|
+
self.events(events)
|
|
79
|
+
payload = dict(self.payload)
|
|
80
|
+
if host == 'codex':
|
|
81
|
+
payload.update(turn_id='synthetic-turn', model='synthetic-model',
|
|
82
|
+
permission_mode='default')
|
|
83
|
+
self.assert_block(self.run_hook(payload))
|
|
84
|
+
|
|
85
|
+
def test_direct_final_overrides_stale_transcript_answer(self):
|
|
86
|
+
self.events(self.claude_load() + [self.assistant('proposed-next: verify the local patch')])
|
|
87
|
+
self.assert_block(self.run_hook())
|
|
88
|
+
self.payload['last_assistant_message'] = 'Done.\nproposed-next: none — status only'
|
|
89
|
+
self.assertEqual(self.run_hook(), {})
|
|
90
|
+
|
|
91
|
+
def test_loop_and_malformed_fields_are_quiet(self):
|
|
92
|
+
self.events(self.claude_load())
|
|
93
|
+
for updates in ({'stop_hook_active': True}, {'stop_hook_active': 'false'},
|
|
94
|
+
{'stop_hook_active': 0}, {'last_assistant_message': None},
|
|
95
|
+
{'last_assistant_message': ''}, {'last_assistant_message': []},
|
|
96
|
+
{'hook_event_name': 'SubagentStop'}, {'hook_event_name': 'StopFailure'}):
|
|
97
|
+
with self.subTest(updates=updates):
|
|
98
|
+
self.assertEqual(self.run_hook(dict(self.payload, **updates)), {})
|
|
99
|
+
for field in ('last_assistant_message', 'stop_hook_active', 'hook_event_name'):
|
|
100
|
+
payload = dict(self.payload)
|
|
101
|
+
del payload[field]
|
|
102
|
+
self.assertEqual(self.run_hook(payload), {})
|
|
103
|
+
for payload in ('[]', 'null', '{}'):
|
|
104
|
+
self.assertEqual(self.run_hook(payload), {})
|
|
105
|
+
|
|
106
|
+
def test_plain_answers_and_unrelated_owner_do_not_trigger(self):
|
|
107
|
+
for events in ([], self.claude_load('testing-strategy'), self.claude_load(error=True),
|
|
108
|
+
self.claude_load()[:1]):
|
|
109
|
+
self.events(events)
|
|
110
|
+
self.assertEqual(self.run_hook(), {})
|
|
111
|
+
|
|
112
|
+
def test_prior_assistant_handoff_is_eligibility_for_both_hosts(self):
|
|
113
|
+
for host in ('claude', 'codex'):
|
|
114
|
+
self.events([self.assistant('proposed-next: verify the local patch', host)])
|
|
115
|
+
self.assert_block(self.run_hook())
|
|
116
|
+
|
|
117
|
+
def test_quoted_examples_and_commentary_do_not_establish_eligibility(self):
|
|
118
|
+
for text in ('> proposed-next: run checks', '```text\nproposed-next: run checks\n```',
|
|
119
|
+
'Example: proposed-next: run checks', 'proposed-next: <action and scope>',
|
|
120
|
+
'`proposed-next: run checks`', ' proposed-next: code example',
|
|
121
|
+
'```text\n```not a closing fence\nproposed-next: code example\n```'):
|
|
122
|
+
self.events([self.assistant(text)])
|
|
123
|
+
self.assertEqual(self.run_hook(), {})
|
|
124
|
+
self.events([self.assistant('proposed-next: run checks', phase='commentary')])
|
|
125
|
+
self.assertEqual(self.run_hook(), {})
|
|
126
|
+
self.events([{'type': 'response_item', 'payload': {'type': 'message', 'role': 'user',
|
|
127
|
+
'content': [{'type': 'input_text', 'text': 'proposed-next: run checks'}]}}])
|
|
128
|
+
self.assertEqual(self.run_hook(), {})
|
|
129
|
+
|
|
130
|
+
def test_marker_in_quote_does_not_satisfy_current_handoff(self):
|
|
131
|
+
self.events(self.claude_load())
|
|
132
|
+
for text in ('Done.\n> proposed-next: run checks',
|
|
133
|
+
'Done.\n```text\nproposed-next: run checks\n```',
|
|
134
|
+
'Done.\nproposed-next: <action and scope>', 'Done.\nproposed-next: '):
|
|
135
|
+
self.payload['last_assistant_message'] = text
|
|
136
|
+
self.assert_block(self.run_hook())
|
|
137
|
+
|
|
138
|
+
def test_status_question_and_user_stop_keep_truthful_formatting(self):
|
|
139
|
+
self.events(self.claude_load())
|
|
140
|
+
for text in ('Which option should I use?', 'Stopped as requested.', 'Status: checks passed.'):
|
|
141
|
+
self.payload['last_assistant_message'] = text
|
|
142
|
+
self.assert_block(self.run_hook())
|
|
143
|
+
for text in ('proposed-next: none — status only', '**proposed-next:** none — status only',
|
|
144
|
+
'proposed-next: run the existing local verification suite'):
|
|
145
|
+
self.payload['last_assistant_message'] = text
|
|
146
|
+
self.assertEqual(self.run_hook(), {})
|
|
147
|
+
|
|
148
|
+
def test_complete_machine_artifacts_are_preserved(self):
|
|
149
|
+
self.events(self.claude_load())
|
|
150
|
+
for text in ('{"status":"done"}', '[1,2]', 'true', '"done"', '42',
|
|
151
|
+
'```json\n{"status":"done"}\n```', '~~~text\nexact output\n~~~'):
|
|
152
|
+
self.payload['last_assistant_message'] = text
|
|
153
|
+
self.assertEqual(self.run_hook(), {})
|
|
154
|
+
self.payload['last_assistant_message'] = '```json\n{}\n```\nChecks passed.\n```text\nexample\n```'
|
|
155
|
+
self.assert_block(self.run_hook())
|
|
156
|
+
|
|
157
|
+
def test_missing_optional_canonical_rule_does_not_disable_other_evidence(self):
|
|
158
|
+
self.skill.unlink()
|
|
159
|
+
for events in (self.claude_load(), [self.assistant('proposed-next: run local checks')]):
|
|
160
|
+
self.events(events)
|
|
161
|
+
self.assert_block(self.run_hook())
|
|
162
|
+
self.events([])
|
|
163
|
+
self.assertEqual(self.run_hook(), {})
|
|
164
|
+
|
|
165
|
+
def test_successful_fragment_read_is_visibility_not_completed_owner(self):
|
|
166
|
+
for command, body in [(f'sed -n \'10,12p\' {self.skill}', self.contract),
|
|
167
|
+
(f'printf before; sed -n \'1,20p\' {self.skill}', self.skill.read_text())]:
|
|
168
|
+
with self.subTest(command=command):
|
|
169
|
+
self.events(self.codex_read(command, body))
|
|
170
|
+
self.assert_block(self.run_hook())
|
|
171
|
+
result = subprocess.run(['python3', str(self.hooks / 'host-input.py'),
|
|
172
|
+
'transcript', str(self.path), str(self.root)],
|
|
173
|
+
capture_output=True, text=True, check=True)
|
|
174
|
+
summary = json.loads(result.stdout)
|
|
175
|
+
self.assertEqual(summary['completed_skills'], [])
|
|
176
|
+
self.assertTrue(summary['continuation_contract_visible'])
|
|
177
|
+
|
|
178
|
+
def test_visibility_requires_whole_current_rule_and_terminal_matching_output(self):
|
|
179
|
+
command = f'sed -n \'10,12p\' {self.skill}'
|
|
180
|
+
for label, events in [
|
|
181
|
+
('pending', self.codex_read(command)[:1]),
|
|
182
|
+
('error', self.codex_read(command, self.contract, code=1)),
|
|
183
|
+
('mismatch', self.codex_read(command, self.contract, output_id='unpaired')),
|
|
184
|
+
('truncated', self.codex_read(command, 'Warning: truncated output\n' + self.contract)),
|
|
185
|
+
('partial-rule', self.codex_read(command, self.contract[:120])),
|
|
186
|
+
('strings-only', self.codex_read(command, 'proposed-next: <action and scope>')),
|
|
187
|
+
]:
|
|
188
|
+
with self.subTest(label=label):
|
|
189
|
+
self.events(events)
|
|
190
|
+
self.assertEqual(self.run_hook(), {})
|
|
191
|
+
events = self.codex_read(command)
|
|
192
|
+
events[1]['payload']['output'] = 'Process running with session ID 1\nOutput:\n' + self.contract
|
|
193
|
+
self.events(events)
|
|
194
|
+
self.assertEqual(self.run_hook(), {})
|
|
195
|
+
|
|
196
|
+
def test_claude_successful_read_visibility_is_not_a_skill_load(self):
|
|
197
|
+
request = {'type': 'assistant', 'message': {'content': [{'type': 'tool_use', 'name': 'Read',
|
|
198
|
+
'id': 'read', 'input': {'file_path': str(self.skill)}}]}}
|
|
199
|
+
for error, expected in ((False, 'block'), (True, None)):
|
|
200
|
+
self.events([request, {'type': 'user', 'message': {'content': [{'type': 'tool_result',
|
|
201
|
+
'tool_use_id': 'read', 'is_error': error,
|
|
202
|
+
'content': [{'type': 'text', 'text': self.contract}]}]}}])
|
|
203
|
+
self.assertEqual(self.run_hook().get('decision'), expected)
|
|
204
|
+
|
|
205
|
+
def test_visibility_tracks_canonical_rule_without_copied_matching_prose(self):
|
|
206
|
+
self.contract += ' Synthetic changed obligation.'
|
|
207
|
+
self.skill.write_text('---\nname: product-rd-workflow\n---\n\n' + self.contract + '\n')
|
|
208
|
+
self.events(self.codex_read('sed -n \'1,20p\' skills/product-rd-workflow/SKILL.md', self.contract))
|
|
209
|
+
self.assert_block(self.run_hook())
|
|
210
|
+
|
|
211
|
+
def test_internal_errors_warn_without_leaking_inputs(self):
|
|
212
|
+
self.events(self.claude_load())
|
|
213
|
+
self.payload['last_assistant_message'] = 'Private synthetic payload, never echo this.'
|
|
214
|
+
first = self.run_hook()
|
|
215
|
+
self.payload['last_assistant_message'] = 'Another synthetic payload.'
|
|
216
|
+
self.assertEqual(self.run_hook(), first)
|
|
217
|
+
self.assertNotIn('synthetic', json.dumps(first))
|
|
218
|
+
result = self.run_hook('{invalid secret fixture')
|
|
219
|
+
self.assertIn('unavailable', result.get('systemMessage', ''))
|
|
220
|
+
self.assertNotIn('secret', json.dumps(result))
|
|
221
|
+
(self.hooks / 'host-input.py').unlink()
|
|
222
|
+
self.assertIn('unavailable', self.run_hook().get('systemMessage', ''))
|
|
223
|
+
|
|
224
|
+
def test_scan_is_bounded_and_no_filesystem_markers_are_written(self):
|
|
225
|
+
self.events([{'type': 'ignored'}] * 20000 + self.claude_load())
|
|
226
|
+
before = set(self.root.rglob('*'))
|
|
227
|
+
self.assertIn('unverified', self.run_hook().get('systemMessage', '').lower())
|
|
228
|
+
self.assertEqual(set(self.root.rglob('*')), before)
|
|
229
|
+
self.events([{'type': 'ignored'}] * 20000)
|
|
230
|
+
self.assertEqual(self.run_hook(), {})
|
|
231
|
+
self.events(self.claude_load())
|
|
232
|
+
self.assert_block(self.run_hook())
|
|
233
|
+
self.assert_block(self.run_hook()) # A new host turn must not be suppressed by session id.
|
|
234
|
+
self.assertEqual(set(self.root.rglob('*')), before)
|
|
235
|
+
|
|
236
|
+
def test_oversized_or_malformed_input_fails_soft_without_transcript_echo(self):
|
|
237
|
+
self.path.write_text(json.dumps({'type': 'ignored', 'text': 'x' * (1024 * 1024)}) + '\n')
|
|
238
|
+
self.assertIn('unverified', self.run_hook().get('systemMessage', '').lower())
|
|
239
|
+
oversized = dict(self.payload, last_assistant_message='x' * (2 * 1024 * 1024))
|
|
240
|
+
self.assertIn('unavailable', self.run_hook(oversized).get('systemMessage', ''))
|
|
241
|
+
malformed = self.claude_load()
|
|
242
|
+
malformed[0]['message']['content'][0]['id'] = ['synthetic-private']
|
|
243
|
+
malformed[1]['message']['content'][0]['tool_use_id'] = ['synthetic-private']
|
|
244
|
+
self.events(malformed)
|
|
245
|
+
result = self.run_hook()
|
|
246
|
+
self.assertIn('unavailable', result.get('systemMessage', ''))
|
|
247
|
+
self.assertNotIn('synthetic-private', json.dumps(result))
|
|
248
|
+
|
|
249
|
+
def test_nonregular_transcript_does_not_block_on_a_pipe(self):
|
|
250
|
+
pipe = self.root / 'synthetic-pipe'
|
|
251
|
+
os.mkfifo(pipe)
|
|
252
|
+
payload = dict(self.payload, transcript_path=str(pipe))
|
|
253
|
+
result = subprocess.run(['python3', str(self.hooks / 'host-input.py'), 'proposed-next'],
|
|
254
|
+
input=json.dumps(payload), text=True, capture_output=True, timeout=2)
|
|
255
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
256
|
+
self.assertIn('unavailable', json.loads(result.stdout).get('systemMessage', ''))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == '__main__':
|
|
260
|
+
unittest.main()
|
|
@@ -48,8 +48,9 @@ printf '%s' "$ctx" | grep -q 'app.txt' \
|
|
|
48
48
|
printf '%s' "$ctx" | grep -q 'read the smallest relevant local session/memory slice' \
|
|
49
49
|
&& printf '%s' "$ctx" | grep -q 'Do not ask the user to reconstruct discoverable history' \
|
|
50
50
|
&& ok "capsule makes history recovery controller-owned" || bad "missing autonomous recovery contract"
|
|
51
|
-
printf '%s' "$ctx" |
|
|
52
|
-
|
|
51
|
+
recovery=$(printf '%s' "$ctx" | sed -n '/^<agent-context-recovery /,$p')
|
|
52
|
+
printf '%s' "$recovery" | grep -q 'local_history: repo-attributed-available' \
|
|
53
|
+
&& ! printf '%s' "$recovery" | grep -Eq 'codex_sessions|claude_project_sessions|cursor_sessions|opencode_sessions|\.codex|\.claude|\.cursor' \
|
|
53
54
|
&& ok "capsule aggregates local history without host/tool fingerprint" || bad "capsule leaks per-tool history identity"
|
|
54
55
|
|
|
55
56
|
UNRELATED_HOME="$WORK/unrelated-history-home"
|
|
@@ -89,7 +90,8 @@ printf '%s' "$unknown_ctx" | grep -q 'repo_root: unknown' \
|
|
|
89
90
|
for i in $(seq 1 25); do printf 'dirty\n' > "$REPO/dirty-$i.txt"; done
|
|
90
91
|
many_out=$(printf '{"cwd":"%s"}' "$REPO" | HOME="$HISTORY_HOME" bash "$HOOK")
|
|
91
92
|
many_ctx=$(printf '%s' "$many_out" | jq -r '.hookSpecificOutput.additionalContext // empty')
|
|
92
|
-
printf '%s' "$
|
|
93
|
+
many_recovery=$(printf '{"cwd":"%s"}' "$REPO" | HOME="$HISTORY_HOME" bash "$(dirname "$HOOK")/session-context.sh")
|
|
94
|
+
printf '%s' "$many_recovery" | grep -Eq '\.\.\. \(\+[0-9]+ more; refresh with git status\)' \
|
|
93
95
|
&& ok "dirty-scope truncation is explicit" || bad "dirty-scope truncation is silent"
|
|
94
96
|
|
|
95
97
|
# --- the optional context must never corrupt the mandatory bootstrap ----------
|
|
@@ -166,5 +168,85 @@ printf '{}' | bash "$d/hooks/session-start.sh" >/dev/null 2>"$d/err"
|
|
|
166
168
|
grep -q 'routing layer NOT injected' "$d/err" \
|
|
167
169
|
&& ok "missing bootstrap is diagnosable on stderr" || bad "missing bootstrap failed silently"
|
|
168
170
|
|
|
171
|
+
# Keep the real merged payload inside both hosts' direct-context budgets.
|
|
172
|
+
# Codex estimates tokens from UTF-8 bytes; a byte cap also bounds Claude characters.
|
|
173
|
+
printf '%s' "$many_ctx" | grep -Fq 'proposed-next:' \
|
|
174
|
+
&& printf '%s' "$many_ctx" | grep -Fq 'none — status only' \
|
|
175
|
+
&& ok "delivery handoff cue remains directly visible" \
|
|
176
|
+
|| bad "startup omits the delivery handoff cue"
|
|
177
|
+
ctx_bytes=$(printf '%s' "$many_ctx" | LC_ALL=C wc -c | tr -d ' ')
|
|
178
|
+
[ "$ctx_bytes" -le 9600 ] \
|
|
179
|
+
&& ok "startup with bounded dirty scope fits direct-context budget" \
|
|
180
|
+
|| bad "startup context exceeds 9600 bytes: $ctx_bytes"
|
|
181
|
+
for source in startup clear compact resume fork; do
|
|
182
|
+
matcher=$(jq -r '.hooks.SessionStart[0].matcher' "$(dirname "$HOOK")/hooks.json")
|
|
183
|
+
printf '%s' "$source" | grep -Eq "^($matcher)$" \
|
|
184
|
+
&& ok "SessionStart matches $source" || bad "SessionStart omits $source"
|
|
185
|
+
done
|
|
186
|
+
|
|
187
|
+
policy="$(cd "$(dirname "$HOOK")/.." && pwd)/agent-context/session-policy.md"
|
|
188
|
+
printf '%s' "$ctx" | grep -Fq "](<$policy>)" \
|
|
189
|
+
&& [ -r "$policy" ] \
|
|
190
|
+
&& ok "deferred policy has a readable absolute plugin path" || bad "deferred policy pointer cannot resolve from a product cwd"
|
|
191
|
+
|
|
192
|
+
r=$(fake_root '#!/usr/bin/env bash
|
|
193
|
+
printf "<agent-context-recovery priority=\"high\">\n"
|
|
194
|
+
printf "%020000d\n" 0
|
|
195
|
+
printf "</agent-context-recovery>\n"')
|
|
196
|
+
c=$(ctx_of "$r")
|
|
197
|
+
[ "$(printf '%s' "$c" | LC_ALL=C wc -c | tr -d ' ')" -le 9600 ] \
|
|
198
|
+
&& printf '%s' "$c" | grep -q '</ccl-skills-routing>' \
|
|
199
|
+
&& printf '%s' "$c" | grep -q 'refresh live Git' \
|
|
200
|
+
&& [ "$(tag_balance "$r")" = "1 1" ] \
|
|
201
|
+
&& ok "oversized recovery is replaced whole while mandatory rules stay intact" \
|
|
202
|
+
|| bad "oversized recovery spills or truncates the startup rules"
|
|
203
|
+
|
|
204
|
+
# Mandatory content cannot be truncated to satisfy a host budget. Diagnose that
|
|
205
|
+
# distinct limit without making it worse by adding an optional replacement frame.
|
|
206
|
+
r=$(fake_root '#!/usr/bin/env bash
|
|
207
|
+
exit 0')
|
|
208
|
+
printf '%010000d' 0 > "$r/agent-context/session-start.md"
|
|
209
|
+
c=$(ctx_of "$r")
|
|
210
|
+
[ "$c" = "$(cat "$r/agent-context/session-start.md")" ] \
|
|
211
|
+
&& grep -q 'mandatory bootstrap exceeds direct-context budget' "$r/err" \
|
|
212
|
+
&& ! grep -q 'recovery context exceeds' "$r/err" \
|
|
213
|
+
&& ok "oversized mandatory bootstrap stays whole with an accurate degradation diagnostic" \
|
|
214
|
+
|| bad "oversized mandatory bootstrap is altered or misdiagnosed as recovery overflow"
|
|
215
|
+
|
|
216
|
+
# A source file can fit while its installed policy link pushes the rendered
|
|
217
|
+
# bootstrap over budget. Use a synthetic long plugin root, not a host path.
|
|
218
|
+
long_root="$r/$(printf '%0180d' 0)"
|
|
219
|
+
mkdir -p "$long_root"
|
|
220
|
+
mv "$r/hooks" "$r/agent-context" "$long_root/"
|
|
221
|
+
r="$long_root"
|
|
222
|
+
printf '%09450d\n[Policy](session-policy.md)' 0 > "$r/agent-context/session-start.md"
|
|
223
|
+
c=$(ctx_of "$r")
|
|
224
|
+
expected=$(printf '%09450d\n[Policy](<%s/agent-context/session-policy.md>)' 0 "$r")
|
|
225
|
+
[ "$(LC_ALL=C wc -c < "$r/agent-context/session-start.md" | tr -d ' ')" -le 9600 ] \
|
|
226
|
+
&& [ "$(printf '%s' "$c" | LC_ALL=C wc -c | tr -d ' ')" -gt 9600 ] \
|
|
227
|
+
&& [ "$c" = "$expected" ] \
|
|
228
|
+
&& grep -q 'mandatory bootstrap exceeds direct-context budget' "$r/err" \
|
|
229
|
+
&& ok "policy path expansion is included in mandatory bootstrap overflow detection" \
|
|
230
|
+
|| bad "expanded mandatory policy path overflow is silent or truncated"
|
|
231
|
+
|
|
232
|
+
r=$(fake_root '#!/usr/bin/env bash
|
|
233
|
+
printf "<agent-context-recovery priority=\"high\">\n"
|
|
234
|
+
printf "%020000d\n" 0
|
|
235
|
+
printf "</agent-context-recovery>\n"')
|
|
236
|
+
printf '%09500d' 0 > "$r/agent-context/session-start.md"
|
|
237
|
+
c=$(ctx_of "$r")
|
|
238
|
+
[ "$c" = "$(cat "$r/agent-context/session-start.md")" ] \
|
|
239
|
+
&& grep -q 'recovery context omitted entirely' "$r/err" \
|
|
240
|
+
&& ok "near-limit bootstrap omits a replacement capsule that would still exceed budget" \
|
|
241
|
+
|| bad "replacement recovery capsule pushes a near-limit bootstrap over budget"
|
|
242
|
+
|
|
243
|
+
r=$(fake_root '#!/usr/bin/env bash
|
|
244
|
+
exit 0')
|
|
245
|
+
printf '%09600d' 0 > "$r/agent-context/session-start.md"
|
|
246
|
+
c=$(ctx_of "$r")
|
|
247
|
+
[ "$c" = "$(cat "$r/agent-context/session-start.md")" ] && [ ! -s "$r/err" ] \
|
|
248
|
+
&& ok "exact-budget bootstrap with no recovery does not add a phantom separator or frame" \
|
|
249
|
+
|| bad "exact-budget bootstrap is incorrectly treated as overflow"
|
|
250
|
+
|
|
169
251
|
printf '%s\n' "---" "PASS=$PASS FAIL=$FAIL"
|
|
170
252
|
[ "$FAIL" -eq 0 ]
|