@envseal/core 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +201 -0
  2. package/dist/approvals.d.ts +13 -0
  3. package/dist/approvals.js +73 -0
  4. package/dist/audit.d.ts +42 -0
  5. package/dist/audit.js +37 -0
  6. package/dist/broker.d.ts +31 -0
  7. package/dist/broker.js +449 -0
  8. package/dist/exec.d.ts +18 -0
  9. package/dist/exec.js +148 -0
  10. package/dist/guard.d.ts +66 -0
  11. package/dist/guard.js +157 -0
  12. package/dist/index.d.ts +16 -0
  13. package/dist/index.js +15 -0
  14. package/dist/manifest.d.ts +24 -0
  15. package/dist/manifest.js +165 -0
  16. package/dist/paths.d.ts +14 -0
  17. package/dist/paths.js +87 -0
  18. package/dist/presence.d.ts +20 -0
  19. package/dist/presence.js +58 -0
  20. package/dist/redact.d.ts +20 -0
  21. package/dist/redact.js +338 -0
  22. package/dist/sinks/cli-sink-base.d.ts +88 -0
  23. package/dist/sinks/cli-sink-base.js +217 -0
  24. package/dist/sinks/doppler.d.ts +45 -0
  25. package/dist/sinks/doppler.js +198 -0
  26. package/dist/sinks/dotenv.d.ts +57 -0
  27. package/dist/sinks/dotenv.js +407 -0
  28. package/dist/sinks/keychain.d.ts +21 -0
  29. package/dist/sinks/keychain.js +333 -0
  30. package/dist/sinks/onepassword.d.ts +58 -0
  31. package/dist/sinks/onepassword.js +183 -0
  32. package/dist/sinks/registry.d.ts +4 -0
  33. package/dist/sinks/registry.js +63 -0
  34. package/dist/sinks/sops.d.ts +54 -0
  35. package/dist/sinks/sops.js +254 -0
  36. package/dist/sinks/types.d.ts +10 -0
  37. package/dist/sinks/types.js +2 -0
  38. package/dist/sinks/vault.d.ts +41 -0
  39. package/dist/sinks/vault.js +156 -0
  40. package/dist/tickets.d.ts +49 -0
  41. package/dist/tickets.js +179 -0
  42. package/dist/validation-state.d.ts +33 -0
  43. package/dist/validation-state.js +48 -0
  44. package/dist/verify.d.ts +8 -0
  45. package/dist/verify.js +133 -0
  46. package/package.json +38 -0
