cc-safety-net 0.8.0 → 0.8.2

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.md CHANGED
@@ -164,11 +164,9 @@ Running both together provides defense-in-depth. Sandboxing handles unknown thre
164
164
  ```bash
165
165
  /plugin marketplace add kenryu42/cc-marketplace
166
166
  /plugin install safety-net@cc-marketplace
167
+ /reload-plugins
167
168
  ```
168
169
 
169
- > [!NOTE]
170
- > After installing the plugin, you need to restart your Claude Code for it to take effect.
171
-
172
170
  ### Claude Code Auto-Update
173
171
 
174
172
  1. Run `/plugin` → Select `Marketplaces` → Choose `cc-marketplace` → Enable auto-update
@@ -207,95 +205,14 @@ gemini extensions install https://github.com/kenryu42/gemini-safety-net
207
205
 
208
206
  ### GitHub Copilot CLI Installation
209
207
 
210
- Safety Net supports GitHub Copilot CLI via its [hooks system](https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks).
211
-
212
- > [!NOTE]
213
- > Copilot CLI currently supports two hook configuration styles:
214
- >
215
- > - Hook files:
216
- > - repository: `.github/hooks/*.json`
217
- > - user: `~/.copilot/hooks/*.json` on Copilot CLI `0.0.422+`
218
- > - Inline `hooks` inside Copilot config files on Copilot CLI `1.0.8+`:
219
- > - user: `~/.copilot/config.json`
220
- > - repository: `.github/copilot/settings.json`
221
- > - local override: `.github/copilot/settings.local.json`
222
- >
223
- > Copilot settings cascade from user -> repository -> local (later files override earlier ones, so local overrides repository overrides user). `disableAllHooks: true` disables both repo-level and user-level hooks. If you use `COPILOT_HOME`, replace `~/.copilot` with that directory.
224
-
225
- #### Option A: Hook Files
226
-
227
- This is the classic hook-file format. It still works, and it is the easiest shared setup for a repository.
228
-
229
- 1. **Create the hooks directory** in your repository:
230
-
231
- ```bash
232
- mkdir -p .github/hooks
233
- ```
234
-
235
- 2. **Create `.github/hooks/safety-net.json`**:
236
-
237
- ```json
238
- {
239
- "version": 1,
240
- "hooks": {
241
- "preToolUse": [
242
- {
243
- "type": "command",
244
- "bash": "npx -y cc-safety-net --copilot-cli",
245
- "cwd": ".",
246
- "timeoutSec": 15
247
- }
248
- ]
249
- }
250
- }
251
- ```
252
-
253
- 3. **Restart Copilot CLI** — hooks are loaded at session start.
254
-
255
- The hook will intercept shell commands and block destructive operations before they execute.
256
-
257
- To install the same hook globally for your user account on Copilot CLI `0.0.422+`, place the same JSON file in:
258
-
259
- - `~/.copilot/hooks/safety-net.json`
260
-
261
- #### Option B: Inline Hooks In Copilot Settings
262
-
263
- On Copilot CLI `1.0.8+`, you can define the same hook inline in Copilot settings files instead of a separate `.json` file under `.github/hooks` or `~/.copilot/hooks`.
264
-
265
- ```json
266
- {
267
- "hooks": {
268
- "preToolUse": [
269
- {
270
- "type": "command",
271
- "bash": "npx -y cc-safety-net --copilot-cli",
272
- "cwd": ".",
273
- "timeoutSec": 15
274
- }
275
- ]
276
- }
277
- }
208
+ ```bash
209
+ /plugin install kenryu42/copilot-safety-net
278
210
  ```
279
211
 
280
- Use that schema in one of these files:
281
-
282
- - `~/.copilot/config.json`
283
- - `.github/copilot/settings.json`
284
- - `.github/copilot/settings.local.json`
285
-
286
- Recommended usage:
287
-
288
- - Use `~/.copilot/config.json` for your personal default across repositories.
289
- - Use `.github/copilot/settings.json` for a committed repository-wide setup.
290
- - Use `.github/copilot/settings.local.json` for personal repo-specific overrides, and add it to `.gitignore`.
291
-
292
- If you need to turn hooks off explicitly, set:
212
+ > [!NOTE]
213
+ > After installing the plugin, you need to restart your Copilot CLI for it to take effect.
293
214
 
294
- ```json
295
- {
296
- "disableAllHooks": true
297
- }
298
- ```
215
+ ---
299
216
 
300
217
  ## Status Line Integration
301
218
 
@@ -2865,6 +2865,7 @@ function normalizePathForComparison(p) {
2865
2865
  }
2866
2866
  var REASON_RM_RF = "rm -rf outside cwd is blocked. Use explicit paths within the current directory, or delete manually.";
2867
2867
  var REASON_RM_RF_ROOT_HOME = "rm -rf targeting root or home directory is extremely dangerous and always blocked.";
2868
+ var REASON_RM_HOME_CWD = "rm -rf in home directory is dangerous. Change to a project directory first.";
2868
2869
  function analyzeRm(tokens, options = {}) {
2869
2870
  const {
2870
2871
  cwd,
@@ -2921,18 +2922,16 @@ function classifyTarget(target, ctx) {
2921
2922
  if (isDangerousRootOrHomeTarget(target)) {
2922
2923
  return { kind: "root_or_home_target" };
2923
2924
  }
2924
- const anchoredCwd = ctx.anchoredCwd;
2925
- if (anchoredCwd) {
2926
- if (isCwdSelfTarget(target, anchoredCwd)) {
2927
- return { kind: "cwd_self_target" };
2928
- }
2929
- }
2930
2925
  if (isTempTarget(target, ctx.trustTmpdirVar)) {
2931
2926
  return { kind: "temp_target" };
2932
2927
  }
2928
+ const anchoredCwd = ctx.anchoredCwd;
2933
2929
  if (anchoredCwd) {
2934
2930
  if (isCwdHomeForRmPolicy(anchoredCwd, ctx.homeDir)) {
2935
- return { kind: "root_or_home_target" };
2931
+ return { kind: "home_cwd_target" };
2932
+ }
2933
+ if (isCwdSelfTarget(target, anchoredCwd)) {
2934
+ return { kind: "cwd_self_target" };
2936
2935
  }
2937
2936
  if (isTargetWithinCwd(target, anchoredCwd, ctx.resolvedCwd ?? anchoredCwd)) {
2938
2937
  return { kind: "within_anchored_cwd" };
@@ -2944,10 +2943,12 @@ function reasonForClassification(classification, ctx) {
2944
2943
  switch (classification.kind) {
2945
2944
  case "root_or_home_target":
2946
2945
  return REASON_RM_RF_ROOT_HOME;
2947
- case "cwd_self_target":
2948
- return REASON_RM_RF;
2949
2946
  case "temp_target":
2950
2947
  return null;
2948
+ case "home_cwd_target":
2949
+ return REASON_RM_HOME_CWD;
2950
+ case "cwd_self_target":
2951
+ return REASON_RM_RF;
2951
2952
  case "within_anchored_cwd":
2952
2953
  if (ctx.paranoid) {
2953
2954
  return `${REASON_RM_RF} (SAFETY_NET_PARANOID_RM enabled)`;
@@ -3069,14 +3070,6 @@ function isTargetWithinCwd(target, originalCwd, effectiveCwd) {
3069
3070
  return false;
3070
3071
  }
3071
3072
  }
3072
- function isHomeDirectory(cwd) {
3073
- const home = process.env.HOME ?? homedir3();
3074
- try {
3075
- return normalizePathForComparison(cwd) === normalizePathForComparison(home);
3076
- } catch {
3077
- return false;
3078
- }
3079
- }
3080
3073
 
3081
3074
  // src/core/analyze/parallel.ts
3082
3075
  var REASON_PARALLEL_RM = "parallel rm -rf with dynamic input is dangerous. Use explicit file list instead.";
@@ -3497,7 +3490,6 @@ function matchesBlockArgs(tokens, blockArgs, shortOpts) {
3497
3490
  // src/core/analyze/segment.ts
3498
3491
  var REASON_INTERPRETER_DANGEROUS = "Detected potentially dangerous command in interpreter code.";
3499
3492
  var REASON_INTERPRETER_BLOCKED = "Interpreter one-liners are blocked in paranoid mode.";
3500
- var REASON_RM_HOME_CWD = "rm -rf in home directory is dangerous. Change to a project directory first.";
3501
3493
  function deriveCwdContext(options) {
3502
3494
  const cwdUnknown = options.effectiveCwd === null;
3503
3495
  const cwdForRm = cwdUnknown ? undefined : options.effectiveCwd ?? options.cwd;
@@ -3561,11 +3553,6 @@ function analyzeSegment(tokens, depth, options) {
3561
3553
  }
3562
3554
  }
3563
3555
  if (isRm) {
3564
- if (cwdForRm && isHomeDirectory(cwdForRm)) {
3565
- if (hasRecursiveForceFlags(stripped)) {
3566
- return REASON_RM_HOME_CWD;
3567
- }
3568
- }
3569
3556
  const rmResult = analyzeRm(stripped, {
3570
3557
  cwd: cwdForRm,
3571
3558
  originalCwd,
@@ -3731,6 +3718,7 @@ function analyzeCommand(command, options = {}) {
3731
3718
  }
3732
3719
 
3733
3720
  // src/bin/doctor/hooks.ts
3721
+ var COPILOT_PLUGIN_CONFIG_PATH = "copilot-plugin";
3734
3722
  var SELF_TEST_CASES = [
3735
3723
  { command: "git reset --hard", description: "git reset --hard", expectBlocked: true },
3736
3724
  { command: "rm -rf /", description: "rm -rf /", expectBlocked: true },
@@ -4178,13 +4166,15 @@ function detectAllHooks(cwd, options) {
4178
4166
  errors: errors.length > 0 ? errors : undefined
4179
4167
  };
4180
4168
  }
4181
- if (hooksCheck.activeConfigPaths.length > 0) {
4169
+ if (options?.copilotPluginInstalled === true || hooksCheck.activeConfigPaths.length > 0) {
4170
+ const viaPlugin = options?.copilotPluginInstalled === true;
4171
+ const primaryConfigPath = hooksCheck.activeConfigPaths[0];
4182
4172
  return {
4183
4173
  platform: "copilot-cli",
4184
4174
  status: "configured",
4185
- method: "hook config",
4186
- configPath: hooksCheck.activeConfigPaths[0],
4187
- configPaths: hooksCheck.activeConfigPaths,
4175
+ method: viaPlugin ? "plugin list" : "hook config",
4176
+ configPath: primaryConfigPath ?? (viaPlugin ? COPILOT_PLUGIN_CONFIG_PATH : undefined),
4177
+ configPaths: hooksCheck.activeConfigPaths.length > 0 ? hooksCheck.activeConfigPaths : undefined,
4188
4178
  selfTest: runSelfTest(),
4189
4179
  errors: errors.length > 0 ? errors : undefined
4190
4180
  };
@@ -4205,11 +4195,12 @@ function detectAllHooks(cwd, options) {
4205
4195
 
4206
4196
  // src/bin/doctor/system-info.ts
4207
4197
  import { spawn } from "node:child_process";
4208
- var CURRENT_VERSION = "0.8.0";
4198
+ var CURRENT_VERSION = "0.8.2";
4209
4199
  var VERSION_FETCH_TIMEOUT_MS = 2000;
4210
4200
  function getPackageVersion() {
4211
4201
  return CURRENT_VERSION;
4212
4202
  }
4203
+ var COPILOT_PLUGIN_ID = "copilot-safety-net";
4213
4204
  var defaultVersionFetcher = async (args) => {
4214
4205
  const [cmd, ...rest] = args;
4215
4206
  if (!cmd)
@@ -4260,6 +4251,12 @@ function parseVersion(output) {
4260
4251
  `)[0]?.trim();
