@envseal/core 0.1.4 → 0.1.6

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/dist/audit.d.ts CHANGED
@@ -30,12 +30,86 @@ export type AuditEvent = {
30
30
  type: 'blocked';
31
31
  reason: string;
32
32
  detail: string;
33
+ }
34
+ /**
35
+ * One env_use execution attempt. Written after consent succeeds,
36
+ * immediately before spawn — a crash mid-run still leaves the attempt
37
+ * recorded; denied consent records nothing. The command is persisted only
38
+ * after passing through the redaction engine.
39
+ */
40
+ | {
41
+ type: 'use';
42
+ command: string;
43
+ keys: string[];
44
+ networkEgress: boolean;
45
+ targetHashes?: Record<string, string>;
46
+ }
47
+ /** How the attempt ended; paired with the preceding 'use' record. */
48
+ | {
49
+ type: 'use_result';
50
+ exitCode: number | null;
51
+ signal: string | null;
52
+ durationMs: number;
33
53
  };
34
54
  export type AuditRecord = AuditEvent & {
35
55
  at: string;
36
56
  v: number;
37
57
  };
58
+ /**
59
+ * Chain fields. `seq` starts at 1; `prev` is the sha256 hex of the previous
60
+ * RAW line (genesis = 64 zeros), so any edit to a surviving record — or its
61
+ * deletion, which surfaces as a seq gap — breaks every successor's
62
+ * attestation and is reported by verifyAuditChain(). The chain proves the
63
+ * records that survive are intact and ordered; nothing outside the log
64
+ * records how many records should exist, so tail truncation is NOT
65
+ * detectable and is documented as an honest boundary rather than hidden.
66
+ */
67
+ export declare const GENESIS_PREV: string;
38
68
  export declare function appendAudit(paths: ProjectPaths, event: AuditEvent): void;
69
+ /** Mirror file for one project root. The key is sha256 of the project's
70
+ * canonical path, so the same directory hashes to one mirror file no matter
71
+ * how the root was spelled — Windows 8.3 short names, symlinks, differing
72
+ * case, or a trailing separator all collapse to the same identity.
73
+ * realpathSync.native is the only core API that expands 8.3 short names
74
+ * (plain realpath does not); it falls back to realpath then to the resolved
75
+ * spelling when the filesystem or a torn-down project refuses it. */
76
+ export declare function auditMirrorPath(root: string): string;
77
+ /** Raw non-empty lines of the project's mirror, or null when there is none. */
78
+ export declare function readMirrorLines(root: string): string[] | null;
79
+ export interface MirrorComparison {
80
+ mirrorPresent: boolean;
81
+ mirrorRecords: number;
82
+ projectRecords: number;
83
+ /** The mirror holds records the project log no longer does. */
84
+ tailTruncated: boolean;
85
+ }
86
+ /**
87
+ * Compare the project log's raw bytes against its mirror.
88
+ *
89
+ * Truncation is chain-anchored, not count-based: the mirror's record with
90
+ * seq = projectMaxSeq + 1 must attest exactly the project's last surviving
91
+ * line (prev == sha256 of that raw line) for an alarm to fire. That is
92
+ * cryptographic proof those records existed and were deleted after mirroring.
93
+ * A legitimate full reset (project log restarted at seq 1) does not false-positive:
94
+ * its successor in the mirror was written before the reset, so its prev chain
95
+ * cannot match the new log's lines. An empty project log beside a non-empty
96
+ * mirror is always loss — records demonstrably existed for this project.
97
+ */
98
+ export declare function compareWithMirror(projectRaw: string, mirrorLines: string[] | null): MirrorComparison;
99
+ /**
100
+ * Verify the hash chain over the raw lines of an audit log.
101
+ *
102
+ * Blame semantics: a deleted or wrongly-numbered record is reported at its own
103
+ * expected position; a record whose CONTENT was edited in place keeps a valid
104
+ * seq, so the break surfaces through its successor's chained hash — it is
105
+ * blamed on the tampered record itself (brokenAt = k, detected at k+1).
106
+ * Tail truncation verifies OK by design — see the chain-field comment above.
107
+ */
108
+ export declare function verifyAuditChain(raw: string): {
109
+ ok: boolean;
110
+ brokenAt?: number;
111
+ count: number;
112
+ };
39
113
  export declare function readAudit(paths: ProjectPaths): Array<AuditEvent & {
40
114
  at: string;
41
115
  }>;
