testronaut 1.1.2 → 1.2.1
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/CONTRIBUTING.md +194 -0
- package/bin/cli.js +1 -1
- package/bin/init.js +141 -57
- package/bin/initHelpers.js +140 -0
- package/core/agent.js +1 -1
- package/core/redaction.js +161 -33
- package/core/turnLoop.js +170 -107
- package/llm/gemini/geminiProvider.js +188 -0
- package/llm/llmFactory.js +50 -0
- package/llm/modelResolver.js +65 -0
- package/llm/openAI/openaiProvider.js +54 -0
- package/package.json +9 -3
- package/tests/binTests/init.test.js +189 -0
- package/tests/binTests/initHelpersTests/defaultConfig.test.js +12 -0
- package/tests/binTests/initHelpersTests/makeEnvTemplate.test.js +14 -0
- package/tests/binTests/initHelpersTests/migrateLegacyConfig.test.js +19 -0
- package/tests/binTests/initHelpersTests/models.test.js +21 -0
- package/tests/coreTests/redation.test.js +118 -0
- package/tests/coreTests/turnLoop.test.js +177 -0
- package/tests/llmTests/geminiProvider.test.js +191 -0
- package/tests/llmTests/llmFactory.test.js +78 -0
- package/tests/llmTests/modelResolver.test.js +119 -0
- package/tests/llmTests/openAIProvider.test.js +117 -0
- package/tests/toolsTests/tokenControl.test.js +150 -0
- package/tools/tokenControl.js +107 -17
- package/vitest.config.js +8 -0
- package/openAI/modelResolver.js +0 -33
package/core/redaction.js
CHANGED
|
@@ -1,78 +1,206 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* redaction.js
|
|
3
|
+
* -------------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Centralized redaction utilities for Testronaut logs and plans.
|
|
6
|
+
* - Single source of truth for sensitive keywords
|
|
7
|
+
* - Masks tool args when targeting sensitive fields (e.g., #access, password inputs)
|
|
8
|
+
* - Masks free-form instructions that reveal credentials
|
|
9
|
+
*
|
|
10
|
+
* Design goals:
|
|
11
|
+
* - No side effects (pure functions)
|
|
12
|
+
* - Easy to extend by editing one keyword list
|
|
13
|
+
* - Unit testable via Vitest
|
|
14
|
+
*
|
|
15
|
+
* Related tests:
|
|
16
|
+
* See `tests/core/redaction.test.js`
|
|
17
|
+
*
|
|
18
|
+
* Used by:
|
|
19
|
+
* - CLI log/plan renderers
|
|
20
|
+
* - Tool call serializers (type/fill)
|
|
21
|
+
*
|
|
22
|
+
* Example usage:
|
|
23
|
+
* const safeArgs = redactArgs('fill', { selector: '#access', text: 'P@ssw0rd' });
|
|
24
|
+
* const safePlan = redactPasswordInText(`Type "P@ssw0rd" into #access`);
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Sensitive Keywords (Single Source of Truth)
|
|
29
|
+
// Add/remove terms here and every consumer updates automatically.
|
|
30
|
+
// Hyphenated forms are expanded to also match `_` and spaces (api-key/api_key/api key).
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export const SENSITIVE_KEYWORDS = [
|
|
34
|
+
'pass',
|
|
35
|
+
'password',
|
|
36
|
+
'pwd',
|
|
37
|
+
'passwd',
|
|
38
|
+
'access',
|
|
39
|
+
'secret',
|
|
40
|
+
'token',
|
|
41
|
+
'api-key',
|
|
42
|
+
'apikey',
|
|
43
|
+
'bearer',
|
|
44
|
+
'auth',
|
|
45
|
+
'pin',
|
|
46
|
+
'otp',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Builds a single RegExp that treats hyphens as `[-_\s]?` to catch variants.
|
|
51
|
+
* @returns {RegExp}
|
|
52
|
+
*/
|
|
53
|
+
function buildSensitiveRegex() {
|
|
54
|
+
// Expand hyphen variants: "api-key" -> "api[-_\s]?key"
|
|
55
|
+
const parts = SENSITIVE_KEYWORDS.map(k => k.replace(/-/g, '[-_\\s]?'));
|
|
56
|
+
return new RegExp(`\\b(${parts.join('|')})\\b`, 'i');
|
|
57
|
+
}
|
|
2
58
|
|
|
59
|
+
const SENSITIVE_RE = buildSensitiveRegex();
|
|
60
|
+
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// Core helpers
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Safe clone: prefer structuredClone, fallback to JSON.
|
|
67
|
+
* @template T
|
|
68
|
+
* @param {T} obj
|
|
69
|
+
* @returns {T}
|
|
70
|
+
*/
|
|
3
71
|
function clone(obj) {
|
|
4
72
|
try { return structuredClone(obj); } catch { return JSON.parse(JSON.stringify(obj ?? {})); }
|
|
5
73
|
}
|
|
6
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Returns a masked preview. If showLength is true, includes length for debugging.
|
|
77
|
+
* @param {unknown} val
|
|
78
|
+
* @param {boolean} [showLength=true]
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
7
81
|
export function maskPreview(val, showLength = true) {
|
|
8
82
|
const s = String(val ?? '');
|
|
9
83
|
if (!s) return '••••••';
|
|
10
|
-
// Show length for debugging without leaking the value
|
|
11
84
|
return showLength ? `•••••• (${s.length})` : '••••••';
|
|
12
85
|
}
|
|
13
86
|
|
|
14
|
-
|
|
15
|
-
|
|
87
|
+
/**
|
|
88
|
+
* True if the provided string contains sensitive hints (password, token, access, etc.).
|
|
89
|
+
* @param {string} [str='']
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
function hasSensitiveHint(str = '') {
|
|
93
|
+
return SENSITIVE_RE.test(String(str || ''));
|
|
16
94
|
}
|
|
17
95
|
|
|
18
|
-
|
|
96
|
+
/**
|
|
97
|
+
* True if an HTML input type itself is sensitive.
|
|
98
|
+
* @param {string} [t='']
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
function isSensitiveInputType(t = '') {
|
|
102
|
+
return String(t || '').toLowerCase() === 'password';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Returns true if a tool call (type/fill) looks like it targets a sensitive field.
|
|
107
|
+
* We check selector/label/placeholder/name/role/testId OR inputType=password.
|
|
108
|
+
*
|
|
109
|
+
* @param {'type'|'fill'|string} fnName
|
|
110
|
+
* @param {object} [args={}]
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
function isSensitiveCall(fnName, args = {}) {
|
|
19
114
|
if (!(fnName === 'type' || fnName === 'fill')) return false;
|
|
20
|
-
const { selector, label, placeholder, name, inputType, role, testId } = args;
|
|
115
|
+
const { selector, label, placeholder, name, inputType, role, testId } = args || {};
|
|
21
116
|
return (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
117
|
+
hasSensitiveHint(selector) ||
|
|
118
|
+
hasSensitiveHint(label) ||
|
|
119
|
+
hasSensitiveHint(placeholder) ||
|
|
120
|
+
hasSensitiveHint(name) ||
|
|
121
|
+
isSensitiveInputType(inputType) ||
|
|
122
|
+
hasSensitiveHint(role) ||
|
|
123
|
+
hasSensitiveHint(testId)
|
|
29
124
|
);
|
|
30
125
|
}
|
|
31
126
|
|
|
127
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
// Public API
|
|
129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
32
131
|
/**
|
|
33
|
-
* Redact ONLY
|
|
34
|
-
* -
|
|
35
|
-
* -
|
|
132
|
+
* Redact ONLY secret-like values in tool args (shallow).
|
|
133
|
+
* - Masks any property whose *key* name looks sensitive (e.g., apiKey, token)
|
|
134
|
+
* - If the call targets a sensitive field, masks common value props (text/value/input/keys)
|
|
135
|
+
*
|
|
136
|
+
* @param {'type'|'fill'|string} fnName
|
|
137
|
+
* @param {Record<string, unknown>} [args={}]
|
|
138
|
+
* @param {{ showLength?: boolean }} [opts]
|
|
139
|
+
* @returns {Record<string, unknown>}
|
|
36
140
|
*/
|
|
37
141
|
export function redactArgs(fnName, args = {}, { showLength = true } = {}) {
|
|
38
142
|
const out = clone(args);
|
|
39
143
|
|
|
40
|
-
// Mask direct props
|
|
144
|
+
// Mask direct props with sensitive-looking keys
|
|
41
145
|
for (const k of Object.keys(out || {})) {
|
|
42
|
-
if (
|
|
146
|
+
if (hasSensitiveHint(k)) {
|
|
43
147
|
out[k] = maskPreview(out[k], showLength);
|
|
44
148
|
}
|
|
45
149
|
}
|
|
46
150
|
|
|
47
|
-
//
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if ('keys' in out) out.keys = maskPreview(out.keys, showLength);
|
|
151
|
+
// Mask value-like props when the call targets a sensitive field
|
|
152
|
+
if (isSensitiveCall(fnName, args)) {
|
|
153
|
+
for (const key of ['text', 'value', 'input', 'keys']) {
|
|
154
|
+
if (key in out) out[key] = maskPreview(out[key], showLength);
|
|
155
|
+
}
|
|
53
156
|
}
|
|
54
157
|
|
|
55
158
|
return out;
|
|
56
159
|
}
|
|
57
160
|
|
|
58
161
|
/**
|
|
59
|
-
* Redact
|
|
60
|
-
*
|
|
61
|
-
*
|
|
162
|
+
* Redact sensitive literals in freeform mission text.
|
|
163
|
+
*
|
|
164
|
+
* Cases:
|
|
165
|
+
* 1) Sensitive-word ... "VALUE"
|
|
166
|
+
* e.g., `password is "hunter2"`
|
|
167
|
+
*
|
|
168
|
+
* 2) Sensitive-word ... (with|as|to|=) VALUE
|
|
169
|
+
* e.g., `token=abcd1234`, `auth with xyz`
|
|
170
|
+
*
|
|
171
|
+
* 3) "VALUE" ... into/in/on/to TARGET (TARGET contains a sensitive hint)
|
|
172
|
+
* e.g., `Type "Imp0st3r123!" into #access`
|
|
173
|
+
*
|
|
174
|
+
* @param {string} [text='']
|
|
175
|
+
* @param {{ showLength?: boolean }} [opts]
|
|
176
|
+
* @returns {string}
|
|
62
177
|
*/
|
|
63
178
|
export function redactPasswordInText(text = '', { showLength = true } = {}) {
|
|
64
179
|
let s = String(text ?? '');
|
|
65
180
|
|
|
66
|
-
//
|
|
181
|
+
// Build a reusable source pattern for SENSITIVE_KEYWORDS with hyphen expansion
|
|
182
|
+
const kwParts = SENSITIVE_KEYWORDS.map(k => k.replace(/-/g, '[-_\\s]?'));
|
|
183
|
+
const kwGroup = `(${kwParts.join('|')})`;
|
|
184
|
+
|
|
185
|
+
// Case 1: keyword ... "VALUE"
|
|
67
186
|
s = s.replace(
|
|
68
|
-
|
|
69
|
-
(_m, pre,
|
|
187
|
+
new RegExp(`(\\b${kwGroup}\\b[^"'\\\\\\n]{0,80}["'])([^"']+)(["'])`, 'gi'),
|
|
188
|
+
(_m, pre, _kw, secret, post) => pre + maskPreview(secret, showLength) + post
|
|
70
189
|
);
|
|
71
190
|
|
|
72
|
-
// Case 2:
|
|
191
|
+
// Case 2: keyword ... (with|as|to|=) VALUE
|
|
73
192
|
s = s.replace(
|
|
74
|
-
|
|
75
|
-
(_m, pre,
|
|
193
|
+
new RegExp(`(\\b${kwGroup}\\b[^.\\n]{0,80}?\\b(with|as|to|=)\\s*)([^\\s"'\\\`]+)`, 'gi'),
|
|
194
|
+
(_m, pre, _kw1, _kw2, secret) => pre + maskPreview(secret, showLength)
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Case 3: "VALUE" ... into/in/on/to TARGET (TARGET looks sensitive)
|
|
198
|
+
s = s.replace(
|
|
199
|
+
/(["'`])([^"'`]+)\1\s+(?:into|in|on|to)\s+([^\s,.;:]+)/gi,
|
|
200
|
+
(_m, q, val, target) => {
|
|
201
|
+
if (hasSensitiveHint(target)) return q + maskPreview(val, showLength) + q + ' into ' + target;
|
|
202
|
+
return _m;
|
|
203
|
+
}
|
|
76
204
|
);
|
|
77
205
|
|
|
78
206
|
return s;
|