@envseal/core 0.1.2 → 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.
@@ -0,0 +1,31 @@
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
+ /**
10
+ * Model-supplied argv, key names and probe metadata land in a dialog the user
11
+ * is about to trust. Control characters let a crafted argument forge extra
12
+ * lines — "keys: none", "this command is safe" — inside the very block that
13
+ * exists to tell the truth about the command; Unicode separators can split
14
+ * lines invisibly and bidi controls can reorder what a terminal shows.
15
+ * Render all of them visibly instead.
16
+ */
17
+ export declare function escapeForDisplay(value: string): string;
18
+ export declare function displayArg(arg: string): string;
19
+ /**
20
+ * The full `env_use` approval dialog: project, keys, argv one-per-line,
21
+ * 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.
24
+ */
25
+ export declare function useConfirmationBody(info: {
26
+ command: string[];
27
+ keys: string[];
28
+ networkEgress: boolean;
29
+ target?: import('./exec.js').TargetInfo;
30
+ }, projectRoot: string): string;
31
+ //# sourceMappingURL=display.d.ts.map
@@ -0,0 +1,98 @@
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
+ /** Per-argument display cap; longer arguments are shown truncated, and said to be. */
10
+ const MAX_ARG_CHARS = 300;
11
+ /**
12
+ * Model-supplied argv, key names and probe metadata land in a dialog the user
13
+ * is about to trust. Control characters let a crafted argument forge extra
14
+ * lines — "keys: none", "this command is safe" — inside the very block that
15
+ * exists to tell the truth about the command; Unicode separators can split
16
+ * lines invisibly and bidi controls can reorder what a terminal shows.
17
+ * Render all of them visibly instead.
18
+ */
19
+ export function escapeForDisplay(value) {
20
+ let out = '';
21
+ for (const ch of value) {
22
+ const code = ch.codePointAt(0) ?? 0;
23
+ const c1 = code < 0x20 ||
24
+ (code >= 0x7f && code <= 0x9f) ||
25
+ code === 0x2028 ||
26
+ code === 0x2029 ||
27
+ (code >= 0x200b && code <= 0x200f) ||
28
+ (code >= 0x202a && code <= 0x202e) ||
29
+ (code >= 0x2066 && code <= 0x2069) ||
30
+ code === 0xfeff;
31
+ out += c1 ? `<U+${code.toString(16).toUpperCase().padStart(4, '0')}>` : ch;
32
+ }
33
+ return out;
34
+ }
35
+ export function displayArg(arg) {
36
+ const escaped = escapeForDisplay(arg);
37
+ if (escaped.length <= MAX_ARG_CHARS) {
38
+ return escaped;
39
+ }
40
+ const hidden = escaped.length - MAX_ARG_CHARS;
41
+ return `${escaped.slice(0, MAX_ARG_CHARS)}[... ${hidden} more characters, not shown]`;
42
+ }
43
+ /**
44
+ * The full `env_use` approval dialog: project, keys, argv one-per-line,
45
+ * 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.
48
+ */
49
+ export function useConfirmationBody(info, projectRoot) {
50
+ const lines = [
51
+ 'EnvSeal is about to run a program with these secrets in its environment.',
52
+ '',
53
+ ` project: ${escapeForDisplay(projectRoot)}`,
54
+ ` keys: ${info.keys.length > 0 ? info.keys.map(escapeForDisplay).join(', ') : '(none)'}`,
55
+ '',
56
+ ' command, one argument per line, exactly as it will be run (no shell):',
57
+ ];
58
+ info.command.forEach((arg, index) => {
59
+ lines.push(` [${index}] ${displayArg(arg)}`);
60
+ });
61
+ lines.push('');
62
+ if (info.target) {
63
+ const { resolvedPath, sha256, hashedFiles } = info.target;
64
+ const targetLabel = sha256 !== null
65
+ ? escapeForDisplay(resolvedPath)
66
+ : `${escapeForDisplay(resolvedPath)} (not a readable file)`;
67
+ lines.push(` target: ${targetLabel}`);
68
+ if (hashedFiles.length > 0) {
69
+ // The approval binds to these fingerprints, not to the text above:
70
+ // every named file is re-hashed just before spawn and any mismatch
71
+ // refuses with SEP_TARGET_CHANGED, so content swapped in after this
72
+ // dialog closes does not run.
73
+ for (const file of hashedFiles) {
74
+ lines.push(` ${escapeForDisplay(file.argument)}`);
75
+ lines.push(` sha256: ${file.sha256}`);
76
+ }
77
+ lines.push(' Each listed file is re-checked against its fingerprint immediately', ' before the program runs; one that changed since you read this will', ' not run.');
78
+ }
79
+ else {
80
+ // Honest about the boundary of the control: nothing in the command
81
+ // named a readable file, so approval stays name-level.
82
+ lines.push(' No argument named a readable file, so approval covers names only.');
83
+ }
84
+ lines.push('');
85
+ }
86
+ if (info.networkEgress) {
87
+ lines.push(' WARNING: this command can reach the network, so it could send these', ' values somewhere. Only continue if you trust it.');
88
+ }
89
+ else {
90
+ // Honest about what the check is worth: NETWORK_TOOLS plus a URL scan is a
91
+ // heuristic, and claiming more would be the kind of overstatement this
92
+ // project has already had to walk back once.
93
+ lines.push(' No network tool or URL was recognised in this command. That is a', ' heuristic, not a guarantee: any program can open a socket.');
94
+ }
95
+ lines.push('', 'Type yes to approve, or submit an empty box to deny. Nothing runs unless you approve.');
96
+ return lines.join('\\n');
97
+ }
98
+ //# sourceMappingURL=display.js.map
package/dist/exec.d.ts CHANGED
@@ -2,8 +2,45 @@ import type { SecretValue, ExecResult } from '@envseal/protocol';
2
2
  /**
3
3
  * Residual risk on Linux: A same-uid process can read /proc/<pid>/environ
4
4
  * of the child process. This cannot be defended against without sandboxing.
5
- * Users on shared systems should be aware of this limitation.
5
+ * Users on shared systems should see this limitation.
6
6
  */
