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=
|
|
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 | **
|
|
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
|
|
package/dist/assets/.arcane.json
CHANGED
|
@@ -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
|
|
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 (!
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
2229
|
-
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
|
+
}
|
|
2230
2263
|
await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
|
|
2231
|
-
await writeFile5(hookPath,
|
|
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
|
|
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
|
|
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
|
|
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") !==
|
|
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
|
|
2311
|
+
async function removeHook(cwd, hookName) {
|
|
2273
2312
|
const existing = await readHooksPath(cwd);
|
|
2274
2313
|
if (existing === void 0 || existing.unreadable === true) return;
|
|
2275
|
-
|
|
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
|
|
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
|
|
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
|
|
3628
|
-
import { join as
|
|
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
|
-
|
|
3886
|
-
|
|
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 =
|
|
4126
|
+
const manifestPath2 = join12(targetDir, ".arcane.json");
|
|
3921
4127
|
try {
|
|
3922
4128
|
await access4(manifestPath2);
|
|
3923
4129
|
} catch {
|
|
3924
|
-
const selfHostedManifestPath =
|
|
4130
|
+
const selfHostedManifestPath = join12(targetDir, "src", "assets", ".arcane.json");
|
|
3925
4131
|
try {
|
|
3926
|
-
const raw = await
|
|
4132
|
+
const raw = await readFile7(selfHostedManifestPath, "utf8");
|
|
3927
4133
|
const manifest = JSON.parse(raw);
|
|
3928
|
-
if (manifest
|
|
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
|
|
4151
|
+
const raw = await readFile7(manifestPath2, "utf8");
|
|
3946
4152
|
const manifest = JSON.parse(raw);
|
|
3947
|
-
if (!manifest
|
|
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
|
|
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 =
|
|
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 =
|
|
4254
|
+
const destPath = join12(targetDir, file);
|
|
4035
4255
|
try {
|
|
4036
4256
|
await access4(destPath);
|
|
4037
4257
|
} catch {
|
|
4038
|
-
const srcPath =
|
|
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 =
|
|
4319
|
+
const filePath = join12(targetDir, ".mcp.json");
|
|
4100
4320
|
let raw;
|
|
4101
4321
|
try {
|
|
4102
|
-
raw = await
|
|
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
|
|
4326
|
+
let rawParsed;
|
|
4107
4327
|
try {
|
|
4108
|
-
|
|
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 =
|
|
4375
|
+
const filePath = join12(targetDir, ".arcane", "delegations.json");
|
|
4141
4376
|
let raw;
|
|
4142
4377
|
try {
|
|
4143
|
-
raw = await
|
|
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
|
|
4382
|
+
let rawParsed;
|
|
4148
4383
|
try {
|
|
4149
|
-
|
|
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
|
-
|
|
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>",
|