@link-assistant/hive-mind 2.12.2 → 2.12.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.12.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 5659a7d: Sanitize credentials that only appear encoded. A GitHub token leaked through a published log because GHCR's token endpoint echoes the supplied credential back base64-encoded inside a JSON body, and every sanitization layer compared surface bytes: the base64 spelling shares no substring with the token, contains no `gho_`, and the structured-assignment rules that would have masked it on the key alone could not read through the backslash-escaped quotes that agent tool results wrap stdout in. Bounded base64 (at all three byte alignments), base64url, hex, percent, byte-escape and HTML-entity runs are now decoded, sanitized with the existing rules, and re-encoded with the round trip verified before substitution — so the `abc…xyz` mask survives the encoding and a run that cannot be rebuilt exactly is redacted whole. Line-wrapped blobs are scanned as groups of base64-only lines, since every common wrapper folds at 76 or 64 columns and leaves no single-line run to match, and the stream sanitizer holds such a group back rather than releasing byte-misaligned slices — together with the line the blob starts on, which is not base64-only and so joins no group, and which released on its own would leave a credential straddling the first fold in neither half. That line is recognised by whether its trailing base64 run decodes to text rather than by its length, so a URL, a commit SHA or a content digest still passes through without delay. Secretlint now runs over the decoded payloads too, sharing the same decoder walk as the synchronous core, which is the redundancy that actually helps: measured against the incident log, its recommended preset finds nothing at all in the surface text. Also fixes the `SENSITIVE_KEY` affixes, whose unbounded backtracking turned a 553 KB base64 blob into a hang at a fail-closed publication boundary, and `postKillRecoveryNotice()`, the one publication path that never sanitized — together with the `require-sanitized-output` rule's blindness to argv-array `gh` invocations that let it stay that way.
8
+
9
+ ## 2.12.3
10
+
11
+ ### Patch Changes
12
+
13
+ - 97b034f: Make a refused work session explain itself. A Formal AI sidecar (or any isolation) launch that never produced a container now logs the session UUID, backend, tool and reason to stderr instead of failing silently, keeps the UUID in the Telegram failure reply together with a sentence saying the session has no log and is not listed by `--list`, records the reason on the `session_untracked` event and in the durable session store, and is reported by `/queue` as a failed item rather than as `Finished: … (started)`. Registry pull refusals are classified, so a permanent `unauthorized`/`denied`/`not-found` escalates with remediation instead of repeating the same bland warning, and a task image that cannot be pulled falls back to a locally present one. Telegram replies also show start-command's execution UUID — the identifier `$ --list` prints — next to the session UUID, so a running or finished task can finally be found in the session list.
14
+
3
15
  ## 2.12.2
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.12.2",
3
+ "version": "2.12.4",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { StringDecoder } from 'node:string_decoder';
10
+ import { sanitizeEncodedCredentials, wrappedBase64HoldStart } from './encoded-credential-detection.lib.mjs';
10
11
 
11
12
  export const CREDENTIAL_SANITIZATION_ERROR_CODE = 'ERR_CREDENTIAL_SANITIZATION';
12
13
  export const CREDENTIAL_SANITIZATION_FAILURE_MESSAGE = 'Credential sanitization failed; publication was blocked.';
@@ -70,7 +71,17 @@ const VENDOR_PATTERNS = Object.freeze([
70
71
  /\b(?:[A-Z0-9]+[_-])+(?:TOKEN|SECRET|PASSWORD|CREDENTIAL|API[_-]?KEY)(?:[_-][A-Z0-9]+)+\b/g,
71
72
  ]);
72
73
 
