keystone-cli 0.5.0 → 0.6.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.
Files changed (47) hide show
  1. package/README.md +55 -8
  2. package/package.json +5 -3
  3. package/src/cli.ts +33 -192
  4. package/src/db/memory-db.test.ts +54 -0
  5. package/src/db/memory-db.ts +122 -0
  6. package/src/db/sqlite-setup.ts +49 -0
  7. package/src/db/workflow-db.test.ts +41 -10
  8. package/src/db/workflow-db.ts +84 -28
  9. package/src/expression/evaluator.test.ts +19 -0
  10. package/src/expression/evaluator.ts +134 -39
  11. package/src/parser/schema.ts +41 -0
  12. package/src/runner/audit-verification.test.ts +23 -0
  13. package/src/runner/auto-heal.test.ts +64 -0
  14. package/src/runner/debug-repl.test.ts +74 -0
  15. package/src/runner/debug-repl.ts +225 -0
  16. package/src/runner/foreach-executor.ts +327 -0
  17. package/src/runner/llm-adapter.test.ts +27 -14
  18. package/src/runner/llm-adapter.ts +90 -112
  19. package/src/runner/llm-executor.test.ts +91 -6
  20. package/src/runner/llm-executor.ts +26 -6
  21. package/src/runner/mcp-client.audit.test.ts +69 -0
  22. package/src/runner/mcp-client.test.ts +12 -3
  23. package/src/runner/mcp-client.ts +199 -19
  24. package/src/runner/mcp-manager.ts +19 -8
  25. package/src/runner/mcp-server.test.ts +8 -5
  26. package/src/runner/mcp-server.ts +31 -17
  27. package/src/runner/optimization-runner.ts +305 -0
  28. package/src/runner/reflexion.test.ts +87 -0
  29. package/src/runner/shell-executor.test.ts +12 -0
  30. package/src/runner/shell-executor.ts +9 -6
  31. package/src/runner/step-executor.test.ts +46 -1
  32. package/src/runner/step-executor.ts +154 -60
  33. package/src/runner/stream-utils.test.ts +65 -0
  34. package/src/runner/stream-utils.ts +186 -0
  35. package/src/runner/workflow-runner.test.ts +4 -4
  36. package/src/runner/workflow-runner.ts +436 -251
  37. package/src/templates/agents/keystone-architect.md +6 -4
  38. package/src/templates/full-feature-demo.yaml +4 -4
  39. package/src/types/assets.d.ts +14 -0
  40. package/src/types/status.ts +1 -1
  41. package/src/ui/dashboard.tsx +38 -26
  42. package/src/utils/auth-manager.ts +3 -1
  43. package/src/utils/logger.test.ts +76 -0
  44. package/src/utils/logger.ts +39 -0
  45. package/src/utils/prompt.ts +75 -0
  46. package/src/utils/redactor.test.ts +86 -4
  47. package/src/utils/redactor.ts +48 -13
@@ -15,9 +15,10 @@ You are the Keystone Architect. Your goal is to design and generate high-quality
15
15
  - **inputs**: Map of `{ type: 'string'|'number'|'boolean'|'array'|'object', default: any, description: string }` under the `inputs` key.
16
16
  - **outputs**: Map of expressions (e.g., `${{ steps.id.output }}`) under the `outputs` key.
17
17
  - **env**: (Optional) Map of workflow-level environment variables.
18
- - **concurrency**: (Optional) Global concurrency limit for the workflow (number or expression).
18
+ - **concurrency**: (Optional) Global concurrency limit for the workflow (must be a positive integer or expression resolving to one).
19
+ - **eval**: (Optional) Configuration for prompt optimization `{ scorer: 'llm'|'script', agent, prompt, run }`.
19
20
  - **steps**: Array of step objects. Each step MUST have an `id` and a `type`:
20
- - **shell**: `{ id, type: 'shell', run, dir, env, transform }`
21
+ - **shell**: `{ id, type: 'shell', run, dir, env, allowInsecure, transform }` (Set `allowInsecure: true` to bypass risky command checks)
21
22
  - **llm**: `{ id, type: 'llm', agent, prompt, schema, provider, model, tools, maxIterations, useGlobalMcp, allowClarification, mcpServers }`
