@marcomigozzi/trailhead 0.11.0 → 0.12.0
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.it.md +6 -3
- package/README.md +6 -3
- package/bin/lib/codex-projection.js +2 -1
- package/bin/lib/codex-projection.test.js +4 -1
- package/bin/trailhead.js +27 -15
- package/bin/trailhead.test.js +417 -2
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/adopt.md +5 -1
- package/plugins/trailhead/commands/audit.md +10 -0
- package/plugins/trailhead/commands/auto.md +5 -1
- package/plugins/trailhead/commands/bug.md +5 -1
- package/plugins/trailhead/commands/config.md +5 -1
- package/plugins/trailhead/commands/dashboard.md +5 -1
- package/plugins/trailhead/commands/grill.md +5 -1
- package/plugins/trailhead/commands/idea.md +5 -1
- package/plugins/trailhead/commands/inbox.md +5 -1
- package/plugins/trailhead/commands/map.md +5 -1
- package/plugins/trailhead/commands/new.md +5 -1
- package/plugins/trailhead/commands/note.md +5 -1
- package/plugins/trailhead/commands/pause.md +5 -1
- package/plugins/trailhead/commands/prune.md +5 -1
- package/plugins/trailhead/commands/quick.md +6 -2
- package/plugins/trailhead/commands/resume.md +5 -1
- package/plugins/trailhead/commands/seed.md +5 -1
- package/plugins/trailhead/commands/split.md +5 -1
- package/plugins/trailhead/commands/ticket.md +5 -1
- package/plugins/trailhead/commands/todo.md +5 -1
- package/plugins/trailhead/commands/update.md +5 -1
- package/plugins/trailhead/commands/whiteboard.md +5 -1
- package/plugins/trailhead/commands/work.md +5 -1
- package/plugins/trailhead/hooks/hooks.json +9 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +802 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +50 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +399 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +489 -0
- package/plugins/trailhead/hooks/lib/model-defaults.json +16 -0
- package/plugins/trailhead/hooks/trailhead-check-update.js +69 -14
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +95 -0
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +145 -0
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.js +38 -0
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +64 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +151 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +56 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +7 -4
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/principles.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +5 -5
- package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +132 -0
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +1 -1
- package/plugins/trailhead/templates/trailhead-audit-walk.js +685 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trailhead-mockup-link-guard.js: PreToolUse(AskUserQuestion) hook.
|
|
3
|
+
// Structural backstop for the Prototype technique's "surface the mockup" rule
|
|
4
|
+
// (_shared/techniques/prototype.md): a message that asks to approve, react to,
|
|
5
|
+
// or pick between mockups must carry each mockup's clickable link in its
|
|
6
|
+
// plain prose, right before the question. The prose rule alone was skipped in
|
|
7
|
+
// practice (an approval asked with only the project's name, no URL), so this
|
|
8
|
+
// hook blocks an AskUserQuestion that is a mockup-approval ask when the
|
|
9
|
+
// assistant prose immediately before it (no tool call in between) holds no
|
|
10
|
+
// URL or file path.
|
|
11
|
+
// Blocks with exit 2 + {"decision":"block",...}; allows everything else.
|
|
12
|
+
// Fail-OPEN on any error (exit 0): a missing/unreadable transcript or an
|
|
13
|
+
// unexpected shape must never wedge the workflow.
|
|
14
|
+
// Claude-Code-specific (Codex has no AskUserQuestion tool; its twin there is
|
|
15
|
+
// trailhead-mockup-link-stop.js). Detection lives in lib/mockup-link.js.
|
|
16
|
+
// Pure functions are exported for tests; runs as a hook when executed directly.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const { hasLink, isMockupApprovalAsk, BLOCK_REASON } = require('./lib/mockup-link.js');
|
|
20
|
+
|
|
21
|
+
// Only the transcript tail matters: the prose sits right before the ask.
|
|
22
|
+
const TAIL_BYTES = 512 * 1024;
|
|
23
|
+
|
|
24
|
+
const blocksOf = (e) => (Array.isArray(e.message && e.message.content) ? e.message.content : []);
|
|
25
|
+
const isPendingAsk = (e) => e.type === 'assistant'
|
|
26
|
+
&& blocksOf(e).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion');
|
|
27
|
+
|
|
28
|
+
// The assistant prose right before the ask: walk the main-chain entries back
|
|
29
|
+
// from the end, skip the pending AskUserQuestion entries, and collect text
|
|
30
|
+
// blocks until anything else (a user turn, a tool result, another tool call).
|
|
31
|
+
function proseBeforeAsk(entries) {
|
|
32
|
+
const main = entries.filter((e) => e && (e.type === 'assistant' || e.type === 'user') && !e.isSidechain);
|
|
33
|
+
let i = main.length - 1;
|
|
34
|
+
while (i >= 0 && isPendingAsk(main[i])) i--;
|
|
35
|
+
const texts = [];
|
|
36
|
+
for (; i >= 0; i--) {
|
|
37
|
+
const e = main[i];
|
|
38
|
+
if (e.type !== 'assistant') break;
|
|
39
|
+
const blocks = blocksOf(e);
|
|
40
|
+
if (blocks.some((b) => b.type === 'tool_use')) break;
|
|
41
|
+
blocks.filter((b) => b.type === 'text').forEach((b) => texts.unshift(b.text || ''));
|
|
42
|
+
}
|
|
43
|
+
return texts.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readTail(file) {
|
|
47
|
+
const fd = fs.openSync(file, 'r');
|
|
48
|
+
try {
|
|
49
|
+
const size = fs.fstatSync(fd).size;
|
|
50
|
+
const len = Math.min(size, TAIL_BYTES);
|
|
51
|
+
const buf = Buffer.alloc(len);
|
|
52
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
53
|
+
return buf.toString('utf8');
|
|
54
|
+
} finally {
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Parse JSONL leniently: the first line of a tail read may be cut mid-record.
|
|
60
|
+
function parseEntries(text) {
|
|
61
|
+
return text.split('\n').flatMap((l) => {
|
|
62
|
+
try { return l.trim() ? [JSON.parse(l)] : []; } catch { return []; }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function block() {
|
|
67
|
+
process.stdout.write(JSON.stringify({ decision: 'block', code: 'MOCKUP_LINK_MISSING', reason: BLOCK_REASON }));
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function run(data) {
|
|
72
|
+
try {
|
|
73
|
+
const payload = JSON.parse(data);
|
|
74
|
+
if (payload.tool_name && payload.tool_name !== 'AskUserQuestion') process.exit(0);
|
|
75
|
+
if (!isMockupApprovalAsk(payload.tool_input)) process.exit(0);
|
|
76
|
+
if (!payload.transcript_path) process.exit(0);
|
|
77
|
+
const prose = proseBeforeAsk(parseEntries(readTail(payload.transcript_path)));
|
|
78
|
+
if (!hasLink(prose)) block();
|
|
79
|
+
} catch {
|
|
80
|
+
// fall through to allow: a guard must never wedge the workflow
|
|
81
|
+
}
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (require.main === module) {
|
|
86
|
+
let data = '';
|
|
87
|
+
const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
|
|
88
|
+
process.stdin.on('data', (c) => (data += c));
|
|
89
|
+
process.stdin.on('end', () => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
run(data);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { isMockupApprovalAsk, hasLink, proseBeforeAsk };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for trailhead-mockup-link-guard.js. Run: node trailhead-mockup-link-guard.test.js
|
|
3
|
+
// No framework: plain asserts + child_process for the end-to-end hook behaviour.
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
9
|
+
const {
|
|
10
|
+
isMockupApprovalAsk,
|
|
11
|
+
hasLink,
|
|
12
|
+
proseBeforeAsk,
|
|
13
|
+
} = require('./trailhead-mockup-link-guard.js');
|
|
14
|
+
|
|
15
|
+
const HOOK = path.join(__dirname, 'trailhead-mockup-link-guard.js');
|
|
16
|
+
|
|
17
|
+
let passed = 0;
|
|
18
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
19
|
+
|
|
20
|
+
// Costruttori di righe transcript (stesso shape JSONL di Claude Code)
|
|
21
|
+
const text = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }] } });
|
|
22
|
+
const thinking = () => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'thinking', thinking: '' }] } });
|
|
23
|
+
const toolUse = (name, input = {}) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id: 'x', name, input }] } });
|
|
24
|
+
const toolResult = (c) => ({ type: 'user', isSidechain: false, message: { content: [{ type: 'tool_result', tool_use_id: 'x', content: c }] } });
|
|
25
|
+
const userPrompt = (c) => ({ type: 'user', isSidechain: false, message: { content: c } });
|
|
26
|
+
|
|
27
|
+
const approvalInput = {
|
|
28
|
+
questions: [{
|
|
29
|
+
question: 'Approvi il mockup della pagina FAQ?',
|
|
30
|
+
header: 'Mockup',
|
|
31
|
+
multiSelect: false,
|
|
32
|
+
options: [{ label: 'Approvo', description: 'Procedi col codice UI' }, { label: 'Chiedo modifiche', description: '' }],
|
|
33
|
+
}],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// --- isMockupApprovalAsk ---
|
|
37
|
+
ok('IT mockup approval ask is detected', isMockupApprovalAsk(approvalInput));
|
|
38
|
+
ok('EN mockup approval ask is detected', isMockupApprovalAsk({
|
|
39
|
+
questions: [{ question: 'Do you approve the login mockup?', options: [{ label: 'Approve' }, { label: 'Request changes' }] }],
|
|
40
|
+
}));
|
|
41
|
+
ok('pick between prototype variants is detected', isMockupApprovalAsk({
|
|
42
|
+
questions: [{ question: 'Quale variante del prototipo scegli?', options: [{ label: 'Variante A' }, { label: 'Variante B' }] }],
|
|
43
|
+
}));
|
|
44
|
+
ok('"want mockups first?" offer is NOT an approval ask', !isMockupApprovalAsk({
|
|
45
|
+
questions: [{ question: 'Vuoi prima un mockup della schermata?', options: [{ label: 'Sì' }, { label: 'No' }] }],
|
|
46
|
+
}));
|
|
47
|
+
// Regressione da una sessione reale: l'offerta di un mockup cita una
|
|
48
|
+
// approvazione futura solo nella descrizione dell'opzione
|
|
49
|
+
ok('mockup offer whose option description mentions approval is NOT an approval ask', !isMockupApprovalAsk({
|
|
50
|
+
questions: [{
|
|
51
|
+
question: 'Vuoi cambiare qualcosa nello scope della pagina FAQ?',
|
|
52
|
+
header: 'Scope',
|
|
53
|
+
options: [
|
|
54
|
+
{ label: 'Va bene così', description: 'Procedo con lo scope descritto' },
|
|
55
|
+
{ label: 'Prima un mockup', description: 'Disegno la pagina su claude.ai/design e attendo approvazione prima di scriverla' },
|
|
56
|
+
],
|
|
57
|
+
}],
|
|
58
|
+
}));
|
|
59
|
+
ok('mockup and approval words in different questions do not combine', !isMockupApprovalAsk({
|
|
60
|
+
questions: [
|
|
61
|
+
{ question: 'Vuoi prima un mockup?', options: [{ label: 'Sì' }] },
|
|
62
|
+
{ question: 'Approvi il piano?', options: [{ label: 'Sì' }] },
|
|
63
|
+
],
|
|
64
|
+
}));
|
|
65
|
+
ok('unrelated ask is not detected', !isMockupApprovalAsk({
|
|
66
|
+
questions: [{ question: 'Approvi il piano?', options: [{ label: 'Sì' }, { label: 'No' }] }],
|
|
67
|
+
}));
|
|
68
|
+
ok('malformed input is not detected', !isMockupApprovalAsk({}) && !isMockupApprovalAsk(null));
|
|
69
|
+
|
|
70
|
+
// --- hasLink ---
|
|
71
|
+
ok('https URL counts as a link', hasLink('Ecco: https://claude.ai/design/p/abc?file=faq.html'));
|
|
72
|
+
ok('absolute html path counts as a link', hasLink('Mockup in /home/u/app/mockups/faq.html'));
|
|
73
|
+
ok('relative html path counts as a link', hasLink('apri `mockups/faq.html`'));
|
|
74
|
+
ok('markdown link to a file counts', hasLink('[faq](mockups/faq.html)'));
|
|
75
|
+
ok('project name alone is NOT a link', !hasLink('Il mockup della pagina FAQ è pronto su MyApp, gruppo Marketing.'));
|
|
76
|
+
ok('empty prose has no link', !hasLink(''));
|
|
77
|
+
|
|
78
|
+
// --- proseBeforeAsk ---
|
|
79
|
+
const failing = [
|
|
80
|
+
userPrompt('lavora #12'),
|
|
81
|
+
toolUse('Bash'),
|
|
82
|
+
toolResult('ok'),
|
|
83
|
+
thinking(),
|
|
84
|
+
text('Il mockup della pagina FAQ è pronto su MyApp, con indice sezioni.'),
|
|
85
|
+
toolUse('AskUserQuestion', approvalInput),
|
|
86
|
+
];
|
|
87
|
+
ok('prose right before the ask is collected', /pronto su MyApp/.test(proseBeforeAsk(failing)));
|
|
88
|
+
|
|
89
|
+
const linkBeforeTools = [
|
|
90
|
+
userPrompt('lavora #12'),
|
|
91
|
+
text('Mockup: https://claude.ai/design/p/abc'),
|
|
92
|
+
toolUse('Bash'),
|
|
93
|
+
toolResult('ok'),
|
|
94
|
+
text('Pronto.'),
|
|
95
|
+
toolUse('AskUserQuestion', approvalInput),
|
|
96
|
+
];
|
|
97
|
+
ok('a link before an intervening tool call does not count', !hasLink(proseBeforeAsk(linkBeforeTools)));
|
|
98
|
+
|
|
99
|
+
const sidechain = [
|
|
100
|
+
text('Mockup FAQ pronto.'),
|
|
101
|
+
{ ...text('https://example.com/side'), isSidechain: true },
|
|
102
|
+
toolUse('AskUserQuestion', approvalInput),
|
|
103
|
+
];
|
|
104
|
+
ok('sidechain text is ignored', !hasLink(proseBeforeAsk(sidechain)));
|
|
105
|
+
|
|
106
|
+
const noAskYet = [userPrompt('x'), text('Ecco https://a.b/c')];
|
|
107
|
+
ok('works when the pending tool_use is not yet in the transcript', hasLink(proseBeforeAsk(noAskYet)));
|
|
108
|
+
|
|
109
|
+
// --- end-to-end ---
|
|
110
|
+
function writeTranscript(entries) {
|
|
111
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-mlg-'));
|
|
112
|
+
const f = path.join(dir, 't.jsonl');
|
|
113
|
+
fs.writeFileSync(f, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
114
|
+
return f;
|
|
115
|
+
}
|
|
116
|
+
function runHook(payload) {
|
|
117
|
+
try {
|
|
118
|
+
const out = execFileSync('node', [HOOK], { input: JSON.stringify(payload) });
|
|
119
|
+
return { code: 0, out: out.toString() };
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { code: e.status, out: (e.stdout || '').toString() };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const r1 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(failing) });
|
|
126
|
+
ok('approval ask without a link in the prose is blocked (exit 2)', r1.code === 2 && /MOCKUP_LINK_MISSING/.test(r1.out));
|
|
127
|
+
|
|
128
|
+
const withLink = [...failing.slice(0, 4), text('Mockup: https://claude.ai/design/p/abc?file=faq.html'), failing[5]];
|
|
129
|
+
const r2 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(withLink) });
|
|
130
|
+
ok('approval ask with a link in the prose is allowed', r2.code === 0 && r2.out.trim() === '');
|
|
131
|
+
|
|
132
|
+
const r3 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: '/nonexistent/t.jsonl' });
|
|
133
|
+
ok('missing transcript fails open', r3.code === 0);
|
|
134
|
+
|
|
135
|
+
const r4 = runHook({ tool_name: 'Bash', tool_input: { command: 'ls' }, transcript_path: writeTranscript(failing) });
|
|
136
|
+
ok('other tools are ignored', r4.code === 0);
|
|
137
|
+
|
|
138
|
+
const r5 = runHook({ tool_name: 'AskUserQuestion', tool_input: { questions: [{ question: 'Approvi il piano?', options: [] }] }, transcript_path: writeTranscript(failing) });
|
|
139
|
+
ok('non-mockup ask is allowed', r5.code === 0);
|
|
140
|
+
|
|
141
|
+
let r6;
|
|
142
|
+
try { execFileSync('node', [HOOK], { input: 'not json' }); r6 = 0; } catch (e) { r6 = e.status; }
|
|
143
|
+
ok('unparseable input fails open', r6 === 0);
|
|
144
|
+
|
|
145
|
+
console.log(`trailhead-mockup-link-guard: ${passed} passed`);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trailhead-mockup-link-stop.js: Stop hook (Codex).
|
|
3
|
+
// Codex twin of trailhead-mockup-link-guard.js. On Codex trailhead asks its
|
|
4
|
+
// questions as a plain-text numbered list (request_user_input is Plan-Mode
|
|
5
|
+
// only), so there is no tool call to gate: this hook reads the final reply
|
|
6
|
+
// (`last_assistant_message`) instead. When the reply asks to approve a mockup
|
|
7
|
+
// (or pick between mockup variants) and carries no URL or file path, it
|
|
8
|
+
// answers {"decision":"block","reason":...}: on Stop, Codex does not reject
|
|
9
|
+
// the turn but continues it, with the reason as the next prompt, so the agent
|
|
10
|
+
// rewrites the ask with the link.
|
|
11
|
+
// Never blocks a turn that was already continued (`stop_hook_active`), so it
|
|
12
|
+
// cannot loop. Fail-OPEN on any error (exit 0, no output).
|
|
13
|
+
|
|
14
|
+
const { hasLink, isMockupApprovalMessage, BLOCK_REASON } = require('./lib/mockup-link.js');
|
|
15
|
+
|
|
16
|
+
function run(data) {
|
|
17
|
+
try {
|
|
18
|
+
const payload = JSON.parse(data);
|
|
19
|
+
if (payload.stop_hook_active) process.exit(0);
|
|
20
|
+
const msg = payload.last_assistant_message;
|
|
21
|
+
if (isMockupApprovalMessage(msg) && !hasLink(msg)) {
|
|
22
|
+
process.stdout.write(JSON.stringify({ decision: 'block', reason: BLOCK_REASON }));
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// fall through to allow: a guard must never wedge the workflow
|
|
26
|
+
}
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (require.main === module) {
|
|
31
|
+
let data = '';
|
|
32
|
+
const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
|
|
33
|
+
process.stdin.on('data', (c) => (data += c));
|
|
34
|
+
process.stdin.on('end', () => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
run(data);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for trailhead-mockup-link-stop.js (+ lib/mockup-link.js message detection).
|
|
3
|
+
// Run: node trailhead-mockup-link-stop.test.js
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const { isMockupApprovalMessage } = require('./lib/mockup-link.js');
|
|
8
|
+
|
|
9
|
+
const HOOK = path.join(__dirname, 'trailhead-mockup-link-stop.js');
|
|
10
|
+
|
|
11
|
+
let passed = 0;
|
|
12
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
13
|
+
|
|
14
|
+
// Risposta Codex: la domanda è un elenco numerato in testo semplice
|
|
15
|
+
const noLink = [
|
|
16
|
+
'Il mockup della pagina FAQ è pronto su MyApp, con indice sezioni.',
|
|
17
|
+
'',
|
|
18
|
+
'Approvi il mockup della pagina FAQ?',
|
|
19
|
+
'1. Approvo',
|
|
20
|
+
'2. Chiedo modifiche',
|
|
21
|
+
].join('\n');
|
|
22
|
+
const withLink = noLink.replace('pronto su MyApp', 'pronto: https://claude.ai/design/p/abc?file=faq.html');
|
|
23
|
+
|
|
24
|
+
// --- isMockupApprovalMessage ---
|
|
25
|
+
ok('IT approval reply is detected', isMockupApprovalMessage(noLink));
|
|
26
|
+
ok('EN approval reply is detected', isMockupApprovalMessage('Mockup ready.\n\nDo you approve the login mockup?\n1. Yes\n2. No'));
|
|
27
|
+
ok('variant pick is detected', isMockupApprovalMessage('Due prototipi pronti.\nQuale variante scegli?\n1. A\n2. B'));
|
|
28
|
+
ok('past approval mention is NOT an ask', !isMockupApprovalMessage('Ho implementato il mockup approvato. Commit fatto.'));
|
|
29
|
+
ok('approval word without a question line is NOT an ask', !isMockupApprovalMessage('Approvi il mockup quando vuoi.'));
|
|
30
|
+
ok('"want mockups first?" offer is NOT an ask', !isMockupApprovalMessage('Vuoi prima un mockup?\n1. Sì\n2. No'));
|
|
31
|
+
ok('non-string is not detected', !isMockupApprovalMessage(undefined));
|
|
32
|
+
|
|
33
|
+
// --- end-to-end ---
|
|
34
|
+
function runHook(payload) {
|
|
35
|
+
try {
|
|
36
|
+
const out = execFileSync('node', [HOOK], { input: typeof payload === 'string' ? payload : JSON.stringify(payload) });
|
|
37
|
+
return { code: 0, out: out.toString() };
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return { code: e.status, out: (e.stdout || '').toString() };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const parse = (s) => { try { return JSON.parse(s); } catch { return null; } };
|
|
43
|
+
|
|
44
|
+
const r1 = runHook({ hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: noLink });
|
|
45
|
+
const j1 = parse(r1.out);
|
|
46
|
+
ok('approval reply without a link: continues the turn with a block decision (exit 0, JSON)',
|
|
47
|
+
r1.code === 0 && j1 && j1.decision === 'block' && /mockup-link/.test(j1.reason));
|
|
48
|
+
|
|
49
|
+
const r2 = runHook({ hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: withLink });
|
|
50
|
+
ok('approval reply with a link is allowed', r2.code === 0 && r2.out.trim() === '');
|
|
51
|
+
|
|
52
|
+
const r3 = runHook({ hook_event_name: 'Stop', stop_hook_active: true, last_assistant_message: noLink });
|
|
53
|
+
ok('already-continued turn is never blocked again (no loop)', r3.code === 0 && r3.out.trim() === '');
|
|
54
|
+
|
|
55
|
+
const r4 = runHook({ hook_event_name: 'Stop', stop_hook_active: false });
|
|
56
|
+
ok('missing last_assistant_message fails open', r4.code === 0 && r4.out.trim() === '');
|
|
57
|
+
|
|
58
|
+
const r5 = runHook({ hook_event_name: 'Stop', last_assistant_message: 'Fatto, commit pushato.' });
|
|
59
|
+
ok('ordinary reply is allowed', r5.code === 0 && r5.out.trim() === '');
|
|
60
|
+
|
|
61
|
+
const r6 = runHook('not json');
|
|
62
|
+
ok('unparseable input fails open', r6.code === 0 && r6.out.trim() === '');
|
|
63
|
+
|
|
64
|
+
console.log(`trailhead-mockup-link-stop: ${passed} passed`);
|
|
@@ -49,6 +49,17 @@
|
|
|
49
49
|
// that flag's value is exempted, and only for docker/podman verbs; see
|
|
50
50
|
// ENV_FILE_EXEMPT_VERBS below.
|
|
51
51
|
//
|
|
52
|
+
// Name-only operand positions: `git check-ignore` / `git ls-files` /
|
|
53
|
+
// `git rm --cached` pathspecs and the destination of a one-source `cp`/`mv`
|
|
54
|
+
// are names, never contents (the checks that keep a secret OUT of git, and
|
|
55
|
+
// `cp .env.example .env`). Only those positions are exempt, behind closed
|
|
56
|
+
// option sets that fail closed; see GIT_PATHSPEC_SUBCOMMANDS and
|
|
57
|
+
// COPY_NAME_ONLY_FLAGS. Templates (`.env.example`, `.env.*.sample`,
|
|
58
|
+
// `.template`, `.dist`) are not secrets: see NON_SECRET_ENV_SUFFIXES.
|
|
59
|
+
// Known gap (write class, not read): the cp/mv destination exemption can move
|
|
60
|
+
// a link prepared under a non-secret name onto `.env`
|
|
61
|
+
// (`ln -s .env.example l && mv l .env`).
|
|
62
|
+
//
|
|
52
63
|
// Deliberately best-effort on the Bash leg, like the sibling guards: not a
|
|
53
64
|
// full shell parser. It does not care about `cd` shape at all (that is
|
|
54
65
|
// trailhead-search-guard.js's job) — it only asks "does any token here
|
|
@@ -73,22 +84,33 @@ const {
|
|
|
73
84
|
|
|
74
85
|
// --- path helpers ------------------------------------------------------------
|
|
75
86
|
|
|
87
|
+
// Suffissi finali di `.env.<...>` che indicano un template committato e
|
|
88
|
+
// privo di segreti (`.env.example`, `.env.local.sample`, ...), non un segreto.
|
|
89
|
+
// Conta solo l'ULTIMO segmento: `.env.example.bak` resta un segreto.
|
|
90
|
+
const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist']);
|
|
91
|
+
|
|
76
92
|
// A basename is a "secret pattern" when it is exactly `.env`, exactly
|
|
77
93
|
// `.secrets`, or `.env.<something>` (e.g. `.env.local`, `.env.production`),
|
|
78
94
|
// matched case-insensitively (`.ENV`/`.Secrets` are the same file on a
|
|
79
95
|
// case-insensitive filesystem). Near-misses like `env.sample` (no leading
|
|
80
|
-
// dot) or `.environment` (no dot after `.env`) must NOT match
|
|
96
|
+
// dot) or `.environment` (no dot after `.env`) must NOT match, and neither
|
|
97
|
+
// must a template whose last segment is in NON_SECRET_ENV_SUFFIXES.
|
|
81
98
|
function isSecretBasename(name) {
|
|
82
99
|
if (!name) return false;
|
|
83
100
|
const lower = String(name).toLowerCase();
|
|
84
101
|
if (lower === '.env' || lower === '.secrets') return true;
|
|
85
|
-
|
|
102
|
+
if (!/^\.env\..+$/.test(lower)) return false;
|
|
103
|
+
return !NON_SECRET_ENV_SUFFIXES.has(lower.slice(lower.lastIndexOf('.') + 1));
|
|
86
104
|
}
|
|
87
105
|
|
|
106
|
+
// Anche la forma `<rev>:<path>` (`git show HEAD:.env`, `scp host:.env .`):
|
|
107
|
+
// il path dopo il primo `:` è letto quanto un operando nudo.
|
|
88
108
|
function isSecretPathArg(t) {
|
|
89
109
|
const bare = stripQuotes(t);
|
|
90
110
|
if (!bare) return false;
|
|
91
|
-
|
|
111
|
+
if (isSecretBasename(basename(bare))) return true;
|
|
112
|
+
const colon = bare.indexOf(':');
|
|
113
|
+
return colon !== -1 && isSecretBasename(basename(bare.slice(colon + 1)));
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
// --- Bash leg: quote-aware, statement-based scan -----------------------------
|
|
@@ -110,6 +132,56 @@ const VALUE_FLAGS = new Set(['-f', '--file']);
|
|
|
110
132
|
const ENV_FILE_EXEMPT_VERBS = new Set(['docker', 'podman', 'docker-compose', 'podman-compose']);
|
|
111
133
|
const ENV_FILE_EXEMPT_FLAGS = new Set(['--env-file']);
|
|
112
134
|
const NO_EXEMPT_FLAGS = new Set();
|
|
135
|
+
const NO_SKIP = new Set();
|
|
136
|
+
|
|
137
|
+
// Sottocomandi git che riportano o rimuovono lo stato ignore/tracking di un
|
|
138
|
+
// path senza mai stamparne il contenuto: i loro pathspec sono NOMI. Ognuno ha
|
|
139
|
+
// il set CHIUSO delle sue opzioni senza valore (`git <sub> -h`, git 2.49):
|
|
140
|
+
// qualsiasi altra opzione ritira l'esenzione dall'intero statement (fail
|
|
141
|
+
// closed), così il valore di un'opzione non è mai scambiato per un pathspec
|
|
142
|
+
// (`-X <file>` legge il file; `--pathspec-from-file=<file>` ne stampa le
|
|
143
|
+
// righe nell'errore "did not match"). `requires` = opzione senza la quale il
|
|
144
|
+
// sottocomando resta controllato (`git rm` solo con `--cached`).
|
|
145
|
+
const GIT_PATHSPEC_SUBCOMMANDS = new Map([
|
|
146
|
+
['check-ignore', {
|
|
147
|
+
flags: new Set(['-q', '--quiet', '-v', '--verbose', '--stdin', '-z', '-n', '--non-matching', '--no-index', '--index']),
|
|
148
|
+
}],
|
|
149
|
+
['ls-files', {
|
|
150
|
+
flags: new Set([
|
|
151
|
+
'-z', '-t', '-v', '-f', '-c', '--cached', '-d', '--deleted', '-m', '--modified', '-o', '--others',
|
|
152
|
+
'-i', '--ignored', '-s', '--stage', '-k', '--killed', '-u', '--unmerged', '--directory', '--eol',
|
|
153
|
+
'--no-empty-directory', '--resolve-undo', '--exclude-standard', '--full-name',
|
|
154
|
+
'--recurse-submodules', '--error-unmatch', '--abbrev', '--debug', '--deduplicate', '--sparse',
|
|
155
|
+
]),
|
|
156
|
+
}],
|
|
157
|
+
['rm', {
|
|
158
|
+
flags: new Set(['--cached', '-f', '--force', '-n', '--dry-run', '-q', '--quiet', '-r', '--ignore-unmatch', '--sparse']),
|
|
159
|
+
requires: '--cached',
|
|
160
|
+
}],
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
// Opzioni globali di git (usage line, git 2.49), per trovare il sottocomando.
|
|
164
|
+
// Un'opzione con valore consuma la parola successiva salvo forma
|
|
165
|
+
// `--opt=value`; un'opzione in nessuno dei due set fa fail closed, così un
|
|
166
|
+
// valore non è mai letto come sottocomando (`git -C ls-files show HEAD:.env`
|
|
167
|
+
// esegue `show`).
|
|
168
|
+
const GIT_GLOBAL_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env']);
|
|
169
|
+
const GIT_GLOBAL_FLAGS = new Set([
|
|
170
|
+
'-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--no-lazy-fetch',
|
|
171
|
+
'--no-optional-locks', '--no-advice', '--bare',
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
// `cp`/`mv` scrivono la destinazione e non la stampano: un nome segreto che è
|
|
175
|
+
// SOLO la destinazione di una copia/spostamento a sorgente singola è un nome
|
|
176
|
+
// (`cp .env.example .env`). Vale per esattamente due operandi preceduti da
|
|
177
|
+
// opzioni di questo set chiuso; tutto il resto lascia lo statement
|
|
178
|
+
// controllato: `-t` (ogni operando diventa sorgente), backup (`-b`,
|
|
179
|
+
// `--backup`, `-S`: il vecchio segreto sopravvive come `.env~`), link (`-l`,
|
|
180
|
+
// `-s`) o `--exchange` (la destinazione condivide o scambia il segreto). Le
|
|
181
|
+
// opzioni devono precedere gli operandi: una in coda è opzione con la
|
|
182
|
+
// permutazione GNU ma operando con POSIXLY_CORRECT, quindi quale parola sia
|
|
183
|
+
// la destinazione dipenderebbe da un ambiente che l'hook non vede.
|
|
184
|
+
const COPY_NAME_ONLY_FLAGS = new Set(['-f', '--force', '-i', '--interactive', '-n', '--no-clobber', '-v', '--verbose']);
|
|
113
185
|
|
|
114
186
|
// The pattern-first search verbs (grep/rg/ag/sed/awk), shared with
|
|
115
187
|
// trailhead-search-guard.js. Their first positional is a PATTERN, not a file
|
|
@@ -139,9 +211,11 @@ function stripTrailingPunct(t) {
|
|
|
139
211
|
// redirect target. Returns the offending token or null. `exemptFlags` (#180)
|
|
140
212
|
// names flags whose value must be skipped entirely rather than scanned, in
|
|
141
213
|
// both the `--flag=value` and spaced `--flag value` forms.
|
|
142
|
-
function scanTokensForSecretPath(tokens, exemptFlags) {
|
|
214
|
+
function scanTokensForSecretPath(tokens, exemptFlags, skipIndices) {
|
|
143
215
|
const exempt = exemptFlags || NO_EXEMPT_FLAGS;
|
|
216
|
+
const skip = skipIndices || NO_SKIP;
|
|
144
217
|
for (let i = 0; i < tokens.length; i++) {
|
|
218
|
+
if (skip.has(i)) continue; // posizione nome-soltanto: mai letta
|
|
145
219
|
const t = stripTrailingPunct(tokens[i]);
|
|
146
220
|
if (!t) continue;
|
|
147
221
|
|
|
@@ -175,7 +249,7 @@ function scanTokensForSecretPath(tokens, exemptFlags) {
|
|
|
175
249
|
// `-f value` / `--file value` (separate-token value).
|
|
176
250
|
if (VALUE_FLAGS.has(t)) {
|
|
177
251
|
const next = i + 1 < tokens.length ? stripTrailingPunct(tokens[i + 1]) : null;
|
|
178
|
-
if (next && isSecretPathArg(next)) return next;
|
|
252
|
+
if (next && !skip.has(i + 1) && isSecretPathArg(next)) return next;
|
|
179
253
|
continue;
|
|
180
254
|
}
|
|
181
255
|
// Glued short-option value (`-f.env`, finding #2): a single-dash flag
|
|
@@ -197,6 +271,77 @@ function scanTokensForSecretPath(tokens, exemptFlags) {
|
|
|
197
271
|
return null;
|
|
198
272
|
}
|
|
199
273
|
|
|
274
|
+
// Vero se `text` è un'opzione lunga elencata, o un cluster corto (`-ci`) di
|
|
275
|
+
// cui OGNI lettera è elencata. `--opt=value`, un'abbreviazione o un cluster
|
|
276
|
+
// che contiene un'opzione con valore (`-ciX`) non lo sono.
|
|
277
|
+
function isListedFlag(text, flags) {
|
|
278
|
+
if (text.startsWith('--')) return flags.has(text);
|
|
279
|
+
return /^-[A-Za-z]+$/.test(text) && [...text.slice(1)].every((ch) => flags.has(`-${ch}`));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Indice del sottocomando git oltre le opzioni globali, o -1 se lo precede
|
|
283
|
+
// un'opzione fuori da GIT_GLOBAL_VALUE_OPTIONS / GIT_GLOBAL_FLAGS.
|
|
284
|
+
function gitSubcommandIndex(operands) {
|
|
285
|
+
for (let k = 0; k < operands.length; k++) {
|
|
286
|
+
const t = operands[k];
|
|
287
|
+
if (!t.startsWith('-')) return k;
|
|
288
|
+
if (GIT_GLOBAL_VALUE_OPTIONS.has(t)) { k++; continue; }
|
|
289
|
+
if (GIT_GLOBAL_FLAGS.has(t)) continue;
|
|
290
|
+
const eq = t.indexOf('=');
|
|
291
|
+
const isLongValueForm = t.startsWith('--') && eq !== -1 && GIT_GLOBAL_VALUE_OPTIONS.has(t.slice(0, eq));
|
|
292
|
+
if (!isLongValueForm) return -1;
|
|
293
|
+
}
|
|
294
|
+
return -1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Indici dei pathspec di un sottocomando in GIT_PATHSPEC_SUBCOMMANDS. git
|
|
298
|
+
// permuta le opzioni (una può seguire un pathspec); dopo `--` ogni parola è
|
|
299
|
+
// un pathspec. Sottocomando confrontato case-sensitive, come fa git.
|
|
300
|
+
function gitPathspecIndices(operands) {
|
|
301
|
+
const sub = gitSubcommandIndex(operands);
|
|
302
|
+
const spec = sub === -1 ? undefined : GIT_PATHSPEC_SUBCOMMANDS.get(operands[sub]);
|
|
303
|
+
if (!spec) return NO_SKIP;
|
|
304
|
+
const pathspecs = new Set();
|
|
305
|
+
let required = spec.requires === undefined;
|
|
306
|
+
let endOfOptions = false;
|
|
307
|
+
for (let k = sub + 1; k < operands.length; k++) {
|
|
308
|
+
const t = operands[k];
|
|
309
|
+
if (endOfOptions || !t.startsWith('-') || t === '-') pathspecs.add(k);
|
|
310
|
+
else if (t === '--') endOfOptions = true;
|
|
311
|
+
else if (!isListedFlag(t, spec.flags)) return NO_SKIP;
|
|
312
|
+
else if (t === spec.requires) required = true;
|
|
313
|
+
}
|
|
314
|
+
return required ? pathspecs : NO_SKIP;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Indice della destinazione di un `cp`/`mv` a sorgente singola (vedi
|
|
318
|
+
// COPY_NAME_ONLY_FLAGS).
|
|
319
|
+
function copyDestinationIndices(operands) {
|
|
320
|
+
const positional = [];
|
|
321
|
+
let endOfOptions = false;
|
|
322
|
+
for (let k = 0; k < operands.length; k++) {
|
|
323
|
+
const t = operands[k];
|
|
324
|
+
if (endOfOptions || !t.startsWith('-') || t === '-') positional.push(k);
|
|
325
|
+
else if (positional.length) return NO_SKIP; // opzione o `--` dopo un operando
|
|
326
|
+
else if (t === '--') endOfOptions = true;
|
|
327
|
+
else if (!isListedFlag(t, COPY_NAME_ONLY_FLAGS)) return NO_SKIP;
|
|
328
|
+
}
|
|
329
|
+
return positional.length === 2 ? new Set([positional[1]]) : NO_SKIP;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Indici dei token (verbo incluso, indice 0) che lo statement consuma come
|
|
333
|
+
// NOME e mai come CONTENUTO: pathspec di git check-ignore/ls-files/rm
|
|
334
|
+
// --cached e destinazione di cp/mv. Solo queste posizioni saltano il
|
|
335
|
+
// controllo; ogni altro token resta controllato, quindi l'esenzione non può
|
|
336
|
+
// riciclare una lettura altrove nello statement.
|
|
337
|
+
function nameOnlyTokenIndices(bin, tokens) {
|
|
338
|
+
const operands = tokens.slice(1).map((t) => stripQuotes(stripTrailingPunct(t)));
|
|
339
|
+
let indices = NO_SKIP;
|
|
340
|
+
if (bin === 'git') indices = gitPathspecIndices(operands);
|
|
341
|
+
else if (bin === 'cp' || bin === 'mv') indices = copyDestinationIndices(operands);
|
|
342
|
+
return new Set([...indices].map((k) => k + 1));
|
|
343
|
+
}
|
|
344
|
+
|
|
200
345
|
// Does `stmtTokens` invoke `bash -c "<cmd>"` / `sh -c "<cmd>"` / `eval
|
|
201
346
|
// "<cmd>"` (etc.)? If so, return the inner command TEXT (quotes stripped, not
|
|
202
347
|
// yet re-tokenized) so the caller can recurse into it as a nested command.
|
|
@@ -243,7 +388,7 @@ function scanStatementForSecretPath(stmt) {
|
|
|
243
388
|
// standalone docker-compose/podman-compose binaries) get --env-file's value
|
|
244
389
|
// exempted; every other verb keeps today's behaviour unchanged.
|
|
245
390
|
const exempt = ENV_FILE_EXEMPT_VERBS.has(bin) ? ENV_FILE_EXEMPT_FLAGS : NO_EXEMPT_FLAGS;
|
|
246
|
-
return scanTokensForSecretPath(tokens, exempt);
|
|
391
|
+
return scanTokensForSecretPath(tokens, exempt, nameOnlyTokenIndices(bin, tokens));
|
|
247
392
|
}
|
|
248
393
|
|
|
249
394
|
// Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,
|
|
@@ -129,6 +129,62 @@ ok('hook allows Bash(podman run --env-file=.env img) end-to-end (exit 0, no outp
|
|
|
129
129
|
const d3 = runHook('Bash', { command: 'cat .env' });
|
|
130
130
|
ok('hook still denies Bash(cat .env) end-to-end (exit 2, block decision)', d3.code === 2 && /"decision":"block"/.test(d3.out));
|
|
131
131
|
|
|
132
|
+
// --- template env files (.env.example/.sample/.template/.dist) are not secrets ---
|
|
133
|
+
const allowCmd = (c) => ok(`Bash \`${c}\` is allowed`, !detectSecretRead('Bash', { command: c }));
|
|
134
|
+
const denyCmd = (c) => ok(`Bash \`${c}\` is denied`, !!detectSecretRead('Bash', { command: c }));
|
|
135
|
+
ok('Read .env.example is allowed', !detectSecretRead('Read', { file_path: '.env.example' }));
|
|
136
|
+
ok('Read .env.local.example is allowed', !detectSecretRead('Read', { file_path: 'app/.env.local.example' }));
|
|
137
|
+
ok('Read .ENV.SAMPLE is allowed', !detectSecretRead('Read', { file_path: '.ENV.SAMPLE' }));
|
|
138
|
+
allowCmd('cat .env.template');
|
|
139
|
+
allowCmd('cat .env.dist');
|
|
140
|
+
ok('Read .env.example.bak is still denied', !!detectSecretRead('Read', { file_path: '.env.example.bak' }));
|
|
141
|
+
ok('Read .env.examples is still denied', !!detectSecretRead('Read', { file_path: '.env.examples' }));
|
|
142
|
+
|
|
143
|
+
// --- name-only operand positions: git pathspecs, cp/mv destination ---
|
|
144
|
+
// Questi comandi non stampano mai il contenuto del file: sono i controlli che
|
|
145
|
+
// tengono un segreto fuori da git, e il guard non deve bloccarli.
|
|
146
|
+
allowCmd('git check-ignore -v .env');
|
|
147
|
+
allowCmd('git check-ignore -q .env .secrets');
|
|
148
|
+
allowCmd('git ls-files --error-unmatch .env');
|
|
149
|
+
allowCmd('git ls-files -ci --exclude-standard -- .env');
|
|
150
|
+
allowCmd('git rm --cached .env');
|
|
151
|
+
allowCmd('git rm -r --cached -f .env');
|
|
152
|
+
allowCmd('git -C app check-ignore .env');
|
|
153
|
+
allowCmd('git --no-pager ls-files .env');
|
|
154
|
+
allowCmd('cp .env.example .env');
|
|
155
|
+
allowCmd('cp -f .env.example .env');
|
|
156
|
+
allowCmd('mv .env.sample .env');
|
|
157
|
+
allowCmd('cp -- .env.example .env');
|
|
158
|
+
allowCmd('git check-ignore .env && cp .env.example .env');
|
|
159
|
+
// Fail-closed: forme che leggono, o fuori dal set di opzioni chiuso.
|
|
160
|
+
denyCmd('git show HEAD:.env');
|
|
161
|
+
denyCmd('git rm .env');
|
|
162
|
+
denyCmd('git rm -- --cached .env');
|
|
163
|
+
denyCmd('git rm --cached --pathspec-from-file=.env');
|
|
164
|
+
denyCmd('git ls-files -X .env');
|
|
165
|
+
denyCmd('git ls-files -ciX .env');
|
|
166
|
+
denyCmd('git -C ls-files show HEAD:.env');
|
|
167
|
+
denyCmd('git --unknown check-ignore .env');
|
|
168
|
+
denyCmd('git diff .env');
|
|
169
|
+
denyCmd('cp .env /tmp/x');
|
|
170
|
+
denyCmd('cp .env .env.bak');
|
|
171
|
+
denyCmd('mv .env /tmp/x');
|
|
172
|
+
denyCmd('cp -t /tmp .env.example .env');
|
|
173
|
+
denyCmd('cp .env.example .env /tmp');
|
|
174
|
+
denyCmd('cp -b .env.example .env');
|
|
175
|
+
denyCmd('cp --backup .env.example .env');
|
|
176
|
+
denyCmd('cp -l .env.example .env');
|
|
177
|
+
denyCmd('cp -s .env.example .env');
|
|
178
|
+
denyCmd('mv --exchange .env.example .env');
|
|
179
|
+
denyCmd('cp .env.example .env -f');
|
|
180
|
+
denyCmd('cp .env.example .env && cat .env');
|
|
181
|
+
denyCmd('git check-ignore .env; cat .env');
|
|
182
|
+
|
|
183
|
+
const a6 = runHook('Bash', { command: 'git check-ignore -v .env' });
|
|
184
|
+
ok('hook allows Bash(git check-ignore -v .env) end-to-end (exit 0, no output)', a6.code === 0 && a6.out.trim() === '');
|
|
185
|
+
const a7 = runHook('Bash', { command: 'cp .env.example .env' });
|
|
186
|
+
ok('hook allows Bash(cp .env.example .env) end-to-end (exit 0, no output)', a7.code === 0 && a7.out.trim() === '');
|
|
187
|
+
|
|
132
188
|
// --- crash safety ---
|
|
133
189
|
const crash1 = runHook('Read', undefined);
|
|
134
190
|
ok('missing tool_input never crashes (exit 0)', crash1.code === 0);
|
|
@@ -54,6 +54,8 @@ This is the operational recipe every applicable call-site follows. It is **manda
|
|
|
54
54
|
|
|
55
55
|
Worked examples: 2 real options + delegate + defer = 4 -> all in the menu; 3 real options + delegate + defer = 5 -> the menu carries the 3 real options and delegate/defer are offered in the prose line right after it; 2 real options + delegate (defer not applicable) = 3 -> in the menu.
|
|
56
56
|
|
|
57
|
+
**Menu call-sites (an `AskUserQuestion` trailhead authors): links live in the prose.** A picker renders its question and options as plain text, so nothing in it is clickable. When a question is about something the user has to open first (a mockup to approve or pick from, a published page, a PR, a ticket comment), write its clickable link(s) in the reply text **immediately before** the menu, in that same message, never only earlier in the conversation, never as a name in place of the URL, and never inside the picker. If there is no link to show yet, do not ask. For mockups the full rule is "Surface the mockup" in `techniques/prototype.md`, enforced by the mockup-link hooks (Claude Code and Codex).
|
|
58
|
+
|
|
57
59
|
**Prose / conversational call-sites (grilling, or any choice put in words rather than a menu).** There is no cap: **state the applicable affordance(s) in words** in the same message that puts the choice (for example append "Oppure: scegli tu, propongo io / scelgo dopo se dipende da un prerequisito."). Grilling stays human-decides, so delegate is not offered there; **defer is offered in prose when the decision is genuinely blocked on a prerequisite.**
|
|
58
60
|
|
|
59
61
|
`(Recommended)` on a real option is a separate, static hint that may coexist with the affordances; it is not the delegate option.
|