@ericrisco/rsc 0.1.18 → 0.1.19

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": "0.1.18",
2
+ "version": "0.1.19",
3
3
  "counts": {
4
4
  "skills": 231
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -152,6 +152,13 @@ of these too; doing them here means the user starts clean.
152
152
  installed for this project and on the machine, and flags possible overlap or skills with no
153
153
  footprint here — so the project starts with the right set, not a pile. It re-runs on a cadence via
154
154
  the SessionStart nudge. Summarize the result at the user's accompaniment level.
155
+ 4. **Danger guard follows `technical_level`.** When you record `technical_level: non-technical` (or
156
+ `mixed`, or while no profile exists yet), a `PreToolUse` guard (`.rsc/danger-guard.mjs`) **blocks**
157
+ irreversible foot-gun commands (`rm -rf`, `git push --force`, `git reset --hard`, `DROP`/`TRUNCATE`,
158
+ `DELETE`/`UPDATE` with no `WHERE`, `dd` to a device, `curl | bash`, …) and asks for a safer
159
+ alternative. A fully `technical` user is never guarded. It only turns off if the **user explicitly
160
+ asks** to allow dangerous commands — then write `.rsc/.no-danger-guard`. Mention this protection
161
+ exists when you set a non-technical level so the user is not surprised by a block.
155
162
 
156
163
  ### Phase 4 — HANDOFF
157
164
 
package/targets/claude.js CHANGED
@@ -71,7 +71,20 @@ export function wireHook(paths) {
71
71
  );
72
72
  settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: sgCmd }] });
73
73
 
74
+ // Danger guard: a PreToolUse(Bash) hook that DENIES irreversible foot-gun commands
75
+ // (rm -rf, git push --force, DROP/TRUNCATE, DELETE/UPDATE without WHERE, dd to /dev,
76
+ // curl|bash, …) when the user-profile says the user is NON-technical (default-safe
77
+ // when no profile exists yet; never guards a fully `technical` user). Materialized +
78
+ // node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
79
+ const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
80
+ copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
81
+ const dgCmd = `node "${dgDest}" "${paths.projectRoot}"`;
82
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
83
+ (e) => !JSON.stringify(e).includes('.rsc/danger-guard.'),
84
+ );
85
+ settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: dgCmd }] });
86
+
74
87
  mkdirSync(dirname(file), { recursive: true });