73
- const SENSITIVE_KEY = String.raw`(?:[A-Za-z0-9_.-]*(?:api[-_]?key|account[-_]?key|client[-_]?secret|consumer[-_]?secret|webhook[-_]?secret|access[-_]?token|refresh[-_]?token|auth[-_]?token|password|passwd|pwd|private[-_]?key|secret|token|session[-_]?key|session[-_]?token|cookie|docker[-_]?auth|registry[-_]?auth|shared[-_]?access[-_]?signature|sas[-_]?token)[A-Za-z0-9_.-]*|auth|authorization)`;
74
+ // The affixes around the sensitive word are bounded rather than `*`. Unbounded
75
+ // they made every assignment rule quadratic: at each of the N starting offsets
76
+ // in a long identifier-shaped run the engine consumed to the end and then
77
+ // backtracked one character at a time looking for the sensitive word. A single
78
+ // 64 KB base64 blob — routine in a published log — took 19 s per rule and a
79
+ // 256 KB one never finished, which turns the fail-closed publication path into
80
+ // a hang. Bounding costs nothing in coverage: a key whose affix is longer than
81
+ // this simply matches from a later offset, and the affix is preserved text
82
+ // rather than masked content, so the sanitized output is identical.
83
+ const MAX_KEY_AFFIX = 64;
84
+ const SENSITIVE_KEY = String.raw`(?:[A-Za-z0-9_.-]{0,${MAX_KEY_AFFIX}}(?:api[-_]?key|account[-_]?key|client[-_]?secret|consumer[-_]?secret|webhook[-_]?secret|access[-_]?token|refresh[-_]?token|auth[-_]?token|password|passwd|pwd|private[-_]?key|secret|token|session[-_]?key|session[-_]?token|cookie|docker[-_]?auth|registry[-_]?auth|shared[-_]?access[-_]?signature|sas[-_]?token)[A-Za-z0-9_.-]{0,${MAX_KEY_AFFIX}}|auth|authorization)`;
74
85
 
75
86
  // Issue #2119: token *accounting* is not a credential. Every AI provider SDK
76
87
  // spells usage telemetry with the plural "tokens" (`tokens`, `inputTokens`,
@@ -90,14 +101,38 @@ const normalizeAssignmentKey = prefix =>
90
101
  .toLowerCase();
91
102
 
92
103
  const isTokenCounterAssignment = (prefix, value) => NUMERIC_VALUE.test(String(value ?? '').trim()) && TOKEN_COUNTER_KEY.test(normalizeAssignmentKey(prefix));
104
+
105
+ // Issue #2156: `SENSITIVE_KEY` matches any key *containing* `secret`, `token`
106
+ // or `auth`, which includes real npm package names — `secretlint`,
107
+ // `@secretlint/secretlint-rule-preset-recommend`, `next-auth`. Their manifest
108
+ // values are version ranges, and masking those rewrote dependency versions to
109
+ // `[REDACTED]` in every published `package.json` excerpt. A credential is
110
+ // never a bare semver range, so exempting that exact value shape costs no
111
+ // protection. The guard is on the value alone; `token: "1.2.3"` in a real
112
+ // credential field would be a 5-character value that `maskToken` reduces to
113
+ // `[REDACTED]` anyway.
114
+ // One comparator (`^1.2.3`, `>=4.0.0`, `v2.1`), optionally repeated as a range
115
+ // joined by whitespace, `-` or `||` — the full npm/semver range grammar.
116
+ const VERSION_COMPARATOR = String.raw`(?:\^|~|[><]=?|=)?\s*v?\d+(?:\.\d+){1,2}(?:[-+][0-9A-Za-z.-]+)?`;
117
+ const VERSION_RANGE_VALUE = new RegExp(`^${VERSION_COMPARATOR}(?:(?:\\s*(?:\\|\\||-)\\s*|\\s+)${VERSION_COMPARATOR})*$`);
118
+ const isVersionRangeAssignment = value => VERSION_RANGE_VALUE.test(String(value ?? '').trim());
93
119
  const SENSITIVE_ENV_NAME = /(?:API_?KEY|ACCOUNT_?KEY|CLIENT_?SECRET|CONSUMER_?SECRET|WEBHOOK_?SECRET|ACCESS_?TOKEN|REFRESH_?TOKEN|AUTH_?TOKEN|PASSWORD|PASSWD|PRIVATE_?KEY|SECRET|TOKEN|COOKIE|AUTH)$/i;
