runspec-node 0.31.0 → 0.33.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.
package/src/testing.ts CHANGED
@@ -314,18 +314,12 @@ export class ParseHarness {
314
314
  return argv;
315
315
  }
316
316
 
317
+ // `suppressSummary` is retained for back-compat but is now a no-op: the
318
+ // run-summary terminal echo is opt-in (`summary_console`, default off), so a
319
+ // parse never leaks the closing line into test output, and the audit record
320
+ // is written to the log file only — never the console.
317
321
  private withSummaryPinned<T>(fn: () => T): T {
318
- if (!this.suppressSummary) return fn();
319
- const prefix = this.scriptName.toUpperCase().replace(/-/g, '_');
320
- const key = `RUNSPEC_${prefix}_ARG_NO_SUMMARY`;
321
- const prev = process.env[key];
322
- process.env[key] = '1';
323
- try {
324
- return fn();
325
- } finally {
326
- if (prev === undefined) delete process.env[key];
327
- else process.env[key] = prev;
328
- }
322
+ return fn();
329
323
  }
330
324
  }
331
325
 
@@ -0,0 +1,170 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { parse, loadSpec } from '../src/parser';
5
+ import { resolveRunAs, verifyRunAsIdentity } from '../src/become';
6
+
7
+ function makeTmpConfig(toml: string): string {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-run-as-test-'));
9
+ const configPath = path.join(dir, 'runspec.toml');
10
+ fs.writeFileSync(configPath, toml);
11
+ return configPath;
12
+ }
13
+
14
+ // POSIX-only: the uid comparison needs process.geteuid + `id`.
15
+ const posixIt = process.geteuid ? test : test.skip;
16
+
17
+ // ── resolveRunAs ──────────────────────────────────────────────────────────────
18
+
19
+ describe('resolveRunAs', () => {
20
+ test('null/undefined/empty → no escalation', () => {
21
+ expect(resolveRunAs(undefined, 'host1')).toBe('');
22
+ expect(resolveRunAs(null, 'host1')).toBe('');
23
+ expect(resolveRunAs('', 'host1')).toBe('');
24
+ });
25
+
26
+ test('simple string', () => {
27
+ expect(resolveRunAs('oracle', 'anything')).toBe('oracle');
28
+ });
29
+
30
+ test('$ENV reference', () => {
31
+ process.env['ORACLE_RUN_AS'] = 'orasvc';
32
+ expect(resolveRunAs('$ORACLE_RUN_AS', 'h')).toBe('orasvc');
33
+ delete process.env['ORACLE_RUN_AS'];
34
+ expect(resolveRunAs('$ORACLE_RUN_AS', 'h')).toBe('');
35
+ });
36
+
37
+ test('per-host exact match, then default', () => {
38
+ const spec = { default: 'oracle', hosts: { 'box-01': 'dba', 'box-02': '' } };
39
+ expect(resolveRunAs(spec, 'box-01')).toBe('dba');
40
+ expect(resolveRunAs(spec, 'box-02')).toBe(''); // explicit no-escalation
41
+ expect(resolveRunAs(spec, 'other')).toBe('oracle');
42
+ });
43
+
44
+ test('patterns, full-match, first wins', () => {
45
+ const spec = { default: 'oracle', patterns: { '[lg]pexp[0-9]*': 'orasvc', 'prod[0-9]*': 'produser' } };
46
+ expect(resolveRunAs(spec, 'lpexp01')).toBe('orasvc');
47
+ expect(resolveRunAs(spec, 'prod7')).toBe('produser');
48
+ expect(resolveRunAs(spec, 'lpexp')).toBe('orasvc');
49
+ expect(resolveRunAs(spec, 'xlpexp01')).toBe('oracle'); // not a full match
50
+ });
51
+ });
52
+
53
+ // ── verifyRunAsIdentity ───────────────────────────────────────────────────────
54
+
55
+ describe('verifyRunAsIdentity', () => {
56
+ test('empty target is a no-op', () => {
57
+ expect(verifyRunAsIdentity('')).toBeNull();
58
+ });
59
+
60
+ posixIt('current uid (by number) matches', () => {
61
+ expect(verifyRunAsIdentity(String(process.geteuid!()))).toBeNull();
62
+ });
63
+
64
+ posixIt('nonexistent user is reported', () => {
65
+ const detail = verifyRunAsIdentity('nope-user-xyz');
66
+ expect(detail).toContain('does not exist');
67
+ });
68
+
69
+ posixIt('uid mismatch is reported', () => {
70
+ const realUid = process.geteuid!();
71
+ const spy = jest.spyOn(process, 'geteuid').mockReturnValue(999999);
72
+ try {
73
+ const detail = verifyRunAsIdentity(String(realUid));
74
+ expect(detail).toContain('999999');
75
+ expect(detail).toContain(`uid ${realUid}`);
76
+ } finally {
77
+ spy.mockRestore();
78
+ }
79
+ });
80
+
81
+ test('no POSIX uid model (Windows) is a no-op', () => {
82
+ const orig = process.geteuid;
83
+ (process as { geteuid?: () => number }).geteuid = undefined;
84
+ try {
85
+ expect(verifyRunAsIdentity('root')).toBeNull();
86
+ } finally {
87
+ (process as { geteuid?: () => number }).geteuid = orig;
88
+ }
89
+ });
90
+ });
91
+
92
+ // ── enforce_run_as wired into the parser ──────────────────────────────────────
93
+
94
+ function body(opts: { enforce?: string; configEnforce?: string } = {}): string {
95
+ return [
96
+ '[config]',
97
+ opts.configEnforce ?? '',
98
+ '',
99
+ '[tool]',
100
+ 'description = "x"',
101
+ 'run_as = "nope-user-xyz"',
102
+ opts.enforce ?? '',
103
+ '',
104
+ ].join('\n');
105
+ }
106
+
107
+ function doParse(opts: { enforce?: string; configEnforce?: string } = {}) {
108
+ return parse({ scriptName: 'tool', argv: [], configPath: makeTmpConfig(body(opts)) });
109
+ }
110
+
111
+ describe('enforce_run_as gate', () => {
112
+ posixIt('error is the default', () => {
113
+ expect(() => doParse()).toThrow(/Refusing to run 'tool'/);
114
+ });
115
+
116
+ posixIt('explicit error throws', () => {
117
+ expect(() => doParse({ enforce: 'enforce_run_as = "error"' })).toThrow(/Refusing to run/);
118
+ });
119
+
120
+ posixIt('warn continues and writes stderr', () => {
121
+ const spy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
122
+ try {
123
+ const args = doParse({ enforce: 'enforce_run_as = "warn"' });
124
+ expect(args.__runspec_script__).toBe('tool');
125
+ const written = spy.mock.calls.map((c) => String(c[0])).join('');
126
+ expect(written).toContain('⚠');
127
+ expect(written).toContain('nope-user-xyz');
128
+ } finally {
129
+ spy.mockRestore();
130
+ }
131
+ });
132
+
133
+ posixIt('off is silent', () => {
134
+ const spy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
135
+ try {
136
+ const args = doParse({ enforce: 'enforce_run_as = "off"' });
137
+ expect(args.__runspec_script__).toBe('tool');
138
+ expect(spy.mock.calls.length).toBe(0);
139
+ } finally {
140
+ spy.mockRestore();
141
+ }
142
+ });
143
+
144
+ posixIt('config default applies when runnable omits enforce_run_as', () => {
145
+ const spy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
146
+ try {
147
+ doParse({ configEnforce: 'enforce_run_as = "warn"' });
148
+ const written = spy.mock.calls.map((c) => String(c[0])).join('');
149
+ expect(written).toContain('⚠');
150
+ } finally {
151
+ spy.mockRestore();
152
+ }
153
+ });
154
+
155
+ posixIt('runnable value overrides a permissive config default', () => {
156
+ expect(() =>
157
+ doParse({ enforce: 'enforce_run_as = "error"', configEnforce: 'enforce_run_as = "off"' }),
158
+ ).toThrow(/Refusing to run/);
159
+ });
160
+
161
+ test('no run_as means no check', () => {
162
+ const args = parse({ scriptName: 'tool', argv: [], configPath: makeTmpConfig('[tool]\ndescription = "x"\n') });
163
+ expect(args.__runspec_script__).toBe('tool');
164
+ });
165
+
166
+ posixIt('loadSpec skips the check', () => {
167
+ const args = loadSpec({ scriptName: 'tool', configPath: makeTmpConfig(body({ enforce: 'enforce_run_as = "error"' })) });
168
+ expect(args.__runspec_script__).toBe('tool');
169
+ });
170
+ });
@@ -20,15 +20,14 @@ function tmpDir(): string {
20
20
  }
