hilos-agent 0.10.1 → 0.11.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/LICENSE +21 -0
- package/README.md +54 -10
- package/bin/hilos-agent.mjs +87 -14
- package/package.json +7 -5
- package/src/acp-session.mjs +16 -1
- package/src/cli.mjs +34 -1
- package/src/codex-mcp-session.mjs +36 -0
- package/src/config.mjs +39 -5
- package/src/conflicts.mjs +156 -0
- package/src/cursor-store.mjs +43 -8
- package/src/handler.mjs +1197 -127
- package/src/iterate-claim-recovery.mjs +574 -0
- package/src/local-harness-events.mjs +370 -0
- package/src/local-harness-transport.mjs +157 -0
- package/src/mcp.mjs +411 -34
- package/src/model-resolve.mjs +3 -1
- package/src/opencode-session.mjs +22 -2
- package/src/progress-emitter.mjs +1 -1
- package/src/queue.mjs +70 -3
- package/src/redact.mjs +360 -7
- package/src/run-inputs.mjs +211 -0
- package/src/run.mjs +462 -76
- package/src/thread-pr.mjs +26 -4
- package/src/webmcp-bridge.mjs +77 -4
package/src/queue.mjs
CHANGED
|
@@ -71,21 +71,37 @@ export function looksLikeCancel(text) {
|
|
|
71
71
|
* (optional) dedupes against the active + queued jobs.
|
|
72
72
|
* - cancelActive(reason) aborts ONLY the in-flight job's AbortSignal; queued jobs
|
|
73
73
|
* are untouched.
|
|
74
|
+
* - shutdown(reason) closes intake, discards queued jobs, aborts active work,
|
|
75
|
+
* and resolves only after that work has completed its teardown.
|
|
74
76
|
* - idle() resolves when nothing is active or queued (used to drain on --once).
|
|
75
77
|
*/
|
|
76
78
|
/**
|
|
77
79
|
* @param {{
|
|
78
80
|
* runJob: (job: any, ctx: { signal: AbortSignal }) => Promise<void> | void,
|
|
81
|
+
* afterJob?: (job: any) => Promise<boolean | void> | boolean | void,
|
|
82
|
+
* afterJobRetryDelay?: () => Promise<void>,
|
|
79
83
|
* concurrency?: number,
|
|
80
84
|
* }} [opts]
|
|
81
85
|
*/
|
|
82
86
|
export function createQueue(opts = {}) {
|
|
83
|
-
const {
|
|
87
|
+
const {
|
|
88
|
+
runJob,
|
|
89
|
+
afterJob,
|
|
90
|
+
afterJobRetryDelay = () => new Promise((resolve) => setTimeout(resolve, 1000)),
|
|
91
|
+
concurrency = 1,
|
|
92
|
+
} = opts;
|
|
84
93
|
void concurrency; // single-slot in v1; documented in the ticket.
|
|
85
94
|
const queued = [];
|
|
86
95
|
let active = null; // { job, controller }
|
|
87
96
|
let draining = false;
|
|
97
|
+
let closed = false;
|
|
98
|
+
let betweenJobs = false;
|
|
88
99
|
let idleResolvers = [];
|
|
100
|
+
let resolveClosed;
|
|
101
|
+
const closedPromise = new Promise((resolve) => {
|
|
102
|
+
resolveClosed = resolve;
|
|
103
|
+
});
|
|
104
|
+
const CLOSED = Symbol("queue-closed");
|
|
89
105
|
|
|
90
106
|
function has(key) {
|
|
91
107
|
if (key == null) return false;
|
|
@@ -99,7 +115,7 @@ export function createQueue(opts = {}) {
|
|
|
99
115
|
async function drain() {
|
|
100
116
|
if (draining) return;
|
|
101
117
|
draining = true;
|
|
102
|
-
while (queued.length) {
|
|
118
|
+
while (!closed && queued.length) {
|
|
103
119
|
const job = queued.shift();
|
|
104
120
|
const controller = new AbortController();
|
|
105
121
|
active = { job, controller };
|
|
@@ -108,6 +124,30 @@ export function createQueue(opts = {}) {
|
|
|
108
124
|
} catch {
|
|
109
125
|
// runJob is expected to handle its own errors; never break the loop.
|
|
110
126
|
}
|
|
127
|
+
if (afterJob) {
|
|
128
|
+
// A completed handler may have left durable ownership state behind.
|
|
129
|
+
// Keep this job active and fence the next FIFO entry until the hook
|
|
130
|
+
// proves that state safe. Intake can continue appending behind the
|
|
131
|
+
// fence, so a poll response is never acknowledged and then dropped.
|
|
132
|
+
betweenJobs = true;
|
|
133
|
+
let safe = false;
|
|
134
|
+
while (!safe && !closed) {
|
|
135
|
+
const check = Promise.resolve()
|
|
136
|
+
.then(() => afterJob(job))
|
|
137
|
+
.then((result) => result !== false)
|
|
138
|
+
.catch(() => false);
|
|
139
|
+
const outcome = await Promise.race([
|
|
140
|
+
check,
|
|
141
|
+
closedPromise.then(() => CLOSED),
|
|
142
|
+
]);
|
|
143
|
+
if (outcome === CLOSED) break;
|
|
144
|
+
safe = outcome;
|
|
145
|
+
if (!safe && !closed) {
|
|
146
|
+
await Promise.race([afterJobRetryDelay(), closedPromise]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
betweenJobs = false;
|
|
150
|
+
}
|
|
111
151
|
active = null;
|
|
112
152
|
}
|
|
113
153
|
draining = false;
|
|
@@ -117,6 +157,15 @@ export function createQueue(opts = {}) {
|
|
|
117
157
|
}
|
|
118
158
|
|
|
119
159
|
function enqueue(job) {
|
|
160
|
+
if (closed) {
|
|
161
|
+
return {
|
|
162
|
+
accepted: false,
|
|
163
|
+
deduped: false,
|
|
164
|
+
shutdown: true,
|
|
165
|
+
position: 0,
|
|
166
|
+
startedImmediately: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
120
169
|
if (job && job.key != null && has(job.key)) {
|
|
121
170
|
return { accepted: false, deduped: true, position: 0, startedImmediately: false };
|
|
122
171
|
}
|
|
@@ -140,10 +189,28 @@ export function createQueue(opts = {}) {
|
|
|
140
189
|
function activeCount() {
|
|
141
190
|
return active ? 1 : 0;
|
|
142
191
|
}
|
|
192
|
+
function blocked() {
|
|
193
|
+
return betweenJobs;
|
|
194
|
+
}
|
|
143
195
|
function idle() {
|
|
144
196
|
if (!active && queued.length === 0) return Promise.resolve();
|
|
145
197
|
return new Promise((r) => idleResolvers.push(r));
|
|
146
198
|
}
|
|
147
199
|
|
|
148
|
-
|
|
200
|
+
function shutdown(reason) {
|
|
201
|
+
// Set the fence before abort dispatch: an abort listener (or any other
|
|
202
|
+
// synchronous callback) can try to enqueue, and must observe a closed
|
|
203
|
+
// queue. Clearing pending jobs in the same turn ensures drain() can never
|
|
204
|
+
// advance to one after the active handler's teardown completes.
|
|
205
|
+
closed = true;
|
|
206
|
+
resolveClosed();
|
|
207
|
+
queued.splice(0, queued.length);
|
|
208
|
+
if (active) {
|
|
209
|
+
active.cancelled = true;
|
|
210
|
+
active.controller.abort(reason || "queue shutdown");
|
|
211
|
+
}
|
|
212
|
+
return idle();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { enqueue, cancelActive, shutdown, has, size, active: activeCount, blocked, idle };
|
|
149
216
|
}
|
package/src/redact.mjs
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
// redact CLI output before it is returned, logged, or attached to a report.
|
|
4
4
|
|
|
5
5
|
const PEM_BLOCK = /-----BEGIN [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----[\s\S]*?-----END [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----/g;
|
|
6
|
+
// A bounded caller may intentionally provide only the front of a long log.
|
|
7
|
+
// Once a private-key header appears, fail closed even if its END marker fell
|
|
8
|
+
// beyond that caller's scan window.
|
|
9
|
+
const UNTERMINATED_PEM = /-----BEGIN [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----[\s\S]*$/g;
|
|
6
10
|
const JWT = /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}(?:\.[A-Za-z0-9_-]{6,})?\b/g;
|
|
11
|
+
const UNTERMINATED_JWT = /\beyJ[A-Za-z0-9_-]{20,}(?:\.[A-Za-z0-9_-]*)?$/g;
|
|
7
12
|
const URL_CREDENTIALS = /(\b[a-z][a-z0-9+.-]*:\/\/)([^\/\s:@"']+):([^\/\s@"']+)@/gi;
|
|
13
|
+
const UNTERMINATED_URL_CREDENTIALS = /(\b[a-z][a-z0-9+.-]*:\/\/)([^\/\s:@"']+):([^\/\s@"']{8,})$/gi;
|
|
8
14
|
const PROVIDER_TOKENS = new RegExp(
|
|
9
15
|
[
|
|
10
16
|
"sk-ant-[A-Za-z0-9_-]{8,}",
|
|
@@ -25,23 +31,370 @@ const PROVIDER_TOKENS = new RegExp(
|
|
|
25
31
|
].join("|"),
|
|
26
32
|
"g",
|
|
27
33
|
);
|
|
28
|
-
const BEARER = /(\b[Bb]earer["']?[\s:=]+["']?)[A-Za-z0-9._~+/=-]
|
|
29
|
-
const
|
|
30
|
-
|
|
34
|
+
const BEARER = /(\b[Bb]earer["']?[\s:=]+["']?)[A-Za-z0-9._~+/=-]+/g;
|
|
35
|
+
const SENSITIVE_HEADER = /(\b(?:proxy-authorization|authorization|set-cookie|cookie)\s*[:=])([^\r\n]*)/gi;
|
|
36
|
+
// Structured keys canonicalize punctuation before classification. Accept a
|
|
37
|
+
// bounded run of the same non-alphanumeric separators in serialized/form keys
|
|
38
|
+
// (`api[key]`, `api@key`) without allowing the scan to cross an assignment,
|
|
39
|
+
// quote, or physical line boundary.
|
|
40
|
+
const ASSIGNMENT_KEY_SEPARATOR =
|
|
41
|
+
"[\\t !#$%&()*+,./?@\\[\\]\\\\^_`{|}~-]{0,8}";
|
|
42
|
+
const GENERIC_SENSITIVE_ASSIGNMENT_KEY = [
|
|
43
|
+
`api${ASSIGNMENT_KEY_SEPARATOR}(?:keys?|tokens?)`,
|
|
44
|
+
`access${ASSIGNMENT_KEY_SEPARATOR}(?:keys?(?:${ASSIGNMENT_KEY_SEPARATOR}id)?|tokens?)`,
|
|
45
|
+
`secret(?:(?:${ASSIGNMENT_KEY_SEPARATOR}access)?${ASSIGNMENT_KEY_SEPARATOR}keys?)?`,
|
|
46
|
+
`client${ASSIGNMENT_KEY_SEPARATOR}secret`,
|
|
47
|
+
`private${ASSIGNMENT_KEY_SEPARATOR}key`,
|
|
48
|
+
`service${ASSIGNMENT_KEY_SEPARATOR}role${ASSIGNMENT_KEY_SEPARATOR}key`,
|
|
49
|
+
`(?:auth|refresh|session|oauth|id)${ASSIGNMENT_KEY_SEPARATOR}tokens?`,
|
|
50
|
+
"tokens?",
|
|
51
|
+
`authorization(?:${ASSIGNMENT_KEY_SEPARATOR}header)?`,
|
|
52
|
+
`proxy${ASSIGNMENT_KEY_SEPARATOR}authorization`,
|
|
53
|
+
`cookie(?:${ASSIGNMENT_KEY_SEPARATOR}header)?`,
|
|
54
|
+
`set${ASSIGNMENT_KEY_SEPARATOR}cookie`,
|
|
55
|
+
"password",
|
|
56
|
+
"passwd",
|
|
57
|
+
"credentials?",
|
|
58
|
+
].join("|");
|
|
59
|
+
// Once a credential root is identified, consume a bounded serializer suffix
|
|
60
|
+
// as one character-class run. Digits, punctuation, and the letters in `value`
|
|
61
|
+
// cover arbitrary `[n].value` depth without nested regex repetition. Excluding
|
|
62
|
+
// other letters prevents prose such as `token is [redacted:jwt]` from being
|
|
63
|
+
// reinterpreted as one enormous assignment key.
|
|
64
|
+
const SENSITIVE_ASSIGNMENT_SUFFIX =
|
|
65
|
+
"[ \\t._/@\\[\\](){}0-9vVaAlLuUeE-]{0,160}";
|
|
66
|
+
const SENSITIVE_ASSIGNMENT_KEY =
|
|
67
|
+
`(?:${GENERIC_SENSITIVE_ASSIGNMENT_KEY})${SENSITIVE_ASSIGNMENT_SUFFIX}`;
|
|
68
|
+
const EXCESSIVE_ESCAPE_RUN = /\\{33}/;
|
|
69
|
+
const SENSITIVE_CANONICAL_KEY_SUFFIX = /(?:api_?(?:keys?|tokens?)|access_?(?:keys?(?:_?id)?|tokens?)|secret(?:_?access_?keys?|_?keys?)?|client_?secret|private_?key|service_?role_?key|(?:auth|refresh|session|oauth|id)_?tokens?|tokens?|authorization(?:_?header)?|proxy_?authorization|cookie(?:_?header)?|set_?cookie|password|passwd|credentials?)(?:(?:_value)|(?:_\d{1,6}))*$/;
|
|
70
|
+
// Start of a quoted credential assignment. The value is scanned below instead
|
|
71
|
+
// of captured by a regular expression: an escaped quote inside a password is
|
|
72
|
+
// content, not the closing delimiter, and nested serialized JSON represents
|
|
73
|
+
// each delimiter with the same run of backslashes.
|
|
74
|
+
const QUOTED_ASSIGNMENT_START = new RegExp(
|
|
75
|
+
`((?:(?:\\\\{1,32})?["']?${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["']?)[ \\t]*[:=]+[ \\t]*)(\\$?)(\\\\{0,32})(["'])`,
|
|
76
|
+
"gi",
|
|
77
|
+
);
|
|
78
|
+
const YAML_MULTILINE_ASSIGNMENT_LINE = new RegExp(
|
|
79
|
+
`^(([ \\t]*(?:-[ \\t]+)?)(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]+)*(?:(?:\\\\{1,32})?["']?${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["']?)[ \\t]*:[ \\t]*)([^\\r\\n]*)(\\r?\\n|$)`,
|
|
80
|
+
"gim",
|
|
81
|
+
);
|
|
82
|
+
const YAML_EXPLICIT_MULTILINE_ASSIGNMENT_LINE = new RegExp(
|
|
83
|
+
`^(([ \\t]*(?:-[ \\t]+)?)\\?[ \\t]+(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]+)*(?:(?:\\\\{1,32})?["']?${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["']?)[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n(?:[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n)*[ \\t]*:[ \\t]*)([^\\r\\n]*)(\\r?\\n|$)`,
|
|
84
|
+
"gim",
|
|
85
|
+
);
|
|
86
|
+
const YAML_SPLIT_EXPLICIT_MULTILINE_ASSIGNMENT_LINE = new RegExp(
|
|
87
|
+
`^(([ \\t]*(?:-[ \\t]+)?)\\?[ \\t]*(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]*)*(?:#[^\\r\\n]*)?\\r?\\n(?:[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n)*(?:[ \\t]+(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]*)+(?:#[^\\r\\n]*)?\\r?\\n(?:[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n)*)*[ \\t]+(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]+)*(?:(?:\\\\{1,32})?["']?${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["']?)[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n(?:[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n)*[ \\t]*:[ \\t]*)([^\\r\\n]*)(\\r?\\n|$)`,
|
|
88
|
+
"gim",
|
|
89
|
+
);
|
|
90
|
+
const SHELL_MULTILINE_ASSIGNMENT = new RegExp(
|
|
91
|
+
`(${SENSITIVE_ASSIGNMENT_KEY}[ \\t]*[:=]+[ \\t]*)(?:\\$?\\(|[<>]\\(|\\$\\{|\\$\\[|\\x60)[\\s\\S]*`,
|
|
92
|
+
"gi",
|
|
93
|
+
);
|
|
94
|
+
const JSON_UNQUOTED_ASSIGNMENT = new RegExp(
|
|
95
|
+
`(((?:\\\\{1,32})?["']${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["'])[ \\t]*[:=]+[ \\t]*)(?!(?:\\\\{1,32})?["'])([^,;}\\]\\r\\n]+)`,
|
|
96
|
+
"gi",
|
|
97
|
+
);
|
|
98
|
+
const UNQUOTED_ASSIGNMENT = new RegExp(
|
|
99
|
+
`(${SENSITIVE_ASSIGNMENT_KEY}[ \\t]*[:=]+[ \\t]*)(?!(?:\\$?)(?:\\\\{1,32})?["'])((?:\\\\(?:\\r?\\n|;)|[^;\\r\\n])+)`,
|
|
100
|
+
"gi",
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
function redactAssignment(match, prefix, value, suffix = "", allowSyntaxLiterals = true) {
|
|
104
|
+
const normalized = value.trim();
|
|
105
|
+
if (
|
|
106
|
+
!normalized ||
|
|
107
|
+
/^\[redacted:[^\]]+\]$/i.test(normalized) ||
|
|
108
|
+
(allowSyntaxLiterals && /^(?:null|true|false|undefined|bearer)$/i.test(normalized)) ||
|
|
109
|
+
(allowSyntaxLiterals && /^(?:null|true|false|undefined)[ \t]+\\{33,}$/i.test(normalized)) ||
|
|
110
|
+
/^bearer\s+\[redacted:[^\]]+\]["']?$/i.test(normalized)
|
|
111
|
+
) {
|
|
112
|
+
return match;
|
|
113
|
+
}
|
|
114
|
+
return `${prefix}[redacted:value]${suffix}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function closingQuote(text, start, quote, delimiterSlashes) {
|
|
118
|
+
for (let i = start; i < text.length; i += 1) {
|
|
119
|
+
if (text[i] !== quote) continue;
|
|
120
|
+
let slashCount = 0;
|
|
121
|
+
for (let j = i - 1; j >= start && text[j] === "\\"; j -= 1) slashCount += 1;
|
|
122
|
+
if (delimiterSlashes === 0 ? slashCount % 2 === 0 : slashCount === delimiterSlashes) {
|
|
123
|
+
return { quoteIndex: i, tokenStart: i - slashCount };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Redact escape-aware quoted assignments without ever exposing a suffix. */
|
|
130
|
+
function redactQuotedAssignments(text) {
|
|
131
|
+
QUOTED_ASSIGNMENT_START.lastIndex = 0;
|
|
132
|
+
let cursor = 0;
|
|
133
|
+
let out = "";
|
|
134
|
+
let match;
|
|
135
|
+
while ((match = QUOTED_ASSIGNMENT_START.exec(text)) !== null) {
|
|
136
|
+
const prefix = match[1];
|
|
137
|
+
const sigil = match[2];
|
|
138
|
+
const slashes = match[3];
|
|
139
|
+
const quote = match[4];
|
|
140
|
+
const valueStart = QUOTED_ASSIGNMENT_START.lastIndex;
|
|
141
|
+
const closing = closingQuote(text, valueStart, quote, slashes.length);
|
|
142
|
+
// A malformed/unterminated credential value fails closed through the rest
|
|
143
|
+
// of the bounded input. Stopping at a newline would expose every following
|
|
144
|
+
// continuation line while still claiming redaction was applied.
|
|
145
|
+
const valueEnd = closing ? closing.tokenStart : text.length;
|
|
146
|
+
const quotedEnd = closing ? closing.quoteIndex + 1 : text.length;
|
|
147
|
+
let segmentEnd = quotedEnd;
|
|
148
|
+
if (closing) {
|
|
149
|
+
// Shell assignments may concatenate quoted and unquoted word fragments:
|
|
150
|
+
// PASSWORD="secret"tail is one credential, not a secret plus safe text.
|
|
151
|
+
// Consume the attached fragment through the assignment terminator.
|
|
152
|
+
const quotedKey = /(?:\\+)?["'][^"'\r\n]+(?:\\+)?["']\s*[:=]+\s*$/.test(prefix);
|
|
153
|
+
const terminator = quotedKey ? /[\s,;}\]]/ : /[\s;]/;
|
|
154
|
+
while (segmentEnd < text.length) {
|
|
155
|
+
if (!quotedKey && text[segmentEnd] === "\\" && segmentEnd + 1 < text.length) {
|
|
156
|
+
segmentEnd += text[segmentEnd + 1] === "\r" && text[segmentEnd + 2] === "\n" ? 3 : 2;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (terminator.test(text[segmentEnd])) break;
|
|
160
|
+
segmentEnd += 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const value = text.slice(valueStart, valueEnd) + text.slice(quotedEnd, segmentEnd);
|
|
164
|
+
const opener = `${sigil}${slashes}${quote}`;
|
|
165
|
+
const suffix = closing ? `${slashes}${quote}` : "";
|
|
166
|
+
const whole = text.slice(match.index, segmentEnd);
|
|
167
|
+
|
|
168
|
+
out += text.slice(cursor, match.index);
|
|
169
|
+
out += redactAssignment(whole, `${prefix}${opener}`, value, suffix, false);
|
|
170
|
+
cursor = segmentEnd;
|
|
171
|
+
QUOTED_ASSIGNMENT_START.lastIndex = segmentEnd;
|
|
172
|
+
}
|
|
173
|
+
return out + text.slice(cursor);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function redactYamlMatches(text, assignmentLine, redactSameLineValue = false) {
|
|
177
|
+
assignmentLine.lastIndex = 0;
|
|
178
|
+
let cursor = 0;
|
|
179
|
+
let out = "";
|
|
180
|
+
let match;
|
|
181
|
+
while ((match = assignmentLine.exec(text)) !== null) {
|
|
182
|
+
const prefix = match[1];
|
|
183
|
+
const keyIndent = match[2].length;
|
|
184
|
+
const firstValue = match[3];
|
|
185
|
+
const firstLineBreak = match[4];
|
|
186
|
+
const uncommentedFirstValue = firstValue
|
|
187
|
+
.replace(/(?:^|[ \t]+)#.*$/, "")
|
|
188
|
+
.trim();
|
|
189
|
+
const blockScalar =
|
|
190
|
+
/^(?:(?:![^ \t]+|&[^ \t]+)[ \t]+)*[|>][+\-0-9]*(?:[ \t]+#.*)?$/.test(
|
|
191
|
+
firstValue.trim(),
|
|
192
|
+
);
|
|
193
|
+
const allowsIndentlessSequence =
|
|
194
|
+
/^(?:(?:![^ \t]+|&[^ \t]+)[ \t]*)*$/.test(uncommentedFirstValue);
|
|
195
|
+
const hasSensitiveSameLineValue =
|
|
196
|
+
redactSameLineValue &&
|
|
197
|
+
Boolean(uncommentedFirstValue) &&
|
|
198
|
+
!/^(?:null|true|false|undefined)$/i.test(uncommentedFirstValue);
|
|
199
|
+
if (!firstLineBreak && !hasSensitiveSameLineValue) continue;
|
|
200
|
+
|
|
201
|
+
let scan = assignmentLine.lastIndex;
|
|
202
|
+
let lastConsumed = scan;
|
|
203
|
+
let sawIndentedContent = false;
|
|
204
|
+
while (scan < text.length) {
|
|
205
|
+
const newline = text.indexOf("\n", scan);
|
|
206
|
+
const next = newline === -1 ? text.length : newline + 1;
|
|
207
|
+
const rawLine = text.slice(scan, newline === -1 ? text.length : newline);
|
|
208
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
209
|
+
if (line.trim().length === 0 || (!blockScalar && /^[ \t]*#/.test(line))) {
|
|
210
|
+
scan = next;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const indentation = /^[ \t]*/.exec(line)?.[0].length ?? 0;
|
|
214
|
+
const indentlessSequence =
|
|
215
|
+
allowsIndentlessSequence &&
|
|
216
|
+
indentation === keyIndent &&
|
|
217
|
+
/^-(?:[ \t]|$)/.test(line.slice(indentation));
|
|
218
|
+
if (indentation <= keyIndent && !indentlessSequence) break;
|
|
219
|
+
sawIndentedContent = true;
|
|
220
|
+
lastConsumed = next;
|
|
221
|
+
scan = next;
|
|
222
|
+
}
|
|
223
|
+
if (!sawIndentedContent && !hasSensitiveSameLineValue) continue;
|
|
224
|
+
|
|
225
|
+
out += text.slice(cursor, match.index);
|
|
226
|
+
out += `${prefix}${/[ \\t]$/.test(prefix) ? "" : " "}[redacted:value]`;
|
|
227
|
+
if (lastConsumed < text.length) out += firstLineBreak;
|
|
228
|
+
cursor = lastConsumed;
|
|
229
|
+
assignmentLine.lastIndex = lastConsumed;
|
|
230
|
+
}
|
|
231
|
+
return out + text.slice(cursor);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Redact YAML scalar bodies while preserving keys at the same or lower indent. */
|
|
235
|
+
function redactYamlMultilineAssignments(text) {
|
|
236
|
+
return redactYamlMatches(
|
|
237
|
+
redactYamlMatches(
|
|
238
|
+
redactYamlMatches(text, YAML_SPLIT_EXPLICIT_MULTILINE_ASSIGNMENT_LINE, true),
|
|
239
|
+
YAML_EXPLICIT_MULTILINE_ASSIGNMENT_LINE,
|
|
240
|
+
true,
|
|
241
|
+
),
|
|
242
|
+
YAML_MULTILINE_ASSIGNMENT_LINE,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function decodeJsonKeyEscapes(value) {
|
|
247
|
+
const simple = {
|
|
248
|
+
b: "\b",
|
|
249
|
+
f: "\f",
|
|
250
|
+
n: "\n",
|
|
251
|
+
r: "\r",
|
|
252
|
+
t: "\t",
|
|
253
|
+
"\"": "\"",
|
|
254
|
+
"\\": "\\",
|
|
255
|
+
"/": "/",
|
|
256
|
+
};
|
|
257
|
+
const text = String(value || "");
|
|
258
|
+
if (!text.includes("\\")) return text;
|
|
259
|
+
const output = [];
|
|
260
|
+
let cursor = 0;
|
|
261
|
+
let index = 0;
|
|
262
|
+
while (index < text.length) {
|
|
263
|
+
if (text[index] !== "\\") {
|
|
264
|
+
index += 1;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
let escapeAt = index;
|
|
268
|
+
while (escapeAt < text.length && text[escapeAt] === "\\") escapeAt += 1;
|
|
269
|
+
const escape = text[escapeAt];
|
|
270
|
+
let replacement;
|
|
271
|
+
let consumed = escapeAt + 1;
|
|
272
|
+
if (
|
|
273
|
+
escape?.toLowerCase() === "u" &&
|
|
274
|
+
/^[0-9a-f]{4}$/i.test(text.slice(escapeAt + 1, escapeAt + 5))
|
|
275
|
+
) {
|
|
276
|
+
replacement = String.fromCharCode(
|
|
277
|
+
Number.parseInt(text.slice(escapeAt + 1, escapeAt + 5), 16),
|
|
278
|
+
);
|
|
279
|
+
consumed = escapeAt + 5;
|
|
280
|
+
} else if (Object.hasOwn(simple, escape)) {
|
|
281
|
+
replacement = simple[escape];
|
|
282
|
+
}
|
|
283
|
+
if (replacement === undefined) {
|
|
284
|
+
index = escapeAt + (escape ? 1 : 0);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
output.push(text.slice(cursor, index), replacement);
|
|
288
|
+
cursor = consumed;
|
|
289
|
+
index = consumed;
|
|
290
|
+
}
|
|
291
|
+
if (output.length === 0) return text;
|
|
292
|
+
output.push(text.slice(cursor));
|
|
293
|
+
return output.join("");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isSensitiveAssignmentCandidate(value) {
|
|
297
|
+
const canonical = decodeJsonKeyEscapes(value)
|
|
298
|
+
.slice(-200)
|
|
299
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
300
|
+
.replace(/([A-Za-z])(\d)/g, "$1_$2")
|
|
301
|
+
.replace(/(\d)([A-Za-z])/g, "$1_$2")
|
|
302
|
+
.replace(/([A-Za-z0-9])(value)/gi, "$1_$2")
|
|
303
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
304
|
+
.replace(/^_+|_+$/g, "")
|
|
305
|
+
.toLowerCase();
|
|
306
|
+
return SENSITIVE_CANONICAL_KEY_SUFFIX.test(canonical);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isBenignAssignmentValue(value) {
|
|
310
|
+
const normalized = value.trimStart().replace(/^\$?[\\"']+/, "");
|
|
311
|
+
return /^(?:null|true|false|undefined)(?=$|[\s,;}\]])/i.test(normalized) ||
|
|
312
|
+
/^\[redacted:[^\]]+\][\\"']*(?=$|[\s,;}\]])/i.test(normalized) ||
|
|
313
|
+
/^bearer\s+\[redacted:[^\]]+\][\\"']*(?=$|[\s,;}\]])/i.test(normalized);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Find a credential-shaped key before any plausible assignment delimiter.
|
|
317
|
+
* Manual bounded suffix classification keeps arbitrary punctuation aligned
|
|
318
|
+
* with structured object keys without a separator-depth regex bypass. */
|
|
319
|
+
function hasSensitiveAssignment(text, allowEmptyValue = false) {
|
|
320
|
+
let lineStart = 0;
|
|
321
|
+
while (lineStart <= text.length) {
|
|
322
|
+
let lineEnd = lineStart;
|
|
323
|
+
while (lineEnd < text.length && !/[\r\n\u2028\u2029\v\f]/.test(text[lineEnd])) {
|
|
324
|
+
lineEnd += 1;
|
|
325
|
+
}
|
|
326
|
+
for (let delimiter = lineStart; delimiter < lineEnd; delimiter += 1) {
|
|
327
|
+
if (text[delimiter] !== ":" && text[delimiter] !== "=") continue;
|
|
328
|
+
let end = delimiter;
|
|
329
|
+
while (end > lineStart && /[ \t]/.test(text[end - 1])) end -= 1;
|
|
330
|
+
const keyLength = end - lineStart;
|
|
331
|
+
const candidate = text.slice(Math.max(lineStart, end - 200), end);
|
|
332
|
+
if (keyLength <= 160 && !isSensitiveAssignmentCandidate(candidate)) continue;
|
|
333
|
+
let valueStart = delimiter + 1;
|
|
334
|
+
while (valueStart < lineEnd && /[ \t]/.test(text[valueStart])) valueStart += 1;
|
|
335
|
+
const value = text.slice(valueStart, Math.min(lineEnd, valueStart + 256));
|
|
336
|
+
if (isBenignAssignmentValue(value)) continue;
|
|
337
|
+
if (value || allowEmptyValue) return true;
|
|
338
|
+
}
|
|
339
|
+
if (lineEnd >= text.length) break;
|
|
340
|
+
lineStart = text[lineEnd] === "\r" && text[lineEnd + 1] === "\n"
|
|
341
|
+
? lineEnd + 2
|
|
342
|
+
: lineEnd + 1;
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function hasDeeplyEscapedSensitiveAssignment(text) {
|
|
348
|
+
if (!EXCESSIVE_ESCAPE_RUN.test(text)) return false;
|
|
349
|
+
const collapsed = decodeJsonKeyEscapes(text).replace(/[\\"']/g, "");
|
|
350
|
+
return hasSensitiveAssignment(collapsed, true);
|
|
351
|
+
}
|
|
31
352
|
|
|
32
353
|
/** Replace credential-shaped content with typed markers. Idempotent. */
|
|
33
354
|
export function redactSecrets(text) {
|
|
34
355
|
if (!text) return text;
|
|
35
|
-
|
|
356
|
+
if (hasDeeplyEscapedSensitiveAssignment(text)) {
|
|
357
|
+
return "[redacted:deeply-escaped]";
|
|
358
|
+
}
|
|
359
|
+
// Detect multiline value syntax before the header pass. Otherwise a key
|
|
360
|
+
// such as `authorization: |` is rewritten as a one-line header first and the
|
|
361
|
+
// block marker disappears while its secret body remains.
|
|
362
|
+
const structuralRedacted = redactYamlMultilineAssignments(text)
|
|
363
|
+
.replace(SHELL_MULTILINE_ASSIGNMENT, (_match, prefix) => `${prefix}[redacted:value]`);
|
|
364
|
+
const redacted = structuralRedacted
|
|
36
365
|
.replace(PEM_BLOCK, "[redacted:pem]")
|
|
366
|
+
.replace(UNTERMINATED_PEM, "[redacted:pem]")
|
|
37
367
|
.replace(JWT, "[redacted:jwt]")
|
|
368
|
+
.replace(UNTERMINATED_JWT, "[redacted:jwt]")
|
|
38
369
|
.replace(URL_CREDENTIALS, "$1[redacted:url-credentials]@")
|
|
370
|
+
.replace(UNTERMINATED_URL_CREDENTIALS, "$1[redacted:url-credentials]")
|
|
39
371
|
.replace(PROVIDER_TOKENS, "[redacted:token]")
|
|
40
372
|
.replace(BEARER, "$1[redacted:token]")
|
|
41
|
-
.replace(
|
|
42
|
-
|
|
43
|
-
|
|
373
|
+
.replace(SENSITIVE_HEADER, (match, prefix, rawValue) => {
|
|
374
|
+
const value = rawValue.trimStart();
|
|
375
|
+
if (
|
|
376
|
+
!value ||
|
|
377
|
+
/^\[redacted:[^\]]+\]$/i.test(value) ||
|
|
378
|
+
/^Bearer\s+\[redacted:[^\]]+\]["']?$/i.test(value)
|
|
379
|
+
) {
|
|
380
|
+
return match;
|
|
381
|
+
}
|
|
382
|
+
return `${prefix}${rawValue.slice(0, rawValue.length - value.length)}[redacted:value]`;
|
|
44
383
|
});
|
|
384
|
+
const assignmentsRedacted = redactQuotedAssignments(redacted)
|
|
385
|
+
.replace(JSON_UNQUOTED_ASSIGNMENT, (match, prefix, _key, value) =>
|
|
386
|
+
redactAssignment(match, prefix, value),
|
|
387
|
+
)
|
|
388
|
+
.replace(UNQUOTED_ASSIGNMENT, (match, prefix, value) =>
|
|
389
|
+
redactAssignment(match, prefix, value),
|
|
390
|
+
);
|
|
391
|
+
// A runtime may serialize a structured key with punctuation the regex-safe
|
|
392
|
+
// fast path deliberately does not enumerate (`api:key`, nine separators).
|
|
393
|
+
// Withhold that rare field rather than let a different adapter's spelling
|
|
394
|
+
// become a credential bypass.
|
|
395
|
+
return hasSensitiveAssignment(assignmentsRedacted)
|
|
396
|
+
? "[redacted:credential-assignment]"
|
|
397
|
+
: assignmentsRedacted;
|
|
45
398
|
}
|
|
46
399
|
|
|
47
400
|
/**
|