@envseal/cli 0.1.5 → 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/bin.js CHANGED
@@ -11,6 +11,7 @@ import { verify } from './commands/verify.js';
11
11
  import { run } from './commands/run.js';
12
12
  import { doctor } from './commands/doctor.js';
13
13
  import { revoke } from './commands/revoke.js';
14
+ import { audit } from './commands/audit.js';
14
15
  import { mcp } from './commands/mcp.js';
15
16
  import { init } from './commands/init.js';
16
17
  const VERSION = '0.1.5';
@@ -115,6 +116,10 @@ async function main() {
115
116
  await revoke(root, key, json, parsed.flags.yes === true);
116
117
  break;
117
118
  }
119
+ case 'audit': {
120
+ await audit(root, json, parsed.flags.verify === true);
121
+ break;
122
+ }
118
123
  case 'mcp': {
119
124
  await mcp(root);
120
125
  break;
@@ -146,6 +151,8 @@ Commands:
146
151
  run -- <cmd...> Execute command with injected secrets
147
152
  doctor Report project configuration status
148
153
  revoke <KEY> Revoke a key from the sink
154
+ audit [--verify] Print audit events (--json for machine form);
155
+ --verify checks the log's tamper-evidence chain
149
156
  mcp Start the MCP server
150
157
 
151
158
  Global Options:
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `envseal audit` — inspect the project's audit log.
3
+ *
4
+ * Default: print recorded events (human-readable) or the raw event array
5
+ * (--json). With --verify: check the hash chain instead; exit 7
6
+ * (AUDIT_CHAIN_FAILED) when the chain is broken. A missing log verifies as
7
+ * intact with zero records — there is nothing to attest, and "no log yet"
8
+ * must not look like tampering.
9
+ *
10
+ * When the project's out-of-band mirror (~/.envseal/mirrors/) exists, verify
11
+ * also compares the log against it: the mirror is a second copy the project's
12
+ * agent cannot silently shrink, so records the mirror proves existed but the
13
+ * log lost are tail truncation — exit 7. See docs/residual-risks.md §10.
14
+ */
15
+ export declare function audit(root: string, json: boolean, verifyMode: boolean): Promise<void>;
16
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1,117 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { compareWithMirror, projectPaths, readAudit, readMirrorLines, verifyAuditChain } from '@envseal/core';
4
+ import { EXIT } from '../exit-codes.js';
5
+ import { finish } from '../exit.js';
6
+ /**
7
+ * `envseal audit` — inspect the project's audit log.
8
+ *
9
+ * Default: print recorded events (human-readable) or the raw event array
10
+ * (--json). With --verify: check the hash chain instead; exit 7
11
+ * (AUDIT_CHAIN_FAILED) when the chain is broken. A missing log verifies as
12
+ * intact with zero records — there is nothing to attest, and "no log yet"
13
+ * must not look like tampering.
14
+ *
15
+ * When the project's out-of-band mirror (~/.envseal/mirrors/) exists, verify
16
+ * also compares the log against it: the mirror is a second copy the project's
17
+ * agent cannot silently shrink, so records the mirror proves existed but the
18
+ * log lost are tail truncation — exit 7. See docs/residual-risks.md §10.
19
+ */
20
+ export async function audit(root, json, verifyMode) {
21
+ if (!verifyMode) {
22
+ // No manifest gate here on purpose: the log is written by provisioning and
23
+ // use flows, and it stays readable even in a half-torn-down project whose
24
+ // env.schema.jsonc is already gone. An audit surface that refuses to
25
+ // audit would be its own finding.
26
+ const events = readAudit(projectPaths(root));
27
+ if (json) {
28
+ console.log(JSON.stringify(events, null, 0));
29
+ finish(EXIT.OK);
30
+ return;
31
+ }
32
+ if (events.length === 0) {
33
+ console.log('No audit events recorded.');
34
+ finish(EXIT.OK);
35
+ return;
36
+ }
37
+ for (const e of events) {
38
+ console.log(formatEvent(e));
39
+ }
40
+ finish(EXIT.OK);
41
+ return;
42
+ }
43
+ // --verify mode: chain check over the raw bytes. A missing file is an empty
44
+ // chain, not an error (see doc comment).
45
+ let raw = '';
46
+ try {
47
+ raw = readFileSync(join(root, '.envseal', 'audit.jsonl'), 'utf8');
48
+ }
49
+ catch {
50
+ raw = '';
51
+ }
52
+ const result = verifyAuditChain(raw);
53
+ const mirror = compareWithMirror(raw, readMirrorLines(root));
54
+ const ok = result.ok && !mirror.tailTruncated;
55
+ if (json) {
56
+ console.log(JSON.stringify(!ok
57
+ ? {
58
+ ok: false,
59
+ brokenAt: result.ok ? null : (result.brokenAt ?? null),
60
+ count: result.count,
61
+ mirror: { present: mirror.mirrorPresent, records: mirror.mirrorRecords },
62
+ }
63
+ : { ok: true, count: result.count, mirror: { present: mirror.mirrorPresent, records: mirror.mirrorRecords } }, null, 0));
64
+ finish(ok ? EXIT.OK : EXIT.AUDIT_CHAIN_FAILED);
65
+ return;
66
+ }
67
+ if (!ok) {
68
+ if (!result.ok) {
69
+ console.error(`AUDIT CHAIN FAILED: first break at record ${result.brokenAt} of ${result.count}. ` +
70
+ 'Records were edited, deleted, reordered, or spliced after the fact. ' +
71
+ 'Treat every record after the break as untrusted and investigate the host.');
72
+ }
73
+ else {
74
+ console.error(`AUDIT TAIL LOST: the project log holds ${mirror.projectRecords} record(s) but its out-of-band mirror ` +
75
+ `attests ${mirror.mirrorRecords}. Records after the surviving tail were deleted after being mirrored. ` +
76
+ 'Treat the log as incomplete and investigate the host (docs/residual-risks.md §10).');
77
+ }
78
+ finish(EXIT.AUDIT_CHAIN_FAILED);
79
+ return;
80
+ }
81
+ if (mirror.mirrorPresent && mirror.mirrorRecords > mirror.projectRecords) {
82
+ console.log(`Audit chain intact (${result.count} record${result.count === 1 ? '' : 's'}); mirror holds ` +
83
+ `${mirror.mirrorRecords} — pre-reset history, not tampering.`);
84
+ }
85
+ else {
86
+ console.log(`Audit chain intact (${result.count} record${result.count === 1 ? '' : 's'}).`);
87
+ }
88
+ finish(EXIT.OK);
89
+ }
90
+ function formatEvent(e) {
91
+ const at = e.at;
92
+ switch (e.type) {
93
+ case 'declare':
94
+ return `${at} declare keys=${JSON.stringify(e.keys)}`;
95
+ case 'request':
96
+ return `${at} request ticket=${e.ticket} keys=${JSON.stringify(e.keys)} surface=${e.surface}`;
97
+ case 'stored':
98
+ return `${at} stored key=${e.key} sink=${e.sink}`;
99
+ case 'skipped':
100
+ case 'cancelled':
101
+ case 'timeout':
102
+ return `${at} ${e.type} ticket=${e.ticket} key=${e.key}`;
103
+ case 'verify':
104
+ return `${at} verify key=${e.key} result=${e.result}`;
105
+ case 'revoke':
106
+ return `${at} revoke key=${e.key} sink=${e.sink}`;
107
+ case 'blocked':
108
+ return `${at} blocked reason=${e.reason}`;
109
+ case 'use':
110
+ return `${at} use keys=${JSON.stringify(e.keys)} networkEgress=${String(e.networkEgress)} cmd=${e.command}`;
111
+ case 'use_result':
112
+ return `${at} use_result exit=${String(e.exitCode)} signal=${String(e.signal)} ${e.durationMs}ms`;
113
+ default:
114
+ return `${at} ${e.type}`;
115
+ }
116
+ }
117
+ //# sourceMappingURL=audit.js.map
@@ -1,7 +1,7 @@
1
1
  import { existsSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { SepError } from '@envseal/protocol';
4
- import { inspectDotenvGitSafety, projectPaths } from '@envseal/core';
4
+ import { inspectDotenvGitSafety, loadManifest, projectPaths, readHookHeartbeat } from '@envseal/core';
5
5
  import { emit, fail } from '../output.js';
6
6
  import { EXIT } from '../exit-codes.js';
7
7
  import { detectHost } from '../host.js';
@@ -22,6 +22,7 @@ export async function doctor(root, json) {
22
22
  }));
