@marcomigozzi/trailhead 0.12.1 → 0.14.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 +38 -13
- package/README.md +48 -13
- package/bin/lib/codex-projection.js +18 -11
- package/bin/lib/codex-projection.test.js +39 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/trailhead.js +37 -25
- package/bin/trailhead.test.js +162 -5
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
- package/plugins/trailhead/hooks/mod/README.md +187 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
- package/plugins/trailhead/hooks/mod/register.tsx +280 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +79 -7
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +3 -1
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -4
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +15 -16
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
- package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
- package/plugins/trailhead/types/index.d.ts +69 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// unmodelled.js: the ONE closed list of shell constructs the secret-guard, the
|
|
3
|
+
// body-guard and the secret-read guard cannot interpret with certainty (#227).
|
|
4
|
+
// The threat model of those guards is the ACCIDENTAL leak: a construct they cannot
|
|
5
|
+
// follow is not emulated (the emulation of #210-#220 stays as it is), it is blocked
|
|
6
|
+
// with a "rewrite it in a simple form" message, but only when it can matter to the
|
|
7
|
+
// guard that looks at it.
|
|
8
|
+
//
|
|
9
|
+
// This module finds the constructs and the code each one hides; it does NOT decide
|
|
10
|
+
// what a gh write is. Every hit carries what a guard needs (`stmt`, `payload`,
|
|
11
|
+
// `after`, `calls`, `reach`) and each guard judges relevance with its OWN write
|
|
12
|
+
// classifier, passed to firstRelevant (secret-guard: ghWrites, body-guard:
|
|
13
|
+
// ghBodyWrites; the read guard looks for a secret-looking piece itself), so there is
|
|
14
|
+
// no second classifier to drift. Best-effort like the guards: not a shell parser.
|
|
15
|
+
// Self-contained: requires only the sibling libs.
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
splitStatementsWithSeparators,
|
|
19
|
+
substitutionBodies,
|
|
20
|
+
heredocBodies,
|
|
21
|
+
heredocSubstitutionBodies,
|
|
22
|
+
functionSubstitutions,
|
|
23
|
+
inWordContinuations,
|
|
24
|
+
} = require('./shell-scan.js');
|
|
25
|
+
const { words, ASSIGN_TOKEN_RE } = require('./body-sources.js');
|
|
26
|
+
const { parseGhSubcommand, dequote } = require('./gh-subcommand.js');
|
|
27
|
+
|
|
28
|
+
// The closed list. `reach` says what makes a hit relevant to a guard:
|
|
29
|
+
// payload the code it hides holds a write (eval, funsub), or the construct is
|
|
30
|
+
// CALLED later and what it runs holds a write (function, alias);
|
|
31
|
+
// state a write comes AFTER it and can depend on state it changed (source,
|
|
32
|
+
// printf -v: an expansion, a ~ or a relative path in the write; a fully
|
|
33
|
+
// literal write is out of reach);
|
|
34
|
+
// local its own statement (with its pipeline) is a write;
|
|
35
|
+
// self the construct is itself a body-field write (a quoted gh api key).
|
|
36
|
+
const UNMODELLED = {
|
|
37
|
+
eval: { label: 'eval', reach: 'payload', hint: 'call gh directly, not through eval, with the body in a file passed by its literal absolute path' },
|
|
38
|
+
source: { label: 'source / `.`', reach: 'state', hint: 'run the sourced steps in a separate Bash call first, then post with a literal --body-file path' },
|
|
39
|
+
funsub: { label: 'a function substitution (`${ cmd; }`)', reach: 'payload', hint: 'use a plain $(...) command substitution or a separate command' },
|
|
40
|
+
'printf-v': { label: 'printf -v', reach: 'state', hint: 'put the text in a file, or in a single-quoted literal' },
|
|
41
|
+
function: { label: 'a function definition', reach: 'payload', hint: 'call gh directly, not through a function defined in the same command' },
|
|
42
|
+
alias: { label: 'alias', reach: 'payload', hint: 'call gh directly, not through an alias' },
|
|
43
|
+
'api-field': { label: 'a quoted or split gh api body field name', reach: 'self', hint: 'write the field plainly: `-f body=...` or `-f "body=..."`' },
|
|
44
|
+
continuation: { label: 'a backslash-newline inside a word', reach: 'local', hint: 'keep every word on one line (a trailing backslash may only sit between words)' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const MAX_DEPTH = 32;
|
|
48
|
+
const MAX_BYTES = 64 * 1024;
|
|
49
|
+
const SAMPLE_MAX = 80;
|
|
50
|
+
|
|
51
|
+
// --- command word ----------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const LEADERS = new Set(['!', '{', '(', 'then', 'do', 'else', 'elif', 'if', 'while', 'until', 'time']);
|
|
54
|
+
const WRAPPER_VALUE_FLAGS = {
|
|
55
|
+
env: ['-u', '-C', '-S'],
|
|
56
|
+
sudo: ['-u', '-g', '-h', '-p', '-C', '-r', '-t', '-U', '-D', '-R'],
|
|
57
|
+
nice: ['-n'],
|
|
58
|
+
timeout: ['-k', '-s'],
|
|
59
|
+
command: [],
|
|
60
|
+
builtin: [],
|
|
61
|
+
exec: ['-a'],
|
|
62
|
+
nohup: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Index just past a wrapper's own flags (and assignments), from the wrapper at toks[k].
|
|
66
|
+
function skipWrapper(toks, k) {
|
|
67
|
+
const w = dequote(toks[k]);
|
|
68
|
+
let j = k + 1;
|
|
69
|
+
while (j < toks.length) {
|
|
70
|
+
const t = dequote(toks[j]);
|
|
71
|
+
if (WRAPPER_VALUE_FLAGS[w].includes(t)) j += 2;
|
|
72
|
+
else if (t.startsWith('-')) j++;
|
|
73
|
+
else if ((w === 'env' || w === 'sudo') && ASSIGN_TOKEN_RE.test(t)) j++;
|
|
74
|
+
else break;
|
|
75
|
+
}
|
|
76
|
+
if (w === 'timeout' && j < toks.length) j++; // the duration
|
|
77
|
+
return j;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The words of a statement from its command word on: reserved words, a leading `(`,
|
|
81
|
+
// NAME=v prefixes, case heads and arm patterns and wrappers are skipped. So the
|
|
82
|
+
// word `eval` in `--body 'use eval here'` is never a command word.
|
|
83
|
+
function commandInfo(text) {
|
|
84
|
+
let toks = words(text);
|
|
85
|
+
while (toks.length) {
|
|
86
|
+
const open = /^\(+/.exec(toks[0]);
|
|
87
|
+
if (open) { toks = toks[0].length > open[0].length ? [toks[0].slice(open[0].length), ...toks.slice(1)] : toks.slice(1); continue; }
|
|
88
|
+
const d = dequote(toks[0]);
|
|
89
|
+
if (LEADERS.has(d) || ASSIGN_TOKEN_RE.test(toks[0])) { toks = toks.slice(1); continue; }
|
|
90
|
+
if (d === 'case' && toks.includes('in')) { toks = toks.slice(toks.indexOf('in') + 1); continue; }
|
|
91
|
+
if (/^[^\s()$"'`]*\)$/.test(toks[0])) { toks = toks.slice(1); continue; } // case arm pattern)
|
|
92
|
+
if (Object.prototype.hasOwnProperty.call(WRAPPER_VALUE_FLAGS, d)) { toks = toks.slice(skipWrapper(toks, 0)); continue; }
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
return { toks, cmd: toks.length ? dequote(toks[0]) : '', args: toks.slice(1) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const NAME_RE = /^[A-Za-z_][\w.:+-]*$/;
|
|
99
|
+
|
|
100
|
+
// The name a statement defines as a function (`f()`, `f(){`, `f ()`, `function f`), or null.
|
|
101
|
+
function functionHeadName(toks) {
|
|
102
|
+
if (!toks.length) return null;
|
|
103
|
+
const glued = /^([A-Za-z_][\w.:+-]*)\(\)/.exec(toks[0]);
|
|
104
|
+
if (glued) return glued[1];
|
|
105
|
+
if (NAME_RE.test(toks[0]) && toks[1] && /^\(\)/.test(toks[1])) return toks[0];
|
|
106
|
+
if (toks[0] === 'function' && toks[1]) {
|
|
107
|
+
const name = toks[1].replace(/\(\)\{?$/, '');
|
|
108
|
+
return NAME_RE.test(name) ? name : null;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- per-construct helpers -------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
const compact = (t) => String(t).replace(/\s+/g, ' ').trim();
|
|
116
|
+
|
|
117
|
+
// The statements, from entries[idx], that make up a function body: up to the closing
|
|
118
|
+
// `}` of the first brace group, or just the head statement when none opens there.
|
|
119
|
+
function functionBody(entries, idx) {
|
|
120
|
+
const parts = [];
|
|
121
|
+
let depth = 0;
|
|
122
|
+
let opened = false;
|
|
123
|
+
for (let j = idx; j < entries.length; j++) {
|
|
124
|
+
parts.push(entries[j].text);
|
|
125
|
+
for (const w of words(entries[j].text)) {
|
|
126
|
+
if (w === '{' || /^\S+\(\)\{$/.test(w)) { depth++; opened = true; } else if (w === '}') depth--;
|
|
127
|
+
}
|
|
128
|
+
if (!opened || depth <= 0) break;
|
|
129
|
+
}
|
|
130
|
+
return parts.join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The later statements that call `name` as a command word (also through a command
|
|
134
|
+
// substitution in them): [{ text, args }].
|
|
135
|
+
function callsOf(name, entries, idx) {
|
|
136
|
+
const calls = [];
|
|
137
|
+
const scan = (text) => {
|
|
138
|
+
const info = commandInfo(text);
|
|
139
|
+
if (info.cmd === name) calls.push({ text, args: info.args });
|
|
140
|
+
};
|
|
141
|
+
for (const e of entries.slice(idx + 1)) {
|
|
142
|
+
scan(e.text);
|
|
143
|
+
for (const body of substitutionBodies(e.text)) {
|
|
144
|
+
for (const inner of splitStatementsWithSeparators(body)) scan(inner.text);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return calls;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const API_FIELD_FLAGS = new Set(['-f', '-F', '--field', '--raw-field']);
|
|
151
|
+
|
|
152
|
+
// A `gh api` statement with a body field whose key is `body` once quotes are removed
|
|
153
|
+
// but is not written as the bare key (`'body'=`, `bo""dy=`, `b\ody=`). A fully quoted
|
|
154
|
+
// pair (`-f "body=x"`, `-f 'body=x'`) is the plain form. Returns the statement
|
|
155
|
+
// rewritten with the plain field (`-f 'body=<value>'`), or null when there is no such
|
|
156
|
+
// field, so a guard can judge the body the field sets.
|
|
157
|
+
function quotedBodyFieldStatement(toks) {
|
|
158
|
+
const parsed = parseGhSubcommand(toks.join(' '));
|
|
159
|
+
if (!parsed || parsed.sub !== 'api') return null;
|
|
160
|
+
for (let k = 0; k < toks.length; k++) {
|
|
161
|
+
const flag = dequote(toks[k]);
|
|
162
|
+
let raw = null;
|
|
163
|
+
let lead = ''; // what stays in front of the rewritten field when the flag and value are glued
|
|
164
|
+
let at = k; // index of the token holding the value
|
|
165
|
+
if (API_FIELD_FLAGS.has(flag) && k + 1 < toks.length) { raw = toks[k + 1]; at = k + 1; k++; }
|
|
166
|
+
else if (/^--(?:raw-)?field=/.test(flag)) { raw = toks[k].slice(toks[k].indexOf('=') + 1); lead = `${flag.slice(0, flag.indexOf('='))} `; }
|
|
167
|
+
else if (/^-[fF]./.test(toks[k])) { raw = toks[k].slice(2); lead = `${toks[k].slice(0, 2)} `; }
|
|
168
|
+
if (raw === null) continue;
|
|
169
|
+
const value = dequote(raw);
|
|
170
|
+
const eq = value.indexOf('=');
|
|
171
|
+
if (eq < 0 || value.slice(0, eq) !== 'body') continue;
|
|
172
|
+
const plain = /^body=/.test(raw) || /^"body=(?:[^"\\]|\\[\s\S])*"$/.test(raw) || /^'body=[^']*'$/.test(raw);
|
|
173
|
+
if (plain) continue;
|
|
174
|
+
const field = `${lead}'body=${value.slice(eq + 1).replace(/'/g, "'\\''")}'`;
|
|
175
|
+
return [...toks.slice(0, at), field, ...toks.slice(at + 1)].join(' ');
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// printf with a -v option before its format.
|
|
181
|
+
function printfAssigns(args) {
|
|
182
|
+
for (const a of args) {
|
|
183
|
+
const d = dequote(a);
|
|
184
|
+
if (d === '--' || !d.startsWith('-')) return false;
|
|
185
|
+
if (d.startsWith('-v')) return true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- the walk --------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
function makeHit(id, fields) {
|
|
193
|
+
const { label, hint, reach } = UNMODELLED[id];
|
|
194
|
+
return { id, label, hint, reach, sample: '', stmt: '', stmtIndex: -1, payload: '', name: '', calls: [], after: '', afterEntries: [], ...fields };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Hits that belong to one statement (its command word and what it defines).
|
|
198
|
+
function statementHits(entries, idx) {
|
|
199
|
+
const e = entries[idx];
|
|
200
|
+
const { toks, cmd, args } = commandInfo(e.text);
|
|
201
|
+
const base = { stmt: e.text, stmtIndex: idx, sample: compact(e.text), afterEntries: entries.slice(idx + 1).map((x, i, rest) => ({ text: x.text, piped: (i === 0 ? e.sep : rest[i - 1].sep) === '|', heredocs: x.heredocs })), after: entries.slice(idx + 1).map((x) => x.text).join('\n') };
|
|
202
|
+
const fnName = functionHeadName(toks);
|
|
203
|
+
if (fnName) {
|
|
204
|
+
return [makeHit('function', { ...base, name: fnName, payload: functionBody(entries, idx), calls: callsOf(fnName, entries, idx).map((c) => c.text) })];
|
|
205
|
+
}
|
|
206
|
+
if (cmd === 'eval') return [makeHit('eval', { ...base, payload: args.map(dequote).join(' ') })];
|
|
207
|
+
if (cmd === 'source' || cmd === '.') return [makeHit('source', base)];
|
|
208
|
+
if (cmd === 'printf' && printfAssigns(args)) return [makeHit('printf-v', { ...base, payload: args.map(dequote).join(' ') })];
|
|
209
|
+
if (cmd === 'alias') {
|
|
210
|
+
const defs = args.map(dequote).filter((a) => a.includes('=')).map((a) => [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)]);
|
|
211
|
+
const calls = defs.flatMap(([name, value]) => callsOf(name, entries, idx).map((c) => `${value} ${c.args.join(' ')}`.trim()));
|
|
212
|
+
return [makeHit('alias', { ...base, name: defs.length ? defs[0][0] : '', payload: defs.map((d) => d[1]).join('\n'), calls })];
|
|
213
|
+
}
|
|
214
|
+
const normalized = cmd ? quotedBodyFieldStatement(toks) : null;
|
|
215
|
+
return normalized ? [makeHit('api-field', { ...base, payload: normalized })] : [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The pipeline a statement belongs to, joined (`a | b | c`).
|
|
219
|
+
function pipelineText(entries, idx) {
|
|
220
|
+
let from = idx;
|
|
221
|
+
let to = idx;
|
|
222
|
+
while (from > 0 && entries[from - 1].sep === '|') from--;
|
|
223
|
+
while (to < entries.length - 1 && entries[to].sep === '|') to++;
|
|
224
|
+
return entries.slice(from, to + 1).map((x) => x.text).join(' | ');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function continuationHits(text, entries) {
|
|
228
|
+
return inWordContinuations(text).map(({ junction }) => {
|
|
229
|
+
const idx = entries.findIndex((e) => e.text.includes(junction));
|
|
230
|
+
const stmt = idx < 0 ? text : pipelineText(entries, idx);
|
|
231
|
+
return makeHit('continuation', { stmt, stmtIndex: idx, sample: compact(stmt) });
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Every unmodelled construct in a command, including those inside command
|
|
236
|
+
// substitutions, backtick spans and unquoted heredoc bodies (depth- and size-capped).
|
|
237
|
+
function findUnmodelled(cmd) {
|
|
238
|
+
const out = [];
|
|
239
|
+
const work = [{ text: String(cmd), depth: 0 }];
|
|
240
|
+
let bytes = 0;
|
|
241
|
+
for (let w = 0; w < work.length; w++) {
|
|
242
|
+
const { text, depth } = work[w];
|
|
243
|
+
if (depth > 0) bytes += text.length;
|
|
244
|
+
if (depth > MAX_DEPTH || bytes > MAX_BYTES) continue;
|
|
245
|
+
const entries = splitStatementsWithSeparators(text);
|
|
246
|
+
entries.forEach((_, idx) => out.push(...statementHits(entries, idx)));
|
|
247
|
+
for (const f of functionSubstitutions(text)) {
|
|
248
|
+
out.push(makeHit('funsub', { payload: f.payload, sample: compact('${' + f.payload + '}'), stmt: compact(f.payload) }));
|
|
249
|
+
}
|
|
250
|
+
out.push(...continuationHits(text, entries));
|
|
251
|
+
for (const body of substitutionBodies(text)) work.push({ text: body, depth: depth + 1 });
|
|
252
|
+
for (const doc of heredocBodies(text)) {
|
|
253
|
+
if (doc.quoted) continue;
|
|
254
|
+
for (const body of heredocSubstitutionBodies(doc.body)) work.push({ text: body, depth: depth + 1 });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Does a raw shell word hold an expansion the shell would run (`$`, a backtick) outside
|
|
261
|
+
// single quotes? Double-quoted text counts; `$'..'` and `$".."` are quoting, not expansion.
|
|
262
|
+
function hasLiveExpansion(raw) {
|
|
263
|
+
let q = null; // ' or "
|
|
264
|
+
for (let i = 0; i < raw.length; i++) {
|
|
265
|
+
const c = raw[i];
|
|
266
|
+
if (q === "'") { if (c === "'") q = null; continue; }
|
|
267
|
+
if (c === '\\') { i++; continue; }
|
|
268
|
+
if (c === '`') return true;
|
|
269
|
+
if (c === '$') {
|
|
270
|
+
if (raw[i + 1] === "'" && q === null) { // ANSI-C span: literal, backslash escapes inside
|
|
271
|
+
for (i += 2; i < raw.length && raw[i] !== "'"; i++) if (raw[i] === '\\') i++;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (raw[i + 1] === '"' && q === null) continue;
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
if (q === '"') { if (c === '"') q = null; continue; }
|
|
278
|
+
if (c === "'" || c === '"') q = c;
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const STDIN_VALUES = new Set(['-', '/dev/stdin', '/dev/fd/0']);
|
|
284
|
+
|
|
285
|
+
// Can what a statement posts depend on state a sourced script or printf -v may have
|
|
286
|
+
// changed? An expansion outside single quotes, a `~`, a relative body-file / `-F` /
|
|
287
|
+
// `--input` / api `@path`, or a body read from stdin (`-`, /dev/stdin, `@-`, an input
|
|
288
|
+
// redirect, here-string or heredoc, or the right side of a pipe, whose input another
|
|
289
|
+
// statement builds) can; a fully literal write cannot. Judged token by token.
|
|
290
|
+
// `entry` is { text, piped, heredocs }.
|
|
291
|
+
function stateDependent(entry) {
|
|
292
|
+
if (entry.piped || entry.heredocs > 0) return true;
|
|
293
|
+
const toks = words(entry.text);
|
|
294
|
+
const fileValue = (flag, v) => { // a file the flag reads: stdin or a relative path
|
|
295
|
+
if (flag === '-F' && v.includes('=')) return false; // a field, not a file
|
|
296
|
+
return STDIN_VALUES.has(v) || !v.startsWith('/');
|
|
297
|
+
};
|
|
298
|
+
for (let k = 0; k < toks.length; k++) {
|
|
299
|
+
const raw = toks[k];
|
|
300
|
+
if (hasLiveExpansion(raw) || /^~|[=:]~/.test(raw) || /^<(?!\()/.test(raw)) return true;
|
|
301
|
+
const d = dequote(raw);
|
|
302
|
+
const flag = /^(--body-file|-F|--input)(?:=([\s\S]*))?$/.exec(d);
|
|
303
|
+
if (flag) {
|
|
304
|
+
const v = flag[2] !== undefined ? flag[2] : (k + 1 < toks.length ? dequote(toks[k + 1]) : null);
|
|
305
|
+
if (v !== null && fileValue(flag[1], v)) return true;
|
|
306
|
+
}
|
|
307
|
+
const field = /body=@([\s\S]*)$/.exec(d);
|
|
308
|
+
if (field && (field[1] === '-' || !field[1].startsWith('/'))) return true;
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- relevance, per hit, by the construct's reach ----------------------------------
|
|
314
|
+
|
|
315
|
+
// `isWrite(text)` is the CALLING guard's own write classifier.
|
|
316
|
+
function isRelevant(hit, isWrite) {
|
|
317
|
+
switch (hit.id) {
|
|
318
|
+
case 'eval':
|
|
319
|
+
case 'funsub': return isWrite(hit.payload);
|
|
320
|
+
case 'function': return hit.calls.length > 0 && isWrite(hit.payload);
|
|
321
|
+
case 'alias': return hit.calls.some((c) => isWrite(c));
|
|
322
|
+
case 'source':
|
|
323
|
+
case 'printf-v': return hit.afterEntries.some((e) => isWrite(e.text) && stateDependent(e));
|
|
324
|
+
case 'continuation': return isWrite(hit.stmt);
|
|
325
|
+
default: return true; // api-field: the statement is itself a body write
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The first relevant hit, or null. `modelled` lists ids of constructs the calling guard
|
|
330
|
+
// already follows exactly (its frozen emulation), so they are not blocked there.
|
|
331
|
+
function firstRelevant(hits, isWrite, modelled = []) {
|
|
332
|
+
return hits.find((h) => !modelled.includes(h.id) && isRelevant(h, isWrite)) || null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- the message -----------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
// `redact(text)` must strip anything secret-looking from the sample; without one the
|
|
338
|
+
// sample is withheld, so a token is never echoed back.
|
|
339
|
+
function unmodelledReason(guard, what, hit, redact) {
|
|
340
|
+
const sample = typeof redact === 'function' ? redact(String(hit.sample)).slice(0, SAMPLE_MAX) : '';
|
|
341
|
+
return `trailhead ${guard}: this command uses ${hit.label}${sample ? ` (\`${sample}\`)` : ''}, which the guard cannot ` +
|
|
342
|
+
`interpret with certainty, so it cannot verify ${what}. Rewrite it in a simple form: ${hit.hint}.`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
module.exports = { UNMODELLED, findUnmodelled, firstRelevant, unmodelledReason };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for unmodelled.js. Run: node unmodelled.test.js
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { findUnmodelled, firstRelevant, unmodelledReason, UNMODELLED } = require('./unmodelled.js');
|
|
5
|
+
|
|
6
|
+
let passed = 0;
|
|
7
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
8
|
+
|
|
9
|
+
const ids = (cmd) => findUnmodelled(cmd).map((h) => h.id);
|
|
10
|
+
const has = (cmd, id) => ids(cmd).includes(id);
|
|
11
|
+
const none = (cmd) => ids(cmd).length === 0;
|
|
12
|
+
// A stand-in write classifier: the lib has none of its own, each guard passes its own.
|
|
13
|
+
const isWrite = (t) => /\bgh\s+(?:issue|pr)\s+(?:comment|edit|create)\b/.test(t);
|
|
14
|
+
const relevant = (cmd) => !!firstRelevant(findUnmodelled(cmd), isWrite);
|
|
15
|
+
|
|
16
|
+
// --- the closed table ---
|
|
17
|
+
ok('UNMODELLED lists every construct with a label and a hint', ['eval', 'source', 'funsub', 'printf-v', 'function', 'alias', 'api-field', 'continuation']
|
|
18
|
+
.every((id) => UNMODELLED[id] && UNMODELLED[id].label && UNMODELLED[id].hint));
|
|
19
|
+
|
|
20
|
+
// --- eval ---
|
|
21
|
+
ok('eval is detected', has('eval "$(ssh-agent)"', 'eval'));
|
|
22
|
+
ok('eval payload is its quote-removed arguments', findUnmodelled("eval 'gh issue comment 1 --body x'")[0].payload === 'gh issue comment 1 --body x');
|
|
23
|
+
ok('eval behind a wrapper is detected', has('command eval x', 'eval') && has('FOO=1 eval x', 'eval'));
|
|
24
|
+
ok('eval after && is detected', has('true && eval x', 'eval'));
|
|
25
|
+
ok('eval in quoted prose is not', none("gh issue comment 1 --body 'use eval here'"));
|
|
26
|
+
ok('eval as an argument is not', none('echo eval'));
|
|
27
|
+
ok('eval in a comment is not', none('echo hi # eval x'));
|
|
28
|
+
|
|
29
|
+
// --- source ---
|
|
30
|
+
ok('source is detected', has('source ./post.sh', 'source'));
|
|
31
|
+
ok('. is detected', has('. ./post.sh', 'source'));
|
|
32
|
+
ok('. inside quotes is not', none('echo ". x"'));
|
|
33
|
+
ok('./script is not', none('./post.sh'));
|
|
34
|
+
ok('source as an argument is not', none('echo source x'));
|
|
35
|
+
|
|
36
|
+
// --- function substitution ---
|
|
37
|
+
ok('${ cmd; } is detected', has('echo ${ gh issue comment 1 --body x; }', 'funsub'));
|
|
38
|
+
ok('${| cmd; } is detected', has('echo ${| REPLY=x; }', 'funsub'));
|
|
39
|
+
ok('${<newline> is detected', has('echo ${\ndate\n}', 'funsub'));
|
|
40
|
+
ok('funsub payload is the body', findUnmodelled('echo ${ date; }')[0].payload === ' date; ');
|
|
41
|
+
ok('plain expansions are not', none('echo ${x} ${x:-y}'));
|
|
42
|
+
ok('a single-quoted funsub is not', none("echo '${ x; }'"));
|
|
43
|
+
ok('a funsub inside a $(..) body is found', has('echo $(echo ${ date; })', 'funsub'));
|
|
44
|
+
ok('a funsub inside a backtick body is found', has('echo `echo ${ date; }`', 'funsub'));
|
|
45
|
+
ok('a funsub in an unquoted heredoc is found', has('cat <<E\n${ date; }\nE', 'funsub'));
|
|
46
|
+
|
|
47
|
+
// --- printf -v ---
|
|
48
|
+
ok('printf -v is detected', has('printf -v B %s x', 'printf-v'));
|
|
49
|
+
ok('printf -vNAME is detected', has('printf -vB %s x', 'printf-v'));
|
|
50
|
+
ok('printf without -v is not', none("printf '%s' x"));
|
|
51
|
+
ok('a -v after the format is not', none('printf %s -v'));
|
|
52
|
+
|
|
53
|
+
// --- function definitions ---
|
|
54
|
+
ok('name() { is detected', has('f(){ :; }', 'function'));
|
|
55
|
+
ok('name () is detected', has('f () { :; }', 'function'));
|
|
56
|
+
ok('function name is detected', has('function f { :; }', 'function'));
|
|
57
|
+
ok('a function head after a leader is detected', has('true && f() { :; }', 'function'));
|
|
58
|
+
ok('f() in quotes is not', none('echo "f()"') && none('git commit -m "f() {"'));
|
|
59
|
+
ok('function name and body are reported', (() => {
|
|
60
|
+
const h = findUnmodelled('f() { gh issue comment 1 --body x; }; f')[0];
|
|
61
|
+
return h.id === 'function' && h.name === 'f' && /gh issue comment/.test(h.payload) && h.calls.length === 1;
|
|
62
|
+
})());
|
|
63
|
+
ok('a multi-line function body is the payload', /gh issue comment/.test(findUnmodelled('f() {\n ls\n gh issue comment 1 --body x\n}\nf')[0].payload));
|
|
64
|
+
|
|
65
|
+
// --- alias ---
|
|
66
|
+
ok('alias is detected', has('alias a=b', 'alias'));
|
|
67
|
+
ok('alias in prose is not', none('echo alias'));
|
|
68
|
+
ok('alias name and value are reported', (() => {
|
|
69
|
+
const h = findUnmodelled('alias g=gh; g issue list')[0];
|
|
70
|
+
return h.name === 'g' && h.payload === 'gh' && h.calls.length === 1 && h.calls[0] === 'gh issue list';
|
|
71
|
+
})());
|
|
72
|
+
|
|
73
|
+
// --- gh api body field ---
|
|
74
|
+
ok("-f 'body'= is detected", has("gh api -X PATCH repos/o/r/issues/1 -f 'body'=\"\"", 'api-field'));
|
|
75
|
+
ok('-f bo""dy= is detected', has('gh api -X PATCH repos/o/r/issues/1 -f bo""dy=""', 'api-field'));
|
|
76
|
+
ok('-f b\\ody= is detected', has('gh api repos/o/r/issues/1 -f b\\ody=x', 'api-field'));
|
|
77
|
+
ok('--field "body"= is detected', has('gh api repos/o/r/issues/1 --field "body"=x', 'api-field'));
|
|
78
|
+
ok('--raw-field=bo""dy=x is detected', has('gh api repos/o/r/issues/1 --raw-field=bo""dy=x', 'api-field'));
|
|
79
|
+
ok('-Fb\'ody\'=x glued is detected', has("gh api repos/o/r/issues/1 -F'body'=x", 'api-field'));
|
|
80
|
+
ok('the api-field payload is the statement with a plain field', findUnmodelled("gh api -X PATCH r -f 'body'=\"\"")[0].payload === "gh api -X PATCH r -f 'body='");
|
|
81
|
+
ok('the api-field payload keeps a glued form plain too', findUnmodelled('gh api r --field=bo""dy=hi')[0].payload === "gh api r --field 'body=hi'");
|
|
82
|
+
ok('-f body=x is plain', none('gh api repos/o/r/issues/1 -f body=x'));
|
|
83
|
+
ok('-f "body=x" is plain', none('gh api repos/o/r/issues/1 -f "body=x"'));
|
|
84
|
+
ok("-f 'body=x' is plain", none("gh api repos/o/r/issues/1 -f 'body=x'"));
|
|
85
|
+
ok('-f "body=say \\"hi\\"" is plain', none('gh api repos/o/r/issues/1 -f "body=say \\"hi\\""'));
|
|
86
|
+
ok('-F title=x is plain', none('gh api repos/o/r/issues/1 -F title=x'));
|
|
87
|
+
ok('-f "title"=x is plain', none('gh api repos/o/r/issues/1 -f "title"=x'));
|
|
88
|
+
ok('a non-api gh statement is not', none('gh issue comment 1 --body "bo""dy=x"'));
|
|
89
|
+
|
|
90
|
+
// --- in-word continuation ---
|
|
91
|
+
ok('cat .e\\<nl>nv is detected', has('cat .e\\\nnv', 'continuation'));
|
|
92
|
+
ok('a continuation between words is not', none('gh issue comment 1 \\\n --body x'));
|
|
93
|
+
ok('a single-quoted continuation is not', none("echo 'a\\\nb'"));
|
|
94
|
+
|
|
95
|
+
// --- statement bookkeeping ---
|
|
96
|
+
ok('a hit carries its statement and index', (() => {
|
|
97
|
+
const h = findUnmodelled('echo a; source f; echo b')[0];
|
|
98
|
+
return h.stmt === 'source f' && h.stmtIndex === 1 && h.after === 'echo b';
|
|
99
|
+
})());
|
|
100
|
+
ok('the sample is the compacted statement', findUnmodelled('echo a\nsource f')[0].sample === 'source f');
|
|
101
|
+
ok('a hit inside a $(..) is found with its own statements', has('x=$(source f; gh issue comment 1 --body y)', 'source'));
|
|
102
|
+
ok('a hit inside a quoted heredoc body is not', none("cat <<'E'\neval x\nsource y\nE"));
|
|
103
|
+
ok('a hit inside an unquoted heredoc $(..) is found', has('cat <<E\n$(source f)\nE', 'source'));
|
|
104
|
+
|
|
105
|
+
// --- relevance: payload constructs ---
|
|
106
|
+
ok('eval with a write payload is relevant', relevant("eval 'gh issue comment 1 --body x'"));
|
|
107
|
+
ok('eval "$(ssh-agent)" next to a write is not', !relevant('eval "$(ssh-agent)"; gh issue comment 1 --body-file /tmp/clean.md'));
|
|
108
|
+
ok('eval alone is not', !relevant('eval "$(ssh-agent)"'));
|
|
109
|
+
ok('funsub with a write payload is relevant', relevant('echo ${ gh issue comment 1 --body-file /tmp/f; }'));
|
|
110
|
+
ok('funsub without a write payload is not', !relevant('echo ${ date; }; gh issue comment 1 --body-file /tmp/clean.md'));
|
|
111
|
+
ok('a called function with a write body is relevant', relevant('f() { gh issue comment 1 --body x; }; f'));
|
|
112
|
+
ok('an uncalled function with a write body is not', !relevant('f() { gh issue comment 1 --body x; }'));
|
|
113
|
+
ok('a called function without a write body next to a write is not', !relevant('f(){ ls; }; f; gh issue comment 1 --body-file /tmp/clean.md'));
|
|
114
|
+
ok('a function called through a substitution is relevant', relevant('f() { gh issue comment 1 --body x; }; x=$(f)'));
|
|
115
|
+
ok('alias g=gh then g issue comment is relevant', relevant('alias g=gh; g issue comment 1 --body x'));
|
|
116
|
+
ok('alias g=gh alone is not', !relevant('alias g=gh'));
|
|
117
|
+
ok('alias g=gh then a read-only call is not', !relevant('alias g=gh; g issue list'));
|
|
118
|
+
ok("alias ll='ls -l' next to a write is not", !relevant("alias ll='ls -l'; gh issue comment 1 --body-file /tmp/clean.md"));
|
|
119
|
+
ok('a write quoted in prose next to an alias is not', !relevant('echo "gh issue edit 1"; alias ll=ls'));
|
|
120
|
+
|
|
121
|
+
// --- relevance: state constructs ---
|
|
122
|
+
ok('source before a state-dependent write is relevant', relevant('source ./post.sh; gh issue comment 1 --body "$B"'));
|
|
123
|
+
ok('source before a relative body file is relevant', relevant('source x; gh issue comment 1 --body-file rel.md'));
|
|
124
|
+
ok('source before a ~ body file is relevant', relevant('source x; gh issue comment 1 --body-file ~/b.md'));
|
|
125
|
+
ok('source before a relative api @path is relevant', relevant('source x; gh issue edit 1 --body-file ./b.md'));
|
|
126
|
+
ok('source before a backtick body is relevant', relevant('source x; gh issue comment 1 --body `date`'));
|
|
127
|
+
ok('source before a literal write is not', !relevant('source venv/bin/activate && gh issue comment 1 --body-file /tmp/clean.md'));
|
|
128
|
+
ok('. before a literal inline body is not', !relevant(". venv/bin/activate && gh issue edit 1 --body 'hello'"));
|
|
129
|
+
ok('a single-quoted dollar is literal', !relevant("source x; gh issue comment 1 --body 'costs $5'"));
|
|
130
|
+
ok('an unrelated expansion beside a literal write is not', !relevant('source x; echo $HOME; gh issue comment 1 --body-file /tmp/clean.md'));
|
|
131
|
+
ok('source after the write is not', !relevant('gh issue comment 1 --body-file /tmp/clean.md; source ./post.sh'));
|
|
132
|
+
ok('source with no write is not', !relevant('source ~/.venv/bin/activate && pytest'));
|
|
133
|
+
ok('printf -v before a write is relevant', relevant('printf -v B %s x; gh issue comment 1 --body "$B"'));
|
|
134
|
+
ok('printf -v after the write is not', !relevant('gh issue comment 1 --body-file /tmp/clean.md; printf -v B %s x'));
|
|
135
|
+
ok('W2: an apostrophe inside double quotes does not hide an expansion', relevant(`printf -v B %s ''; gh issue edit 1 --body "it's $B and 'x'"`));
|
|
136
|
+
ok('W2: the same after source', relevant(`source x.sh; gh issue edit 1 --body "it's $B and 'x'"`));
|
|
137
|
+
ok('W2: stdin fed by a pipe from another statement', relevant('printf -v B %s ""; printf %s "$B" | gh issue edit 1 --body-file -'));
|
|
138
|
+
ok('W2: stdin fed by an unquoted heredoc', relevant('printf -v B %s ""; gh issue edit 1 --body-file - <<EOF\n$B\nEOF'));
|
|
139
|
+
ok('W2: stdin fed by a redirect', relevant('source x; gh issue edit 1 --body-file - < in.md'));
|
|
140
|
+
ok('W2: stdin fed by a here-string', relevant('source x; gh issue edit 1 --body-file - <<< "$B"'));
|
|
141
|
+
ok('W2: --body-file /dev/stdin', relevant('source x; gh issue edit 1 --body-file /dev/stdin'));
|
|
142
|
+
ok('W2: --input - (the stand-in classifier knows issue writes only)', relevant('source x; gh issue edit 1 --input -'));
|
|
143
|
+
ok('W2: api body=@-', relevant('source x; gh issue edit 1 -F body=@-'));
|
|
144
|
+
ok('W2: a quoted relative -F value is one token', relevant("source x; gh issue edit 1 -F 'a b.md'"));
|
|
145
|
+
ok('W2: a double-quoted dollar counts', relevant('source x; gh issue edit 1 --body "$B"'));
|
|
146
|
+
ok('W2: a literal body with an apostrophe in double quotes is not', !relevant(`source x; gh issue edit 1 --body "it's fine"`));
|
|
147
|
+
ok('W2: a plain word body is not', !relevant('source x; gh issue edit 1 --body lit'));
|
|
148
|
+
ok('W2: a single-quoted body is not', !relevant("source x; gh issue edit 1 --body 'lit'"));
|
|
149
|
+
ok('W2: an absolute body file is not', !relevant('source x; gh issue edit 1 --body-file /abs/clean.md'));
|
|
150
|
+
ok('W2: tokens are not matched across each other', !relevant("source x; gh issue edit 1 --body 'a -F b' --title t"));
|
|
151
|
+
ok('printf -v before a literal write is not', !relevant("printf -v B %s x; gh issue edit 1 --body 'hello'"));
|
|
152
|
+
ok('printf -v with no write is not', !relevant('printf -v x %s y; echo $x'));
|
|
153
|
+
|
|
154
|
+
// --- relevance: statement-local constructs ---
|
|
155
|
+
ok('a quoted api field is always relevant', relevant("gh api -X PATCH repos/o/r/issues/1 -f 'body'=\"\""));
|
|
156
|
+
ok('an in-word continuation in a write is relevant', relevant('gh issue com\\\nment 1 --body x'));
|
|
157
|
+
ok('an in-word continuation outside a write is not', !relevant('cat .e\\\nnv; gh issue comment 1 --body-file /tmp/clean.md'));
|
|
158
|
+
ok('an in-word continuation in a piped write is relevant', relevant('cat .e\\\nnv | gh issue comment 1 --body-file -'));
|
|
159
|
+
|
|
160
|
+
ok('a construct the guard already models is skipped', firstRelevant(findUnmodelled("gh api repos/o/r/issues/1 -f 'body'=x"), isWrite, ['api-field']) === null);
|
|
161
|
+
|
|
162
|
+
// --- the message ---
|
|
163
|
+
{
|
|
164
|
+
const hit = findUnmodelled('eval "gh issue comment 1 --body ghp_' + 'a'.repeat(36) + '"')[0];
|
|
165
|
+
const redact = (t) => t.replace(/ghp_[A-Za-z0-9]+/g, '<REDACTED>');
|
|
166
|
+
const msg = unmodelledReason('secret-guard', 'the body it posts is secret-free', hit, redact);
|
|
167
|
+
ok('the message names the guard, the construct and the rewrite', /^trailhead secret-guard: this command uses /.test(msg) && /Rewrite it in a simple form: /.test(msg) && msg.includes(UNMODELLED.eval.label));
|
|
168
|
+
ok('the message never echoes a token', !msg.includes('ghp_'));
|
|
169
|
+
ok('the sample is truncated to 80 chars', unmodelledReason('g', 'w', { ...hit, sample: 'x'.repeat(200) }, (t) => t).match(/`(x+)`/)[1].length === 80);
|
|
170
|
+
ok('without a redactor the sample is withheld', !/ghp_/.test(unmodelledReason('g', 'w', hit)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log(`✓ unmodelled: ${passed} assertions passed`);
|