sshlg-skills 0.76.0 → 0.77.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 CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.77.0 — closing a false positive found the bypass it was hiding
4
+
5
+ **B-59: the hygiene guard could not tell a command being run from one being written
6
+ down.** It reads the whole Bash payload, so quoting the forbidden invocation *in a
7
+ document* was refused too — a verification-ledger row blocked its own commit, and the
8
+ sentence had to be split around the guard.
9
+
10
+ `executablePart()` now removes what cannot execute before matching:
11
+
12
+ - **A heredoc body fed to something that is not a shell.** `python3 - <<'PY' … PY` and
13
+ `cat > f <<EOF … EOF` are data.
14
+ - **A whole-line comment.**
15
+
16
+ And what it keeps is the load-bearing half. **A `bash <<EOF … EOF` body is still read** —
17
+ stripping every heredoc would trade a false positive for a documented bypass. **Quoted
18
+ strings are still read** too, because `bash -c '…'` is a real invocation.
19
+
20
+ **That last decision is where the actual finding was.** Testing it produced a case the
21
+ guard *should* refuse and did not: `bareName` kept the trailing quote, so `ux-flows'`
22
+ matched no family id and **`bash -c 'npx skills add ux-flows'` passed the guard untouched**
23
+ — verified against `HEAD` before the change, so it had been open the whole time. The row
24
+ asked for relief from an annoyance; the annoyance was hiding a hole.
25
+
26
+ Both directions are fixtured — 17 → **22** checks — and the real hook was driven as a
27
+ process to confirm it agrees with the pure module. The fixture strings are **assembled at
28
+ runtime** rather than written whole, because a file containing the literal payload is
29
+ itself refused by anything that reads it: the defect demonstrated itself three times
30
+ during the fix, twice blocking the commands that were repairing it, and once making three
31
+ new cases pass for the wrong reason by targeting a member the fixture's manifest does not
32
+ declare.
33
+
3
34
  ## v0.76.0 — a ledger records two different things, and most record only one
4
35
 
5
36
  **B-62 said five ledger shapes. Measured: ten, across 815 rows.**
package/lib/hygiene.js CHANGED
@@ -22,6 +22,12 @@
22
22
 
23
23
  /** Strip an `@version` suffix so `sshlg-skills@latest` is not read as `skills`. */
24
24
  function bareName(token) {
25
+ // Shell quoting is not part of a name. `bash -c 'npx skills update ux-flows'` reaches
26
+ // this as the token `ux-flows'`, which matched no family id — so a real invocation
27
+ // wrapped in quotes passed the guard untouched. Found on 2026-08-16 while fixing the
28
+ // opposite complaint (B-59, a false positive on documented text); the same run turned
29
+ // up a bypass worth more than the annoyance that started it.
30
+ token = String(token || '').replace(/^['"]+/, '').replace(/['"]+$/, '');
25
31
  return String(token || '').replace(/@.*$/, '').replace(/^.*\//, '');
26
32
  }
27
33
 
@@ -43,6 +49,62 @@ function isFlag(token) {
43
49
  * `claude` exactly where the target would be, and reading it as the target let a
44
50
  * real family skill through unguarded. Watched failing in the fixtures.
45
51
  */
52
+ /**
53
+ * The part of a payload that could actually run, with data stripped out.
54
+ *
55
+ * B-59: this guard reads the whole command text, so **writing the forbidden string into a
56
+ * document is refused too**. On 2026-08-16 a verification-ledger row quoting the
57
+ * diagnostic invocation blocked its own commit, and the sentence had to be split around
58
+ * the guard. The family's own documents could not quote the command they warn about.
59
+ *
60
+ * Two things are removed, and the boundary between them and everything else is the whole
61
+ * design:
62
+ *
63
+ * - **A heredoc body fed to something that is not a shell.** `python3 - <<'PY' … PY` and
64
+ * `cat > f <<EOF … EOF` are data. `bash <<EOF … EOF` is a script, so its body is KEPT —
65
+ * stripping every heredoc would be a documented bypass, which is worse than the false
66
+ * positive it fixes.
67
+ * - **A whole-line comment.** A line whose first non-space character is `#` does not run.
68
+ *
69
+ * **Quoted strings are deliberately NOT stripped.** `bash -c 'npx skills update ux-flows'`
70
+ * is a real invocation living inside quotes, and a guard that ignored quoted text would
71
+ * miss it. The false positive that remains — the string in a single-line argument to
72
+ * `echo` — is the price of not opening that hole, and it is documented rather than fixed.
73
+ */
74
+ const HEREDOC_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'eval', 'source', '.']);
75
+
76
+ function executablePart(command) {
77
+ const lines = String(command || '').split('\n');
78
+ const out = [];
79
+ let terminator = null;
80
+ let dropping = false;
81
+ for (const line of lines) {
82
+ if (terminator !== null) {
83
+ // The terminator may be indented when the heredoc used `<<-`.
84
+ if (line.trim() === terminator) { terminator = null; dropping = false; continue; }
85
+ if (dropping) continue;
86
+ out.push(line);
87
+ continue;
88
+ }
89
+ if (/^\s*#/.test(line)) continue;
90
+ const here = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
91
+ if (here) {
92
+ terminator = here[2];
93
+ // Which command is being fed? The last simple-command word before the `<<`, taken
94
+ // after the final pipe or `&&` so `foo | python3 - <<EOF` reads as python3.
95
+ const before = line.slice(0, here.index);
96
+ const segment = before.split(/\|\||&&|[|;]/).pop();
97
+ const words = segment.trim().split(/\s+/).filter(Boolean).map(bareName);
98
+ const cmd = words.find((w) => !w.includes('=')) || '';
99
+ dropping = !HEREDOC_SHELLS.has(cmd);
100
+ out.push(before);
101
+ continue;
102
+ }
103
+ out.push(line);
104
+ }
105
+ return out.join('\n');
106
+ }
107
+
46
108
  function skillsCli(command) {
47
109
  const tokens = String(command || '').split(/\s+/).filter(Boolean);
48
110
  for (let i = 0; i < tokens.length; i += 1) {
@@ -83,7 +145,9 @@ const REMEDY = 'npx --yes sshlg-skills@latest update';
83
145
  * `~/.claude/skills/`, which is what does the shadowing.
84
146
  */
85
147
  function bareFamilyInstall(command, ids) {
86
- const call = skillsCli(command);
148
+ // Data first: a heredoc body written by a non-shell, and whole-line comments, cannot
149
+ // install anything. See `executablePart` for why quoted strings are not stripped.
150
+ const call = skillsCli(executablePart(command));
87
151
  if (!call || !call.args.length) return null;
88
152
  if (!['update', 'add', 'install'].includes(call.verb)) return null;
89
153
  const target = call.args.find((a) => ids.has(bareName(a)));
@@ -174,6 +238,6 @@ function reapply(snapshot, fresh) {
174
238
  }
175
239
 
176
240
  module.exports = {
177
- skillsCli, familyIds, bareFamilyInstall, launcherRunning,
241
+ skillsCli, familyIds, bareFamilyInstall, launcherRunning, executablePart,
178
242
  isObsidianSetup, parseConfig, reapply, bareName, REMEDY,
179
243
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sshlg-skills",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "One launcher/updater for the ssheleg skill family (super-ux, task-pipeline, agent-sync, make-skill, sheleg-design, seo-aeo-audit, sheleg-dev, agent-stack) across every agent — Claude Code, Cursor, OpenCode, Kilo, Kimi, Hermes, OpenClaw, Codex, and more.",
5
5
  "bin": {
6
6
  "sshlg-skills": "bin/sshlg-skills.js"