cc-safety-net 1.0.0 → 1.0.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
@@ -237,6 +237,25 @@ Install CC Safety Net with OpenCode's native plugin command:
237
237
  opencode plugin -g cc-safety-net
238
238
  ```
239
239
 
240
+ > [!NOTE]
241
+ > OpenCode can sometimes keep using a stale cached plugin version. See
242
+ > anomalyco/opencode#25293 for the current tracking issue.
243
+ >
244
+ > To force OpenCode to reinstall `cc-safety-net`, remove its cached package and
245
+ > install the version you want:
246
+ >
247
+ > ```sh
248
+ > rm -rf ~/.cache/opencode/packages/cc-safety-net@latest
249
+ > opencode plugin -g -f cc-safety-net@latest
250
+ >
251
+ > If you prefer pinning a specific version:
252
+ >
253
+ > rm -rf ~/.cache/opencode/packages/cc-safety-net@latest
254
+ > opencode plugin -g -f cc-safety-net@<version>
255
+ >
256
+ > Restart OpenCode after updating so the plugin is loaded from the refreshed
257
+ > cache.
258
+
240
259
  ---
241
260
 
242
261
  ### Pi Installation
@@ -338,6 +338,10 @@ function dangerousInText(text) {
338
338
  return null;
339
339
  }
340
340
 
341
+ // src/core/analyze/segment.ts
342
+ import { realpathSync as realpathSync6 } from "node:fs";
343
+ import { normalize as normalize3 } from "node:path";
344
+
341
345
  // src/core/analyze/awk.ts
342
346
  var AWK_INTERPRETERS = new Set(["awk", "gawk", "nawk", "mawk"]);
343
347
  var REASON_AWK_SYSTEM_DYNAMIC = "Detected awk system() call with dynamic command that cannot be safely analyzed.";
@@ -4143,8 +4147,7 @@ function analyzeParallelCommand(context) {
4143
4147
  }
