@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/redact.js ADDED
@@ -0,0 +1,338 @@
1
+ import { unsafeSecretToUtf8 } from './sinks/dotenv.js';
2
+ /**
3
+ * Values shorter than this are never redacted. Rejecting such a value at entry
4
+ * is the manifest's job (PLAN §7.4); the floor exists here because a 7-byte
5
+ * filter removes far more innocent output than real material. Documented in
6
+ * docs/residual-risks.md §9.1.
7
+ */
8
+ const MIN_SECRET_LENGTH = 8;
9
+ /**
10
+ * Detection window. Any contiguous run of at least WINDOW characters of any
11
+ * variant form is redacted — which subsumes the historical rule "every prefix
12
+ * of length >= 20 is redacted" and additionally covers suffixes and interior
13
+ * fragments (W2-F5: a value split across a line break emerges as a prefix and a
14
+ * suffix; only the prefix used to be caught).
15
+ */
16
+ const WINDOW = 20;
17
+ /**
18
+ * The whole index is a chained hash table over WINDOW-length windows of every
19
+ * variant. Memory is bounded by this many entries (16 bytes each, so ~64 MB at
20
+ * the cap) no matter how long the stored values are. Past the cap the index
21
+ * becomes sparse (see `strideFor`) rather than growing — it never allocates an
22
+ * unbounded pattern, which is what used to abort the process (W2-F9: the old
23
+ * implementation compiled every prefix into one regex alternation, O(N^2) in
24
+ * pattern source, and V8's regex compiler aborts rather than throwing).
25
+ */
26
+ const MAX_INDEX_ENTRIES = 4_000_000;
27
+ /**
28
+ * Cap on candidate windows examined per text position. Only entries whose full
29
+ * 32-bit hash equals the probe's count against it, so the cap is reached only
30
+ * when a value contains 64+ windows with an identical hash — i.e. a highly
31
+ * repetitive value, whose windows are byte-identical, so the first candidate
32
+ * already verifies. Bucket collisions between *different* windows are skipped
33
+ * by a single integer compare and never consume the budget.
34
+ */
35
+ const MAX_PROBES = 64;
36
+ /**
37
+ * A label is emitted verbatim into the output stream, so it must be a plain
38
+ * identifier and nothing else. Manifest keys already match a stricter pattern
39
+ * (`/^[A-Z][A-Z0-9_]{0,63}$/`); anything outside this one falls back to the
40
+ * unlabelled token rather than being escaped, because a caller supplying such a
41
+ * "key name" is not describing a real key.
42
+ */
43
+ const SAFE_LABEL = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
44
+ const GENERIC_TOKEN = '«redacted»';
45
+ const HASH_BASE = 33_554_467;
46
+ const HASH_MIX = 0x9e3779b1 | 0;
47
+ const HASH_TOP = (() => {
48
+ let acc = 1;
49
+ for (let i = 1; i < WINDOW; i++)
50
+ acc = Math.imul(acc, HASH_BASE);
51
+ return acc >>> 0;
52
+ })();
53
+ function hashWindow(s, at) {
54
+ let h = 0;
55
+ for (let i = 0; i < WINDOW; i++)
56
+ h = (Math.imul(h, HASH_BASE) + s.charCodeAt(at + i)) >>> 0;
57
+ return h;
58
+ }
59
+ function rollWindow(h, outCode, inCode) {
60
+ return (Math.imul((h - Math.imul(outCode, HASH_TOP)) >>> 0, HASH_BASE) + inCode) >>> 0;
61
+ }
62
+ /**
63
+ * The forms a child process is most likely to emit a value in. Deliberately
64
+ * NOT exhaustive: a process that chooses to can always encode a value in a form
65
+ * no filter matches (PLAN §2.3 non-goal). See docs/residual-risks.md §9.1.
66
+ */
67
+ function variantsOf(secret) {
68
+ const variants = new Set([secret]);
69
+ const encoded = Buffer.from(secret, 'utf8');
70
+ const base64 = encoded.toString('base64');
71
+ variants.add(base64);
72
+ variants.add(base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''));
73
+ variants.add(encodeURIComponent(secret));
74
+ const jsonEscaped = JSON.stringify(secret);
75
+ if (jsonEscaped.length >= 3)
76
+ variants.add(jsonEscaped.slice(1, -1));
77
+ const hex = encoded.toString('hex');
78
+ variants.add(hex);
79
+ variants.add(hex.toUpperCase());
80
+ return [...variants];
81
+ }
82
+ /**
83
+ * Index every `stride`-th window. Offset 0 is always indexed, so a whole
84
+ * variant and every prefix of it are always detected exactly. With stride > 1
85
+ * the *interior* detection length degrades from WINDOW to WINDOW + stride - 1;
86
+ * that only happens for values large enough that a dense index would exceed
87
+ * MAX_INDEX_ENTRIES (roughly 370 KB of stored value).
88
+ */
89
+ function strideFor(variants) {
90
+ let dense = 0;
91
+ for (const v of variants)
92
+ dense += v.source.length - WINDOW + 1;
93
+ if (dense <= MAX_INDEX_ENTRIES)
94
+ return 1;
95
+ return Math.ceil(dense / MAX_INDEX_ENTRIES);
96
+ }
97
+ function buildIndex(variants, stride) {
98
+ let total = 0;
99
+ for (const v of variants)
100
+ total += Math.ceil((v.source.length - WINDOW + 1) / stride);
101
+ let bits = 1;
102
+ while (1 << bits < total && bits < 24)
103
+ bits++;
104
+ const size = 1 << bits;
105
+ const head = new Int32Array(size);
106
+ const entryNext = new Int32Array(total);
107
+ const entryVariant = new Uint32Array(total);
108
+ const entryOffset = new Uint32Array(total);
109
+ const entryHash = new Uint32Array(total);
110
+ const shift = 32 - bits;
111
+ let entry = 0;
112
+ for (let vi = 0; vi < variants.length; vi++) {
113
+ const src = variants[vi].source;
114
+ const windows = src.length - WINDOW + 1;
115
+ let h = hashWindow(src, 0);
116
+ for (let off = 0; off < windows; off++) {
117
+ if (off % stride === 0) {
118
+ entryVariant[entry] = vi;
119
+ entryOffset[entry] = off;
120
+ entryHash[entry] = h;
121
+ entry++;
122
+ }
123
+ if (off + 1 < windows)
124
+ h = rollWindow(h, src.charCodeAt(off), src.charCodeAt(off + WINDOW));
125
+ }
126
+ }
127
+ // Chain in descending entry order so each bucket's head is its LOWEST offset.
128
+ // For a repetitive value every window hashes alike; starting from the lowest
129
+ // offset gives the match the most room to extend forward, which keeps a long
130
+ // run one redaction rather than a chain of fragments.
131
+ for (let i = total - 1; i >= 0; i--) {
132
+ const bucket = Math.imul(entryHash[i], HASH_MIX) >>> shift;
133
+ entryNext[i] = head[bucket];
134
+ head[bucket] = i + 1;
135
+ }
136
+ return {
137
+ sources: variants.map((v) => v.source),
138
+ tokens: variants.map((v) => v.token),
139
+ head,
140
+ entryNext,
141
+ entryVariant,
142
+ entryOffset,
143
+ entryHash,
144
+ shift,
145
+ };
146
+ }
147
+ const NO_MATCH = { back: 0, forward: 0, variant: -1 };
148
+ /**
149
+ * Longest run of any indexed variant anchored at text[p..p+WINDOW). `maxBack`
150
+ * bounds the backwards extension so a match can never reach into text that has
151
+ * already been emitted.
152
+ */
153
+ function findMatch(idx, text, p, h, maxBack) {
154
+ const bucket = Math.imul(h, HASH_MIX) >>> idx.shift;
155
+ const textLength = text.length;
156
+ let best = NO_MATCH;
157
+ let probes = 0;
158
+ for (let e = idx.head[bucket]; e !== 0; e = idx.entryNext[e - 1]) {
159
+ const ei = e - 1;
160
+ if (idx.entryHash[ei] !== h)
161
+ continue;
162
+ if (++probes > MAX_PROBES)
163
+ break;
164
+ const vi = idx.entryVariant[ei];
165
+ const src = idx.sources[vi];
166
+ const off = idx.entryOffset[ei];
167
+ let k = 0;
168
+ while (k < WINDOW && src.charCodeAt(off + k) === text.charCodeAt(p + k))
169
+ k++;
170
+ if (k < WINDOW)
171
+ continue;
172
+ let forward = WINDOW;
173
+ while (off + forward < src.length &&
174
+ p + forward < textLength &&
175
+ src.charCodeAt(off + forward) === text.charCodeAt(p + forward)) {
176
+ forward++;
177
+ }
178
+ let back = 0;
179
+ while (back < maxBack &&
180
+ off - back > 0 &&
181
+ src.charCodeAt(off - back - 1) === text.charCodeAt(p - back - 1)) {
182
+ back++;
183
+ }
184
+ if (forward + back > best.forward + best.back)
185
+ best = { back, forward, variant: vi };
186
+ }
187
+ return best;
188
+ }
189
+ /**
190
+ * Greedy left-to-right scan. At each position try an indexed WINDOW match
191
+ * (extended as far as it verifiably reaches), then the shorter literals.
192
+ * `maxBack` bounds the backwards extension so a match can never reach into
193
+ * text that has already been claimed by an earlier span.
194
+ */
195
+ function scanSpans(idx, literals, text) {
196
+ const spans = [];
197
+ const length = text.length;
198
+ let copied = 0;
199
+ let p = 0;
200
+ let h = 0;
201
+ let hashValid = false;
202
+ while (p < length) {
203
+ let start = p;
204
+ let end = p;
205
+ let token = '';
206
+ if (idx !== null && p + WINDOW <= length) {
207
+ if (!hashValid) {
208
+ h = hashWindow(text, p);
209
+ hashValid = true;
210
+ }
211
+ const match = findMatch(idx, text, p, h, p - copied);
212
+ if (match.variant >= 0) {
213
+ start = p - match.back;
214
+ end = p + match.forward;
215
+ token = idx.tokens[match.variant];
216
+ }
217
+ }
218
+ // Only reached when no indexed variant matched: every indexed match is at
219
+ // least WINDOW long and every literal is shorter than WINDOW.
220
+ if (end === start) {
221
+ for (const literal of literals) {
222
+ if (text.startsWith(literal.source, p)) {
223
+ start = p;
224
+ end = p + literal.source.length;
225
+ token = literal.token;
226
+ break;
227
+ }
228
+ }
229
+ }
230
+ if (end > start) {
231
+ spans.push({ start, end, token });
232
+ copied = end;
233
+ p = end;
234
+ hashValid = false;
235
+ continue;
236
+ }
237
+ if (hashValid && p + WINDOW < length) {
238
+ h = rollWindow(h, text.charCodeAt(p), text.charCodeAt(p + WINDOW));
239
+ }
240
+ else {
241
+ hashValid = false;
242
+ }
243
+ p++;
244
+ }
245
+ return spans;
246
+ }
247
+ /** Merge overlapping spans, keeping the earliest span's token and the union's
248
+ * extent. Input spans may come from two different scans of the same text. */
249
+ function mergeSpans(spans) {
250
+ if (spans.length <= 1)
251
+ return spans;
252
+ const sorted = [...spans].sort((a, b) => a.start - b.start || b.end - a.end);
253
+ const merged = [];
254
+ let current = sorted[0];
255
+ for (let i = 1; i < sorted.length; i++) {
256
+ const next = sorted[i];
257
+ if (next.start <= current.end) {
258
+ if (next.end > current.end)
259
+ current = { ...current, end: next.end };
260
+ }
261
+ else {
262
+ merged.push(current);
263
+ current = next;
264
+ }
265
+ }
266
+ merged.push(current);
267
+ return merged;
268
+ }
269
+ const WHITESPACE_RE = /\s/;
270
+ /**
271
+ * Replace every occurrence of a stored value — and of the encodings in
272
+ * `variantsOf` — with an opaque token. Cost is O(text) in time and O(value) in
273
+ * memory, both bounded; nothing here compiles a pattern, so no input length can
274
+ * abort the process.
275
+ *
276
+ * When the text contains whitespace, a second scan runs over a
277
+ * whitespace-stripped dense copy with a dense→original index map (W2-F5: a
278
+ * value split across a line break emerges as a short head plus a suffix, and
279
+ * neither fragment alone may reach the detection window). Matches found dense
280
+ * are mapped back to original offsets and merged with the first pass's spans,
281
+ * so the joined fragments redact as one region including the separator.
282
+ */
283
+ export function redact(text, secrets, labels) {
284
+ const indexable = [];
285
+ const literals = [];
286
+ const seen = new Set();
287
+ for (const secret of secrets) {
288
+ if (secret.length < MIN_SECRET_LENGTH)
289
+ continue;
290
+ const label = labels?.get(secret);
291
+ const token = label !== undefined && SAFE_LABEL.test(label) ? `«redacted:${label}»` : GENERIC_TOKEN;
292
+ for (const source of variantsOf(unsafeSecretToUtf8(secret))) {
293
+ if (source.length === 0 || seen.has(source))
294
+ continue;
295
+ seen.add(source);
296
+ if (source.length >= WINDOW)
297
+ indexable.push({ source, token });
298
+ else
299
+ literals.push({ source, token });
300
+ }
301
+ }
302
+ if (indexable.length === 0 && literals.length === 0)
303
+ return { text, count: 0 };
304
+ literals.sort((a, b) => b.source.length - a.source.length);
305
+ const idx = indexable.length > 0 ? buildIndex(indexable, strideFor(indexable)) : null;
306
+ let spans = scanSpans(idx, literals, text);
307
+ if (WHITESPACE_RE.test(text)) {
308
+ const map = [];
309
+ let dense = '';
310
+ for (let i = 0; i < text.length; i++) {
311
+ const ch = text[i];
312
+ if (!WHITESPACE_RE.test(ch)) {
313
+ map.push(i);
314
+ dense += ch;
315
+ }
316
+ }
317
+ if (dense.length !== text.length) {
318
+ for (const s of scanSpans(idx, literals, dense)) {
319
+ // A dense span [s.start, s.end) maps back to the original positions of
320
+ // its first and last characters; any whitespace between them is part of
321
+ // the split value and is swallowed into the redacted region.
322
+ spans.push({ start: map[s.start], end: map[s.end - 1] + 1, token: s.token });
323
+ }
324
+ spans = mergeSpans(spans);
325
+ }
326
+ }
327
+ if (spans.length === 0)
328
+ return { text, count: 0 };
329
+ let out = '';
330
+ let copied = 0;
331
+ for (const span of spans) {
332
+ out += text.slice(copied, span.start) + span.token;
333
+ copied = span.end;
334
+ }
335
+ out += text.slice(copied);
336
+ return { text: out, count: spans.length };
337
+ }
338
+ //# sourceMappingURL=redact.js.map
@@ -0,0 +1,88 @@
1
+ import { SepError } from '@envseal/protocol';
2
+ import type { SecretValue } from '@envseal/protocol';
3
+ import type { ProjectPaths } from '../paths.js';
4
+ import type { Sink } from './types.js';
5
+ export interface CliExecResult {
6
+ stdout: string;
7
+ stderr: string;
8
+ }
9
+ export interface CliExecOptions {
10
+ /**
11
+ * Stdin payload. This is the ONLY sanctioned route for secret bytes: argv
12
+ * is world-readable via ps/process listings, an inherited pipe is not.
13
+ */
14
+ input?: string;
15
+ /**
16
+ * Complete environment for the child (undefined values drop the variable).
17
+ * Omit to inherit process.env unchanged. This REPLACES the environment
18
+ * rather than merging — copy process.env explicitly when adding variables,
19
+ * the way keychain.ts scrubs PSModulePath.
20
+ */
21
+ env?: Record<string, string | undefined>;
22
+ timeoutMs?: number;
23
+ }
24
+ /**
25
+ * A provider CLI ran and refused: nonzero exit, spawn failure, or timeout.
26
+ * The exit code rides on the error because several tools document specific
27
+ * codes as "item absent" — the adapter, not this base, decides which codes
28
+ * mean absence for read()/remove() and which are real failures.
29
+ */
30
+ export declare class CliCommandFailure extends Error {
31
+ readonly command: string;
32
+ readonly exitCode: number | undefined;
33
+ readonly stderr: string;
34
+ constructor(message: string, command: string, exitCode: number | undefined, stderr: string);
35
+ }
36
+ export declare function exitCodeOf(error: unknown): number | undefined;
37
+ /** Same probe keychain.ts uses; `where`/`which` exit nonzero when absent. */
38
+ export declare function commandExists(command: string): Promise<boolean>;
39
+ export declare function execCli(command: string, args: string[], options?: CliExecOptions): Promise<CliExecResult>;
40
+ export type CliSinkOperation = 'read' | 'write' | 'remove';
41
+ export declare abstract class CliSinkBase implements Sink {
42
+ abstract readonly id: string;
43
+ /**
44
+ * External CLIs this sink shells out to, probed in order. List every binary
45
+ * an operation spawns, not configuration: VAULT_ADDR-style settings are
46
+ * validated at operation time, where a bad value can produce a precise
47
+ * message instead of a vague "not installed".
48
+ */
49
+ protected abstract readonly requiredCommands: readonly string[];
50
+ /**
51
+ * What is missing, phrased for the user — e.g. "the vault CLI is not
52
+ * installed or VAULT_ADDR is unset". Feeds both the SEP_SINK_UNAVAILABLE
53
+ * thrown when a listed command is missing at operation time and the stub
54
+ * refusals below, until an implementing agent fills the operations in.
55
+ */
56
+ protected abstract unavailableReason(): string;
57
+ available(_paths: ProjectPaths): Promise<boolean>;
58
+ read(_paths: ProjectPaths, _key: string): Promise<SecretValue | null>;
59
+ write(_paths: ProjectPaths, _key: string, _value: SecretValue): Promise<void>;
60
+ remove(_paths: ProjectPaths, _key: string): Promise<boolean>;
61
+ /**
62
+ * Probe requiredCommands and turn a miss into SEP_SINK_UNAVAILABLE. Every
63
+ * real operation starts here: available() may have been consulted long
64
+ * before, and the CLI can vanish in between.
65
+ */
66
+ protected requirePrerequisites(): Promise<void>;
67
+ /**
68
+ * Default mapping of a raw CLI failure onto the protocol surface. The
69
+ * protocol has no read/remove-specific code, so WRITE_FAILED — the generic
70
+ * "sink operation failed" surface, and retriable, which fits how most of
71
+ * these failures clear (expired session, unreachable host) — is the default
72
+ * for every operation. Adapters override this where their tool argues for
73
+ * something sharper.
74
+ *
75
+ * Provider stderr stays in details only: it is diagnostics, and no
76
+ * user-facing message should repeat output we do not control.
77
+ */
78
+ protected sinkFailure(operation: CliSinkOperation, error: unknown, key?: string): SepError;
79
+ private firstMissingCommand;
80
+ private unavailableError;
81
+ /**
82
+ * Body of every operation until an implementing agent overrides it. The
83
+ * parenthetical stays even after the other operations land: a forgotten
84
+ * override must announce itself, not masquerade as a missing CLI.
85
+ */
86
+ private stubFailure;
87
+ }
88
+ //# sourceMappingURL=cli-sink-base.d.ts.map
@@ -0,0 +1,217 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { SepError } from '@envseal/protocol';
3
+ /**
4
+ * Machinery shared by sinks that store secrets through an external provider
5
+ * CLI (vault, op, doppler, sops). The base owns what must behave identically
6
+ * across all four — presence probing, exec with timeout and stderr capture,
7
+ * exit-code-to-SepError mapping — so each adapter only translates its tool's
8
+ * verbs and documented exit codes.
9
+ *
10
+ * Posture inherited by every subclass:
11
+ * - values travel through stdin (the `input` option), never argv, where any
12
+ * process listing can read them;
13
+ * - no path here ever logs, wraps, or stringifies a value;
14
+ * - read() reports absence as null and remove() as false — via each tool's
15
+ * documented "not found" exit code, which only the adapter knows — while
16
+ * every other failure is a loud SepError; silence is reserved for genuine
17
+ * absence;
18
+ * - a missing provider CLI degrades available() to false and never throws at
19
+ * import or construction time.
20
+ *
21
+ * No per-platform hook surface ships here on purpose: none of the four CLIs
22
+ * needs platform-specific invocation today, and when one does, branch on
23
+ * process.platform inside the adapter exactly as keychain.ts does.
24
+ */
25
+ /**
26
+ * A provider CLI that sits silent is usually sitting on an interactive login
27
+ * or passphrase prompt this non-interactive pipe can never answer. Kill it
28
+ * rather than holding the broker open forever.
29
+ */
30
+ const DEFAULT_TIMEOUT_MS = 15_000;
31
+ /**
32
+ * A provider CLI ran and refused: nonzero exit, spawn failure, or timeout.
33
+ * The exit code rides on the error because several tools document specific
34
+ * codes as "item absent" — the adapter, not this base, decides which codes
35
+ * mean absence for read()/remove() and which are real failures.
36
+ */
37
+ export class CliCommandFailure extends Error {
38
+ command;
39
+ exitCode;
40
+ stderr;
41
+ constructor(message, command, exitCode, stderr) {
42
+ super(message);
43
+ this.command = command;
44
+ this.exitCode = exitCode;
45
+ this.stderr = stderr;
46
+ this.name = 'CliCommandFailure';
47
+ }
48
+ }
49
+ export function exitCodeOf(error) {
50
+ return error instanceof CliCommandFailure
51
+ ? error.exitCode
52
+ : error?.exitCode;
53
+ }
54
+ /** First stderr line, truncated: enough to diagnose, small enough for a dialog. */
55
+ function firstStderrLine(stderr) {
56
+ const first = stderr.trimStart().split(/\r?\n/, 1)[0];
57
+ if (first === undefined || first.length === 0)
58
+ return '';
59
+ return first.length > 200 ? `${first.slice(0, 197)}...` : first;
60
+ }
61
+ /** Same probe keychain.ts uses; `where`/`which` exit nonzero when absent. */
62
+ export function commandExists(command) {
63
+ const checker = process.platform === 'win32' ? 'where' : 'which';
64
+ return new Promise((resolve) => {
65
+ const proc = spawn(checker, [command], { shell: false, stdio: 'ignore' });
66
+ proc.on('close', (code) => resolve(code === 0));
67
+ proc.on('error', () => resolve(false));
68
+ });
69
+ }
70
+ export function execCli(command, args, options = {}) {
71
+ return new Promise((resolve, reject) => {
72
+ let settled = false;
73
+ let stdout = '';
74
+ let stderr = '';
75
+ const proc = spawn(command, args, {
76
+ shell: false,
77
+ stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
78
+ ...(options.env ? { env: options.env } : {}),
79
+ });
80
+ const beginSettle = () => {
81
+ if (settled)
82
+ return false;
83
+ settled = true;
84
+ clearTimeout(timer);
85
+ return true;
86
+ };
87
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
88
+ const timer = setTimeout(() => {
89
+ // Settle BEFORE killing: the killed child's 'close' must never overwrite
90
+ // the timeout verdict with a spurious nonzero-exit error.
91
+ if (beginSettle()) {
92
+ reject(new CliCommandFailure(`${command} produced no output within ${timeoutMs}ms — it is likely waiting on an interactive prompt this channel cannot answer`, command, undefined, stderr));
93
+ }
94
+ proc.kill();
95
+ }, timeoutMs);
96
+ if (proc.stdout) {
97
+ proc.stdout.on('data', (chunk) => {
98
+ stdout += chunk.toString();
99
+ });
100
+ }
101
+ if (proc.stderr) {
102
+ proc.stderr.on('data', (chunk) => {
103
+ stderr += chunk.toString();
104
+ });
105
+ }
106
+ proc.on('error', (err) => {
107
+ if (beginSettle())
108
+ reject(err);
109
+ });
110
+ proc.on('close', (code) => {
111
+ if (!beginSettle())
112
+ return;
113
+ if (code === 0) {
114
+ resolve({ stdout, stderr });
115
+ return;
116
+ }
117
+ // Callers distinguish "item absent" (a documented exit code per tool)
118
+ // from real failures, so the code rides on the error itself.
119
+ const line = firstStderrLine(stderr);
120
+ reject(new CliCommandFailure(line ? `${command} exited with code ${code}: ${line}` : `${command} exited with code ${code}`, command, code ?? undefined, stderr));
121
+ });
122
+ if (proc.stdin && options.input !== undefined) {
123
+ // A child that dies mid-read raises EPIPE on our stdin end; swallow it —
124
+ // the real verdict arrives via 'close' or 'error'.
125
+ proc.stdin.on('error', () => { });
126
+ proc.stdin.write(options.input);
127
+ proc.stdin.end();
128
+ }
129
+ });
130
+ }
131
+ export class CliSinkBase {
132
+ async available(_paths) {
133
+ return (await this.firstMissingCommand()) === null;
134
+ }
135
+ async read(_paths, _key) {
136
+ throw this.stubFailure('read');
137
+ }
138
+ async write(_paths, _key, _value) {
139
+ throw this.stubFailure('write');
140
+ }
141
+ async remove(_paths, _key) {
142
+ throw this.stubFailure('remove');
143
+ }
144
+ /**
145
+ * Probe requiredCommands and turn a miss into SEP_SINK_UNAVAILABLE. Every
146
+ * real operation starts here: available() may have been consulted long
147
+ * before, and the CLI can vanish in between.
148
+ */
149
+ async requirePrerequisites() {
150
+ if ((await this.firstMissingCommand()) !== null)
151
+ throw this.unavailableError();
152
+ }
153
+ /**
154
+ * Default mapping of a raw CLI failure onto the protocol surface. The
155
+ * protocol has no read/remove-specific code, so WRITE_FAILED — the generic
156
+ * "sink operation failed" surface, and retriable, which fits how most of
157
+ * these failures clear (expired session, unreachable host) — is the default
158
+ * for every operation. Adapters override this where their tool argues for
159
+ * something sharper.
160
+ *
161
+ * Provider stderr stays in details only: it is diagnostics, and no
162
+ * user-facing message should repeat output we do not control.
163
+ */
164
+ sinkFailure(operation, error, key) {
165
+ if (error instanceof SepError)
166
+ return error;
167
+ const target = key === undefined ? '' : ` ${key}`;
168
+ // The binary vanished between the prerequisite probe and the spawn; that
169
+ // is unavailability, not a failed operation.
170
+ if (error?.code === 'ENOENT') {
171
+ return this.unavailableError();
172
+ }
173
+ if (error instanceof CliCommandFailure) {
174
+ return new SepError({
175
+ code: 'SEP_SINK_WRITE_FAILED',
176
+ userMessage: `Could not ${operation}${target} via ${this.id}: ${error.command} failed with exit code ${error.exitCode ?? 'unknown'}.`,
177
+ details: {
178
+ sink: this.id,
179
+ operation,
180
+ command: error.command,
181
+ exitCode: error.exitCode ?? null,
182
+ stderr: error.stderr.slice(0, 400),
183
+ },
184
+ });
185
+ }
186
+ return new SepError({
187
+ code: 'SEP_SINK_WRITE_FAILED',
188
+ userMessage: `Could not ${operation}${target} via ${this.id}: unexpected error.`,
189
+ details: { sink: this.id, operation },
190
+ });
191
+ }
192
+ async firstMissingCommand() {
193
+ for (const command of this.requiredCommands) {
194
+ if (!(await commandExists(command)))
195
+ return command;
196
+ }
197
+ return null;
198
+ }
199
+ unavailableError() {
200
+ return new SepError({
201
+ code: 'SEP_SINK_UNAVAILABLE',
202
+ userMessage: `The ${this.id} sink is not available — ${this.unavailableReason()}.`,
203
+ });
204
+ }
205
+ /**
206
+ * Body of every operation until an implementing agent overrides it. The
207
+ * parenthetical stays even after the other operations land: a forgotten
208
+ * override must announce itself, not masquerade as a missing CLI.
209
+ */
210
+ stubFailure(operation) {
211
+ return new SepError({
212
+ code: 'SEP_SINK_UNAVAILABLE',
213
+ userMessage: `The ${this.id} sink is not available — ${this.unavailableReason()} (${operation} not implemented yet).`,
214
+ });
215
+ }
216
+ }
217
+ //# sourceMappingURL=cli-sink-base.js.map
@@ -0,0 +1,45 @@
1
+ import type { SecretValue } from '@envseal/protocol';
2
+ import type { ProjectPaths } from '../paths.js';
3
+ import { CliSinkBase } from './cli-sink-base.js';
4
+ import type { CliExecOptions, CliExecResult } from './cli-sink-base.js';
5
+ /**
6
+ * True when a doppler credential exists: DOPPLER_TOKEN set, or a config file
7
+ * on disk from a previous `doppler configure` (~/.doppler/.doppler.json, the
8
+ * documented location). homedir() is resolved per call — os.homedir() honors
9
+ * HOME/USERPROFILE, which lets tests isolate the file branch. The check also
10
+ * degrades safely: a wrong guess about the path yields false (with an
11
+ * unavailable message that names DOPPLER_TOKEN), never a false "ready".
12
+ */
13
+ export declare function dopplerCredentialConfigured(): boolean;
14
+ export declare class DopplerSink extends CliSinkBase {
15
+ readonly id = "doppler";
16
+ protected readonly requiredCommands: string[];
17
+ protected unavailableReason(): string;
18
+ /**
19
+ * Single choke point for every spawn this sink makes. Production resolves
20
+ * `doppler` off PATH; tests substitute a fake provider here instead of
21
+ * staging one on PATH, because Windows cannot spawn shebang scripts and
22
+ * modern Node refuses .cmd shims outright (CVE-2024-27980) — a PATH-only
23
+ * double would leave the parsing/error-mapping logic unexercised there
24
+ * (same trade as vault.ts).
25
+ */
26
+ protected run(args: readonly string[], options?: CliExecOptions): Promise<CliExecResult>;
27
+ /**
28
+ * Doppler on PATH AND a credential to talk to. The base probe deliberately
29
+ * checks binaries only — credential configuration is validated here and at
30
+ * operation time, where its absence can produce a precise message.
31
+ */
32
+ available(_paths: ProjectPaths): Promise<boolean>;
33
+ read(paths: ProjectPaths, key: string): Promise<SecretValue | null>;
34
+ write(paths: ProjectPaths, key: string, value: SecretValue): Promise<void>;
35
+ remove(paths: ProjectPaths, key: string): Promise<boolean>;
36
+ /**
37
+ * Every operation re-probes the CLI — available() may have been consulted
38
+ * long before, and the binary can vanish in between — then demands a
39
+ * credential separately, whose absence gets its own precise message instead
40
+ * of hiding behind the generic reason.
41
+ */
42
+ private requireReady;
43
+ }
44
+ export declare const dopplerSink: DopplerSink;
45
+ //# sourceMappingURL=doppler.d.ts.map