envpkt 0.8.1 → 0.9.1
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/README.md +30 -6
- package/dist/cli.js +283 -30
- package/dist/index.d.ts +76 -8
- package/dist/index.js +312 -31
- package/package.json +24 -22
- package/schemas/envpkt.schema.json +9 -4
package/README.md
CHANGED
|
@@ -141,6 +141,29 @@ value = "info"
|
|
|
141
141
|
purpose = "Application log verbosity"
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
### Aliases
|
|
145
|
+
|
|
146
|
+
When a consumer hardcodes a different env var name than the one you govern
|
|
147
|
+
canonically, use `from_key` to expose the same value under a second name —
|
|
148
|
+
without duplicating the secret:
|
|
149
|
+
|
|
150
|
+
```toml
|
|
151
|
+
[secret.API_KEY]
|
|
152
|
+
service = "stripe"
|
|
153
|
+
expires = "2027-01-15"
|
|
154
|
+
rotation_url = "https://dashboard.stripe.com/apikeys"
|
|
155
|
+
|
|
156
|
+
# Same governed value, under a legacy name some consumer expects
|
|
157
|
+
[secret.STRIPE_SECRET_KEY]
|
|
158
|
+
from_key = "secret.API_KEY"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Both names are injected at boot, both appear in audit output, and expiration
|
|
162
|
+
tracking lives on the target — an alias is healthy iff its target is. Same
|
|
163
|
+
pattern works for `[env.*]`. Cross-type aliasing (secret → env) is rejected
|
|
164
|
+
at load time. See [TOML Schema → Aliases](https://envpkt.dev/reference/toml-schema/#aliases)
|
|
165
|
+
for the full rules.
|
|
166
|
+
|
|
144
167
|
See [`examples/`](./examples/) for more configurations.
|
|
145
168
|
|
|
146
169
|
## Sealed Packets
|
|
@@ -150,21 +173,22 @@ Sealed packets embed age-encrypted secret values directly in `envpkt.toml`. This
|
|
|
150
173
|
### Setup
|
|
151
174
|
|
|
152
175
|
```bash
|
|
153
|
-
# Generate an age keypair
|
|
154
|
-
|
|
155
|
-
# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
|
176
|
+
# Generate an age keypair — writes to ~/.envpkt/<project>-key.txt and updates envpkt.toml
|
|
177
|
+
envpkt keygen
|
|
156
178
|
```
|
|
157
179
|
|
|
158
|
-
|
|
180
|
+
This writes `[identity]` with `name`, `recipient`, and `key_file` to your `envpkt.toml`. Add the key file to `.gitignore`:
|
|
159
181
|
|
|
160
182
|
```toml
|
|
161
183
|
[identity]
|
|
162
184
|
name = "my-agent"
|
|
163
185
|
recipient = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"
|
|
164
|
-
key_file = "
|
|
186
|
+
key_file = "~/.envpkt/my-agent-key.txt"
|
|
165
187
|
```
|
|
166
188
|
|
|
167
|
-
|
|
189
|
+
`envpkt keygen` defaults to a **project-specific path** (`~/.envpkt/<project>-key.txt`), so separate projects never collide. For multi-environment projects (e.g. `prod.envpkt.toml` + `dev.envpkt.toml`), each config gets its own key automatically. Pass `--global` to use the shared `~/.envpkt/age-key.txt` path instead.
|
|
190
|
+
|
|
191
|
+
The `key_file` path supports `~` expansion and environment variables (`$VAR`, `${VAR}`). Relative paths are resolved from the config file's directory. When omitted, envpkt falls back to `ENVPKT_AGE_KEY_FILE` env var, then `~/.envpkt/age-key.txt` — but it's best to set `key_file` explicitly so the config tells you which key it needs.
|
|
168
192
|
|
|
169
193
|
### Seal
|
|
170
194
|
|
package/dist/cli.js
CHANGED
|
@@ -54,10 +54,28 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
|
|
|
54
54
|
purpose,
|
|
55
55
|
created: Option(meta.created),
|
|
56
56
|
expires: Option(meta.expires),
|
|
57
|
-
issues: List(issues)
|
|
57
|
+
issues: List(issues),
|
|
58
|
+
alias_of: Option(void 0)
|
|
58
59
|
};
|
|
59
60
|
};
|
|
60
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Build a SecretHealth row for an alias entry. Status is inherited from the
|
|
63
|
+
* target; metadata (purpose, tags) comes from the alias entry itself where
|
|
64
|
+
* set, otherwise falls through to the target so operators see context.
|
|
65
|
+
*/
|
|
66
|
+
const classifyAlias = (key, meta, targetHealth, targetRef) => ({
|
|
67
|
+
key,
|
|
68
|
+
service: targetHealth.service,
|
|
69
|
+
status: targetHealth.status,
|
|
70
|
+
days_remaining: targetHealth.days_remaining,
|
|
71
|
+
rotation_url: targetHealth.rotation_url,
|
|
72
|
+
purpose: meta.purpose !== void 0 ? Option(meta.purpose) : targetHealth.purpose,
|
|
73
|
+
created: targetHealth.created,
|
|
74
|
+
expires: targetHealth.expires,
|
|
75
|
+
issues: List([]),
|
|
76
|
+
alias_of: Option(targetRef)
|
|
77
|
+
});
|
|
78
|
+
const computeAudit = (config, fnoxKeys, today, aliasTable) => {
|
|
61
79
|
const now = today ?? /* @__PURE__ */ new Date();
|
|
62
80
|
const lifecycle = config.lifecycle ?? {};
|
|
63
81
|
const staleWarningDays = lifecycle.stale_warning_days ?? 90;
|
|
@@ -65,9 +83,31 @@ const computeAudit = (config, fnoxKeys, today) => {
|
|
|
65
83
|
const requireService = lifecycle.require_service ?? false;
|
|
66
84
|
const keys = fnoxKeys ?? /* @__PURE__ */ new Set();
|
|
67
85
|
const secretEntries = config.secret ?? {};
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
86
|
+
const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
87
|
+
const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
|
|
88
|
+
const nonAliasMetaKeys = new Set(nonAliasEntries.map(([k]) => k));
|
|
89
|
+
const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
|
|
90
|
+
const healthByKey = new Map(nonAliasHealth.map((h) => [h.key, h]));
|
|
91
|
+
const aliasHealth = aliasEntries.map(([key, meta]) => {
|
|
92
|
+
const targetKey = (aliasTable?.entries.get(`secret.${key}`))?.targetKey;
|
|
93
|
+
const targetHealth = targetKey !== void 0 ? healthByKey.get(targetKey) : void 0;
|
|
94
|
+
const targetRef = meta.from_key ?? (targetKey !== void 0 ? `secret.${targetKey}` : "");
|
|
95
|
+
if (!targetHealth) return {
|
|
96
|
+
key,
|
|
97
|
+
service: Option(meta.service),
|
|
98
|
+
status: "missing",
|
|
99
|
+
days_remaining: Option(void 0),
|
|
100
|
+
rotation_url: Option(meta.rotation_url),
|
|
101
|
+
purpose: Option(meta.purpose),
|
|
102
|
+
created: Option(meta.created),
|
|
103
|
+
expires: Option(meta.expires),
|
|
104
|
+
issues: List(["Alias target not resolvable"]),
|
|
105
|
+
alias_of: Option(targetRef)
|
|
106
|
+
};
|
|
107
|
+
return classifyAlias(key, meta, targetHealth, targetRef);
|
|
108
|
+
});
|
|
109
|
+
const secrets = List([...nonAliasHealth, ...aliasHealth]);
|
|
110
|
+
const orphaned = keys.size > 0 ? [...nonAliasMetaKeys].filter((k) => !keys.has(k)).length : 0;
|
|
71
111
|
const total = secrets.size;
|
|
72
112
|
const expired = secrets.count((s) => s.status === "expired");
|
|
73
113
|
const missing = secrets.count((s) => s.status === "missing");
|
|
@@ -75,6 +115,7 @@ const computeAudit = (config, fnoxKeys, today) => {
|
|
|
75
115
|
const expiring_soon = secrets.count((s) => s.status === "expiring_soon");
|
|
76
116
|
const stale = secrets.count((s) => s.status === "stale");
|
|
77
117
|
const healthy = secrets.count((s) => s.status === "healthy");
|
|
118
|
+
const aliases = aliasHealth.length;
|
|
78
119
|
return {
|
|
79
120
|
status: Cond.of().when(expired > 0 || missing > 0, "critical").elseWhen(expiring_soon > 0 || stale > 0 || missing_metadata > 0, "degraded").else("healthy"),
|
|
80
121
|
secrets,
|
|
@@ -86,6 +127,7 @@ const computeAudit = (config, fnoxKeys, today) => {
|
|
|
86
127
|
missing,
|
|
87
128
|
missing_metadata,
|
|
88
129
|
orphaned,
|
|
130
|
+
aliases,
|
|
89
131
|
identity: config.identity
|
|
90
132
|
};
|
|
91
133
|
};
|
|
@@ -93,13 +135,17 @@ const computeEnvAudit = (config, env = process.env) => {
|
|
|
93
135
|
const envEntries = config.env ?? {};
|
|
94
136
|
const entries = Object.entries(envEntries).map(([key, entry]) => {
|
|
95
137
|
const currentValue = env[key];
|
|
96
|
-
const
|
|
138
|
+
const effectiveDefault = entry.from_key !== void 0 ? (() => {
|
|
139
|
+
const targetKey = /^env\.(.+)$/.exec(entry.from_key)?.[1];
|
|
140
|
+
return (targetKey !== void 0 ? envEntries[targetKey] : void 0)?.value ?? "";
|
|
141
|
+
})() : entry.value ?? "";
|
|
97
142
|
return {
|
|
98
143
|
key,
|
|
99
|
-
defaultValue:
|
|
144
|
+
defaultValue: effectiveDefault,
|
|
100
145
|
currentValue,
|
|
101
|
-
status,
|
|
102
|
-
purpose: entry.purpose
|
|
146
|
+
status: Cond.of().when(currentValue === void 0, "missing").elseWhen(currentValue !== effectiveDefault, "overridden").else("default"),
|
|
147
|
+
purpose: entry.purpose,
|
|
148
|
+
alias_of: Option(entry.from_key)
|
|
103
149
|
};
|
|
104
150
|
});
|
|
105
151
|
return {
|
|
@@ -158,6 +204,7 @@ const SecretMetaSchema = Type.Object({
|
|
|
158
204
|
model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
|
|
159
205
|
source: Type.Optional(Type.String({ description: "Where the secret value originates (e.g. 'vault', 'ci')" })),
|
|
160
206
|
encrypted_value: Type.Optional(Type.String({ description: "Age-encrypted secret value (armored ciphertext, safe to commit)" })),
|
|
207
|
+
from_key: Type.Optional(Type.String({ description: "Reference another entry (format: 'secret.<KEY>') whose resolved value this alias reuses. Mutually exclusive with encrypted_value." })),
|
|
161
208
|
required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
|
|
162
209
|
tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
|
|
163
210
|
}, { description: "Metadata about a single secret" });
|
|
@@ -182,7 +229,8 @@ const CallbackConfigSchema = Type.Object({
|
|
|
182
229
|
}, { description: "Automation callbacks for lifecycle events" });
|
|
183
230
|
const ToolsConfigSchema = Type.Record(Type.String(), Type.Unknown(), { description: "Tool integration configuration — open namespace for third-party extensions" });
|
|
184
231
|
const EnvMetaSchema = Type.Object({
|
|
185
|
-
value: Type.String({ description: "Default value for this environment variable" }),
|
|
232
|
+
value: Type.Optional(Type.String({ description: "Default value for this environment variable. Optional when from_key is set; required otherwise." })),
|
|
233
|
+
from_key: Type.Optional(Type.String({ description: "Reference another entry (format: 'env.<KEY>') whose resolved value this alias reuses. Mutually exclusive with value." })),
|
|
186
234
|
purpose: Type.Optional(Type.String({ description: "Why this env var exists" })),
|
|
187
235
|
comment: Type.Optional(Type.String({ description: "Free-form annotation or note" })),
|
|
188
236
|
tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
|
|
@@ -822,6 +870,140 @@ const readFnoxConfig = (path) => Try(() => readFileSync(path, "utf-8")).fold((er
|
|
|
822
870
|
/** Extract the set of secret key names from a parsed fnox config */
|
|
823
871
|
const extractFnoxKeys = (config) => new Set(Object.keys(config.secrets));
|
|
824
872
|
//#endregion
|
|
873
|
+
//#region src/core/alias.ts
|
|
874
|
+
const ALIAS_REF_RE = /^(secret|env)\.(.+)$/;
|
|
875
|
+
const parseRef = (raw) => {
|
|
876
|
+
const match = ALIAS_REF_RE.exec(raw);
|
|
877
|
+
if (!match) return Option(void 0);
|
|
878
|
+
const kind = match[1];
|
|
879
|
+
const key = match[2];
|
|
880
|
+
if (!key) return Option(void 0);
|
|
881
|
+
return Option({
|
|
882
|
+
kind,
|
|
883
|
+
key
|
|
884
|
+
});
|
|
885
|
+
};
|
|
886
|
+
const validateOneSecret = (key, meta, secretEntries) => {
|
|
887
|
+
if (meta.from_key === void 0) return Right(Option(void 0));
|
|
888
|
+
const ref = meta.from_key;
|
|
889
|
+
if (meta.encrypted_value !== void 0) return Left({
|
|
890
|
+
_tag: "AliasValueConflict",
|
|
891
|
+
key,
|
|
892
|
+
kind: "secret",
|
|
893
|
+
field: "encrypted_value"
|
|
894
|
+
});
|
|
895
|
+
return parseRef(ref).fold(() => Left({
|
|
896
|
+
_tag: "AliasInvalidSyntax",
|
|
897
|
+
key,
|
|
898
|
+
kind: "secret",
|
|
899
|
+
value: ref
|
|
900
|
+
}), (parsed) => {
|
|
901
|
+
if (parsed.kind !== "secret") return Left({
|
|
902
|
+
_tag: "AliasCrossType",
|
|
903
|
+
key,
|
|
904
|
+
kind: "secret",
|
|
905
|
+
targetKind: parsed.kind
|
|
906
|
+
});
|
|
907
|
+
if (parsed.key === key) return Left({
|
|
908
|
+
_tag: "AliasSelfReference",
|
|
909
|
+
key: `secret.${key}`
|
|
910
|
+
});
|
|
911
|
+
return Option(secretEntries[parsed.key]).fold(() => Left({
|
|
912
|
+
_tag: "AliasTargetMissing",
|
|
913
|
+
key: `secret.${key}`,
|
|
914
|
+
target: ref
|
|
915
|
+
}), (target) => {
|
|
916
|
+
if (target.from_key !== void 0) return Left({
|
|
917
|
+
_tag: "AliasChained",
|
|
918
|
+
key: `secret.${key}`,
|
|
919
|
+
target: ref
|
|
920
|
+
});
|
|
921
|
+
return Right(Option({
|
|
922
|
+
kind: "secret",
|
|
923
|
+
targetKind: "secret",
|
|
924
|
+
targetKey: parsed.key
|
|
925
|
+
}));
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
};
|
|
929
|
+
const validateOneEnv = (key, meta, envEntries) => {
|
|
930
|
+
if (meta.from_key === void 0) return Right(Option(void 0));
|
|
931
|
+
const ref = meta.from_key;
|
|
932
|
+
if (meta.value !== void 0) return Left({
|
|
933
|
+
_tag: "AliasValueConflict",
|
|
934
|
+
key,
|
|
935
|
+
kind: "env",
|
|
936
|
+
field: "value"
|
|
937
|
+
});
|
|
938
|
+
return parseRef(ref).fold(() => Left({
|
|
939
|
+
_tag: "AliasInvalidSyntax",
|
|
940
|
+
key,
|
|
941
|
+
kind: "env",
|
|
942
|
+
value: ref
|
|
943
|
+
}), (parsed) => {
|
|
944
|
+
if (parsed.kind !== "env") return Left({
|
|
945
|
+
_tag: "AliasCrossType",
|
|
946
|
+
key,
|
|
947
|
+
kind: "env",
|
|
948
|
+
targetKind: parsed.kind
|
|
949
|
+
});
|
|
950
|
+
if (parsed.key === key) return Left({
|
|
951
|
+
_tag: "AliasSelfReference",
|
|
952
|
+
key: `env.${key}`
|
|
953
|
+
});
|
|
954
|
+
return Option(envEntries[parsed.key]).fold(() => Left({
|
|
955
|
+
_tag: "AliasTargetMissing",
|
|
956
|
+
key: `env.${key}`,
|
|
957
|
+
target: ref
|
|
958
|
+
}), (target) => {
|
|
959
|
+
if (target.from_key !== void 0) return Left({
|
|
960
|
+
_tag: "AliasChained",
|
|
961
|
+
key: `env.${key}`,
|
|
962
|
+
target: ref
|
|
963
|
+
});
|
|
964
|
+
return Right(Option({
|
|
965
|
+
kind: "env",
|
|
966
|
+
targetKind: "env",
|
|
967
|
+
targetKey: parsed.key
|
|
968
|
+
}));
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
};
|
|
972
|
+
/**
|
|
973
|
+
* Validate all `from_key` references in a resolved config. Produces an
|
|
974
|
+
* AliasTable mapping each alias to its target, or an AliasError describing
|
|
975
|
+
* the first failure.
|
|
976
|
+
*
|
|
977
|
+
* Rules:
|
|
978
|
+
* - Ref must be "secret.<KEY>" or "env.<KEY>"
|
|
979
|
+
* - Target must exist in the same resolved config
|
|
980
|
+
* - Target must be the same type (secret→secret, env→env only)
|
|
981
|
+
* - Target must not itself be a from_key entry (single hop only)
|
|
982
|
+
* - Self-reference is rejected
|
|
983
|
+
* - An alias entry cannot also carry a value field (encrypted_value for
|
|
984
|
+
* secrets, value for env)
|
|
985
|
+
*/
|
|
986
|
+
const validateAliases = (config) => {
|
|
987
|
+
const secretEntries = config.secret ?? {};
|
|
988
|
+
const envEntries = config.env ?? {};
|
|
989
|
+
const entries = /* @__PURE__ */ new Map();
|
|
990
|
+
const secretResults = Object.entries(secretEntries).map(([key, meta]) => [key, validateOneSecret(key, meta, secretEntries)]);
|
|
991
|
+
for (const [key, result] of secretResults) {
|
|
992
|
+
const outcome = result.fold((err) => err, (opt) => opt.orUndefined());
|
|
993
|
+
if (outcome === void 0) continue;
|
|
994
|
+
if ("_tag" in outcome) return Left(outcome);
|
|
995
|
+
entries.set(`secret.${key}`, outcome);
|
|
996
|
+
}
|
|
997
|
+
const envResults = Object.entries(envEntries).map(([key, meta]) => [key, validateOneEnv(key, meta, envEntries)]);
|
|
998
|
+
for (const [key, result] of envResults) {
|
|
999
|
+
const outcome = result.fold((err) => err, (opt) => opt.orUndefined());
|
|
1000
|
+
if (outcome === void 0) continue;
|
|
1001
|
+
if ("_tag" in outcome) return Left(outcome);
|
|
1002
|
+
entries.set(`env.${key}`, outcome);
|
|
1003
|
+
}
|
|
1004
|
+
return Right({ entries });
|
|
1005
|
+
};
|
|
1006
|
+
//#endregion
|
|
825
1007
|
//#region src/core/keygen.ts
|
|
826
1008
|
/** Resolve the age identity file path: ENVPKT_AGE_KEY_FILE env var > ~/.envpkt/age-key.txt */
|
|
827
1009
|
const resolveKeyPath = () => process.env["ENVPKT_AGE_KEY_FILE"] ?? join(homedir(), ".envpkt", "age-key.txt");
|
|
@@ -1089,7 +1271,7 @@ const looksLikeSecret = (value) => {
|
|
|
1089
1271
|
};
|
|
1090
1272
|
const checkEnvMisclassification = (config) => {
|
|
1091
1273
|
const envEntries = config.env ?? {};
|
|
1092
|
-
return Object.entries(envEntries).filter(([, entry]) => looksLikeSecret(entry.value)).map(([key]) => `[env.${key}] value looks like a secret — consider moving to [secret.${key}]`);
|
|
1274
|
+
return Object.entries(envEntries).filter(([, entry]) => entry.value !== void 0 && looksLikeSecret(entry.value)).map(([key]) => `[env.${key}] value looks like a secret — consider moving to [secret.${key}]`);
|
|
1093
1275
|
};
|
|
1094
1276
|
/** Programmatic boot — returns Either<BootError, BootResult> */
|
|
1095
1277
|
const bootSafe = (options) => {
|
|
@@ -1097,26 +1279,29 @@ const bootSafe = (options) => {
|
|
|
1097
1279
|
const inject = opts.inject !== false;
|
|
1098
1280
|
const failOnExpired = opts.failOnExpired !== false;
|
|
1099
1281
|
const warnOnly = opts.warnOnly ?? false;
|
|
1100
|
-
return resolveAndLoad(opts).flatMap(({ config, configPath, configDir, configSource }) => {
|
|
1282
|
+
return resolveAndLoad(opts).flatMap(({ config, configPath, configDir, configSource }) => validateAliases(config).fold((err) => Left(err), (aliasTable) => {
|
|
1101
1283
|
const secretEntries = config.secret ?? {};
|
|
1102
|
-
const
|
|
1103
|
-
const
|
|
1284
|
+
const envEntries = config.env ?? {};
|
|
1285
|
+
const nonAliasSecretEntries = Object.fromEntries(Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0));
|
|
1286
|
+
const aliasSecretKeys = Object.keys(secretEntries).filter((k) => secretEntries[k].from_key !== void 0);
|
|
1287
|
+
const nonAliasEnvEntries = Object.entries(envEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
1288
|
+
const aliasEnvKeys = Object.keys(envEntries).filter((k) => envEntries[k].from_key !== void 0);
|
|
1289
|
+
const nonAliasMetaKeys = Object.keys(nonAliasSecretEntries);
|
|
1290
|
+
const hasSealedValues = nonAliasMetaKeys.some((k) => !!nonAliasSecretEntries[k].encrypted_value);
|
|
1104
1291
|
const identityKeyResult = resolveIdentityKey(config, configDir);
|
|
1105
1292
|
const identityKey = identityKeyResult.fold(() => Option(void 0), (k) => k);
|
|
1106
1293
|
if (identityKeyResult.isLeft() && !hasSealedValues) return identityKeyResult.fold((err) => Left(err), () => Left({
|
|
1107
1294
|
_tag: "ReadError",
|
|
1108
1295
|
message: "unexpected"
|
|
1109
1296
|
}));
|
|
1110
|
-
const audit = computeAudit(config, detectFnoxKeys(configDir));
|
|
1297
|
+
const audit = computeAudit(config, detectFnoxKeys(configDir), void 0, aliasTable);
|
|
1111
1298
|
return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
|
|
1112
1299
|
const secrets = {};
|
|
1113
1300
|
const injected = [];
|
|
1114
1301
|
const skipped = [];
|
|
1115
1302
|
warnings.push(...checkEnvMisclassification(config));
|
|
1116
|
-
const
|
|
1117
|
-
const
|
|
1118
|
-
const envDefaults = Object.fromEntries(envEntriesArr.flatMap(([key, entry]) => Option(process.env[key]).fold(() => [[key, entry.value]], () => [])));
|
|
1119
|
-
const overridden = envEntriesArr.flatMap(([key]) => Option(process.env[key]).fold(() => [], () => [key]));
|
|
1303
|
+
const envDefaults = Object.fromEntries(nonAliasEnvEntries.flatMap(([key, entry]) => Option(process.env[key]).fold(() => entry.value !== void 0 ? [[key, entry.value]] : [], () => [])));
|
|
1304
|
+
const overridden = nonAliasEnvEntries.flatMap(([key]) => Option(process.env[key]).fold(() => [], () => [key]));
|
|
1120
1305
|
if (inject) Object.entries(envDefaults).forEach(([key, value]) => {
|
|
1121
1306
|
process.env[key] = value;
|
|
1122
1307
|
});
|
|
@@ -1125,7 +1310,7 @@ const bootSafe = (options) => {
|
|
|
1125
1310
|
if (hasSealedValues) identityFilePath.fold(() => {
|
|
1126
1311
|
warnings.push("Sealed values found but no identity file available for decryption");
|
|
1127
1312
|
}, (idPath) => {
|
|
1128
|
-
unsealSecrets(
|
|
1313
|
+
unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
|
|
1129
1314
|
warnings.push(`Sealed value decryption failed: ${err.message}`);
|
|
1130
1315
|
}, (unsealed) => {
|
|
1131
1316
|
const unsealedEntries = Object.entries(unsealed);
|
|
@@ -1134,7 +1319,7 @@ const bootSafe = (options) => {
|
|
|
1134
1319
|
unsealedEntries.map(([key]) => key).forEach((key) => sealedKeys.add(key));
|
|
1135
1320
|
});
|
|
1136
1321
|
});
|
|
1137
|
-
const remainingKeys =
|
|
1322
|
+
const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
|
|
1138
1323
|
if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
|
|
1139
1324
|
warnings.push(`fnox export failed: ${err.message}`);
|
|
1140
1325
|
skipped.push(...remainingKeys);
|
|
@@ -1152,9 +1337,34 @@ const bootSafe = (options) => {
|
|
|
1152
1337
|
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
1153
1338
|
skipped.push(...remainingKeys);
|
|
1154
1339
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1340
|
+
aliasSecretKeys.forEach((aliasKey) => {
|
|
1341
|
+
const entry = aliasTable.entries.get(`secret.${aliasKey}`);
|
|
1342
|
+
if (!entry) return;
|
|
1343
|
+
const targetValue = secrets[entry.targetKey];
|
|
1344
|
+
if (targetValue !== void 0) {
|
|
1345
|
+
secrets[aliasKey] = targetValue;
|
|
1346
|
+
injected.push(aliasKey);
|
|
1347
|
+
} else skipped.push(aliasKey);
|
|
1348
|
+
});
|
|
1349
|
+
aliasEnvKeys.forEach((aliasKey) => {
|
|
1350
|
+
const entry = aliasTable.entries.get(`env.${aliasKey}`);
|
|
1351
|
+
if (!entry) return;
|
|
1352
|
+
if (process.env[aliasKey] !== void 0) {
|
|
1353
|
+
overridden.push(aliasKey);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const targetEntry = envEntries[entry.targetKey];
|
|
1357
|
+
if (targetEntry?.value === void 0) return;
|
|
1358
|
+
envDefaults[aliasKey] = process.env[entry.targetKey] ?? targetEntry.value;
|
|
1157
1359
|
});
|
|
1360
|
+
if (inject) {
|
|
1361
|
+
Object.entries(envDefaults).forEach(([key, value]) => {
|
|
1362
|
+
process.env[key] ??= value;
|
|
1363
|
+
});
|
|
1364
|
+
Object.entries(secrets).forEach(([key, value]) => {
|
|
1365
|
+
process.env[key] = value;
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1158
1368
|
return {
|
|
1159
1369
|
audit,
|
|
1160
1370
|
injected,
|
|
@@ -1167,7 +1377,7 @@ const bootSafe = (options) => {
|
|
|
1167
1377
|
configSource
|
|
1168
1378
|
};
|
|
1169
1379
|
});
|
|
1170
|
-
});
|
|
1380
|
+
}));
|
|
1171
1381
|
};
|
|
1172
1382
|
//#endregion
|
|
1173
1383
|
//#region src/core/patterns.ts
|
|
@@ -1883,14 +2093,27 @@ const envScan = (env, options) => {
|
|
|
1883
2093
|
low_confidence
|
|
1884
2094
|
};
|
|
1885
2095
|
};
|
|
2096
|
+
const parseAliasRef = (raw, expectedKind) => {
|
|
2097
|
+
const match = /^(secret|env)\.(.+)$/.exec(raw);
|
|
2098
|
+
if (!match) return void 0;
|
|
2099
|
+
if (match[1] !== expectedKind) return void 0;
|
|
2100
|
+
return match[2];
|
|
2101
|
+
};
|
|
1886
2102
|
/** Bidirectional drift detection between config and live environment */
|
|
1887
2103
|
const envCheck = (config, env) => {
|
|
1888
2104
|
const secretEntries = config.secret ?? {};
|
|
1889
2105
|
const metaKeys = Object.keys(secretEntries);
|
|
1890
2106
|
const trackedSet = new Set(metaKeys);
|
|
2107
|
+
const isSecretPresent = (key) => {
|
|
2108
|
+
if (env[key] !== void 0 && env[key] !== "") return true;
|
|
2109
|
+
const meta = secretEntries[key];
|
|
2110
|
+
if (meta?.from_key === void 0) return false;
|
|
2111
|
+
const targetKey = parseAliasRef(meta.from_key, "secret");
|
|
2112
|
+
return targetKey !== void 0 && env[targetKey] !== void 0 && env[targetKey] !== "";
|
|
2113
|
+
};
|
|
1891
2114
|
const secretDriftEntries = metaKeys.map((key) => {
|
|
1892
2115
|
const meta = secretEntries[key];
|
|
1893
|
-
const present =
|
|
2116
|
+
const present = isSecretPresent(key);
|
|
1894
2117
|
return {
|
|
1895
2118
|
envVar: key,
|
|
1896
2119
|
service: Option(meta.service),
|
|
@@ -1899,12 +2122,19 @@ const envCheck = (config, env) => {
|
|
|
1899
2122
|
};
|
|
1900
2123
|
});
|
|
1901
2124
|
const envDefaults = config.env ?? {};
|
|
2125
|
+
const isEnvPresent = (key) => {
|
|
2126
|
+
if (env[key] !== void 0 && env[key] !== "") return true;
|
|
2127
|
+
const meta = envDefaults[key];
|
|
2128
|
+
if (meta?.from_key === void 0) return false;
|
|
2129
|
+
const targetKey = parseAliasRef(meta.from_key, "env");
|
|
2130
|
+
return targetKey !== void 0 && env[targetKey] !== void 0 && env[targetKey] !== "";
|
|
2131
|
+
};
|
|
1902
2132
|
const envDefaultEntries = Object.keys(envDefaults).filter((key) => {
|
|
1903
2133
|
if (trackedSet.has(key)) return false;
|
|
1904
2134
|
trackedSet.add(key);
|
|
1905
2135
|
return true;
|
|
1906
2136
|
}).map((key) => {
|
|
1907
|
-
const present =
|
|
2137
|
+
const present = isEnvPresent(key);
|
|
1908
2138
|
return {
|
|
1909
2139
|
envVar: key,
|
|
1910
2140
|
service: Option(void 0),
|
|
@@ -2193,7 +2423,9 @@ const runEnvExport = (options) => {
|
|
|
2193
2423
|
if (boot.overridden.length > 0) loadConfig(boot.configPath).fold(() => {}, (config) => {
|
|
2194
2424
|
const envEntries = config.env ?? {};
|
|
2195
2425
|
boot.overridden.forEach((key) => {
|
|
2196
|
-
if (key in envEntries)
|
|
2426
|
+
if (!(key in envEntries)) return;
|
|
2427
|
+
const value = envEntries[key].value ?? process.env[key] ?? "";
|
|
2428
|
+
console.log(`export ${key}='${shellEscape(value)}'`);
|
|
2197
2429
|
});
|
|
2198
2430
|
});
|
|
2199
2431
|
Object.entries(boot.secrets).forEach(([key, value]) => {
|
|
@@ -2969,6 +3201,7 @@ const handleGetPacketHealth = (args) => {
|
|
|
2969
3201
|
status: s.status,
|
|
2970
3202
|
days_remaining: s.days_remaining.fold(() => null, (d) => d),
|
|
2971
3203
|
rotation_url: s.rotation_url.fold(() => null, (u) => u),
|
|
3204
|
+
alias_of: s.alias_of.fold(() => null, (a) => a),
|
|
2972
3205
|
issues: s.issues.toArray()
|
|
2973
3206
|
}));
|
|
2974
3207
|
return textResult(JSON.stringify({
|
|
@@ -2980,6 +3213,7 @@ const handleGetPacketHealth = (args) => {
|
|
|
2980
3213
|
expired: audit.expired,
|
|
2981
3214
|
stale: audit.stale,
|
|
2982
3215
|
missing: audit.missing,
|
|
3216
|
+
aliases: audit.aliases,
|
|
2983
3217
|
secrets: secretDetails
|
|
2984
3218
|
}, null, 2));
|
|
2985
3219
|
};
|
|
@@ -3009,11 +3243,30 @@ const handleGetSecretMeta = (args) => {
|
|
|
3009
3243
|
const loaded = loadConfigForTool(configPathArg(args));
|
|
3010
3244
|
if (!loaded.ok) return loaded.result;
|
|
3011
3245
|
const { config } = loaded;
|
|
3012
|
-
|
|
3013
|
-
|
|
3246
|
+
const secretEntries = config.secret ?? {};
|
|
3247
|
+
return Option(secretEntries[key]).fold(() => errorResult(`Secret not found: ${key}`), (meta) => {
|
|
3248
|
+
const { encrypted_value: _, from_key: fromKey, ...rest } = meta;
|
|
3249
|
+
if (fromKey !== void 0) {
|
|
3250
|
+
const targetKey = /^secret\.(.+)$/.exec(fromKey)?.[1];
|
|
3251
|
+
const target = targetKey !== void 0 ? secretEntries[targetKey] : void 0;
|
|
3252
|
+
if (target) {
|
|
3253
|
+
const { encrypted_value: __, from_key: ___, ...targetRest } = target;
|
|
3254
|
+
return textResult(JSON.stringify({
|
|
3255
|
+
key,
|
|
3256
|
+
...targetRest,
|
|
3257
|
+
...rest,
|
|
3258
|
+
alias_of: fromKey
|
|
3259
|
+
}, null, 2));
|
|
3260
|
+
}
|
|
3261
|
+
return textResult(JSON.stringify({
|
|
3262
|
+
key,
|
|
3263
|
+
...rest,
|
|
3264
|
+
alias_of: fromKey
|
|
3265
|
+
}, null, 2));
|
|
3266
|
+
}
|
|
3014
3267
|
return textResult(JSON.stringify({
|
|
3015
3268
|
key,
|
|
3016
|
-
...
|
|
3269
|
+
...rest
|
|
3017
3270
|
}, null, 2));
|
|
3018
3271
|
});
|
|
3019
3272
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ declare const SecretMetaSchema: _$_sinclair_typebox0.TObject<{
|
|
|
44
44
|
model_hint: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
45
45
|
source: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
46
46
|
encrypted_value: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
47
|
+
from_key: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
47
48
|
required: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TBoolean>;
|
|
48
49
|
tags: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TRecord<_$_sinclair_typebox0.TString, _$_sinclair_typebox0.TString>>;
|
|
49
50
|
}>;
|
|
@@ -63,7 +64,8 @@ type CallbackConfig = Static<typeof CallbackConfigSchema>;
|
|
|
63
64
|
declare const ToolsConfigSchema: _$_sinclair_typebox0.TRecord<_$_sinclair_typebox0.TString, _$_sinclair_typebox0.TUnknown>;
|
|
64
65
|
type ToolsConfig = Static<typeof ToolsConfigSchema>;
|
|
65
66
|
declare const EnvMetaSchema: _$_sinclair_typebox0.TObject<{
|
|
66
|
-
value: _$_sinclair_typebox0.TString
|
|
67
|
+
value: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
68
|
+
from_key: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
67
69
|
purpose: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
68
70
|
comment: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
69
71
|
tags: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TRecord<_$_sinclair_typebox0.TString, _$_sinclair_typebox0.TString>>;
|
|
@@ -96,11 +98,13 @@ declare const EnvpktConfigSchema: _$_sinclair_typebox0.TObject<{
|
|
|
96
98
|
model_hint: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
97
99
|
source: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
98
100
|
encrypted_value: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
101
|
+
from_key: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
99
102
|
required: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TBoolean>;
|
|
100
103
|
tags: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TRecord<_$_sinclair_typebox0.TString, _$_sinclair_typebox0.TString>>;
|
|
101
104
|
}>>>;
|
|
102
105
|
env: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TRecord<_$_sinclair_typebox0.TString, _$_sinclair_typebox0.TObject<{
|
|
103
|
-
value: _$_sinclair_typebox0.TString
|
|
106
|
+
value: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
107
|
+
from_key: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
104
108
|
purpose: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
105
109
|
comment: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TString>;
|
|
106
110
|
tags: _$_sinclair_typebox0.TOptional<_$_sinclair_typebox0.TRecord<_$_sinclair_typebox0.TString, _$_sinclair_typebox0.TString>>;
|
|
@@ -133,7 +137,8 @@ type SecretHealth = {
|
|
|
133
137
|
readonly purpose: Option<string>;
|
|
134
138
|
readonly created: Option<string>;
|
|
135
139
|
readonly expires: Option<string>;
|
|
136
|
-
readonly issues: List<string>;
|
|
140
|
+
readonly issues: List<string>; /** If this entry is an alias (from_key), the reference it points at (e.g. "secret.X") */
|
|
141
|
+
readonly alias_of: Option<string>;
|
|
137
142
|
};
|
|
138
143
|
type AuditResult = {
|
|
139
144
|
readonly status: HealthStatus;
|
|
@@ -145,7 +150,8 @@ type AuditResult = {
|
|
|
145
150
|
readonly stale: number;
|
|
146
151
|
readonly missing: number;
|
|
147
152
|
readonly missing_metadata: number;
|
|
148
|
-
readonly orphaned: number;
|
|
153
|
+
readonly orphaned: number; /** Count of entries that are aliases (from_key). Included in `secrets` but reported separately for visibility. */
|
|
154
|
+
readonly aliases: number;
|
|
149
155
|
readonly identity?: Identity;
|
|
150
156
|
};
|
|
151
157
|
type EnvDriftStatus = "default" | "overridden" | "missing";
|
|
@@ -154,7 +160,8 @@ type EnvDriftEntry = {
|
|
|
154
160
|
readonly defaultValue: string;
|
|
155
161
|
readonly currentValue: string | undefined;
|
|
156
162
|
readonly status: EnvDriftStatus;
|
|
157
|
-
readonly purpose: string | undefined;
|
|
163
|
+
readonly purpose: string | undefined; /** If this entry is an alias (from_key), the reference it points at (e.g. "env.X") */
|
|
164
|
+
readonly alias_of: Option<string>;
|
|
158
165
|
};
|
|
159
166
|
type EnvAuditResult = {
|
|
160
167
|
readonly entries: ReadonlyArray<EnvDriftEntry>;
|
|
@@ -238,6 +245,40 @@ type CatalogError = {
|
|
|
238
245
|
readonly _tag: "MissingSecretsList";
|
|
239
246
|
readonly message: string;
|
|
240
247
|
};
|
|
248
|
+
type AliasTable = {
|
|
249
|
+
/** key → { type: "secret"|"env", targetType, targetKey } for every alias entry */readonly entries: ReadonlyMap<string, {
|
|
250
|
+
readonly kind: "secret" | "env";
|
|
251
|
+
readonly targetKind: "secret" | "env";
|
|
252
|
+
readonly targetKey: string;
|
|
253
|
+
}>;
|
|
254
|
+
};
|
|
255
|
+
type AliasError = {
|
|
256
|
+
readonly _tag: "AliasInvalidSyntax";
|
|
257
|
+
readonly key: string;
|
|
258
|
+
readonly kind: "secret" | "env";
|
|
259
|
+
readonly value: string;
|
|
260
|
+
} | {
|
|
261
|
+
readonly _tag: "AliasTargetMissing";
|
|
262
|
+
readonly key: string;
|
|
263
|
+
readonly target: string;
|
|
264
|
+
} | {
|
|
265
|
+
readonly _tag: "AliasSelfReference";
|
|
266
|
+
readonly key: string;
|
|
267
|
+
} | {
|
|
268
|
+
readonly _tag: "AliasChained";
|
|
269
|
+
readonly key: string;
|
|
270
|
+
readonly target: string;
|
|
271
|
+
} | {
|
|
272
|
+
readonly _tag: "AliasCrossType";
|
|
273
|
+
readonly key: string;
|
|
274
|
+
readonly kind: "secret" | "env";
|
|
275
|
+
readonly targetKind: "secret" | "env";
|
|
276
|
+
} | {
|
|
277
|
+
readonly _tag: "AliasValueConflict";
|
|
278
|
+
readonly key: string;
|
|
279
|
+
readonly kind: "secret" | "env";
|
|
280
|
+
readonly field: string;
|
|
281
|
+
};
|
|
241
282
|
type BootOptions = {
|
|
242
283
|
readonly configPath?: string;
|
|
243
284
|
readonly profile?: string;
|
|
@@ -256,7 +297,7 @@ type BootResult = {
|
|
|
256
297
|
readonly configPath: string;
|
|
257
298
|
readonly configSource: ConfigSource;
|
|
258
299
|
};
|
|
259
|
-
type BootError = ConfigError | FnoxError | CatalogError | {
|
|
300
|
+
type BootError = ConfigError | FnoxError | CatalogError | AliasError | {
|
|
260
301
|
readonly _tag: "AuditFailed";
|
|
261
302
|
readonly audit: AuditResult;
|
|
262
303
|
readonly message: string;
|
|
@@ -354,6 +395,33 @@ declare const resolveSecrets: (agentMeta: Record<string, SecretMeta>, catalogMet
|
|
|
354
395
|
/** Resolve an agent config against its catalog (if any), producing a flat self-contained config */
|
|
355
396
|
declare const resolveConfig: (agentConfig: EnvpktConfig, agentConfigDir: string) => Either<CatalogError, ResolveResult>;
|
|
356
397
|
//#endregion
|
|
398
|
+
//#region src/core/alias.d.ts
|
|
399
|
+
/**
|
|
400
|
+
* Validate all `from_key` references in a resolved config. Produces an
|
|
401
|
+
* AliasTable mapping each alias to its target, or an AliasError describing
|
|
402
|
+
* the first failure.
|
|
403
|
+
*
|
|
404
|
+
* Rules:
|
|
405
|
+
* - Ref must be "secret.<KEY>" or "env.<KEY>"
|
|
406
|
+
* - Target must exist in the same resolved config
|
|
407
|
+
* - Target must be the same type (secret→secret, env→env only)
|
|
408
|
+
* - Target must not itself be a from_key entry (single hop only)
|
|
409
|
+
* - Self-reference is rejected
|
|
410
|
+
* - An alias entry cannot also carry a value field (encrypted_value for
|
|
411
|
+
* secrets, value for env)
|
|
412
|
+
*/
|
|
413
|
+
declare const validateAliases: (config: EnvpktConfig) => Either<AliasError, AliasTable>;
|
|
414
|
+
/** Does this secret entry point at another entry? */
|
|
415
|
+
declare const isSecretAlias: (meta: {
|
|
416
|
+
from_key?: string;
|
|
417
|
+
} | undefined) => boolean;
|
|
418
|
+
/** Does this env entry point at another entry? */
|
|
419
|
+
declare const isEnvAlias: (meta: {
|
|
420
|
+
from_key?: string;
|
|
421
|
+
} | undefined) => boolean;
|
|
422
|
+
/** Format an alias error into a human-readable message */
|
|
423
|
+
declare const formatAliasError: (error: AliasError) => string;
|
|
424
|
+
//#endregion
|
|
357
425
|
//#region src/core/format.d.ts
|
|
358
426
|
type SecretDisplay = "encrypted" | "plaintext";
|
|
359
427
|
type FormatPacketOptions = {
|
|
@@ -364,7 +432,7 @@ declare const maskValue: (value: string) => string;
|
|
|
364
432
|
declare const formatPacket: (result: ResolveResult, options?: FormatPacketOptions) => string;
|
|
365
433
|
//#endregion
|
|
366
434
|
//#region src/core/audit.d.ts
|
|
367
|
-
declare const computeAudit: (config: EnvpktConfig, fnoxKeys?: ReadonlySet<string>, today?: Date) => AuditResult;
|
|
435
|
+
declare const computeAudit: (config: EnvpktConfig, fnoxKeys?: ReadonlySet<string>, today?: Date, aliasTable?: AliasTable) => AuditResult;
|
|
368
436
|
declare const computeEnvAudit: (config: EnvpktConfig, env?: Readonly<Record<string, string | undefined>>) => EnvAuditResult;
|
|
369
437
|
//#endregion
|
|
370
438
|
//#region src/core/patterns.d.ts
|
|
@@ -554,4 +622,4 @@ type ToolDef = {
|
|
|
554
622
|
declare const toolDefinitions: readonly ToolDef[];
|
|
555
623
|
declare const callTool: (name: string, args: Record<string, unknown>) => CallToolResult;
|
|
556
624
|
//#endregion
|
|
557
|
-
export { type AgentIdentity, AgentIdentitySchema, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type CheckResult, type ConfidenceLevel, type ConfigError, type ConfigSource, type ConsumerType, type CredentialPattern, type DriftEntry, type DriftStatus, type EnvAuditResult, type EnvDriftEntry, type EnvDriftStatus, type EnvMeta, EnvMetaSchema, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatPacketOptions, type HealthStatus, type Identity, type IdentityError, IdentitySchema, type KeygenError, type KeygenResult, type LifecycleConfig, LifecycleConfigSchema, type MatchResult, type ResolveOptions, type ResolveResult, type ResolvedPath, type ScanOptions, type ScanResult, type SealError, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type TomlEditError, type ToolsConfig, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createServer, deriveServiceFromName, detectFnox, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatPacket, generateKeypair, generateTomlFromScan, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateConfig };
|
|
625
|
+
export { type AgentIdentity, AgentIdentitySchema, type AliasError, type AliasTable, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type CheckResult, type ConfidenceLevel, type ConfigError, type ConfigSource, type ConsumerType, type CredentialPattern, type DriftEntry, type DriftStatus, type EnvAuditResult, type EnvDriftEntry, type EnvDriftStatus, type EnvMeta, EnvMetaSchema, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatPacketOptions, type HealthStatus, type Identity, type IdentityError, IdentitySchema, type KeygenError, type KeygenResult, type LifecycleConfig, LifecycleConfigSchema, type MatchResult, type ResolveOptions, type ResolveResult, type ResolvedPath, type ScanOptions, type ScanResult, type SealError, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type TomlEditError, type ToolsConfig, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createServer, deriveServiceFromName, detectFnox, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
package/dist/index.js
CHANGED
|
@@ -60,6 +60,7 @@ const SecretMetaSchema = Type.Object({
|
|
|
60
60
|
model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
|
|
61
61
|
source: Type.Optional(Type.String({ description: "Where the secret value originates (e.g. 'vault', 'ci')" })),
|
|
62
62
|
encrypted_value: Type.Optional(Type.String({ description: "Age-encrypted secret value (armored ciphertext, safe to commit)" })),
|
|
63
|
+
from_key: Type.Optional(Type.String({ description: "Reference another entry (format: 'secret.<KEY>') whose resolved value this alias reuses. Mutually exclusive with encrypted_value." })),
|
|
63
64
|
required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
|
|
64
65
|
tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
|
|
65
66
|
}, { description: "Metadata about a single secret" });
|
|
@@ -84,7 +85,8 @@ const CallbackConfigSchema = Type.Object({
|
|
|
84
85
|
}, { description: "Automation callbacks for lifecycle events" });
|
|
85
86
|
const ToolsConfigSchema = Type.Record(Type.String(), Type.Unknown(), { description: "Tool integration configuration — open namespace for third-party extensions" });
|
|
86
87
|
const EnvMetaSchema = Type.Object({
|
|
87
|
-
value: Type.String({ description: "Default value for this environment variable" }),
|
|
88
|
+
value: Type.Optional(Type.String({ description: "Default value for this environment variable. Optional when from_key is set; required otherwise." })),
|
|
89
|
+
from_key: Type.Optional(Type.String({ description: "Reference another entry (format: 'env.<KEY>') whose resolved value this alias reuses. Mutually exclusive with value." })),
|
|
88
90
|
purpose: Type.Optional(Type.String({ description: "Why this env var exists" })),
|
|
89
91
|
comment: Type.Optional(Type.String({ description: "Free-form annotation or note" })),
|
|
90
92
|
tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
|
|
@@ -347,6 +349,155 @@ const resolveConfig = (agentConfig, agentConfigDir) => {
|
|
|
347
349
|
}));
|
|
348
350
|
};
|
|
349
351
|
//#endregion
|
|
352
|
+
//#region src/core/alias.ts
|
|
353
|
+
const ALIAS_REF_RE = /^(secret|env)\.(.+)$/;
|
|
354
|
+
const parseRef = (raw) => {
|
|
355
|
+
const match = ALIAS_REF_RE.exec(raw);
|
|
356
|
+
if (!match) return Option(void 0);
|
|
357
|
+
const kind = match[1];
|
|
358
|
+
const key = match[2];
|
|
359
|
+
if (!key) return Option(void 0);
|
|
360
|
+
return Option({
|
|
361
|
+
kind,
|
|
362
|
+
key
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
const validateOneSecret = (key, meta, secretEntries) => {
|
|
366
|
+
if (meta.from_key === void 0) return Right(Option(void 0));
|
|
367
|
+
const ref = meta.from_key;
|
|
368
|
+
if (meta.encrypted_value !== void 0) return Left({
|
|
369
|
+
_tag: "AliasValueConflict",
|
|
370
|
+
key,
|
|
371
|
+
kind: "secret",
|
|
372
|
+
field: "encrypted_value"
|
|
373
|
+
});
|
|
374
|
+
return parseRef(ref).fold(() => Left({
|
|
375
|
+
_tag: "AliasInvalidSyntax",
|
|
376
|
+
key,
|
|
377
|
+
kind: "secret",
|
|
378
|
+
value: ref
|
|
379
|
+
}), (parsed) => {
|
|
380
|
+
if (parsed.kind !== "secret") return Left({
|
|
381
|
+
_tag: "AliasCrossType",
|
|
382
|
+
key,
|
|
383
|
+
kind: "secret",
|
|
384
|
+
targetKind: parsed.kind
|
|
385
|
+
});
|
|
386
|
+
if (parsed.key === key) return Left({
|
|
387
|
+
_tag: "AliasSelfReference",
|
|
388
|
+
key: `secret.${key}`
|
|
389
|
+
});
|
|
390
|
+
return Option(secretEntries[parsed.key]).fold(() => Left({
|
|
391
|
+
_tag: "AliasTargetMissing",
|
|
392
|
+
key: `secret.${key}`,
|
|
393
|
+
target: ref
|
|
394
|
+
}), (target) => {
|
|
395
|
+
if (target.from_key !== void 0) return Left({
|
|
396
|
+
_tag: "AliasChained",
|
|
397
|
+
key: `secret.${key}`,
|
|
398
|
+
target: ref
|
|
399
|
+
});
|
|
400
|
+
return Right(Option({
|
|
401
|
+
kind: "secret",
|
|
402
|
+
targetKind: "secret",
|
|
403
|
+
targetKey: parsed.key
|
|
404
|
+
}));
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
const validateOneEnv = (key, meta, envEntries) => {
|
|
409
|
+
if (meta.from_key === void 0) return Right(Option(void 0));
|
|
410
|
+
const ref = meta.from_key;
|
|
411
|
+
if (meta.value !== void 0) return Left({
|
|
412
|
+
_tag: "AliasValueConflict",
|
|
413
|
+
key,
|
|
414
|
+
kind: "env",
|
|
415
|
+
field: "value"
|
|
416
|
+
});
|
|
417
|
+
return parseRef(ref).fold(() => Left({
|
|
418
|
+
_tag: "AliasInvalidSyntax",
|
|
419
|
+
key,
|
|
420
|
+
kind: "env",
|
|
421
|
+
value: ref
|
|
422
|
+
}), (parsed) => {
|
|
423
|
+
if (parsed.kind !== "env") return Left({
|
|
424
|
+
_tag: "AliasCrossType",
|
|
425
|
+
key,
|
|
426
|
+
kind: "env",
|
|
427
|
+
targetKind: parsed.kind
|
|
428
|
+
});
|
|
429
|
+
if (parsed.key === key) return Left({
|
|
430
|
+
_tag: "AliasSelfReference",
|
|
431
|
+
key: `env.${key}`
|
|
432
|
+
});
|
|
433
|
+
return Option(envEntries[parsed.key]).fold(() => Left({
|
|
434
|
+
_tag: "AliasTargetMissing",
|
|
435
|
+
key: `env.${key}`,
|
|
436
|
+
target: ref
|
|
437
|
+
}), (target) => {
|
|
438
|
+
if (target.from_key !== void 0) return Left({
|
|
439
|
+
_tag: "AliasChained",
|
|
440
|
+
key: `env.${key}`,
|
|
441
|
+
target: ref
|
|
442
|
+
});
|
|
443
|
+
return Right(Option({
|
|
444
|
+
kind: "env",
|
|
445
|
+
targetKind: "env",
|
|
446
|
+
targetKey: parsed.key
|
|
447
|
+
}));
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
};
|
|
451
|
+
/**
|
|
452
|
+
* Validate all `from_key` references in a resolved config. Produces an
|
|
453
|
+
* AliasTable mapping each alias to its target, or an AliasError describing
|
|
454
|
+
* the first failure.
|
|
455
|
+
*
|
|
456
|
+
* Rules:
|
|
457
|
+
* - Ref must be "secret.<KEY>" or "env.<KEY>"
|
|
458
|
+
* - Target must exist in the same resolved config
|
|
459
|
+
* - Target must be the same type (secret→secret, env→env only)
|
|
460
|
+
* - Target must not itself be a from_key entry (single hop only)
|
|
461
|
+
* - Self-reference is rejected
|
|
462
|
+
* - An alias entry cannot also carry a value field (encrypted_value for
|
|
463
|
+
* secrets, value for env)
|
|
464
|
+
*/
|
|
465
|
+
const validateAliases = (config) => {
|
|
466
|
+
const secretEntries = config.secret ?? {};
|
|
467
|
+
const envEntries = config.env ?? {};
|
|
468
|
+
const entries = /* @__PURE__ */ new Map();
|
|
469
|
+
const secretResults = Object.entries(secretEntries).map(([key, meta]) => [key, validateOneSecret(key, meta, secretEntries)]);
|
|
470
|
+
for (const [key, result] of secretResults) {
|
|
471
|
+
const outcome = result.fold((err) => err, (opt) => opt.orUndefined());
|
|
472
|
+
if (outcome === void 0) continue;
|
|
473
|
+
if ("_tag" in outcome) return Left(outcome);
|
|
474
|
+
entries.set(`secret.${key}`, outcome);
|
|
475
|
+
}
|
|
476
|
+
const envResults = Object.entries(envEntries).map(([key, meta]) => [key, validateOneEnv(key, meta, envEntries)]);
|
|
477
|
+
for (const [key, result] of envResults) {
|
|
478
|
+
const outcome = result.fold((err) => err, (opt) => opt.orUndefined());
|
|
479
|
+
if (outcome === void 0) continue;
|
|
480
|
+
if ("_tag" in outcome) return Left(outcome);
|
|
481
|
+
entries.set(`env.${key}`, outcome);
|
|
482
|
+
}
|
|
483
|
+
return Right({ entries });
|
|
484
|
+
};
|
|
485
|
+
/** Does this secret entry point at another entry? */
|
|
486
|
+
const isSecretAlias = (meta) => meta?.from_key !== void 0;
|
|
487
|
+
/** Does this env entry point at another entry? */
|
|
488
|
+
const isEnvAlias = (meta) => meta?.from_key !== void 0;
|
|
489
|
+
/** Format an alias error into a human-readable message */
|
|
490
|
+
const formatAliasError = (error) => {
|
|
491
|
+
switch (error._tag) {
|
|
492
|
+
case "AliasInvalidSyntax": return `[${error.kind}.${error.key}] from_key = "${error.value}" — expected "secret.<KEY>" or "env.<KEY>"`;
|
|
493
|
+
case "AliasTargetMissing": return `[${error.key}] from_key target "${error.target}" not found in config`;
|
|
494
|
+
case "AliasSelfReference": return `[${error.key}] from_key cannot reference itself`;
|
|
495
|
+
case "AliasChained": return `[${error.key}] from_key target "${error.target}" is itself an alias; chained aliases are not supported`;
|
|
496
|
+
case "AliasCrossType": return `[${error.kind}.${error.key}] cannot alias a ${error.targetKind} entry; same-type aliasing only (secret→secret, env→env)`;
|
|
497
|
+
case "AliasValueConflict": return `[${error.kind}.${error.key}] cannot declare both from_key and ${error.field}; an alias has no value of its own`;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
//#endregion
|
|
350
501
|
//#region src/core/format.ts
|
|
351
502
|
const maskValue = (value) => {
|
|
352
503
|
if (value.length > 8) return `${value.slice(0, 3)}${"•".repeat(5)}${value.slice(-4)}`;
|
|
@@ -393,11 +544,20 @@ const formatPacket = (result, options) => {
|
|
|
393
544
|
const metaEntries = Object.entries(secretConfig);
|
|
394
545
|
const secretHeader = `secrets: ${metaEntries.length}`;
|
|
395
546
|
const secretLines = metaEntries.map(([key, meta]) => {
|
|
396
|
-
const header = ` ${key} → ${meta.service ?? key}${meta.encrypted_value ? " [sealed]" : ""}${Option(options?.secrets?.[key]).fold(() => "", (secretValue) => ` = ${(options?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}`)}`;
|
|
547
|
+
const header = ` ${key} → ${meta.service ?? key}${meta.encrypted_value ? " [sealed]" : ""}${meta.from_key ? ` [alias → ${meta.from_key}]` : ""}${Option(options?.secrets?.[key]).fold(() => "", (secretValue) => ` = ${(options?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}`)}`;
|
|
397
548
|
const fields = formatSecretFields(meta, " ");
|
|
398
549
|
return fields ? `${header}\n${fields}` : header;
|
|
399
550
|
});
|
|
400
551
|
sections.push([secretHeader, ...secretLines].join("\n"));
|
|
552
|
+
const envConfig = config.env ?? {};
|
|
553
|
+
const envEntriesArr = Object.entries(envConfig);
|
|
554
|
+
if (envEntriesArr.length > 0) {
|
|
555
|
+
const envHeader = `env: ${envEntriesArr.length}`;
|
|
556
|
+
const envLines = envEntriesArr.map(([key, meta]) => {
|
|
557
|
+
return ` ${key}${meta.from_key ? ` [alias → ${meta.from_key}]` : ""}${meta.value !== void 0 ? ` = ${meta.value}` : ""}${meta.purpose ? `\n purpose: ${meta.purpose}` : ""}`;
|
|
558
|
+
});
|
|
559
|
+
sections.push([envHeader, ...envLines].join("\n"));
|
|
560
|
+
}
|
|
401
561
|
if (config.lifecycle) {
|
|
402
562
|
const lcLines = ["lifecycle:"];
|
|
403
563
|
if (config.lifecycle.stale_warning_days !== void 0) lcLines.push(` stale_warning_days: ${config.lifecycle.stale_warning_days}`);
|
|
@@ -456,10 +616,28 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
|
|
|
456
616
|
purpose,
|
|
457
617
|
created: Option(meta.created),
|
|
458
618
|
expires: Option(meta.expires),
|
|
459
|
-
issues: List(issues)
|
|
619
|
+
issues: List(issues),
|
|
620
|
+
alias_of: Option(void 0)
|
|
460
621
|
};
|
|
461
622
|
};
|
|
462
|
-
|
|
623
|
+
/**
|
|
624
|
+
* Build a SecretHealth row for an alias entry. Status is inherited from the
|
|
625
|
+
* target; metadata (purpose, tags) comes from the alias entry itself where
|
|
626
|
+
* set, otherwise falls through to the target so operators see context.
|
|
627
|
+
*/
|
|
628
|
+
const classifyAlias = (key, meta, targetHealth, targetRef) => ({
|
|
629
|
+
key,
|
|
630
|
+
service: targetHealth.service,
|
|
631
|
+
status: targetHealth.status,
|
|
632
|
+
days_remaining: targetHealth.days_remaining,
|
|
633
|
+
rotation_url: targetHealth.rotation_url,
|
|
634
|
+
purpose: meta.purpose !== void 0 ? Option(meta.purpose) : targetHealth.purpose,
|
|
635
|
+
created: targetHealth.created,
|
|
636
|
+
expires: targetHealth.expires,
|
|
637
|
+
issues: List([]),
|
|
638
|
+
alias_of: Option(targetRef)
|
|
639
|
+
});
|
|
640
|
+
const computeAudit = (config, fnoxKeys, today, aliasTable) => {
|
|
463
641
|
const now = today ?? /* @__PURE__ */ new Date();
|
|
464
642
|
const lifecycle = config.lifecycle ?? {};
|
|
465
643
|
const staleWarningDays = lifecycle.stale_warning_days ?? 90;
|
|
@@ -467,9 +645,31 @@ const computeAudit = (config, fnoxKeys, today) => {
|
|
|
467
645
|
const requireService = lifecycle.require_service ?? false;
|
|
468
646
|
const keys = fnoxKeys ?? /* @__PURE__ */ new Set();
|
|
469
647
|
const secretEntries = config.secret ?? {};
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
const
|
|
648
|
+
const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
649
|
+
const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
|
|
650
|
+
const nonAliasMetaKeys = new Set(nonAliasEntries.map(([k]) => k));
|
|
651
|
+
const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
|
|
652
|
+
const healthByKey = new Map(nonAliasHealth.map((h) => [h.key, h]));
|
|
653
|
+
const aliasHealth = aliasEntries.map(([key, meta]) => {
|
|
654
|
+
const targetKey = (aliasTable?.entries.get(`secret.${key}`))?.targetKey;
|
|
655
|
+
const targetHealth = targetKey !== void 0 ? healthByKey.get(targetKey) : void 0;
|
|
656
|
+
const targetRef = meta.from_key ?? (targetKey !== void 0 ? `secret.${targetKey}` : "");
|
|
657
|
+
if (!targetHealth) return {
|
|
658
|
+
key,
|
|
659
|
+
service: Option(meta.service),
|
|
660
|
+
status: "missing",
|
|
661
|
+
days_remaining: Option(void 0),
|
|
662
|
+
rotation_url: Option(meta.rotation_url),
|
|
663
|
+
purpose: Option(meta.purpose),
|
|
664
|
+
created: Option(meta.created),
|
|
665
|
+
expires: Option(meta.expires),
|
|
666
|
+
issues: List(["Alias target not resolvable"]),
|
|
667
|
+
alias_of: Option(targetRef)
|
|
668
|
+
};
|
|
669
|
+
return classifyAlias(key, meta, targetHealth, targetRef);
|
|
670
|
+
});
|
|
671
|
+
const secrets = List([...nonAliasHealth, ...aliasHealth]);
|
|
672
|
+
const orphaned = keys.size > 0 ? [...nonAliasMetaKeys].filter((k) => !keys.has(k)).length : 0;
|
|
473
673
|
const total = secrets.size;
|
|
474
674
|
const expired = secrets.count((s) => s.status === "expired");
|
|
475
675
|
const missing = secrets.count((s) => s.status === "missing");
|
|
@@ -477,6 +677,7 @@ const computeAudit = (config, fnoxKeys, today) => {
|
|
|
477
677
|
const expiring_soon = secrets.count((s) => s.status === "expiring_soon");
|
|
478
678
|
const stale = secrets.count((s) => s.status === "stale");
|
|
479
679
|
const healthy = secrets.count((s) => s.status === "healthy");
|
|
680
|
+
const aliases = aliasHealth.length;
|
|
480
681
|
return {
|
|
481
682
|
status: Cond.of().when(expired > 0 || missing > 0, "critical").elseWhen(expiring_soon > 0 || stale > 0 || missing_metadata > 0, "degraded").else("healthy"),
|
|
482
683
|
secrets,
|
|
@@ -488,6 +689,7 @@ const computeAudit = (config, fnoxKeys, today) => {
|
|
|
488
689
|
missing,
|
|
489
690
|
missing_metadata,
|
|
490
691
|
orphaned,
|
|
692
|
+
aliases,
|
|
491
693
|
identity: config.identity
|
|
492
694
|
};
|
|
493
695
|
};
|
|
@@ -495,13 +697,17 @@ const computeEnvAudit = (config, env = process.env) => {
|
|
|
495
697
|
const envEntries = config.env ?? {};
|
|
496
698
|
const entries = Object.entries(envEntries).map(([key, entry]) => {
|
|
497
699
|
const currentValue = env[key];
|
|
498
|
-
const
|
|
700
|
+
const effectiveDefault = entry.from_key !== void 0 ? (() => {
|
|
701
|
+
const targetKey = /^env\.(.+)$/.exec(entry.from_key)?.[1];
|
|
702
|
+
return (targetKey !== void 0 ? envEntries[targetKey] : void 0)?.value ?? "";
|
|
703
|
+
})() : entry.value ?? "";
|
|
499
704
|
return {
|
|
500
705
|
key,
|
|
501
|
-
defaultValue:
|
|
706
|
+
defaultValue: effectiveDefault,
|
|
502
707
|
currentValue,
|
|
503
|
-
status,
|
|
504
|
-
purpose: entry.purpose
|
|
708
|
+
status: Cond.of().when(currentValue === void 0, "missing").elseWhen(currentValue !== effectiveDefault, "overridden").else("default"),
|
|
709
|
+
purpose: entry.purpose,
|
|
710
|
+
alias_of: Option(entry.from_key)
|
|
505
711
|
};
|
|
506
712
|
});
|
|
507
713
|
return {
|
|
@@ -1226,14 +1432,27 @@ const envScan = (env, options) => {
|
|
|
1226
1432
|
low_confidence
|
|
1227
1433
|
};
|
|
1228
1434
|
};
|
|
1435
|
+
const parseAliasRef = (raw, expectedKind) => {
|
|
1436
|
+
const match = /^(secret|env)\.(.+)$/.exec(raw);
|
|
1437
|
+
if (!match) return void 0;
|
|
1438
|
+
if (match[1] !== expectedKind) return void 0;
|
|
1439
|
+
return match[2];
|
|
1440
|
+
};
|
|
1229
1441
|
/** Bidirectional drift detection between config and live environment */
|
|
1230
1442
|
const envCheck = (config, env) => {
|
|
1231
1443
|
const secretEntries = config.secret ?? {};
|
|
1232
1444
|
const metaKeys = Object.keys(secretEntries);
|
|
1233
1445
|
const trackedSet = new Set(metaKeys);
|
|
1446
|
+
const isSecretPresent = (key) => {
|
|
1447
|
+
if (env[key] !== void 0 && env[key] !== "") return true;
|
|
1448
|
+
const meta = secretEntries[key];
|
|
1449
|
+
if (meta?.from_key === void 0) return false;
|
|
1450
|
+
const targetKey = parseAliasRef(meta.from_key, "secret");
|
|
1451
|
+
return targetKey !== void 0 && env[targetKey] !== void 0 && env[targetKey] !== "";
|
|
1452
|
+
};
|
|
1234
1453
|
const secretDriftEntries = metaKeys.map((key) => {
|
|
1235
1454
|
const meta = secretEntries[key];
|
|
1236
|
-
const present =
|
|
1455
|
+
const present = isSecretPresent(key);
|
|
1237
1456
|
return {
|
|
1238
1457
|
envVar: key,
|
|
1239
1458
|
service: Option(meta.service),
|
|
@@ -1242,12 +1461,19 @@ const envCheck = (config, env) => {
|
|
|
1242
1461
|
};
|
|
1243
1462
|
});
|
|
1244
1463
|
const envDefaults = config.env ?? {};
|
|
1464
|
+
const isEnvPresent = (key) => {
|
|
1465
|
+
if (env[key] !== void 0 && env[key] !== "") return true;
|
|
1466
|
+
const meta = envDefaults[key];
|
|
1467
|
+
if (meta?.from_key === void 0) return false;
|
|
1468
|
+
const targetKey = parseAliasRef(meta.from_key, "env");
|
|
1469
|
+
return targetKey !== void 0 && env[targetKey] !== void 0 && env[targetKey] !== "";
|
|
1470
|
+
};
|
|
1245
1471
|
const envDefaultEntries = Object.keys(envDefaults).filter((key) => {
|
|
1246
1472
|
if (trackedSet.has(key)) return false;
|
|
1247
1473
|
trackedSet.add(key);
|
|
1248
1474
|
return true;
|
|
1249
1475
|
}).map((key) => {
|
|
1250
|
-
const present =
|
|
1476
|
+
const present = isEnvPresent(key);
|
|
1251
1477
|
return {
|
|
1252
1478
|
envVar: key,
|
|
1253
1479
|
service: Option(void 0),
|
|
@@ -1693,7 +1919,7 @@ const looksLikeSecret = (value) => {
|
|
|
1693
1919
|
};
|
|
1694
1920
|
const checkEnvMisclassification = (config) => {
|
|
1695
1921
|
const envEntries = config.env ?? {};
|
|
1696
|
-
return Object.entries(envEntries).filter(([, entry]) => looksLikeSecret(entry.value)).map(([key]) => `[env.${key}] value looks like a secret — consider moving to [secret.${key}]`);
|
|
1922
|
+
return Object.entries(envEntries).filter(([, entry]) => entry.value !== void 0 && looksLikeSecret(entry.value)).map(([key]) => `[env.${key}] value looks like a secret — consider moving to [secret.${key}]`);
|
|
1697
1923
|
};
|
|
1698
1924
|
/** Programmatic boot — returns Either<BootError, BootResult> */
|
|
1699
1925
|
const bootSafe = (options) => {
|
|
@@ -1701,26 +1927,29 @@ const bootSafe = (options) => {
|
|
|
1701
1927
|
const inject = opts.inject !== false;
|
|
1702
1928
|
const failOnExpired = opts.failOnExpired !== false;
|
|
1703
1929
|
const warnOnly = opts.warnOnly ?? false;
|
|
1704
|
-
return resolveAndLoad(opts).flatMap(({ config, configPath, configDir, configSource }) => {
|
|
1930
|
+
return resolveAndLoad(opts).flatMap(({ config, configPath, configDir, configSource }) => validateAliases(config).fold((err) => Left(err), (aliasTable) => {
|
|
1705
1931
|
const secretEntries = config.secret ?? {};
|
|
1706
|
-
const
|
|
1707
|
-
const
|
|
1932
|
+
const envEntries = config.env ?? {};
|
|
1933
|
+
const nonAliasSecretEntries = Object.fromEntries(Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0));
|
|
1934
|
+
const aliasSecretKeys = Object.keys(secretEntries).filter((k) => secretEntries[k].from_key !== void 0);
|
|
1935
|
+
const nonAliasEnvEntries = Object.entries(envEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
1936
|
+
const aliasEnvKeys = Object.keys(envEntries).filter((k) => envEntries[k].from_key !== void 0);
|
|
1937
|
+
const nonAliasMetaKeys = Object.keys(nonAliasSecretEntries);
|
|
1938
|
+
const hasSealedValues = nonAliasMetaKeys.some((k) => !!nonAliasSecretEntries[k].encrypted_value);
|
|
1708
1939
|
const identityKeyResult = resolveIdentityKey(config, configDir);
|
|
1709
1940
|
const identityKey = identityKeyResult.fold(() => Option(void 0), (k) => k);
|
|
1710
1941
|
if (identityKeyResult.isLeft() && !hasSealedValues) return identityKeyResult.fold((err) => Left(err), () => Left({
|
|
1711
1942
|
_tag: "ReadError",
|
|
1712
1943
|
message: "unexpected"
|
|
1713
1944
|
}));
|
|
1714
|
-
const audit = computeAudit(config, detectFnoxKeys(configDir));
|
|
1945
|
+
const audit = computeAudit(config, detectFnoxKeys(configDir), void 0, aliasTable);
|
|
1715
1946
|
return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
|
|
1716
1947
|
const secrets = {};
|
|
1717
1948
|
const injected = [];
|
|
1718
1949
|
const skipped = [];
|
|
1719
1950
|
warnings.push(...checkEnvMisclassification(config));
|
|
1720
|
-
const
|
|
1721
|
-
const
|
|
1722
|
-
const envDefaults = Object.fromEntries(envEntriesArr.flatMap(([key, entry]) => Option(process.env[key]).fold(() => [[key, entry.value]], () => [])));
|
|
1723
|
-
const overridden = envEntriesArr.flatMap(([key]) => Option(process.env[key]).fold(() => [], () => [key]));
|
|
1951
|
+
const envDefaults = Object.fromEntries(nonAliasEnvEntries.flatMap(([key, entry]) => Option(process.env[key]).fold(() => entry.value !== void 0 ? [[key, entry.value]] : [], () => [])));
|
|
1952
|
+
const overridden = nonAliasEnvEntries.flatMap(([key]) => Option(process.env[key]).fold(() => [], () => [key]));
|
|
1724
1953
|
if (inject) Object.entries(envDefaults).forEach(([key, value]) => {
|
|
1725
1954
|
process.env[key] = value;
|
|
1726
1955
|
});
|
|
@@ -1729,7 +1958,7 @@ const bootSafe = (options) => {
|
|
|
1729
1958
|
if (hasSealedValues) identityFilePath.fold(() => {
|
|
1730
1959
|
warnings.push("Sealed values found but no identity file available for decryption");
|
|
1731
1960
|
}, (idPath) => {
|
|
1732
|
-
unsealSecrets(
|
|
1961
|
+
unsealSecrets(nonAliasSecretEntries, idPath).fold((err) => {
|
|
1733
1962
|
warnings.push(`Sealed value decryption failed: ${err.message}`);
|
|
1734
1963
|
}, (unsealed) => {
|
|
1735
1964
|
const unsealedEntries = Object.entries(unsealed);
|
|
@@ -1738,7 +1967,7 @@ const bootSafe = (options) => {
|
|
|
1738
1967
|
unsealedEntries.map(([key]) => key).forEach((key) => sealedKeys.add(key));
|
|
1739
1968
|
});
|
|
1740
1969
|
});
|
|
1741
|
-
const remainingKeys =
|
|
1970
|
+
const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
|
|
1742
1971
|
if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
|
|
1743
1972
|
warnings.push(`fnox export failed: ${err.message}`);
|
|
1744
1973
|
skipped.push(...remainingKeys);
|
|
@@ -1756,9 +1985,34 @@ const bootSafe = (options) => {
|
|
|
1756
1985
|
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
1757
1986
|
skipped.push(...remainingKeys);
|
|
1758
1987
|
}
|
|
1759
|
-
|
|
1760
|
-
|
|
1988
|
+
aliasSecretKeys.forEach((aliasKey) => {
|
|
1989
|
+
const entry = aliasTable.entries.get(`secret.${aliasKey}`);
|
|
1990
|
+
if (!entry) return;
|
|
1991
|
+
const targetValue = secrets[entry.targetKey];
|
|
1992
|
+
if (targetValue !== void 0) {
|
|
1993
|
+
secrets[aliasKey] = targetValue;
|
|
1994
|
+
injected.push(aliasKey);
|
|
1995
|
+
} else skipped.push(aliasKey);
|
|
1761
1996
|
});
|
|
1997
|
+
aliasEnvKeys.forEach((aliasKey) => {
|
|
1998
|
+
const entry = aliasTable.entries.get(`env.${aliasKey}`);
|
|
1999
|
+
if (!entry) return;
|
|
2000
|
+
if (process.env[aliasKey] !== void 0) {
|
|
2001
|
+
overridden.push(aliasKey);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
const targetEntry = envEntries[entry.targetKey];
|
|
2005
|
+
if (targetEntry?.value === void 0) return;
|
|
2006
|
+
envDefaults[aliasKey] = process.env[entry.targetKey] ?? targetEntry.value;
|
|
2007
|
+
});
|
|
2008
|
+
if (inject) {
|
|
2009
|
+
Object.entries(envDefaults).forEach(([key, value]) => {
|
|
2010
|
+
process.env[key] ??= value;
|
|
2011
|
+
});
|
|
2012
|
+
Object.entries(secrets).forEach(([key, value]) => {
|
|
2013
|
+
process.env[key] = value;
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
1762
2016
|
return {
|
|
1763
2017
|
audit,
|
|
1764
2018
|
injected,
|
|
@@ -1771,7 +2025,7 @@ const bootSafe = (options) => {
|
|
|
1771
2025
|
configSource
|
|
1772
2026
|
};
|
|
1773
2027
|
});
|
|
1774
|
-
});
|
|
2028
|
+
}));
|
|
1775
2029
|
};
|
|
1776
2030
|
/** Programmatic boot — throws EnvpktBootError on failure (intentional throwing wrapper over bootSafe) */
|
|
1777
2031
|
const boot = (options) => bootSafe(options).fold((err) => {
|
|
@@ -1803,6 +2057,12 @@ const formatBootError = (error) => {
|
|
|
1803
2057
|
case "AgeNotFound": return `age not found: ${error.message}`;
|
|
1804
2058
|
case "DecryptFailed": return `Decrypt failed: ${error.message}`;
|
|
1805
2059
|
case "IdentityNotFound": return `Identity file not found: ${error.path}`;
|
|
2060
|
+
case "AliasInvalidSyntax":
|
|
2061
|
+
case "AliasTargetMissing":
|
|
2062
|
+
case "AliasSelfReference":
|
|
2063
|
+
case "AliasChained":
|
|
2064
|
+
case "AliasCrossType":
|
|
2065
|
+
case "AliasValueConflict": return formatAliasError(error);
|
|
1806
2066
|
default: return `Boot error: ${JSON.stringify(error)}`;
|
|
1807
2067
|
}
|
|
1808
2068
|
};
|
|
@@ -2238,6 +2498,7 @@ const handleGetPacketHealth = (args) => {
|
|
|
2238
2498
|
status: s.status,
|
|
2239
2499
|
days_remaining: s.days_remaining.fold(() => null, (d) => d),
|
|
2240
2500
|
rotation_url: s.rotation_url.fold(() => null, (u) => u),
|
|
2501
|
+
alias_of: s.alias_of.fold(() => null, (a) => a),
|
|
2241
2502
|
issues: s.issues.toArray()
|
|
2242
2503
|
}));
|
|
2243
2504
|
return textResult(JSON.stringify({
|
|
@@ -2249,6 +2510,7 @@ const handleGetPacketHealth = (args) => {
|
|
|
2249
2510
|
expired: audit.expired,
|
|
2250
2511
|
stale: audit.stale,
|
|
2251
2512
|
missing: audit.missing,
|
|
2513
|
+
aliases: audit.aliases,
|
|
2252
2514
|
secrets: secretDetails
|
|
2253
2515
|
}, null, 2));
|
|
2254
2516
|
};
|
|
@@ -2278,11 +2540,30 @@ const handleGetSecretMeta = (args) => {
|
|
|
2278
2540
|
const loaded = loadConfigForTool(configPathArg(args));
|
|
2279
2541
|
if (!loaded.ok) return loaded.result;
|
|
2280
2542
|
const { config } = loaded;
|
|
2281
|
-
|
|
2282
|
-
|
|
2543
|
+
const secretEntries = config.secret ?? {};
|
|
2544
|
+
return Option(secretEntries[key]).fold(() => errorResult(`Secret not found: ${key}`), (meta) => {
|
|
2545
|
+
const { encrypted_value: _, from_key: fromKey, ...rest } = meta;
|
|
2546
|
+
if (fromKey !== void 0) {
|
|
2547
|
+
const targetKey = /^secret\.(.+)$/.exec(fromKey)?.[1];
|
|
2548
|
+
const target = targetKey !== void 0 ? secretEntries[targetKey] : void 0;
|
|
2549
|
+
if (target) {
|
|
2550
|
+
const { encrypted_value: __, from_key: ___, ...targetRest } = target;
|
|
2551
|
+
return textResult(JSON.stringify({
|
|
2552
|
+
key,
|
|
2553
|
+
...targetRest,
|
|
2554
|
+
...rest,
|
|
2555
|
+
alias_of: fromKey
|
|
2556
|
+
}, null, 2));
|
|
2557
|
+
}
|
|
2558
|
+
return textResult(JSON.stringify({
|
|
2559
|
+
key,
|
|
2560
|
+
...rest,
|
|
2561
|
+
alias_of: fromKey
|
|
2562
|
+
}, null, 2));
|
|
2563
|
+
}
|
|
2283
2564
|
return textResult(JSON.stringify({
|
|
2284
2565
|
key,
|
|
2285
|
-
...
|
|
2566
|
+
...rest
|
|
2286
2567
|
}, null, 2));
|
|
2287
2568
|
});
|
|
2288
2569
|
};
|
|
@@ -2356,4 +2637,4 @@ const startServer = async () => {
|
|
|
2356
2637
|
await server.connect(transport);
|
|
2357
2638
|
};
|
|
2358
2639
|
//#endregion
|
|
2359
|
-
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createServer, deriveServiceFromName, detectFnox, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatPacket, generateKeypair, generateTomlFromScan, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateConfig };
|
|
2640
|
+
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, createServer, deriveServiceFromName, detectFnox, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "envpkt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Credential lifecycle and fleet management for AI agents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"credentials",
|
|
@@ -20,18 +20,36 @@
|
|
|
20
20
|
"bin": {
|
|
21
21
|
"envpkt": "dist/cli.js"
|
|
22
22
|
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"validate": "ts-builds validate",
|
|
25
|
+
"format": "ts-builds format",
|
|
26
|
+
"format:check": "ts-builds format:check",
|
|
27
|
+
"lint": "ts-builds lint",
|
|
28
|
+
"lint:check": "ts-builds lint:check",
|
|
29
|
+
"typecheck": "ts-builds typecheck",
|
|
30
|
+
"test": "ts-builds test",
|
|
31
|
+
"test:watch": "ts-builds test:watch",
|
|
32
|
+
"test:coverage": "ts-builds test:coverage",
|
|
33
|
+
"build": "ts-builds build",
|
|
34
|
+
"build:schema": "tsx scripts/build-schema.ts",
|
|
35
|
+
"demo": "tsx scripts/generate-demo-html.ts",
|
|
36
|
+
"dev": "ts-builds dev",
|
|
37
|
+
"docs:dev": "pnpm --dir site dev",
|
|
38
|
+
"docs:build": "pnpm --dir site build",
|
|
39
|
+
"prepublishOnly": "pnpm validate"
|
|
40
|
+
},
|
|
23
41
|
"dependencies": {
|
|
24
42
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
43
|
"@sinclair/typebox": "^0.34.49",
|
|
26
44
|
"commander": "^14.0.3",
|
|
27
|
-
"functype": "^0.
|
|
45
|
+
"functype": "^0.58.1",
|
|
28
46
|
"functype-os": "^0.4.2",
|
|
29
47
|
"smol-toml": "^1.6.1"
|
|
30
48
|
},
|
|
31
49
|
"devDependencies": {
|
|
32
50
|
"@types/node": "^24.12.2",
|
|
33
|
-
"ts-builds": "^2.
|
|
34
|
-
"tsdown": "^0.21.
|
|
51
|
+
"ts-builds": "^2.7.0",
|
|
52
|
+
"tsdown": "^0.21.9",
|
|
35
53
|
"tsx": "^4.21.0"
|
|
36
54
|
},
|
|
37
55
|
"type": "module",
|
|
@@ -52,21 +70,5 @@
|
|
|
52
70
|
"schemas"
|
|
53
71
|
],
|
|
54
72
|
"prettier": "ts-builds/prettier",
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
"format": "ts-builds format",
|
|
58
|
-
"format:check": "ts-builds format:check",
|
|
59
|
-
"lint": "ts-builds lint",
|
|
60
|
-
"lint:check": "ts-builds lint:check",
|
|
61
|
-
"typecheck": "ts-builds typecheck",
|
|
62
|
-
"test": "ts-builds test",
|
|
63
|
-
"test:watch": "ts-builds test:watch",
|
|
64
|
-
"test:coverage": "ts-builds test:coverage",
|
|
65
|
-
"build": "ts-builds build",
|
|
66
|
-
"build:schema": "tsx scripts/build-schema.ts",
|
|
67
|
-
"demo": "tsx scripts/generate-demo-html.ts",
|
|
68
|
-
"dev": "ts-builds dev",
|
|
69
|
-
"docs:dev": "pnpm --dir site dev",
|
|
70
|
-
"docs:build": "pnpm --dir site build"
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
+
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
|
74
|
+
}
|
|
@@ -151,6 +151,10 @@
|
|
|
151
151
|
"description": "Age-encrypted secret value (armored ciphertext, safe to commit)",
|
|
152
152
|
"type": "string"
|
|
153
153
|
},
|
|
154
|
+
"from_key": {
|
|
155
|
+
"description": "Reference another entry (format: 'secret.<KEY>') whose resolved value this alias reuses. Mutually exclusive with encrypted_value.",
|
|
156
|
+
"type": "string"
|
|
157
|
+
},
|
|
154
158
|
"required": {
|
|
155
159
|
"description": "Whether this secret is required for operation",
|
|
156
160
|
"type": "boolean"
|
|
@@ -175,12 +179,13 @@
|
|
|
175
179
|
"^(.*)$": {
|
|
176
180
|
"description": "Metadata for a plaintext environment default (non-secret)",
|
|
177
181
|
"type": "object",
|
|
178
|
-
"required": [
|
|
179
|
-
"value"
|
|
180
|
-
],
|
|
181
182
|
"properties": {
|
|
182
183
|
"value": {
|
|
183
|
-
"description": "Default value for this environment variable",
|
|
184
|
+
"description": "Default value for this environment variable. Optional when from_key is set; required otherwise.",
|
|
185
|
+
"type": "string"
|
|
186
|
+
},
|
|
187
|
+
"from_key": {
|
|
188
|
+
"description": "Reference another entry (format: 'env.<KEY>') whose resolved value this alias reuses. Mutually exclusive with value.",
|
|
184
189
|
"type": "string"
|
|
185
190
|
},
|
|
186
191
|
"purpose": {
|