package/dist/broker.js ADDED
@@ -0,0 +1,449 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { SepError, isSepError, asSecret, zero } from '@envseal/protocol';
3
+ import { getProvider, findKey } from '@envseal/registry';
4
+ import { selectPrompter } from '@envseal/prompters';
5
+ import { projectPaths, loadOrCreateSalt } from './paths.js';
6
+ import { loadManifest, declareEntries, emptyManifest } from './manifest.js';
7
+ import { resolvePresence } from './presence.js';
8
+ import { TicketStore } from './tickets.js';
9
+ import { appendAudit } from './audit.js';
10
+ import { verifyKey } from './verify.js';
11
+ import { runWithSecrets } from './exec.js';
12
+ import { getSink } from './sinks/registry.js';
13
+ import { getValidation, recordValidation } from './validation-state.js';
14
+ import { scanText, secretInRequestError } from './guard.js';
15
+ const LENGTH_BUCKETS = ['<8', '8-16', '16-32', '32-48', '48-64', '64-128', '128+'];
16
+ function getLengthBucket(length) {
17
+ if (length < 8)
18
+ return '<8';
19
+ if (length < 16)
20
+ return '8-16';
21
+ if (length < 32)
22
+ return '16-32';
23
+ if (length < 48)
24
+ return '32-48';
25
+ if (length < 64)
26
+ return '48-64';
27
+ if (length < 128)
28
+ return '64-128';
29
+ return '128+';
30
+ }
31
+ function computeFingerprint(value, salt) {
32
+ const hmac = createHmac('sha256', salt);
33
+ hmac.update(value);
34
+ const digest = hmac.digest('hex');
35
+ return `fp_${digest.slice(0, 8)}`;
36
+ }
37
+ export class Broker {
38
+ paths;
39
+ prompter;
40
+ prompterPromise;
41
+ ticketStore;
42
+ onConfirm;
43
+ onApprovalNeeded;
44
+ salt;
45
+ constructor(opts) {
46
+ this.paths = projectPaths(opts.root);
47
+ this.ticketStore = new TicketStore();
48
+ this.onConfirm = opts.onConfirm;
49
+ this.onApprovalNeeded = opts.onApprovalNeeded;
50
+ this.salt = loadOrCreateSalt(this.paths);
51
+ this.prompter = opts.prompter ?? null;
52
+ this.prompterPromise = opts.prompter ? null : selectPrompter();
53
+ }
54
+ async getPrompter() {
55
+ if (this.prompter) {
56
+ return this.prompter;
57
+ }
58
+ if (this.prompterPromise) {
59
+ this.prompter = await this.prompterPromise;
60
+ return this.prompter;
61
+ }
62
+ throw new Error('Prompter not available');
63
+ }
64
+ async describe() {
65
+ const manifest = loadManifest(this.paths) ?? emptyManifest();
66
+ // Sink-aware: a keychain-declared entry is only resolvable through its
67
+ // sink, so presence must consult it or status would report present:false
68
+ // forever (the write-only era bug).
69
+ const presence = await resolvePresence(this.paths, manifest.entries.map((e) => e.key), { sinks: new Map(manifest.entries.map((e) => [e.key, e.sink ?? 'dotenv'])) });
70
+ const entries = [];
71
+ const missingRequired = [];
72
+ for (const entry of manifest.entries) {
73
+ const presenceInfo = presence.get(entry.key);
74
+ const present = presenceInfo?.present ?? false;
75
+ const value = presenceInfo?.value ?? null;
76
+ const lengthBucket = value ? getLengthBucket(value.length) : '<8';
77
+ const fingerprint = value ? computeFingerprint(value, this.salt) : 'unknown';
78
+ // NEVER evaluate the manifest's format.pattern against the live value
79
+ // here. That pattern is model-supplied via env_declare, so compiling it
80
+ // against the secret and returning the boolean is an unlimited
81
+ // chosen-predicate oracle — enough to reconstruct the value in a few
82
+ // hundred calls. See validation-state.ts.
83
+ //
84
+ // Report the outcome recorded when the value was stored. If we have no
85
+ // record for THIS value (e.g. it predates envseal, or was written by
86
+ // hand), fall back to the registry's pattern, which is bundled data the
87
+ // model cannot influence — and otherwise report unknown.
88
+ let formatValid = null;
89
+ if (present && value) {
90
+ formatValid = getValidation(this.paths, entry.key, fingerprint);
91
+ if (formatValid === null) {
92
+ const trusted = findKey(entry.key)?.key.format?.pattern;
93
+ if (trusted !== undefined) {
94
+ formatValid = new RegExp(trusted).test(value.toString('utf8'));
95
+ }
96
+ }
97
+ }
98
+ const status = {
99
+ key: entry.key,
100
+ declared: true,
101
+ present,
102
+ sink: entry.sink ?? 'dotenv',
103
+ formatValid,
104
+ lengthBucket,
105
+ fingerprint,
106
+ lastVerified: null,
107
+ verifyResult: null,
108
+ source: 'user-prompt',
109
+ rotationDue: null,
110
+ };
111
+ entries.push(status);
112
+ if (entry.required && !present) {
113
+ missingRequired.push(entry.key);
114
+ }
115
+ }
116
+ return {
117
+ projectRoot: this.paths.root,
118
+ manifestPath: this.paths.manifest,
119
+ entries,
120
+ missingRequired,
121
+ };
122
+ }
123
+ async declare(input) {
124
+ const manifest = loadManifest(this.paths) ?? emptyManifest();
125
+ const withDefaults = input.entries.map((entry) => {
126
+ if (entry.format || entry.provider || entry.verify) {
127
+ return entry;
128
+ }
129
+ // Look up by ENV VAR NAME, not provider id. `getProvider('OPENAI_API_KEY')`
130
+ // never matches — provider ids are 'openai', 'stripe', … — so this path
131
+ // silently filled in nothing, leaving a model-declared key with no format
132
+ // validation, no signup link in the prompt, and no verify probe.
133
+ const found = findKey(entry.key);
134
+ if (!found) {
135
+ return entry;
136
+ }
137
+ const { provider: registryEntry, key } = found;
138
+ return {
139
+ ...entry,
140
+ format: entry.format ?? key.format,
141
+ provider: entry.provider ??
142
+ {
143
+ id: registryEntry.id,
144
+ name: registryEntry.name,
145
+ signupUrl: key.signupUrl,
146
+ docsUrl: key.docsUrl,
147
+ rotateUrl: key.rotateUrl,
148
+ },
149
+ verify: entry.verify ?? key.verify,
150
+ };
151
+ });
152
+ return declareEntries(this.paths, withDefaults);
153
+ }
154
+ async request(input) {
155
+ // Before the manifest is even read: `reason` is free text that goes
156
+ // verbatim into .envseal/audit.jsonl, which §4.1 says holds names only. A
157
+ // credential pasted there must not mint a ticket, reach the prompter, or
158
+ // appear in a log line — so nothing above this point may have a side effect.
159
+ // `keys` is not scanned here; every key has already been through the
160
+ // declare-time guard, and an undeclared one throws below.
161
+ const reasonFinding = scanText('reason', input.reason, 'strict');
162
+ if (reasonFinding !== null) {
163
+ appendAudit(this.paths, {
164
+ type: 'blocked',
165
+ reason: 'secret_in_request',
166
+ detail: `${reasonFinding.path}: ${reasonFinding.label}`,
167
+ });
168
+ throw secretInRequestError(reasonFinding);
169
+ }
170
+ const manifest = loadManifest(this.paths) ?? emptyManifest();
171
+ const declaredKeys = new Set(manifest.entries.map((e) => e.key));
172
+ for (const key of input.keys) {
173
+ if (!declaredKeys.has(key)) {
174
+ throw new SepError({
175
+ code: 'SEP_NOT_DECLARED',
176
+ });
177
+ }
178
+ }
179
+ // Report the surface actually selected. Hardcoding 'loopback-browser' told
180
+ // the caller a browser window had opened even when the resolved prompter was
181
+ // `none` (CI) or a native dialog — so the model relayed instructions to the
182
+ // user for a prompt that did not exist.
183
+ const prompter = await this.getPrompter();
184
+ const surface = prompter.id;
185
+ // Refuse before minting a ticket when there is no way to ask a human.
186
+ // Previously the `none` prompter threw inside startPrompt(), where a
187
+ // catch-all turned it into `cancelled` — so in CI the model was told the
188
+ // USER had declined, and the documented exit code 4 was unreachable.
189
+ if (surface === 'none') {
190
+ throw new SepError({
191
+ code: 'SEP_NO_INTERACTIVE_SURFACE',
192
+ userMessage: `No interactive surface is available to collect ${input.keys.join(', ')}. ` +
193
+ 'Provide these values out of band (CI secret store, keychain, or a pre-populated .env), ' +
194
+ 'or run in an environment with a browser or terminal.',
195
+ });
196
+ }
197
+ const ticket = this.ticketStore.create({
198
+ keys: input.keys,
199
+ reason: input.reason,
200
+ surface,
201
+ ttlMs: 600000,
202
+ });
203
+ appendAudit(this.paths, {
204
+ type: 'request',
205
+ ticket: ticket.ticket,
206
+ keys: input.keys,
207
+ reason: input.reason,
208
+ surface: ticket.surface,
209
+ });
210
+ this.startPrompt(ticket.ticket, input.keys, input.reason).catch(() => { });
211
+ return {
212
+ ticket: ticket.ticket,
213
+ nonce: ticket.nonce,
214
+ // Narrow rather than cast: the ticket stores the surface as a plain
215
+ // string, but the protocol type is a union. An `as any` here would hide a
216
+ // typo in a surface name until it reached a client.
217
+ surface: ticket.surface,
218
+ expiresAt: new Date(ticket.expiresAt).toISOString(),
219
+ // No 'none' branch: request() throws SEP_NO_INTERACTIVE_SURFACE before
220
+ // reaching here, so a ticket always corresponds to a real prompt.
221
+ userMessage: surface === 'loopback-browser'
222
+ ? `A browser window has opened to collect ${input.keys.join(', ')}. Verify it shows code ${ticket.nonce} before typing anything.`
223
+ : `A prompt has opened to collect ${input.keys.join(', ')}. Verify it shows code ${ticket.nonce}.`,
224
+ };
225
+ }
226
+ async startPrompt(ticketId, keys, reason) {
227
+ const manifest = loadManifest(this.paths) ?? emptyManifest();
228
+ const ticket = this.ticketStore.get(ticketId);
229
+ if (!ticket)
230
+ return;
231
+ const keyPrompts = keys.map((keyName) => {
232
+ const entry = manifest.entries.find((e) => e.key === keyName);
233
+ if (!entry) {
234
+ return {
235
+ key: keyName,
236
+ description: '',
237
+ };
238
+ }
239
+ return {
240
+ key: keyName,
241
+ description: entry.description,
242
+ providerName: entry.provider?.name,
243
+ signupUrl: entry.provider?.signupUrl,
244
+ docsUrl: entry.provider?.docsUrl,
245
+ formatHint: entry.format?.example,
246
+ pattern: entry.format?.pattern,
247
+ optional: !entry.required,
248
+ };
249
+ });
250
+ const prompter = await this.getPrompter();
251
+ const promptReq = {
252
+ ticket: ticketId,
253
+ nonce: ticket.nonce,
254
+ projectRoot: this.paths.root,
255
+ reason,
256
+ keys: keyPrompts,
257
+ timeoutMs: 600000,
258
+ };
259
+ try {
260
+ const response = await prompter.prompt(promptReq);
261
+ for (const result of response.results) {
262
+ const entry = manifest.entries.find((e) => e.key === result.key);
263
+ if (!entry) {
264
+ // The value-handling region starts here, not at the sink: a result
265
+ // for a key that is no longer in the manifest is dropped without a
266
+ // sink write, and its buffer must not survive that drop either.
267
+ if (result.outcome === 'entered') {
268
+ zero(result.value);
269
+ }
270
+ continue;
271
+ }
272
+ if (result.outcome === 'entered') {
273
+ if (entry.format?.pattern) {
274
+ const pattern = new RegExp(entry.format.pattern);
275
+ const valueStr = result.value.toString('utf8');
276
+ if (!pattern.test(valueStr)) {
277
+ this.ticketStore.setOutcome(ticketId, result.key, 'invalid_format');
278
+ appendAudit(this.paths, {
279
+ type: 'skipped',
280
+ ticket: ticketId,
281
+ key: result.key,
282
+ });
283
+ zero(result.value);
284
+ continue;
285
+ }
286
+ }
287
+ // Every exit from this region — including the sink write throwing
288
+ // SEP_SINK_WRITE_FAILED — must zero the entered value. The catch
289
+ // below records the failure but used to leave the buffer live in
290
+ // the heap.
291
+ try {
292
+ const sink = getSink(entry.sink ?? 'dotenv');
293
+ await sink.write(this.paths, result.key, result.value);
294
+ const fingerprint = computeFingerprint(result.value, this.salt);
295
+ // Record the outcome now, while we legitimately hold the value. This
296
+ // is the only place format validation touches a secret; env_describe
297
+ // afterwards reports THIS result rather than re-testing a pattern the
298
+ // model may have changed in the meantime.
299
+ recordValidation(this.paths, result.key, fingerprint, true);
300
+ this.ticketStore.setOutcome(ticketId, result.key, 'stored');
301
+ appendAudit(this.paths, {
302
+ type: 'stored',
303
+ ticket: ticketId,
304
+ key: result.key,
305
+ sink: sink.id,
306
+ fingerprint,
307
+ });
308
+ }
309
+ finally {
310
+ zero(result.value);
311
+ }
312
+ }
313
+ else if (result.outcome === 'skipped') {
314
+ this.ticketStore.setOutcome(ticketId, result.key, 'skipped');
315
+ appendAudit(this.paths, {
316
+ type: 'skipped',
317
+ ticket: ticketId,
318
+ key: result.key,
319
+ });
320
+ }
321
+ else if (result.outcome === 'cancelled') {
322
+ this.ticketStore.setOutcome(ticketId, result.key, 'cancelled');
323
+ appendAudit(this.paths, {
324
+ type: 'cancelled',
325
+ ticket: ticketId,
326
+ key: result.key,
327
+ });
328
+ }
329
+ else if (result.outcome === 'timeout') {
330
+ this.ticketStore.setOutcome(ticketId, result.key, 'timeout');
331
+ appendAudit(this.paths, {
332
+ type: 'timeout',
333
+ ticket: ticketId,
334
+ key: result.key,
335
+ });
336
+ }
337
+ }
338
+ this.ticketStore.resolve(ticketId);
339
+ }
340
+ catch (error) {
341
+ // The prompt surface failed rather than the user declining. Record why,
342
+ // so the distinction survives into the audit log even though the ticket
343
+ // state cannot express it.
344
+ appendAudit(this.paths, {
345
+ type: 'blocked',
346
+ reason: 'prompt_failed',
347
+ detail: isSepError(error) ? error.code : 'unknown_prompter_error',
348
+ });
349
+ this.ticketStore.cancel(ticketId);
350
+ }
351
+ }
352
+ async await(input) {
353
+ return this.ticketStore.await(input.ticket, input.timeoutMs ?? 90000);
354
+ }
355
+ async verify(input) {
356
+ const manifest = loadManifest(this.paths) ?? emptyManifest();
357
+ const results = [];
358
+ for (const keyName of input.keys) {
359
+ const entry = manifest.entries.find((e) => e.key === keyName);
360
+ if (!entry) {
361
+ results.push({
362
+ key: keyName,
363
+ result: 'no_probe',
364
+ message: 'Key not found in manifest',
365
+ checkedAt: new Date().toISOString(),
366
+ });
367
+ continue;
368
+ }
369
+ const sink = getSink(entry.sink ?? 'dotenv');
370
+ const value = await sink.read(this.paths, keyName);
371
+ if (!value) {
372
+ results.push({
373
+ key: keyName,
374
+ result: 'no_probe',
375
+ message: 'Key not stored',
376
+ checkedAt: new Date().toISOString(),
377
+ });
378
+ continue;
379
+ }
380
+ const result = await verifyKey(this.paths, entry, value, {
381
+ onApprovalNeeded: this.onApprovalNeeded,
382
+ });
383
+ results.push(result);
384
+ zero(value);
385
+ }
386
+ return results;
387
+ }
388
+ async use(input) {
389
+ const manifest = loadManifest(this.paths) ?? emptyManifest();
390
+ const secrets = new Map();
391
+ for (const keyName of input.keys) {
392
+ const entry = manifest.entries.find((e) => e.key === keyName);
393
+ if (!entry)
394
+ continue;
395
+ const sink = getSink(entry.sink ?? 'dotenv');
396
+ const value = await sink.read(this.paths, keyName);
397
+ if (value) {
398
+ secrets.set(keyName, value);
399
+ }
400
+ }
401
+ const result = await runWithSecrets(input.command, secrets, {
402
+ onConfirm: this.onConfirm,
403
+ });
404
+ for (const value of secrets.values()) {
405
+ zero(value);
406
+ }
407
+ return result;
408
+ }
409
+ async revoke(input) {
410
+ const manifest = loadManifest(this.paths) ?? emptyManifest();
411
+ const results = [];
412
+ for (const keyName of input.keys) {
413
+ const entry = manifest.entries.find((e) => e.key === keyName);
414
+ if (!entry)
415
+ continue;
416
+ const sink = getSink(entry.sink ?? 'dotenv');
417
+ const removed = await sink.remove(this.paths, keyName);
418
+ // Fall back to the registry. A manifest entry commonly carries only
419
+ // `provider.id` — a model declaring a key has no reason to type out the
420
+ // rotation URL — and returning null there defeats the field's whole
421
+ // purpose, which is telling the user where to invalidate a burned key.
422
+ let rotateUrl = entry.provider?.rotateUrl ?? null;
423
+ if (rotateUrl === null && entry.provider?.id !== undefined) {
424
+ rotateUrl = getProvider(entry.provider.id)?.keys.find((k) => k.envVar === keyName)
425
+ ?.rotateUrl
426
+ ?? getProvider(entry.provider.id)?.keys[0]?.rotateUrl
427
+ ?? null;
428
+ }
429
+ if (rotateUrl === null) {
430
+ rotateUrl = findKey(keyName)?.key.rotateUrl ?? null;
431
+ }
432
+ results.push({
433
+ key: keyName,
434
+ removed,
435
+ rotateUrl,
436
+ });
437
+ appendAudit(this.paths, {
438
+ type: 'revoke',
439
+ key: keyName,
440
+ sink: sink.id,
441
+ });
442
+ }
443
+ return results;
444
+ }
445
+ dispose() {
446
+ this.ticketStore.dispose();
447
+ }
448
+ }
449
+ //# sourceMappingURL=broker.js.map
package/dist/exec.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { SecretValue, ExecResult } from '@envseal/protocol';
2
+ /**
3
+ * Residual risk on Linux: A same-uid process can read /proc/<pid>/environ
4
+ * of the child process. This cannot be defended against without sandboxing.
5
+ * Users on shared systems should be aware of this limitation.
6
+ */
7
+ export interface ExecOptions {
8
+ cwd?: string;
9
+ timeoutMs?: number;
10
+ onConfirm?: (info: {
11
+ command: string[];
12
+ keys: string[];
13
+ networkEgress: boolean;
14
+ }) => Promise<boolean>;
15
+ approvedCommands?: string[];
16
+ }
17
+ export declare function runWithSecrets(command: string[], secrets: Map<string, SecretValue>, opts?: ExecOptions): Promise<ExecResult>;
18
+ //# sourceMappingURL=exec.d.ts.map
package/dist/exec.js ADDED
@@ -0,0 +1,148 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { SepError } from '@envseal/protocol';
3
+ import { redact } from './redact.js';
4
+ import { unsafeSecretToUtf8 } from './sinks/dotenv.js';
5
+ const NETWORK_TOOLS = new Set([
6
+ 'curl',
7
+ 'wget',
8
+ 'nc',
9
+ 'ncat',
10
+ 'netcat',
11
+ 'ssh',
12
+ 'scp',
13
+ 'rsync',
14
+ 'http',
15
+ 'httpie',
16
+ 'telnet',
17
+ 'socat',
18
+ ]);
19
+ function detectNetworkEgress(command) {
20
+ if (command.length === 0) {
21
+ return false;
22
+ }
23
+ const basename = command[0].split(/[\\/]/).pop()?.toLowerCase() ?? '';
24
+ if (NETWORK_TOOLS.has(basename)) {
25
+ return true;
26
+ }
27
+ for (const arg of command) {
28
+ if (/^https?:\/\//.test(arg)) {
29
+ return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+ export async function runWithSecrets(command, secrets, opts) {
35
+ if (command.length === 0) {
36
+ throw new SepError({
37
+ code: 'SEP_FORMAT_INVALID',
38
+ userMessage: 'Command cannot be empty',
39
+ });
40
+ }
41
+ const networkEgress = detectNetworkEgress(command);
42
+ const secretKeys = Array.from(secrets.keys());
43
+ const joinedCommand = command.join(' ');
44
+ const isApproved = opts?.approvedCommands?.some((approved) => approved === joinedCommand);
45
+ if (!isApproved && opts?.onConfirm) {
46
+ const confirmed = await opts.onConfirm({
47
+ command,
48
+ keys: secretKeys,
49
+ networkEgress,
50
+ });
51
+ if (!confirmed) {
52
+ throw new SepError({
53
+ code: 'SEP_CONFIRMATION_DENIED',
54
+ });
55
+ }
56
+ }
57
+ else if (!isApproved && !opts?.onConfirm) {
58
+ throw new SepError({
59
+ code: 'SEP_CONFIRMATION_DENIED',
60
+ });
61
+ }
62
+ const childEnv = { ...process.env };
63
+ const secretValues = [];
64
+ // W2-F31: docs/cli-contract.md §"redaction" promises masks read
65
+ // «redacted:KEY_NAME». Nothing but the key name rides along — redact()
66
+ // rejects a label that is not a plain identifier, so a label can never carry
67
+ // markup or a value fragment into the output stream.
68
+ const redactionLabels = new Map();
69
+ for (const [key, value] of secrets) {
70
+ const valueStr = unsafeSecretToUtf8(value);
71
+ childEnv[key] = valueStr;
72
+ secretValues.push(value);
73
+ redactionLabels.set(value, key);
74
+ }
75
+ const MAX_BUFFER = 1024 * 1024;
76
+ const proc = spawn(command[0], command.slice(1), {
77
+ cwd: opts?.cwd,
78
+ env: childEnv,
79
+ shell: false,
80
+ stdio: ['ignore', 'pipe', 'pipe'],
81
+ });
82
+ let stdout = Buffer.alloc(0);
83
+ let stderr = Buffer.alloc(0);
84
+ let timedOut = false;
85
+ let exitCode = null;
86
+ const stdoutListener = (chunk) => {
87
+ if (stdout.length < MAX_BUFFER) {
88
+ stdout = Buffer.concat([stdout, chunk], Math.min(stdout.length + chunk.length, MAX_BUFFER));
89
+ }
90
+ };
91
+ const stderrListener = (chunk) => {
92
+ if (stderr.length < MAX_BUFFER) {
93
+ stderr = Buffer.concat([stderr, chunk], Math.min(stderr.length + chunk.length, MAX_BUFFER));
94
+ }
95
+ };
96
+ proc.stdout?.on('data', stdoutListener);
97
+ proc.stderr?.on('data', stderrListener);
98
+ return new Promise((resolve, reject) => {
99
+ let timeoutHandle;
100
+ const cleanup = () => {
101
+ if (timeoutHandle !== undefined) {
102
+ clearTimeout(timeoutHandle);
103
+ }
104
+ proc.stdout?.removeListener('data', stdoutListener);
105
+ proc.stderr?.removeListener('data', stderrListener);
106
+ };
107
+ const finish = (code) => {
108
+ cleanup();
109
+ exitCode = code;
110
+ const stdoutStr = stdout.toString('utf8');
111
+ const stderrStr = stderr.toString('utf8');
112
+ const redactStdout = redact(stdoutStr, secretValues, redactionLabels);
113
+ const redactStderr = redact(stderrStr, secretValues, redactionLabels);
114
+ resolve({
115
+ exitCode,
116
+ stdout: redactStdout.text,
117
+ stderr: redactStderr.text,
118
+ timedOut,
119
+ redactedCount: redactStdout.count + redactStderr.count,
120
+ });
121
+ };
122
+ proc.on('exit', (code) => {
123
+ if (!timedOut) {
124
+ finish(code);
125
+ }
126
+ });
127
+ proc.on('error', (err) => {
128
+ cleanup();
129
+ reject(err);
130
+ });
131
+ if (opts?.timeoutMs && opts.timeoutMs > 0) {
132
+ timeoutHandle = setTimeout(() => {
133
+ if (proc.exitCode === null) {
134
+ timedOut = true;
135
+ proc.kill('SIGTERM');
136
+ setTimeout(() => {
137
+ if (proc.exitCode === null) {
138
+ proc.kill('SIGKILL');
139
+ }
140
+ finish(null);
141
+ }, 1000);
142
+ }
143
+ }, opts.timeoutMs);
144
+ timeoutHandle.unref();
145
+ }
146
+ });
147
+ }
148
+ //# sourceMappingURL=exec.js.map
@@ -0,0 +1,66 @@
1
+ import type { Detection } from '@envseal/detector';
2
+ import { SepError } from '@envseal/protocol';
3
+ import type { ManifestEntry } from '@envseal/protocol';
4
+ /**
5
+ * Secret-shaped-input guard for the model-facing free-text boundary (PLAN §2.2 T3).
6
+ *
7
+ * `.strict()` on the protocol schemas rejects a literal `value` field, which is
8
+ * the only leak an honest caller commits by accident. It does nothing about the
9
+ * fields that are SUPPOSED to hold prose: a credential pasted into an
10
+ * `env_declare` description or a `format.example` was written verbatim into
11
+ * `env.schema.jsonc` — a file §6.1 commits to git — and one pasted into an
12
+ * `env_request` reason was written verbatim into `.envseal/audit.jsonl`, which
13
+ * §4.1 says holds "names only, no values". `@envseal/detector` existed for
14
+ * exactly this and had no consumer inside the broker.
15
+ *
16
+ * Nothing here ever echoes the matched text. A finding carries the field path
17
+ * and the detector's pattern label, both of which are safe to put in an error
18
+ * message and in the audit log; the value itself never leaves this module.
19
+ */
20
+ /**
21
+ * Which detector confidences reject.
22
+ *
23
+ * `high` alone is not enough. Measured over 17 structurally realistic
24
+ * credentials with random bodies, a high-only threshold let three through —
25
+ * `wJalrXUtnFEMI7K2MDENGbPxRfiCYT7qLm2Xp9Rv` (AWS secret access key shape), a
26
+ * bare 56-char mixed-case token, and a base64 blob. Those have no vendor prefix
27
+ * to pattern-match, so they are reachable ONLY through the generic
28
+ * entropy heuristic, which is always `medium`. They are also the single most
29
+ * common shape of self-hosted credential.
30
+ *
31
+ * The medium tier is affordable here: over the detector's own 100-line
32
+ * false-positive corpus it fires zero times, and over 393 strings from the
33
+ * bundled provider registry it fires zero times (see `strict` below for why
34
+ * that number depends on the placeholder filter).
35
+ */
36
+ export type GuardTier = 'strict' | 'high-only';
37
+ export interface SecretFinding {
38
+ /** Dotted path of the offending field, e.g. `entries[0].format.example`. */
39
+ path: string;
40
+ /** The detector's pattern label. Describes the SHAPE, never the text. */
41
+ label: string;
42
+ confidence: Detection['confidence'];
43
+ }
44
+ /**
45
+ * The first credential-shaped span in `text`, or null. The returned finding
46
+ * deliberately carries no offsets and no excerpt — an offset pair plus a
47
+ * repeatable call is itself a substring oracle over the input.
48
+ */
49
+ export declare function scanText(path: string, text: string, tier: GuardTier): SecretFinding | null;
50
+ /**
51
+ * Every free-text string on a parsed entry that reaches `env.schema.jsonc`.
52
+ *
53
+ * `key` is scanned at `high-only`, alone among the fields. It is the one field
54
+ * whose content is not the caller's to rephrase — it is the environment
55
+ * variable's actual name — so a false positive there is unfixable rather than
56
+ * merely annoying, and the generic entropy tier does produce them:
57
+ * `STRIPE_WEBHOOK_SIGNING_SECRET_V2` is a plausible name that scores as a
58
+ * medium-confidence hit. High-confidence patterns still apply, so a key named
59
+ * `AKIAT7QLM2XP9RV4NC3B` is rejected. Non-string fields (booleans, numbers,
60
+ * `sink`, `verify.method`) cannot carry a value past their zod types and are
61
+ * not scanned.
62
+ */
63
+ export declare function scanManifestEntry(entry: ManifestEntry, basePath: string): SecretFinding | null;
64
+ export declare function secretInDeclarationError(finding: SecretFinding): SepError;
65
+ export declare function secretInRequestError(finding: SecretFinding): SepError;
66
+ //# sourceMappingURL=guard.d.ts.map