@ericrisco/rsc 1.0.29 → 1.0.31

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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.29",
2
+ "version": "1.0.31",
3
3
  "counts": {
4
4
  "skills": 276
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -146,9 +146,20 @@ function safeRelative(cwd, absPath) {
146
146
  return rel.split(sep).join('/');
147
147
  }
148
148
 
149
+ // A manifest path is split on BOTH separators, because the input is file content — readManifest()
150
+ // reads it off disk — and not every manifest was written by safeRelative() on this platform. A path
151
+ // stored with Windows separators (`..\..\somewhere`) has no '/' to split on, so the old check saw a
152
+ // single segment, found no '..', and handed the raw string to join() — which honours backslashes on
153
+ // Windows. The guard only guarded on POSIX.
154
+ //
155
+ // Splitting into real segments does both jobs at once: `..` becomes visible to the check, and the
156
+ // path is rebuilt by join() rather than passed through, so the separator the manifest happened to
157
+ // use stops mattering. Found while fixing the Windows hook-duplication bug: same family.
158
+ const pathSegments = (value) => value.split(/[/\\]+/).filter(Boolean);
159
+
149
160
  function safeJoin(cwd, relPath) {
150
- if (!relPath || isAbsolute(relPath) || relPath.split('/').includes('..')) {
161
+ if (!relPath || isAbsolute(relPath) || pathSegments(relPath).includes('..')) {
151
162
  throw new Error(`path is outside project root: ${relPath}`);
152
163
  }
153
- return join(cwd, ...relPath.split('/'));
164
+ return join(cwd, ...pathSegments(relPath));
154
165
  }
package/targets/claude.js CHANGED
@@ -14,6 +14,31 @@ export function writeSkill(id, fromDir, toPath) {
14
14
  return linkOrCopy(fromDir, toPath);
15
15
  }
16
16
 
17
+ // A hook entry, stringified with its path separators normalized to `/`.
18
+ //
19
+ // This exists because of a Windows bug that cost real users 4× the context budget. `join()` yields
20
+ // `.rsc\session-start.mjs` on Windows, `JSON.stringify` escapes that to `.rsc\\session-start.mjs`,
21
+ // and all seven dedup filters below searched for a forward-slash needle (`.rsc/session-start.`).
22
+ // The needle never matched, so every `install` / `add` / `sync` APPENDED a hook instead of replacing
23
+ // it: a real Windows box reported every hook present four times, which means the `suggest` always-on
24
+ // body was injected 4× per session start and all six guards ran 4× per command.
25
+ //
26
+ // Worse, and unreported: `unwireHook` uses the same needles, so on Windows `uninstall` and `purge`
27
+ // removed NONE of the hooks they claim to remove — a cleanup that silently cleans nothing.
28
+ //
29
+ // ONE replacement, and only the JSON-escaped form. `JSON.stringify` escapes every backslash as
30
+ // `\\`, so a separator always arrives doubled and a LONE backslash in the JSON text is never a
31
+ // separator — it is some other escape (`\"`, `\n`, `\uXXXX`). Collapsing those too would mangle
32
+ // them for no gain, which is why the second `.replace(/\\/g, '/')` this started with is gone: a
33
+ // mutation pass showed nothing depended on it.
34
+ //
35
+ // Normalizing the HAYSTACK (never the needle) keeps every call site readable as a plain POSIX path,
36
+ // and keeps a user's own `C:\...\my-own-hook.mjs` from ever looking like an rsc hook.
37
+ // `scripts/doctor.js` already handled both forms; this is that knowledge, shared.
38
+ export function hookWiringOf(entry) {
39
+ return JSON.stringify(entry).replace(/\\\\/g, '/');
40
+ }
41
+
17
42
  // Inverse of wireHook: drop every rsc-wired hook entry (any command pointing at a
18
43
  // .rsc/ script — session-start, worklog-checkpoint, ship-guard, danger-guard, … —
19
44
  // plus the legacy cat-form) from settings.json, across all events. User hooks and
@@ -27,7 +52,7 @@ export function unwireHook(paths) {
27
52
  if (!settings.hooks) return [];
28
53
  for (const event of Object.keys(settings.hooks)) {
29
54
  settings.hooks[event] = (settings.hooks[event] || []).filter((e) => {
30
- const s = JSON.stringify(e);
55
+ const s = hookWiringOf(e);
31
56
  return !s.includes('.rsc/') && !s.includes('skills/rsc/suggest');
32
57
  });
33
58
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
@@ -61,7 +86,7 @@ export function wireHook(paths) {
61
86
  settings.hooks ||= {};
62
87
  settings.hooks.SessionStart ||= [];
63
88
  settings.hooks.SessionStart = settings.hooks.SessionStart.filter((e) => {
64
- const s = JSON.stringify(e);
89
+ const s = hookWiringOf(e);
65
90
  // drop legacy cat-form and any prior session-start script (.sh from the bash era, or .mjs)
66
91
  return !s.includes('skills/rsc/suggest') && !s.includes('.rsc/session-start.');
67
92
  });
@@ -78,7 +103,7 @@ export function wireHook(paths) {
78
103
  for (const event of ['PreCompact', 'SessionEnd']) {
79
104
  settings.hooks[event] ||= [];
80
105
  settings.hooks[event] = settings.hooks[event].filter(
81
- (e) => !JSON.stringify(e).includes('.rsc/worklog-checkpoint.'),
106
+ (e) => !hookWiringOf(e).includes('.rsc/worklog-checkpoint.'),
82
107
  );
83
108
  settings.hooks[event].push({ hooks: [{ type: 'command', command: wlCmd }] });
84
109
  }
@@ -96,7 +121,7 @@ export function wireHook(paths) {
96
121
  const sgCmd = `node "${sgDest}" "${paths.projectRoot}"`;
97
122
  settings.hooks.PreToolUse ||= [];
98
123
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
99
- (e) => !JSON.stringify(e).includes('.rsc/ship-guard.'),
124
+ (e) => !hookWiringOf(e).includes('.rsc/ship-guard.'),
100
125
  );
101
126
  settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: sgCmd }] });
102
127
 
@@ -109,7 +134,7 @@ export function wireHook(paths) {
109
134
  copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
110
135
  const dgCmd = `node "${dgDest}" "${paths.projectRoot}"`;
111
136
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
112
- (e) => !JSON.stringify(e).includes('.rsc/danger-guard.'),
137
+ (e) => !hookWiringOf(e).includes('.rsc/danger-guard.'),
113
138
  );
114
139
  settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: dgCmd }] });