94
- const QUOTED_ASSIGNMENT = new RegExp(`((?:["']?${SENSITIVE_KEY}["']?)\\s*(?:=>|[:=])\\s*)(["'])([^"'\\r\\n]*)(\\2)`, 'gi');
120
+ // Issue #2156: structured output is routinely nested inside another JSON
121
+ // document — an agent tool result embeds the command's stdout as a JSON
122
+ // *string*, so a response body reaches the log as `{\"token\":\"...\"}` with
123
+ // every quote backslash-escaped. Anchoring on a bare `"` missed all of those,
124
+ // which meant the generic `token` / `password` / `api_key` rules silently did
125
+ // nothing for the single most common shape in our own logs. A quote delimiter
126
+ // is therefore any run of backslashes followed by a quote character, and the
127
+ // value is matched lazily so it stops at the escape rather than swallowing it.
128
+ const QUOTE = String.raw`\\*["']`;
129
+ const QUOTED_ASSIGNMENT = new RegExp(`((?:${QUOTE})?${SENSITIVE_KEY}(?:${QUOTE})?\\s*(?:=>|[:=])\\s*)(${QUOTE})([^"'\\r\\n]*?)(${QUOTE})`, 'gi');
95
130
  // Issue #2119: a value that *opens* a JSON/JS structure is punctuation, not a
96
131
  // secret. Without this guard `"tokens": {` was rewritten to `"tokens": [REDACTED]`,
97
132
  // which silently truncated the object and made the whole record unparseable.
98
133
  // The guard only rejects a structural character in first position, so a
99
134
  // credential that merely contains a brace (`password=ab{cd`) is still masked whole.
100
- const UNQUOTED_ASSIGNMENT = new RegExp(`((?:["']?${SENSITIVE_KEY}["']?)\\s*(?:=>|[:=])\\s*)(?!["']|[{[]|(?:Bearer|Basic|SharedAccessSignature)\\s)([^\\s,;}&'"\\r\\n]+)`, 'gi');
135
+ const UNQUOTED_ASSIGNMENT = new RegExp(`((?:${QUOTE})?${SENSITIVE_KEY}(?:${QUOTE})?\\s*(?:=>|[:=])\\s*)(?!${QUOTE}|[{[]|(?:Bearer|Basic|SharedAccessSignature)\\s)([^\\s,;}&'"\\r\\n]+)`, 'gi');
101
136
  const XML_CREDENTIAL = new RegExp(`(<(${SENSITIVE_KEY})\\b[^>]*>)([\\s\\S]*?)(<\\/\\2\\s*>)`, 'gi');
102
137
  const CLI_CREDENTIAL_QUOTED = new RegExp(`(--${SENSITIVE_KEY}(?:\\s+|=))(["'])([^"'\\r\\n]*)(\\2)`, 'gi');
103
138
  const CLI_CREDENTIAL = new RegExp(`(--${SENSITIVE_KEY}(?:\\s+|=))(?!["'])([^\\s"'\\r\\n]+)`, 'gi');