23
23
  return;
24
24
  }
25
+ const manifest = loadManifest(projectPaths(root));
25
26
  const broker = await createBroker(root);
26
27
  const status = await broker.describe();
27
28
  const gitignorePath = join(root, '.gitignore');
@@ -29,14 +30,25 @@ export async function doctor(root, json) {
29
30
  const gitSafety = inspectDotenvGitSafety(projectPaths(root));
30
31
  const gitignoreCovers = gitSafety.ignored;
31
32
  const hookFailClosed = process.env.ENVSEAL_HOOK_FAIL_CLOSED === '1';
32
- // Check .env permissions
33
- let envFileOk = false;
33
+ // Check .env permissions.
34
+ //
35
+ // POSIX mode bits are only enforced on POSIX. Windows statSync still
36
+ // reports a mode (0o666 writable, 0o444 read-only), so the group/other
37
+ // test would produce 0o066 ≠ 0 and report permissionsOk:false on EVERY
38
+ // Windows machine regardless of the real ACLs — a permanent false alarm.
39
+ // Report null ("not measurable here") instead; access on Windows is an
40
+ // ACL question this check cannot answer.
41
+ let envFileOk = null;
34
42
  if (existsSync(envPath)) {
35
- const stats = statSync(envPath);
36
- envFileOk = (stats.mode & 0o077) === 0;
43
+ if (process.platform !== 'win32') {
44
+ const stats = statSync(envPath);
45
+ envFileOk = (stats.mode & 0o077) === 0;
46
+ }
37
47
  }
38
48
  const host = detectHost(root);
49
+ const egress = manifest?.policy?.egress;
39
50
  const inspection = inspectPrimaryHostWiring(root, host.id, { probe: true });
51
+ const hookLastRan = readHookHeartbeat(root);
40
52
  const output = {
41
53
  projectRoot: root,
42
54
  manifestPath,
@@ -57,9 +69,14 @@ export async function doctor(root, json) {
57
69
  isTracked: gitSafety.tracked,
58
70
  permissionsOk: envFileOk,
59
71
  },
72
+ egressPolicy: egress ?? { mode: 'warn', allow: [] },
60
73
  hookFailClosed,
74
+ hookLastRan,
61
75
  missingRequiredCount: status.missingRequired.length,
62
76
  missingRequired: status.missingRequired,
77
+ rotationOverdue: status.entries
78
+ .filter((e) => isOverdue(e.rotationDue))
79
+ .map((e) => ({ key: e.key, due: e.rotationDue })),
63
80
  ...(inspection.mcp === undefined
64
81
  ? {}
65
82
  : {
@@ -82,13 +99,22 @@ export async function doctor(root, json) {
82
99
  }
83
100
  console.log(` ${inspection.message}`);
84
101
  console.log(`Gitignore covers .env: ${gitignoreCovers ? 'yes' : 'no'}`);
102
+ console.log(`Egress policy: ${egress?.mode === 'allowlist' ? `allowlist (${egress.allow.length} allowed host${egress.allow.length === 1 ? '' : 's'})` : 'warn (default)'}`);
85
103
  console.log(`Hook on internal error: ${hookFailClosed ? 'fail-closed' : 'fail-open (default)'}`);
104
+ console.log(`Hook heartbeat: ${describeHeartbeatAge(hookLastRan)}`);
86
105
  console.log(`Missing required keys: ${status.missingRequired.length}`);
87
106
  if (status.missingRequired.length > 0) {
88
107
  for (const key of status.missingRequired) {
89
108
  console.log(` - ${key}`);
90
109
  }
91
110
  }
111
+ const overdue = status.entries.filter((e) => e.rotationDue !== null && isOverdue(e.rotationDue));
112
+ if (overdue.length > 0) {
113
+ console.log('Rotation overdue (advisory — rotate the credential, then rewrite the value):');
114
+ for (const e of overdue) {
115
+ console.log(` - ${e.key}: due ${e.rotationDue.slice(0, 10)}`);
116
+ }
117
+ }
92
118
  }
93
119
  else {
94
120
  emit(json, '', output);
@@ -102,4 +128,38 @@ export async function doctor(root, json) {
102
128
  fail(json, error);
103
129
  }
104
130
  }
131
+ /** Advisory by design: overdue rotation never fails doctor the way a
132
+ * missing required key does, because an aged-but-working credential is a
133
+ * hygiene problem, not an outage. */
134
+ function isOverdue(rotationDue) {
135
+ if (rotationDue === null)
136
+ return false;
137
+ const due = Date.parse(rotationDue);
138
+ return !Number.isNaN(due) && due <= Date.now();
139
+ }
140
+ /**
141
+ * Human phrasing for the hook heartbeat. Advisory only — wiring can be
142
+ * present while the hook has never run (no plugin version, no tool call yet),
143
+ * and a recent timestamp proves liveness, not correctness.
144
+ */
145
+ function describeHeartbeatAge(hookLastRan) {
146
+ if (hookLastRan === null) {
147
+ return 'none recorded (hook has not run for this project, or pre-heartbeat plugin)';
148
+ }
149
+ const then = Date.parse(hookLastRan);
150
+ if (Number.isNaN(then)) {
151
+ return 'unreadable timestamp';
152
+ }
153
+ const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
154
+ if (seconds < 90)
155
+ return `${seconds}s ago`;
156
+ const minutes = Math.round(seconds / 60);
157
+ if (minutes < 90)
158
+ return `${minutes}m ago`;
159
+ const hours = Math.round(minutes / 60);
160
+ if (hours < 36)
161
+ return `${hours}h ago`;
162
+ const days = Math.round(hours / 24);
163
+ return `${days}d ago`;
164
+ }
105
165
  //# sourceMappingURL=doctor.js.map
@@ -18,7 +18,8 @@ export async function status(root, keys, json) {
18
18
  else {
19
19
  for (const entry of entriesToShow) {
20
20
  const status_str = entry.present ? '✓' : '✗';
21
- console.log(`${status_str} ${entry.key}`);
21
+ const due = describeRotation(entry.rotationDue);
22
+ console.log(`${status_str} ${entry.key}${due}`);
22
23
  }
23
24
  }
24
25
  }
@@ -33,6 +34,7 @@ export async function status(root, keys, json) {
33
34
  fingerprint: e.fingerprint,
34
35
  lastVerified: e.lastVerified,
35
36
  verifyResult: e.verifyResult,
37
+ rotationDue: e.rotationDue,
36
38
  })),
37
39
  });