21
21
 
22
22
  function makeCfg(dir: string, overrides: Record<string, unknown> = {}): Parameters<typeof configureLogging>[0] {
23
- const { debug, noSummary, autonomy, agent, commandPath, ...logOverrides } = overrides as {
24
- debug?: boolean; noSummary?: boolean; autonomy?: string; agent?: boolean; commandPath?: string[];
23
+ const { debug, autonomy, agent, commandPath, ...logOverrides } = overrides as {
24
+ debug?: boolean; autonomy?: string; agent?: boolean; commandPath?: string[];
25
25
  } & Record<string, unknown>;
26
26
  return {
27
27
  logCfg: { rotate: 'midnight', keep: 7, summary: true, ...logOverrides },
28
28
  runnableName: 'myscript',
29
29
  configPath: path.join(dir, 'runspec.toml'),
30
30
  debug,
31
- noSummary,
32
31
  autonomy,
33
32
  agent,
34
33
  commandPath,
@@ -46,7 +45,6 @@ function captureStderr() {
46
45
 
47
46
  beforeEach(() => {
48
47
  _resetForTest();
49
- delete process.env['RUNSPEC_MYSCRIPT_ARG_NO_SUMMARY'];
50
48
  });
51
49
 
52
50
  afterAll(() => {
@@ -168,16 +166,6 @@ test('summary emit is idempotent', () => {
168
166
 
169
167
  // ── disable switches ─────────────────────────────────────────────────────────
170
168
 
171
- test('noSummary option disables summary', () => {
172
- const dir = tmpDir();
173
- configureLogging(makeCfg(dir, { noSummary: true }));
174
- const cap = captureStderr();
175
- emitRunSummary(); // no-op because state is null
176
- cap.restore();
177
- expect(cap.lines.join('')).not.toContain('runspec: ');
178
- expect(fs.existsSync(path.join(dir, 'logs', 'myscript.log'))).toBe(false);
179
- });
180
-
181
169
  test('summary=false in config disables the audit record', () => {
182
170
  const dir = tmpDir();
183
171
  configureLogging(makeCfg(dir, { summary: false }));
@@ -210,16 +198,6 @@ test('summary_console works independently of summary (line on, record off)', ()
210
198
  expect(records.find(o => o.logger === RUN_SUMMARY_LOGGER)).toBeUndefined();
211
199
  });
212
200
 
213
- test('RUNSPEC_MYSCRIPT_ARG_NO_SUMMARY=1 disables summary', () => {
214
- process.env['RUNSPEC_MYSCRIPT_ARG_NO_SUMMARY'] = '1';
215
- const dir = tmpDir();
216
- configureLogging(makeCfg(dir));
217
- const cap = captureStderr();
218
- emitRunSummary();
219
- cap.restore();
220
- expect(cap.lines.join('')).not.toContain('runspec: ');
221
- });
222
-
223
201
  // ── stderr line shape ────────────────────────────────────────────────────────
224
202
 
225
203
  test('success line uses abbreviated "warn"/"err" (no "warning"/"error")', () => {