75
88
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
76
- return [file, scriptDest, wlDest, sgDest];
89
+ return [file, scriptDest, wlDest, sgDest, dgDest];
77
90
  }
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // rsc Danger guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash)
3
+ // as `node ...` so it runs on every platform including Windows.
4
+ // argv[2] = absolute project root stdin = PreToolUse hook JSON
5
+ //
6
+ // For a NON-TECHNICAL user (per 02-DOCS/wiki/harness/user-profile.md → technical_level),
7
+ // it DENIES irreversible, foot-gun Bash commands and tells the agent to find a safer,
8
+ // scoped alternative. A fully `technical` user is never guarded. Default-safe: if there
9
+ // is no profile yet, the harness convention is "assume non-technical", so the guard is ON.
10
+ //
11
+ // Disable per project with .rsc/.no-danger-guard — but only when the USER explicitly asks
12
+ // for it (the deny message says so). Fail-open on any internal error (never brick a shell).
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ const root = process.argv[2] || process.cwd();
17
+
18
+ function allow() { process.exit(0); }
19
+ function deny(why) {
20
+ process.stdout.write(JSON.stringify({
21
+ hookSpecificOutput: {
22
+ hookEventName: 'PreToolUse',
23
+ permissionDecision: 'deny',
24
+ permissionDecisionReason:
25
+ `BLOCKED for a non-technical user — this command ${why}. ` +
26
+ `Do NOT run it: explain the risk in plain language and propose a safer, scoped alternative ` +
27
+ `(name exact paths, add a WHERE clause, back up first, etc.). ` +
28
+ `Only if the USER explicitly insists on allowing dangerous commands here, create .rsc/.no-danger-guard to disable this guard.`,
29
+ },
30
+ }));
31
+ process.exit(0);
32
+ }
33
+
34
+ if (existsSync(join(root, '.rsc', '.no-danger-guard'))) allow();
35
+
36
+ // technical_level === 'technical' → not guarded. non-technical / mixed / missing → guarded.
37
+ function technicalLevel() {
38
+ try {
39
+ const txt = readFileSync(join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md'), 'utf8');
40
+ const m = txt.match(/technical_level:\s*([a-z-]+)/i);
41
+ return m ? m[1].toLowerCase() : null;
42
+ } catch { return null; }
43
+ }
44
+ if (technicalLevel() === 'technical') allow();
45
+
46
+ // Only Bash commands can be dangerous here.
47
+ let input = {};
48
+ try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
49
+ if ((input.tool_name || input.toolName) !== 'Bash') allow();
50
+ const cmd = input.tool_input?.command || input.toolInput?.command || '';
51
+ if (typeof cmd !== 'string' || !cmd) allow();
52
+
53
+ // --- the dangerous-command list ---------------------------------------------
54
+ // High-signal, low-false-positive. Each rule: what it catches and the plain why.
55
+ const noWhere = (verbRe) => verbRe.test(cmd) && !/\bwhere\b/i.test(cmd);
56
+
57
+ function isRmRecursiveForce() {
58
+ 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
62
+ }
63
+
64
+ const RULES = [
65
+ { id: 'rm-rf', why: 'deletes whole files/folders irreversibly (rm with -r and -f)', match: isRmRecursiveForce },
66
+ { id: 'find-delete', why: 'mass-deletes matched files (find … -delete / -exec rm)', match: () => /\bfind\b[^|;&]*(-delete\b|-exec\s+rm\b)/i.test(cmd) },
67
+ { id: 'dd-disk', why: 'overwrites a raw disk device and can destroy the whole drive (dd of=/dev/…)', match: () => /\bdd\b[^|;&]*\bof=\/dev\//i.test(cmd) },
68
+ { id: 'mkfs', why: 'formats a filesystem, erasing everything on it (mkfs)', match: () => /\bmkfs(\.\w+)?\b/i.test(cmd) },
69
+ { id: 'curl-pipe-shell', why: 'pipes a downloaded script straight into a shell (curl|bash) — runs untrusted code', match: () => /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i.test(cmd) },
70
+
71
+ { id: 'git-push-force', why: 'force-pushes and can overwrite shared history for everyone (git push --force)', match: () => /\bgit\s+push\b[^|;&]*(--force(?!-with-lease)\b|\s-f\b)/i.test(cmd) },
72
+ { id: 'git-reset-hard', why: 'throws away all uncommitted work with no undo (git reset --hard)', match: () => /\bgit\s+reset\b[^|;&]*--hard\b/i.test(cmd) },
73
+ { id: 'git-clean', why: 'permanently deletes untracked files (git clean -f)', match: () => /\bgit\s+clean\b[^|;&]*-[a-z]*f/i.test(cmd) },
74
+ { id: 'git-branch-D', why: 'force-deletes a branch even if its work was never merged (git branch -D)', match: () => /\bgit\s+branch\b[^|;&]*\s-D\b/.test(cmd) },
75
+
76
+ { id: 'sql-drop', why: 'drops an entire database/schema/table (DROP …)', match: () => /\bdrop\s+(database|schema|table)\b/i.test(cmd) },
77
+ { id: 'sql-truncate', why: 'empties an entire table (TRUNCATE)', match: () => /\btruncate\s+(table\s+)?\S/i.test(cmd) },
78
+ { id: 'sql-delete-no-where', why: 'deletes EVERY row — a DELETE with no WHERE clause', match: () => noWhere(/\bdelete\s+from\s+\S/i) },
79
+ { id: 'sql-update-no-where', why: 'rewrites EVERY row — an UPDATE with no WHERE clause', match: () => noWhere(/\bupdate\s+\S+\s+set\b/i) },
80
+ { id: 'mongo-wipe', why: 'drops a collection/database or deletes all documents (drop()/dropDatabase/deleteMany({}))', match: () => /\.drop\(\s*\)|dropdatabase\s*\(|deletemany\(\s*\{\s*\}\s*\)|\.remove\(\s*\{\s*\}\s*\)/i.test(cmd) },
81
+ ];
82
+
83
+ for (const rule of RULES) {
84
+ try { if (rule.match()) deny(rule.why); } catch { /* a rule erroring must never block */ }
85
+ }
86
+
87
+ allow();