38
40
  }
@@ -46,4 +48,21 @@ export async function status(root, keys, json) {
46
48
  fail(json, error);
47
49
  }
48
50
  }
51
+ /**
52
+ * Overdue rotation is the only state worth a human's glance in the terse
53
+ * listing; a future due date is noise. Absent policy or unknown age (hand
54
+ * written .env before first status) reports nothing.
55
+ */
56
+ function describeRotation(rotationDue) {
57
+ if (rotationDue === null)
58
+ return '';
59
+ const due = Date.parse(rotationDue);
60
+ if (Number.isNaN(due))
61
+ return '';
62
+ if (due > Date.now())
63
+ return '';
64
+ const days = Math.floor((Date.now() - due) / (24 * 60 * 60 * 1000));
65
+ const when = days === 0 ? 'today' : `${days}d ago`;
66
+ return ` (rotation overdue, due ${rotationDue.slice(0, 10)}, ${when})`;
67
+ }
49
68
  //# sourceMappingURL=status.js.map
@@ -7,6 +7,7 @@ export declare const EXIT: {
7
7
  readonly NO_SURFACE: 4;
8
8
  readonly SINK_FAILURE: 5;
9
9
  readonly VERIFY_FAILED: 6;
10
+ readonly AUDIT_CHAIN_FAILED: 7;
10
11
  };