22
23
  - **workflow**: `{ id, type: 'workflow', path, inputs }`
23
24
  - **file**: `{ id, type: 'file', path, op: 'read'|'write'|'append', content }`
@@ -25,7 +26,8 @@ You are the Keystone Architect. Your goal is to design and generate high-quality
25
26
  - **human**: `{ id, type: 'human', message, inputType: 'confirm'|'text' }` (Note: 'confirm' returns boolean but automatically fallbacks to text if input is not yes/no)
26
27
  - **sleep**: `{ id, type: 'sleep', duration }` (duration can be a number or expression string)
27
28
  - **script**: `{ id, type: 'script', run, allowInsecure }` (Executes JS in a secure sandbox; set allowInsecure to true to allow fallback to insecure VM)
28
- - **Common Step Fields**: `needs` (array of IDs), `if` (expression), `timeout` (ms), `retry` (`{ count, backoff: 'linear'|'exponential', baseDelay }`), `foreach`, `concurrency`, `transform`.
29
+ - **memory**: `{ id, type: 'memory', op: 'search'|'store', query, text, model, metadata, limit }`
30
+ - **Common Step Fields**: `needs` (array of IDs), `if` (expression), `timeout` (ms), `retry` (`{ count, backoff: 'linear'|'exponential', baseDelay }`), `auto_heal` (`{ agent, maxAttempts, model }`), `reflexion` (`{ limit, hint }`), `learn` (boolean, auto-index for few-shot), `foreach`, `concurrency` (positive integer), `transform`.
29
31
  - **finally**: Optional array of steps to run at the end of the workflow, regardless of success or failure.
30
32
  - **IMPORTANT**: Steps run in **parallel** by default. To ensure sequential execution, a step must explicitly list the previous step's ID in its `needs` array.
31
33
 
@@ -41,7 +43,7 @@ Markdown files with YAML frontmatter:
41
43
  ## Expression Syntax
42
44
  - `${{ inputs.name }}`
43
45
  - `${{ steps.id.output }}`
44
- - `${{ steps.id.status }}`
46
+ - `${{ steps.id.status }}` (e.g., `'pending'`, `'running'`, `'success'`, `'failed'`, `'skipped'`)
45
47
  - `${{ args.paramName }}` (used inside agent tools)
46
48
  - Standard JS-like expressions: `${{ steps.count > 0 ? 'yes' : 'no' }}`
47
49
 
@@ -27,21 +27,21 @@ steps:
27
27
  - id: write_file
28
28
  type: file
29
29
  op: write
30
- path: /tmp/keystone-test.txt
30
+ path: ./tmp/keystone-test.txt
31
31
  content: "Test file created at ${{ steps.get_date.output }}"
32
32
 
33
33
  # Test file read
34
34
  - id: read_file
35
35
  type: file
36
36
  op: read
37
- path: /tmp/keystone-test.txt
37
+ path: ./tmp/keystone-test.txt
38
38
  needs: [write_file]
39
39
 
40
40
  # Test shell with dependencies
41
41
  - id: count_files
42
42
  type: shell
43
43
  needs: [write_file]
44
- run: ls /tmp/keystone-*.txt | wc -l
44
+ run: ls ./tmp/keystone-*.txt | wc -l
45
45
  transform: parseInt(stdout.trim())
46
46
 
47
47
  # Test conditional execution
@@ -66,4 +66,4 @@ steps:
66
66
  finally:
67
67
  - id: cleanup
68
68
  type: shell