4144
4148
  var CWD_CHANGE_REGEX = /^\s*(?:\$\(\s*)?[({]*\s*(?:command\s+|builtin\s+)?(?:cd|pushd|popd)(?:\s|$)/;
4145
4149
  function segmentChangesCwd(segment) {
4146
- const stripped = stripLeadingGrouping(segment);
4147
- const unwrapped = stripWrappers([...stripped]);
4150
+ const unwrapped = getCwdChangeTokens(segment);
4148
4151
  if (unwrapped.length === 0) {
4149
4152
  return false;
4150
4153
  }
@@ -4163,6 +4166,32 @@ function segmentChangesCwd(segment) {
4163
4166
  const joined = segment.join(" ");
4164
4167
  return CWD_CHANGE_REGEX.test(joined);
4165
4168
  }
4169
+ function resolveCwdAfterSegment(segment, cwd) {
4170
+ if (!segmentChangesCwd(segment)) {
4171
+ return;
4172
+ }
4173
+ if (!cwd) {
4174
+ return null;
4175
+ }
4176
+ const unwrapped = getCwdChangeTokens(segment, cwd);
4177
+ const cdIndex = getCdCommandIndex(unwrapped);
4178
+ if (cdIndex === -1 || unwrapped[cdIndex] !== "cd") {
4179
+ return null;
4180
+ }
4181
+ const target = unwrapped[cdIndex + 1];
4182
+ if (!target || target === "-" || target.includes("$") || target.includes("`")) {
4183
+ return null;
4184
+ }
4185
+ try {
4186
+ const resolved = resolveChdirTarget(cwd, target);
4187
+ if (samePath(resolved, cwd)) {
4188
+ return cwd;
4189
+ }
4190
+ } catch {
4191
+ return null;
4192
+ }
4193
+ return null;
4194
+ }
4166
4195
  function getHeadAfterTimePrefix(tokens, startIndex) {
4167
4196
  let i = startIndex;
4168
4197
  while (tokens[i]?.startsWith("-")) {
@@ -4170,6 +4199,31 @@ function getHeadAfterTimePrefix(tokens, startIndex) {
4170
4199
  }
4171
4200
  return tokens[i] ?? "";
4172
4201
  }
4202
+ function getCdCommandIndex(tokens) {
4203
+ let headIndex = 0;
4204
+ if (tokens[0] === "builtin" && tokens.length > 1) {
4205
+ headIndex = 1;
4206
+ }
4207
+ if (tokens[headIndex] !== "time") {
4208
+ return headIndex;
4209
+ }
4210
+ let i = headIndex + 1;
4211
+ while (tokens[i]?.startsWith("-")) {
4212
+ i++;
4213
+ }
4214
+ return i;
4215
+ }
4216
+ function getCwdChangeTokens(segment, cwd) {
4217
+ const stripped = stripLeadingGrouping(segment);
4218
+ return stripWrappers([...stripped], cwd);
4219
+ }
4220
+ function samePath(a, b) {
4221
+ try {
4222
+ return normalize3(realpathSync6(a)) === normalize3(realpathSync6(b));
4223
+ } catch {
4224
+ return normalize3(a) === normalize3(b);
4225
+ }
4226
+ }
4173
4227
  function stripLeadingGrouping(tokens) {
4174
4228
  let i = 0;
4175
4229
  while (i < tokens.length) {
@@ -4520,8 +4574,9 @@ function analyzeCommandInternal(command2, depth, options2) {
4520
4574
  if (textReason) {
4521
4575
  return { reason: textReason, segment: segmentStr };
4522
4576
  }
4523
- if (segmentChangesCwd(segment)) {
4524
- effectiveCwd = null;
4577
+ const nextCwd2 = resolveCwdAfterSegment(segment, effectiveCwd);
4578
+ if (nextCwd2 !== undefined) {
4579
+ effectiveCwd = nextCwd2;
4525
4580
  }
4526
4581
  continue;
4527
4582
  }
@@ -4543,8 +4598,9 @@ function analyzeCommandInternal(command2, depth, options2) {
4543
4598
  if (reason) {
4544
4599
  return { reason, segment: segmentStr };
4545
4600
  }
4546
- if (segmentChangesCwd(segment)) {
4547
- effectiveCwd = null;
4601
+ const nextCwd = resolveCwdAfterSegment(segment, effectiveCwd);
4602
+ if (nextCwd !== undefined) {
4603
+ effectiveCwd = nextCwd;
4548
4604
  }
4549
4605
  applyShellGitContextEnvSegment(segment, shellGitContextState);
4550
4606
  }
@@ -7830,7 +7886,7 @@ import { existsSync as existsSync14 } from "node:fs";
7830
7886
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
7831
7887
  import { tmpdir as tmpdir4 } from "node:os";
7832
7888
  import { delimiter, extname, join as join11 } from "node:path";
7833
- var CURRENT_VERSION = "1.0.0";
7889
+ var CURRENT_VERSION = "1.0.2";
7834
7890
  var VERSION_FETCH_TIMEOUT_MS = 2000;
7835
7891
  var PI_PROBE_TIMEOUT_MS = 5000;
7836
7892
  var PI_SENTINEL_COMMAND = "cc-safety-net";
@@ -8882,12 +8938,14 @@ function explainCommand2(command2, options2) {
8882
8938
  token: redactEnvAssignmentsInString(segment[0]),
8883
8939
  matched: false
8884
8940
  });
8885
- if (segmentChangesCwd(segment)) {
8886
- segmentSteps.push({
8887
- type: "cwd-change",
8888
- segment: redactEnvAssignmentsInString(segment.join(" ")),
8889
- effectiveCwdNowUnknown: true
8890
- });
8941
+ const nextCwd2 = resolveCwdAfterSegment(segment, effectiveCwd);
8942
+ if (nextCwd2 !== undefined) {
8943
+ if (nextCwd2 !== null) {
8944
+ effectiveCwd = nextCwd2;
8945
+ trace.segments.push({ index: i, steps: segmentSteps });
8946
+ continue;
8947
+ }
8948
+ segmentSteps.push(cwdChangeStep(segment));
8891
8949
  effectiveCwd = null;
8892
8950
  }
8893
8951
  trace.segments.push({ index: i, steps: segmentSteps });
@@ -8903,12 +8961,15 @@ function explainCommand2(command2, options2) {
8903
8961
  blockReason = result.reason;
8904
8962
  blockSegment = redactEnvAssignmentsInString(segment.join(" "));
8905
8963
  }
8906
- if (segmentChangesCwd(segment)) {
8907
- segmentSteps.push({
8908
- type: "cwd-change",
8909
- segment: redactEnvAssignmentsInString(segment.join(" ")),
8910
- effectiveCwdNowUnknown: true
8911
- });
8964
+ const nextCwd = resolveCwdAfterSegment(segment, effectiveCwd);
8965
+ if (nextCwd !== undefined) {
8966
+ if (nextCwd !== null) {
8967
+ effectiveCwd = nextCwd;
8968
+ applyShellGitContextEnvSegment(segment, shellGitContextState);
8969
+ trace.segments.push({ index: i, steps: segmentSteps });
8970
+ continue;
8971
+ }
8972
+ segmentSteps.push(cwdChangeStep(segment));
8912
8973
  effectiveCwd = null;
8913
8974
  }
8914
8975
  applyShellGitContextEnvSegment(segment, shellGitContextState);
@@ -8924,6 +8985,13 @@ function explainCommand2(command2, options2) {
8924
8985
  configValid
8925
8986
  };
8926
8987
  }
8988
+ function cwdChangeStep(segment) {
8989
+ return {
8990
+ type: "cwd-change",
8991
+ segment: redactEnvAssignmentsInString(segment.join(" ")),
8992
+ effectiveCwdNowUnknown: true
8993
+ };
8994
+ }
8927
8995
  function getCustomRuleMetadata(reason, options2, cwd) {
8928
8996
  const id = reason?.match(/^\[([^\]]+)]/)?.[1];
8929
8997
  if (!id)
@@ -9327,7 +9395,7 @@ function formatTraceJson(result) {
9327
9395
  return JSON.stringify(result, null, 2);
9328
9396
  }
9329
9397
  // src/bin/help.ts
9330
- var version = "1.0.0";
9398
+ var version = "1.0.2";
9331
9399
  var INDENT = " ";
9332
9400
  var PROGRAM_NAME = "cc-safety-net";
9333
9401
  function formatOptionFlags(option) {
@@ -11,10 +11,8 @@ type ConfiguredHookAdapter<T> = Omit<HookAdapter<T>, 'outputDeny'> & {
11
11
  createDenyOutput: (message: string) => object;
12
12
  getManualPermissionAdvice?: (reason: string) => boolean | undefined;
13
13
  };
14
- export declare function outputHookDeny(createDenyOutput: (message: string) => object, reason: string, command?: string, segment?: string, manualPermissionAdvice?: boolean): void;
15
- export declare function readHookInput<T>(outputDeny: (reason: string) => void): Promise<T | null>;
16
14
  export declare function parseHookJson<T>(inputText: string, outputDeny: (reason: string) => void, strictReason: string): T | null;
15
+ /** @internal - exported for direct test coverage */
17
16
  export declare function handleBlockedHookCommand(command: string, cwd: string, sessionId: string | undefined, outputDeny: (reason: string, command?: string, segment?: string) => void): void;
18
- export declare function runHookAdapter<T>(adapter: HookAdapter<T>): Promise<void>;
19
17
  export declare function runConfiguredHookAdapter<T>(adapter: ConfiguredHookAdapter<T>): Promise<void>;
20
18
  export {};
@@ -1,10 +1,4 @@
1
1
  import { type LoadedRulesPolicy, type RulebookLockEntryWithStats } from '@/core/rules/policy';
2
- export declare function printSyncResult(result: {
3
- ok: boolean;
4
- errors: string[];
5
- warnings?: string[];
6
- entries: RulebookLockEntryWithStats[];
7
- }): void;
8
2
  export declare function printRuleChangeResult(result: {
9
3
  ok: boolean;
10
4
  errors: string[];
@@ -18,4 +12,3 @@ export declare function printRulesTestResult(result: {
18
12
  entries: RulebookLockEntryWithStats[];
19
13
  }, sourceDisplayMap?: Map<string, string>): void;
20
14
  export declare function printRulesListReport(policy: LoadedRulesPolicy, sourceDisplayMaps: Record<'user' | 'project', Map<string, string>>): void;
21
- export declare function relativeDisplay(cwd: string, path: string): string;
@@ -9,4 +9,5 @@ export interface ParallelAnalyzeContext {
9
9
  analyzeNested: (command: string, overrides?: AnalyzeNestedOverrides) => string | null;
10
10
  }
11
11
  export declare function analyzeParallel(tokens: readonly string[], context: ParallelAnalyzeContext): string | null;
12
+ /** @internal - exported for test coverage */
12
13
  export declare function extractParallelChildCommand(tokens: readonly string[]): string[];
@@ -8,3 +8,4 @@ export type InternalOptions = AnalyzeOptions & {
8
8
  };
9
9
  export declare function analyzeSegment(tokens: string[], depth: number, options: InternalOptions): string | null;
10
10
  export declare function segmentChangesCwd(segment: readonly string[]): boolean;
11
+ export declare function resolveCwdAfterSegment(segment: readonly string[], cwd: string | null | undefined): string | null | undefined;
@@ -11,5 +11,6 @@ interface XargsParseResult {
11
11
  childTokens: string[];
12
12
  replacementToken: string | null;
13
13
  }
14
+ /** @internal - exported for test coverage */
14
15
  export declare function extractXargsChildCommandWithInfo(tokens: readonly string[]): XargsParseResult;
15
16
  export {};
@@ -1,6 +1,9 @@
1
1
  export declare const GIT_CONTEXT_ENV_OVERRIDES: readonly ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"];
2
+ /** @internal - exported for test coverage */
2
3
  export declare const GIT_CONFIG_AFFECTING_ENV_NAMES: ReadonlySet<string>;
4
+ /** @internal - exported for test coverage */
3
5
  export declare const GIT_SSH_ENV_NAMES: ReadonlySet<string>;
6
+ /** @internal - exported for test coverage */
4
7
  export declare function isGitContextEnvOverrideName(name: string): boolean;
5
8
  export declare function isGitConfigEnvName(name: string): boolean;
6
9
  export declare function isTrackedGitEnvName(name: string): boolean;
@@ -1,6 +1,5 @@
1
- export { readRulesConfig, validateRulesConfig, writeDefaultRulesConfig, writeStarterRulebook, } from './config-file';
2
- export { getLegacyUserRulesConfigPath, getProjectRulesConfigPath, getProjectRulesDir, getProjectRulesLockPath, getRulebookCachePath, getRulebookDisplaySource, getRulesLockPathForConfigPath, getUserRulesConfigPath, getUserRulesDir, getUserRulesLockPath, RULES_DIR, } from './paths';
3
- export { getRulebookMigratedFrom, getRulesConfigRuntimeErrorsForConfig, getRulesConfigSourceDisplayMap, getUnknownOverrideErrorsForConfig, loadRulesPolicy, rulesPolicyToConfig, } from './scope-policy';
4
- export { parseGitHubSource } from './sources';
5
- export { addRulebookSource, removeRulebookSource, repairLocalRulesPolicy, syncRulesConfig, testRulebookSources, } from './sync';
6
- export type { LoadedRulebookInfo, LoadedRulesPolicy, RulebookLockEntry, RulebookLockEntryWithStats, RulebookSourceKind, RuleOverride, RulesConfig, RulesLockfile, RulesPolicyOptions, SyncRulesConfigOptions, SyncRulesConfigResult, } from './types';
1
+ export { readRulesConfig, writeDefaultRulesConfig, writeStarterRulebook, } from './config-file';
2
+ export { getLegacyUserRulesConfigPath, getProjectRulesConfigPath, getProjectRulesDir, getRulebookDisplaySource, getRulesLockPathForConfigPath, getUserRulesConfigPath, getUserRulesDir, getUserRulesLockPath, RULES_DIR, } from './paths';
3
+ export { getRulebookMigratedFrom, getRulesConfigRuntimeErrorsForConfig, getRulesConfigSourceDisplayMap, loadRulesPolicy, } from './scope-policy';
4
+ export { addRulebookSource, removeRulebookSource, syncRulesConfig, testRulebookSources, } from './sync';
5
+ export type { LoadedRulesPolicy, RulebookLockEntryWithStats, RuleOverride, SyncRulesConfigOptions, } from './types';
@@ -17,6 +17,7 @@ export interface ScopePaths {
17
17
  }
18
18
  export declare function getProjectRulesDir(cwd?: string): string;
19
19
  export declare function getProjectRulesConfigPath(cwd?: string): string;
20
+ /** @internal - exported for test coverage */
20
21
  export declare function getProjectRulesLockPath(cwd?: string): string;
21
22
  export declare function getUserRulesDir(options?: RulesPolicyOptions): string;
22
23
  export declare function getUserRulesConfigPath(options?: RulesPolicyOptions): string;
@@ -11,6 +11,7 @@ interface ScopePolicy {
11
11
  export declare function loadRulesPolicy(options?: RulesPolicyOptions): LoadedRulesPolicy;
12
12
  export declare function getRulesConfigSourceDisplayMap(configPath: string): Map<string, string>;
13
13
  export declare function getRulesConfigRuntimeErrorsForConfig(configPath: string, lockPath: string, options: RulesPolicyOptions): string[];
14
+ /** @internal - exported for test coverage */
14
15
  export declare function getUnknownOverrideErrorsForConfig(configPath: string, lockPath: string, options: RulesPolicyOptions): string[];
15
16
  export declare function loadScopePolicy(config: RulesConfig, lockPath: string, configDir: string, options: RulesPolicyOptions, source: 'user' | 'project'): ScopePolicy;
16
17
  export declare function rulesPolicyToConfig(policy: LoadedRulesPolicy): Config;
@@ -2,7 +2,6 @@ import type { CustomRule } from '@/types';
2
2
  export type RuleOverride = 'off' | {
3
3
  reason: string;
4
4
  };
5
- export type RulebookSourceKind = RulebookLockEntry['kind'];
6
5
  export interface RulesConfig {
7
6
  version: 1;
8
7
  rules: string[];
@@ -14,6 +14,7 @@ export interface Rulebook {
14
14
  rules: CustomRule[];
15
15
  tests: RulebookFixture[];
16
16
  }
17
+ /** @internal - exported for test coverage */
17
18
  export interface RulebookFixtureFailure {
18
19
  command: string;
19
20
  message: string;
@@ -23,6 +24,7 @@ export interface RulebookFixtureResult {
23
24
  ok: boolean;
24
25
  failures: RulebookFixtureFailure[];
25
26
  }
27
+ /** @internal - exported for test coverage */
26
28
  export declare function validateRulebook(rulebook: unknown): ValidationResult;
27
29
  export declare function runRulebookFixtures(rulebook: Rulebook): RulebookFixtureResult;
28
30
  export declare function assertValidRulebook(rulebook: unknown): Rulebook;
package/dist/index.js CHANGED
@@ -282,6 +282,10 @@ function dangerousInText(text) {
282
282
  return null;
283
283
  }
284
284
 
285
+ // src/core/analyze/segment.ts
286
+ import { realpathSync as realpathSync6 } from "node:fs";
287
+ import { normalize as normalize3 } from "node:path";
288
+
285
289
  // src/core/analyze/awk.ts
286
290
  var AWK_INTERPRETERS = new Set(["awk", "gawk", "nawk", "mawk"]);
287
291
  var REASON_AWK_SYSTEM_DYNAMIC = "Detected awk system() call with dynamic command that cannot be safely analyzed.";
@@ -4087,8 +4091,7 @@ function analyzeParallelCommand(context) {
4087
4091
  }
4088
4092
  var CWD_CHANGE_REGEX = /^\s*(?:\$\(\s*)?[({]*\s*(?:command\s+|builtin\s+)?(?:cd|pushd|popd)(?:\s|$)/;
4089
4093
  function segmentChangesCwd(segment) {
4090
- const stripped = stripLeadingGrouping(segment);
4091
- const unwrapped = stripWrappers([...stripped]);
4094
+ const unwrapped = getCwdChangeTokens(segment);
4092
4095
  if (unwrapped.length === 0) {
4093
4096
  return false;
4094
4097
  }
@@ -4107,6 +4110,32 @@ function segmentChangesCwd(segment) {
4107
4110
  const joined = segment.join(" ");
4108
4111
  return CWD_CHANGE_REGEX.test(joined);
4109
4112
  }
4113
+ function resolveCwdAfterSegment(segment, cwd) {
4114
+ if (!segmentChangesCwd(segment)) {
4115
+ return;
4116
+ }
4117
+ if (!cwd) {
4118
+ return null;
4119
+ }
4120
+ const unwrapped = getCwdChangeTokens(segment, cwd);
4121
+ const cdIndex = getCdCommandIndex(unwrapped);
4122
+ if (cdIndex === -1 || unwrapped[cdIndex] !== "cd") {
4123
+ return null;
4124
+ }
4125
+ const target = unwrapped[cdIndex + 1];
4126
+ if (!target || target === "-" || target.includes("$") || target.includes("`")) {
4127
+ return null;
4128
+ }
4129
+ try {
4130
+ const resolved = resolveChdirTarget(cwd, target);
4131
+ if (samePath(resolved, cwd)) {
4132
+ return cwd;
4133
+ }
4134
+ } catch {
4135
+ return null;
4136
+ }
4137
+ return null;
4138
+ }
4110
4139
  function getHeadAfterTimePrefix(tokens, startIndex) {
4111
4140
  let i = startIndex;
4112
4141
  while (tokens[i]?.startsWith("-")) {
@@ -4114,6 +4143,31 @@ function getHeadAfterTimePrefix(tokens, startIndex) {
4114
4143
  }
4115
4144
  return tokens[i] ?? "";
4116
4145
  }
4146
+ function getCdCommandIndex(tokens) {
4147
+ let headIndex = 0;
4148
+ if (tokens[0] === "builtin" && tokens.length > 1) {
4149
+ headIndex = 1;
4150
+ }
4151
+ if (tokens[headIndex] !== "time") {
4152
+ return headIndex;
4153
+ }
4154
+ let i = headIndex + 1;
4155
+ while (tokens[i]?.startsWith("-")) {
4156
+ i++;
4157
+ }
4158
+ return i;
4159
+ }
4160
+ function getCwdChangeTokens(segment, cwd) {
4161
+ const stripped = stripLeadingGrouping(segment);
4162
+ return stripWrappers([...stripped], cwd);
4163
+ }
4164
+ function samePath(a, b) {
4165
+ try {
4166
+ return normalize3(realpathSync6(a)) === normalize3(realpathSync6(b));
4167
+ } catch {
4168
+ return normalize3(a) === normalize3(b);
4169
+ }
4170
+ }
4117
4171
  function stripLeadingGrouping(tokens) {
4118
4172
  let i = 0;
4119
4173
  while (i < tokens.length) {
@@ -4464,8 +4518,9 @@ function analyzeCommandInternal(command2, depth, options2) {
4464
4518
  if (textReason) {
4465
4519
  return { reason: textReason, segment: segmentStr };
4466
4520
  }
4467
- if (segmentChangesCwd(segment)) {
4468
- effectiveCwd = null;
4521
+ const nextCwd2 = resolveCwdAfterSegment(segment, effectiveCwd);
4522
+ if (nextCwd2 !== undefined) {
4523
+ effectiveCwd = nextCwd2;
4469
4524
  }
4470
4525
  continue;
4471
4526
  }
@@ -4487,8 +4542,9 @@ function analyzeCommandInternal(command2, depth, options2) {
4487
4542
  if (reason) {
4488
4543
  return { reason, segment: segmentStr };
4489
4544
  }
4490
- if (segmentChangesCwd(segment)) {
4491
- effectiveCwd = null;
4545
+ const nextCwd = resolveCwdAfterSegment(segment, effectiveCwd);
4546
+ if (nextCwd !== undefined) {
4547
+ effectiveCwd = nextCwd;
4492
4548
  }
4493
4549
  applyShellGitContextEnvSegment(segment, shellGitContextState);
4494
4550
  }
@@ -11,5 +11,6 @@ type PiCommandContext = {
11
11
  isIdle: () => boolean;
12
12
  };
13
13
  export declare function registerBuiltinCommands(pi: PiCommandApi): void;
14
+ /** @internal - exported for test coverage */
14
15
  export declare function buildSafetyNetCommandPrompt(args: string): string;
15
16
  export {};
@@ -1,5 +1,5 @@
1
1
  import { registerBuiltinCommands } from '@/pi/builtin-commands';
2
- import { registerToolUseEvent } from '@/pi/tool-use';
3
- type PiExtensionApi = Parameters<typeof registerBuiltinCommands>[0] & Parameters<typeof registerToolUseEvent>[0];
2
+ import { registerToolCallEvent } from '@/pi/tool-call';
3
+ type PiExtensionApi = Parameters<typeof registerBuiltinCommands>[0] & Parameters<typeof registerToolCallEvent>[0];
4
4
  export default function ccSafetyNetPiExtension(pi: PiExtensionApi): void;
5
5
  export {};
package/dist/pi/index.js CHANGED
@@ -275,6 +275,9 @@ function buildSafetyNetCommandPrompt(args) {
275
275
 
276
276
  ${args.trim() || DEFAULT_USER_REQUEST}`;
277
277
  }
278
+ // src/pi/tool-call.ts
279
+ import { resolve as resolve8 } from "node:path";
280
+
278
281
  // src/core/analyze/dangerous-text.ts
279
282
  function dangerousInText(text) {
280
283
  const t = text.toLowerCase();
@@ -343,6 +346,10 @@ function dangerousInText(text) {
343
346
  return null;
344
347
  }
345
348
 
349
+ // src/core/analyze/segment.ts
350
+ import { realpathSync as realpathSync6 } from "node:fs";
351
+ import { normalize as normalize3 } from "node:path";
352
+
346
353
  // src/core/analyze/awk.ts
347
354
  var AWK_INTERPRETERS = new Set(["awk", "gawk", "nawk", "mawk"]);
348
355
  var REASON_AWK_SYSTEM_DYNAMIC = "Detected awk system() call with dynamic command that cannot be safely analyzed.";
@@ -4148,8 +4155,7 @@ function analyzeParallelCommand(context) {
4148
4155
  }
4149
4156
  var CWD_CHANGE_REGEX = /^\s*(?:\$\(\s*)?[({]*\s*(?:command\s+|builtin\s+)?(?:cd|pushd|popd)(?:\s|$)/;
4150
4157
  function segmentChangesCwd(segment) {
4151
- const stripped = stripLeadingGrouping(segment);
4152
- const unwrapped = stripWrappers([...stripped]);
4158
+ const unwrapped = getCwdChangeTokens(segment);
4153
4159
  if (unwrapped.length === 0) {
4154
4160
  return false;
4155
4161
  }
@@ -4168,6 +4174,32 @@ function segmentChangesCwd(segment) {
4168
4174
  const joined = segment.join(" ");
4169
4175
  return CWD_CHANGE_REGEX.test(joined);
4170
4176
  }
4177
+ function resolveCwdAfterSegment(segment, cwd) {
4178
+ if (!segmentChangesCwd(segment)) {
4179
+ return;
4180
+ }
4181
+ if (!cwd) {
4182
+ return null;
4183
+ }
4184
+ const unwrapped = getCwdChangeTokens(segment, cwd);
4185
+ const cdIndex = getCdCommandIndex(unwrapped);
4186
+ if (cdIndex === -1 || unwrapped[cdIndex] !== "cd") {
4187
+ return null;
4188
+ }
4189
+ const target = unwrapped[cdIndex + 1];
4190
+ if (!target || target === "-" || target.includes("$") || target.includes("`")) {
4191
+ return null;
4192
+ }
4193
+ try {
4194
+ const resolved = resolveChdirTarget(cwd, target);
4195
+ if (samePath(resolved, cwd)) {
4196
+ return cwd;
4197
+ }
4198
+ } catch {
4199
+ return null;
4200
+ }
4201
+ return null;
4202
+ }
4171
4203
  function getHeadAfterTimePrefix(tokens, startIndex) {
4172
4204
  let i = startIndex;
4173
4205
  while (tokens[i]?.startsWith("-")) {
@@ -4175,6 +4207,31 @@ function getHeadAfterTimePrefix(tokens, startIndex) {
4175
4207
  }
4176
4208
  return tokens[i] ?? "";
4177
4209
  }
4210
+ function getCdCommandIndex(tokens) {
4211
+ let headIndex = 0;
4212
+ if (tokens[0] === "builtin" && tokens.length > 1) {
4213
+ headIndex = 1;
4214
+ }
4215
+ if (tokens[headIndex] !== "time") {
4216
+ return headIndex;
4217
+ }
4218
+ let i = headIndex + 1;
4219
+ while (tokens[i]?.startsWith("-")) {
4220
+ i++;
4221
+ }
4222
+ return i;
4223
+ }
4224
+ function getCwdChangeTokens(segment, cwd) {
4225
+ const stripped = stripLeadingGrouping(segment);
4226
+ return stripWrappers([...stripped], cwd);
4227
+ }
4228
+ function samePath(a, b) {
4229
+ try {
4230
+ return normalize3(realpathSync6(a)) === normalize3(realpathSync6(b));
4231
+ } catch {
4232
+ return normalize3(a) === normalize3(b);
4233
+ }
4234
+ }
4178
4235
  function stripLeadingGrouping(tokens) {
4179
4236
  let i = 0;
4180
4237
  while (i < tokens.length) {
@@ -4525,8 +4582,9 @@ function analyzeCommandInternal(command2, depth, options2) {
4525
4582
  if (textReason) {
4526
4583
  return { reason: textReason, segment: segmentStr };
4527
4584
  }
4528
- if (segmentChangesCwd(segment)) {
4529
- effectiveCwd = null;
4585
+ const nextCwd2 = resolveCwdAfterSegment(segment, effectiveCwd);
4586
+ if (nextCwd2 !== undefined) {
4587
+ effectiveCwd = nextCwd2;
4530
4588
  }
4531
4589
  continue;
4532
4590
  }
@@ -4548,8 +4606,9 @@ function analyzeCommandInternal(command2, depth, options2) {
4548
4606
  if (reason) {
4549
4607
  return { reason, segment: segmentStr };
4550
4608
  }
4551
- if (segmentChangesCwd(segment)) {
4552
- effectiveCwd = null;
4609
+ const nextCwd = resolveCwdAfterSegment(segment, effectiveCwd);
4610
+ if (nextCwd !== undefined) {
4611
+ effectiveCwd = nextCwd;
4553
4612
  }
4554
4613
  applyShellGitContextEnvSegment(segment, shellGitContextState);
4555
4614
  }
@@ -6331,22 +6390,34 @@ async function runConfiguredHookAdapter(adapter) {
6331
6390
  });
6332
6391
  }
6333
6392
 
6334
- // src/pi/tool-use.ts
6335
- function registerToolUseEvent(pi) {
6336
- pi.on("tool_call", handlePiToolUse);
6393
+ // src/pi/tool-call.ts
6394
+ var PI_SHELL_TOOL_ADAPTERS = {
6395
+ bash: {
6396
+ commandField: "command"
6397
+ },
6398
+ Shell: {
6399
+ commandField: "command",
6400
+ cwdField: "working_directory"
6401
+ }
6402
+ };
6403
+ function registerToolCallEvent(pi) {
6404
+ pi.on("tool_call", handlePiToolCall);
6337
6405
  }
6338
- function handlePiToolUse(event, ctx) {
6339
- if (!isPiBashToolUseEvent(event))
6406
+ function handlePiToolCall(event, ctx) {
6407
+ const shellToolCall = getPiShellToolCall(event, ctx);
6408
+ if (!shellToolCall)
6340
6409
  return;
6341
- if (typeof event.input.command !== "string") {
6342
- return blockPiToolUse(REASON_SAFETY_NET_FAILED_CLOSED);
6410
+ if ("malformed" in shellToolCall) {
6411
+ return blockPiToolCall(REASON_SAFETY_NET_FAILED_CLOSED);
6343
6412
  }
6413
+ const command2 = shellToolCall.command;
6414
+ const cwd = shellToolCall.cwd;
6344
6415
  const modes = getCCSafetyNetEnvModes();
6345
6416
  let result;
6346
6417
  try {
6347
- result = (ctx.safetyNetAnalyzeCommand ?? analyzeCommand)(event.input.command, {
6348
- cwd: ctx.cwd,
6349
- config: loadConfig(ctx.cwd, {
6418
+ result = (ctx.safetyNetAnalyzeCommand ?? analyzeCommand)(command2, {
6419
+ cwd,
6420
+ config: loadConfig(cwd, {
6350
6421
  repairLocalRulebooks: true,
6351
6422
  ...ctx.safetyNetConfigOptions
6352
6423
  }),
@@ -6357,14 +6428,14 @@ function handlePiToolUse(event, ctx) {
6357
6428
  });
6358
6429
  } catch (error) {
6359
6430
  if (envTruthy(ENV_FLAGS.debug)) {
6360
- console.error(`CC Safety Net debug: pi tool_use analysis failed: ${redactSecrets(error instanceof Error ? error.message : String(error))}`);
6431
+ console.error(`CC Safety Net debug: pi tool_call analysis failed: ${redactSecrets(error instanceof Error ? error.message : String(error))}`);
6361
6432
  }
6362
- return blockPiToolUse(REASON_SAFETY_NET_FAILED_CLOSED, event.input.command, event.input.command);
6433
+ return blockPiToolCall(REASON_SAFETY_NET_FAILED_CLOSED, command2, command2);
6363
6434
  }
6364
6435
  if (!result) {
6365
6436
  const sessionId2 = ctx.sessionManager.getSessionFile();
6366
6437
  if (sessionId2 && envTruthy(ENV_FLAGS.debug)) {
6367
- writeAuditLog(sessionId2, event.input.command, event.input.command, "allowed", ctx.cwd, {
6438
+ writeAuditLog(sessionId2, command2, command2, "allowed", cwd, {
6368
6439
  decision: "allow"
6369
6440
  });
6370
6441
  }
@@ -6372,17 +6443,29 @@ function handlePiToolUse(event, ctx) {
6372
6443
  }
6373
6444
  const sessionId = ctx.sessionManager.getSessionFile();
6374
6445
  if (sessionId) {
6375
- writeAuditLog(sessionId, event.input.command, result.segment, result.reason, ctx.cwd);
6446
+ writeAuditLog(sessionId, command2, result.segment, result.reason, cwd);
6376
6447
  }
6377
- return blockPiToolUse(result.reason, event.input.command, result.segment, result.manualPermissionAdvice);
6448
+ return blockPiToolCall(result.reason, command2, result.segment, result.manualPermissionAdvice);
6378
6449
  }
6379
- function isPiBashToolUseEvent(event) {
6450
+ function getPiShellToolCall(event, ctx) {
6380
6451
  if (!event || typeof event !== "object")
6381
- return false;
6382
- const toolUse = event;
6383
- return toolUse.toolName === "bash" && !!toolUse.input;
6384
- }
6385
- function blockPiToolUse(reason, command2, segment, manualPermissionAdvice) {
6452
+ return;
6453
+ const toolCall = event;
6454
+ if (typeof toolCall.toolName !== "string")
6455
+ return;
6456
+ const adapter = PI_SHELL_TOOL_ADAPTERS[toolCall.toolName];
6457
+ if (!adapter)
6458
+ return;
6459
+ if (!toolCall.input || typeof toolCall.input !== "object")
6460
+ return { malformed: true };
6461
+ const command2 = toolCall.input[adapter.commandField];
6462
+ if (typeof command2 !== "string")
6463
+ return { malformed: true };
6464
+ const cwdInput = adapter.cwdField ? toolCall.input[adapter.cwdField] : undefined;
6465
+ const cwd = typeof cwdInput === "string" ? resolve8(ctx.cwd, cwdInput) : ctx.cwd;
6466
+ return { command: command2, cwd };
6467
+ }
6468
+ function blockPiToolCall(reason, command2, segment, manualPermissionAdvice) {
6386
6469
  return {
6387
6470
  block: true,
6388
6471
  reason: formatBlockedMessage({
@@ -6397,7 +6480,7 @@ function blockPiToolUse(reason, command2, segment, manualPermissionAdvice) {
6397
6480
 
6398
6481
  // src/pi/index.ts
6399
6482
  function ccSafetyNetPiExtension(pi) {
6400
- registerToolUseEvent(pi);
6483
+ registerToolCallEvent(pi);
6401
6484
  registerBuiltinCommands(pi);
6402
6485
  }
6403
6486
  export {
@@ -1,9 +1,9 @@
1
1
  import { analyzeCommand } from '@/core/analyze';
2
2
  import type { LoadConfigOptions } from '@/core/config';
3
3
  type PiApi = {
4
- on: (event: 'tool_call', handler: (event: unknown, ctx: PiToolUseContext) => PiToolUseResult) => void;
4
+ on: (event: 'tool_call', handler: (event: unknown, ctx: PiToolCallContext) => PiToolCallResult) => void;
5
5
  };
6
- type PiToolUseContext = {
6
+ type PiToolCallContext = {
7
7
  cwd: string;
8
8
  sessionManager: {
9
9
  getSessionFile: () => string | undefined;
@@ -11,10 +11,11 @@ type PiToolUseContext = {
11
11
  safetyNetAnalyzeCommand?: typeof analyzeCommand;
12
12
  safetyNetConfigOptions?: LoadConfigOptions;
13
13
  };
14
- type PiToolUseResult = {
14
+ type PiToolCallResult = {
15
15
  block: true;
16
16
  reason: string;
17
17
  } | undefined;
18
- export declare function registerToolUseEvent(pi: PiApi): void;
19
- export declare function handlePiToolUse(event: unknown, ctx: PiToolUseContext): PiToolUseResult;
18
+ export declare function registerToolCallEvent(pi: PiApi): void;
19
+ /** @internal - exported for test coverage */
20
+ export declare function handlePiToolCall(event: unknown, ctx: PiToolCallContext): PiToolCallResult;
20
21
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-safety-net",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "A coding agent CLI hook - block destructive git and filesystem commands before execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",