package/dist/audit.js CHANGED
@@ -1,12 +1,240 @@
1
- import { appendFileSync, readFileSync, chmodSync } from 'node:fs';
1
+ import { appendFileSync, chmodSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
2
5
  import { ensureStateDir } from './paths.js';
6
+ /**
7
+ * Chain fields. `seq` starts at 1; `prev` is the sha256 hex of the previous
8
+ * RAW line (genesis = 64 zeros), so any edit to a surviving record — or its
9
+ * deletion, which surfaces as a seq gap — breaks every successor's
10
+ * attestation and is reported by verifyAuditChain(). The chain proves the
11
+ * records that survive are intact and ordered; nothing outside the log
12
+ * records how many records should exist, so tail truncation is NOT
13
+ * detectable and is documented as an honest boundary rather than hidden.
14
+ */
15
+ export const GENESIS_PREV = '0'.repeat(64);
3
16
  const isPosix = process.platform !== 'win32';
17
+ /** sha256 hex of one raw log line (no trailing newline). */
18
+ function lineHash(line) {
19
+ return createHash('sha256').update(line, 'utf8').digest('hex');
20
+ }
21
+ /** Last non-empty raw line of the audit file, or null when absent/empty. */
22
+ function lastRawLine(auditPath) {
23
+ let raw;
24
+ try {
25
+ raw = readFileSync(auditPath, 'utf8');
26
+ }
27
+ catch (error) {
28
+ if (error.code === 'ENOENT')
29
+ return null;
30
+ throw error;
31
+ }
32
+ const lines = raw.split(/\r?\n/).filter((line) => line.length > 0);
33
+ return lines.length > 0 ? (lines[lines.length - 1] ?? null) : null;
34
+ }
35
+ function lastSeqOf(line) {
36
+ try {
37
+ const parsed = JSON.parse(line);
38
+ return typeof parsed.seq === 'number' && Number.isInteger(parsed.seq) && parsed.seq > 0 ? parsed.seq : 0;
39
+ }
40
+ catch {
41
+ return 0;
42
+ }
43
+ }
4
44
  export function appendAudit(paths, event) {
5
45
  ensureStateDir(paths);
6
- const record = { ...event, at: new Date().toISOString(), v: 1 };
7
- appendFileSync(paths.audit, `${JSON.stringify(record)}\n`, { mode: 0o600 });
46
+ const prevLine = lastRawLine(paths.audit);
47
+ // A corrupt trailing line reads as seq 0, so the next record restarts at 1.
48
+ // That is the honest reading of a log whose head of chain is unverifiable.
49
+ const prev = prevLine === null ? GENESIS_PREV : lineHash(prevLine);
50
+ const seq = prevLine === null ? 1 : lastSeqOf(prevLine) + 1;
51
+ const record = { ...event, at: new Date().toISOString(), v: 1, seq, prev };
52
+ const line = `${JSON.stringify(record)}\n`;
53
+ appendFileSync(paths.audit, line, { mode: 0o600 });
8
54
  if (isPosix)
9
55
  chmodSync(paths.audit, 0o600);
56
+ mirrorAuditLine(paths.root, line);
57
+ }
58
+ // --- Out-of-band mirror -----------------------------------------------------
59
+ //
60
+ // Residual risk §10: the project audit log is agent-writable and tail
61
+ // truncation is undetectable from the log alone. The mirror streams the same
62
+ // records to a second file under the user's own ~/.envseal, outside the
63
+ // project the agent operates in, so `envseal audit --verify` can detect
64
+ // records the project log lost. It raises the bar; it is not immutability —
65
+ // a same-uid attacker can tamper both copies, and the docs say so.
66
+ let mirrorWarned = false;
67
+ /** Directory holding per-project mirrors. ENVSEAL_AUDIT_MIRROR=0 opts out;
68
+ * a path value redirects the mirror (e.g. at a synced folder so records also
69
+ * leave the machine); unset defaults to ~/.envseal/mirrors. */
70
+ function mirrorsDir() {
71
+ const setting = process.env.ENVSEAL_AUDIT_MIRROR;
72
+ if (setting !== undefined && setting !== '0' && setting.trim() !== '') {
73
+ return setting;
74
+ }
75
+ return join(homedir(), '.envseal', 'mirrors');
76
+ }
77
+ /** Mirror file for one project root. The key is sha256 of the project's
78
+ * canonical path, so the same directory hashes to one mirror file no matter
79
+ * how the root was spelled — Windows 8.3 short names, symlinks, differing
80
+ * case, or a trailing separator all collapse to the same identity.
81
+ * realpathSync.native is the only core API that expands 8.3 short names
82
+ * (plain realpath does not); it falls back to realpath then to the resolved
83
+ * spelling when the filesystem or a torn-down project refuses it. */
84
+ export function auditMirrorPath(root) {
85
+ const resolved = resolve(root);
86
+ let canonical = resolved;
87
+ try {
88
+ canonical = realpathSync.native(resolved);
89
+ }
90
+ catch {
91
+ try {
92
+ canonical = realpathSync(resolved);
93
+ }
94
+ catch {
95
+ // Nonexistent root: the resolved spelling is the best identity available.
96
+ }
97
+ }
98
+ const key = createHash('sha256').update(resolvePosix(canonical), 'utf8').digest('hex');
99
+ return join(mirrorsDir(), `${key}.jsonl`);
100
+ }
101
+ function resolvePosix(p) {
102
+ return p.replace(/\\/g, '/');
103
+ }
104
+ /**
105
+ * Best-effort append of one already-serialized audit line to the project's
106
+ * mirror. Default ON; ENVSEAL_AUDIT_MIRROR=0 opts out. A mirror failure never
107
+ * blocks provisioning and never throws: one fixed stderr warning per process,
108
+ * no path and no error detail (a path or error could carry secret-shaped text).
109
+ */
110
+ function mirrorAuditLine(root, line) {
111
+ if (process.env.ENVSEAL_AUDIT_MIRROR === '0') {
112
+ return;
113
+ }
114
+ try {
115
+ const mirror = auditMirrorPath(root);
116
+ mkdirSync(dirname(mirror), { recursive: true, mode: 0o700 });
117
+ appendFileSync(mirror, line, { mode: 0o600 });
118
+ if (isPosix)
119
+ chmodSync(mirror, 0o600);
120
+ }
121
+ catch {
122
+ if (!mirrorWarned) {
123
+ mirrorWarned = true;
124
+ process.stderr.write('envseal: audit mirror unavailable — continuing without it\n');
125
+ }
126
+ }
127
+ }
128
+ /** Raw non-empty lines of the project's mirror, or null when there is none. */
129
+ export function readMirrorLines(root) {
130
+ try {
131
+ const raw = readFileSync(auditMirrorPath(root), 'utf8');
132
+ return raw.split(/\r?\n/).filter((line) => line.length > 0);
133
+ }
134
+ catch (error) {
135
+ if (error.code === 'ENOENT')
136
+ return null;
137
+ return null;
138
+ }
139
+ }
140
+ function parseSeqs(lines) {
141
+ const out = [];
142
+ for (const line of lines) {
143
+ try {
144
+ const parsed = JSON.parse(line);
145
+ if (typeof parsed.seq === 'number' &&
146
+ Number.isInteger(parsed.seq) &&
147
+ parsed.seq > 0 &&
148
+ typeof parsed.prev === 'string') {
149
+ out.push({ seq: parsed.seq, line, prev: parsed.prev });
150
+ }
151
+ }
152
+ catch {
153
+ // Foreign/corrupt mirror lines carry no attestation and are skipped.
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+ /**
159
+ * Compare the project log's raw bytes against its mirror.
160
+ *
161
+ * Truncation is chain-anchored, not count-based: the mirror's record with
162
+ * seq = projectMaxSeq + 1 must attest exactly the project's last surviving
163
+ * line (prev == sha256 of that raw line) for an alarm to fire. That is
164
+ * cryptographic proof those records existed and were deleted after mirroring.
165
+ * A legitimate full reset (project log restarted at seq 1) does not false-positive:
166
+ * its successor in the mirror was written before the reset, so its prev chain
167
+ * cannot match the new log's lines. An empty project log beside a non-empty
168
+ * mirror is always loss — records demonstrably existed for this project.
169
+ */
170
+ export function compareWithMirror(projectRaw, mirrorLines) {
171
+ const projectLines = projectRaw
172
+ .split(/\r?\n/)
173
+ .map((line) => line.replace(/\r$/, ''))
174
+ .filter((line) => line.length > 0);
175
+ if (mirrorLines === null) {
176
+ return { mirrorPresent: false, mirrorRecords: 0, projectRecords: projectLines.length, tailTruncated: false };
177
+ }
178
+ const project = parseSeqs(projectLines);
179
+ const mirror = parseSeqs(mirrorLines);
180
+ let tailTruncated = false;
181
+ if (project.length === 0) {
182
+ tailTruncated = mirror.length > 0;
183
+ }
184
+ else {
185
+ const projectMax = project[project.length - 1].seq;
186
+ const successor = mirror.find((m) => m.seq === projectMax + 1);
187
+ if (successor !== undefined && successor.prev === lineHash(project[project.length - 1].line)) {
188
+ tailTruncated = true;
189
+ }
190
+ }
191
+ return {
192
+ mirrorPresent: true,
193
+ mirrorRecords: mirror.length,
194
+ projectRecords: project.length,
195
+ tailTruncated,
196
+ };
197
+ }
198
+ /**
199
+ * Verify the hash chain over the raw lines of an audit log.
200
+ *
201
+ * Blame semantics: a deleted or wrongly-numbered record is reported at its own
202
+ * expected position; a record whose CONTENT was edited in place keeps a valid
203
+ * seq, so the break surfaces through its successor's chained hash — it is
204
+ * blamed on the tampered record itself (brokenAt = k, detected at k+1).
205
+ * Tail truncation verifies OK by design — see the chain-field comment above.
206
+ */
207
+ export function verifyAuditChain(raw) {
208
+ const lines = raw
209
+ .split(/\r?\n/)
210
+ .map((line) => line.replace(/\r$/, ''))
211
+ .filter((line) => line.length > 0);
212
+ let expectedSeq = 1;
213
+ let prevHash = GENESIS_PREV;
214
+ for (const line of lines) {
215
+ let parsed;
216
+ try {
217
+ parsed = JSON.parse(line);
218
+ }
219
+ catch {
220
+ // Unparseable line at this position IS the corruption.
221
+ return { ok: false, brokenAt: expectedSeq, count: expectedSeq - 1 };
222
+ }
223
+ if (parsed.seq !== expectedSeq) {
224
+ // Missing, duplicated, or renumbered record: blame the position itself.
225
+ return { ok: false, brokenAt: expectedSeq, count: expectedSeq - 1 };
226
+ }
227
+ if (parsed.prev !== prevHash) {
228
+ // This record's attestation over its predecessor fails, so the
229
+ // PREDECESSOR's bytes were changed after the fact. First record with a
230
+ // non-genesis prev means the log head itself was tampered with.
231
+ const blamed = Math.max(1, expectedSeq - 1);
232
+ return { ok: false, brokenAt: blamed, count: expectedSeq - 1 };
233
+ }
234
+ prevHash = lineHash(line);
235
+ expectedSeq += 1;
236
+ }
237
+ return { ok: true, count: expectedSeq - 1 };
10
238
  }
11
239
  export function readAudit(paths) {
12
240
  let raw;
package/dist/broker.js CHANGED
@@ -12,6 +12,7 @@ import { verifyKey } from './verify.js';
12
12
  import { runWithSecrets } from './exec.js';
13
13
  import { getSink } from './sinks/registry.js';
14
14
  import { getValidation, recordValidation } from './validation-state.js';
15
+ import { loadRotationState, recordRotation } from './rotation-state.js';
15
16
  import { scanText, secretInRequestError } from './guard.js';
16
17
  function getLengthBucket(length) {
17
18
  if (length < 8)
@@ -71,6 +72,10 @@ export class Broker {
71
72
  const presence = await resolvePresence(this.paths, manifest.entries.map((e) => e.key), { sinks: new Map(manifest.entries.map((e) => [e.key, e.sink ?? 'dotenv'])) });
72
73
  const entries = [];
73
74
  const missingRequired = [];
75
+ // Age markers for rotation reporting, loaded once and stamped lazily here:
76
+ // hand-written .env values never cross an envseal write path, so first
77
+ // sight in describe() is the only hook that sees every stored value.
78
+ const rotationState = loadRotationState(this.paths);
74
79
  for (const entry of manifest.entries) {
75
80
  const presenceInfo = presence.get(entry.key);
76
81
  const present = presenceInfo?.present ?? false;
@@ -97,6 +102,21 @@ export class Broker {
97
102
  }
98
103
  }
99
104
  }
105
+ // rotationDue = first observation of THESE bytes + the declared
106
+ // maxAgeDays. Absent policy, absent value, or unknown age all report
107
+ // null: "no advice" must stay distinguishable from "overdue".
108
+ let rotationDue = null;
109
+ if (present && value) {
110
+ const maxAgeDays = entry.rotation?.maxAgeDays;
111
+ let ageRecord = rotationState[entry.key];
112
+ if (!ageRecord || ageRecord.fingerprint !== fingerprint) {
113
+ ageRecord = recordRotation(this.paths, entry.key, fingerprint);
114
+ rotationState[entry.key] = ageRecord;
115
+ }
116
+ if (maxAgeDays !== undefined) {
117
+ rotationDue = new Date(Date.parse(ageRecord.at) + maxAgeDays * 24 * 60 * 60 * 1000).toISOString();
118
+ }
119
+ }
100
120
  const status = {
101
121
  key: entry.key,
102
122
  declared: true,
@@ -108,7 +128,7 @@ export class Broker {
108
128
  lastVerified: null,
109
129
  verifyResult: null,
110
130
  source: 'user-prompt',
111
- rotationDue: null,
131
+ rotationDue,
112
132
  };
113
133
  entries.push(status);
114
134
  if (entry.required && !present) {
@@ -150,7 +170,14 @@ export class Broker {
150
170
  verify: entry.verify ?? key.verify,
151
171
  };
152
172
  });
153
- return declareEntries(this.paths, withDefaults);
173
+ const result = declareEntries(this.paths, withDefaults);
174
+ // The declaration itself is audited: a model reshaping the manifest is
175
+ // exactly the activity the log exists to answer for later.
176
+ appendAudit(this.paths, {
177
+ type: 'declare',
178
+ keys: withDefaults.map((entry) => entry.key),
179
+ });
180
+ return result;
154
181
  }
155
182
  async request(input) {
156
183
  // Before the manifest is even read: `reason` is free text that goes
@@ -381,6 +408,9 @@ export class Broker {
381
408
  const result = await verifyKey(this.paths, entry, value, {
382
409
  onApprovalNeeded: this.onApprovalNeeded,
383
410
  });
411
+ // Probe outcomes are audited: a verify against an attacker-approved
412
+ // host is a forensic event even when the probe itself leaks nothing.
413
+ appendAudit(this.paths, { type: 'verify', key: keyName, result: result.result });
384
414
  results.push(result);
385
415
  zero(value);
386
416
  }
@@ -415,6 +445,12 @@ export class Broker {
415
445
  }
416
446
  const result = await runWithSecrets(input.command, secrets, {
417
447
  onConfirm: this.onConfirm,
448
+ // The project's standing egress rule: allowlist mode refuses
449
+ // non-allowlisted network targets before any dialog opens.
450
+ egressPolicy: manifest.policy?.egress,
451
+ // Execution auditing (use / use_result) is wired here so the product
452
+ // path records every attempt in the chained audit log.
453
+ auditPaths: this.paths,
418
454
  });
419
455
  for (const value of secrets.values()) {
420
456
  zero(value);
package/dist/display.d.ts CHANGED
@@ -1,11 +1,3 @@
1
- /**
2
- * Shared rendering for the human-consent dialogs across every binding.
3
- *
4
- * This used to live twice — hand-maintained twins in @envseal/mcp-server and
5
- * @envseal/sdk — and a third, weaker copy rolled its own in the CLI's `run`
6
- * confirmation. One copy here feeds all three: cli, sdk and mcp-server all
7
- * already depend on @envseal/core, so this adds no dependency edge.
8
- */
9
1
  /**
10
2
  * Model-supplied argv, key names and probe metadata land in a dialog the user
11
3
  * is about to trust. Control characters let a crafted argument forge extra
@@ -19,13 +11,22 @@ export declare function displayArg(arg: string): string;
19
11
  /**
20
12
  * The full `env_use` approval dialog: project, keys, argv one-per-line,
21
13
  * content fingerprints of every named file (see exec.ts target hashing),
22
- * the egress warning or the honest heuristics disclaimer, and the answer
23
- * format. Every binding renders exactly this.
14
+ * the egress warning plus the extracted network targets, or the honest
15
+ * heuristics disclaimer, and the answer format. Every binding renders
16
+ * exactly this.
24
17
  */
25
18
  export declare function useConfirmationBody(info: {
26
19
  command: string[];
27
20
  keys: string[];
28
21
  networkEgress: boolean;
22
+ /**
23
+ * Where the command could reach the network, as extracted by
24
+ * extractEgressHosts (exec.ts computes it either way — this only decides
25
+ * whether the dialog names the destinations). Optional so older callers
26
+ * keep rendering; when present alongside networkEgress the user sees
27
+ * WHERE data could go, not merely THAT it could leave.
28
+ */
29
+ egressHosts?: string[];
29
30
  target?: import('./exec.js').TargetInfo;
30
31
  }, projectRoot: string): string;
31
32
  /**
package/dist/display.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * confirmation. One copy here feeds all three: cli, sdk and mcp-server all
7
7
  * already depend on @envseal/core, so this adds no dependency edge.
8
8
  */
9
+ import { UNKNOWN_HOST } from './egress.js';
9
10
  /** Per-argument display cap; longer arguments are shown truncated, and said to be. */
10
11
  const MAX_ARG_CHARS = 300;
11
12
  /**
@@ -43,8 +44,9 @@ export function displayArg(arg) {
43
44
  /**
44
45
  * The full `env_use` approval dialog: project, keys, argv one-per-line,
45
46
  * content fingerprints of every named file (see exec.ts target hashing),
46
- * the egress warning or the honest heuristics disclaimer, and the answer
47
- * format. Every binding renders exactly this.
47
+ * the egress warning plus the extracted network targets, or the honest
48
+ * heuristics disclaimer, and the answer format. Every binding renders
49
+ * exactly this.
48
50
  */
49
51
  export function useConfirmationBody(info, projectRoot) {
50
52
  const lines = [
@@ -85,6 +87,17 @@ export function useConfirmationBody(info, projectRoot) {
85
87
  }
86
88
  if (info.networkEgress) {
87
89
  lines.push(' WARNING: this command can reach the network, so it could send these', ' values somewhere. Only continue if you trust it.');
90
+ if (info.egressHosts !== undefined && info.egressHosts.length > 0) {
91
+ // Name the destinations, not just the capability. A host that could
92
+ // not be determined (bare IP, encoded target — UNKNOWN_HOST from
93
+ // core/egress.ts) is said so plainly rather than silently dropped:
94
+ // an undeterminable destination is exactly what exfiltration looks
95
+ // like, and the dialog must not read as more specific than it is.
96
+ const shown = info.egressHosts.includes(UNKNOWN_HOST)
97
+ ? 'undetermined (bare IP or encoded)'
98
+ : info.egressHosts.map(escapeForDisplay).join(', ');
99
+ lines.push(` Network targets: ${shown}`);
100
+ }
88
101
  }
89
102
  else {
90
103
  // Honest about what the check is worth: NETWORK_TOOLS plus a URL scan is a
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Egress target extraction and allowlist matching for env_use.
3
+ *
4
+ * Single source of truth for "can this command reach the network, and where".
5
+ * exec.ts derives its boolean egress heuristic from extraction so the two can
6
+ * never disagree about whether a command is network-touching.
7
+ *
8
+ * The '(unknown)' sentinel marks a network command whose target cannot be
9
+ * determined (bare IPs, encoded hostnames, no target at all). It never
10
+ * matches an allowlist entry — under allowlist mode such a command is
11
+ * refused. That refusal is the feature, not a limitation: an undeterminable
12
+ * destination is exactly what an exfiltration attempt looks like.
13
+ */
14
+ export declare const UNKNOWN_HOST = "(unknown)";
15
+ export declare const NETWORK_TOOLS: Set<string>;
16
+ /**
17
+ * Extract the network destinations a command could reach.
18
+ *
19
+ * Returns hostnames in lowercase plus UNKNOWN_HOST markers; an empty result
20
+ * means the command cannot reach the network at all. Order is unspecified;
21
+ * callers must treat the set collectively.
22
+ */
23
+ export declare function extractEgressHosts(command: string[]): string[];
24
+ /**
25
+ * Anchored allowlist matching. '*.suffix' matches exactly one leading label
26
+ * plus '.suffix': api.openai.com yes, openai.com no, a.b.openai.com no,
27
+ * evil.openai.com.attacker.io no. Plain entries are exact (case-insensitive).
28
+ */
29
+ export declare function hostIsAllowed(host: string, allow: string[]): boolean;
30
+ //# sourceMappingURL=egress.d.ts.map
package/dist/egress.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Egress target extraction and allowlist matching for env_use.
3
+ *
4
+ * Single source of truth for "can this command reach the network, and where".
5
+ * exec.ts derives its boolean egress heuristic from extraction so the two can
6
+ * never disagree about whether a command is network-touching.
7
+ *
8
+ * The '(unknown)' sentinel marks a network command whose target cannot be
9
+ * determined (bare IPs, encoded hostnames, no target at all). It never
10
+ * matches an allowlist entry — under allowlist mode such a command is
11
+ * refused. That refusal is the feature, not a limitation: an undeterminable
12
+ * destination is exactly what an exfiltration attempt looks like.
13
+ */
14
+ export const UNKNOWN_HOST = '(unknown)';
15
+ export const NETWORK_TOOLS = new Set([
16
+ 'curl',
17
+ 'wget',
18
+ 'nc',
19
+ 'ncat',
20
+ 'netcat',
21
+ 'ssh',
22
+ 'scp',
23
+ 'rsync',
24
+ 'http',
25
+ 'httpie',
26
+ 'telnet',
27
+ 'socat',
28
+ ]);
29
+ /** Flags whose NEXT argument is a value, not the network target. */
30
+ const VALUE_TAKING_FLAGS = new Set([
31
+ '-o',
32
+ '--output',
33
+ '-k',
34
+ '--config',
35
+ '--cookie',
36
+ '-c',
37
+ '--cookie-jar',
38
+ '-H',
39
+ '--header',
40
+ '--data',
41
+ '--data-raw',
42
+ '--data-binary',
43
+ '--data-urlencode',
44
+ '-d',
45
+ '-F',
46
+ '--form',
47
+ '-u',
48
+ '--user',
49
+ '--proxy',
50
+ '-x',
51
+ '--pass',
52
+ '--key',
53
+ '--cacert',
54
+ '--capath',
55
+ ]);
56
+ /** Flags whose value IS a network target specification. */
57
+ const TARGET_FLAGS = new Set(['--url', '--connect-to', '--resolve']);
58
+ function basenameOf(head) {
59
+ return head.split(/[\\/]/).pop()?.toLowerCase() ?? '';
60
+ }
61
+ function looksLikeHostname(arg) {
62
+ if (!/^[a-z0-9][a-z0-9.-]*$/i.test(arg))
63
+ return false;
64
+ // A dotted quad has no letters — bare IPs stay undetermined by design.
65
+ if (!/[a-z]/i.test(arg) && arg !== 'localhost')
66
+ return false;
67
+ return arg.includes('.') || arg === 'localhost';
68
+ }
69
+ /**
70
+ * Extract the network destinations a command could reach.
71
+ *
72
+ * Returns hostnames in lowercase plus UNKNOWN_HOST markers; an empty result
73
+ * means the command cannot reach the network at all. Order is unspecified;
74
+ * callers must treat the set collectively.
75
+ */
76
+ export function extractEgressHosts(command) {
77
+ if (command.length === 0)
78
+ return [];
79
+ const hosts = new Set();
80
+ const isNetworkTool = NETWORK_TOOLS.has(basenameOf(command[0]));
81
+ for (let i = 1; i < command.length; i += 1) {
82
+ const arg = command[i];
83
+ if (/^https?:\/\//i.test(arg)) {
84
+ try {
85
+ const url = new URL(arg);
86
+ if (url.hostname.length > 0) {
87
+ hosts.add(url.hostname.toLowerCase());
88
+ }
89
+ else {
90
+ hosts.add(UNKNOWN_HOST);
91
+ }
92
+ }
93
+ catch {
94
+ // Malformed URL literal still proves network intent.
95
+ hosts.add(UNKNOWN_HOST);
96
+ }
97
+ continue;
98
+ }
99
+ if (isNetworkTool && TARGET_FLAGS.has(arg)) {
100
+ const value = command[i + 1];
101
+ if (value !== undefined) {
102
+ if (/^https?:\/\//i.test(value)) {
103
+ try {
104
+ const url = new URL(value);
105
+ if (url.hostname.length > 0)
106
+ hosts.add(url.hostname.toLowerCase());
107
+ else
108
+ hosts.add(UNKNOWN_HOST);
109
+ }
110
+ catch {
111
+ hosts.add(UNKNOWN_HOST);
112
+ }
113
+ }
114
+ else if (looksLikeHostname(value)) {
115
+ hosts.add(value.toLowerCase());
116
+ }
117
+ else {
118
+ hosts.add(UNKNOWN_HOST);
119
+ }
120
+ }
121
+ continue;
122
+ }
123
+ }
124
+ if (isNetworkTool) {
125
+ // Find the positional target: first operand that is neither a flag nor
126
+ // the consumed value of a value-taking flag.
127
+ let skipNext = false;
128
+ let positional;
129
+ for (let i = 1; i < command.length; i += 1) {
130
+ const arg = command[i];
131
+ if (skipNext) {
132
+ skipNext = false;
133
+ continue;
134
+ }
135
+ if (arg.startsWith('-')) {
136
+ if (VALUE_TAKING_FLAGS.has(arg))
137
+ skipNext = true;
138
+ continue;
139
+ }
140
+ positional = arg;
141
+ break;
142
+ }
143
+ const hasExplicitTarget = positional !== undefined ||
144
+ command.some((a) => /^https?:\/\//i.test(a)) ||
145
+ command.some((a) => TARGET_FLAGS.has(a));
146
+ if (!hasExplicitTarget) {
147
+ hosts.add(UNKNOWN_HOST);
148
+ }
149
+ else if (positional !== undefined) {
150
+ // A path (/x/y) or :port suffix does not make the host undeterminable —
151
+ // only strip them and judge the host component. Genuinely
152
+ // undeterminable targets (bare IPs, encoded junk) fail the hostname
153
+ // check and land in UNKNOWN_HOST by design.
154
+ const candidate = positional.split('/')[0].split(':')[0];
155
+ if (looksLikeHostname(candidate)) {
156
+ hosts.add(candidate.toLowerCase());
157
+ }
158
+ else {
159
+ hosts.add(UNKNOWN_HOST);
160
+ }
161
+ }
162
+ }
163
+ return Array.from(hosts);
164
+ }
165
+ /**
166
+ * Anchored allowlist matching. '*.suffix' matches exactly one leading label
167
+ * plus '.suffix': api.openai.com yes, openai.com no, a.b.openai.com no,
168
+ * evil.openai.com.attacker.io no. Plain entries are exact (case-insensitive).
169
+ */
170
+ export function hostIsAllowed(host, allow) {
171
+ const normalized = host.toLowerCase();
172
+ if (normalized === UNKNOWN_HOST)
173
+ return false;
174
+ for (const entry of allow) {
175
+ const e = entry.toLowerCase();
176
+ if (e.startsWith('*.')) {
177
+ const rest = e.slice(1); // ".openai.com"
178
+ if (normalized.endsWith(rest)) {
179
+ const prefix = normalized.slice(0, normalized.length - rest.length);
180
+ // Exactly one label before the suffix: no dots left of it.
181
+ if (prefix.length > 0 && !prefix.includes('.'))
182
+ return true;
183
+ }
184
+ }
185
+ else if (normalized === e) {
186
+ return true;
187
+ }
188
+ }
189
+ return false;
190
+ }
191
+ //# sourceMappingURL=egress.js.map
package/dist/exec.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SecretValue, ExecResult } from '@envseal/protocol';
2
+ import type { ProjectPaths } from './paths.js';
2
3
  /**
3
4
  * Residual risk on Linux: A same-uid process can read /proc/<pid>/environ
4
5
  * of the child process. This cannot be defended against without sandboxing.
@@ -48,9 +49,27 @@ export interface ExecOptions {
48
49
  command: string[];
49
50
  keys: string[];
50
51
  networkEgress: boolean;
52
+ /** Extracted network destinations; present whenever networkEgress is true. */
53
+ egressHosts: string[];
51
54
  target: TargetInfo;
52
55
  }) => Promise<boolean>;
53
56
  approvedCommands?: string[];
57
+ /**
58
+ * When provided, every execution is audited: a 'use' record lands after
59
+ * consent succeeds and immediately before spawn, and a 'use_result' when
60
+ * the child exits. Denied or refused consent records nothing.
61
+ */
62
+ auditPaths?: ProjectPaths;
63
+ /**
64
+ * Declarative egress restriction from the manifest (policy.egress).
65
+ * mode 'allowlist' refuses any network-touching command whose extracted
66
+ * hosts are not all allowed — BEFORE the consent dialog opens. Absent
67
+ * policy means 'warn': egress only adds a warning flag to the dialog.
68
+ */
69
+ egressPolicy?: {
70
+ mode: 'warn' | 'allowlist';
71
+ allow: string[];
72
+ };
54
73
  }
55
74
  export declare function runWithSecrets(command: string[], secrets: Map<string, SecretValue>, opts?: ExecOptions): Promise<ExecResult>;
56
75
  //# sourceMappingURL=exec.d.ts.map
package/dist/exec.js CHANGED
@@ -5,38 +5,10 @@ import { stat } from 'node:fs/promises';
5
5
  import { resolve as resolvePath } from 'node:path';
6
6
  import { SepError } from '@envseal/protocol';
7
7
  import { redact } from './redact.js';
8
+ import { appendAudit } from './audit.js';
9
+ import { extractEgressHosts, hostIsAllowed } from './egress.js';
8
10
  import { unsafeSecretToUtf8 } from './sinks/dotenv.js';
9
- const NETWORK_TOOLS = new Set([
10
- 'curl',
11
- 'wget',
12
- 'nc',
13
- 'ncat',
14
- 'netcat',
15
- 'ssh',
16
- 'scp',
17
- 'rsync',
18
- 'http',
19
- 'httpie',
20
- 'telnet',
21
- 'socat',
22
- ]);
23
- /** Nothing remotely path-shaped is longer than this on any supported OS. */
24
11
  const MAX_PATHISH_CHARS = 4096;
25
- function detectNetworkEgress(command) {
26
- if (command.length === 0) {
27
- return false;
28
- }
29
- const basename = command[0].split(/[\\/]/).pop()?.toLowerCase() ?? '';
30
- if (NETWORK_TOOLS.has(basename)) {
31
- return true;
32
- }
33
- for (const arg of command) {
34
- if (/^https?:\/\//.test(arg)) {
35
- return true;
36
- }
37
- }
38
- return false;
39
- }
40
12
  /**
41
13
  * T11 hardening: hash the files the command names, so approval binds to
42
14
  * content rather than to displayed text. Streaming read — a multi-gigabyte
@@ -138,7 +110,21 @@ export async function runWithSecrets(command, secrets, opts) {
138
110
  userMessage: 'Command cannot be empty',
139
111
  });
140
112
  }
141
- const networkEgress = detectNetworkEgress(command);
113
+ const egressHosts = extractEgressHosts(command);
114
+ const networkEgress = egressHosts.length > 0;
115
+ // Allowlist enforcement precedes every dialog: a policy refusal is not a
116
+ // question for the user to answer, it is the project's standing rule. An
117
+ // undetermined host ('(unknown)') never matches an allow entry, so bare-IP
118
+ // and encoded-target exfil is refused here by construction.
119
+ if (opts?.egressPolicy?.mode === 'allowlist') {
120
+ const denied = egressHosts.filter((host) => !hostIsAllowed(host, opts.egressPolicy.allow));
121
+ if (denied.length > 0) {
122
+ throw new SepError({
123
+ code: 'SEP_EGRESS_DENIED',
124
+ details: { hosts: denied },
125
+ });
126
+ }
127
+ }
142
128
  const secretKeys = Array.from(secrets.keys());
143
129
  const joinedCommand = command.join(' ');
144
130
  const isApproved = opts?.approvedCommands?.some((approved) => approved === joinedCommand);
@@ -155,6 +141,7 @@ export async function runWithSecrets(command, secrets, opts) {
155
141
  command,
156
142
  keys: secretKeys,
157
143
  networkEgress,
144
+ egressHosts,
158
145
  target: approvedSnapshot.info,
159
146
  });
160
147
  if (!confirmed) {
@@ -184,6 +171,27 @@ export async function runWithSecrets(command, secrets, opts) {
184
171
  redactionLabels.set(value, key);
185
172
  }
186
173
  const MAX_BUFFER = 1024 * 1024;
174
+ const startedAt = Date.now();
175
+ let exitSignal = null;
176
+ if (opts?.auditPaths) {
177
+ // What consent actually bound to: the content hashes of every named
178
+ // file, so the audit record stays meaningful even if the file is later
179
+ // rewritten or deleted.
180
+ const targetHashes = {};
181
+ for (const file of approvedSnapshot.info.hashedFiles) {
182
+ targetHashes[file.resolvedPath] = file.sha256;
183
+ }
184
+ // One filtered exit (PLAN.md principle 4): the persisted command goes
185
+ // through the same redaction engine as stdout/stderr, so a value the
186
+ // caller smuggled into argv cannot reach audit.jsonl either.
187
+ appendAudit(opts.auditPaths, {
188
+ type: 'use',
189
+ command: redact(joinedCommand, secretValues, redactionLabels).text,
190
+ keys: secretKeys,
191
+ networkEgress,
192
+ targetHashes,
193
+ });
194
+ }
187
195
  const proc = spawn(command[0], command.slice(1), {
188
196
  cwd: opts?.cwd,
189
197
  env: childEnv,
@@ -222,6 +230,14 @@ export async function runWithSecrets(command, secrets, opts) {
222
230
  const stderrStr = stderr.toString('utf8');
223
231
  const redactStdout = redact(stdoutStr, secretValues, redactionLabels);
224
232
  const redactStderr = redact(stderrStr, secretValues, redactionLabels);
233
+ if (opts?.auditPaths) {
234
+ appendAudit(opts.auditPaths, {
235
+ type: 'use_result',
236
+ exitCode: code,
237
+ signal: exitSignal,
238
+ durationMs: Date.now() - startedAt,
239
+ });
240
+ }
225
241
  resolve({
226
242
  exitCode,
227
243
  stdout: redactStdout.text,
@@ -230,7 +246,8 @@ export async function runWithSecrets(command, secrets, opts) {
230
246
  redactedCount: redactStdout.count + redactStderr.count,
231
247
  });
232
248
  };
233
- proc.on('exit', (code) => {
249
+ proc.on('exit', (code, signal) => {
250
+ exitSignal = signal;
234
251
  if (!timedOut) {
235
252
  finish(code);
236
253
  }
package/dist/paths.d.ts CHANGED
@@ -11,4 +11,7 @@ export declare function projectPaths(root: string): ProjectPaths;
11
11
  export declare function findProjectRoot(startDir: string): string;
12
12
  export declare function ensureStateDir(paths: ProjectPaths): void;
13
13
  export declare function loadOrCreateSalt(paths: ProjectPaths): Buffer;
14
+ export declare const HOOK_HEARTBEAT_FILE = "hook-heartbeat";
15
+ /** ISO timestamp of the hook's last observed run for a project, or null. */
16
+ export declare function readHookHeartbeat(root: string): string | null;
14
17
  //# sourceMappingURL=paths.d.ts.map
package/dist/paths.js CHANGED
@@ -84,4 +84,22 @@ export function loadOrCreateSalt(paths) {
84
84
  chmodSync(paths.salt, 0o600);
85
85
  return salt;
86
86
  }
87
+ // --- Hook liveness heartbeat ------------------------------------------------
88
+ //
89
+ // The Claude Code PreToolUse hook refreshes this marker after every decision
90
+ // (at most once per minute), purely so `envseal doctor` can report WHEN the
91
+ // hook last ran instead of only whether its wiring exists. It is
92
+ // observational: never a gate, never an audit record. The marker name lives
93
+ // here so the writer (plugin) and the reader (doctor) cannot drift.
94
+ export const HOOK_HEARTBEAT_FILE = 'hook-heartbeat';
95
+ /** ISO timestamp of the hook's last observed run for a project, or null. */
96
+ export function readHookHeartbeat(root) {
97
+ try {
98
+ const raw = readFileSync(join(resolve(root), '.envseal', HOOK_HEARTBEAT_FILE), 'utf8').trim();
99
+ return raw === '' ? null : raw;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
87
105
  //# sourceMappingURL=paths.js.map
@@ -0,0 +1,26 @@
1
+ import type { ProjectPaths } from './paths.js';
2
+ /**
3
+ * Age tracking for stored secret bytes.
4
+ *
5
+ * The manifest's `rotation.maxAgeDays` policy is only actionable if we know
6
+ * when the stored value last changed. Nothing else records that: provisioning
7
+ * via `env_use` happens once, hand-written .env values never cross any envseal
8
+ * write path at all, and the audit log answers "who did what", not "how old
9
+ * are the bytes". So `Broker.describe()` lazily stamps a record the first time
10
+ * it sees a key, and re-stamps when the value's fingerprint changes.
11
+ *
12
+ * This is observational state, deliberately outside the audit chain: the
13
+ * caller of describe() is often the model itself, and a read that grows the
14
+ * audit log with model-attributed "rotation" events would manufacture
15
+ * evidence. The timestamp says the BYTES changed, never who changed them.
16
+ */
17
+ export interface RotationRecord {
18
+ /** Fingerprint of the value this age stamp was computed for. */
19
+ fingerprint: string;
20
+ /** ISO timestamp of the first observation of this fingerprint. */
21
+ at: string;
22
+ }
23
+ export declare function loadRotationState(paths: ProjectPaths): Record<string, RotationRecord>;
24
+ /** Stamp (or re-stamp) a key's age marker. Returns the record written. */
25
+ export declare function recordRotation(paths: ProjectPaths, key: string, fingerprint: string, now?: Date): RotationRecord;
26
+ //# sourceMappingURL=rotation-state.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { readFileSync, writeFileSync, chmodSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ensureStateDir } from './paths.js';
4
+ function statePath(paths) {
5
+ return join(paths.stateDir, 'rotation.json');
6
+ }
7
+ function load(paths) {
8
+ try {
9
+ const parsed = JSON.parse(readFileSync(statePath(paths), 'utf8'));
10
+ if (parsed !== null && typeof parsed === 'object')
11
+ return parsed;
12
+ }
13
+ catch {
14
+ // Missing or unreadable: no age known yet, reported as "due now is
15
+ // unknowable" rather than invented.
16
+ }
17
+ return {};
18
+ }
19
+ export function loadRotationState(paths) {
20
+ return load(paths);
21
+ }
22
+ /** Stamp (or re-stamp) a key's age marker. Returns the record written. */
23
+ export function recordRotation(paths, key, fingerprint, now = new Date()) {
24
+ ensureStateDir(paths);
25
+ const all = load(paths);
26
+ const record = { fingerprint, at: now.toISOString() };
27
+ all[key] = record;
28
+ const file = statePath(paths);
29
+ writeFileSync(file, `${JSON.stringify(all, null, 2)}\n`, 'utf8');
30
+ if (process.platform !== 'win32') {
31
+ try {
32
+ chmodSync(file, 0o600);
33
+ }
34
+ catch {
35
+ // best effort
36
+ }
37
+ }
38
+ return record;
39
+ }
40
+ //# sourceMappingURL=rotation-state.js.map
@@ -1,4 +1,4 @@
1
- import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, chmodSync, } from 'node:fs';
1
+ import { appendFileSync, closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, chmodSync, } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { dirname, basename, join, relative } from 'node:path';
4
4
  import { randomBytes } from 'node:crypto';
@@ -223,6 +223,7 @@ function atomicWrite(paths, target, content) {
223
223
  writeTempFile(tmp, content);
224
224
  }
225
225
  catch {
226
+ ensureSiblingTempIgnored(dirname(target));
226
227
  tmp = sibling;
227
228
  writeTempFile(tmp, content);
228
229
  }
@@ -232,6 +233,7 @@ function atomicWrite(paths, target, content) {
232
233
  catch (error) {
233
234
  if (error.code !== 'EXDEV')
234
235
  throw error;
236
+ ensureSiblingTempIgnored(dirname(target));
235
237
  writeTempFile(sibling, content);
236
238
  renameOverwrite(sibling, target);
237
239
  }
@@ -253,6 +255,40 @@ function renameOverwrite(tmp, target) {
253
255
  throw error;
254
256
  }
255
257
  }
258
+ const SIBLING_TEMP_IGNORE = '..env.*.tmp';
259
+ let siblingIgnoreWarned = false;
260
+ /**
261
+ * F-W7-3 residual hardening: a sibling staging temp holds the complete
262
+ * plaintext next to `.env`, and a `.env` gitignore entry does not match the
263
+ * name. Best-effort make the project `.gitignore` carry the pattern so a
264
+ * crash in exactly that window cannot leave the file stageable by an
265
+ * accidental `git add -A`. Append-once, never throws; a failure warns once
266
+ * on stderr with no path or error detail (either could carry secret-shaped
267
+ * text, same discipline as the audit mirror).
268
+ */
269
+ function ensureSiblingTempIgnored(dir) {
270
+ try {
271
+ const gitignorePath = join(dir, '.gitignore');
272
+ let lines = [];
273
+ try {
274
+ lines = readFileSync(gitignorePath, 'utf8').split(/\r\n|\n|\r/);
275
+ }
276
+ catch (error) {
277
+ if (error.code !== 'ENOENT')
278
+ throw error;
279
+ }
280
+ if (lines.some((line) => line.trim() === SIBLING_TEMP_IGNORE)) {
281
+ return;
282
+ }
283
+ appendFileSync(gitignorePath, `\n# envseal: plaintext staging temp for the fallback atomic-write path\n${SIBLING_TEMP_IGNORE}\n`, 'utf8');
284
+ }
285
+ catch {
286
+ if (!siblingIgnoreWarned) {
287
+ siblingIgnoreWarned = true;
288
+ process.stderr.write('envseal: staging temp could not be gitignored (gitignore unwritable) — check for ..env.*.tmp files after failures\n');
289
+ }
290
+ }
291
+ }
256
292
  /**
257
293
  * F-W7-4: every filesystem failure used to escape as a bare Node error, so the
258
294
  * CLI never mapped it to exit code 5 and the message carried the target's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envseal/core",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./dist/index.js",
@@ -22,10 +22,10 @@
22
22
  "dependencies": {
23
23
  "jsonc-parser": "^3.3.1",
24
24
  "ulid": "^2.3.0",
25
- "@envseal/protocol": "0.1.4",
26
- "@envseal/registry": "0.1.4",
27
- "@envseal/detector": "0.1.4",
28
- "@envseal/prompters": "0.1.4"
25
+ "@envseal/protocol": "0.1.6",
26
+ "@envseal/detector": "0.1.6",
27
+ "@envseal/registry": "0.1.6",
28
+ "@envseal/prompters": "0.1.6"
29
29
  },
30
30
  "devDependencies": {
31
31
  "fast-check": "^3.23.1"