sshlg-skills 1.74.2 → 1.75.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/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/hooks/pre-tool-use.js +20 -0
- package/lib/hygiene.js +269 -1
- package/lib/labelledsecrets.js +12 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## v1.75.0 (2026-10-23): milestone M3 wave 1, two guards that let defects through
|
|
2
|
+
|
|
3
|
+
- **B-223 closed:** on 2026-10-17 a review agent's backticked `pi update` inside a double-quoted
|
|
4
|
+
argument ran an in-place `npm install -g` that hit ENOSPC and removed the `pi` link. The
|
|
5
|
+
`PreToolUse` hook now refuses a host self-update (`pi update`, `codex update`, `brew upgrade
|
|
6
|
+
<host>`, `npm i -g <host>` and the bare global upgrades) found inside `$(…)` or backticks: at
|
|
7
|
+
top level, in double quotes, nested, in an unquoted heredoc body and in a `bash -c` script. The
|
|
8
|
+
refusal names the remedy (single quotes, escaped backticks, `-F`/`--body-file`, or the update on
|
|
9
|
+
its own line). Single-quoted text, escaped substitutions and quoted-delimiter heredocs run
|
|
10
|
+
nothing and pass; a top-level `pi update` stays the operator's call. `hostSelfUpdate()` in
|
|
11
|
+
`lib/hygiene.js`; `test/hygiene_test.js` three new cases and `test/hooks_e2e_test.js` one, all
|
|
12
|
+
red on 1.74.2.
|
|
13
|
+
- **B-250 closed:** the labelled-secret detector no longer drops a long token that happens to
|
|
14
|
+
spell a placeholder word. From 100 characters a word (`dummy`, `fake`, `xxxx`, ...) counts only
|
|
15
|
+
with a boundary on both sides, and an ascending run must reach 10; shorter values keep the old
|
|
16
|
+
rules. 36 of 20,000 random 600-character JWTs were missed before, 0 of 130,000 after.
|
|
17
|
+
- **Plan:** `docs/runs/2026-10-23-milestone-m3.md`, gates that cannot lie, with the operator's
|
|
18
|
+
decisions of 2026-10-22 (B-247 closed by decision; B-248, B-180, B-249 scheduled).
|
|
19
|
+
- **Board:** B-253, `executablePart()` drops an unquoted heredoc body and in-string `#` lines
|
|
20
|
+
the shell would still run.
|
|
21
|
+
|
|
1
22
|
## v1.74.2 (2026-10-22): an unread Codex is not a green step
|
|
2
23
|
|
|
3
24
|
- **B-252 closed:** the 1.74.1 `update` printed "11 of 11 steps, all green" beside "not read:
|
package/README.md
CHANGED
|
@@ -775,7 +775,7 @@ npx sshlg-skills humanizers # anti-AI-writing skills here, with th
|
|
|
775
775
|
|---|---|---|
|
|
776
776
|
| `SessionStart` | one note saying the block is not advisory; the session's title and the ledger to watch; **and, since v0.48.0, one line naming any other enabled plugin that also injects here** — silent when there is none, and since v1.56.0 printed only when that set differs from the one recorded in `state.json` — a machine-wide record, so a second session with the same set is not told again | ~90 tokens per session |
|
|
777
777
|
| `UserPromptSubmit` | names the route this prompt asks for — **once per route per session** since v1.56.0 (a route already named is not repeated; `clear` and `compact` reset the record, `resume` keeps it, a fork is a new session id and starts without one; a payload with no session id keeps no turn record at all — its note always prints and it is never escalated). One exception: a facet route printed for the first time on a prompt that writes brings the `task-pipeline` line with it, even when delivery was named earlier | nothing on the turns that don't need it |
|
|
778
|
-
| `PreToolUse` | **copies before any write** to `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.pi/agent/AGENTS.md`, `~/.cursor/rules/sshlg-routing.mdc` and `~/.claude/settings.json` — and **refuses the write** if the copy cannot be taken. Also refuses a bare `npx skills update <member
|
|
778
|
+
| `PreToolUse` | **copies before any write** to `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.pi/agent/AGENTS.md`, `~/.cursor/rules/sshlg-routing.mdc` and `~/.claude/settings.json` — and **refuses the write** if the copy cannot be taken. Also refuses a bare `npx skills update <member>`, a gate piped into a filter and chained into `git`, and a host self-update (`pi update`, `brew upgrade codex`, `npm i -g @openai/codex`) inside `$(…)` or backticks, even in double quotes or nested, with the remedy (single quotes, escaped backticks or a file); single-quoted text runs nothing and passes | nothing until one of those files is touched |
|
|
779
779
|
| `PostToolUse` | on `Bash`, `Read` and `Grep`: names a credential the tool printed — a vendor-shaped key, or a generated-looking value under a label that names one (`x-api-key: …`, `FOO_API_KEY=…`, `Authorization: Bearer …`, `.netrc`, a URL's `user:password@`) — by its label, the tool and the file or program, **never the value**, with the rotation remedy; hashes, ids and fingerprints under neutral names, placeholders, paths and code stay silent. After any skills-CLI run, names the plain copies shadowing a plugin; after `obsidian-wiki setup`, puts back the config keys it truncates | one scan of the output (each field whole up to 1 MiB, else its first 768 KiB and last 256 KiB; it says when a scan was partial), silent when it finds nothing; a local proxy's token (an active loopback `*_BASE_URL` of the same prefix, in the same file, with no other) is not reported; MCP results are not read |
|
|
780
780
|
| `Notification` | a desktop ping when a long run goes idle or a background agent finishes | nothing — it emits a terminal sequence |
|
|
781
781
|
| `ConfigChange` | notices when something else rewrites these entries (a field this pack writes changed; a field you added is not a change, and an entry that is gone looks like one you removed, so `hooks` lists it on demand instead) | nothing — it records; `SessionStart` reports |
|
package/hooks/pre-tool-use.js
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* truncate. Nothing is denied here; `hooks/post-tool-use.js` puts back what
|
|
16
16
|
* the tool drops.
|
|
17
17
|
*
|
|
18
|
+
* Between 2 and 3, two shell-shape refusals from the same pure module: a gate piped
|
|
19
|
+
* into a filter and chained into `git`, and a host self-update (`pi update`,
|
|
20
|
+
* `brew upgrade codex`, `npm i -g @openai/codex`) inside `$(…)` or backticks (B-223).
|
|
21
|
+
*
|
|
18
22
|
* All deciding is in `lib/guard.js` and `lib/hygiene.js`, both pure and fixtured.
|
|
19
23
|
* This file moves bytes and touches the filesystem exactly where a backup happens.
|
|
20
24
|
*
|
|
@@ -233,6 +237,22 @@ process.stdin.on('end', () => {
|
|
|
233
237
|
return process.exit(0);
|
|
234
238
|
}
|
|
235
239
|
|
|
240
|
+
// 1c. A host self-update inside `$(…)` or backticks (hygiene.js #5, B-223): prose the
|
|
241
|
+
// shell reads as code, and an in-place upgrade that can remove the host it updates.
|
|
242
|
+
const selfUpdate = command && hygiene.hostSelfUpdate(command);
|
|
243
|
+
if (selfUpdate) {
|
|
244
|
+
say('deny',
|
|
245
|
+
`\`${selfUpdate.command}\` sits inside a command substitution (${selfUpdate.form}), so the shell ` +
|
|
246
|
+
'runs it before the command around it starts. A host that updates itself in place can fail ' +
|
|
247
|
+
'halfway (ENOSPC, a dropped download) and remove its own link: on 2026-10-17 a backticked ' +
|
|
248
|
+
'`pi update` in a double-quoted argument did exactly that.\n' +
|
|
249
|
+
'If this is text (a commit message, a PR body, a comment), put it in single quotes, escape the ' +
|
|
250
|
+
'backticks as \\`, or pass it through a file (`git commit -F <file>`, `gh ... --body-file <file>`).\n' +
|
|
251
|
+
'If you mean to update the host, run that command on its own, outside `$(...)` and backticks, ' +
|
|
252
|
+
'after checking free disk space (`df -h`).');
|
|
253
|
+
return process.exit(0);
|
|
254
|
+
}
|
|
255
|
+
|
|
236
256
|
// 2. The config `setup` is about to truncate.
|
|
237
257
|
if (command && hygiene.isObsidianSetup(command)) {
|
|
238
258
|
try {
|
package/lib/hygiene.js
CHANGED
|
@@ -347,8 +347,276 @@ function maskedGate(command) {
|
|
|
347
347
|
return { gate: m[1].trim(), filter: m[2], git: m[3] };
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/**
|
|
351
|
+
* 5. **A host self-update inside a command substitution (B-223).** On 2026-10-17 a review
|
|
352
|
+
* agent wrote `pi update` in backticks inside a double-quoted argument. The shell ran it
|
|
353
|
+
* before the command around it started; the in-place `npm install -g` hit ENOSPC and
|
|
354
|
+
* removed the `pi` link. Nobody typed `pi update` as a command: it was prose that the
|
|
355
|
+
* shell read as code.
|
|
356
|
+
*
|
|
357
|
+
* `executablePart` cannot see this and is not meant to: it keeps quoted strings whole and
|
|
358
|
+
* the other rules read them as words, so `"run \`pi update\`"` is one argument to them. This
|
|
359
|
+
* rule reads the payload the way the shell does, with a small lexer of its own:
|
|
360
|
+
*
|
|
361
|
+
* - `$(…)` and backticks are code, at top level, inside double quotes, nested, inside an
|
|
362
|
+
* unquoted heredoc body (the shell expands those for `cat` too) and inside the script of
|
|
363
|
+
* `bash -c '…'` (literal to the outer shell, code to the inner one).
|
|
364
|
+
* - Single quotes, `$'…'`, an escaped `\$(` or `` \` ``, a quoted-delimiter heredoc fed to a
|
|
365
|
+
* non-shell, and a comment are literal: none of them runs.
|
|
366
|
+
* - Inside a substitution, only the COMMAND position counts: `$(echo pi update)` runs `echo`.
|
|
367
|
+
*
|
|
368
|
+
* Only a substitution is refused. `pi update` typed as its own command is a deliberate act
|
|
369
|
+
* and stays the operator's call.
|
|
370
|
+
*
|
|
371
|
+
* Returns `{command, host, form}` (`form` is `$(…)` or `backticks`) or `null`. Pure, and it
|
|
372
|
+
* never throws: a payload the lexer cannot follow ends at end of input.
|
|
373
|
+
*/
|
|
374
|
+
const HOSTS = new Set([
|
|
375
|
+
'pi', 'pi-coding-agent', 'codex', 'claude', 'claude-code', 'gemini', 'gemini-cli',
|
|
376
|
+
'opencode', 'opencode-ai', 'cursor-agent', 'amp', 'goose', 'crush', 'droid', 'qwen',
|
|
377
|
+
'qwen-code', 'kimi', 'kimi-cli', 'copilot', 'hermes', 'hermes-agent',
|
|
378
|
+
]);
|
|
379
|
+
const HOST_VERBS = new Set(['update', 'upgrade', 'self-update']);
|
|
380
|
+
const SCRIPT_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
|
|
381
|
+
const PREFIXES = new Set(['sudo', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'builtin',
|
|
382
|
+
'{', '!', 'if', 'then', 'elif', 'else', 'while', 'until', 'do']);
|
|
383
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
384
|
+
const SUB = '\u0000';
|
|
385
|
+
|
|
386
|
+
/** `@openai/codex@1.2` -> `codex`; `openai/tap/codex` -> `codex`. */
|
|
387
|
+
function packageName(spec) {
|
|
388
|
+
return String(spec || '').replace(/(.)@.*$/, '$1').replace(/^.*\//, '');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Text the shell expands (`"…"`, an unquoted heredoc body): `$(` and backticks run. */
|
|
392
|
+
function expandable(src, i, stop, subs, add) {
|
|
393
|
+
const n = src.length;
|
|
394
|
+
while (i < n) {
|
|
395
|
+
const c = src[i];
|
|
396
|
+
if (stop !== null && c === stop) return i + 1;
|
|
397
|
+
if (c === '\\') {
|
|
398
|
+
const d = src[i + 1];
|
|
399
|
+
if (d === '\n') { i += 2; continue; }
|
|
400
|
+
if (d === '$' || d === '`' || d === '\\' || (stop !== null && d === stop)) { add(d); i += 2; continue; }
|
|
401
|
+
add(c); i += 1; continue;
|
|
402
|
+
}
|
|
403
|
+
if (c === '$' && src[i + 1] === '(') { i = substitution(src, i + 2, subs); add(SUB); continue; }
|
|
404
|
+
if (c === '`') { i = backtick(src, i + 1, subs); add(SUB); continue; }
|
|
405
|
+
add(c); i += 1;
|
|
406
|
+
}
|
|
407
|
+
return n;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function substitution(src, i, subs) {
|
|
411
|
+
const r = lex(src, i, ')', subs);
|
|
412
|
+
subs.push({ form: '$(…)', commands: r.commands });
|
|
413
|
+
return r.end;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function backtick(src, i, subs) {
|
|
417
|
+
// Inside backticks a backslash escapes only `$`, a backtick and itself; the body is
|
|
418
|
+
// unescaped once and read as a script, which is how nested backticks are written.
|
|
419
|
+
let body = '';
|
|
420
|
+
let j = i;
|
|
421
|
+
while (j < src.length && src[j] !== '`') {
|
|
422
|
+
if (src[j] === '\\' && ['`', '\\', '$'].includes(src[j + 1])) { body += src[j + 1]; j += 2; continue; }
|
|
423
|
+
body += src[j]; j += 1;
|
|
424
|
+
}
|
|
425
|
+
const r = lex(body, 0, null, subs);
|
|
426
|
+
subs.push({ form: 'backticks', commands: r.commands });
|
|
427
|
+
return j + 1;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function heredocBodies(src, i, list, subs) {
|
|
431
|
+
for (const h of list) {
|
|
432
|
+
let body = '';
|
|
433
|
+
while (i < src.length) {
|
|
434
|
+
let e = src.indexOf('\n', i);
|
|
435
|
+
if (e < 0) e = src.length;
|
|
436
|
+
const line = src.slice(i, e);
|
|
437
|
+
i = e + 1;
|
|
438
|
+
if (line.trim() === h.delim) break;
|
|
439
|
+
body += line + '\n';
|
|
440
|
+
}
|
|
441
|
+
if (!h.quoted) expandable(body, 0, null, subs, () => {});
|
|
442
|
+
if (h.shell) lex(body, 0, null, subs);
|
|
443
|
+
}
|
|
444
|
+
return i;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Simple commands as word lists, quotes removed, a substitution left as a marker. With
|
|
449
|
+
* `close === ')'` it reads the body of `$(…)` and returns where it ended.
|
|
450
|
+
*/
|
|
451
|
+
function lex(src, start, close, subs) {
|
|
452
|
+
const commands = [];
|
|
453
|
+
const pending = [];
|
|
454
|
+
let words = [];
|
|
455
|
+
let word = null;
|
|
456
|
+
let depth = 0;
|
|
457
|
+
let i = start;
|
|
458
|
+
const n = src.length;
|
|
459
|
+
const add = (s) => { word = (word === null ? '' : word) + s; };
|
|
460
|
+
const endWord = () => { if (word !== null) { words.push(word); word = null; } };
|
|
461
|
+
const endCommand = () => { endWord(); if (words.length) commands.push(words); words = []; };
|
|
462
|
+
while (i < n) {
|
|
463
|
+
const c = src[i];
|
|
464
|
+
if (c === ')' && close === ')' && depth === 0) { endCommand(); return { end: i + 1, commands }; }
|
|
465
|
+
if (c === ' ' || c === '\t' || c === '\r') { endWord(); i += 1; continue; }
|
|
466
|
+
if (c === '\n') {
|
|
467
|
+
endCommand(); i += 1;
|
|
468
|
+
if (pending.length) i = heredocBodies(src, i, pending.splice(0), subs);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (c === '&' && src[i + 1] === '>') { endWord(); i += 2; continue; }
|
|
472
|
+
if (c === ';' || c === '&' || c === '|') { endCommand(); i += 1; continue; }
|
|
473
|
+
if (c === '(') { endCommand(); depth += 1; i += 1; continue; }
|
|
474
|
+
if (c === ')') { endCommand(); if (depth > 0) depth -= 1; i += 1; continue; }
|
|
475
|
+
if (c === '#' && word === null) { while (i < n && src[i] !== '\n') i += 1; continue; }
|
|
476
|
+
if (c === '\\') {
|
|
477
|
+
if (src[i + 1] !== '\n') add(src[i + 1] || '');
|
|
478
|
+
i += 2; continue;
|
|
479
|
+
}
|
|
480
|
+
if (c === "'") {
|
|
481
|
+
const j = src.indexOf("'", i + 1);
|
|
482
|
+
const stop = j < 0 ? n : j;
|
|
483
|
+
add(src.slice(i + 1, stop)); i = stop + 1; continue;
|
|
484
|
+
}
|
|
485
|
+
if (c === '$' && src[i + 1] === "'") {
|
|
486
|
+
let j = i + 2; let s = '';
|
|
487
|
+
while (j < n && src[j] !== "'") {
|
|
488
|
+
if (src[j] === '\\') { s += src[j + 1] || ''; j += 2; } else { s += src[j]; j += 1; }
|
|
489
|
+
}
|
|
490
|
+
add(s); i = j + 1; continue;
|
|
491
|
+
}
|
|
492
|
+
if (c === '"') { add(''); i = expandable(src, i + 1, '"', subs, add); continue; }
|
|
493
|
+
if (c === '$' && src[i + 1] === '(') { i = substitution(src, i + 2, subs); add(SUB); continue; }
|
|
494
|
+
if (c === '`') { i = backtick(src, i + 1, subs); add(SUB); continue; }
|
|
495
|
+
if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') { endWord(); i += 3; continue; }
|
|
496
|
+
if (c === '<' && src[i + 1] === '<') {
|
|
497
|
+
endWord();
|
|
498
|
+
let j = i + 2;
|
|
499
|
+
if (src[j] === '-') j += 1;
|
|
500
|
+
while (src[j] === ' ' || src[j] === '\t') j += 1;
|
|
501
|
+
let delim = '';
|
|
502
|
+
let quoted = false;
|
|
503
|
+
while (j < n && !/[\s;&|<>()]/.test(src[j])) {
|
|
504
|
+
const d = src[j];
|
|
505
|
+
if (d === "'" || d === '"') {
|
|
506
|
+
quoted = true;
|
|
507
|
+
const k = src.indexOf(d, j + 1);
|
|
508
|
+
const stop = k < 0 ? n : k;
|
|
509
|
+
delim += src.slice(j + 1, stop); j = stop + 1; continue;
|
|
510
|
+
}
|
|
511
|
+
if (d === '\\') { quoted = true; delim += src[j + 1] || ''; j += 2; continue; }
|
|
512
|
+
delim += d; j += 1;
|
|
513
|
+
}
|
|
514
|
+
const cmd = words.find((w) => !ASSIGNMENT.test(w)) || '';
|
|
515
|
+
if (delim) pending.push({ delim, quoted, shell: HEREDOC_SHELLS.has(bareName(cmd)) });
|
|
516
|
+
i = j; continue;
|
|
517
|
+
}
|
|
518
|
+
if (c === '<' || c === '>') {
|
|
519
|
+
endWord(); i += 1;
|
|
520
|
+
if (src[i] === '&' || src[i] === '|' || src[i] === '>') i += 1;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
add(c); i += 1;
|
|
524
|
+
}
|
|
525
|
+
endCommand();
|
|
526
|
+
return { end: n, commands };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** The words from the command name on, past `sudo`, `env`, assignments and keywords. */
|
|
530
|
+
function commandWords(words) {
|
|
531
|
+
let k = 0;
|
|
532
|
+
while (k < words.length) {
|
|
533
|
+
const w = words[k];
|
|
534
|
+
if (ASSIGNMENT.test(w)) { k += 1; continue; }
|
|
535
|
+
if (PREFIXES.has(bareName(w))) {
|
|
536
|
+
k += 1;
|
|
537
|
+
while (k < words.length && /^-/.test(words[k])) k += 1;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
return words.slice(k);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Is this simple command a coding-agent host updating itself? `'pi update'` or null. */
|
|
546
|
+
function hostCall(words) {
|
|
547
|
+
const cw = commandWords(words);
|
|
548
|
+
if (!cw.length) return null;
|
|
549
|
+
const tool = bareName(cw[0]);
|
|
550
|
+
const rest = cw.slice(1);
|
|
551
|
+
const pos = rest.filter((w) => !/^-/.test(w));
|
|
552
|
+
const said = () => cw.join(' ');
|
|
553
|
+
if (HOSTS.has(tool)) {
|
|
554
|
+
return pos.length && HOST_VERBS.has(pos[0]) ? { command: `${tool} ${pos[0]}`, host: tool } : null;
|
|
555
|
+
}
|
|
556
|
+
if (['npm', 'pnpm', 'bun', 'yarn'].includes(tool)) {
|
|
557
|
+
let global = rest.some((w) => w === '-g' || w === '--global' || w === '--location=global');
|
|
558
|
+
let args = pos;
|
|
559
|
+
if (rest.includes('--location') && pos.includes('global')) {
|
|
560
|
+
global = true; args = pos.filter((w, k) => !(w === 'global' && k === pos.indexOf('global')));
|
|
561
|
+
}
|
|
562
|
+
if (tool === 'yarn' && args[0] === 'global') { global = true; args = args.slice(1); }
|
|
563
|
+
if (!global) return null;
|
|
564
|
+
const verbs = tool === 'yarn' ? ['add', 'upgrade', 'up'] : ['i', 'install', 'add', 'update', 'up', 'upgrade'];
|
|
565
|
+
if (!verbs.includes(args[0])) return null;
|
|
566
|
+
const pkgs = args.slice(1);
|
|
567
|
+
const host = pkgs.map(packageName).find((p) => HOSTS.has(p));
|
|
568
|
+
if (host) return { command: said(), host };
|
|
569
|
+
if (!pkgs.length && ['update', 'up', 'upgrade'].includes(args[0])) return { command: said(), host: 'every global package' };
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
if (tool === 'brew') {
|
|
573
|
+
if (!['upgrade', 'reinstall', 'install'].includes(pos[0])) return null;
|
|
574
|
+
const formulae = pos.slice(1);
|
|
575
|
+
const host = formulae.map(packageName).find((p) => HOSTS.has(p));
|
|
576
|
+
if (host) return { command: said(), host };
|
|
577
|
+
if (!formulae.length && pos[0] === 'upgrade') return { command: said(), host: 'every outdated formula' };
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** `bash -c '<script>'` / `sh -lc "<script>"`: the script, or null. */
|
|
584
|
+
function shellScript(words) {
|
|
585
|
+
const cw = commandWords(words);
|
|
586
|
+
if (!cw.length || !SCRIPT_SHELLS.has(bareName(cw[0]))) return null;
|
|
587
|
+
const at = cw.findIndex((w, k) => k > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(w));
|
|
588
|
+
return at > 0 && at + 1 < cw.length ? cw[at + 1] : null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function hostSelfUpdate(command) {
|
|
592
|
+
try {
|
|
593
|
+
const text = command == null ? '' : String(command);
|
|
594
|
+
const all = [];
|
|
595
|
+
const top = lex(text, 0, null, all);
|
|
596
|
+
all.push({ form: null, commands: top.commands });
|
|
597
|
+
// Bounded, so a pathological payload costs a fixed amount of work and nothing else.
|
|
598
|
+
for (let p = 0; p < all.length && p < 512; p += 1) {
|
|
599
|
+
const { form, commands } = all[p];
|
|
600
|
+
for (const words of commands) {
|
|
601
|
+
if (form) {
|
|
602
|
+
const hit = hostCall(words);
|
|
603
|
+
if (hit) return Object.assign(hit, { form });
|
|
604
|
+
}
|
|
605
|
+
const script = shellScript(words);
|
|
606
|
+
if (script !== null) {
|
|
607
|
+
const r = lex(script, 0, null, all);
|
|
608
|
+
all.push({ form, commands: r.commands });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
} catch (e) {
|
|
613
|
+
/* Pure and silent: an input this lexer cannot follow is not a reason to break a turn. */
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
|
|
350
618
|
module.exports = {
|
|
351
|
-
maskedGate,
|
|
619
|
+
maskedGate, hostSelfUpdate,
|
|
352
620
|
skillsCli, familyIds, bareFamilyInstall, launcherRunning, executablePart,
|
|
353
621
|
isObsidianSetup, parseConfig, reapply, bareName, REMEDY,
|
|
354
622
|
};
|
package/lib/labelledsecrets.js
CHANGED
|
@@ -258,8 +258,17 @@ function labelIsSecret(label) {
|
|
|
258
258
|
|
|
259
259
|
const PLACEHOLDER = /redacted|placeholder|example|changeme|change_me|your[_-]|xxxx|dummy|fake|sample|replace|todo|\*\*\*|\.\.\./i;
|
|
260
260
|
|
|
261
|
+
// From this length a value is a generated token (a JWT, a session token), and random
|
|
262
|
+
// base64 spells a placeholder word often enough to drop a real one: 36 of 20,000 random
|
|
263
|
+
// 600-character JWTs (B-250). `-` and `_` are base64url letters too, so there a word counts
|
|
264
|
+
// only with a boundary on both sides (`….REDACTED`, `<YOUR_TOKEN>`), and an ascending run
|
|
265
|
+
// must reach 10. Shorter values keep the substring rule (`...EXAMPLEKEY` in docs) and 6.
|
|
266
|
+
const LONG_VALUE = 100;
|
|
267
|
+
const PLACEHOLDER_WORD = /(?:^|[^A-Za-z0-9])(?:redacted|placeholder|example|changeme|change_me|xxxx|dummy|fake|sample|replace|todo)(?=$|[^A-Za-z0-9])|(?:^|[^A-Za-z0-9])your[_-]|\*\*\*|\.\.\./i;
|
|
268
|
+
const ascendingLimit = (v) => (v.length >= LONG_VALUE ? 10 : 6);
|
|
261
269
|
function isPlaceholder(v) {
|
|
262
|
-
|
|
270
|
+
const s = String(v);
|
|
271
|
+
return s.length >= LONG_VALUE ? PLACEHOLDER_WORD.test(s) : PLACEHOLDER.test(s);
|
|
263
272
|
}
|
|
264
273
|
|
|
265
274
|
/** The longest run of consecutive ascending characters (`3456`, `cdef`): an example, typed. */
|
|
@@ -337,7 +346,7 @@ function userinfoLooksSecret(raw) {
|
|
|
337
346
|
if (!/[A-Za-z]/.test(v) || !/\d/.test(v)) return false;
|
|
338
347
|
if (/^[$%{<*]/.test(v) || /\$\{|\{\{/.test(v)) return false;
|
|
339
348
|
if (isPlaceholder(v) || /^(?:pass(?:word)?|secret|token)\d*$/i.test(v)) return false;
|
|
340
|
-
if (ascendingRun(v) >=
|
|
349
|
+
if (ascendingRun(v) >= ascendingLimit(v)) return false;
|
|
341
350
|
if (/^(?:[a-z]+|[A-Z][a-z]*|[A-Z]+)(?:[_-](?:[a-z]+|[A-Z][a-z]*|[A-Z]+))*[_-]?\d{0,4}$/.test(v)) {
|
|
342
351
|
return false; // WORDS_LIKE_THIS_2026
|
|
343
352
|
}
|
|
@@ -380,7 +389,7 @@ function valueLooksSecret(v) {
|
|
|
380
389
|
if (/^(?:pk|pub|public)[_.]/.test(v)) return false;
|
|
381
390
|
if (/^[a-z][a-z0-9]{0,15}(?:_[a-z][a-z0-9]{0,15})+$/.test(v)
|
|
382
391
|
&& (v.match(/\d/g) || []).length <= 3) return false; // a snake_case name
|
|
383
|
-
if (ascendingRun(v) >=
|
|
392
|
+
if (ascendingRun(v) >= ascendingLimit(v)) return false; // `…123456`, `abcdef…`
|
|
384
393
|
if (looksLikeSlug(v)) return false; // a k8s object name
|
|
385
394
|
if (/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(v)) return false; // an ENV_VAR_NAME_2
|
|
386
395
|
if (v.includes('/') && v.split('/').every((seg) => /^[A-Za-z][A-Za-z_-]*\d{0,3}$/.test(seg))) {
|
package/package.json
CHANGED