69
- run: rm /tmp/keystone-test.txt
69
+ run: rm ./tmp/keystone-test.txt
@@ -0,0 +1,14 @@
1
+ declare module '*.md' {
2
+ const content: string;
3
+ export default content;
4
+ }
5
+
6
+ declare module '*.yaml' {
7
+ const content: string;
8
+ export default content;
9
+ }
10
+
11
+ declare module '*.yml' {
12
+ const content: string;
13
+ export default content;
14
+ }
@@ -15,7 +15,7 @@ export const StepStatus = {
15
15
  export type StepStatusType = (typeof StepStatus)[keyof typeof StepStatus];
16
16
 
17
17
  export const WorkflowStatus = {
18
- COMPLETED: 'completed',
18
+ SUCCESS: 'success',
19
19
  FAILED: 'failed',
20
20
  PAUSED: 'paused',
21
21
  SUSPENDED: 'suspended',
@@ -1,5 +1,5 @@
1
1
  import { Box, Newline, Text, render, useInput } from 'ink';
2
- import React, { useState, useEffect, useCallback } from 'react';
2
+ import React, { useState, useEffect, useCallback, useMemo } from 'react';
3
3
  import { WorkflowDb } from '../db/workflow-db.ts';
4
4
 
5
5
  interface Run {
@@ -14,39 +14,49 @@ const Dashboard = () => {
14
14
  const [runs, setRuns] = useState<Run[]>([]);
15
15
  const [loading, setLoading] = useState(true);
16
16
 
17
- const fetchData = useCallback(() => {
18
- const db = new WorkflowDb();
17
+ // Reuse database connection instead of creating new one every 2 seconds
18
+ const db = useMemo(() => new WorkflowDb(), []);
19
+
20
+ // Cleanup database connection on unmount
21
+ useEffect(() => {
22
+ return () => db.close();
23
+ }, [db]);
24
+
25
+ const fetchData = useCallback(async () => {
19
26
  try {
20
- const recentRuns = db.listRuns(10) as (Run & { outputs: string | null })[];
21
- const runsWithUsage = recentRuns.map((run) => {
22
- let total_tokens = 0;
23
- try {
24
- // Get steps to aggregate tokens if not in outputs (future-proofing)
25
- const steps = db.getStepsByRun(run.id);
26
- total_tokens = steps.reduce((sum, s) => {
27
- if (s.usage) {
28
- try {
29
- const u = JSON.parse(s.usage);
30
- return sum + (u.total_tokens || 0);
31
- } catch (e) {
32
- return sum;
27
+ const recentRuns = (await db.listRuns(10)) as (Run & { outputs: string | null })[];
28
+ const runsWithUsage = await Promise.all(
29
+ recentRuns.map(async (run) => {
30
+ let total_tokens = 0;
31
+ try {
32
+ // Get steps to aggregate tokens if not in outputs (future-proofing)
33
+ const steps = await db.getStepsByRun(run.id);
34
+ total_tokens = steps.reduce((sum, s) => {
35
+ if (s.usage) {
36
+ try {
37
+ const u = JSON.parse(s.usage);
38
+ return sum + (u.total_tokens || 0);
39
+ } catch (e) {
40
+ return sum;
41
+ }
33
42
  }
34
- }
35
- return sum;
36
- }, 0);
37
- } catch (e) {
38
- // Ignore write error
39
- }
40
- return { ...run, total_tokens };
41
- });
43
+ return sum;
44
+ }, 0);
45
+ } catch (e) {
46
+ // Ignore read error
47
+ }
48
+ return { ...run, total_tokens };
49
+ })
50
+ );
42
51
  setRuns(runsWithUsage);
43
52
  } catch (error) {
53
+ // Dashboard is UI, console.error is acceptable but let's be consistent if possible.
54
+ // For now we keep it as is or could use a toast.
44
55
  console.error('Failed to fetch runs:', error);
45
56
  } finally {
46
57
  setLoading(false);
47
- db.close();
48
58
  }
49
- }, []);
59
+ }, [db]);
50
60
 
51
61
  useEffect(() => {
52
62
  fetchData();
@@ -159,6 +169,7 @@ const Dashboard = () => {
159
169
  const getStatusColor = (status: string) => {
160
170
  switch (status.toLowerCase()) {
161
171
  case 'completed':
172
+ case 'success':
162
173
  return 'green';
163
174
  case 'failed':
164
175
  return 'red';
@@ -176,6 +187,7 @@ const getStatusColor = (status: string) => {
176
187
  const getStatusIcon = (status: string) => {
177
188
  switch (status.toLowerCase()) {
178
189
  case 'completed':
190
+ case 'success':
179
191
  return '✅';
180
192
  case 'failed':
181
193
  return '❌';
@@ -57,8 +57,9 @@ export class AuthManager {
57
57
  const path = AuthManager.getAuthPath();
58
58
  const current = AuthManager.load();
59
59
  try {
60
- writeFileSync(path, JSON.stringify({ ...current, ...data }, null, 2));
60
+ writeFileSync(path, JSON.stringify({ ...current, ...data }, null, 2), { mode: 0o600 });
61
61
  } catch (error) {
62
+ // Use ConsoleLogger as a safe fallback for top-level utility
62
63
  console.error(
63
64
  'Failed to save auth data:',
64
65
  error instanceof Error ? error.message : String(error)
@@ -196,6 +197,7 @@ export class AuthManager {
196
197
 
197
198
  return data.token;
198
199
  } catch (error) {
200
+ // Use ConsoleLogger as a safe fallback for top-level utility
199
201
  console.error('Error refreshing Copilot token:', error);
200
202
  return undefined;
201
203
  }
@@ -0,0 +1,76 @@
1
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
2
+ import { ConsoleLogger, SilentLogger } from './logger';
3
+
4
+ describe('ConsoleLogger', () => {
5
+ let logSpy: ReturnType<typeof spyOn>;
6
+ let warnSpy: ReturnType<typeof spyOn>;
7
+ let errorSpy: ReturnType<typeof spyOn>;
8
+ let infoSpy: ReturnType<typeof spyOn>;
9
+ let debugSpy: ReturnType<typeof spyOn>;
10
+ const originalDebug = process.env.DEBUG;
11
+ const originalVerbose = process.env.VERBOSE;
12
+
13
+ beforeEach(() => {
14
+ logSpy = spyOn(console, 'log').mockImplementation(() => {});
15
+ warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
16
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
17
+ infoSpy = spyOn(console, 'info').mockImplementation(() => {});
18
+ debugSpy = spyOn(console, 'debug').mockImplementation(() => {});
19
+ });
20
+
21
+ afterEach(() => {
22
+ logSpy.mockRestore();
23
+ warnSpy.mockRestore();
24
+ errorSpy.mockRestore();
25
+ infoSpy.mockRestore();
26
+ debugSpy.mockRestore();
27
+ if (originalDebug === undefined) {
28
+ process.env.DEBUG = undefined;
29
+ } else {
30
+ process.env.DEBUG = originalDebug;
31
+ }
32
+ if (originalVerbose === undefined) {
33
+ process.env.VERBOSE = undefined;
34
+ } else {
35
+ process.env.VERBOSE = originalVerbose;
36
+ }
37
+ });
38
+
39
+ it('writes to console methods', () => {
40
+ const logger = new ConsoleLogger();
41
+ logger.log('log');
42
+ logger.warn('warn');
43
+ logger.error('error');
44
+ logger.info('info');
45
+
46
+ expect(logSpy).toHaveBeenCalledWith('log');
47
+ expect(warnSpy).toHaveBeenCalledWith('warn');
48
+ expect(errorSpy).toHaveBeenCalledWith('error');
49
+ expect(infoSpy).toHaveBeenCalledWith('info');
50
+ });
51
+
52
+ it('logs debug only when DEBUG or VERBOSE is set', () => {
53
+ const logger = new ConsoleLogger();
54
+
55
+ process.env.DEBUG = undefined;
56
+ process.env.VERBOSE = undefined;
57
+ logger.debug('quiet');
58
+ expect(debugSpy).not.toHaveBeenCalled();
59
+
60
+ process.env.DEBUG = '1';
61
+ logger.debug('loud');
62
+ expect(debugSpy).toHaveBeenCalledWith('loud');
63
+ });
64
+ });
65
+
66
+ describe('SilentLogger', () => {
67
+ it('ignores all log calls', () => {
68
+ const logger = new SilentLogger();
69
+ logger.log('log');
70
+ logger.warn('warn');
71
+ logger.error('error');
72
+ logger.info('info');
73
+ logger.debug('debug');
74
+ expect(true).toBe(true);
75
+ });
76
+ });
@@ -0,0 +1,39 @@
1
+ export interface Logger {
2
+ log(message: string): void;
3
+ error(message: string): void;
4
+ warn(message: string): void;
5
+ info(message: string): void;
6
+ debug?(message: string): void;
7
+ }
8
+
9
+ export class ConsoleLogger implements Logger {
10
+ log(message: string): void {
11
+ console.log(message);
12
+ }
13
+
14
+ error(message: string): void {
15
+ console.error(message);
16
+ }
17
+
18
+ warn(message: string): void {
19
+ console.warn(message);
20
+ }
21
+
22
+ info(message: string): void {
23
+ console.info(message);
24
+ }
25
+
26
+ debug(message: string): void {
27
+ if (process.env.DEBUG || process.env.VERBOSE) {
28
+ console.debug(message);
29
+ }
30
+ }
31
+ }
32
+
33
+ export class SilentLogger implements Logger {
34
+ log(_message: string): void {}
35
+ error(_message: string): void {}
36
+ warn(_message: string): void {}
37
+ info(_message: string): void {}
38
+ debug(_message: string): void {}
39
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Prompts the user for a secret/password input, masking the characters with *.
3
+ * @param promptText The text to display before the input
4
+ * @returns The secret string entered by the user
5
+ */
6
+ export async function promptSecret(promptText: string): Promise<string> {
7
+ const { stdin, stdout } = process;
8
+
9
+ if (!stdin.isTTY) {
10
+ // Non-interactive mode: just read a line
11
+ stdout.write(promptText);
12
+ const readline = require('node:readline');
13
+ const rl = readline.createInterface({
14
+ input: stdin,
15
+ terminal: false,
16
+ });
17
+
18
+ return new Promise((resolve) => {
19
+ rl.on('line', (line: string) => {
20
+ rl.close();
21
+ resolve(line.trim());
22
+ });
23
+
24
+ rl.on('close', () => {
25
+ resolve('');
26
+ });
27
+ });
28
+ }
29
+
30
+ return new Promise((resolve) => {
31
+ stdout.write(promptText);
32
+ stdin.setRawMode(true);
33
+ stdin.resume();
34
+ stdin.setEncoding('utf8');
35
+
36
+ let input = '';
37
+
38
+ const handler = (char: string) => {
39
+ // Ctrl+C (End of Text)
40
+ if (char === '\u0003') {
41
+ stdin.setRawMode(false);
42
+ stdin.pause();
43
+ stdout.write('\n^C\n');
44
+ process.exit(130);
45
+ }
46
+
47
+ // Enter (Carriage Return)
48
+ if (char === '\r' || char === '\n') {
49
+ stdin.setRawMode(false);
50
+ stdin.pause();
51
+ stdin.removeListener('data', handler);
52
+ stdout.write('\n');
53
+ resolve(input);
54
+ return;
55
+ }
56
+
57
+ // Backspace
58
+ if (char === '\u007f' || char === '\u0008') {
59
+ if (input.length > 0) {
60
+ input = input.slice(0, -1);
61
+ stdout.write('\b \b'); // Move back, overwrite with space, move back again
62
+ }
63
+ return;
64
+ }
65
+
66
+ // Normal characters
67
+ if (char.length === 1 && char.charCodeAt(0) >= 32) {
68
+ input += char;
69
+ stdout.write('*');
70
+ }
71
+ };
72
+
73
+ stdin.on('data', handler);
74
+ });
75
+ }
@@ -58,15 +58,97 @@ describe('Redactor', () => {
58
58
  expect(redactor.redact(123)).toBe(123);
59
59
  });
60
60
 
61
- it('should ignore secrets shorter than 3 characters', () => {
62
- const shortRedactor = new Redactor({ S1: 'a', S2: '12', S3: 'abc' });
61
+ it('should ignore secrets shorter than 3 characters for sensitive keys', () => {
62
+ const shortRedactor = new Redactor({ S1: 'a', S2: '12', TOKEN: 'abc' });
63
63
  const text = 'a and 12 are safe, but abc is a secret';
64
64
  expect(shortRedactor.redact(text)).toBe('a and 12 are safe, but ***REDACTED*** is a secret');
65
65
  });
66
66
 
67
67
  it('should not redact substrings of larger words when using alphanumeric secrets', () => {
68
- const wordRedactor = new Redactor({ USER: 'mark' });
69
- const text = 'mark went to the marketplace';
68
+ const wordRedactor = new Redactor({ USER: 'mark-long-enough' });
69
+ const text = 'mark-long-enough went to the marketplace';
70
70
  expect(wordRedactor.redact(text)).toBe('***REDACTED*** went to the marketplace');
71
71
  });
72
+
73
+ it('should NOT redact common words in the blocklist', () => {
74
+ const blocklistRedactor = new Redactor({
75
+ STATUS: 'success',
76
+ DEBUG: 'true',
77
+ LEVEL: 'info',
78
+ });
79
+ const text = 'Operation was a success with info level and true flag';
80
+ expect(blocklistRedactor.redact(text)).toBe(text);
81
+ });
82
+
83
+ it('should redact short values only for sensitive keys', () => {
84
+ const mixedRedactor = new Redactor({
85
+ PASSWORD: 'abc', // sensitive key, short value
86
+ OTHER: 'def', // non-sensitive key, short value
87
+ LONG: 'this-is-long-enough', // non-sensitive, long value
88
+ });
89
+ const text = 'pwd: abc, other: def, long: this-is-long-enough';
90
+ expect(mixedRedactor.redact(text)).toBe(
91
+ 'pwd: ***REDACTED***, other: def, long: ***REDACTED***'
92
+ );
93
+ });
94
+
95
+ it('should ignore non-sensitive values shorter than 10 characters', () => {
96
+ const thresholdRedactor = new Redactor({
97
+ S1: '123456789', // 9 chars
98
+ S2: '1234567890', // 10 chars
99
+ });
100
+ const text = 'S1 is 123456789 and S2 is 1234567890';
101
+ expect(thresholdRedactor.redact(text)).toBe('S1 is 123456789 and S2 is ***REDACTED***');
102
+ });
103
+
104
+ it('should respect word boundaries for short secrets', () => {
105
+ const shortRedactor = new Redactor({ TOKEN: 'key' }); // 'key' is < 5 chars but TOKEN is sensitive
106
+ // Should redact " key " but NOT "keyboard" or "donkey"
107
+ const text = 'The key is on the keyboard near the donkey.';
108
+ expect(shortRedactor.redact(text)).toBe(
109
+ 'The ***REDACTED*** is on the keyboard near the donkey.'
110
+ );
111
+ });
112
+ });
113
+
114
+ describe('RedactionBuffer', () => {
115
+ const redactor = new Redactor({ SECRET: 'super-secret-value' });
116
+
117
+ it(' should redact secrets across chunks', () => {
118
+ const buffer = new (require('./redactor').RedactionBuffer)(redactor);
119
+ const chunk1 = 'This is sup';
120
+ const chunk2 = 'er-secret-value in parts.';
121
+
122
+ const out1 = buffer.process(chunk1);
123
+ const out2 = buffer.process(chunk2);
124
+ const out3 = buffer.flush();
125
+
126
+ expect(out1 + out2 + out3).toContain('***REDACTED***');
127
+ expect(out1 + out2 + out3).not.toContain('super-secret-value');
128
+ });
129
+
130
+ it('should not leak partial secrets in process()', () => {
131
+ const buffer = new (require('./redactor').RedactionBuffer)(redactor);
132
+ // Secret is 18 chars long.
133
+ // 'super-s' is 7 chars.
134
+ const chunk = 'super-s';
135
+ const output = buffer.process(chunk);
136
+
137
+ // Should not output anything if it could be the start of a secret
138
+ expect(output).toBe('');
139
+ expect(buffer.flush()).toBe('super-s');
140
+ });
141
+
142
+ it('should handle multiple secrets in stream', () => {
143
+ const multiRedactor = new Redactor({ S1: 'secret-one', S2: 'secret-two' });
144
+ const buffer = new (require('./redactor').RedactionBuffer)(multiRedactor);
145
+
146
+ const text = 'S1: secret-one, S2: secret-two';
147
+ const out1 = buffer.process(text.substring(0, 15));
148
+ const out2 = buffer.process(text.substring(15));
149
+ const out3 = buffer.flush();
150
+
151
+ const full = out1 + out2 + out3;
152
+ expect(full).toBe('S1: ***REDACTED***, S2: ***REDACTED***');
153
+ });
72
154
  });
@@ -7,6 +7,8 @@
7
7
 
8
8
  export class Redactor {
9
9
  private patterns: RegExp[] = [];
10
+ private combinedPattern: RegExp | null = null;
11
+ public readonly maxSecretLength: number;
10
12
 
11
13
  constructor(secrets: Record<string, string>) {
12
14
  // Keys that indicate high sensitivity - always redact their values regardless of length
@@ -28,23 +30,57 @@ export class Redactor {
28
30
  // Extract all secret values
29
31
  // We filter based on:
30
32
  // 1. Value must be a string and not empty
31
- // 2. Either the key indicates high sensitivity OR length >= 3
33
+ // 2. Value must not be in the blocklist of common words
34
+ // 3. Either the key indicates high sensitivity OR length >= 10 (conservative limit for unknown values)
32
35
  const secretsToRedact = new Set<string>();
33
36
 
37
+ const valueBlocklist = new Set([
38
+ 'true',
39
+ 'false',
40
+ 'null',
41
+ 'undefined',
42
+ 'info',
43
+ 'warn',
44
+ 'error',
45
+ 'debug',
46
+ 'success',
47
+ 'pending',
48
+ 'failed',
49
+ 'skipped',
50
+ 'suspended',
51
+ 'default',
52
+ 'public',
53
+ 'private',
54
+ 'protected',
55
+ ]);
56
+
34
57
  for (const [key, value] of Object.entries(secrets)) {
35
- if (!value) continue;
58
+ if (!value || typeof value !== 'string') continue;
59
+
60
+ const lowerValue = value.toLowerCase();
61
+ if (valueBlocklist.has(lowerValue)) continue;
36
62
 
37
63
  const lowerKey = key.toLowerCase();
38
64
  // Check if key contains any sensitive term
39
65
  const isSensitiveKey = Array.from(sensitiveKeys).some((k) => lowerKey.includes(k));
40
66
 
41
- if (isSensitiveKey || value.length >= 3) {
67
+ // If it's a sensitive key, redact it even if short (>=3)
68
+ // Otherwise, only redact if it's long enough to be likely a real secret
69
+ if (isSensitiveKey) {
70
+ if (value.length >= 3) {
71
+ secretsToRedact.add(value);
72
+ }
73
+ } else if (value.length >= 10) {
42
74
  secretsToRedact.add(value);
43
75
  }
44
76
  }
45
77
 
46
78
  const uniqueSecrets = Array.from(secretsToRedact).sort((a, b) => b.length - a.length);
47
79
 
80
+ // Build regex patterns
81
+ // Optimization: Group secrets into a single combined regex where possible
82
+ const parts: string[] = [];
83
+
48
84
  for (const secret of uniqueSecrets) {
49
85
  // Escape special regex characters in the secret
50
86
  const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -52,24 +88,23 @@ export class Redactor {
52
88
  // Use word boundaries if the secret starts/ends with an alphanumeric character
53
89
  // to avoid partial matches (e.g. redacting 'mark' in 'marketplace')
54
90
  // BUT only if length is small (< 5), otherwise matching inside strings is desirable
55
- let pattern: RegExp;
56
91
  if (secret.length < 5) {
57
92
  const startBoundary = /^\w/.test(secret) ? '\\b' : '';
58
93
  const endBoundary = /\w$/.test(secret) ? '\\b' : '';
59
- pattern = new RegExp(`${startBoundary}${escaped}${endBoundary}`, 'g');
94
+ parts.push(`${startBoundary}${escaped}${endBoundary}`);
60
95
  } else {
61
- pattern = new RegExp(escaped, 'g');
96
+ parts.push(escaped);
62
97
  }
98
+ }
63
99
 
64
- this.patterns.push(pattern);
100
+ if (parts.length > 0) {
101
+ this.combinedPattern = new RegExp(parts.join('|'), 'g');
65
102
  }
66
103
 
67
104
  // Capture the maximum length for buffering purposes
68
105
  this.maxSecretLength = uniqueSecrets.reduce((max, s) => Math.max(max, s.length), 0);
69
106
  }
70
107
 
71
- public readonly maxSecretLength: number;
72
-
73
108
  /**
74
109
  * Redact all secrets from a string
75
110
  */
@@ -78,11 +113,11 @@ export class Redactor {
78
113
  return text;
79
114
  }
80
115
 
81
- let redacted = text;
82
- for (const pattern of this.patterns) {
83
- redacted = redacted.replace(pattern, '***REDACTED***');
116
+ if (!this.combinedPattern) {
117
+ return text;
84
118
  }
85
- return redacted;
119
+
120
+ return text.replace(this.combinedPattern, '***REDACTED***');
86
121
  }
87
122
 
88
123
  /**