arcane-cli 0.30.0 → 0.31.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/dist/index.js CHANGED
@@ -2177,7 +2177,7 @@ function isArcaneHooksPath(root, value) {
2177
2177
  };
2178
2178
  return normalize(value) === normalize(ARCANE_HOOKS_DIR);
2179
2179
  }
2180
- var HOOK_BODY = `#!/bin/sh
2180
+ var PRE_PUSH_HOOK_BODY = `#!/bin/sh
2181
2181
  # Installed by Arcane because this repository's .arcane.json sets
2182
2182
  # push_policy: "blocked".
2183
2183
  #
@@ -2233,13 +2233,13 @@ async function repositoryRoot(cwd) {
2233
2233
  }
2234
2234
  return cwd;
2235
2235
  }
2236
- async function hookFilePath(cwd) {
2237
- return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, "pre-push");
2236
+ async function hookFilePath(cwd, hookName = "pre-push") {
2237
+ return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2238
2238
  }
2239
2239
  async function hooksPathValue(cwd) {
2240
2240
  return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2241
2241
  }
2242
- async function installPrePushHook(cwd) {
2242
+ async function installHook(cwd, hookName, body) {
2243
2243
  const root = await repositoryRoot(cwd);
2244
2244
  const existing = await readHooksPath(cwd);
2245
2245
  if (existing?.unreadable === true) return { status: "refused-unreadable-config" };
@@ -2256,19 +2256,27 @@ async function installPrePushHook(cwd) {
2256
2256
  return { status: "refused-default-hooks", hooks: displaced };
2257
2257
  }
2258
2258
  }
2259
- const hookPath = join4(root, ARCANE_HOOKS_DIR, "pre-push");
2260
- if (existing !== void 0 && await hookBodyMatches(cwd)) return { status: "already-ours" };
2259
+ const hookPath = join4(root, ARCANE_HOOKS_DIR, hookName);
2260
+ if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
2261
+ return { status: "already-ours" };
2262
+ }
2261
2263
  await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2262
- await writeFile5(hookPath, HOOK_BODY, "utf-8");
2264
+ await writeFile5(hookPath, body, "utf-8");
2263
2265
  await chmod(hookPath, 493);
2264
2266
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2265
2267
  return { status: "installed", path: hookPath };
2266
2268
  }
