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.
- package/README.md +55 -8
- package/package.json +5 -3
- package/src/cli.ts +33 -192
- package/src/db/memory-db.test.ts +54 -0
- package/src/db/memory-db.ts +122 -0
- package/src/db/sqlite-setup.ts +49 -0
- package/src/db/workflow-db.test.ts +41 -10
- package/src/db/workflow-db.ts +84 -28
- package/src/expression/evaluator.test.ts +19 -0
- package/src/expression/evaluator.ts +134 -39
- package/src/parser/schema.ts +41 -0
- package/src/runner/audit-verification.test.ts +23 -0
- package/src/runner/auto-heal.test.ts +64 -0
- package/src/runner/debug-repl.test.ts +74 -0
- package/src/runner/debug-repl.ts +225 -0
- package/src/runner/foreach-executor.ts +327 -0
- package/src/runner/llm-adapter.test.ts +27 -14
- package/src/runner/llm-adapter.ts +90 -112
- package/src/runner/llm-executor.test.ts +91 -6
- package/src/runner/llm-executor.ts +26 -6
- package/src/runner/mcp-client.audit.test.ts +69 -0
- package/src/runner/mcp-client.test.ts +12 -3
- package/src/runner/mcp-client.ts +199 -19
- package/src/runner/mcp-manager.ts +19 -8
- package/src/runner/mcp-server.test.ts +8 -5
- package/src/runner/mcp-server.ts +31 -17
- package/src/runner/optimization-runner.ts +305 -0
- package/src/runner/reflexion.test.ts +87 -0
- package/src/runner/shell-executor.test.ts +12 -0
- package/src/runner/shell-executor.ts +9 -6
- package/src/runner/step-executor.test.ts +46 -1
- package/src/runner/step-executor.ts +154 -60
- package/src/runner/stream-utils.test.ts +65 -0
- package/src/runner/stream-utils.ts +186 -0
- package/src/runner/workflow-runner.test.ts +4 -4
- package/src/runner/workflow-runner.ts +436 -251
- package/src/templates/agents/keystone-architect.md +6 -4
- package/src/templates/full-feature-demo.yaml +4 -4
- package/src/types/assets.d.ts +14 -0
- package/src/types/status.ts +1 -1
- package/src/ui/dashboard.tsx +38 -26
- package/src/utils/auth-manager.ts +3 -1
- package/src/utils/logger.test.ts +76 -0
- package/src/utils/logger.ts +39 -0
- package/src/utils/prompt.ts +75 -0
- package/src/utils/redactor.test.ts +86 -4
- 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 (
|
|
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
|
-
- **
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
69
|
+
run: rm ./tmp/keystone-test.txt
|
package/src/types/status.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
+
SUCCESS: 'success',
|
|
19
19
|
FAILED: 'failed',
|
|
20
20
|
PAUSED: 'paused',
|
|
21
21
|
SUSPENDED: 'suspended',
|
package/src/ui/dashboard.tsx
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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 =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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',
|
|
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
|
});
|
package/src/utils/redactor.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
94
|
+
parts.push(`${startBoundary}${escaped}${endBoundary}`);
|
|
60
95
|
} else {
|
|
61
|
-
|
|
96
|
+
parts.push(escaped);
|
|
62
97
|
}
|
|
98
|
+
}
|
|
63
99
|
|
|
64
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
redacted = redacted.replace(pattern, '***REDACTED***');
|
|
116
|
+
if (!this.combinedPattern) {
|
|
117
|
+
return text;
|
|
84
118
|
}
|
|
85
|
-
|
|
119
|
+
|
|
120
|
+
return text.replace(this.combinedPattern, '***REDACTED***');
|
|
86
121
|
}
|
|
87
122
|
|
|
88
123
|
/**
|