7
+ /**
8
+ * One argument that named a readable file at approval time, bound to its
9
+ * content. Repo scripts usually ride as arguments (`node ./build/x.mjs`,
10
+ * `bash scripts/release.sh`), so consent must cover them, not just argv[0].
11
+ */
12
+ export interface TargetFile {
13
+ /** The argument exactly as it appeared in the command. */
14
+ argument: string;
15
+ /** Absolute path whose content was hashed. */
16
+ resolvedPath: string;
17
+ /** SHA-256 of the file content at approval time. */
18
+ sha256: string;
19
+ }
20
+ /**
21
+ * What the approver is told about the program they are approving. Consent for
22
+ * `env_use` binds to this content, not to the displayed argument text: a repo
23
+ * script named anywhere in argv can be rewritten while the approval dialog is
24
+ * open, so the dialog shows content fingerprints and every named file is
25
+ * re-checked immediately before spawn (see the T11 note below).
26
+ */
27
+ export interface TargetInfo {
28
+ /** Absolute resolution of argv[0]; often a PATH lookup, not a file. */
29
+ resolvedPath: string;
30
+ /**
31
+ * SHA-256 of argv[0]'s own content when argv[0] names a readable file
32
+ * (direct script invocation: `./scripts/release.sh`), else null
33
+ * (PATH-resolved executables: `node`, `python`, ...).
34
+ */
35
+ sha256: string | null;
36
+ /**
37
+ * Every distinct argument that resolved to a readable file — including
38
+ * argv[0] itself when it is one. Each entry is re-verified against fresh
39
+ * disk content just before spawn; any change refuses with
40
+ * SEP_TARGET_CHANGED.
41
+ */
42
+ hashedFiles: TargetFile[];
43
+ }
7
44
  export interface ExecOptions {
8
45
  cwd?: string;
9
46
  timeoutMs?: number;
@@ -11,6 +48,7 @@ export interface ExecOptions {
11
48
  command: string[];
12
49
  keys: string[];
13
50
  networkEgress: boolean;
51
+ target: TargetInfo;
14
52
  }) => Promise<boolean>;
15
53
  approvedCommands?: string[];
16
54
  }