115
140
 
@@ -124,7 +149,7 @@ export function wireHook(paths) {
124
149
  copyFileSync(join(HERE, 'gitmoji-guard.mjs'), gmDest);
125
150
  const gmCmd = `node "${gmDest}" "${paths.projectRoot}"`;
126
151
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
127
- (e) => !JSON.stringify(e).includes('.rsc/gitmoji-guard.'),
152
+ (e) => !hookWiringOf(e).includes('.rsc/gitmoji-guard.'),
128
153
  );
129
154
  settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: gmCmd }] });
130
155
 
@@ -141,7 +166,7 @@ export function wireHook(paths) {
141
166
  const fgCmd = `node "${fgDest}" "${paths.projectRoot}"`;
142
167
  settings.hooks.UserPromptSubmit ||= [];
143
168
  settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
144
- (e) => !JSON.stringify(e).includes('.rsc/userprompt-gate.'),
169
+ (e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
145
170
  );
146
171
  settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
147
172
 
@@ -54,11 +54,62 @@ if (typeof cmd !== 'string' || !cmd) allow();
54
54
  // High-signal, low-false-positive. Each rule: what it catches and the plain why.
55
55
  const noWhere = (verbRe) => verbRe.test(cmd) && !/\bwhere\b/i.test(cmd);
56
56
 
57
+ // A flag is a whitespace-separated token that BEGINS with a dash. That sounds obvious, and the
58
+ // first version of this rule got it wrong in a way that cost real trust: it tested
59
+ // /-[a-z]*r[a-z]*\b/ and /-[a-z]*f[a-z]*\b/ against the ENTIRE command string, so any hyphenated
60
+ // path segment carrying an "r" and an "f" read as the flags -r and -f. The segment `-fixture` alone
61
+ // satisfies both (f-i-x-t-u-r-e), so `rm skills/zz-case-fixture/SKILL.md` and `rm my-draft.md` were
62
+ // DENIED with the reason "rm with -r and -f" — a claim that was false about the command in front of
63
+ // it. It also denied commands that merely QUOTED a dangerous one, which is the same mistake the
64
+ // gitmoji guard had to fix: blocking the conversation about a command, not the command.
65
+ //
66
+ // That is worse than a nuisance. The deny message ends by telling the user how to switch this guard
67
+ // OFF, so crying wolf on a single-file delete is an active instruction to disable the thing that
68
+ // stops a real recursive wipe. Friction has to stay proportional to risk or the protection gets
69
+ // turned off (P7), and a denial's stated reason has to be true (P6).
70
+ //
71
+ // Flags are also scoped to the segment that actually runs rm: `rm notes.md && ls -lrtF` carries an
72
+ // r and an F, but they belong to ls.
73
+ function rmFlagsIn(segment) {
74
+ return segment.split(/\s+/).filter((t) => /^--?[A-Za-z]/.test(t));
75
+ }
76
+
77
+ // Wrappers that stand in front of the real command without being it. A shell with -c is included
78
+ // deliberately: `bash -c "rm -rf /"` DOES run the delete, and dropping it would have been the hole
79
+ // this rewrite opened while fixing the over-fire.
80
+ const WRAPPERS = /^(sudo|doas|env|command|time|nohup|xargs)$/i;
81
+ const SHELLS = /^(bash|sh|zsh|dash|ksh)$/i;
82
+
83
+ // `rm` has to be the command being EXECUTED, not a word inside someone else's argument. Without
84
+ // this, `echo "never run rm -rf /"` was denied — the guard blocking the conversation about a
85
+ // command instead of the command, exactly the defect the gitmoji guard had to fix with its own
86
+ // RUNS_GIT check. A guard that argues with you about a sentence is a guard you turn off.
87
+ function runsRm(segment) {
88
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
89
+ let i = 0;
90
+ while (i < tokens.length) {
91
+ const bare = tokens[i].replace(/^['"]/, '');
92
+ if (WRAPPERS.test(bare)) { i += 1; continue; }
93
+ if (SHELLS.test(bare)) {
94
+ i += 1;
95
+ while (i < tokens.length && /^-/.test(tokens[i])) i += 1; // skip -c and friends
96
+ continue;
97
+ }
98
+ return bare === 'rm' || bare.endsWith('/rm');
99
+ }
100
+ return false;
101
+ }
102
+
57
103
  function isRmRecursiveForce() {
58
104
  if (!/\brm\b/.test(cmd)) return false;
59
- const hasR = /(-[a-z]*r[a-z]*|--recursive)\b/i.test(cmd);
60
- const hasF = /(-[a-z]*f[a-z]*|--force)\b/i.test(cmd);
61
- return hasR && hasF; // -rf / -fr / -r -f / --recursive --force
105
+ for (const segment of cmd.split(/\|\||&&|[|;&]/)) {
106
+ if (!runsRm(segment)) continue;
107
+ const flags = rmFlagsIn(segment);
108
+ const hasR = flags.some((f) => /^--recursive$/i.test(f) || /^-[A-Za-z]*r/i.test(f));
109
+ const hasF = flags.some((f) => /^--force$/i.test(f) || /^-[A-Za-z]*f/i.test(f));
110
+ if (hasR && hasF) return true; // -rf / -fr / -Rf / -r -f / --recursive --force
111
+ }
112
+ return false;
62
113
  }
63
114
 
64
115
  const RULES = [