imprint-mcp 0.6.0 → 0.6.2
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 +2 -0
- package/package.json +4 -2
- package/prompts/audit-agent.md +4 -2
- package/prompts/auth-compile-agent.md +149 -158
- package/prompts/build-planning.md +14 -9
- package/prompts/compile-agent.md +46 -23
- package/prompts/live-verifier-agent.md +101 -0
- package/prompts/playbook-compilation.md +8 -1
- package/prompts/tool-candidate-detection.md +9 -29
- package/src/cli.ts +83 -1
- package/src/imprint/audit.ts +219 -20
- package/src/imprint/auth-compile-agent.ts +215 -155
- package/src/imprint/auth-compile-tools.ts +300 -118
- package/src/imprint/auth-verifier.ts +167 -180
- package/src/imprint/backend-cache.ts +18 -11
- package/src/imprint/backend-ladder.ts +225 -177
- package/src/imprint/build-plan.ts +3 -8
- package/src/imprint/cdp-browser-fetch.ts +930 -83
- package/src/imprint/claude-cli-compile.ts +46 -20
- package/src/imprint/codex-cli-compile.ts +436 -139
- package/src/imprint/compile-agent-types.ts +15 -10
- package/src/imprint/compile-agent.ts +88 -28
- package/src/imprint/compile-tools.ts +228 -39
- package/src/imprint/compile-verification.ts +187 -0
- package/src/imprint/compile.ts +71 -2
- package/src/imprint/credential-store.ts +57 -44
- package/src/imprint/emit.ts +3 -1
- package/src/imprint/freeform-redact.ts +27 -6
- package/src/imprint/live-verifier.ts +1665 -0
- package/src/imprint/llm.ts +22 -3
- package/src/imprint/login.ts +107 -25
- package/src/imprint/mcp-compile-server.ts +447 -114
- package/src/imprint/mcp-live-verifier-server.ts +359 -0
- package/src/imprint/mcp-server.ts +116 -35
- package/src/imprint/probe-backends.ts +88 -7
- package/src/imprint/recording-request.ts +36 -0
- package/src/imprint/request-capture.ts +40 -17
- package/src/imprint/runtime.ts +490 -337
- package/src/imprint/teach-state.ts +36 -2
- package/src/imprint/teach.ts +302 -146
- package/src/imprint/tool-candidates.ts +8 -10
- package/src/imprint/tool-loader.ts +12 -4
- package/src/imprint/types.ts +137 -68
|
@@ -1,234 +1,221 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The whole point is ONE persistent browser session across the two 2FA phases:
|
|
7
|
-
* a per-instance `cdpPool` is passed to every `runWorkflowWithLadder` call, so
|
|
8
|
-
* the cdp-replay rung reuses the same live Chrome page across phase 1 (send the
|
|
9
|
-
* OTP / push) → user input → phase 2 (submit the OTP / poll). Launching a fresh
|
|
10
|
-
* browser between phases would reset a server-side challenge or drop a
|
|
11
|
-
* single-use in-page token, so the pool is the load-bearing piece.
|
|
12
|
-
*
|
|
13
|
-
* Lifecycle: the orchestrator (teach) creates one AuthVerifier per auth run,
|
|
14
|
-
* calls `runPhase` as the agent requests verifications, and ALWAYS calls
|
|
15
|
-
* `drain()` in a `finally` — the pool keeps a real Chrome alive across the
|
|
16
|
-
* user-input wait and any cool-off, so it must be closed deterministically.
|
|
17
|
-
*
|
|
18
|
-
* General: nothing here is site- or channel-specific; every decision is driven
|
|
19
|
-
* by the compiled `workflow.json` / `authConfig` and the ladder.
|
|
2
|
+
* Live auth verification. The compile agent defines the auth program; this
|
|
3
|
+
* class only preserves one browser session and continuation state while running
|
|
4
|
+
* the requested actions.
|
|
20
5
|
*/
|
|
21
6
|
|
|
22
7
|
import { runWorkflowWithLadder } from './backend-ladder.ts';
|
|
23
|
-
import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
|
|
8
|
+
import type { CdpBrowserFetch, CdpPageSnapshot } from './cdp-browser-fetch.ts';
|
|
9
|
+
import { redactFreeformText } from './freeform-redact.ts';
|
|
24
10
|
import { createLog } from './log.ts';
|
|
25
11
|
import type { CredentialStore } from './runtime.ts';
|
|
26
12
|
|
|
27
13
|
const log = createLog('auth-verify');
|
|
28
14
|
|
|
29
|
-
/** Test seam: the ladder runner AuthVerifier drives. Swappable so the budget /
|
|
30
|
-
* challenge-counting logic can be unit-tested without a live browser. */
|
|
31
15
|
type LadderRunner = typeof runWorkflowWithLadder;
|
|
32
16
|
let ladderRunner: LadderRunner = runWorkflowWithLadder;
|
|
17
|
+
|
|
33
18
|
export function __setAuthVerifierLadderForTest(fn: LadderRunner | null): void {
|
|
34
19
|
ladderRunner = fn ?? runWorkflowWithLadder;
|
|
35
20
|
}
|
|
36
21
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* - CHALLENGE budget (`maxInitiate`, env IMPRINT_AUTH_MAX_INITIATE, default 2):
|
|
40
|
-
* how many initiates may actually REACH the user — a completed login (`ok`) or
|
|
41
|
-
* an `AWAITING_2FA` (a challenge was delivered). This is what bounds how many
|
|
42
|
-
* OTPs/pushes the user ever sees. It is a *lower bound* on pushes for a
|
|
43
|
-
* multi-request initiate sequence: if the push is sent mid-sequence but a later
|
|
44
|
-
* sub-request fails, the whole initiate is not counted — the attempt budget
|
|
45
|
-
* backstops that.
|
|
46
|
-
* - ATTEMPT budget (`maxInitiateAttempts`, env IMPRINT_AUTH_MAX_INITIATE_ATTEMPTS,
|
|
47
|
-
* default 5): how many initiate tries total, INCLUDING ones that fail BEFORE any
|
|
48
|
-
* challenge is delivered (an edge 403 / network error / bad request shape never
|
|
49
|
-
* reaches the 2FA step, so it sends nothing). This is the runaway guard so a
|
|
50
|
-
* login blocked pre-challenge can't loop forever without ever spending the
|
|
51
|
-
* challenge budget. Clamped `>= maxInitiate` so it can never fire first.
|
|
52
|
-
*
|
|
53
|
-
* Why two: a pre-challenge failure (e.g. an Akamai edge 403 on the credential
|
|
54
|
-
* POST) sends ZERO OTPs/pushes, so it must NOT consume the user-visible challenge
|
|
55
|
-
* budget — otherwise a transient block exhausts the budget and a subsequently
|
|
56
|
-
* corrected workflow can never be verified in the same run. */
|
|
57
|
-
const DEFAULT_MAX_INITIATE = 2;
|
|
58
|
-
const DEFAULT_MAX_INITIATE_ATTEMPTS = 5;
|
|
59
|
-
|
|
60
|
-
type AuthPhase = 'initiate' | 'submit_otp' | 'complete';
|
|
61
|
-
|
|
62
|
-
export interface AuthPhaseResult {
|
|
22
|
+
export interface AuthActionResult {
|
|
23
|
+
action: string;
|
|
63
24
|
ok: boolean;
|
|
64
|
-
/** Error code when !ok (e.g. AWAITING_2FA, AUTH_EXPIRED, BAD_RESPONSE,
|
|
65
|
-
* BUDGET_EXHAUSTED [challenge cap], ATTEMPT_BUDGET_EXHAUSTED [attempt cap]). */
|
|
66
25
|
error?: string;
|
|
67
26
|
message?: string;
|
|
68
|
-
|
|
27
|
+
nextAction?: string;
|
|
28
|
+
continuation?: Record<string, unknown>;
|
|
69
29
|
usedBackend: string;
|
|
70
|
-
twoFactorType?: string;
|
|
71
|
-
/** The `${state.X}` values echoed on AWAITING_2FA, carried into the next phase. */
|
|
72
|
-
twoFactorContext?: Record<string, unknown>;
|
|
73
|
-
/** HTTP status code that produced this result, when one was received. Surfaced
|
|
74
|
-
* so the compile agent sees the concrete code (e.g. 401 vs 400) directly. */
|
|
75
30
|
status?: number;
|
|
76
|
-
/** Truncated response body of the failing / initiate request (first ~500
|
|
77
|
-
* chars) so the agent can inspect the server payload without re-running. */
|
|
78
31
|
responseBodyPreview?: string;
|
|
79
|
-
/** Wall-clock duration of this phase's live execution, in milliseconds. */
|
|
80
32
|
durationMs: number;
|
|
81
33
|
}
|
|
82
34
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
35
|
+
interface AuthPageInspection {
|
|
36
|
+
ok: boolean;
|
|
37
|
+
message?: string;
|
|
38
|
+
url?: string;
|
|
39
|
+
title?: string;
|
|
40
|
+
bodyText?: string;
|
|
41
|
+
bodyTextTruncated?: boolean;
|
|
42
|
+
cookies?: CdpPageSnapshot['cookies'];
|
|
87
43
|
}
|
|
88
44
|
|
|
89
45
|
export class AuthVerifier {
|
|
90
|
-
/** Per-run CDP pool — the ONE live session reused across phases. */
|
|
91
46
|
private readonly cdpPool = new Map<string, CdpBrowserFetch>();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
private challengesIssued = 0;
|
|
95
|
-
/** All initiate tries, including ones that failed before any challenge was
|
|
96
|
-
* delivered. Bounded by `maxInitiateAttempts` — the runaway guard. */
|
|
97
|
-
private initiateAttempts = 0;
|
|
98
|
-
/** twoFactorContext echoed by the most recent initiate, threaded into the
|
|
99
|
-
* completion phase so submit_otp can resolve `${state.X}`. */
|
|
100
|
-
private lastTwoFactorContext: Record<string, unknown> | undefined;
|
|
101
|
-
private readonly maxInitiate: number;
|
|
102
|
-
/** Public so the orchestrator's progress line can show "attempt N/M". */
|
|
103
|
-
readonly maxInitiateAttempts: number;
|
|
47
|
+
private continuation: Record<string, unknown> | undefined;
|
|
48
|
+
private readonly sensitiveValues = new Set<string>();
|
|
104
49
|
|
|
105
50
|
constructor(
|
|
106
51
|
private readonly workflowPath: string,
|
|
107
52
|
private readonly credentials: CredentialStore,
|
|
108
|
-
maxInitiate?: number,
|
|
109
|
-
maxInitiateAttempts?: number,
|
|
110
53
|
) {
|
|
111
|
-
this.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
DEFAULT_MAX_INITIATE;
|
|
115
|
-
// The attempt cap can never fire before the challenge budget is usable.
|
|
116
|
-
this.maxInitiateAttempts = Math.max(
|
|
117
|
-
this.maxInitiate,
|
|
118
|
-
maxInitiateAttempts ??
|
|
119
|
-
parsePositiveInt(process.env.IMPRINT_AUTH_MAX_INITIATE_ATTEMPTS) ??
|
|
120
|
-
DEFAULT_MAX_INITIATE_ATTEMPTS,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** User-visible challenges delivered so far (each ≈ one OTP/push the user saw). */
|
|
125
|
-
get initiatesUsed(): number {
|
|
126
|
-
return this.challengesIssued;
|
|
54
|
+
this.rememberSensitiveValues(credentials.values);
|
|
55
|
+
for (const cookie of credentials.cookies) this.rememberSensitiveValues(cookie.value);
|
|
56
|
+
for (const storage of credentials.storage ?? []) this.rememberSensitiveValues(storage.value);
|
|
127
57
|
}
|
|
128
58
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// Gate 1: challenge budget — bounds OTPs/pushes the user actually sees.
|
|
139
|
-
if (this.challengesIssued >= this.maxInitiate) {
|
|
140
|
-
return {
|
|
141
|
-
ok: false,
|
|
142
|
-
error: 'BUDGET_EXHAUSTED',
|
|
143
|
-
message: `Live-login budget of ${this.maxInitiate} delivered 2FA challenge(s) reached — do NOT request another initiate. Either give_up, or only call run_verification for the completion phase if a challenge is already pending.`,
|
|
144
|
-
usedBackend: 'none',
|
|
145
|
-
durationMs: 0,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
// Gate 2: attempt budget — runaway guard for logins blocked BEFORE any
|
|
149
|
-
// challenge is delivered (edge 403 / network / bad request shape). These
|
|
150
|
-
// send nothing, so they don't spend the challenge budget, but they must
|
|
151
|
-
// not loop forever.
|
|
152
|
-
if (this.initiateAttempts >= this.maxInitiateAttempts) {
|
|
153
|
-
return {
|
|
154
|
-
ok: false,
|
|
155
|
-
error: 'ATTEMPT_BUDGET_EXHAUSTED',
|
|
156
|
-
message: `Made ${this.maxInitiateAttempts} live initiate attempt(s), none of which delivered a 2FA challenge — the login is being blocked before it reaches the 2FA step (edge/anti-bot block or a defect in the request shape). Stop and give_up; a fresh run with corrected artifacts is needed.`,
|
|
157
|
-
usedBackend: 'none',
|
|
158
|
-
durationMs: 0,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const params: Record<string, string> = { action: phase };
|
|
164
|
-
if (phase === 'submit_otp' && opts?.otp_code) params.otp_code = opts.otp_code;
|
|
165
|
-
|
|
166
|
-
const t0 = Date.now();
|
|
59
|
+
async runAction(
|
|
60
|
+
action: string,
|
|
61
|
+
parameters: Record<string, string | number | boolean> = {},
|
|
62
|
+
options: { freshSession?: boolean; cleanSession?: boolean } = {},
|
|
63
|
+
): Promise<AuthActionResult> {
|
|
64
|
+
if (options.freshSession || options.cleanSession) await this.reset();
|
|
65
|
+
this.rememberSensitiveValues(parameters);
|
|
66
|
+
const previousContinuation = this.continuation;
|
|
67
|
+
const startedAt = Date.now();
|
|
167
68
|
const ladder = await ladderRunner({
|
|
168
69
|
workflowPath: this.workflowPath,
|
|
169
|
-
params,
|
|
170
|
-
credentials:
|
|
70
|
+
params: { ...parameters, action },
|
|
71
|
+
credentials: options.cleanSession
|
|
72
|
+
? { ...this.credentials, cookies: [], storage: [] }
|
|
73
|
+
: this.credentials,
|
|
171
74
|
cdpPool: this.cdpPool,
|
|
172
|
-
// Pin every phase to cdp-replay. This is the load-bearing decision for 2FA:
|
|
173
|
-
// ONLY the cdp-replay rung keeps a live browser in `cdpPool`, and the
|
|
174
|
-
// AWAITING_2FA carve-out in runCdpReplay retains it across the user-input
|
|
175
|
-
// gap — so phase 2 reuses the EXACT session (cookies + server challenge +
|
|
176
|
-
// in-page tokens) that phase 1 minted. Left to the probe, the fastest rung
|
|
177
|
-
// returning AWAITING_2FA wins (often fetch/fetch-bootstrap), which is
|
|
178
|
-
// stateless across calls → the completion poll/re-login 401s with
|
|
179
|
-
// "tokens missing". cdp-replay is also the most anti-bot-robust rung, so
|
|
180
|
-
// there's no downside for an (infrequent) auth flow.
|
|
181
75
|
forceBackend: 'cdp-replay',
|
|
182
|
-
|
|
183
|
-
// session's submit_otp resolves ${state.X}. (Cookies ride the shared
|
|
184
|
-
// pool/jar; this covers body-returned tokens.)
|
|
185
|
-
initialState: phase === 'initiate' ? undefined : this.lastTwoFactorContext,
|
|
76
|
+
initialState: this.continuation,
|
|
186
77
|
});
|
|
187
|
-
const durationMs = Date.now() -
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
78
|
+
const durationMs = Date.now() - startedAt;
|
|
79
|
+
const result = ladder.result;
|
|
80
|
+
|
|
81
|
+
for (const browser of this.cdpPool.values()) {
|
|
82
|
+
if (!browser.snapshotCookies) continue;
|
|
83
|
+
try {
|
|
84
|
+
for (const cookie of await browser.snapshotCookies()) {
|
|
85
|
+
this.rememberSensitiveValues(cookie.value);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// A failed browser will be handled by the ladder's liveness policy.
|
|
89
|
+
}
|
|
199
90
|
}
|
|
200
|
-
|
|
201
|
-
|
|
91
|
+
|
|
92
|
+
if (result.ok) {
|
|
93
|
+
this.continuation = undefined;
|
|
94
|
+
} else if (result.continuation !== undefined) {
|
|
95
|
+
this.continuation = result.continuation;
|
|
96
|
+
} else {
|
|
97
|
+
this.continuation = previousContinuation;
|
|
202
98
|
}
|
|
99
|
+
this.rememberSensitiveValues(this.continuation);
|
|
100
|
+
|
|
101
|
+
const actionResult: AuthActionResult = {
|
|
102
|
+
action,
|
|
103
|
+
ok: result.ok,
|
|
104
|
+
error: result.ok ? undefined : this.sanitize(result.error),
|
|
105
|
+
message: result.ok ? undefined : this.sanitize(result.message),
|
|
106
|
+
nextAction: result.ok ? undefined : this.sanitizeOptional(result.nextAction),
|
|
107
|
+
continuation: result.ok ? undefined : this.continuation,
|
|
108
|
+
usedBackend: ladder.usedBackend,
|
|
109
|
+
status: result.ok ? undefined : result.status,
|
|
110
|
+
responseBodyPreview: result.ok
|
|
111
|
+
? undefined
|
|
112
|
+
: this.sanitizeOptional(result.responseBodyPreview),
|
|
113
|
+
durationMs,
|
|
114
|
+
};
|
|
203
115
|
log(
|
|
204
|
-
`
|
|
116
|
+
`action=${JSON.stringify(action)} backend=${ladder.usedBackend} ok=${result.ok}${
|
|
117
|
+
result.ok ? '' : ` error=${result.error}`
|
|
118
|
+
} in ${durationMs}ms`,
|
|
205
119
|
);
|
|
120
|
+
return actionResult;
|
|
121
|
+
}
|
|
206
122
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
:
|
|
123
|
+
async inspectPage(
|
|
124
|
+
options: { maxChars?: number; includeCookies?: boolean } = {},
|
|
125
|
+
): Promise<AuthPageInspection> {
|
|
126
|
+
const browser = Array.from(this.cdpPool.values()).at(-1);
|
|
127
|
+
if (!browser) {
|
|
128
|
+
return { ok: false, message: 'No active verification browser session exists.' };
|
|
129
|
+
}
|
|
130
|
+
if (!browser.inspectPage) {
|
|
131
|
+
return { ok: false, message: 'The active verification browser cannot inspect pages.' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const maxChars = Math.max(256, Math.min(20_000, Math.floor(options.maxChars ?? 8_000)));
|
|
135
|
+
const snapshot = await browser.inspectPage();
|
|
136
|
+
const bodyText = this.sanitize(snapshot.bodyText);
|
|
213
137
|
return {
|
|
214
|
-
ok:
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
138
|
+
ok: true,
|
|
139
|
+
url: this.sanitize(snapshot.url),
|
|
140
|
+
title: this.sanitize(snapshot.title),
|
|
141
|
+
bodyText: bodyText.slice(0, maxChars),
|
|
142
|
+
bodyTextTruncated: bodyText.length > maxChars,
|
|
143
|
+
...(options.includeCookies === false
|
|
144
|
+
? {}
|
|
145
|
+
: {
|
|
146
|
+
cookies: snapshot.cookies.map((cookie) => ({
|
|
147
|
+
name: cookie.name,
|
|
148
|
+
domain: cookie.domain,
|
|
149
|
+
path: cookie.path,
|
|
150
|
+
expires: cookie.expires,
|
|
151
|
+
httpOnly: cookie.httpOnly,
|
|
152
|
+
secure: cookie.secure,
|
|
153
|
+
sameSite: cookie.sameSite,
|
|
154
|
+
})),
|
|
155
|
+
}),
|
|
223
156
|
};
|
|
224
157
|
}
|
|
225
158
|
|
|
226
|
-
/** Close every pooled browser. MUST be called (teach's `finally`) — the pool
|
|
227
|
-
* holds a live Chrome across the user-input wait, so it never auto-closes. */
|
|
228
159
|
async drain(): Promise<void> {
|
|
229
|
-
|
|
230
|
-
|
|
160
|
+
await this.reset();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async reset(): Promise<void> {
|
|
164
|
+
for (const browser of this.cdpPool.values()) {
|
|
165
|
+
await browser.close().catch(() => {});
|
|
231
166
|
}
|
|
232
167
|
this.cdpPool.clear();
|
|
168
|
+
this.continuation = undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private rememberSensitiveValues(value: unknown, seen = new Set<object>()): void {
|
|
172
|
+
if (typeof value === 'string') {
|
|
173
|
+
this.rememberSensitiveValue(value);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (value === null || typeof value !== 'object' || seen.has(value)) return;
|
|
177
|
+
|
|
178
|
+
seen.add(value);
|
|
179
|
+
for (const nested of Object.values(value)) this.rememberSensitiveValues(nested, seen);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private rememberSensitiveValue(value: string): void {
|
|
183
|
+
if (value.length < 3) return;
|
|
184
|
+
this.sensitiveValues.add(value);
|
|
185
|
+
|
|
186
|
+
const encoded = encodeURIComponent(value);
|
|
187
|
+
const lowercaseEncoded = encoded.replace(/%[0-9A-F]{2}/g, (encodedByte) =>
|
|
188
|
+
encodedByte.toLowerCase(),
|
|
189
|
+
);
|
|
190
|
+
this.sensitiveValues.add(encoded);
|
|
191
|
+
this.sensitiveValues.add(encoded.replace(/%20/g, '+'));
|
|
192
|
+
this.sensitiveValues.add(lowercaseEncoded);
|
|
193
|
+
this.sensitiveValues.add(lowercaseEncoded.replace(/%20/g, '+'));
|
|
194
|
+
this.sensitiveValues.add(JSON.stringify(value).slice(1, -1));
|
|
195
|
+
this.sensitiveValues.add(
|
|
196
|
+
Array.from(value, (character) =>
|
|
197
|
+
/[a-z0-9]/i.test(character) ? character : `&#${character.codePointAt(0) ?? 0};`,
|
|
198
|
+
).join(''),
|
|
199
|
+
);
|
|
200
|
+
this.sensitiveValues.add(
|
|
201
|
+
Array.from(value, (character) =>
|
|
202
|
+
/[a-z0-9]/i.test(character)
|
|
203
|
+
? character
|
|
204
|
+
: `&#x${(character.codePointAt(0) ?? 0).toString(16)};`,
|
|
205
|
+
).join(''),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private sanitize(value: string): string {
|
|
210
|
+
let sanitized = redactFreeformText(value).redacted;
|
|
211
|
+
const secrets = Array.from(this.sensitiveValues).sort((a, b) => b.length - a.length);
|
|
212
|
+
for (const secret of secrets) {
|
|
213
|
+
sanitized = sanitized.split(secret).join('[REDACTED]');
|
|
214
|
+
}
|
|
215
|
+
return sanitized;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private sanitizeOptional(value: string | undefined): string | undefined {
|
|
219
|
+
return value === undefined ? undefined : this.sanitize(value);
|
|
233
220
|
}
|
|
234
221
|
}
|
|
@@ -15,7 +15,14 @@ export type BackendsCacheStatus =
|
|
|
15
15
|
cache: BackendsCache;
|
|
16
16
|
}
|
|
17
17
|
| {
|
|
18
|
-
status: 'stale'
|
|
18
|
+
status: 'stale';
|
|
19
|
+
path: string;
|
|
20
|
+
cache: BackendsCache;
|
|
21
|
+
reason: string;
|
|
22
|
+
remediation: string;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
status: 'invalid';
|
|
19
26
|
path: string;
|
|
20
27
|
reason: string;
|
|
21
28
|
remediation: string;
|
|
@@ -66,6 +73,15 @@ export function loadBackendsCacheStatus(
|
|
|
66
73
|
try {
|
|
67
74
|
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
68
75
|
const parsed = BackendsCacheSchema.parse(raw);
|
|
76
|
+
const invalidPreferredReason = invalidPreferredOrderReason(parsed);
|
|
77
|
+
if (invalidPreferredReason) {
|
|
78
|
+
if (opts.warn !== false) {
|
|
79
|
+
process.stderr.write(
|
|
80
|
+
`[imprint] backends.json at ${path} has unsafe preferred backends — ignoring (run \`${remediation}\` to regenerate): ${invalidPreferredReason}\n`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return { status: 'invalid', path, reason: invalidPreferredReason, remediation };
|
|
84
|
+
}
|
|
69
85
|
if (parsed.schemaVersion && parsed.schemaVersion >= 2 && parsed.workflowHash) {
|
|
70
86
|
const workflowPath = pathResolve(toolDir, 'workflow.json');
|
|
71
87
|
if (existsSync(workflowPath)) {
|
|
@@ -77,19 +93,10 @@ export function loadBackendsCacheStatus(
|
|
|
77
93
|
`[imprint] backends.json at ${path} is stale for current workflow — ignoring (run \`${remediation}\` to regenerate)\n`,
|
|
78
94
|
);
|
|
79
95
|
}
|
|
80
|
-
return { status: 'stale', path, reason, remediation };
|
|
96
|
+
return { status: 'stale', path, cache: parsed, reason, remediation };
|
|
81
97
|
}
|
|
82
98
|
}
|
|
83
99
|
}
|
|
84
|
-
const invalidPreferredReason = invalidPreferredOrderReason(parsed);
|
|
85
|
-
if (invalidPreferredReason) {
|
|
86
|
-
if (opts.warn !== false) {
|
|
87
|
-
process.stderr.write(
|
|
88
|
-
`[imprint] backends.json at ${path} has unsafe preferred backends — ignoring (run \`${remediation}\` to regenerate): ${invalidPreferredReason}\n`,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
return { status: 'invalid', path, reason: invalidPreferredReason, remediation };
|
|
92
|
-
}
|
|
93
100
|
return { status: 'ok', path, cache: parsed };
|
|
94
101
|
} catch (err) {
|
|
95
102
|
const reason = err instanceof Error ? err.message : String(err);
|