arcane-cli 0.29.6 → 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.
@@ -193,7 +193,7 @@ Use this consistently across all credential storage:
193
193
  auth = { provider = "token", token = { source = "env", id = "SECRET_NAME" } }
194
194
 
195
195
  # In .env (never committed)
196
- SECRET_NAME=actual-secret-value-here
196
+ SECRET_NAME={THE_REAL_VALUE}
197
197
  ```
198
198
 
199
199
  **Enforcement: explicitly advisory prose (ARC-023) — Arcane ships no config-schema validator or lint rule in this repository that checks a downstream repo's config for inline secrets versus this reference pattern; adoption depends on the operator's own discipline.**
@@ -50,7 +50,7 @@ See also: [Hardening Checklist](hardening-checklist.md), [Authentication Strateg
50
50
  | Supply chain (package installs) | **Mitigated** | Global package installs use a user-owned directory, not a system directory. No `sudo` during install. |
51
51
  | Inter-machine communication | **Mitigated** | Encrypted mesh tunnel/VPN. No direct unencrypted LAN communication. |
52
52
  | Token/credential exposure (at rest) | **Mitigated** | Token stored as an env var in a `.env` file. Config uses a secret reference, not plaintext. State directory is mode `700`. |
53
- | Credential committed to version control | **Not mitigated** | Storage conventions are documented (secret references, `.gitignore` entries for `.env`/`*.pem`/`*.key`) but **no detection exists** — no scanner runs on the commit path, the push path, or in CI, and nothing verifies the conventions were followed. `spell-security-review`'s secrets step is an on-demand agent read, not a control. **Treat every credential that reaches a commit as compromised and rotate it — history, existing clones, and remote backups all survive deleting the file (EF-35). Enforcement: explicitly advisory prose (ARC-023) — rotating a leaked credential happens at the third-party provider that issued it; no Arcane mechanism can perform or verify that a specific credential was actually rotated.** |
53
+ | Credential committed to version control | **Partially mitigated** (ARC-037) | A pattern-based scanner (`src/modules/secrets-scan.ts`) runs at two points: a pre-commit hook step (local, `--no-verify`-bypassable by design) and a repository-wide backstop inside `npm run build`'s existing CI gate (not bypassable, catches anything that skipped or bypassed the hook before merge) — plus an on-demand `spell doctor --leaks` for a manual check anytime. This is detection, not prevention: a regex denylist can miss non-standard credential shapes, and neither point runs on a commit that is never pushed. `spell-security-review`'s secrets step remains an on-demand agent read, not a control, and is unchanged by this. **Treat every credential that reaches a commit as compromised and rotate it — history, existing clones, and remote backups all survive deleting the file (EF-35). Enforcement: explicitly advisory prose (ARC-023) — rotating a leaked credential happens at the third-party provider that issued it; no Arcane mechanism can perform or verify that a specific credential was actually rotated.** |
54
54
 
55
55
  ---
56
56
 
@@ -1,5 +1,11 @@
1
1
  {
2
2
  "selfHosted": true,
3
3
  "tracking_mode": "internal",
4
- "external_provider": null
4
+ "external_provider": null,
5
+ "secretsScanExcludePrefixes": [
6
+ "test/copy-assets.test.ts",
7
+ "test/org-token-lint.test.ts",
8
+ "test/secrets-scan.test.ts",
9
+ "src/modules/secrets-scan.ts"
10
+ ]
5
11
  }
package/dist/index.js CHANGED
@@ -112,6 +112,14 @@ function validateSubjectRoot(value, filePath) {
112
112
  throw new ManifestInvalidFieldError(filePath, "subject_root", value);
113
113
  }
114
114
  }
115
+ function isValidSecretsScanExcludePrefixes(value) {
116
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0);
117
+ }
118
+ function validateSecretsScanExcludePrefixes(value, filePath) {
119
+ if (!isValidSecretsScanExcludePrefixes(value)) {
120
+ throw new ManifestInvalidFieldError(filePath, "secretsScanExcludePrefixes", value);
121
+ }
122
+ }
115
123
  function manifestPath(targetDir) {
116
124
  return path2.join(targetDir, MANIFEST_FILE);
117
125
  }
@@ -138,6 +146,9 @@ function validateTrackingFields(manifest, filePath) {
138
146
  }
139
147
  validateSubjectRoot(manifest.subject_root, filePath);
140
148
  }
149
+ if (manifest.secretsScanExcludePrefixes !== void 0) {
150
+ validateSecretsScanExcludePrefixes(manifest.secretsScanExcludePrefixes, filePath);
151
+ }
141
152
  }
142
153
  async function readManifest(targetDir) {
143
154
  const filePath = manifestPath(targetDir);
@@ -159,6 +170,26 @@ async function readManifest(targetDir) {
159
170
  validateTrackingFields(manifest, filePath);
160
171
  return manifest;
161
172
  }
173
+ function isRecord(value) {
174
+ return typeof value === "object" && value !== null && !Array.isArray(value);
175
+ }
176
+ async function resolveSecretsScanExcludePrefixes(targetDir) {
177
+ try {
178
+ const manifest = await readManifest(targetDir);
179
+ return manifest.secretsScanExcludePrefixes ?? [];
180
+ } catch {
181
+ }
182
+ try {
183
+ const raw = await readFile(path2.join(targetDir, "src", "assets", ".arcane.json"), "utf-8");
184
+ const parsed = JSON.parse(raw);
185
+ if (isRecord(parsed) && parsed["selfHosted"] === true && parsed["tracking_mode"] === "internal") {
186
+ const prefixes = parsed["secretsScanExcludePrefixes"];
187
+ if (isValidSecretsScanExcludePrefixes(prefixes)) return prefixes;
188
+ }
189
+ } catch {
190
+ }
191
+ return [];
192
+ }
162
193
  async function writeManifest(targetDir, manifest) {
163
194
  const filePath = manifestPath(targetDir);
164
195
  await writeFile(filePath, JSON.stringify(manifest, null, 2), "utf-8");
@@ -1100,11 +1131,11 @@ var AgentConfigValidationError = class extends Error {
1100
1131
  this.name = "AgentConfigValidationError";
1101
1132
  }
1102
1133
  };
1103
- function isRecord(value) {
1134
+ function isRecord2(value) {
1104
1135
  return typeof value === "object" && value !== null && !Array.isArray(value);
1105
1136
  }
1106
1137
  function requireRecord(value, path4, issues) {
1107
- if (!isRecord(value)) {
1138
+ if (!isRecord2(value)) {
1108
1139
  issues.push(`${path4} must be an object`);
1109
1140
  return void 0;
1110
1141
  }
@@ -2146,7 +2177,7 @@ function isArcaneHooksPath(root, value) {
2146
2177
  };
2147
2178
  return normalize(value) === normalize(ARCANE_HOOKS_DIR);
2148
2179
  }
2149
- var HOOK_BODY = `#!/bin/sh
2180
+ var PRE_PUSH_HOOK_BODY = `#!/bin/sh
2150
2181
  # Installed by Arcane because this repository's .arcane.json sets
2151
2182
  # push_policy: "blocked".
2152
2183
  #
@@ -2202,13 +2233,13 @@ async function repositoryRoot(cwd) {
2202
2233
  }
2203
2234
  return cwd;
2204
2235
  }
2205
- async function hookFilePath(cwd) {
2206
- 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);
2207
2238
  }
2208
2239
  async function hooksPathValue(cwd) {
2209
2240
  return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2210
2241
  }
2211
- async function installPrePushHook(cwd) {
2242
+ async function installHook(cwd, hookName, body) {
2212
2243
  const root = await repositoryRoot(cwd);
2213
2244
  const existing = await readHooksPath(cwd);
2214
2245
  if (existing?.unreadable === true) return { status: "refused-unreadable-config" };
@@ -2225,19 +2256,27 @@ async function installPrePushHook(cwd) {
2225
2256
  return { status: "refused-default-hooks", hooks: displaced };
2226
2257
  }
2227
2258
  }
2228
- const hookPath = join4(root, ARCANE_HOOKS_DIR, "pre-push");
2229
- 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
+ }
2230
2263
  await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2231
- await writeFile5(hookPath, HOOK_BODY, "utf-8");
2264
+ await writeFile5(hookPath, body, "utf-8");
2232
2265
  await chmod(hookPath, 493);
2233
2266
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2234
2267
  return { status: "installed", path: hookPath };
2235
2268
  }
2236
- 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) {
2237
2273
  const hooksPath = await readHooksPath(cwd);
2238
2274
  if (hooksPath === void 0 || hooksPath.unreadable === true) return false;
2239
2275
  if (!isArcaneHooksPath(await repositoryRoot(cwd), hooksPath.value)) return false;
2240
- 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);
2241
2280
  }
2242
2281
  async function defaultHooks(cwd) {
2243
2282
  let hooksDir;
@@ -2253,11 +2292,11 @@ async function defaultHooks(cwd) {
2253
2292
  return [];
2254
2293
  }
2255
2294
  }
2256
- async function hookBodyMatches(cwd) {
2257
- const hookPath = await hookFilePath(cwd);
2295
+ async function hookFileMatches(cwd, hookName, body) {
2296
+ const hookPath = await hookFilePath(cwd, hookName);
2258
2297
  if (!await fileExists(hookPath)) return false;
2259
2298
  try {
2260
- if (await readFile4(hookPath, "utf-8") !== HOOK_BODY) return false;
2299
+ if (await readFile4(hookPath, "utf-8") !== body) return false;
2261
2300
  } catch {
2262
2301
  return false;
2263
2302
  }
@@ -2269,21 +2308,33 @@ async function hookBodyMatches(cwd) {
2269
2308
  return false;
2270
2309
  }
2271
2310
  }
2272
- async function removePrePushHook(cwd) {
2311
+ async function removeHook(cwd, hookName) {
2273
2312
  const existing = await readHooksPath(cwd);
2274
2313
  if (existing === void 0 || existing.unreadable === true) return;
2275
- if (!isArcaneHooksPath(await repositoryRoot(cwd), existing.value)) return;
2314
+ const root = await repositoryRoot(cwd);
2315
+ if (!isArcaneHooksPath(root, existing.value)) return;
2276
2316
  if (existing.scope !== "local" && existing.scope !== "worktree") return;
2277
- const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2278
2317
  try {
2279
- await runGit(cwd, ["config", scopeFlag, "--unset", "core.hooksPath"]);
2318
+ await rm2(await hookFilePath(cwd, hookName), { force: true });
2319
+ } catch {
2320
+ }
2321
+ const hooksDir = join4(root, ARCANE_HOOKS_DIR);
2322
+ let remaining = [];
2323
+ try {
2324
+ remaining = await readdir3(hooksDir);
2280
2325
  } catch {
2326
+ remaining = [];
2281
2327
  }
2328
+ if (remaining.length > 0) return;
2329
+ const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2282
2330
  try {
2283
- await rm2(await hookFilePath(cwd), { force: true });
2331
+ await runGit(cwd, ["config", scopeFlag, "--unset", "core.hooksPath"]);
2284
2332
  } catch {
2285
2333
  }
2286
2334
  }
2335
+ async function removePrePushHook(cwd) {
2336
+ return removeHook(cwd, "pre-push");
2337
+ }
2287
2338
  async function listRemotes(cwd) {
2288
2339
  try {
2289
2340
  const { stdout } = await runGit(cwd, ["remote"]);
@@ -2491,6 +2542,58 @@ function describeError(error) {
2491
2542
  return message.split("\n")[0].trim();
2492
2543
  }
2493
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
+
2494
2597
  // src/commands/init.ts
2495
2598
  var VALID_PROFILES = ["full", "lite", "governance-only", "methodology", "docs"];
2496
2599
  var PROFILE_ICONS = {
@@ -2863,6 +2966,28 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2863
2966
  );
2864
2967
  }
2865
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
+ }
2866
2991
  let agentsConfigured = false;
2867
2992
  if (hasAgents && !options.profile) {
2868
2993
  console.log();
@@ -3619,13 +3744,17 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3619
3744
  for (const file of markerFiles) {
3620
3745
  await stripMarkerSection(targetDir, file, { dryRun: false });
3621
3746
  }
3747
+ try {
3748
+ await removeSecretsPrecommitHook(targetDir);
3749
+ } catch {
3750
+ }
3622
3751
  console.log(`