@@ -121,9 +156,13 @@ const replaceCookieHeader = (_match, prefix, cookieText) => {
121
156
 
122
157
  /**
123
158
  * Synchronously sanitize known vendor credentials and credential-like
124
- * structured values. The operation is deterministic and idempotent.
159
+ * structured values in their plaintext representation.
160
+ *
161
+ * Encoded representations are handled by {@link sanitizeCredentialText}, which
162
+ * wraps this function; keeping the plaintext rules separate is what lets the
163
+ * encoded layer call back into them without recursing forever.
125
164
  */
126
- export const sanitizeCredentialText = (input, options = {}) => {
165
+ const sanitizePlaintextCredentials = (input, options = {}) => {
127
166
  let output = String(input ?? '');
128
167
 
129
168
  // Known active credentials are the strongest signal and are replaced before
@@ -160,8 +199,11 @@ export const sanitizeCredentialText = (input, options = {}) => {
160
199
 
161
200
  // XML and JSON/YAML/TOML/INI/shell-style assignments.
162
201
  output = output.replace(XML_CREDENTIAL, (_match, start, _key, value, end) => `${start}${maskValue(value.trim())}${end}`);
163
- output = output.replace(QUOTED_ASSIGNMENT, (match, prefix, quote, value) => (isTokenCounterAssignment(prefix, value) ? match : `${prefix}${quote}${maskValue(value)}${quote}`));
164
- output = output.replace(UNQUOTED_ASSIGNMENT, (match, prefix, value) => (isTokenCounterAssignment(prefix, value) ? match : `${prefix}${maskValue(value)}`));
202
+ // The opening and closing delimiters are captured independently because an
203
+ // escaped payload may not balance them symmetrically; each is preserved as
204
+ // written so the surrounding document stays byte-for-byte parseable.
205
+ output = output.replace(QUOTED_ASSIGNMENT, (match, prefix, openQuote, value, closeQuote) => (isTokenCounterAssignment(prefix, value) || isVersionRangeAssignment(value) ? match : `${prefix}${openQuote}${maskValue(value)}${closeQuote}`));
206
+ output = output.replace(UNQUOTED_ASSIGNMENT, (match, prefix, value) => (isTokenCounterAssignment(prefix, value) || isVersionRangeAssignment(value) ? match : `${prefix}${maskValue(value)}`));
165
207
 
166
208
  // CLI arguments and sensitive query parameters.
167
209
  output = output.replace(CLI_CREDENTIAL_QUOTED, (_match, prefix, quote, value) => `${prefix}${quote}${maskValue(value)}${quote}`);
@@ -176,6 +218,50 @@ export const sanitizeCredentialText = (input, options = {}) => {
176
218
  return output;
177
219
  };
178
220
 
221
+ /**
222
+ * How many nested encoding layers to peel. A credential wrapped in base64 of
223
+ * base64 is still a credential; beyond three layers the payload is no longer
224
+ * something any real service produces.
225
+ */
226
+ const MAX_ENCODED_SANITIZATION_DEPTH = 3;
227
+
228
+ const collectKnownTokenValues = options => {
229
+ const environmentTokens =
230
+ options.includeEnvironmentCredentials === false
231
+ ? []
232
+ : Object.entries(process.env)
233
+ .filter(([name, value]) => SENSITIVE_ENV_NAME.test(name) && typeof value === 'string' && value.length > 0)
234
+ .map(([, value]) => value);
235
+ const explicit = (options.knownTokens || []).map(token => (typeof token === 'string' ? token : token?.value)).filter(Boolean);
236
+ return [...environmentTokens, ...explicit];
237
+ };
238
+
239
+ /**
240
+ * Synchronously sanitize known vendor credentials and credential-like
241
+ * structured values, in both plaintext **and encoded** representations. The
242
+ * operation is deterministic and idempotent.
243
+ *
244
+ * Issue #2156: a credential that reaches a log only as base64 (or hex, percent,
245
+ * escape or entity encoding) has no plaintext substring for the rules above to
246
+ * match, yet remains fully recoverable — and GitHub's secret scanning does
247
+ * recover it. Encoded runs are therefore decoded, sanitized with the same
248
+ * rules, and re-encoded in place.
249
+ */
250
+ export const sanitizeCredentialText = (input, options = {}) => {
251
+ const knownTokens = collectKnownTokenValues(options);
252
+
253
+ const sanitizeAtDepth = (text, depth) => {
254
+ const plain = sanitizePlaintextCredentials(text, options);
255
+ if (depth >= MAX_ENCODED_SANITIZATION_DEPTH || options.skipEncodedCredentials === true) return plain;
256
+ return sanitizeEncodedCredentials(plain, {
257
+ knownTokens,
258
+ sanitizePlaintext: nested => sanitizeAtDepth(nested, depth + 1),
259
+ });
260
+ };
261
+
262
+ return sanitizeAtDepth(String(input ?? ''), 0);
263
+ };
264
+
179
265
  /**
180
266
  * Return a non-sensitive indication that another sanitizer pass would change
181
267
  * the text. Callers never receive the matching value.
@@ -232,8 +318,17 @@ export const createCredentialStreamSanitizer = options => {
232
318
 
233
319
  const boundary = Math.max(pending.lastIndexOf('\n'), pending.lastIndexOf('\r'));
234
320
  if (boundary < 0) break;
235
- output += sanitizeCredentialText(pending.slice(0, boundary + 1), options);
236
- pending = pending.slice(boundary + 1);
321
+
322
+ // Issue #2156: a base64 blob wrapped across lines is one credential
323
+ // carrier spread over many records. Releasing those records one at a time
324
+ // hands the sanitizer a byte-misaligned slice that decodes to noise, so
325
+ // hold a trailing group of base64-only lines back until a line that
326
+ // cannot belong to the blob arrives (or `flush` forces the issue), the
327
+ // same way an unterminated PEM block is held above.
328
+ const releaseEnd = wrappedBase64HoldStart(pending, boundary + 1) ?? boundary + 1;
329
+ if (releaseEnd <= 0) break;
330
+ output += sanitizeCredentialText(pending.slice(0, releaseEnd), options);
331
+ pending = pending.slice(releaseEnd);
237
332
  }
238
333
 
239
334
  return output;