akm-cli 0.8.0-rc2 → 0.8.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/{.github/CHANGELOG.md → CHANGELOG.md} +191 -3
- package/README.md +22 -6
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +93 -3
- package/dist/cli/shared.js +129 -0
- package/dist/cli.js +2141 -1268
- package/dist/commands/add-cli.js +279 -0
- package/dist/commands/agent-dispatch.js +20 -12
- package/dist/commands/agent-support.js +11 -5
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +129 -517
- package/dist/commands/consolidate.js +1533 -144
- package/dist/commands/curate.js +44 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +5 -3
- package/dist/commands/distill.js +906 -100
- package/dist/commands/env.js +213 -0
- package/dist/commands/eval-cases.js +3 -0
- package/dist/commands/events.js +3 -0
- package/dist/commands/extract-cli.js +127 -0
- package/dist/commands/extract-prompt.js +204 -0
- package/dist/commands/extract.js +477 -0
- package/dist/commands/feedback-cli.js +331 -0
- package/dist/commands/graph.js +260 -5
- package/dist/commands/health.js +977 -51
- package/dist/commands/help/help-accept.md +6 -3
- package/dist/commands/help/help-improve.md +36 -8
- package/dist/commands/help/help-proposals.md +7 -4
- package/dist/commands/help/help-reject.md +5 -2
- package/dist/commands/history.js +51 -16
- package/dist/commands/improve-auto-accept.js +97 -0
- package/dist/commands/improve-cli.js +236 -0
- package/dist/commands/improve-profiles.js +184 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +1725 -332
- package/dist/commands/info.js +3 -0
- package/dist/commands/init.js +49 -1
- package/dist/commands/installed-stashes.js +6 -23
- package/dist/commands/knowledge.js +3 -0
- package/dist/commands/lint/agent-linter.js +3 -0
- package/dist/commands/lint/base-linter.js +199 -5
- package/dist/commands/lint/command-linter.js +3 -0
- package/dist/commands/lint/default-linter.js +3 -0
- package/dist/commands/lint/env-key-rules.js +154 -0
- package/dist/commands/lint/index.js +92 -3
- package/dist/commands/lint/knowledge-linter.js +3 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +3 -0
- package/dist/commands/lint/registry.js +3 -0
- package/dist/commands/lint/skill-linter.js +3 -0
- package/dist/commands/lint/task-linter.js +15 -12
- package/dist/commands/lint/types.js +3 -0
- package/dist/commands/lint/workflow-linter.js +3 -0
- package/dist/commands/lint.js +3 -0
- package/dist/commands/migration-help.js +5 -2
- package/dist/commands/proposal-drain-policies.js +128 -0
- package/dist/commands/proposal-drain.js +477 -0
- package/dist/commands/proposal.js +60 -6
- package/dist/commands/propose.js +24 -19
- package/dist/commands/reflect.js +1004 -94
- package/dist/commands/registry-cli.js +150 -0
- package/dist/commands/registry-search.js +3 -0
- package/dist/commands/remember-cli.js +257 -0
- package/dist/commands/remember.js +15 -6
- package/dist/commands/schema-repair.js +88 -15
- package/dist/commands/search.js +99 -14
- package/dist/commands/secret.js +173 -0
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +32 -13
- package/dist/commands/source-add.js +7 -35
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +3 -0
- package/dist/commands/tasks.js +161 -95
- package/dist/commands/url-checker.js +3 -0
- package/dist/core/action-contributors.js +3 -0
- package/dist/core/asset-ref.js +13 -2
- package/dist/core/asset-registry.js +9 -2
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +61 -5
- package/dist/core/common.js +93 -5
- package/dist/core/concurrent.js +3 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +622 -0
- package/dist/core/config-schema.js +558 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +366 -1077
- package/dist/core/errors.js +42 -20
- package/dist/core/events.js +31 -25
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +75 -10
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +3 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +142 -14
- package/dist/core/parse.js +3 -0
- package/dist/core/paths.js +218 -50
- package/dist/core/proposal-quality-validators.js +380 -0
- package/dist/core/proposal-validators.js +11 -3
- package/dist/core/proposals.js +464 -5
- package/dist/core/state-db.js +349 -56
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +3 -0
- package/dist/core/tty.js +59 -0
- package/dist/core/warn.js +7 -2
- package/dist/core/write-source.js +12 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +136 -28
- package/dist/indexer/db.js +661 -166
- package/dist/indexer/ensure-index.js +3 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +162 -40
- package/dist/indexer/graph-db.js +241 -51
- package/dist/indexer/graph-dedup.js +3 -7
- package/dist/indexer/graph-extraction.js +242 -149
- package/dist/indexer/index-context.js +3 -9
- package/dist/indexer/indexer.js +84 -14
- package/dist/indexer/llm-cache.js +24 -19
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +184 -11
- package/dist/indexer/memory-inference.js +94 -50
- package/dist/indexer/metadata-contributors.js +3 -0
- package/dist/indexer/metadata.js +110 -50
- package/dist/indexer/path-resolver.js +3 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +134 -7
- package/dist/indexer/ranking.js +8 -1
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +91 -2
- package/dist/indexer/search-source.js +20 -1
- package/dist/indexer/semantic-status.js +4 -1
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +3 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +121 -401
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +6 -14
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +3 -0
- package/dist/integrations/agent/prompts.js +137 -8
- package/dist/integrations/agent/runner.js +208 -0
- package/dist/integrations/agent/sdk-runner.js +8 -2
- package/dist/integrations/agent/spawn.js +54 -14
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +22 -51
- package/dist/integrations/session-logs/index.js +4 -0
- package/dist/integrations/session-logs/inline-refs.js +35 -0
- package/dist/integrations/session-logs/pre-filter.js +152 -0
- package/dist/integrations/session-logs/providers/claude-code.js +226 -0
- package/dist/integrations/session-logs/providers/opencode.js +231 -25
- package/dist/integrations/session-logs/types.js +3 -0
- package/dist/llm/call-ai.js +14 -26
- package/dist/llm/client.js +16 -2
- package/dist/llm/embedder.js +20 -29
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +42 -1
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +92 -56
- package/dist/llm/graph-extract.js +401 -30
- package/dist/llm/index-passes.js +44 -29
- package/dist/llm/memory-infer.js +30 -2
- package/dist/llm/metadata-enhance.js +3 -7
- package/dist/llm/prompts/extract-session.md +80 -0
- package/dist/llm/prompts/graph-extract-user-prompt.md +24 -1
- package/dist/output/cli-hints-full.md +60 -32
- package/dist/output/cli-hints-short.md +10 -7
- package/dist/output/cli-hints.js +5 -2
- package/dist/output/context.js +60 -8
- package/dist/output/renderers.js +170 -194
- package/dist/output/shapes/curate.js +56 -0
- package/dist/output/shapes/distill.js +10 -0
- package/dist/output/shapes/env-list.js +19 -0
- package/dist/output/shapes/events.js +11 -0
- package/dist/output/shapes/helpers.js +424 -0
- package/dist/output/shapes/history.js +7 -0
- package/dist/output/shapes/passthrough.js +105 -0
- package/dist/output/shapes/proposal-accept.js +7 -0
- package/dist/output/shapes/proposal-diff.js +7 -0
- package/dist/output/shapes/proposal-list.js +7 -0
- package/dist/output/shapes/proposal-producer.js +11 -0
- package/dist/output/shapes/proposal-reject.js +7 -0
- package/dist/output/shapes/proposal-show.js +7 -0
- package/dist/output/shapes/registry-search.js +6 -0
- package/dist/output/shapes/registry.js +30 -0
- package/dist/output/shapes/search.js +6 -0
- package/dist/output/shapes/secret-list.js +19 -0
- package/dist/output/shapes/show.js +6 -0
- package/dist/output/shapes/vault-list.js +19 -0
- package/dist/output/shapes.js +51 -549
- package/dist/output/text/add.js +6 -0
- package/dist/output/text/clone.js +6 -0
- package/dist/output/text/config.js +6 -0
- package/dist/output/text/curate.js +6 -0
- package/dist/output/text/distill.js +7 -0
- package/dist/output/text/enable-disable.js +7 -0
- package/dist/output/text/events.js +10 -0
- package/dist/output/text/feedback.js +6 -0
- package/dist/output/text/helpers.js +1059 -0
- package/dist/output/text/history.js +7 -0
- package/dist/output/text/import.js +6 -0
- package/dist/output/text/index.js +6 -0
- package/dist/output/text/info.js +6 -0
- package/dist/output/text/init.js +6 -0
- package/dist/output/text/list.js +6 -0
- package/dist/output/text/proposal-producer.js +8 -0
- package/dist/output/text/proposal.js +12 -0
- package/dist/output/text/registry-commands.js +11 -0
- package/dist/output/text/registry.js +30 -0
- package/dist/output/text/remember.js +6 -0
- package/dist/output/text/remove.js +6 -0
- package/dist/output/text/save.js +6 -0
- package/dist/output/text/search.js +6 -0
- package/dist/output/text/show.js +6 -0
- package/dist/output/text/update.js +6 -0
- package/dist/output/text/upgrade.js +6 -0
- package/dist/output/text/vault.js +16 -0
- package/dist/output/text/wiki.js +15 -0
- package/dist/output/text/workflow.js +14 -0
- package/dist/output/text.js +44 -1329
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +4 -1
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +11 -2
- package/dist/registry/providers/static-index.js +10 -1
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17767 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +306 -67
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +3 -11
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +171 -21
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +3 -0
- package/dist/tasks/backends/cron.js +3 -0
- package/dist/tasks/backends/exec-utils.js +3 -0
- package/dist/tasks/backends/index.js +3 -11
- package/dist/tasks/backends/launchd.js +3 -0
- package/dist/tasks/backends/schtasks.js +3 -0
- package/dist/tasks/parser.js +51 -38
- package/dist/tasks/resolveAkmBin.js +3 -0
- package/dist/tasks/runner.js +35 -9
- package/dist/tasks/schedule.js +20 -1
- package/dist/tasks/schema.js +5 -3
- package/dist/tasks/validator.js +6 -3
- package/dist/version.js +3 -0
- package/dist/wiki/wiki-templates.js +3 -0
- package/dist/wiki/wiki.js +3 -0
- package/dist/workflows/authoring.js +3 -0
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +140 -10
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +3 -0
- package/dist/workflows/runs.js +18 -1
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +3 -0
- package/dist/workflows/validator.js +5 -9
- package/docs/README.md +7 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.5.md +2 -2
- package/docs/migration/release-notes/0.8.0.md +57 -5
- package/docs/migration/v0.7-to-v0.8.md +1378 -0
- package/package.json +28 -11
- package/.github/LICENSE +0 -374
- package/dist/commands/install-audit.js +0 -385
- package/dist/commands/vault.js +0 -310
- package/dist/indexer/match-contributors.js +0 -141
- package/dist/integrations/agent/pipeline.js +0 -39
- package/dist/integrations/agent/runners.js +0 -31
|
@@ -1,385 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { filterNonEmptyStrings, toPosix } from "../core/common";
|
|
4
|
-
const DEFAULT_INSTALL_AUDIT_CONFIG = {
|
|
5
|
-
enabled: true,
|
|
6
|
-
blockOnCritical: true,
|
|
7
|
-
blockUnlistedRegistries: false,
|
|
8
|
-
registryAllowlist: [],
|
|
9
|
-
allowedFindings: [],
|
|
10
|
-
};
|
|
11
|
-
const MAX_SCANNED_FILE_BYTES = 256 * 1024;
|
|
12
|
-
const LIFECYCLE_SCRIPT_NAMES = new Set([
|
|
13
|
-
"preinstall",
|
|
14
|
-
"install",
|
|
15
|
-
"postinstall",
|
|
16
|
-
"prepublish",
|
|
17
|
-
"prepublishOnly",
|
|
18
|
-
"prepare",
|
|
19
|
-
]);
|
|
20
|
-
const TEXT_FILE_EXTENSIONS = new Set([
|
|
21
|
-
".cjs",
|
|
22
|
-
".cts",
|
|
23
|
-
".js",
|
|
24
|
-
".json",
|
|
25
|
-
".jsonc",
|
|
26
|
-
".jsx",
|
|
27
|
-
".mjs",
|
|
28
|
-
".md",
|
|
29
|
-
".ps1",
|
|
30
|
-
".py",
|
|
31
|
-
".rb",
|
|
32
|
-
".sh",
|
|
33
|
-
".toml",
|
|
34
|
-
".ts",
|
|
35
|
-
".tsx",
|
|
36
|
-
".txt",
|
|
37
|
-
".yaml",
|
|
38
|
-
".yml",
|
|
39
|
-
]);
|
|
40
|
-
const BLOCKED_PACKAGE_DIRECTORIES = new Set(["node_modules", "venv", ".venv", "site-packages"]);
|
|
41
|
-
const CONTENT_RULES = [
|
|
42
|
-
{
|
|
43
|
-
id: "prompt-ignore-previous-instructions",
|
|
44
|
-
severity: "high",
|
|
45
|
-
category: "prompt-injection",
|
|
46
|
-
message: "Contains instructions to ignore prior prompts or instructions.",
|
|
47
|
-
pattern: /\b(ignore|disregard|forget)\b[^.\n]{0,100}\b(previous|prior|earlier)\b[^.\n]{0,100}\b(instructions?|prompts?|messages?)\b/i,
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
id: "prompt-reveal-hidden-secrets",
|
|
51
|
-
severity: "critical",
|
|
52
|
-
category: "prompt-injection",
|
|
53
|
-
message: "Contains instructions to reveal hidden prompts or secrets.",
|
|
54
|
-
pattern: /\b(?:reveal|print|dump|show|output|return|exfiltrat(?:e|ion))\b[^.\n]{0,60}\b(?:your|the)\b[^.\n]{0,40}\b(system prompt|hidden instructions?|developer message|api key|token|secret|password)\b/i,
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
id: "prompt-bypass-guardrails",
|
|
58
|
-
severity: "high",
|
|
59
|
-
category: "prompt-injection",
|
|
60
|
-
message: "Contains instructions to bypass safety or security controls.",
|
|
61
|
-
pattern: /\b(bypass|disable|ignore)\b[^.\n]{0,100}\b(safety|security|guardrails|restrictions|policies)\b/i,
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
id: "remote-shell-pipe",
|
|
65
|
-
severity: "critical",
|
|
66
|
-
category: "malicious-code",
|
|
67
|
-
message: "Downloads remote content and pipes it directly into a shell.",
|
|
68
|
-
pattern: /\b(curl|wget)\b[^\n|]{0,200}\|\s*(sh|bash|zsh)\b/i,
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
id: "powershell-download-exec",
|
|
72
|
-
severity: "critical",
|
|
73
|
-
category: "malicious-code",
|
|
74
|
-
message: "Downloads remote content and executes it in PowerShell.",
|
|
75
|
-
pattern: /\b(Invoke-WebRequest|iwr|curl)\b[^\n|]{0,200}\|\s*(iex|Invoke-Expression)\b/i,
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
id: "powershell-encoded-command",
|
|
79
|
-
severity: "critical",
|
|
80
|
-
category: "malicious-code",
|
|
81
|
-
message: "Uses an encoded PowerShell command.",
|
|
82
|
-
pattern: /\bpowershell(?:\.exe)?\b[^\n]{0,120}\s-(?:enc|encodedcommand)\b/i,
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
id: "credential-exfiltration-language",
|
|
86
|
-
severity: "high",
|
|
87
|
-
category: "malicious-code",
|
|
88
|
-
message: "Contains language associated with credential or secret exfiltration.",
|
|
89
|
-
pattern: /\b(exfiltrat(?:e|ion)|harvest|steal)\b[^.\n]{0,120}\b(credentials?|tokens?|secrets?|ssh keys?|passwords?|cookies?)\b/i,
|
|
90
|
-
},
|
|
91
|
-
];
|
|
92
|
-
export function resolveInstallAuditConfig(config) {
|
|
93
|
-
const installAudit = config?.security?.installAudit;
|
|
94
|
-
const allowlist = filterNonEmptyStrings(installAudit?.registryAllowlist) ??
|
|
95
|
-
filterNonEmptyStrings(installAudit?.registryWhitelist) ??
|
|
96
|
-
[];
|
|
97
|
-
return {
|
|
98
|
-
enabled: installAudit?.enabled ?? DEFAULT_INSTALL_AUDIT_CONFIG.enabled,
|
|
99
|
-
blockOnCritical: installAudit?.blockOnCritical ?? DEFAULT_INSTALL_AUDIT_CONFIG.blockOnCritical,
|
|
100
|
-
blockUnlistedRegistries: installAudit?.blockUnlistedRegistries ?? DEFAULT_INSTALL_AUDIT_CONFIG.blockUnlistedRegistries,
|
|
101
|
-
registryAllowlist: allowlist.map((entry) => entry.trim().toLowerCase()),
|
|
102
|
-
allowedFindings: installAudit?.allowedFindings ?? DEFAULT_INSTALL_AUDIT_CONFIG.allowedFindings,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
export function enforceRegistryInstallPolicy(registryLabels, config, ref) {
|
|
106
|
-
const resolved = resolveInstallAuditConfig(config);
|
|
107
|
-
if (!resolved.blockUnlistedRegistries)
|
|
108
|
-
return;
|
|
109
|
-
if (resolved.registryAllowlist.length === 0) {
|
|
110
|
-
throw new Error(`Install blocked for ${ref}: no registries are allowlisted. Configure security.installAudit.registryAllowlist or disable security.installAudit.blockUnlistedRegistries.`);
|
|
111
|
-
}
|
|
112
|
-
const matched = registryLabels.some((label) => resolved.registryAllowlist.includes(label.toLowerCase()));
|
|
113
|
-
if (matched)
|
|
114
|
-
return;
|
|
115
|
-
throw new Error(`Install blocked for ${ref}: registry is not allowlisted. Allowed: ${resolved.registryAllowlist.join(", ")}. Seen: ${registryLabels.join(", ")}.`);
|
|
116
|
-
}
|
|
117
|
-
export function auditInstallCandidate(input) {
|
|
118
|
-
const resolved = resolveInstallAuditConfig(input.config);
|
|
119
|
-
if (!resolved.enabled) {
|
|
120
|
-
return {
|
|
121
|
-
enabled: false,
|
|
122
|
-
passed: true,
|
|
123
|
-
blocked: false,
|
|
124
|
-
trusted: false,
|
|
125
|
-
registryLabels: [...input.registryLabels],
|
|
126
|
-
findings: [],
|
|
127
|
-
scannedFiles: 0,
|
|
128
|
-
scannedBytes: 0,
|
|
129
|
-
summary: buildSummary([]),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
const findings = [];
|
|
133
|
-
const counters = { scannedFiles: 0, scannedBytes: 0 };
|
|
134
|
-
scanDirectory(input.rootDir, input.rootDir, findings, counters);
|
|
135
|
-
const { findings: activeFindings, waivedFindings } = splitAllowedFindings(findings, input.ref, resolved.allowedFindings);
|
|
136
|
-
const summary = buildSummary(activeFindings);
|
|
137
|
-
const blocked = !input.trustThisInstall && resolved.blockOnCritical && summary.critical > 0;
|
|
138
|
-
return {
|
|
139
|
-
enabled: true,
|
|
140
|
-
passed: activeFindings.length === 0,
|
|
141
|
-
blocked,
|
|
142
|
-
trusted: Boolean(input.trustThisInstall),
|
|
143
|
-
registryLabels: [...input.registryLabels],
|
|
144
|
-
findings: activeFindings,
|
|
145
|
-
scannedFiles: counters.scannedFiles,
|
|
146
|
-
scannedBytes: counters.scannedBytes,
|
|
147
|
-
summary,
|
|
148
|
-
...(waivedFindings.length > 0 ? { waivedFindings } : {}),
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
export function formatInstallAuditFailure(ref, report) {
|
|
152
|
-
return formatInstallAuditFailureForAction(ref, report, "add");
|
|
153
|
-
}
|
|
154
|
-
export function formatInstallAuditFailureForAction(ref, report, action) {
|
|
155
|
-
const lines = [`Security audit failed for ${ref}.`, formatInstallAuditSummary(report)];
|
|
156
|
-
for (const finding of report.findings.slice(0, 5)) {
|
|
157
|
-
lines.push(`- [${finding.severity}] ${finding.message}${finding.file ? ` (${finding.file})` : ""}`);
|
|
158
|
-
}
|
|
159
|
-
if (report.findings.length > 5) {
|
|
160
|
-
lines.push(`- ${report.findings.length - 5} more finding(s) omitted`);
|
|
161
|
-
}
|
|
162
|
-
const trustCommand = action === "update" ? `akm update ${ref} --trust` : `akm add ${ref} --trust`;
|
|
163
|
-
lines.push("Disable blocking with `security.installAudit.blockOnCritical = false`, or disable audits with `security.installAudit.enabled = false`." +
|
|
164
|
-
` Or pass --trust on a one-off '${trustCommand}' to bypass this audit for this ${action} only.`);
|
|
165
|
-
return lines.join("\n");
|
|
166
|
-
}
|
|
167
|
-
export function formatInstallAuditSummary(report) {
|
|
168
|
-
if (!report.enabled)
|
|
169
|
-
return "Audit: disabled";
|
|
170
|
-
const severitySummary = [];
|
|
171
|
-
if (report.summary.critical > 0)
|
|
172
|
-
severitySummary.push(`${report.summary.critical} critical`);
|
|
173
|
-
if (report.summary.high > 0)
|
|
174
|
-
severitySummary.push(`${report.summary.high} high`);
|
|
175
|
-
if (report.summary.moderate > 0)
|
|
176
|
-
severitySummary.push(`${report.summary.moderate} moderate`);
|
|
177
|
-
if (report.summary.low > 0)
|
|
178
|
-
severitySummary.push(`${report.summary.low} low`);
|
|
179
|
-
const detail = severitySummary.length > 0 ? severitySummary.join(", ") : "no findings";
|
|
180
|
-
const status = report.blocked ? "blocked" : report.passed ? "passed" : report.trusted ? "trusted" : "warnings";
|
|
181
|
-
const waived = report.waivedFindings?.length ? `; waived ${report.waivedFindings.length}` : "";
|
|
182
|
-
return `Audit: ${status} (${detail}; scanned ${report.scannedFiles} file${report.scannedFiles === 1 ? "" : "s"}${waived})`;
|
|
183
|
-
}
|
|
184
|
-
export function deriveRegistryLabels(input) {
|
|
185
|
-
const labels = new Set();
|
|
186
|
-
labels.add(input.source);
|
|
187
|
-
if (input.source === "github")
|
|
188
|
-
labels.add("github.com");
|
|
189
|
-
if (input.source === "npm")
|
|
190
|
-
labels.add("npm");
|
|
191
|
-
addUrlLabels(labels, input.artifactUrl);
|
|
192
|
-
addUrlLabels(labels, input.gitUrl);
|
|
193
|
-
if (input.source === "github" && input.ref.startsWith("github:")) {
|
|
194
|
-
labels.add("github");
|
|
195
|
-
}
|
|
196
|
-
return [...labels];
|
|
197
|
-
}
|
|
198
|
-
function scanDirectory(dir, rootDir, findings, counters) {
|
|
199
|
-
let entries;
|
|
200
|
-
try {
|
|
201
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
for (const entry of entries) {
|
|
207
|
-
if (entry.name === ".git")
|
|
208
|
-
continue;
|
|
209
|
-
const fullPath = path.join(dir, entry.name);
|
|
210
|
-
if (entry.isDirectory()) {
|
|
211
|
-
if (BLOCKED_PACKAGE_DIRECTORIES.has(entry.name)) {
|
|
212
|
-
const relativePath = path.relative(rootDir, fullPath) || entry.name;
|
|
213
|
-
findings.push({
|
|
214
|
-
id: "bundled-package-directory",
|
|
215
|
-
severity: "critical",
|
|
216
|
-
category: "vendored-dependency",
|
|
217
|
-
message: `Contains bundled dependency directory "${entry.name}".`,
|
|
218
|
-
file: relativePath,
|
|
219
|
-
snippet: relativePath,
|
|
220
|
-
});
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
scanDirectory(fullPath, rootDir, findings, counters);
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
if (!entry.isFile())
|
|
227
|
-
continue;
|
|
228
|
-
scanFile(fullPath, rootDir, findings, counters);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
function scanFile(filePath, rootDir, findings, counters) {
|
|
232
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
233
|
-
const basename = path.basename(filePath).toLowerCase();
|
|
234
|
-
if (basename !== "package.json" && !TEXT_FILE_EXTENSIONS.has(ext))
|
|
235
|
-
return;
|
|
236
|
-
let fileSize;
|
|
237
|
-
try {
|
|
238
|
-
fileSize = fs.statSync(filePath).size;
|
|
239
|
-
}
|
|
240
|
-
catch {
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
const readSize = Math.min(fileSize, MAX_SCANNED_FILE_BYTES);
|
|
244
|
-
const buf = Buffer.alloc(readSize);
|
|
245
|
-
let bytesRead;
|
|
246
|
-
try {
|
|
247
|
-
const fd = fs.openSync(filePath, "r");
|
|
248
|
-
try {
|
|
249
|
-
bytesRead = fs.readSync(fd, buf, 0, readSize, 0);
|
|
250
|
-
}
|
|
251
|
-
finally {
|
|
252
|
-
fs.closeSync(fd);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
catch {
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
if (bytesRead === 0)
|
|
259
|
-
return;
|
|
260
|
-
const bytes = buf.subarray(0, bytesRead);
|
|
261
|
-
if (bytes.includes(0))
|
|
262
|
-
return;
|
|
263
|
-
counters.scannedFiles += 1;
|
|
264
|
-
counters.scannedBytes += bytesRead;
|
|
265
|
-
const content = bytes.toString("utf8");
|
|
266
|
-
const relativePath = path.relative(rootDir, filePath) || path.basename(filePath);
|
|
267
|
-
const genericContent = basename === "package.json" ? stripPackageJsonScripts(content) : content;
|
|
268
|
-
for (const rule of CONTENT_RULES) {
|
|
269
|
-
const match = genericContent.match(rule.pattern);
|
|
270
|
-
if (!match)
|
|
271
|
-
continue;
|
|
272
|
-
findings.push({
|
|
273
|
-
id: rule.id,
|
|
274
|
-
severity: rule.severity,
|
|
275
|
-
category: rule.category,
|
|
276
|
-
message: rule.message,
|
|
277
|
-
file: relativePath,
|
|
278
|
-
snippet: clipSnippet(match[0]),
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
if (basename === "package.json") {
|
|
282
|
-
scanPackageJson(content, relativePath, findings);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
function stripPackageJsonScripts(content) {
|
|
286
|
-
let parsed;
|
|
287
|
-
try {
|
|
288
|
-
parsed = JSON.parse(content);
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
return content;
|
|
292
|
-
}
|
|
293
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
294
|
-
return content;
|
|
295
|
-
const packageJson = { ...parsed };
|
|
296
|
-
delete packageJson.scripts;
|
|
297
|
-
return JSON.stringify(packageJson, null, 2);
|
|
298
|
-
}
|
|
299
|
-
function scanPackageJson(content, relativePath, findings) {
|
|
300
|
-
let parsed;
|
|
301
|
-
try {
|
|
302
|
-
parsed = JSON.parse(content);
|
|
303
|
-
}
|
|
304
|
-
catch {
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
308
|
-
return;
|
|
309
|
-
const scripts = parsed.scripts;
|
|
310
|
-
if (typeof scripts !== "object" || scripts === null || Array.isArray(scripts))
|
|
311
|
-
return;
|
|
312
|
-
for (const [name, command] of Object.entries(scripts)) {
|
|
313
|
-
if (!LIFECYCLE_SCRIPT_NAMES.has(name) || typeof command !== "string")
|
|
314
|
-
continue;
|
|
315
|
-
for (const rule of CONTENT_RULES) {
|
|
316
|
-
if (!rule.pattern.test(command))
|
|
317
|
-
continue;
|
|
318
|
-
findings.push({
|
|
319
|
-
id: `lifecycle-${name}-${rule.id}`,
|
|
320
|
-
severity: rule.severity,
|
|
321
|
-
category: "install-script",
|
|
322
|
-
message: `Lifecycle script "${name}" is suspicious: ${rule.message.toLowerCase()}`,
|
|
323
|
-
file: relativePath,
|
|
324
|
-
snippet: clipSnippet(command),
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
function clipSnippet(value) {
|
|
330
|
-
const normalized = value.replace(/\s+/g, " ").trim();
|
|
331
|
-
return normalized.length <= 140 ? normalized : `${normalized.slice(0, 137)}...`;
|
|
332
|
-
}
|
|
333
|
-
function buildSummary(findings) {
|
|
334
|
-
const summary = { low: 0, moderate: 0, high: 0, critical: 0, total: findings.length };
|
|
335
|
-
for (const finding of findings) {
|
|
336
|
-
summary[finding.severity] += 1;
|
|
337
|
-
}
|
|
338
|
-
return summary;
|
|
339
|
-
}
|
|
340
|
-
function splitAllowedFindings(findings, ref, allowedFindings) {
|
|
341
|
-
const active = [];
|
|
342
|
-
const waived = [];
|
|
343
|
-
for (const finding of findings) {
|
|
344
|
-
if (matchesAllowedFinding(finding, ref, allowedFindings)) {
|
|
345
|
-
waived.push(finding);
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
active.push(finding);
|
|
349
|
-
}
|
|
350
|
-
return { findings: active, waivedFindings: waived };
|
|
351
|
-
}
|
|
352
|
-
function matchesAllowedFinding(finding, ref, allowedFindings) {
|
|
353
|
-
// Normalize paths so a waiver written against `scripts/setup.sh` matches
|
|
354
|
-
// a finding emitted as `./scripts/setup.sh` or `scripts//setup.sh`. On
|
|
355
|
-
// Windows we also fold case, mirroring `isWithin`'s comparison rules.
|
|
356
|
-
const findingPathNormalized = normalizeWaiverPath(finding.file);
|
|
357
|
-
return allowedFindings.some((allowed) => {
|
|
358
|
-
if (allowed.id !== finding.id)
|
|
359
|
-
return false;
|
|
360
|
-
if (allowed.ref && allowed.ref !== ref)
|
|
361
|
-
return false;
|
|
362
|
-
if (allowed.path && normalizeWaiverPath(allowed.path) !== findingPathNormalized)
|
|
363
|
-
return false;
|
|
364
|
-
return true;
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
function normalizeWaiverPath(value) {
|
|
368
|
-
if (!value)
|
|
369
|
-
return value;
|
|
370
|
-
// Strip a leading `./` and POSIX-ify after path.normalize so Windows path
|
|
371
|
-
// separators don't trigger spurious mismatches.
|
|
372
|
-
const normalized = toPosix(path.normalize(value)).replace(/^\.\/+/, "");
|
|
373
|
-
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
374
|
-
}
|
|
375
|
-
function addUrlLabels(labels, rawUrl) {
|
|
376
|
-
if (!rawUrl)
|
|
377
|
-
return;
|
|
378
|
-
try {
|
|
379
|
-
const parsed = new URL(rawUrl);
|
|
380
|
-
labels.add(parsed.hostname.toLowerCase());
|
|
381
|
-
}
|
|
382
|
-
catch {
|
|
383
|
-
// Ignore non-URL refs (for example git@host:path)
|
|
384
|
-
}
|
|
385
|
-
}
|
package/dist/commands/vault.js
DELETED
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vault asset type — secret storage backed by `.env` files.
|
|
3
|
-
*
|
|
4
|
-
* Invariant: vault values must never be written to stdout, returned through
|
|
5
|
-
* the indexer, the `akm show` renderer, or any structured output channel.
|
|
6
|
-
* The supported load paths are:
|
|
7
|
-
*
|
|
8
|
-
* - `source "$(akm vault path vault:<name>)"` — direct shell loading path.
|
|
9
|
-
* - `injectIntoEnv(vaultPath, target)` / `loadEnv(vaultPath)` — programmatic
|
|
10
|
-
* APIs for modules that need values in process memory.
|
|
11
|
-
*
|
|
12
|
-
* Value parsing is delegated to the `dotenv` package — we deliberately do not
|
|
13
|
-
* implement our own quoting/escaping rules for security-sensitive content.
|
|
14
|
-
*/
|
|
15
|
-
import fs from "node:fs";
|
|
16
|
-
import path from "node:path";
|
|
17
|
-
import dotenv from "dotenv";
|
|
18
|
-
import { writeFileAtomic } from "../core/common";
|
|
19
|
-
/** Matches a KEY=value assignment line, capturing only the key. */
|
|
20
|
-
const ASSIGN_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
21
|
-
/** Scan lines and return KEY names in file order, without duplicates. */
|
|
22
|
-
function scanKeys(text) {
|
|
23
|
-
const keys = [];
|
|
24
|
-
const seen = new Set();
|
|
25
|
-
for (const line of text.split(/\r?\n/)) {
|
|
26
|
-
const m = line.match(ASSIGN_RE);
|
|
27
|
-
if (!m)
|
|
28
|
-
continue;
|
|
29
|
-
const key = m[1];
|
|
30
|
-
if (seen.has(key))
|
|
31
|
-
continue;
|
|
32
|
-
seen.add(key);
|
|
33
|
-
keys.push(key);
|
|
34
|
-
}
|
|
35
|
-
return keys;
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Scan lines and return start-of-line `#` comments (with the leading `#` and
|
|
39
|
-
* any leading whitespace stripped). Inline/trailing `#` after an assignment is
|
|
40
|
-
* never extracted.
|
|
41
|
-
*/
|
|
42
|
-
function scanComments(text) {
|
|
43
|
-
const comments = [];
|
|
44
|
-
for (const line of text.split(/\r?\n/)) {
|
|
45
|
-
const trimmed = line.trimStart();
|
|
46
|
-
if (trimmed.startsWith("#")) {
|
|
47
|
-
comments.push(trimmed.slice(1).trimStart());
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return comments;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Read and return ONLY non-secret metadata (keys + start-of-line comments).
|
|
54
|
-
*
|
|
55
|
-
* The function reads the whole file into memory (same as any dotenv parser)
|
|
56
|
-
* but deliberately does not parse values — the LHS-only regex scanners above
|
|
57
|
-
* ensure no value content is retained or returned. The guarantee is that
|
|
58
|
-
* values never leave this function.
|
|
59
|
-
*/
|
|
60
|
-
export function listKeys(vaultPath) {
|
|
61
|
-
if (!fs.existsSync(vaultPath))
|
|
62
|
-
return { keys: [], comments: [] };
|
|
63
|
-
const text = fs.readFileSync(vaultPath, "utf8");
|
|
64
|
-
return { keys: scanKeys(text), comments: scanComments(text) };
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Return structured `entries` pairing each key with the nearest preceding
|
|
68
|
-
* comment line (if any). This replaces the parallel `keys[]` + `comments[]`
|
|
69
|
-
* shape used internally by `listKeys` with a single merged array, which is
|
|
70
|
-
* easier for callers to consume (QA #35).
|
|
71
|
-
*
|
|
72
|
-
* Values are never included — the same privacy guarantee as `listKeys`.
|
|
73
|
-
*/
|
|
74
|
-
export function listEntries(vaultPath) {
|
|
75
|
-
if (!fs.existsSync(vaultPath))
|
|
76
|
-
return [];
|
|
77
|
-
const text = fs.readFileSync(vaultPath, "utf8");
|
|
78
|
-
const lines = text.split(/\r?\n/);
|
|
79
|
-
const seen = new Set();
|
|
80
|
-
const entries = [];
|
|
81
|
-
let pendingComment;
|
|
82
|
-
for (const line of lines) {
|
|
83
|
-
const trimmed = line.trimStart();
|
|
84
|
-
if (trimmed.startsWith("#")) {
|
|
85
|
-
// Capture the most recent comment before a key
|
|
86
|
-
pendingComment = trimmed.slice(1).trimStart() || undefined;
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
const m = line.match(ASSIGN_RE);
|
|
90
|
-
if (m) {
|
|
91
|
-
const key = m[1];
|
|
92
|
-
if (!seen.has(key)) {
|
|
93
|
-
seen.add(key);
|
|
94
|
-
const entry = { key };
|
|
95
|
-
if (pendingComment)
|
|
96
|
-
entry.comment = pendingComment;
|
|
97
|
-
entries.push(entry);
|
|
98
|
-
}
|
|
99
|
-
pendingComment = undefined;
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
// Any non-comment, non-assignment line (including blank lines)
|
|
103
|
-
// breaks "nearest preceding comment line" association.
|
|
104
|
-
pendingComment = undefined;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return entries;
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Read all KEY=value pairs from a vault file. Intended for programmatic
|
|
111
|
-
* callers that need to inject values into a process environment. Callers
|
|
112
|
-
* MUST NOT write the returned values to stdout or any logged output.
|
|
113
|
-
*
|
|
114
|
-
* Value parsing (quoting, escapes, multi-line, etc.) is delegated to dotenv.
|
|
115
|
-
*/
|
|
116
|
-
export function loadEnv(vaultPath) {
|
|
117
|
-
if (!fs.existsSync(vaultPath))
|
|
118
|
-
return {};
|
|
119
|
-
const buf = fs.readFileSync(vaultPath);
|
|
120
|
-
return dotenv.parse(buf);
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Load a vault and assign its values into `target` (defaults to `process.env`).
|
|
124
|
-
* Returns the list of keys that were set so the caller can log/observe without
|
|
125
|
-
* touching values.
|
|
126
|
-
*
|
|
127
|
-
* Existing keys in `target` are overwritten — callers who want to preserve
|
|
128
|
-
* pre-existing environment variables should filter before calling.
|
|
129
|
-
*/
|
|
130
|
-
export function injectIntoEnv(vaultPath, target = process.env) {
|
|
131
|
-
const env = loadEnv(vaultPath);
|
|
132
|
-
for (const [key, value] of Object.entries(env)) {
|
|
133
|
-
target[key] = value;
|
|
134
|
-
}
|
|
135
|
-
return Object.keys(env);
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* Serialise a vault's values as a POSIX shell script of `export KEY='value'`
|
|
139
|
-
* lines, with single-quote escaping (`'\''`). Every line is an assignment of
|
|
140
|
-
* a literal string — there is no expansion, command substitution, or
|
|
141
|
-
* non-assignment content, so sourcing the output is safe regardless of what
|
|
142
|
-
* the vault file contains.
|
|
143
|
-
*
|
|
144
|
-
* Retained for programmatic callers/tests that need a literal export script.
|
|
145
|
-
*/
|
|
146
|
-
export function buildShellExportScript(vaultPath) {
|
|
147
|
-
const env = loadEnv(vaultPath);
|
|
148
|
-
const lines = [];
|
|
149
|
-
for (const [key, value] of Object.entries(env)) {
|
|
150
|
-
// Defence in depth: dotenv already validates key shape, but reject any
|
|
151
|
-
// key we wouldn't be able to export safely.
|
|
152
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
153
|
-
continue;
|
|
154
|
-
const escaped = value.replace(/'/g, "'\\''");
|
|
155
|
-
lines.push(`export ${key}='${escaped}'`);
|
|
156
|
-
}
|
|
157
|
-
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Set a key in the vault file, preserving line order and comments. Creates
|
|
161
|
-
* the file (and parent directory) if it does not exist.
|
|
162
|
-
*
|
|
163
|
-
* `quoteValue` picks the safest representation that dotenv round-trips:
|
|
164
|
-
* single-quoted when the value has no `'`, double-quoted when it has `'` but
|
|
165
|
-
* no `"` and no literal `\n`/`\r` escape sequences, and unquoted only for
|
|
166
|
-
* values that contain no characters requiring escaping (see quoteValue for
|
|
167
|
-
* the full rule set). Values containing newlines or both quote types are
|
|
168
|
-
* rejected outright. Round-trip safety is enforced by the test suite.
|
|
169
|
-
*
|
|
170
|
-
* When `comment` is provided it is written as a `# <comment>` line
|
|
171
|
-
* immediately before the `KEY=value` line:
|
|
172
|
-
* - New key: the comment line is inserted just before the appended key.
|
|
173
|
-
* - Existing key: if the preceding line is already a comment it is replaced
|
|
174
|
-
* with the new comment; otherwise a new comment line is inserted.
|
|
175
|
-
* When `comment` is absent the surrounding comment lines are left unchanged.
|
|
176
|
-
*/
|
|
177
|
-
export function setKey(vaultPath, key, value, comment) {
|
|
178
|
-
validateKeyName(key);
|
|
179
|
-
if (comment !== undefined && /[\r\n]/.test(comment)) {
|
|
180
|
-
throw new Error("Vault key comment cannot contain newline characters.");
|
|
181
|
-
}
|
|
182
|
-
ensureParentDir(vaultPath);
|
|
183
|
-
const existing = fs.existsSync(vaultPath) ? fs.readFileSync(vaultPath, "utf8") : "";
|
|
184
|
-
const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
|
|
185
|
-
const formatted = `${key}=${quoteValue(value)}`;
|
|
186
|
-
let replaced = false;
|
|
187
|
-
for (let i = 0; i < lines.length; i++) {
|
|
188
|
-
const m = lines[i].match(ASSIGN_RE);
|
|
189
|
-
if (m && m[1] === key) {
|
|
190
|
-
lines[i] = formatted;
|
|
191
|
-
replaced = true;
|
|
192
|
-
if (comment !== undefined) {
|
|
193
|
-
const commentLine = `# ${comment}`;
|
|
194
|
-
const prevIsComment = i > 0 && lines[i - 1].trimStart().startsWith("#");
|
|
195
|
-
if (prevIsComment) {
|
|
196
|
-
lines[i - 1] = commentLine;
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
lines.splice(i, 0, commentLine);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
break;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (!replaced) {
|
|
206
|
-
if (comment !== undefined) {
|
|
207
|
-
const commentLine = `# ${comment}`;
|
|
208
|
-
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
209
|
-
lines[lines.length - 1] = commentLine;
|
|
210
|
-
lines.push(formatted);
|
|
211
|
-
lines.push("");
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
lines.push(commentLine);
|
|
215
|
-
lines.push(formatted);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
else if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
219
|
-
lines[lines.length - 1] = formatted;
|
|
220
|
-
lines.push("");
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
lines.push(formatted);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
let out = lines.join("\n");
|
|
227
|
-
if (!out.endsWith("\n"))
|
|
228
|
-
out += "\n";
|
|
229
|
-
writeFileAtomic(vaultPath, out, 0o600);
|
|
230
|
-
}
|
|
231
|
-
/** Remove a key from the vault file. Returns true if the key was present. */
|
|
232
|
-
export function unsetKey(vaultPath, key) {
|
|
233
|
-
if (!fs.existsSync(vaultPath))
|
|
234
|
-
return false;
|
|
235
|
-
const text = fs.readFileSync(vaultPath, "utf8");
|
|
236
|
-
const lines = text.split(/\r?\n/);
|
|
237
|
-
const kept = [];
|
|
238
|
-
let removed = false;
|
|
239
|
-
for (const line of lines) {
|
|
240
|
-
const m = line.match(ASSIGN_RE);
|
|
241
|
-
if (m && m[1] === key) {
|
|
242
|
-
removed = true;
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
kept.push(line);
|
|
246
|
-
}
|
|
247
|
-
if (!removed)
|
|
248
|
-
return false;
|
|
249
|
-
let out = kept.join("\n");
|
|
250
|
-
if (out.length > 0 && !out.endsWith("\n"))
|
|
251
|
-
out += "\n";
|
|
252
|
-
writeFileAtomic(vaultPath, out, 0o600);
|
|
253
|
-
return true;
|
|
254
|
-
}
|
|
255
|
-
/** Create an empty vault file (does nothing if it already exists). */
|
|
256
|
-
export function createVault(vaultPath) {
|
|
257
|
-
ensureParentDir(vaultPath);
|
|
258
|
-
if (fs.existsSync(vaultPath))
|
|
259
|
-
return;
|
|
260
|
-
writeFileAtomic(vaultPath, "", 0o600);
|
|
261
|
-
}
|
|
262
|
-
/**
|
|
263
|
-
* Characters that are safe in an UNquoted dotenv value AND are not
|
|
264
|
-
* metacharacters in POSIX shells. Anything outside this set forces quoting,
|
|
265
|
-
* which is defense-in-depth for any caller that might ever `source` the
|
|
266
|
-
* vault file directly instead of going through `akm vault path`.
|
|
267
|
-
*/
|
|
268
|
-
const UNQUOTED_SAFE_RE = /^[A-Za-z0-9_.:/@%+,-]+$/;
|
|
269
|
-
/**
|
|
270
|
-
* Quote a value for safe storage in a .env file that round-trips through
|
|
271
|
-
* `dotenv.parse` AND is safe if the file is ever `source`d by a POSIX shell.
|
|
272
|
-
*
|
|
273
|
-
* Strategy:
|
|
274
|
-
* - empty → empty
|
|
275
|
-
* - all-safe chars (alnum + `_.:/@%+,-`) → unquoted
|
|
276
|
-
* - no `'` → single-quote (dotenv and shell both treat single-quoted
|
|
277
|
-
* content literally: no expansion, no escapes)
|
|
278
|
-
* - no `"` and no literal `\n`/`\r` escape sequence → double-quote
|
|
279
|
-
* (dotenv unescapes `\n`/`\r` on read, so we
|
|
280
|
-
* can't double-quote a value that contains
|
|
281
|
-
* those literal sequences)
|
|
282
|
-
* - newlines or both quote types → reject
|
|
283
|
-
*
|
|
284
|
-
* dotenv intentionally does NOT support `\"` inside double-quoted values, so
|
|
285
|
-
* we never produce that pattern.
|
|
286
|
-
*/
|
|
287
|
-
function quoteValue(value) {
|
|
288
|
-
if (value.length === 0)
|
|
289
|
-
return "";
|
|
290
|
-
if (/[\n\r]/.test(value)) {
|
|
291
|
-
throw new Error("Vault values cannot contain literal newlines.");
|
|
292
|
-
}
|
|
293
|
-
if (UNQUOTED_SAFE_RE.test(value))
|
|
294
|
-
return value;
|
|
295
|
-
if (!value.includes("'"))
|
|
296
|
-
return `'${value}'`;
|
|
297
|
-
if (!value.includes('"') && !/\\[nr]/.test(value))
|
|
298
|
-
return `"${value}"`;
|
|
299
|
-
throw new Error("Vault value contains both single and double quote characters; not supported.");
|
|
300
|
-
}
|
|
301
|
-
function validateKeyName(key) {
|
|
302
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
303
|
-
throw new Error(`Invalid vault key name: "${key}". Must match [A-Za-z_][A-Za-z0-9_]*`);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
function ensureParentDir(filePath) {
|
|
307
|
-
const dir = path.dirname(filePath);
|
|
308
|
-
if (!fs.existsSync(dir))
|
|
309
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
310
|
-
}
|