3623
3752
  \u2713 Uninstalled \u2014 ${removed} files removed.`);
3624
3753
  }
3625
3754
 
3626
3755
  // src/commands/doctor.ts
3627
- import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3628
- import { join as join11, dirname as dirname3 } from "path";
3756
+ import { access as access4, readFile as readFile7, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3757
+ import { join as join12, dirname as dirname3 } from "path";
3629
3758
  import { execFile as execFile3 } from "child_process";
3630
3759
  import { promisify as promisify2 } from "util";
3631
3760
 
@@ -3694,6 +3823,80 @@ function evaluateIncidentGate(records) {
3694
3823
  };
3695
3824
  }
3696
3825
 
3826
+ // src/modules/denylist-scan.ts
3827
+ import { readFile as readFile6, readdir as readdir5 } from "fs/promises";
3828
+ import { join as join11, relative } from "path";
3829
+ function escapeRegExp(value) {
3830
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3831
+ }
3832
+ function createDenylistRules(tokens, labelPrefix) {
3833
+ return tokens.map((token, index) => ({
3834
+ label: `${labelPrefix}-${index + 1}`,
3835
+ pattern: new RegExp(
3836
+ `(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?=$|[^A-Za-z0-9])`,
3837
+ "i"
3838
+ )
3839
+ }));
3840
+ }
3841
+ async function scanFile(absolutePath, displayPath, rules) {
3842
+ const findings = [];
3843
+ let content;
3844
+ try {
3845
+ content = await readFile6(absolutePath, "utf8");
3846
+ } catch {
3847
+ return findings;
3848
+ }
3849
+ const lines = content.split("\n");
3850
+ for (let index = 0; index < lines.length; index++) {
3851
+ const searchable = lines[index].replace(/\{[A-Z][A-Z0-9_]*\}/g, "");
3852
+ for (const rule of rules) {
3853
+ if (rule.pattern.test(searchable)) {
3854
+ findings.push({ file: displayPath, line: index + 1, rule: rule.label });
3855
+ break;
3856
+ }
3857
+ }
3858
+ }
3859
+ return findings;
3860
+ }
3861
+ var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
3862
+ "node_modules",
3863
+ ".git",
3864
+ "dist",
3865
+ "coverage",
3866
+ ".vitest",
3867
+ ".claude"
3868
+ ]);
3869
+ var DEFAULT_SCANNABLE_EXTENSIONS = [".md", ".ts", ".js", ".json", ".yml", ".yaml"];
3870
+ async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT_SCANNABLE_EXTENSIONS) {
3871
+ const collected = [];
3872
+ let entries;
3873
+ try {
3874
+ entries = await readdir5(root, { withFileTypes: true });
3875
+ } catch {
3876
+ return collected;
3877
+ }
3878
+ for (const entry of entries) {
3879
+ const absolutePath = join11(root, entry.name);
3880
+ if (entry.isDirectory()) {
3881
+ if (SKIP_DIRECTORIES.has(entry.name)) continue;
3882
+ collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
3883
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
3884
+ const displayPath = relative(repoRoot, absolutePath).replace(/\\/g, "/");
3885
+ collected.push([absolutePath, displayPath]);
3886
+ }
3887
+ }
3888
+ return collected;
3889
+ }
3890
+ async function scanRepository(repoRoot, rules, extensions, excludePrefixes = []) {
3891
+ if (rules.length === 0) return [];
3892
+ const findings = [];
3893
+ for (const [absolutePath, displayPath] of await collectScannableFiles(repoRoot, repoRoot, extensions)) {
3894
+ if (excludePrefixes.some((prefix) => displayPath.includes(prefix))) continue;
3895
+ findings.push(...await scanFile(absolutePath, displayPath, rules));
3896
+ }
3897
+ return findings;
3898
+ }
3899
+
3697
3900
  // src/modules/platform-policy.ts
3698
3901
  import { execFile as execFile2 } from "child_process";
3699
3902
  import { promisify } from "util";
@@ -3865,6 +4068,9 @@ function parseAdoRemote(url) {
3865
4068
 
3866
4069
  // src/commands/doctor.ts
3867
4070
  var execFileAsync2 = promisify2(execFile3);
4071
+ function isRecord3(value) {
4072
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4073
+ }
3868
4074
  async function checkNodeVersion() {
3869
4075
  const name = "Node.js version";
3870
4076
  const raw = process.version;
@@ -3882,8 +4088,8 @@ async function checkVSCodeExtension(id, label) {
3882
4088
  const name = `VS Code extension: ${label}`;
3883
4089
  const home = process.env["HOME"] ?? "";
3884
4090
  const extDirs = [
3885
- join11(home, ".vscode", "extensions"),
3886
- join11(home, ".vscode-insiders", "extensions")
4091
+ join12(home, ".vscode", "extensions"),
4092
+ join12(home, ".vscode-insiders", "extensions")
3887
4093
  ];
3888
4094
  for (const dir of extDirs) {
3889
4095
  try {
@@ -3917,15 +4123,15 @@ async function checkVSCodeExtension(id, label) {
3917
4123
  }
3918
4124
  async function checkArcaneManifest(targetDir) {
3919
4125
  const name = "Arcane manifest (.arcane.json)";
3920
- const manifestPath2 = join11(targetDir, ".arcane.json");
4126
+ const manifestPath2 = join12(targetDir, ".arcane.json");
3921
4127
  try {
3922
4128
  await access4(manifestPath2);
3923
4129
  } catch {
3924
- const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
4130
+ const selfHostedManifestPath = join12(targetDir, "src", "assets", ".arcane.json");
3925
4131
  try {
3926
- const raw = await readFile6(selfHostedManifestPath, "utf8");
4132
+ const raw = await readFile7(selfHostedManifestPath, "utf8");
3927
4133
  const manifest = JSON.parse(raw);
3928
- if (manifest.selfHosted === true && manifest.tracking_mode === "internal") {
4134
+ if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
3929
4135
  return {
3930
4136
  name,
3931
4137
  passed: true,
@@ -3942,9 +4148,9 @@ async function checkArcaneManifest(targetDir) {
3942
4148
  };
3943
4149
  }
3944
4150
  try {
3945
- const raw = await readFile6(manifestPath2, "utf8");
4151
+ const raw = await readFile7(manifestPath2, "utf8");
3946
4152
  const manifest = JSON.parse(raw);
3947
- if (!manifest.version || !Array.isArray(manifest.components)) {
4153
+ if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
3948
4154
  return {
3949
4155
  name,
3950
4156
  passed: false,
@@ -3954,7 +4160,7 @@ async function checkArcaneManifest(targetDir) {
3954
4160
  return {
3955
4161
  name,
3956
4162
  passed: true,
3957
- message: `v${manifest.version} \u2014 ${manifest.components.length} component(s) installed`
4163
+ message: `v${manifest["version"]} \u2014 ${manifest["components"].length} component(s) installed`
3958
4164
  };
3959
4165
  } catch {
3960
4166
  return {
@@ -4003,6 +4209,20 @@ function checkIncidentReleaseGate() {
4003
4209
  message: `${result.checked} incidents classified${accepted}`
4004
4210
  };
4005
4211
  }
4212
+ async function checkSecrets(targetDir) {
4213
+ const name = "Secrets scan (ARC-037)";
4214
+ const excludePrefixes = await resolveSecretsScanExcludePrefixes(targetDir);
4215
+ const findings = await scanRepository(targetDir, SECRETS_RULES, void 0, excludePrefixes);
4216
+ if (findings.length === 0) {
4217
+ return { name, passed: true, message: "no credential-shaped patterns found" };
4218
+ }
4219
+ const summary = findings.map((f) => `${f.file}:${f.line} [${f.rule}]`).join("; ");
4220
+ return {
4221
+ name,
4222
+ passed: false,
4223
+ message: `${findings.length} credential-shaped pattern(s) found: ${summary}`
4224
+ };
4225
+ }
4006
4226
  var SESSION_CONTINUITY_FILES = [
4007
4227
  "TODO.md",
4008
4228
  "DECISIONS.md",
@@ -4013,7 +4233,7 @@ async function checkSessionContinuity(targetDir) {
4013
4233
  const results = [];
4014
4234
  for (const file of SESSION_CONTINUITY_FILES) {
4015
4235
  const name = `Session file: ${file}`;
4016
- const filePath = join11(targetDir, file);
4236
+ const filePath = join12(targetDir, file);
4017
4237
  try {
4018
4238
  await access4(filePath);
4019
4239
  results.push({ name, passed: true, message: "present" });
@@ -4031,11 +4251,11 @@ async function checkSessionContinuity(targetDir) {
4031
4251
  async function fixSessionContinuity(targetDir, assetsDir) {
4032
4252
  const created = [];
4033
4253
  for (const file of SESSION_CONTINUITY_FILES) {
4034
- const destPath = join11(targetDir, file);
4254
+ const destPath = join12(targetDir, file);
4035
4255
  try {
4036
4256
  await access4(destPath);
4037
4257
  } catch {
4038
- const srcPath = join11(assetsDir, file);
4258
+ const srcPath = join12(assetsDir, file);
4039
4259
  await mkdir6(dirname3(destPath), { recursive: true });
4040
4260
  await fsCopyFile2(srcPath, destPath);
4041
4261
  created.push(file);
@@ -4096,16 +4316,16 @@ async function checkPushPolicy(targetDir) {
4096
4316
  }
4097
4317
  async function checkMcpConfig(targetDir) {
4098
4318
  const name = "MCP config (per-server timeout, I12)";
4099
- const filePath = join11(targetDir, ".mcp.json");
4319
+ const filePath = join12(targetDir, ".mcp.json");
4100
4320
  let raw;
4101
4321
  try {
4102
- raw = await readFile6(filePath, "utf8");
4322
+ raw = await readFile7(filePath, "utf8");
4103
4323
  } catch {
4104
4324
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
4105
4325
  }
4106
- let parsed;
4326
+ let rawParsed;
4107
4327
  try {
4108
- parsed = JSON.parse(raw);
4328
+ rawParsed = JSON.parse(raw);
4109
4329
  } catch {
4110
4330
  return {
4111
4331
  name,
@@ -4114,6 +4334,15 @@ async function checkMcpConfig(targetDir) {
4114
4334
  message: ".mcp.json exists but contains invalid JSON"
4115
4335
  };
4116
4336
  }
4337
+ if (!isRecord3(rawParsed)) {
4338
+ return {
4339
+ name,
4340
+ passed: false,
4341
+ blocking: false,
4342
+ message: ".mcp.json exists but is not a JSON object"
4343
+ };
4344
+ }
4345
+ const parsed = rawParsed;
4117
4346
  const servers = parsed.mcpServers ?? {};
4118
4347
  const serverNames = Object.keys(servers);
4119
4348
  if (serverNames.length === 0) {
@@ -4135,18 +4364,24 @@ async function checkMcpConfig(targetDir) {
4135
4364
  message: `${serverNames.length} server(s) configured, all with a timeout set`
4136
4365
  };
4137
4366
  }
4367
+ function isDelegation(value) {
4368
+ return isRecord3(value) && typeof value["id"] === "string" && typeof value["status"] === "string" && typeof value["scope"] === "string" && Array.isArray(value["excludedActions"]);
4369
+ }
4370
+ function isDelegationsFile(value) {
4371
+ return isRecord3(value) && Array.isArray(value["delegations"]) && value["delegations"].every(isDelegation);
4372
+ }
4138
4373
  async function checkDelegations(targetDir) {
4139
4374
  const name = "Standing delegations (T13)";
4140
- const filePath = join11(targetDir, ".arcane", "delegations.json");
4375
+ const filePath = join12(targetDir, ".arcane", "delegations.json");
4141
4376
  let raw;
4142
4377
  try {
4143
- raw = await readFile6(filePath, "utf8");
4378
+ raw = await readFile7(filePath, "utf8");
4144
4379
  } catch {
4145
4380
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
4146
4381
  }
4147
- let parsed;
4382
+ let rawParsed;
4148
4383
  try {
4149
- parsed = JSON.parse(raw);
4384
+ rawParsed = JSON.parse(raw);
4150
4385
  } catch {
4151
4386
  return {
4152
4387
  name,
@@ -4155,7 +4390,16 @@ async function checkDelegations(targetDir) {
4155
4390
  message: `.arcane/delegations.json exists but contains invalid JSON`
4156
4391
  };
4157
4392
  }
4158
- const active = (parsed.delegations ?? []).filter((d) => d.status === "active");
4393
+ if (!isDelegationsFile(rawParsed)) {
4394
+ return {
4395
+ name,
4396
+ passed: false,
4397
+ blocking: false,
4398
+ message: `.arcane/delegations.json exists but is not a valid delegations file (expected a "delegations" array of well-formed entries)`
4399
+ };
4400
+ }
4401
+ const parsed = rawParsed;
4402
+ const active = parsed.delegations.filter((d) => d.status === "active");
4159
4403
  if (active.length === 0) {
4160
4404
  return { name, passed: true, blocking: false, message: "no active delegations" };
4161
4405
  }
@@ -4212,6 +4456,19 @@ async function checkPlatformBranchPolicy(targetDir) {
4212
4456
  }
4213
4457
  async function runDoctor(targetDir, options = {}, assetsDir) {
4214
4458
  console.log("\nspell doctor \u2014 checking your Arcane environment\n");
4459
+ if (options.leaks) {
4460
+ const result = await checkSecrets(targetDir);
4461
+ const icon = result.passed ? "\u2713" : "\u2717";
4462
+ console.log(` ${icon} [${result.passed ? "pass" : "FAIL"}] ${result.name}`);
4463
+ if (!result.passed) {
4464
+ console.log(` ${result.message}`);
4465
+ console.log("\n One or more checks failed. Fix the issues above before proceeding.\n");
4466
+ process.exitCode = 1;
4467
+ } else {
4468
+ console.log("\n All checks passed. Your environment is ready.\n");
4469
+ }
4470
+ return;
4471
+ }
4215
4472
  const results = await Promise.all([
4216
4473
  checkNodeVersion(),
4217
4474
  checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
@@ -4268,52 +4525,6 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4268
4525
  // src/modules/ward.ts
4269
4526
  import { readdir as readdir6, readFile as readFile8 } from "fs/promises";
4270
4527
  import { join as join13, relative as relative2 } from "path";
4271
-
4272
- // src/modules/denylist-scan.ts
4273
- import { readFile as readFile7, readdir as readdir5 } from "fs/promises";
4274
- import { join as join12, relative } from "path";
4275
- function escapeRegExp(value) {
4276
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4277
- }
4278
- function createDenylistRules(tokens, labelPrefix) {
4279
- return tokens.map((token, index) => ({
4280
- label: `${labelPrefix}-${index + 1}`,
4281
- pattern: new RegExp(
4282
- `(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?=$|[^A-Za-z0-9])`,
4283
- "i"
4284
- )
4285
- }));
4286
- }
4287
- var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
4288
- "node_modules",
4289
- ".git",
4290
- "dist",
4291
- "coverage",
4292
- ".vitest"
4293
- ]);
4294
- var DEFAULT_SCANNABLE_EXTENSIONS = [".md", ".ts", ".js", ".json", ".yml", ".yaml"];
4295
- async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT_SCANNABLE_EXTENSIONS) {
4296
- const collected = [];
4297
- let entries;
4298
- try {
4299
- entries = await readdir5(root, { withFileTypes: true });
4300
- } catch {
4301
- return collected;
4302
- }
4303
- for (const entry of entries) {
4304
- const absolutePath = join12(root, entry.name);
4305
- if (entry.isDirectory()) {
4306
- if (SKIP_DIRECTORIES.has(entry.name)) continue;
4307
- collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
4308
- } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
4309
- const displayPath = relative(repoRoot, absolutePath).replace(/\\/g, "/");
4310
- collected.push([absolutePath, displayPath]);
4311
- }
4312
- }
4313
- return collected;
4314
- }
4315
-
4316
- // src/modules/ward.ts
4317
4528
  async function scanFileAllRules(absolutePath, displayPath, rules) {
4318
4529
  const findings = [];
4319
4530
  let content;
@@ -4639,8 +4850,8 @@ program.command("uninstall").description("Remove all installed Arcane framework
4639
4850
  program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
4640
4851
  await runUnblockPush(process.cwd());
4641
4852
  });
4642
- program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
4643
- await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
4853
+ program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").option("--leaks", "Run only the on-demand secrets/credential scan (ARC-037), skipping the default checks").action(async (opts) => {
4854
+ await runDoctor(process.cwd(), { fix: opts.fix, leaks: opts.leaks }, ASSETS_DIR);
4644
4855
  });
4645
4856
  program.command("ward").description("Scan the repo for leaked identifiers -- third-party trademarks and the operator's own personal identifiers alike (T18)").option(
4646
4857
  "--terms <terms>",