2267
- async function isHookEnforced(cwd) {
2269
+ async function installPrePushHook(cwd) {
2270
+ return installHook(cwd, "pre-push", PRE_PUSH_HOOK_BODY);
2271
+ }
2272
+ async function isHookInstalled(cwd, hookName, body) {
2268
2273
  const hooksPath = await readHooksPath(cwd);
2269
2274
  if (hooksPath === void 0 || hooksPath.unreadable === true) return false;
2270
2275
  if (!isArcaneHooksPath(await repositoryRoot(cwd), hooksPath.value)) return false;
2271
- return hookBodyMatches(cwd);
2276
+ return hookFileMatches(cwd, hookName, body);
2277
+ }
2278
+ async function isHookEnforced(cwd) {
2279
+ return isHookInstalled(cwd, "pre-push", PRE_PUSH_HOOK_BODY);
2272
2280
  }
2273
2281
  async function defaultHooks(cwd) {
2274
2282
  let hooksDir;
@@ -2284,11 +2292,11 @@ async function defaultHooks(cwd) {
2284
2292
  return [];
2285
2293
  }
2286
2294
  }
2287
- async function hookBodyMatches(cwd) {
2288
- const hookPath = await hookFilePath(cwd);
2295
+ async function hookFileMatches(cwd, hookName, body) {
2296
+ const hookPath = await hookFilePath(cwd, hookName);
2289
2297
  if (!await fileExists(hookPath)) return false;
2290
2298
  try {
2291
- if (await readFile4(hookPath, "utf-8") !== HOOK_BODY) return false;
2299
+ if (await readFile4(hookPath, "utf-8") !== body) return false;
2292
2300
  } catch {
2293
2301
  return false;
2294
2302
  }
@@ -2300,21 +2308,33 @@ async function hookBodyMatches(cwd) {
2300
2308
  return false;
2301
2309
  }
2302
2310
  }
2303
- async function removePrePushHook(cwd) {
2311
+ async function removeHook(cwd, hookName) {
2304
2312
  const existing = await readHooksPath(cwd);
2305
2313
  if (existing === void 0 || existing.unreadable === true) return;
2306
- if (!isArcaneHooksPath(await repositoryRoot(cwd), existing.value)) return;
2314
+ const root = await repositoryRoot(cwd);
2315
+ if (!isArcaneHooksPath(root, existing.value)) return;
2307
2316
  if (existing.scope !== "local" && existing.scope !== "worktree") return;
2308
- const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2309
2317
  try {
2310
- await runGit(cwd, ["config", scopeFlag, "--unset", "core.hooksPath"]);
2318
+ await rm2(await hookFilePath(cwd, hookName), { force: true });
2311
2319
  } catch {
2312
2320
  }
2321
+ const hooksDir = join4(root, ARCANE_HOOKS_DIR);
2322
+ let remaining = [];
2313
2323
  try {
2314
- await rm2(await hookFilePath(cwd), { force: true });
2324
+ remaining = await readdir3(hooksDir);
2325
+ } catch {
2326
+ remaining = [];
2327
+ }
2328
+ if (remaining.length > 0) return;
2329
+ const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2330
+ try {
2331
+ await runGit(cwd, ["config", scopeFlag, "--unset", "core.hooksPath"]);
2315
2332
  } catch {
2316
2333
  }
2317
2334
  }
2335
+ async function removePrePushHook(cwd) {
2336
+ return removeHook(cwd, "pre-push");
2337
+ }
2318
2338
  async function listRemotes(cwd) {
2319
2339
  try {
2320
2340
  const { stdout } = await runGit(cwd, ["remote"]);
@@ -2522,6 +2542,58 @@ function describeError(error) {
2522
2542
  return message.split("\n")[0].trim();
2523
2543
  }
2524
2544
 
2545
+ // src/modules/secrets-scan.ts
2546
+ var SECRETS_RULES = [
2547
+ { label: "api-key", pattern: /API[_-]KEY\s*[:=]\s*\S+/i },
2548
+ // Case-sensitive and requiring an all-caps/underscore identifier (SECRET,
2549
+ // MY_SECRET, API_SECRET_KEY...) -- the real config/env-var convention for a
2550
+ // credential-holding key. Widening this repository-wide (ARC-037 decision 3)
2551
+ // surfaced that the original case-insensitive form matched ANY lowercase
2552
+ // `secret`/`token` code identifier (e.g. `const token = value.trim();`),
2553
+ // which is overwhelmingly a parsed/resolved value, never a literal
2554
+ // credential. The `(?!\$?\{)` guard additionally excludes any
2555
+ // brace-delimited placeholder -- GitHub Actions/templating references
2556
+ // (`TOKEN: ${{ secrets.X }}`, a reference to a secret store, never the
2557
+ // secret itself) and this codebase's own documented `{UPPER_SNAKE}`
2558
+ // placeholder convention (denylist-scan.ts's `scanFile` already strips
2559
+ // those before matching for the sibling org-token scan; a bare single
2560
+ // brace, with no `scanFile`-style stripping in the copy-time call path,
2561
+ // needed the same exclusion here rather than only the double-brace case).
2562
+ // A minimum length of 8 further excludes short non-secret values like
2563
+ // `write` (`id-token: write`).
2564
+ { label: "generic-secret", pattern: /\b[A-Z0-9_]*SECRET[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
2565
+ { label: "generic-token", pattern: /\b[A-Z0-9_]*TOKEN[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
2566
+ { label: "bearer-token", pattern: /Bearer\s+[A-Za-z0-9._~+/-]{20,}/ },
2567
+ // real tokens are long
2568
+ { label: "openai-key", pattern: /sk-[A-Za-z0-9]{20,}/ },
2569
+ { label: "pem-key", pattern: /-----BEGIN [A-Z ]+-----/ },
2570
+ { label: "slack-token", pattern: /xox[bpars]-[A-Za-z0-9-]+/ },
2571
+ { label: "aws-access-key", pattern: /AKIA[0-9A-Z]{16}/ },
2572
+ { label: "github-pat", pattern: /ghp_[A-Za-z0-9]{36}/ }
2573
+ ];
2574
+ var SECRETS_PATTERNS = SECRETS_RULES.map((rule) => rule.pattern);
2575
+ var SECRETS_PRECOMMIT_HOOK_NAME = "pre-commit";
2576
+ var SECRETS_PRECOMMIT_HOOK_BODY = `#!/bin/sh
2577
+ # Installed by Arcane (ARC-037) to scan staged content for accidentally
2578
+ # committed credentials before they enter history.
2579
+ #
2580
+ # This is deliberately not tamper-proof: \`git commit --no-verify\` skips it
2581
+ # entirely. The real defense is the repository-wide scan Arcane's own build
2582
+ # runs in CI on every push -- this hook exists to catch a leak locally, for
2583
+ # free, before it ever reaches a commit.
2584
+ if ! command -v spell >/dev/null 2>&1; then
2585
+ echo "arcane: 'spell' not found on PATH -- skipping secrets scan (see: npm install -g arcane-cli)" >&2
2586
+ exit 0
2587
+ fi
2588
+ spell doctor --leaks
2589
+ `;
2590
+ async function installSecretsPrecommitHook(cwd) {
2591
+ return installHook(cwd, SECRETS_PRECOMMIT_HOOK_NAME, SECRETS_PRECOMMIT_HOOK_BODY);
2592
+ }
2593
+ async function removeSecretsPrecommitHook(cwd) {
2594
+ return removeHook(cwd, SECRETS_PRECOMMIT_HOOK_NAME);
2595
+ }
2596
+
2525
2597
  // src/commands/init.ts
2526
2598
  var VALID_PROFILES = ["full", "lite", "governance-only", "methodology", "docs"];
2527
2599
  var PROFILE_ICONS = {
@@ -2894,6 +2966,28 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2894
2966
  );
2895
2967
  }
2896
2968
  }
2969
+ try {
2970
+ const hook = await installSecretsPrecommitHook(targetDir);
2971
+ if (hook.status === "installed") {
2972
+ printInfo("Installed a pre-commit hook that scans staged content for leaked credentials.");
2973
+ } else if (hook.status === "refused-unreadable-config") {
2974
+ printWarning(
2975
+ "Did not install the secrets pre-commit hook: git could not report the current core.hooksPath (unreadable config, or a git too old for `--show-scope`)."
2976
+ );
2977
+ } else if (hook.status === "refused-default-hooks") {
2978
+ printWarning(
2979
+ `Did not install the secrets pre-commit hook: this repository has hooks in git's default directory (${hook.hooks.join(", ")}). Setting core.hooksPath would silently stop them running. Move them under \`${ARCANE_HOOKS_DIR}\` yourself and re-run.`
2980
+ );
2981
+ } else if (hook.status === "refused-foreign-hooks-path") {
2982
+ printWarning(
2983
+ `Did not install the secrets pre-commit hook: core.hooksPath is already "${hook.existing}" (${describeConfigScope(hook.scope)}), so another hook manager owns it.`
2984
+ );
2985
+ }
2986
+ } catch (err) {
2987
+ printWarning(
2988
+ `Could not install the secrets pre-commit hook: ${err instanceof Error ? err.message : String(err)}. Run \`spell doctor --leaks\` directly to scan on demand instead.`
2989
+ );
2990
+ }
2897
2991
  let agentsConfigured = false;
2898
2992
  if (hasAgents && !options.profile) {
2899
2993
  console.log();
@@ -3650,6 +3744,10 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3650
3744
  for (const file of markerFiles) {
3651
3745
  await stripMarkerSection(targetDir, file, { dryRun: false });
3652
3746
  }
3747
+ try {
3748
+ await removeSecretsPrecommitHook(targetDir);
3749
+ } catch {
3750
+ }
3653
3751
  console.log(`
3654
3752
  \u2713 Uninstalled \u2014 ${removed} files removed.`);
3655
3753
  }
@@ -3799,37 +3897,6 @@ async function scanRepository(repoRoot, rules, extensions, excludePrefixes = [])
3799
3897
  return findings;
3800
3898
  }
3801
3899
 
3802
- // src/modules/secrets-scan.ts
3803
- var SECRETS_RULES = [
3804
- { label: "api-key", pattern: /API[_-]KEY\s*[:=]\s*\S+/i },
3805
- // Case-sensitive and requiring an all-caps/underscore identifier (SECRET,
3806
- // MY_SECRET, API_SECRET_KEY...) -- the real config/env-var convention for a
3807
- // credential-holding key. Widening this repository-wide (ARC-037 decision 3)
3808
- // surfaced that the original case-insensitive form matched ANY lowercase
3809
- // `secret`/`token` code identifier (e.g. `const token = value.trim();`),
3810
- // which is overwhelmingly a parsed/resolved value, never a literal
3811
- // credential. The `(?!\$?\{)` guard additionally excludes any
3812
- // brace-delimited placeholder -- GitHub Actions/templating references
3813
- // (`TOKEN: ${{ secrets.X }}`, a reference to a secret store, never the
3814
- // secret itself) and this codebase's own documented `{UPPER_SNAKE}`
3815
- // placeholder convention (denylist-scan.ts's `scanFile` already strips
3816
- // those before matching for the sibling org-token scan; a bare single
3817
- // brace, with no `scanFile`-style stripping in the copy-time call path,
3818
- // needed the same exclusion here rather than only the double-brace case).
3819
- // A minimum length of 8 further excludes short non-secret values like
3820
- // `write` (`id-token: write`).
3821
- { label: "generic-secret", pattern: /\b[A-Z0-9_]*SECRET[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
3822
- { label: "generic-token", pattern: /\b[A-Z0-9_]*TOKEN[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
3823
- { label: "bearer-token", pattern: /Bearer\s+[A-Za-z0-9._~+/-]{20,}/ },
3824
- // real tokens are long
3825
- { label: "openai-key", pattern: /sk-[A-Za-z0-9]{20,}/ },
3826
- { label: "pem-key", pattern: /-----BEGIN [A-Z ]+-----/ },
3827
- { label: "slack-token", pattern: /xox[bpars]-[A-Za-z0-9-]+/ },
3828
- { label: "aws-access-key", pattern: /AKIA[0-9A-Z]{16}/ },
3829
- { label: "github-pat", pattern: /ghp_[A-Za-z0-9]{36}/ }
3830
- ];
3831
- var SECRETS_PATTERNS = SECRETS_RULES.map((rule) => rule.pattern);
3832
-
3833
3900
  // src/modules/platform-policy.ts
3834
3901
  import { execFile as execFile2 } from "child_process";
3835
3902
  import { promisify } from "util";