imprint-mcp 0.2.0 → 0.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/README.md +1 -1
- package/package.json +1 -1
- package/prompts/compile-agent.md +1 -1
- package/src/cli.ts +2 -2
- package/src/imprint/compile-agent.ts +2 -2
- package/src/imprint/compile.ts +1 -1
- package/src/imprint/credential-extract.ts +174 -25
- package/src/imprint/emit.ts +85 -0
- package/src/imprint/sensitive-keys.ts +141 -7
- package/src/imprint/teach-state.ts +7 -0
- package/src/imprint/teach.ts +127 -6
package/README.md
CHANGED
|
@@ -156,7 +156,7 @@ Shows which providers are detected. Interactive `imprint teach` prompts you to c
|
|
|
156
156
|
|
|
157
157
|
To force a specific provider and skip the picker, pass `--provider <name>` to `teach`, `generate`, or `compile-playbook`. `teach` and `generate` require a compile-agent provider (`claude-cli`, `codex-cli`, or `anthropic-api`); `compile-playbook` can also use `cursor-cli`.
|
|
158
158
|
|
|
159
|
-
After selecting a provider, `teach` prompts for a **model** (e.g. `claude-opus-4-7` vs `claude-sonnet-4-6` for Anthropic, `gpt-5.4` vs `o3` for Codex). Override with `--model <name>`. Each tool compiles with a **
|
|
159
|
+
After selecting a provider, `teach` prompts for a **model** (e.g. `claude-opus-4-7` vs `claude-sonnet-4-6` for Anthropic, `gpt-5.4` vs `o3` for Codex). Override with `--model <name>`. Each tool compiles with a **20-minute timeout** by default — the compile agent writes the MCP server and runs thorough verification tests, so most complex tools take 10-15 minutes. Override with `--timeout <duration>` (e.g. `--timeout 30m`, `--timeout 1h`). To persist the generated tests after compilation, set `IMPRINT_KEEP_TEST=1` or pass `--keep-test`. To skip the replay-and-diff stage (the automated second pass that classifies ephemeral vs constant values), pass `--skip-replay` — faster, but may reduce workflow accuracy for sites with dynamic request parameters.
|
|
160
160
|
|
|
161
161
|
<br>
|
|
162
162
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imprint-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/prompts/compile-agent.md
CHANGED
|
@@ -373,7 +373,7 @@ In all cases, the `give_up` call must include a `what_was_tried` field listing c
|
|
|
373
373
|
|
|
374
374
|
## Time Budget
|
|
375
375
|
|
|
376
|
-
You have a
|
|
376
|
+
You have a 20-minute wall-clock deadline. Most successful runs take 8-20 turns. If you're past 20 turns and still not converging, step back and reconsider your approach:
|
|
377
377
|
- Re-read the response body from scratch
|
|
378
378
|
- Look for a different anchor value
|
|
379
379
|
- Try a different extraction shape
|
package/src/cli.ts
CHANGED
|
@@ -139,7 +139,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
139
139
|
},
|
|
140
140
|
{
|
|
141
141
|
name: '--timeout <duration>',
|
|
142
|
-
description: 'Per-tool compile timeout. Accepts
|
|
142
|
+
description: 'Per-tool compile timeout. Accepts 20m, 1h, 300s, or plain ms. Default 20m.',
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
145
|
name: '--keep-test',
|
|
@@ -190,7 +190,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
190
190
|
{ name: '--out <path>', description: 'Override the workflow.json output path.' },
|
|
191
191
|
{
|
|
192
192
|
name: '--max-duration <time>',
|
|
193
|
-
description: 'Agent timeout (e.g., "
|
|
193
|
+
description: 'Agent timeout (e.g., "20m", "1h", "300s"). Default 20m.',
|
|
194
194
|
},
|
|
195
195
|
{
|
|
196
196
|
name: '--provider <name>',
|
|
@@ -53,7 +53,7 @@ export function resolveCompileAgentModel(provider: ProviderName): string {
|
|
|
53
53
|
interface CompileAgentOptions {
|
|
54
54
|
/** Path to the recorded session JSON (absolute or relative). */
|
|
55
55
|
sessionPath: string;
|
|
56
|
-
/** Hard wall-clock budget. Default
|
|
56
|
+
/** Hard wall-clock budget. Default 20 minutes. */
|
|
57
57
|
maxDurationMs?: number;
|
|
58
58
|
/** Override LLM config (region, model, project). */
|
|
59
59
|
llmConfig?: LLMOptions;
|
|
@@ -197,7 +197,7 @@ ${formatCandidateContext(opts.candidate, opts.sharedContext)}
|
|
|
197
197
|
Begin by calling read_session_summary to orient yourself, then proceed per the system prompt.`;
|
|
198
198
|
|
|
199
199
|
// 7. Compute deadline
|
|
200
|
-
const deadlineMs = Date.now() + (opts.maxDurationMs ??
|
|
200
|
+
const deadlineMs = Date.now() + (opts.maxDurationMs ?? 20 * 60 * 1000);
|
|
201
201
|
|
|
202
202
|
// 8. Instantiate provider (or use injected one for testing).
|
|
203
203
|
// CLI providers take a different path: they don't implement Anthropic
|
package/src/imprint/compile.ts
CHANGED
|
@@ -145,7 +145,7 @@ export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
|
|
|
145
145
|
];
|
|
146
146
|
if (result.outcome === 'timeout') {
|
|
147
147
|
lines.push(
|
|
148
|
-
'hint: increase the timeout with --timeout (teach) or --max-duration (generate)',
|
|
148
|
+
'hint: most complex tools take 10-15 minutes. increase the timeout with --timeout (teach) or --max-duration (generate)',
|
|
149
149
|
);
|
|
150
150
|
}
|
|
151
151
|
throw new Error(lines.join('\n'));
|
|
@@ -12,14 +12,13 @@
|
|
|
12
12
|
* value is visible and lets us confirm which form was the login form.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { isSensitiveCredentialKey,
|
|
15
|
+
import { isSensitiveCredentialKey, isUsernameLikeKey } from './sensitive-keys.ts';
|
|
16
16
|
import type { CapturedEvent, CapturedRequest, Session } from './types.ts';
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* password field.
|
|
20
|
-
*
|
|
21
|
-
const
|
|
22
|
-
/^(user(?:name|id)?|email(?:address)?|login(?:id)?|account|patron(?:number|id)?)$/i;
|
|
18
|
+
/** Predicate: this key looks like the username/email/login partner of a
|
|
19
|
+
* password field. Backed by `USERNAME_LIKE_KEYS` in sensitive-keys.ts so
|
|
20
|
+
* the dictionary stays in one place. */
|
|
21
|
+
const isUsernameKey = (key: string): boolean => isUsernameLikeKey(key);
|
|
23
22
|
|
|
24
23
|
/** Where, within a request, a redactable value lives. */
|
|
25
24
|
export type ReplacementLocation =
|
|
@@ -58,6 +57,29 @@ interface ExtractionResult {
|
|
|
58
57
|
replacements: Replacement[];
|
|
59
58
|
}
|
|
60
59
|
|
|
60
|
+
/** Parsers are tried in this order on every request that has a body. Each
|
|
61
|
+
* one is side-effect-free and returns `null` when its input doesn't fit
|
|
62
|
+
* its expected framing — so trying JSON first on a form body, or form on
|
|
63
|
+
* a JSON body, is safe: only the parser that actually fits will produce a
|
|
64
|
+
* finding.
|
|
65
|
+
*
|
|
66
|
+
* Dispatch is parser-driven, not Content-Type-driven, because real sites
|
|
67
|
+
* routinely mislabel their bodies — the canonical example is the Nextep
|
|
68
|
+
* cafe API (`Content-Type: text/plain` for JSON bodies). Letting the data
|
|
69
|
+
* speak for itself prevents whole classes of silent extraction failures.
|
|
70
|
+
*
|
|
71
|
+
* URL-query parsing runs even on requests without a body (e.g. GET-based
|
|
72
|
+
* logins that pass credentials in the query string). Multipart is checked
|
|
73
|
+
* before generic form-urlencoded because a multipart body still contains
|
|
74
|
+
* `=` characters and would be parsed as a single malformed form pair
|
|
75
|
+
* otherwise. */
|
|
76
|
+
const BODY_PARSERS: Array<(r: CapturedRequest) => BodyFinding | null> = [
|
|
77
|
+
findInJsonBody,
|
|
78
|
+
findInJsonWrappedInForm,
|
|
79
|
+
findInMultipartBody,
|
|
80
|
+
findInFormBody,
|
|
81
|
+
];
|
|
82
|
+
|
|
61
83
|
/** Top-level entry point. */
|
|
62
84
|
export function extractCredentials(session: Session): ExtractionResult {
|
|
63
85
|
const findings: CredentialFinding[] = [];
|
|
@@ -65,13 +87,17 @@ export function extractCredentials(session: Session): ExtractionResult {
|
|
|
65
87
|
const usernamesInDom = collectFormSubmitUsernames(session.events);
|
|
66
88
|
|
|
67
89
|
for (const req of session.requests) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
let found: BodyFinding | null = null;
|
|
91
|
+
if (req.body) {
|
|
92
|
+
for (const parse of BODY_PARSERS) {
|
|
93
|
+
found = parse(req);
|
|
94
|
+
if (found) break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Last-resort: credentials in the URL query string (rare but real for
|
|
98
|
+
// some legacy GET-based login endpoints). Tried after body parsers so
|
|
99
|
+
// body-based logins always win when both are present.
|
|
100
|
+
if (!found) found = findInUrlQuery(req);
|
|
75
101
|
if (!found) continue;
|
|
76
102
|
|
|
77
103
|
const confirmedByDom = usernamesInDom.has(found.usernameValue);
|
|
@@ -132,7 +158,7 @@ function findInFormBody(req: CapturedRequest): BodyFinding | null {
|
|
|
132
158
|
|
|
133
159
|
// Second pass: find a username-like key.
|
|
134
160
|
for (const { key, value } of pairs) {
|
|
135
|
-
if (
|
|
161
|
+
if (isUsernameKey(key) && value.length > 0) {
|
|
136
162
|
usernameKey = key;
|
|
137
163
|
usernameValue = value;
|
|
138
164
|
break;
|
|
@@ -163,11 +189,7 @@ function findInJsonBody(req: CapturedRequest): BodyFinding | null {
|
|
|
163
189
|
if (typeof pwdHit.value !== 'string' || pwdHit.value.length === 0) return null;
|
|
164
190
|
|
|
165
191
|
// Look for a username-like key; prefer one in the same parent object.
|
|
166
|
-
const userHit = findFirstByPredicate(
|
|
167
|
-
parsed,
|
|
168
|
-
(k) => USERNAME_KEY_RE.test(normalizeKey(k)),
|
|
169
|
-
pwdHit.parent,
|
|
170
|
-
);
|
|
192
|
+
const userHit = findFirstByPredicate(parsed, isUsernameKey, pwdHit.parent);
|
|
171
193
|
if (!userHit || typeof userHit.value !== 'string' || userHit.value.length === 0) return null;
|
|
172
194
|
|
|
173
195
|
return {
|
|
@@ -178,6 +200,138 @@ function findInJsonBody(req: CapturedRequest): BodyFinding | null {
|
|
|
178
200
|
};
|
|
179
201
|
}
|
|
180
202
|
|
|
203
|
+
/** Handles legacy framings where a JSON document is the value of a single
|
|
204
|
+
* form-encoded field — `payload={"username":"…","password":"…"}` or
|
|
205
|
+
* `data=…` or `request=…`. Real PHP / ColdFusion apps do this. We delegate
|
|
206
|
+
* the inner pairing to findInJsonBody by synthesizing a child request, and
|
|
207
|
+
* re-encode the path as `body-form` so the redactor knows to swap the
|
|
208
|
+
* whole inner JSON string back in. */
|
|
209
|
+
function findInJsonWrappedInForm(req: CapturedRequest): BodyFinding | null {
|
|
210
|
+
if (!req.body) return null;
|
|
211
|
+
const pairs = parseFormBody(req.body);
|
|
212
|
+
if (pairs.length === 0) return null;
|
|
213
|
+
|
|
214
|
+
const WRAPPER_KEYS = new Set(['payload', 'data', 'request', 'json', 'body']);
|
|
215
|
+
for (const { key, value } of pairs) {
|
|
216
|
+
if (!WRAPPER_KEYS.has(key.toLowerCase())) continue;
|
|
217
|
+
if (!value.startsWith('{') && !value.startsWith('[')) continue;
|
|
218
|
+
// Build a synthetic request with the unwrapped JSON as body.
|
|
219
|
+
const inner: CapturedRequest = { ...req, body: value };
|
|
220
|
+
const found = findInJsonBody(inner);
|
|
221
|
+
if (!found) continue;
|
|
222
|
+
// Project the JSON paths back into form-key terms — the redactor
|
|
223
|
+
// matches on `originalValue` regardless of `location`, but we keep the
|
|
224
|
+
// location semantically correct so future readers aren't confused.
|
|
225
|
+
return {
|
|
226
|
+
...found,
|
|
227
|
+
usernameLocation: { kind: 'body-form', key },
|
|
228
|
+
passwordLocation: { kind: 'body-form', key },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Parse a multipart/form-data body into {key, value} pairs and pair like
|
|
235
|
+
* the form-urlencoded path. Defensive: any malformed part is skipped.
|
|
236
|
+
*
|
|
237
|
+
* We sniff the boundary from the first line (`--<boundary>`) rather than
|
|
238
|
+
* trusting the Content-Type header, because the whole point of this
|
|
239
|
+
* module is to not trust Content-Type. */
|
|
240
|
+
function findInMultipartBody(req: CapturedRequest): BodyFinding | null {
|
|
241
|
+
if (!req.body) return null;
|
|
242
|
+
const body = req.body;
|
|
243
|
+
// First line should be `--<boundary>`. If it doesn't start with `--` or
|
|
244
|
+
// there's no following newline, this isn't multipart.
|
|
245
|
+
const firstNewline = body.indexOf('\n');
|
|
246
|
+
if (firstNewline < 0) return null;
|
|
247
|
+
const firstLine = body.slice(0, firstNewline).trimEnd();
|
|
248
|
+
if (!firstLine.startsWith('--')) return null;
|
|
249
|
+
const boundary = firstLine.slice(2);
|
|
250
|
+
if (boundary.length === 0 || boundary.length > 200) return null;
|
|
251
|
+
// Split on the boundary; skip the prologue (empty before first boundary)
|
|
252
|
+
// and the epilogue (after closing `--<boundary>--`).
|
|
253
|
+
const sep = `--${boundary}`;
|
|
254
|
+
const parts = body.split(sep).slice(1);
|
|
255
|
+
const pairs: Array<{ key: string; value: string }> = [];
|
|
256
|
+
for (const partRaw of parts) {
|
|
257
|
+
const part = partRaw.startsWith('\r\n')
|
|
258
|
+
? partRaw.slice(2)
|
|
259
|
+
: partRaw.startsWith('\n')
|
|
260
|
+
? partRaw.slice(1)
|
|
261
|
+
: partRaw;
|
|
262
|
+
if (part.startsWith('--')) break; // closing boundary
|
|
263
|
+
// Headers and body are separated by a blank line.
|
|
264
|
+
const headerEnd = part.indexOf('\r\n\r\n');
|
|
265
|
+
const headerEnd2 = headerEnd >= 0 ? headerEnd : part.indexOf('\n\n');
|
|
266
|
+
if (headerEnd2 < 0) continue;
|
|
267
|
+
const sepLen = headerEnd >= 0 ? 4 : 2;
|
|
268
|
+
const headers = part.slice(0, headerEnd2);
|
|
269
|
+
let value = part.slice(headerEnd2 + sepLen);
|
|
270
|
+
// Strip the trailing CRLF that precedes the next boundary.
|
|
271
|
+
value = value.replace(/\r?\n$/, '');
|
|
272
|
+
const nameMatch = headers.match(/name="([^"]*)"/i);
|
|
273
|
+
if (!nameMatch) continue;
|
|
274
|
+
const key = nameMatch[1] ?? '';
|
|
275
|
+
if (!key) continue;
|
|
276
|
+
pairs.push({ key, value });
|
|
277
|
+
}
|
|
278
|
+
if (pairs.length === 0) return null;
|
|
279
|
+
return pairFromKeyValuePairs(pairs, 'body-form');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Credentials in the URL query string — `GET /login?username=…&password=…`
|
|
283
|
+
* or a POST whose body is empty but credentials ride in the URL. Rare but
|
|
284
|
+
* real for some legacy CGI endpoints. */
|
|
285
|
+
function findInUrlQuery(req: CapturedRequest): BodyFinding | null {
|
|
286
|
+
let qs: string;
|
|
287
|
+
try {
|
|
288
|
+
const u = new URL(req.url);
|
|
289
|
+
qs = u.search.startsWith('?') ? u.search.slice(1) : u.search;
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
if (!qs) return null;
|
|
294
|
+
const pairs = parseFormBody(qs);
|
|
295
|
+
if (pairs.length === 0) return null;
|
|
296
|
+
return pairFromKeyValuePairs(pairs, 'body-form');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Shared pairing: given key/value pairs, find a password partner and a
|
|
300
|
+
* username partner. Returns a BodyFinding or null. Used by every parser
|
|
301
|
+
* that flattens its input into key/value pairs (form, multipart, URL
|
|
302
|
+
* query). The `location.kind` argument is passed through unchanged. */
|
|
303
|
+
function pairFromKeyValuePairs(
|
|
304
|
+
pairs: Array<{ key: string; value: string }>,
|
|
305
|
+
kind: 'body-form',
|
|
306
|
+
): BodyFinding | null {
|
|
307
|
+
let passwordKey: string | null = null;
|
|
308
|
+
let passwordValue: string | null = null;
|
|
309
|
+
for (const { key, value } of pairs) {
|
|
310
|
+
if (isSensitiveCredentialKey(key) && value.length > 0) {
|
|
311
|
+
passwordKey = key;
|
|
312
|
+
passwordValue = value;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (passwordKey === null || passwordValue === null) return null;
|
|
317
|
+
let usernameKey: string | null = null;
|
|
318
|
+
let usernameValue: string | null = null;
|
|
319
|
+
for (const { key, value } of pairs) {
|
|
320
|
+
if (isUsernameKey(key) && value.length > 0) {
|
|
321
|
+
usernameKey = key;
|
|
322
|
+
usernameValue = value;
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (usernameKey === null || usernameValue === null) return null;
|
|
327
|
+
return {
|
|
328
|
+
usernameValue,
|
|
329
|
+
passwordValue,
|
|
330
|
+
usernameLocation: { kind, key: usernameKey },
|
|
331
|
+
passwordLocation: { kind, key: passwordKey },
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
181
335
|
interface JsonHit {
|
|
182
336
|
key: string;
|
|
183
337
|
value: unknown;
|
|
@@ -238,12 +392,7 @@ function collectFormSubmitUsernames(events: CapturedEvent[]): Set<string> {
|
|
|
238
392
|
fields?: Array<{ name?: string; type?: string; value?: string }>;
|
|
239
393
|
};
|
|
240
394
|
for (const f of detail.fields ?? []) {
|
|
241
|
-
if (
|
|
242
|
-
f.name &&
|
|
243
|
-
f.value &&
|
|
244
|
-
f.type !== 'password' &&
|
|
245
|
-
USERNAME_KEY_RE.test(normalizeKey(f.name))
|
|
246
|
-
) {
|
|
395
|
+
if (f.name && f.value && f.type !== 'password' && isUsernameKey(f.name)) {
|
|
247
396
|
out.add(f.value);
|
|
248
397
|
}
|
|
249
398
|
}
|
package/src/imprint/emit.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
5
5
|
import { basename, dirname, join as pathJoin, resolve as pathResolve } from 'node:path';
|
|
6
6
|
import { loadJsonFile } from './load-json.ts';
|
|
7
7
|
import { ensureImprintRuntimeLink } from './runtime-link.ts';
|
|
8
|
+
import { isLoginFieldKey } from './sensitive-keys.ts';
|
|
8
9
|
import { type Workflow, WorkflowSchema } from './types.ts';
|
|
9
10
|
|
|
10
11
|
interface EmitOptions {
|
|
@@ -36,6 +37,8 @@ export function emit(opts: EmitOptions): EmitResult {
|
|
|
36
37
|
'workflow.json',
|
|
37
38
|
);
|
|
38
39
|
|
|
40
|
+
assertNoCredentialShapedParams(workflow);
|
|
41
|
+
|
|
39
42
|
const outDir = opts.outDir ?? defaultOutDir(opts.workflowPath, workflow);
|
|
40
43
|
|
|
41
44
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -137,6 +140,88 @@ export { WORKFLOW };
|
|
|
137
140
|
`;
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
/** Pre-emit guardrail: refuse to write a workflow whose parameters look
|
|
144
|
+
* like login credentials (`password`, `userid`, `email`, etc., per the
|
|
145
|
+
* shared dictionary in sensitive-keys.ts) but are templated as plain
|
|
146
|
+
* `${param.X}` instead of credential-store references like
|
|
147
|
+
* `${credential.X}`.
|
|
148
|
+
*
|
|
149
|
+
* This catches the failure mode where upstream credential extraction
|
|
150
|
+
* silently failed (e.g. unusual Content-Type, body framing the parser
|
|
151
|
+
* didn't recognise, declined credential-save prompt), so the compile
|
|
152
|
+
* agent had no credential anchor and chose to model the login fields as
|
|
153
|
+
* ordinary callable parameters. The resulting MCP tool would advertise
|
|
154
|
+
* `userid`/`password` as required inputs, forward whatever the caller
|
|
155
|
+
* passed verbatim, and (most often) silently produce empty results when
|
|
156
|
+
* the caller passed empty strings.
|
|
157
|
+
*
|
|
158
|
+
* We require either:
|
|
159
|
+
* - The parameter isn't credential-shaped, OR
|
|
160
|
+
* - The body template references `${credential.<name>}` (or another
|
|
161
|
+
* `credential.*` reference), in which case the workflow is pulling
|
|
162
|
+
* from the credential store and the `${param.X}` parameter is
|
|
163
|
+
* effectively a no-op the user can safely ignore.
|
|
164
|
+
*
|
|
165
|
+
* Throws with the remediation steps the user needs to take. */
|
|
166
|
+
function assertNoCredentialShapedParams(workflow: Workflow): void {
|
|
167
|
+
const offenders: Array<{ name: string; matches: string[] }> = [];
|
|
168
|
+
for (const param of workflow.parameters) {
|
|
169
|
+
if (!isLoginFieldKey(param.name)) continue;
|
|
170
|
+
const paramRef = `\${param.${param.name}}`;
|
|
171
|
+
const credentialRef = `\${credential.${param.name}}`;
|
|
172
|
+
const requestsUsingParam: string[] = [];
|
|
173
|
+
let coveredByCredentialRef = false;
|
|
174
|
+
for (let i = 0; i < workflow.requests.length; i++) {
|
|
175
|
+
const req = workflow.requests[i];
|
|
176
|
+
if (!req) continue;
|
|
177
|
+
const haystack = `${req.url} ${req.body ?? ''} ${Object.values(req.headers).join(' ')}`;
|
|
178
|
+
if (haystack.includes(credentialRef)) {
|
|
179
|
+
coveredByCredentialRef = true;
|
|
180
|
+
}
|
|
181
|
+
if (haystack.includes(paramRef)) {
|
|
182
|
+
requestsUsingParam.push(`requests[${i}] (${req.method} ${req.url})`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Only flag if the body templates the param and there's no parallel
|
|
186
|
+
// credential reference. A workflow that uses both `${param.X}` and
|
|
187
|
+
// `${credential.X}` is suspicious but not necessarily broken — leave
|
|
188
|
+
// it to the user. The dangerous case is `${param.X}` alone.
|
|
189
|
+
if (requestsUsingParam.length > 0 && !coveredByCredentialRef) {
|
|
190
|
+
offenders.push({ name: param.name, matches: requestsUsingParam });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (offenders.length === 0) return;
|
|
194
|
+
|
|
195
|
+
const lines = [
|
|
196
|
+
`Workflow ${JSON.stringify(workflow.toolName)} declares ${offenders.length} credential-shaped parameter(s) that are templated as plain \`\${param.X}\` instead of \`\${credential.X}\`:`,
|
|
197
|
+
'',
|
|
198
|
+
];
|
|
199
|
+
for (const o of offenders) {
|
|
200
|
+
lines.push(` • parameter \`${o.name}\` — used in:`);
|
|
201
|
+
for (const m of o.matches) lines.push(` - ${m}`);
|
|
202
|
+
}
|
|
203
|
+
lines.push(
|
|
204
|
+
'',
|
|
205
|
+
'Credentials MUST be pulled from the credential store via `${credential.<name>}`, never modelled as plain workflow parameters.',
|
|
206
|
+
"This usually means the redact stage failed to extract a username+password pair from the recorded login request — common causes include unusual Content-Type headers, multipart bodies, or login fields the extractor dictionary doesn't yet cover.",
|
|
207
|
+
'',
|
|
208
|
+
'To fix:',
|
|
209
|
+
` 1. Delete the redacted session: rm ${workflowToolHint(workflow)}/sessions/*.redacted.json (or the relevant one)`,
|
|
210
|
+
` 2. Re-run from the redact stage: imprint teach ${workflow.site} --from redact`,
|
|
211
|
+
' 3. Accept the "Save credentials for site to the credential manager?" prompt this time.',
|
|
212
|
+
' 4. Let teach continue through generate → compile-playbook → emit.',
|
|
213
|
+
'',
|
|
214
|
+
"If the prompt does NOT appear during step 3, the extractor still cannot pair this site's login fields — please file a bug attaching the (redacted!) session.",
|
|
215
|
+
);
|
|
216
|
+
throw new Error(lines.join('\n'));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Pretty path hint for the error message above. We don't have IMPRINT_HOME
|
|
220
|
+
* in scope and don't need it — `~/.imprint/<site>` is the convention. */
|
|
221
|
+
function workflowToolHint(workflow: Workflow): string {
|
|
222
|
+
return `~/.imprint/${workflow.site}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
140
225
|
function pascalCase(s: string): string {
|
|
141
226
|
return s
|
|
142
227
|
.split(/[_-]+/)
|
|
@@ -112,15 +112,126 @@ const SENSITIVE_KEYS = [
|
|
|
112
112
|
'dob',
|
|
113
113
|
];
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
// `normalizeKey` (defined below) lowercases and strips `_`/`-` — set
|
|
116
|
+
// membership goes through it, so we MUST pre-normalize the stored entries
|
|
117
|
+
// or lookups for e.g. `j_password` (→ `jpassword`) will miss a stored
|
|
118
|
+
// `j_password`. Hoisting a local copy of the rule rather than ordering
|
|
119
|
+
// gymnastics keeps the file linear.
|
|
120
|
+
const _normalize = (s: string): string => s.toLowerCase().replace(/[-_]/g, '');
|
|
121
|
+
|
|
122
|
+
const SENSITIVE_KEY_SET = new Set(SENSITIVE_KEYS.map(_normalize));
|
|
116
123
|
|
|
117
124
|
/** Subset of SENSITIVE_KEYS that specifically denote a credential (not PII).
|
|
118
125
|
* Used by credential-extract.ts when looking for the password half of a
|
|
119
|
-
* login form pair — we don't want to treat e.g. `dob` as a password.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
* login form pair — we don't want to treat e.g. `dob` as a password.
|
|
127
|
+
*
|
|
128
|
+
* Inclusion criterion: a key name that, when present in a request body
|
|
129
|
+
* alongside a username-like partner, almost always means "this is the
|
|
130
|
+
* password the user typed at login time." Be liberal here — false positives
|
|
131
|
+
* cost the user one extra prompt confirmation; false negatives ship broken
|
|
132
|
+
* tools. New additions should reference a real recorded site that broke
|
|
133
|
+
* without them.
|
|
134
|
+
*
|
|
135
|
+
* Sites observed needing each entry:
|
|
136
|
+
* - password / passwd / pwd: most modern APIs
|
|
137
|
+
* - pin: bank / utility login forms
|
|
138
|
+
* - pass: legacy PHP forms (e.g. SMF)
|
|
139
|
+
* - secret: OAuth ROPC payloads
|
|
140
|
+
* - j_password: Java EE / Spring Security default form-login
|
|
141
|
+
* - userpassword / loginpassword / accountpassword:
|
|
142
|
+
* vendor SSO portals that namespace fields
|
|
143
|
+
* - patronpassword / patron_password: Discover & Go libraries (kept for back-compat)
|
|
144
|
+
*/
|
|
145
|
+
const PASSWORD_LIKE_ENTRIES = [
|
|
146
|
+
'password',
|
|
147
|
+
'passwd',
|
|
148
|
+
'pwd',
|
|
149
|
+
'pin',
|
|
150
|
+
'pass',
|
|
151
|
+
'secret',
|
|
152
|
+
'j_password',
|
|
153
|
+
'userpassword',
|
|
154
|
+
'loginpassword',
|
|
155
|
+
'accountpassword',
|
|
156
|
+
'patronpassword',
|
|
157
|
+
'patron_password',
|
|
158
|
+
];
|
|
159
|
+
const PASSWORD_LIKE_KEYS = new Set(PASSWORD_LIKE_ENTRIES.map(_normalize));
|
|
160
|
+
|
|
161
|
+
/** Subset of SENSITIVE_KEYS that specifically denote a username/email/login
|
|
162
|
+
* identifier — the partner half of a username+password login pair.
|
|
163
|
+
*
|
|
164
|
+
* Same inclusion criterion as PASSWORD_LIKE_KEYS: liberal coverage of real
|
|
165
|
+
* recorded forms, narrow enough not to match arbitrary identifiers. Note
|
|
166
|
+
* this set is intentionally distinct from `email`, `phone` etc. in
|
|
167
|
+
* SENSITIVE_KEYS — those get redacted as PII regardless, but only the
|
|
168
|
+
* subset here qualifies as the "username partner" the credential extractor
|
|
169
|
+
* pairs with a password.
|
|
170
|
+
*
|
|
171
|
+
* Sites observed needing each entry:
|
|
172
|
+
* - user / username / user_name / userid / user_id:
|
|
173
|
+
* most APIs
|
|
174
|
+
* - login / loginid / login_id / login_email:
|
|
175
|
+
* REST endpoints that name the form field after the action
|
|
176
|
+
* - email / emailaddress / email_address: email-as-username flows
|
|
177
|
+
* - account / accountid / account_id: enterprise SSO portals
|
|
178
|
+
* - patron / patronnumber / patron_number / patronid / patron_id:
|
|
179
|
+
* library systems (Discover & Go)
|
|
180
|
+
* - j_username: Java EE / Spring Security default form-login
|
|
181
|
+
* - signin / signinid / sign_in_id: vendor SSO portals (Okta-style)
|
|
182
|
+
* - usr / uid: legacy CGI / older PHP
|
|
183
|
+
* - memberid / member_id / membername / member_name:
|
|
184
|
+
* membership-driven sites (gyms, clubs)
|
|
185
|
+
* - customerid / customer_id / customernumber / customer_number:
|
|
186
|
+
* ecommerce account portals
|
|
187
|
+
* - clientid / client_id / clientnumber / client_number:
|
|
188
|
+
* B2B portals (CAUTION: also matches OAuth client_id;
|
|
189
|
+
* credential-extract.ts gates on having a password
|
|
190
|
+
* partner in the same parent, so OAuth token endpoints
|
|
191
|
+
* that pass client_id without a password won't match)
|
|
192
|
+
*/
|
|
193
|
+
const USERNAME_LIKE_KEYS = new Set(
|
|
194
|
+
[
|
|
195
|
+
'user',
|
|
196
|
+
'username',
|
|
197
|
+
'user_name',
|
|
198
|
+
'userid',
|
|
199
|
+
'user_id',
|
|
200
|
+
'login',
|
|
201
|
+
'loginid',
|
|
202
|
+
'login_id',
|
|
203
|
+
'loginemail',
|
|
204
|
+
'login_email',
|
|
205
|
+
'email',
|
|
206
|
+
'emailaddress',
|
|
207
|
+
'email_address',
|
|
208
|
+
'account',
|
|
209
|
+
'accountid',
|
|
210
|
+
'account_id',
|
|
211
|
+
'patron',
|
|
212
|
+
'patronnumber',
|
|
213
|
+
'patron_number',
|
|
214
|
+
'patronid',
|
|
215
|
+
'patron_id',
|
|
216
|
+
'j_username',
|
|
217
|
+
'signin',
|
|
218
|
+
'signinid',
|
|
219
|
+
'sign_in_id',
|
|
220
|
+
'usr',
|
|
221
|
+
'uid',
|
|
222
|
+
'memberid',
|
|
223
|
+
'member_id',
|
|
224
|
+
'membername',
|
|
225
|
+
'member_name',
|
|
226
|
+
'customerid',
|
|
227
|
+
'customer_id',
|
|
228
|
+
'customernumber',
|
|
229
|
+
'customer_number',
|
|
230
|
+
'clientid',
|
|
231
|
+
'client_id',
|
|
232
|
+
'clientnumber',
|
|
233
|
+
'client_number',
|
|
234
|
+
].map(_normalize),
|
|
124
235
|
);
|
|
125
236
|
|
|
126
237
|
const SENSITIVE_HEADERS = [
|
|
@@ -138,7 +249,7 @@ const SENSITIVE_HEADERS = [
|
|
|
138
249
|
|
|
139
250
|
const SENSITIVE_HEADER_SET = new Set(SENSITIVE_HEADERS.map((h) => h.toLowerCase()));
|
|
140
251
|
|
|
141
|
-
export const normalizeKey =
|
|
252
|
+
export const normalizeKey = _normalize;
|
|
142
253
|
|
|
143
254
|
/** True if the key name suggests a sensitive value (auth, payment, PII). */
|
|
144
255
|
export function isSensitiveKey(key: string): boolean {
|
|
@@ -151,6 +262,29 @@ export function isSensitiveCredentialKey(key: string): boolean {
|
|
|
151
262
|
return PASSWORD_LIKE_KEYS.has(normalizeKey(key));
|
|
152
263
|
}
|
|
153
264
|
|
|
265
|
+
/** True if the key name suggests a username/email/login identifier — the
|
|
266
|
+
* partner half of a login pair. Used in credential extraction and in the
|
|
267
|
+
* pre-emit guardrail that flags workflows templating credentials as plain
|
|
268
|
+
* parameters. */
|
|
269
|
+
export function isUsernameLikeKey(key: string): boolean {
|
|
270
|
+
return USERNAME_LIKE_KEYS.has(normalizeKey(key));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** True for either half of a login pair (username or password). Used by the
|
|
274
|
+
* pre-emit guardrail and the post-redact pairing audit, which both need to
|
|
275
|
+
* decide "is this parameter name credential-shaped?" without caring which
|
|
276
|
+
* half. */
|
|
277
|
+
export function isLoginFieldKey(key: string): boolean {
|
|
278
|
+
const n = normalizeKey(key);
|
|
279
|
+
return PASSWORD_LIKE_KEYS.has(n) || USERNAME_LIKE_KEYS.has(n);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Raw password-like key strings (pre-normalization) for callers that need
|
|
283
|
+
* substring matching against raw body text rather than parsed key lookup. */
|
|
284
|
+
export function passwordLikeTokens(): readonly string[] {
|
|
285
|
+
return PASSWORD_LIKE_ENTRIES;
|
|
286
|
+
}
|
|
287
|
+
|
|
154
288
|
export function isSensitiveHeader(header: string): boolean {
|
|
155
289
|
return SENSITIVE_HEADER_SET.has(header.toLowerCase());
|
|
156
290
|
}
|
|
@@ -55,6 +55,13 @@ export interface WorkflowState {
|
|
|
55
55
|
updatedAt: string;
|
|
56
56
|
candidate?: ToolCandidate;
|
|
57
57
|
sharedContext?: SharedCompileContext;
|
|
58
|
+
/** Non-fatal flags raised by upstream stages that downstream stages (and
|
|
59
|
+
* the user) should know about. Currently used by the redact stage to
|
|
60
|
+
* record `'credentials_not_paired'` when a password-shaped body field
|
|
61
|
+
* was scrubbed but no username+password pair could be extracted —
|
|
62
|
+
* meaning the generated workflow will template credentials as plain
|
|
63
|
+
* parameters instead of `${credential.X}` references. */
|
|
64
|
+
warnings?: string[];
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
export interface TeachState {
|
package/src/imprint/teach.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { describeAgentActivity, formatElapsed } from './progress.ts';
|
|
|
48
48
|
import { record } from './record.ts';
|
|
49
49
|
import { detectPageMintedHeaders, redactSession } from './redact.ts';
|
|
50
50
|
import { loadCredentialStore } from './runtime.ts';
|
|
51
|
+
import { isSensitiveCredentialKey, passwordLikeTokens } from './sensitive-keys.ts';
|
|
51
52
|
import type { ClassifiedValue } from './session-diff.ts';
|
|
52
53
|
import { listSiteSessions, mergeSessions, writeCombinedSession } from './session-merge.ts';
|
|
53
54
|
import {
|
|
@@ -89,7 +90,7 @@ interface TeachOptions {
|
|
|
89
90
|
provider?: ProviderName;
|
|
90
91
|
/** Override the compile model (otherwise prompted or auto-detected). */
|
|
91
92
|
model?: string;
|
|
92
|
-
/** Per-tool compile timeout in ms. Default
|
|
93
|
+
/** Per-tool compile timeout in ms. Default 20 minutes. */
|
|
93
94
|
maxDurationMs?: number;
|
|
94
95
|
fromSession?: string;
|
|
95
96
|
/** Retain parser.test.ts after successful compile-agent verification. */
|
|
@@ -599,8 +600,38 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
599
600
|
`Redacted ${stats.totalRedactions} value(s) across ${stats.requestsRedacted} request(s) and ${stats.cookiesRedacted} cookie(s)${placeholderNote}${freeformNote}.`,
|
|
600
601
|
);
|
|
601
602
|
|
|
603
|
+
// Post-redact pairing audit: if any request body contained a
|
|
604
|
+
// password-shaped field but credential extraction failed to produce a
|
|
605
|
+
// confirmed username+password pair, the downstream compile stage will
|
|
606
|
+
// template credentials as `${param.X}` instead of `${credential.X}` —
|
|
607
|
+
// shipping a broken MCP tool that asks callers to provide credentials
|
|
608
|
+
// by hand instead of pulling from the credential store.
|
|
609
|
+
//
|
|
610
|
+
// The most common reason is an unusual request framing (custom
|
|
611
|
+
// Content-Type, unusual key naming) that the extractor's dictionaries
|
|
612
|
+
// or parsers don't yet cover. Surface this loudly so the user can
|
|
613
|
+
// either re-record, file a bug, or proceed knowing the tool needs
|
|
614
|
+
// hand-editing.
|
|
615
|
+
const warnings: string[] = [];
|
|
616
|
+
const unpairedPasswordSeqs = findUnpairedPasswordRequests(session);
|
|
617
|
+
if (unpairedPasswordSeqs.length > 0 && confirmedReplacements.length === 0) {
|
|
618
|
+
warnings.push('credentials_not_paired');
|
|
619
|
+
const seqList = unpairedPasswordSeqs.slice(0, 5).join(', ');
|
|
620
|
+
const more = unpairedPasswordSeqs.length > 5 ? ', …' : '';
|
|
621
|
+
p.log.warn(
|
|
622
|
+
[
|
|
623
|
+
`Detected ${unpairedPasswordSeqs.length} request(s) with a password-shaped field (seqs: ${seqList}${more}) but no username+password pair was extracted.`,
|
|
624
|
+
'The generated workflow will treat credentials as plain parameters and will NOT pull from the credential store.',
|
|
625
|
+
'This usually means the request body uses an unusual framing (Content-Type, key naming, multipart variant) the extractor did not recognise.',
|
|
626
|
+
`→ Recommended: file a bug with the redacted session at ${toRelative(site, redactedPath)}, then re-record once the extractor is fixed.`,
|
|
627
|
+
'→ To proceed anyway, just continue — the tool will need manual credential wiring before it works.',
|
|
628
|
+
].join('\n'),
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
602
632
|
updateCheckpoint(site, state, workflowKey, 'redact', {
|
|
603
633
|
redactedPath: toRelative(site, redactedPath),
|
|
634
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
604
635
|
});
|
|
605
636
|
}
|
|
606
637
|
|
|
@@ -885,7 +916,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
885
916
|
let compileModel = '';
|
|
886
917
|
if (needsCompileProvider) {
|
|
887
918
|
compileModel = await getModel();
|
|
888
|
-
const timeoutMs = opts.maxDurationMs ??
|
|
919
|
+
const timeoutMs = opts.maxDurationMs ?? 20 * 60 * 1000;
|
|
889
920
|
const timeoutDisplay =
|
|
890
921
|
timeoutMs >= 3_600_000
|
|
891
922
|
? `${Math.round(timeoutMs / 3_600_000)}h`
|
|
@@ -898,10 +929,14 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
898
929
|
`Timeout: ${timeoutDisplay} per tool`,
|
|
899
930
|
'',
|
|
900
931
|
plans.length === 1
|
|
901
|
-
? 'An LLM agent will reverse-engineer the API response format
|
|
902
|
-
: `${plans.length} LLM compile agents will reverse-engineer selected tools with concurrency 3
|
|
903
|
-
|
|
904
|
-
'
|
|
932
|
+
? 'An LLM agent will reverse-engineer the API response format,'
|
|
933
|
+
: `${plans.length} LLM compile agents will reverse-engineer selected tools with concurrency 3,`,
|
|
934
|
+
'write the MCP server, and run thorough verification tests.',
|
|
935
|
+
'Most complex tools take 10-15 minutes — please be patient.',
|
|
936
|
+
`Timeout: ${timeoutDisplay} per tool. You can interrupt with Ctrl-C.`,
|
|
937
|
+
'',
|
|
938
|
+
'To persist the generated tests after compilation, set IMPRINT_KEEP_TEST=1',
|
|
939
|
+
'or pass --keep-test.',
|
|
905
940
|
].join('\n'),
|
|
906
941
|
'Compile step',
|
|
907
942
|
);
|
|
@@ -1568,6 +1603,92 @@ async function promptAndPersistCredentials(opts: {
|
|
|
1568
1603
|
};
|
|
1569
1604
|
}
|
|
1570
1605
|
|
|
1606
|
+
/** Find request seqs whose body contains a password-shaped key (per the
|
|
1607
|
+
* shared sensitive-keys dictionary) — regardless of whether credential
|
|
1608
|
+
* extraction succeeded in pairing it with a username.
|
|
1609
|
+
*
|
|
1610
|
+
* Used by the post-redact pairing audit to detect the failure mode where
|
|
1611
|
+
* a recorded login *did* happen but the extractor couldn't pair its
|
|
1612
|
+
* fields, so the redacted session has no `${credential.X}` placeholders
|
|
1613
|
+
* and the compile stage will template credentials as plain parameters.
|
|
1614
|
+
*
|
|
1615
|
+
* Body shapes covered:
|
|
1616
|
+
* - JSON (any nesting depth)
|
|
1617
|
+
* - form-urlencoded (`a=b&c=d`)
|
|
1618
|
+
* - multipart/form-data (sniffed by leading `--<boundary>`)
|
|
1619
|
+
* - URL query string (covers GET-based logins)
|
|
1620
|
+
*
|
|
1621
|
+
* The scan is intentionally lossy and fast: we substring-check for
|
|
1622
|
+
* password-like key names in the raw body text plus exact-key checks in
|
|
1623
|
+
* parsed JSON. False positives are tolerable here (one extra warning);
|
|
1624
|
+
* false negatives are not (silent failure recurrence). */
|
|
1625
|
+
export function findUnpairedPasswordRequests(session: Session): number[] {
|
|
1626
|
+
const PASSWORD_LIKE_TOKENS = passwordLikeTokens();
|
|
1627
|
+
const out: number[] = [];
|
|
1628
|
+
for (const req of session.requests) {
|
|
1629
|
+
let hit = false;
|
|
1630
|
+
// 1. Check URL query string for password-shaped param names.
|
|
1631
|
+
try {
|
|
1632
|
+
const u = new URL(req.url);
|
|
1633
|
+
for (const k of u.searchParams.keys()) {
|
|
1634
|
+
if (isSensitiveCredentialKey(k)) {
|
|
1635
|
+
hit = true;
|
|
1636
|
+
break;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
} catch {
|
|
1640
|
+
// Bad URL — skip URL-side check.
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// 2. Check body — try JSON first, then fall back to substring scan
|
|
1644
|
+
// that covers form-urlencoded and multipart in one pass.
|
|
1645
|
+
if (!hit && req.body) {
|
|
1646
|
+
const body = req.body;
|
|
1647
|
+
// JSON path.
|
|
1648
|
+
try {
|
|
1649
|
+
const parsed = JSON.parse(body);
|
|
1650
|
+
if (hasPasswordLikeKey(parsed)) hit = true;
|
|
1651
|
+
} catch {
|
|
1652
|
+
// Not JSON — substring scan handles form / multipart / anything
|
|
1653
|
+
// else that contains the key name verbatim.
|
|
1654
|
+
}
|
|
1655
|
+
if (!hit) {
|
|
1656
|
+
const lower = body.toLowerCase();
|
|
1657
|
+
for (const tok of PASSWORD_LIKE_TOKENS) {
|
|
1658
|
+
// Match a key-shaped occurrence: `"password"` (JSON), `password=`
|
|
1659
|
+
// (form/query), or `name="password"` (multipart). Avoid bare
|
|
1660
|
+
// substring matches that could fire on prose payloads.
|
|
1661
|
+
if (
|
|
1662
|
+
lower.includes(`"${tok}"`) ||
|
|
1663
|
+
lower.includes(`${tok}=`) ||
|
|
1664
|
+
lower.includes(`name="${tok}"`)
|
|
1665
|
+
) {
|
|
1666
|
+
hit = true;
|
|
1667
|
+
break;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
if (hit) out.push(req.seq);
|
|
1673
|
+
}
|
|
1674
|
+
return out;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** Recursive helper for findUnpairedPasswordRequests' JSON path. */
|
|
1678
|
+
function hasPasswordLikeKey(node: unknown): boolean {
|
|
1679
|
+
if (Array.isArray(node)) {
|
|
1680
|
+
for (const v of node) if (hasPasswordLikeKey(v)) return true;
|
|
1681
|
+
return false;
|
|
1682
|
+
}
|
|
1683
|
+
if (node && typeof node === 'object') {
|
|
1684
|
+
for (const [k, v] of Object.entries(node)) {
|
|
1685
|
+
if (isSensitiveCredentialKey(k)) return true;
|
|
1686
|
+
if (hasPasswordLikeKey(v)) return true;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return false;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1571
1692
|
/** Write `<workflowDir>/credentials.manifest.json` so consumers of the
|
|
1572
1693
|
* generated tool know what credentials to provision. No values, just names. */
|
|
1573
1694
|
function exportSiteManifest(
|