package/dist/exec.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { createReadStream } from 'node:fs';
4
+ import { stat } from 'node:fs/promises';
5
+ import { resolve as resolvePath } from 'node:path';
2
6
  import { SepError } from '@envseal/protocol';
3
7
  import { redact } from './redact.js';
4
8
  import { unsafeSecretToUtf8 } from './sinks/dotenv.js';
@@ -16,6 +20,8 @@ const NETWORK_TOOLS = new Set([
16
20
  'telnet',
17
21
  'socat',
18
22
  ]);
23
+ /** Nothing remotely path-shaped is longer than this on any supported OS. */
24
+ const MAX_PATHISH_CHARS = 4096;
19
25
  function detectNetworkEgress(command) {
20
26
  if (command.length === 0) {
21
27
  return false;
@@ -31,6 +37,100 @@ function detectNetworkEgress(command) {
31
37
  }
32
38
  return false;
33
39
  }
40
+ /**
41
+ * T11 hardening: hash the files the command names, so approval binds to
42
+ * content rather than to displayed text. Streaming read — a multi-gigabyte
43
+ * argument must not be loaded into memory to be fingerprinted. Any read
44
+ * failure yields null rather than throwing: unreadable targets fail later at
45
+ * spawn with their own honest error, and refusing here would report a denial
46
+ * nobody made.
47
+ */
48
+ async function sha256File(path) {
49
+ try {
50
+ const info = await stat(path);
51
+ if (!info.isFile()) {
52
+ return null;
53
+ }
54
+ const hash = createHash('sha256');
55
+ await new Promise((resolveStream, rejectStream) => {
56
+ const stream = createReadStream(path);
57
+ stream.on('data', (chunk) => hash.update(chunk));
58
+ stream.on('end', () => resolveStream());
59
+ stream.on('error', rejectStream);
60
+ });
61
+ return hash.digest('hex');
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ async function snapshotNamedFiles(command, cwd) {
68
+ const base = cwd ?? process.cwd();
69
+ const files = new Map();
70
+ const pending = new Set();
71
+ let argv0Sha = null;
72
+ let argv0Resolved = '';
73
+ for (let index = 0; index < command.length; index += 1) {
74
+ const arg = command[index];
75
+ if (arg.length === 0 ||
76
+ arg.length > MAX_PATHISH_CHARS ||
77
+ // Scheme-shaped (https://..., file://...) — but NOT a Windows drive
78
+ // path: `C:\repo\script.mjs` matches the naive scheme regex and must
79
+ // stay hashable.
80
+ (/^[a-z][a-z0-9+.-]*:/i.test(arg) && !/^[a-zA-Z]:(\\|\/)/.test(arg))) {
81
+ continue; // empty, impossibly long, or URL-shaped
82
+ }
83
+ const abs = resolvePath(base, arg);
84
+ if (index === 0) {
85
+ argv0Resolved = abs;
86
+ }
87
+ const sha = await sha256File(abs);
88
+ if (sha !== null) {
89
+ if (index === 0) {
90
+ argv0Sha = sha;
91
+ }
92
+ files.set(abs, sha);
93
+ }
94
+ else if (!files.has(abs)) {
95
+ pending.add(abs);
96
+ }
97
+ }
98
+ const hashedFiles = [];
99
+ for (let index = 0; index < command.length; index += 1) {
100
+ const abs = resolvePath(base, command[index]);
101
+ const sha = files.get(abs);
102
+ if (sha !== undefined) {
103
+ hashedFiles.push({ argument: command[index], resolvedPath: abs, sha256: sha });
104
+ }
105
+ }
106
+ return {
107
+ info: { resolvedPath: argv0Resolved, sha256: argv0Sha, hashedFiles },
108
+ snapshot: { files, pending },
109
+ };
110
+ }
111
+ function assertUnchanged(approved, current, samplePath) {
112
+ for (const [path, sha] of approved.files) {
113
+ const now = current.files.get(path);
114
+ if (now === null || now === undefined || now !== sha) {
115
+ throw new SepError({
116
+ code: 'SEP_TARGET_CHANGED',
117
+ details: { target: path },
118
+ });
119
+ }
120
+ }
121
+ for (const path of approved.pending) {
122
+ // Named but absent (or directory) at approval time; a readable file
123
+ // appearing there before spawn means the command would execute content
124
+ // nobody could approve.
125
+ if (current.files.has(path)) {
126
+ throw new SepError({
127
+ code: 'SEP_TARGET_CHANGED',
128
+ details: { target: path },
129
+ });
130
+ }
131
+ }
132
+ void samplePath;
133
+ }
34
134
  export async function runWithSecrets(command, secrets, opts) {
35
135
  if (command.length === 0) {
36
136
  throw new SepError({
@@ -42,17 +142,28 @@ export async function runWithSecrets(command, secrets, opts) {
42
142
  const secretKeys = Array.from(secrets.keys());
43
143
  const joinedCommand = command.join(' ');
44
144
  const isApproved = opts?.approvedCommands?.some((approved) => approved === joinedCommand);
145
+ // The named files are fingerprinted twice: before the dialog is drawn (so
146
+ // the user approves content fingerprints, not just a command line) and
147
+ // again after consent, immediately before spawn. Content that changed in
148
+ // between — the injected-content-mutates-a-repo-script window — refuses
149
+ // with SEP_TARGET_CHANGED and nothing executes. The second read narrows
150
+ // the race to microseconds; closing it entirely would need an fd handed to
151
+ // the OS loader, which Node's spawn does not expose.
152
+ const approvedSnapshot = await snapshotNamedFiles(command, opts?.cwd);
45
153
  if (!isApproved && opts?.onConfirm) {
46
154
  const confirmed = await opts.onConfirm({
47
155
  command,
48
156
  keys: secretKeys,
49
157
  networkEgress,
158
+ target: approvedSnapshot.info,
50
159
  });
51
160
  if (!confirmed) {
52
161
  throw new SepError({
53
162
  code: 'SEP_CONFIRMATION_DENIED',
54
163
  });
55
164
  }
165
+ const justBeforeSpawn = await snapshotNamedFiles(command, opts?.cwd);
166
+ assertUnchanged(approvedSnapshot.snapshot, justBeforeSpawn.snapshot, approvedSnapshot.info.resolvedPath);
56
167
  }
57
168
  else if (!isApproved && !opts?.onConfirm) {
58
169
  throw new SepError({
@@ -61,7 +172,7 @@ export async function runWithSecrets(command, secrets, opts) {
61
172
  }
62
173
  const childEnv = { ...process.env };
63
174
  const secretValues = [];
64
- // W2-F31: docs/cli-contract.md §"redaction" promises masks read
175
+ // W2-F31: docs/cli-contract.md promises masks read
65
176
  // «redacted:KEY_NAME». Nothing but the key name rides along — redact()
66
177
  // rejects a label that is not a plain identifier, so a label can never carry
67
178
  // markup or a value fragment into the output stream.
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export type { DotenvLine, ParsedDotenv, WriteDotenvOptions } from './sinks/doten
11
11
  export * from './approvals.js';
12
12
  export * from './verify.js';
13
13
  export * from './exec.js';
14
+ export * from './display.js';
14
15
  export * from './sinks/registry.js';
15
16
  export { keychainSink } from './sinks/keychain.js';
16
17
  export * from './broker.js';
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { parseDotenv, serializeDotenv, readDotenv, setDotenvValue, removeDotenvK
10
10
  export * from './approvals.js';
11
11
  export * from './verify.js';
12
12
  export * from './exec.js';
13
+ export * from './display.js';
13
14
  export * from './sinks/registry.js';
14
15
  export { keychainSink } from './sinks/keychain.js';
15
16
  export * from './broker.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envseal/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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/registry": "0.1.2",
26
- "@envseal/prompters": "0.1.2",
27
- "@envseal/detector": "0.1.2",
28
- "@envseal/protocol": "0.1.2"
25
+ "@envseal/protocol": "0.1.3",
26
+ "@envseal/registry": "0.1.3",
27
+ "@envseal/detector": "0.1.3",
28
+ "@envseal/prompters": "0.1.3"
29
29
  },
30
30
  "devDependencies": {
31
31
  "fast-check": "^3.23.1"