4261
4252
  return firstLine || null;
4262
4253
  }
4254
+ function hasCopilotSafetyNetPlugin(output) {
4255
+ if (!output)
4256
+ return false;
4257
+ const pluginPattern = new RegExp(`(^|[^a-z0-9-])${COPILOT_PLUGIN_ID}([^a-z0-9-]|$)`, "m");
4258
+ return pluginPattern.test(output);
4259
+ }
4263
4260
  async function getSystemInfo(fetcher = defaultVersionFetcher) {
4264
4261
  const fetchCopilotVersion = async () => {
4265
4262
  const binaryVersionPromise = fetcher(["copilot", "--binary-version"]);
@@ -4270,14 +4267,15 @@ async function getSystemInfo(fetcher = defaultVersionFetcher) {
4270
4267
  }
4271
4268
  return fallbackVersionPromise;
4272
4269
  };
4273
- const [claudeRaw, openCodeRaw, geminiRaw, copilotRaw, nodeRaw, npmRaw, bunRaw] = await Promise.all([
4270
+ const [claudeRaw, openCodeRaw, geminiRaw, copilotRaw, nodeRaw, npmRaw, bunRaw, pluginListRaw] = await Promise.all([
4274
4271
  fetcher(["claude", "--version"]),
4275
4272
  fetcher(["opencode", "--version"]),
4276
4273
  fetcher(["gemini", "--version"]),
4277
4274
  fetchCopilotVersion(),
4278
4275
  fetcher(["node", "--version"]),
4279
4276
  fetcher(["npm", "--version"]),
4280
- fetcher(["bun", "--version"])
4277
+ fetcher(["bun", "--version"]),
4278
+ fetcher(["copilot", "plugin", "list"])
4281
4279
  ]);
4282
4280
  return {
4283
4281
  version: CURRENT_VERSION,
@@ -4288,6 +4286,7 @@ async function getSystemInfo(fetcher = defaultVersionFetcher) {
4288
4286
  nodeVersion: parseVersion(nodeRaw),
4289
4287
  npmVersion: parseVersion(npmRaw),
4290
4288
  bunVersion: parseVersion(bunRaw),
4289
+ copilotPluginInstalled: hasCopilotSafetyNetPlugin(pluginListRaw),
4291
4290
  platform: `${process.platform} ${process.arch}`
4292
4291
  };
4293
4292
  }
@@ -4353,7 +4352,10 @@ function parseDoctorFlags(args) {
4353
4352
  async function runDoctor(options = {}) {
4354
4353
  const cwd = options.cwd ?? process.cwd();
4355
4354
  const system = await getSystemInfo();
4356
- const hooks = detectAllHooks(cwd, { copilotCliVersion: system.copilotCliVersion });
4355
+ const hooks = detectAllHooks(cwd, {
4356
+ copilotCliVersion: system.copilotCliVersion,
4357
+ copilotPluginInstalled: system.copilotPluginInstalled
4358
+ });
4357
4359
  const configInfo = getConfigInfo(cwd);
4358
4360
  const environment = getEnvironmentInfo();
4359
4361
  const activity = getActivitySummary(7);
@@ -4667,19 +4669,9 @@ function explainSegment(tokens, depth, options, steps) {
4667
4669
  return { reason };
4668
4670
  }
4669
4671
  if (isRm) {
4670
- if (effectiveCwd && isHomeDirectory(effectiveCwd) && hasRecursiveForceFlags(strippedTokens)) {
4671
- const reason2 = "rm -rf in home directory is dangerous. Change to a project directory first.";
4672
- steps.push({
4673
- type: "rule-check",
4674
- ruleModule: "rules-rm.ts",
4675
- ruleFunction: "isHomeDirectory",
4676
- matched: true,
4677
- reason: reason2
4678
- });
4679
- return { reason: reason2 };
4680
- }
4681
4672
  const reason = analyzeRm(strippedTokens, {
4682
- cwd: effectiveCwd ?? undefined,
4673
+ cwd: cwdForRm,
4674
+ originalCwd,
4683
4675
  paranoid: options.paranoidRm,
4684
4676
  allowTmpdirVar
4685
4677
  });
@@ -5276,7 +5268,7 @@ function formatTraceJson(result) {
5276
5268
  return JSON.stringify(result, null, 2);
5277
5269
  }
5278
5270
  // src/bin/help.ts
5279
- var version = "0.8.0";
5271
+ var version = "0.8.2";
5280
5272
  var INDENT = " ";
5281
5273
  var PROGRAM_NAME = "cc-safety-net";
5282
5274
  function formatOptionFlags(option) {
@@ -6,6 +6,7 @@ import type { LoadConfigOptions } from '@/core/config';
6
6
  interface HookDetectOptions extends LoadConfigOptions {
7
7
  homeDir?: string;
8
8
  copilotCliVersion?: string | null;
9
+ copilotPluginInstalled?: boolean;
9
10
  }
10
11
  /**
11
12
  * Strip JSONC-style comments and trailing commas from a string.
@@ -105,6 +105,8 @@ export interface SystemInfo {
105
105
  npmVersion: string | null;
106
106
  /** Bun version (from `bun --version`) */
107
107
  bunVersion: string | null;
108
+ /** Whether the copilot-safety-net plugin is installed (from `copilot plugin list`) */
109
+ copilotPluginInstalled: boolean;
108
110
  /** Platform (e.g., "darwin arm64") */
109
111
  platform: string;
110
112
  }
@@ -6,4 +6,5 @@ export interface AnalyzeRmOptions {
6
6
  tmpdirOverridden?: boolean;
7
7
  }
8
8
  export declare function analyzeRm(tokens: string[], options?: AnalyzeRmOptions): string | null;
9
+ /** @internal Exported for testing */
9
10
  export declare function isHomeDirectory(cwd: string): boolean;
package/dist/index.js CHANGED
@@ -1724,6 +1724,7 @@ function normalizePathForComparison(p) {
1724
1724
  }
1725
1725
  var REASON_RM_RF = "rm -rf outside cwd is blocked. Use explicit paths within the current directory, or delete manually.";
1726
1726
  var REASON_RM_RF_ROOT_HOME = "rm -rf targeting root or home directory is extremely dangerous and always blocked.";
1727
+ var REASON_RM_HOME_CWD = "rm -rf in home directory is dangerous. Change to a project directory first.";
1727
1728
  function analyzeRm(tokens, options = {}) {
1728
1729
  const {
1729
1730
  cwd,
@@ -1780,18 +1781,16 @@ function classifyTarget(target, ctx) {
1780
1781
  if (isDangerousRootOrHomeTarget(target)) {
1781
1782
  return { kind: "root_or_home_target" };
1782
1783
  }
1783
- const anchoredCwd = ctx.anchoredCwd;
1784
- if (anchoredCwd) {
1785
- if (isCwdSelfTarget(target, anchoredCwd)) {
1786
- return { kind: "cwd_self_target" };
1787
- }
1788
- }
1789
1784
  if (isTempTarget(target, ctx.trustTmpdirVar)) {
1790
1785
  return { kind: "temp_target" };
1791
1786
  }
1787
+ const anchoredCwd = ctx.anchoredCwd;
1792
1788
  if (anchoredCwd) {
1793
1789
  if (isCwdHomeForRmPolicy(anchoredCwd, ctx.homeDir)) {
1794
- return { kind: "root_or_home_target" };
1790
+ return { kind: "home_cwd_target" };
1791
+ }
1792
+ if (isCwdSelfTarget(target, anchoredCwd)) {
1793
+ return { kind: "cwd_self_target" };
1795
1794
  }
1796
1795
  if (isTargetWithinCwd(target, anchoredCwd, ctx.resolvedCwd ?? anchoredCwd)) {
1797
1796
  return { kind: "within_anchored_cwd" };
@@ -1803,10 +1802,12 @@ function reasonForClassification(classification, ctx) {
1803
1802
  switch (classification.kind) {
1804
1803
  case "root_or_home_target":
1805
1804
  return REASON_RM_RF_ROOT_HOME;
1806
- case "cwd_self_target":
1807
- return REASON_RM_RF;
1808
1805
  case "temp_target":
1809
1806
  return null;
1807
+ case "home_cwd_target":
1808
+ return REASON_RM_HOME_CWD;
1809
+ case "cwd_self_target":
1810
+ return REASON_RM_RF;
1810
1811
  case "within_anchored_cwd":
1811
1812
  if (ctx.paranoid) {
1812
1813
  return `${REASON_RM_RF} (SAFETY_NET_PARANOID_RM enabled)`;
@@ -1928,14 +1929,6 @@ function isTargetWithinCwd(target, originalCwd, effectiveCwd) {
1928
1929
  return false;
1929
1930
  }
1930
1931
  }
1931
- function isHomeDirectory(cwd) {
1932
- const home = process.env.HOME ?? homedir();
1933
- try {
1934
- return normalizePathForComparison(cwd) === normalizePathForComparison(home);
1935
- } catch {
1936
- return false;
1937
- }
1938
- }
1939
1932
 
1940
1933
  // src/core/analyze/parallel.ts
1941
1934
  var REASON_PARALLEL_RM = "parallel rm -rf with dynamic input is dangerous. Use explicit file list instead.";
@@ -2356,7 +2349,6 @@ function matchesBlockArgs(tokens, blockArgs, shortOpts) {
2356
2349
  // src/core/analyze/segment.ts
2357
2350
  var REASON_INTERPRETER_DANGEROUS = "Detected potentially dangerous command in interpreter code.";
2358
2351
  var REASON_INTERPRETER_BLOCKED = "Interpreter one-liners are blocked in paranoid mode.";
2359
- var REASON_RM_HOME_CWD = "rm -rf in home directory is dangerous. Change to a project directory first.";
2360
2352
  function deriveCwdContext(options) {
2361
2353
  const cwdUnknown = options.effectiveCwd === null;
2362
2354
  const cwdForRm = cwdUnknown ? undefined : options.effectiveCwd ?? options.cwd;
@@ -2420,11 +2412,6 @@ function analyzeSegment(tokens, depth, options) {
2420
2412
  }
2421
2413
  }
2422
2414
  if (isRm) {
2423
- if (cwdForRm && isHomeDirectory(cwdForRm)) {
2424
- if (hasRecursiveForceFlags(stripped)) {
2425
- return REASON_RM_HOME_CWD;
2426
- }
2427
- }
2428
2415
  const rmResult = analyzeRm(stripped, {
2429
2416
  cwd: cwdForRm,
2430
2417
  originalCwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-safety-net",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Claude Code / OpenCode plugin - block destructive git and filesystem commands before execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",