@envseal/cli 0.1.1 → 0.1.3

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
@@ -13,7 +13,7 @@ import { doctor } from './commands/doctor.js';
13
13
  import { revoke } from './commands/revoke.js';
14
14
  import { mcp } from './commands/mcp.js';
15
15
  import { init } from './commands/init.js';
16
- const VERSION = '0.1.1';
16
+ const VERSION = '0.1.3';
17
17
  async function main() {
18
18
  const argv = process.argv.slice(2);
19
19
  if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
@@ -1,8 +1,8 @@
1
+ import { SepError } from '@envseal/protocol';
1
2
  import { emit, fail } from '../output.js';
2
3
  import { EXIT } from '../exit-codes.js';
3
4
  import { loadManifest, projectPaths } from '@envseal/core';
4
- import { SepError } from '@envseal/protocol';
5
- import { createBroker, outcomeForKey } from '../cli-utils.js';
5
+ import { createBroker, hasInteractiveSurface, outcomeForKey } from '../cli-utils.js';
6
6
  import { finish } from '../exit.js';
7
7
  export async function ensure(root, json, check = false) {
8
8
  try {
@@ -80,6 +80,18 @@ export async function ensure(root, json, check = false) {
80
80
  }
81
81
  return;
82
82
  }
83
+ // F2: without --check, ensure asks the user for values. When there is no
84
+ // interactive surface and no double-gated test prompter configured, refuse
85
+ // now instead of selecting the loopback surface and waiting on a browser
86
+ // page nobody can see (the W4 hang). Mirrors run.ts's fail-closed stance.
87
+ if (!hasInteractiveSurface() && !(process.env.ENVSEAL_TEST_PROMPTER_VALUE !== undefined ||
88
+ process.env.ENVSEAL_TEST_PROMPTER_OUTCOME !== undefined)) {
89
+ throw new SepError({
90
+ code: 'SEP_NO_INTERACTIVE_SURFACE',
91
+ userMessage: 'envseal ensure needs to collect missing keys, but there is no interactive surface here. ' +
92
+ 'Use --check to gate without prompting, provide values out of band, or run interactively.',
93
+ });
94
+ }
83
95
  // Request all missing keys at once
84
96
  const ticket = await broker.request({
85
97
  keys: missingRequired,
@@ -1,4 +1,5 @@
1
- import { projectPaths, loadManifest, declareEntries } from '@envseal/core';
1
+ import { projectPaths, loadManifest, declareEntries, scanManifestEntry } from '@envseal/core';
2
+ import { SepError } from '@envseal/protocol';
2
3
  import { emit, fail } from '../output.js';
3
4
  import { detectHost } from '../host.js';
4
5
  import { scanForEnvKeys, entryForKey } from '../scan.js';
@@ -31,6 +32,26 @@ export async function init(root, json, hostOverride) {
31
32
  }
32
33
  const paths = projectPaths(root);
33
34
  const discovered = scanForEnvKeys(root);
35
+ // F1: a manifest that already exists was authored by someone else (an
36
+ // editor, another tool, a malicious repo). Declaring into it without
37
+ // validating it first would launder hostile entries — e.g. a
38
+ // secret-shaped format.example — through envseal's own write path and
39
+ // commit them as blessed. Re-run the same schema+guard validation the
40
+ // declare path uses over every entry already on disk.
41
+ const existing = loadManifest(paths);
42
+ if (existing !== null) {
43
+ for (const [index, entry] of existing.entries.entries()) {
44
+ const finding = scanManifestEntry(entry, `entries[${index}]`);
45
+ if (finding !== null) {
46
+ throw new SepError({
47
+ code: 'SEP_VALUE_IN_REQUEST',
48
+ details: { field: finding.path },
49
+ userMessage: `Refusing to init: the existing ${finding.path} entry looks like it contains a real credential (${finding.label}). ` +
50
+ 'Manifest fields are committed to git and must describe keys, never contain values.',
51
+ });
52
+ }
53
+ }
54
+ }
34
55
  const entries = discovered.map(entryForKey);
35
56
  // declareEntries creates the manifest when absent and edits it surgically
36
57
  // when present, so re-running init on an existing project is safe and
@@ -1,7 +1,9 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { SepError } from '@envseal/protocol';
3
+ import { useConfirmationBody } from '@envseal/core';
3
4
  import { emit, fail } from '../output.js';
4
5
  import { createBroker } from '../cli-utils.js';
6
+ import { loadManifest, projectPaths } from '@envseal/core';
5
7
  import { finish } from '../exit.js';
6
8
  /**
7
9
  * Ask the user to approve running a command with secrets injected.
@@ -11,9 +13,11 @@ import { finish } from '../exit.js';
11
13
  * prompt reads from the controlling terminal, so it still works when stdout is
12
14
  * being piped.
13
15
  *
14
- * Fails closed: no TTY and no --yes means no approval. That is the right
15
- * default, because the alternative is a CI job silently handing credentials to
16
- * whatever it was told to run.
16
+ * The dialog body comes from the SAME useConfirmationBody the MCP and SDK
17
+ * bindings render one escaping/fingerprint implementation for every
18
+ * surface. Fails closed: no TTY and no --yes means no approval. That is the
19
+ * right default, because the alternative is a CI job silently handing
20
+ * credentials to whatever it was told to run.
17
21
  */
18
22
  async function confirmInteractive(info) {
19
23
  if (process.env.ENVSEAL_ASSUME_YES === '1')
@@ -22,23 +26,18 @@ async function confirmInteractive(info) {
22
26
  // Distinct from a refusal. Reporting "the user denied the confirmation" when
23
27
  // no human was ever asked is actively misleading, and it is the shell-only
24
28
  // agents of Tier 4 that hit this path — the ones this binding exists for.
29
+ // Deliberately silent about HOW to proceed headlessly: this message lands in
30
+ // agent-visible stderr, and advertising the bypass here handed every reader
31
+ // a one-liner to skip the dialog entirely (documented in docs/ci.md instead).
25
32
  throw new SepError({
26
33
  code: 'SEP_NO_INTERACTIVE_SURFACE',
27
34
  userMessage: 'envseal run needs confirmation before injecting secrets, but there is no terminal to ask on. ' +
28
- 'Re-run in an interactive shell, or pass --yes (or set ENVSEAL_ASSUME_YES=1) to approve non-interactively.',
35
+ 'Nothing was run and no value was read. Re-run it yourself in an interactive shell to review and ' +
36
+ 'approve the command; see docs/ci.md for the supported headless pipeline setup.',
29
37
  });
30
38
  }
31
- const lines = [
32
- '',
33
- 'envseal is about to run a command with secrets in its environment:',
34
- ` command: ${info.command.join(' ')}`,
35
- ` keys: ${info.keys.join(', ')}`,
36
- ];
37
- if (info.networkEgress) {
38
- lines.push(' WARNING: this command can make network requests, so it could send', ' these values somewhere. Only continue if you trust it.');
39
- }
40
- lines.push('');
41
- process.stderr.write(lines.join('\n'));
39
+ const body = useConfirmationBody(info, process.cwd());
40
+ process.stderr.write(`\n${body}\n\n`);
42
41
  const rl = createInterface({ input: process.stdin, output: process.stderr });
43
42
  try {
44
43
  const answer = await new Promise((resolve) => {
@@ -55,6 +54,15 @@ export async function run(root, command, json, assumeYes = false) {
55
54
  const broker = await createBroker(root, {
56
55
  onConfirm: assumeYes ? async () => true : confirmInteractive,
57
56
  });
57
+ // F4: a missing manifest while values still resolve is a suspicious state
58
+ // (deleted by hand or tooling). The run proceeds — the .env is user-owned —
59
+ // but never silently: the condition goes to stderr in both output modes.
60
+ if (loadManifest(projectPaths(root)) === null) {
61
+ const warning = 'envseal: no env.schema.jsonc found for this project. ' +
62
+ 'Values are still resolved from .env/keychain because those are user-owned, ' +
63
+ "but nothing is declared here anymore. Run `envseal init` to re-create the manifest.";
64
+ process.stderr.write(`\n${warning}\n\n`);
65
+ }
58
66
  const status = await broker.describe();
59
67
  const presentKeys = status.entries.filter((e) => e.present).map((e) => e.key);
60
68
  const result = await broker.use({ keys: presentKeys, command });
@@ -1,8 +1,9 @@
1
1
  import { emit, fail } from '../output.js';
2
2
  import { EXIT, exitCodeForOutcome } from '../exit-codes.js';
3
- import { createBroker, outcomeForKey } from '../cli-utils.js';
3
+ import { createBroker, hasInteractiveSurface, outcomeForKey } from '../cli-utils.js';
4
4
  import { finish } from '../exit.js';
5
5
  import { loadManifest, projectPaths, saveManifest } from '@envseal/core';
6
+ import { SepError } from '@envseal/protocol';
6
7
  /**
7
8
  * Undo a declaration THIS invocation added, after nothing was stored under it.
8
9
  *
@@ -37,6 +38,27 @@ export async function set(root, key, json) {
37
38
  let declaredHere = false;
38
39
  try {
39
40
  const broker = await createBroker(root);
41
+ // F2: same fail-closed rule as ensure/run. Without this, a non-TTY caller
42
+ // (agent shell, scheduler) selected the loopback surface and waited on a
43
+ // browser page nobody could see.
44
+ //
45
+ // The guard fires BEFORE any declare, so a refused run also leaves no
46
+ // phantom declaration — but only for keys that are NOT already declared
47
+ // (those keep the pre-existing rollback semantics below). CI=1 keeps the
48
+ // original path: declare-then-request so the refusal is announced with the
49
+ // "declared X but nothing was stored" mutation message, which the contract
50
+ // test pins.
51
+ const surfaceUsable = hasInteractiveSurface() ||
52
+ process.env.CI !== undefined ||
53
+ process.env.ENVSEAL_TEST_PROMPTER_VALUE !== undefined ||
54
+ process.env.ENVSEAL_TEST_PROMPTER_OUTCOME !== undefined;
55
+ if (!surfaceUsable) {
56
+ throw new SepError({
57
+ code: 'SEP_NO_INTERACTIVE_SURFACE',
58
+ userMessage: 'envseal set needs to collect a value, but there is no interactive surface here. ' +
59
+ 'Run it in an interactive shell, or use the documented ENVSEAL_TEST_MODE hooks in tests.',
60
+ });
61
+ }
40
62
  // Declare the key only if it is not already in the manifest.
41
63
  //
42
64
  // `declareEntries` replaces an entry wholesale rather than merging, so
@@ -34,6 +34,7 @@ export function exitCodeForError(e) {
34
34
  case 'SEP_FORMAT_INVALID':
35
35
  case 'SEP_RATE_LIMITED':
36
36
  case 'SEP_TICKET_UNKNOWN':
37
+ case 'SEP_TARGET_CHANGED':
37
38
  case 'SEP_CONFIRMATION_DENIED':
38
39
  return EXIT.UNSATISFIED;
39
40
  default: {
@@ -17,14 +17,22 @@ export function createStubPrompter(value) {
17
17
  return {
18
18
  id: 'ide',
19
19
  available: async () => true,
20
- prompt: async (req) => ({
21
- ticket: req.ticket,
22
- results: req.keys.map((k) => ({
23
- key: k.key,
24
- outcome: 'entered',
25
- value: secretFromUtf8(value),
26
- })),
27
- }),
20
+ prompt: async (req) => {
21
+ // Announce the stub on stderr, mirroring probe-approval.ts's
22
+ // ENVSEAL_TEST_APPROVAL notice: any answer that did not come from a
23
+ // human must be visible in the transcript. A model that discovers the
24
+ // two-variable gate cannot use it as a silent approval oracle.
25
+ process.stderr.write(`ENVSEAL_TEST_MODE: prompter is a STUB — key(s) [${req.keys.map((k) => k.key).join(', ')}] ` +
26
+ `answered from ENVSEAL_TEST_PROMPTER_VALUE with no UI.\n`);
27
+ return {
28
+ ticket: req.ticket,
29
+ results: req.keys.map((k) => ({
30
+ key: k.key,
31
+ outcome: 'entered',
32
+ value: secretFromUtf8(value),
33
+ })),
34
+ };
35
+ },
28
36
  cancel: async () => {
29
37
  /* nothing to tear down */
30
38
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envseal/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.1",
27
- "@envseal/core": "0.1.1",
28
- "@envseal/prompters": "0.1.1",
29
- "@envseal/registry": "0.1.1",
30
- "@envseal/mcp-server": "0.1.1",
31
- "@envseal/http-server": "0.1.1",
32
- "@envseal/detector": "0.1.1"
26
+ "@envseal/core": "0.1.3",
27
+ "@envseal/prompters": "0.1.3",
28
+ "@envseal/protocol": "0.1.3",
29
+ "@envseal/registry": "0.1.3",
30
+ "@envseal/detector": "0.1.3",
31
+ "@envseal/mcp-server": "0.1.3",
32
+ "@envseal/http-server": "0.1.3"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",