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 +115 -48
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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,
|
|
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
|
|
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,
|
|
2260
|
-
if (existing !== void 0 && await
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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") !==
|
|
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
|
|
2311
|
+
async function removeHook(cwd, hookName) {
|
|
2304
2312
|
const existing = await readHooksPath(cwd);
|
|
2305
2313
|
if (existing === void 0 || existing.unreadable === true) return;
|
|
2306
|
-
|
|
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
|
|
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
|
-
|
|
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";
|