11
12
  export declare function exitCodeForError(e: unknown): number;
12
13
  /**
@@ -7,6 +7,7 @@ export const EXIT = {
7
7
  NO_SURFACE: 4,
8
8
  SINK_FAILURE: 5,
9
9
  VERIFY_FAILED: 6,
10
+ AUDIT_CHAIN_FAILED: 7,
10
11
  };
11
12
  export function exitCodeForError(e) {
12
13
  if (!isSepError(e)) {
@@ -39,6 +40,11 @@ export function exitCodeForError(e) {
39
40
  case 'SEP_CONFIRMATION_DENIED':
40
41
  case 'SEP_KEYS_MISSING':
41
42
  return EXIT.UNSATISFIED;
43
+ case 'SEP_EGRESS_DENIED':
44
+ // The project's standing policy refused the command before anything
45
+ // ran. Not retriable by re-running: the policy file must change first,
46
+ // so this maps to USAGE (configuration), not a transient failure.
47
+ return EXIT.USAGE;
42
48
  default: {
43
49
  const _exhaustive = code;
44
50
  return _exhaustive;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envseal/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./dist/index.js",
@@ -23,13 +23,13 @@
23
23
  "provenance": true
24
24
  },
25
25
  "dependencies": {
26
- "@envseal/protocol": "0.1.5",
27
- "@envseal/core": "0.1.5",
28
- "@envseal/registry": "0.1.5",
29
- "@envseal/detector": "0.1.5",
30
- "@envseal/mcp-server": "0.1.5",
31
- "@envseal/http-server": "0.1.5",
32
- "@envseal/prompters": "0.1.5"
26
+ "@envseal/protocol": "0.1.6",
27
+ "@envseal/prompters": "0.1.6",
28
+ "@envseal/registry": "0.1.6",
29
+ "@envseal/core": "0.1.6",
30
+ "@envseal/mcp-server": "0.1.6",
31
+ "@envseal/http-server": "0.1.6",
32
+ "@envseal/detector": "0.1.6"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",