arcane-cli 0.29.6 → 0.30.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
|
}
|
|
@@ -3624,8 +3655,8 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
|
|
|
3624
3655
|
}
|
|
3625
3656
|
|
|
3626
3657
|
// src/commands/doctor.ts
|
|
3627
|
-
import { access as access4, readFile as
|
|
3628
|
-
import { join as
|
|
3658
|
+
import { access as access4, readFile as readFile7, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
|
|
3659
|
+
import { join as join12, dirname as dirname3 } from "path";
|
|
3629
3660
|
import { execFile as execFile3 } from "child_process";
|
|
3630
3661
|
import { promisify as promisify2 } from "util";
|
|
3631
3662
|
|
|
@@ -3694,6 +3725,111 @@ function evaluateIncidentGate(records) {
|
|
|
3694
3725
|
};
|
|
3695
3726
|
}
|
|
3696
3727
|
|
|
3728
|
+
// src/modules/denylist-scan.ts
|
|
3729
|
+
import { readFile as readFile6, readdir as readdir5 } from "fs/promises";
|
|
3730
|
+
import { join as join11, relative } from "path";
|
|
3731
|
+
function escapeRegExp(value) {
|
|
3732
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3733
|
+
}
|
|
3734
|
+
function createDenylistRules(tokens, labelPrefix) {
|
|
3735
|
+
return tokens.map((token, index) => ({
|
|
3736
|
+
label: `${labelPrefix}-${index + 1}`,
|
|
3737
|
+
pattern: new RegExp(
|
|
3738
|
+
`(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?=$|[^A-Za-z0-9])`,
|
|
3739
|
+
"i"
|
|
3740
|
+
)
|
|
3741
|
+
}));
|
|
3742
|
+
}
|
|
3743
|
+
async function scanFile(absolutePath, displayPath, rules) {
|
|
3744
|
+
const findings = [];
|
|
3745
|
+
let content;
|
|
3746
|
+
try {
|
|
3747
|
+
content = await readFile6(absolutePath, "utf8");
|
|
3748
|
+
} catch {
|
|
3749
|
+
return findings;
|
|
3750
|
+
}
|
|
3751
|
+
const lines = content.split("\n");
|
|
3752
|
+
for (let index = 0; index < lines.length; index++) {
|
|
3753
|
+
const searchable = lines[index].replace(/\{[A-Z][A-Z0-9_]*\}/g, "");
|
|
3754
|
+
for (const rule of rules) {
|
|
3755
|
+
if (rule.pattern.test(searchable)) {
|
|
3756
|
+
findings.push({ file: displayPath, line: index + 1, rule: rule.label });
|
|
3757
|
+
break;
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
return findings;
|
|
3762
|
+
}
|
|
3763
|
+
var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
|
|
3764
|
+
"node_modules",
|
|
3765
|
+
".git",
|
|
3766
|
+
"dist",
|
|
3767
|
+
"coverage",
|
|
3768
|
+
".vitest",
|
|
3769
|
+
".claude"
|
|
3770
|
+
]);
|
|
3771
|
+
var DEFAULT_SCANNABLE_EXTENSIONS = [".md", ".ts", ".js", ".json", ".yml", ".yaml"];
|
|
3772
|
+
async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT_SCANNABLE_EXTENSIONS) {
|
|
3773
|
+
const collected = [];
|
|
3774
|
+
let entries;
|
|
3775
|
+
try {
|
|
3776
|
+
entries = await readdir5(root, { withFileTypes: true });
|
|
3777
|
+
} catch {
|
|
3778
|
+
return collected;
|
|
3779
|
+
}
|
|
3780
|
+
for (const entry of entries) {
|
|
3781
|
+
const absolutePath = join11(root, entry.name);
|
|
3782
|
+
if (entry.isDirectory()) {
|
|
3783
|
+
if (SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
3784
|
+
collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
|
|
3785
|
+
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
3786
|
+
const displayPath = relative(repoRoot, absolutePath).replace(/\\/g, "/");
|
|
3787
|
+
collected.push([absolutePath, displayPath]);
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
return collected;
|
|
3791
|
+
}
|
|
3792
|
+
async function scanRepository(repoRoot, rules, extensions, excludePrefixes = []) {
|
|
3793
|
+
if (rules.length === 0) return [];
|
|
3794
|
+
const findings = [];
|
|
3795
|
+
for (const [absolutePath, displayPath] of await collectScannableFiles(repoRoot, repoRoot, extensions)) {
|
|
3796
|
+
if (excludePrefixes.some((prefix) => displayPath.includes(prefix))) continue;
|
|
3797
|
+
findings.push(...await scanFile(absolutePath, displayPath, rules));
|
|
3798
|
+
}
|
|
3799
|
+
return findings;
|
|
3800
|
+
}
|
|
3801
|
+
|
|
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
|
+
|
|
3697
3833
|
// src/modules/platform-policy.ts
|
|
3698
3834
|
import { execFile as execFile2 } from "child_process";
|
|
3699
3835
|
import { promisify } from "util";
|
|
@@ -3865,6 +4001,9 @@ function parseAdoRemote(url) {
|
|
|
3865
4001
|
|
|
3866
4002
|
// src/commands/doctor.ts
|
|
3867
4003
|
var execFileAsync2 = promisify2(execFile3);
|
|
4004
|
+
function isRecord3(value) {
|
|
4005
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4006
|
+
}
|
|
3868
4007
|
async function checkNodeVersion() {
|
|
3869
4008
|
const name = "Node.js version";
|
|
3870
4009
|
const raw = process.version;
|
|
@@ -3882,8 +4021,8 @@ async function checkVSCodeExtension(id, label) {
|
|
|
3882
4021
|
const name = `VS Code extension: ${label}`;
|
|
3883
4022
|
const home = process.env["HOME"] ?? "";
|
|
3884
4023
|
const extDirs = [
|
|
3885
|
-
|
|
3886
|
-
|
|
4024
|
+
join12(home, ".vscode", "extensions"),
|
|
4025
|
+
join12(home, ".vscode-insiders", "extensions")
|
|
3887
4026
|
];
|
|
3888
4027
|
for (const dir of extDirs) {
|
|
3889
4028
|
try {
|
|
@@ -3917,15 +4056,15 @@ async function checkVSCodeExtension(id, label) {
|
|
|
3917
4056
|
}
|
|
3918
4057
|
async function checkArcaneManifest(targetDir) {
|
|
3919
4058
|
const name = "Arcane manifest (.arcane.json)";
|
|
3920
|
-
const manifestPath2 =
|
|
4059
|
+
const manifestPath2 = join12(targetDir, ".arcane.json");
|
|
3921
4060
|
try {
|
|
3922
4061
|
await access4(manifestPath2);
|
|
3923
4062
|
} catch {
|
|
3924
|
-
const selfHostedManifestPath =
|
|
4063
|
+
const selfHostedManifestPath = join12(targetDir, "src", "assets", ".arcane.json");
|
|
3925
4064
|
try {
|
|
3926
|
-
const raw = await
|
|
4065
|
+
const raw = await readFile7(selfHostedManifestPath, "utf8");
|
|
3927
4066
|
const manifest = JSON.parse(raw);
|
|
3928
|
-
if (manifest
|
|
4067
|
+
if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
|
|
3929
4068
|
return {
|
|
3930
4069
|
name,
|
|
3931
4070
|
passed: true,
|
|
@@ -3942,9 +4081,9 @@ async function checkArcaneManifest(targetDir) {
|
|
|
3942
4081
|
};
|
|
3943
4082
|
}
|
|
3944
4083
|
try {
|
|
3945
|
-
const raw = await
|
|
4084
|
+
const raw = await readFile7(manifestPath2, "utf8");
|
|
3946
4085
|
const manifest = JSON.parse(raw);
|
|
3947
|
-
if (!manifest
|
|
4086
|
+
if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
|
|
3948
4087
|
return {
|
|
3949
4088
|
name,
|
|
3950
4089
|
passed: false,
|
|
@@ -3954,7 +4093,7 @@ async function checkArcaneManifest(targetDir) {
|
|
|
3954
4093
|
return {
|
|
3955
4094
|
name,
|
|
3956
4095
|
passed: true,
|
|
3957
|
-
message: `v${manifest
|
|
4096
|
+
message: `v${manifest["version"]} \u2014 ${manifest["components"].length} component(s) installed`
|
|
3958
4097
|
};
|
|
3959
4098
|
} catch {
|
|
3960
4099
|
return {
|
|
@@ -4003,6 +4142,20 @@ function checkIncidentReleaseGate() {
|
|
|
4003
4142
|
message: `${result.checked} incidents classified${accepted}`
|
|
4004
4143
|
};
|
|
4005
4144
|
}
|
|
4145
|
+
async function checkSecrets(targetDir) {
|
|
4146
|
+
const name = "Secrets scan (ARC-037)";
|
|
4147
|
+
const excludePrefixes = await resolveSecretsScanExcludePrefixes(targetDir);
|
|
4148
|
+
const findings = await scanRepository(targetDir, SECRETS_RULES, void 0, excludePrefixes);
|
|
4149
|
+
if (findings.length === 0) {
|
|
4150
|
+
return { name, passed: true, message: "no credential-shaped patterns found" };
|
|
4151
|
+
}
|
|
4152
|
+
const summary = findings.map((f) => `${f.file}:${f.line} [${f.rule}]`).join("; ");
|
|
4153
|
+
return {
|
|
4154
|
+
name,
|
|
4155
|
+
passed: false,
|
|
4156
|
+
message: `${findings.length} credential-shaped pattern(s) found: ${summary}`
|
|
4157
|
+
};
|
|
4158
|
+
}
|
|
4006
4159
|
var SESSION_CONTINUITY_FILES = [
|
|
4007
4160
|
"TODO.md",
|
|
4008
4161
|
"DECISIONS.md",
|
|
@@ -4013,7 +4166,7 @@ async function checkSessionContinuity(targetDir) {
|
|
|
4013
4166
|
const results = [];
|
|
4014
4167
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
4015
4168
|
const name = `Session file: ${file}`;
|
|
4016
|
-
const filePath =
|
|
4169
|
+
const filePath = join12(targetDir, file);
|
|
4017
4170
|
try {
|
|
4018
4171
|
await access4(filePath);
|
|
4019
4172
|
results.push({ name, passed: true, message: "present" });
|
|
@@ -4031,11 +4184,11 @@ async function checkSessionContinuity(targetDir) {
|
|
|
4031
4184
|
async function fixSessionContinuity(targetDir, assetsDir) {
|
|
4032
4185
|
const created = [];
|
|
4033
4186
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
4034
|
-
const destPath =
|
|
4187
|
+
const destPath = join12(targetDir, file);
|
|
4035
4188
|
try {
|
|
4036
4189
|
await access4(destPath);
|
|
4037
4190
|
} catch {
|
|
4038
|
-
const srcPath =
|
|
4191
|
+
const srcPath = join12(assetsDir, file);
|
|
4039
4192
|
await mkdir6(dirname3(destPath), { recursive: true });
|
|
4040
4193
|
await fsCopyFile2(srcPath, destPath);
|
|
4041
4194
|
created.push(file);
|
|
@@ -4096,16 +4249,16 @@ async function checkPushPolicy(targetDir) {
|
|
|
4096
4249
|
}
|
|
4097
4250
|
async function checkMcpConfig(targetDir) {
|
|
4098
4251
|
const name = "MCP config (per-server timeout, I12)";
|
|
4099
|
-
const filePath =
|
|
4252
|
+
const filePath = join12(targetDir, ".mcp.json");
|
|
4100
4253
|
let raw;
|
|
4101
4254
|
try {
|
|
4102
|
-
raw = await
|
|
4255
|
+
raw = await readFile7(filePath, "utf8");
|
|
4103
4256
|
} catch {
|
|
4104
4257
|
return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
|
|
4105
4258
|
}
|
|
4106
|
-
let
|
|
4259
|
+
let rawParsed;
|
|
4107
4260
|
try {
|
|
4108
|
-
|
|
4261
|
+
rawParsed = JSON.parse(raw);
|
|
4109
4262
|
} catch {
|
|
4110
4263
|
return {
|
|
4111
4264
|
name,
|
|
@@ -4114,6 +4267,15 @@ async function checkMcpConfig(targetDir) {
|
|
|
4114
4267
|
message: ".mcp.json exists but contains invalid JSON"
|
|
4115
4268
|
};
|
|
4116
4269
|
}
|
|
4270
|
+
if (!isRecord3(rawParsed)) {
|
|
4271
|
+
return {
|
|
4272
|
+
name,
|
|
4273
|
+
passed: false,
|
|
4274
|
+
blocking: false,
|
|
4275
|
+
message: ".mcp.json exists but is not a JSON object"
|
|
4276
|
+
};
|
|
4277
|
+
}
|
|
4278
|
+
const parsed = rawParsed;
|
|
4117
4279
|
const servers = parsed.mcpServers ?? {};
|
|
4118
4280
|
const serverNames = Object.keys(servers);
|
|
4119
4281
|
if (serverNames.length === 0) {
|
|
@@ -4135,18 +4297,24 @@ async function checkMcpConfig(targetDir) {
|
|
|
4135
4297
|
message: `${serverNames.length} server(s) configured, all with a timeout set`
|
|
4136
4298
|
};
|
|
4137
4299
|
}
|
|
4300
|
+
function isDelegation(value) {
|
|
4301
|
+
return isRecord3(value) && typeof value["id"] === "string" && typeof value["status"] === "string" && typeof value["scope"] === "string" && Array.isArray(value["excludedActions"]);
|
|
4302
|
+
}
|
|
4303
|
+
function isDelegationsFile(value) {
|
|
4304
|
+
return isRecord3(value) && Array.isArray(value["delegations"]) && value["delegations"].every(isDelegation);
|
|
4305
|
+
}
|
|
4138
4306
|
async function checkDelegations(targetDir) {
|
|
4139
4307
|
const name = "Standing delegations (T13)";
|
|
4140
|
-
const filePath =
|
|
4308
|
+
const filePath = join12(targetDir, ".arcane", "delegations.json");
|
|
4141
4309
|
let raw;
|
|
4142
4310
|
try {
|
|
4143
|
-
raw = await
|
|
4311
|
+
raw = await readFile7(filePath, "utf8");
|
|
4144
4312
|
} catch {
|
|
4145
4313
|
return { name, passed: true, blocking: false, message: "no delegations recorded" };
|
|
4146
4314
|
}
|
|
4147
|
-
let
|
|
4315
|
+
let rawParsed;
|
|
4148
4316
|
try {
|
|
4149
|
-
|
|
4317
|
+
rawParsed = JSON.parse(raw);
|
|
4150
4318
|
} catch {
|
|
4151
4319
|
return {
|
|
4152
4320
|
name,
|
|
@@ -4155,7 +4323,16 @@ async function checkDelegations(targetDir) {
|
|
|
4155
4323
|
message: `.arcane/delegations.json exists but contains invalid JSON`
|
|
4156
4324
|
};
|
|
4157
4325
|
}
|
|
4158
|
-
|
|
4326
|
+
if (!isDelegationsFile(rawParsed)) {
|
|
4327
|
+
return {
|
|
4328
|
+
name,
|
|
4329
|
+
passed: false,
|
|
4330
|
+
blocking: false,
|
|
4331
|
+
message: `.arcane/delegations.json exists but is not a valid delegations file (expected a "delegations" array of well-formed entries)`
|
|
4332
|
+
};
|
|
4333
|
+
}
|
|
4334
|
+
const parsed = rawParsed;
|
|
4335
|
+
const active = parsed.delegations.filter((d) => d.status === "active");
|
|
4159
4336
|
if (active.length === 0) {
|
|
4160
4337
|
return { name, passed: true, blocking: false, message: "no active delegations" };
|
|
4161
4338
|
}
|
|
@@ -4212,6 +4389,19 @@ async function checkPlatformBranchPolicy(targetDir) {
|
|
|
4212
4389
|
}
|
|
4213
4390
|
async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
4214
4391
|
console.log("\nspell doctor \u2014 checking your Arcane environment\n");
|
|
4392
|
+
if (options.leaks) {
|
|
4393
|
+
const result = await checkSecrets(targetDir);
|
|
4394
|
+
const icon = result.passed ? "\u2713" : "\u2717";
|
|
4395
|
+
console.log(` ${icon} [${result.passed ? "pass" : "FAIL"}] ${result.name}`);
|
|
4396
|
+
if (!result.passed) {
|
|
4397
|
+
console.log(` ${result.message}`);
|
|
4398
|
+
console.log("\n One or more checks failed. Fix the issues above before proceeding.\n");
|
|
4399
|
+
process.exitCode = 1;
|
|
4400
|
+
} else {
|
|
4401
|
+
console.log("\n All checks passed. Your environment is ready.\n");
|
|
4402
|
+
}
|
|
4403
|
+
return;
|
|
4404
|
+
}
|
|
4215
4405
|
const results = await Promise.all([
|
|
4216
4406
|
checkNodeVersion(),
|
|
4217
4407
|
checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
|
|
@@ -4268,52 +4458,6 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
4268
4458
|
// src/modules/ward.ts
|
|
4269
4459
|
import { readdir as readdir6, readFile as readFile8 } from "fs/promises";
|
|
4270
4460
|
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
4461
|
async function scanFileAllRules(absolutePath, displayPath, rules) {
|
|
4318
4462
|
const findings = [];
|
|
4319
4463
|
let content;
|
|
@@ -4639,8 +4783,8 @@ program.command("uninstall").description("Remove all installed Arcane framework
|
|
|
4639
4783
|
program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
|
|
4640
4784
|
await runUnblockPush(process.cwd());
|
|
4641
4785
|
});
|
|
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);
|
|
4786
|
+
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) => {
|
|
4787
|
+
await runDoctor(process.cwd(), { fix: opts.fix, leaks: opts.leaks }, ASSETS_DIR);
|
|
4644
4788
|
});
|
|
4645
4789
|
program.command("ward").description("Scan the repo for leaked identifiers -- third-party trademarks and the operator's own personal identifiers alike (T18)").option(
|
|
4646
4790
|
"--terms <terms>",
|