agent-sanitizer 2.47.12 → 2.47.14
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/claude-hooks/lib/env-config.mjs +1 -1
- package/claude-hooks/lib/invisible-alert.mjs +170 -40
- package/claude-hooks/pretooluse-sanitize.mjs +40 -1
- package/claude-hooks/sanitize-output.mjs +8 -9
- package/claude-hooks/scan-invisible-chars.mjs +7 -20
- package/package.json +1 -1
- package/src/gates.mjs +5 -5
- package/src/html.mjs +4 -4
- package/src/index.mjs +6 -6
- package/src/invisible.mjs +9 -9
- package/src/output.mjs +10 -11
- package/src/prompt.mjs +4 -3
- package/src/rehydrate.mjs +2 -2
- package/src/view-map.mjs +3 -3
- package/types/claude-hooks/lib/invisible-alert.d.mts +54 -31
- package/types/claude-hooks/sanitize-output.d.mts +1 -1
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
- package/types/invisible.d.mts +1 -1
- package/types/view-map.d.mts +3 -3
|
@@ -105,7 +105,7 @@ export function minEnvSecretLen() {
|
|
|
105
105
|
const hostLen = hostSource()?.minSecretLen;
|
|
106
106
|
// The package floor comes from the same physical file
|
|
107
107
|
// agent_sanitizer.secrets.config reads (DEFAULT_MIN_SECRET_LEN), so the JS
|
|
108
|
-
// pre-gate and the Python daemon
|
|
108
|
+
// pre-gate and the Python daemon read the same number.
|
|
109
109
|
if (hostLen === undefined) return redactionFloor.min_secret_len;
|
|
110
110
|
if (!Number.isInteger(hostLen) || hostLen <= 0)
|
|
111
111
|
throw new Error(
|
|
@@ -11,23 +11,37 @@
|
|
|
11
11
|
* have one definition.
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
|
+
existsSync,
|
|
15
|
+
globSync,
|
|
14
16
|
lstatSync,
|
|
15
17
|
mkdirSync,
|
|
16
18
|
readdirSync,
|
|
17
19
|
readFileSync,
|
|
18
20
|
rmSync,
|
|
21
|
+
statSync,
|
|
19
22
|
} from "node:fs";
|
|
20
23
|
import { randomBytes } from "node:crypto";
|
|
21
24
|
import { basename, join } from "node:path";
|
|
22
|
-
import { tmpdir, userInfo } from "node:os";
|
|
25
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
23
26
|
import {
|
|
24
27
|
lazyImport,
|
|
25
28
|
markerIsTrusted,
|
|
29
|
+
PROJECT_DIR,
|
|
26
30
|
PROJECT_HASH,
|
|
27
31
|
scrubUntrustedText,
|
|
28
32
|
writeFileNoFollow,
|
|
29
33
|
writeSentinelFile,
|
|
30
34
|
} from "./hook-io.mjs";
|
|
35
|
+
// Relative, like scan-invisible-chars.mjs's own import of this module: the
|
|
36
|
+
// launch scope is hook POLICY (see src/claude-context.mjs), and this table is
|
|
37
|
+
// pure data with no fs access of its own — the fs calls below are this
|
|
38
|
+
// module's, not a copy of the SessionStart hook's target-discovery glue.
|
|
39
|
+
import {
|
|
40
|
+
ancestorInstructionFiles,
|
|
41
|
+
CLAUDE_CONTEXT_SUBDIRS,
|
|
42
|
+
CLAUDE_LAUNCH_GLOBS,
|
|
43
|
+
excludeFromContextScan,
|
|
44
|
+
} from "../../src/claude-context.mjs";
|
|
31
45
|
|
|
32
46
|
// Layer-1 scrubber for the untrusted alert-store contents the gate splices into a
|
|
33
47
|
// permissionDecisionReason. The WELL-FORMED composition, not the bare applyLayer1:
|
|
@@ -55,13 +69,13 @@ export const ALERT_BASE = join(
|
|
|
55
69
|
* The path prefix every alert artifact of ONE session under this project shares.
|
|
56
70
|
*
|
|
57
71
|
* Session-keying is what makes the gate's one-time ask correct by construction.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
72
|
+
* Keying by PROJECT alone, reset by a destructive clear at SessionStart, would
|
|
73
|
+
* leave two ways for a session to inherit the previous one's answer: an
|
|
74
|
+
* early-exiting scanner arm (a dep-load failure) returns before the clear, and
|
|
75
|
+
* nothing pins SessionStart against the InstructionsLoaded events fired for
|
|
76
|
+
* the files loaded at launch. A session that cannot see another session's
|
|
77
|
+
* files needs neither the clear nor the ordering — past sessions' artifacts
|
|
78
|
+
* simply age out through {@link sweepStaleSessions}.
|
|
65
79
|
* @param {string} [sessionId] the harness's session identity
|
|
66
80
|
* @returns {string}
|
|
67
81
|
*/
|
|
@@ -256,13 +270,14 @@ export function sweepStaleSessions(sessionId) {
|
|
|
256
270
|
* The ack always expires; the findings only when this session has its own store
|
|
257
271
|
* and so merely INHERITED these.
|
|
258
272
|
*
|
|
259
|
-
* The
|
|
260
|
-
* run at all", so FALLBACK_TTL_MS must never reach them:
|
|
261
|
-
* would render the gap notice on a session that WAS
|
|
262
|
-
*
|
|
263
|
-
* at
|
|
264
|
-
*
|
|
265
|
-
*
|
|
273
|
+
* The three InstructionsLoaded markers under the same prefix answer "did a scan
|
|
274
|
+
* run at all" and "was launch empty", so FALLBACK_TTL_MS must never reach them:
|
|
275
|
+
* expiring one mid-session would render the gap notice on a session that WAS
|
|
276
|
+
* scanned, or re-glob a launch this session already found empty. They still go,
|
|
277
|
+
* at MARKER_TTL_MS — the same age the loop above ages a real session's prefix
|
|
278
|
+
* out at, and far past any session's life — because the loop skips this prefix
|
|
279
|
+
* and would otherwise leave a session-less host's markers in $TMPDIR forever,
|
|
280
|
+
* with the gap notice suppressed on every later session.
|
|
266
281
|
* @param {string} [sessionId]
|
|
267
282
|
* @returns {void}
|
|
268
283
|
*/
|
|
@@ -281,7 +296,11 @@ function sweepStaleFallback(sessionId) {
|
|
|
281
296
|
};
|
|
282
297
|
for (const path of [alertAckFile(), ...entries])
|
|
283
298
|
if (!withinFallbackTtl(path)) drop(path);
|
|
284
|
-
for (const path of [
|
|
299
|
+
for (const path of [
|
|
300
|
+
instructionsLoadedFile(),
|
|
301
|
+
instructionsLoadedNoticeFile(),
|
|
302
|
+
launchEmptyFile(),
|
|
303
|
+
])
|
|
285
304
|
if (!withinTtl(path, MARKER_TTL_MS)) drop(path);
|
|
286
305
|
}
|
|
287
306
|
|
|
@@ -311,32 +330,143 @@ export function recordInstructionsLoaded(sessionId) {
|
|
|
311
330
|
const EVENT_MIN_CLI_VERSION = "2.1.69";
|
|
312
331
|
|
|
313
332
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
333
|
+
* Companion marker: this session already found the LAUNCH set empty — that set
|
|
334
|
+
* (which files load at session start) cannot change mid-session, so a second
|
|
335
|
+
* glob of `dir` can never change that half of the answer. It says nothing about
|
|
336
|
+
* a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
|
|
337
|
+
* covers that half fresh on every call instead.
|
|
338
|
+
* @param {string} [sessionId]
|
|
339
|
+
* @returns {string}
|
|
340
|
+
*/
|
|
341
|
+
export function launchEmptyFile(sessionId) {
|
|
342
|
+
return `${instructionsLoadedFile(sessionId)}.launch-empty`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Every file Claude Code loads as model context AT LAUNCH from `dir`: its own
|
|
347
|
+
* instruction files, its `.claude/` context tree, and the CLAUDE.md /
|
|
348
|
+
* CLAUDE.local.md of every directory above it. THE SSOT scan-invisible-chars.mjs
|
|
349
|
+
* reads too (re-exported there as `findInstructionFiles`) — sharing the one
|
|
350
|
+
* function is what keeps the SessionStart scan's targets and this module's
|
|
351
|
+
* launch-emptiness check from drifting into two different answers for "what
|
|
352
|
+
* loads at launch". See src/claude-context.mjs for why this is the shallow
|
|
353
|
+
* launch scope and not a whole-tree walk.
|
|
354
|
+
* @param {string} dir
|
|
355
|
+
* @returns {string[]}
|
|
356
|
+
*/
|
|
357
|
+
export function launchInstructionFiles(dir) {
|
|
358
|
+
return [
|
|
359
|
+
...globSync([...CLAUDE_LAUNCH_GLOBS], {
|
|
360
|
+
cwd: dir,
|
|
361
|
+
exclude: excludeFromContextScan,
|
|
362
|
+
}).map((name) => join(dir, name)),
|
|
363
|
+
// Filtered, unlike the glob's matches: almost every parent directory holds
|
|
364
|
+
// neither memory file, so the unfiltered chain would file ~10 phantom
|
|
365
|
+
// targets per session into scan-invisible-chars.mjs's operator-facing
|
|
366
|
+
// "absent" bucket. A file that appears after this check was not loaded at
|
|
367
|
+
// launch either, so nothing is lost by not listing it.
|
|
368
|
+
...ancestorInstructionFiles(dir).filter((file) => existsSync(file)),
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether anything Claude Code loads at launch from `dir` actually has bytes.
|
|
374
|
+
* An existing but EMPTY file (a freshly `touch`ed `~/.claude/CLAUDE.md`) is
|
|
375
|
+
* nothing to load: Claude Code 2.1.246 fires no InstructionsLoaded event when
|
|
376
|
+
* a launch has nothing in it, so the missing event is expected there, not a
|
|
377
|
+
* sign the scanner is unwired.
|
|
378
|
+
*
|
|
379
|
+
* A file this uid cannot even STAT (EACCES, EISDIR, ELOOP…) is unvetted
|
|
380
|
+
* context, not evidence of absence — the same split scan-invisible-chars.mjs's
|
|
381
|
+
* classifyReadFailure makes between ABSENT and SKIPPED. Reading such a failure
|
|
382
|
+
* as "no content" would suppress the notice over a file that may carry a real,
|
|
383
|
+
* unscanned payload, so only ENOENT counts as nothing there; anything else
|
|
384
|
+
* counts as content and leaves the notice free to fire.
|
|
385
|
+
* @param {string} dir
|
|
386
|
+
* @returns {boolean}
|
|
387
|
+
*/
|
|
388
|
+
function launchHasContent(dir) {
|
|
389
|
+
for (const file of launchInstructionFiles(dir)) {
|
|
390
|
+
try {
|
|
391
|
+
if (statSync(file).size > 0) return true;
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT")
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether the USER-GLOBAL `~/.claude` memory and rules — a second root Claude
|
|
402
|
+
* Code loads at launch regardless of the project directory (see
|
|
403
|
+
* scan-loaded-instructions.mjs's header) — has any bytes. `launchHasContent`
|
|
404
|
+
* cannot see this root on its own: it is `dir`-relative, and `~/.claude` is
|
|
405
|
+
* outside `dir`'s own tree whenever the project is not `$HOME` itself, exactly
|
|
406
|
+
* the case a project opened anywhere but home is in.
|
|
317
407
|
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
408
|
+
* Honours `CLAUDE_CONFIG_DIR` the way plugin/scripts/enable-auto-update.mjs's
|
|
409
|
+
* own resolution does, since that root — not always `~/.claude` — is where
|
|
410
|
+
* Claude Code actually reads this content from.
|
|
411
|
+
* @param {Record<string, string | undefined>} [env]
|
|
412
|
+
* @returns {boolean}
|
|
413
|
+
*/
|
|
414
|
+
function userGlobalLaunchHasContent(env = process.env) {
|
|
415
|
+
const configDir = env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
416
|
+
const candidates = globSync(
|
|
417
|
+
["*.md", ...CLAUDE_CONTEXT_SUBDIRS.map((sub) => `${sub}/**/*.md`)],
|
|
418
|
+
{ cwd: configDir, exclude: excludeFromContextScan },
|
|
419
|
+
).map((name) => join(configDir, name));
|
|
420
|
+
for (const file of candidates) {
|
|
421
|
+
try {
|
|
422
|
+
if (statSync(file).size > 0) return true;
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT")
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
433
|
+
* or null when the scan has been seen, the notice already ran this session, or
|
|
434
|
+
* neither launch nor `touchedDir` could have fired the event.
|
|
322
435
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
436
|
+
* PURE for the notice: nothing is recorded until the caller confirms it landed
|
|
437
|
+
* in a returned response. Launch-emptiness is cached once found — fixed for the
|
|
438
|
+
* session — but `touchedDir` is re-checked every call: the claim below is about
|
|
439
|
+
* SUBDIRECTORY files, so an empty launch must not silence a later call whose
|
|
440
|
+
* tool touched a directory that DOES carry real, unscanned content.
|
|
326
441
|
*
|
|
327
|
-
*
|
|
328
|
-
* the
|
|
329
|
-
* scan
|
|
330
|
-
* the
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
442
|
+
* SessionStart scans what loads at launch; a subdirectory's file is scanned by
|
|
443
|
+
* the event; no scan means nothing says so — unless nothing touched so far had
|
|
444
|
+
* anything to scan either. The notice names all three remaining causes, since
|
|
445
|
+
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
446
|
+
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
447
|
+
* @param {string} [sessionId] the harness's session identity (see
|
|
448
|
+
* instructionsLoadedFile)
|
|
449
|
+
* @param {string} [dir] the project root to check (injectable; defaults to
|
|
450
|
+
* the real project)
|
|
451
|
+
* @param {string} [touchedDir] this tool call's own target directory, when
|
|
452
|
+
* known — re-checked every call, never cached
|
|
335
453
|
* @returns {string | null}
|
|
336
454
|
*/
|
|
337
|
-
export function instructionsLoadedGapNotice(
|
|
455
|
+
export function instructionsLoadedGapNotice(
|
|
456
|
+
sessionId,
|
|
457
|
+
dir = PROJECT_DIR,
|
|
458
|
+
touchedDir,
|
|
459
|
+
) {
|
|
338
460
|
if (instructionsLoadedSeen(sessionId)) return null;
|
|
339
461
|
if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
|
|
462
|
+
const launchCached = markerIsTrusted(launchEmptyFile(sessionId));
|
|
463
|
+
const launchHasBytes =
|
|
464
|
+
!launchCached && (launchHasContent(dir) || userGlobalLaunchHasContent());
|
|
465
|
+
if (!launchCached && !launchHasBytes)
|
|
466
|
+
writeSentinelFile(launchEmptyFile(sessionId));
|
|
467
|
+
const touchedHasBytes =
|
|
468
|
+
touchedDir !== undefined && launchHasContent(touchedDir);
|
|
469
|
+
if (!launchHasBytes && !touchedHasBytes) return null;
|
|
340
470
|
return (
|
|
341
471
|
"agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
|
|
342
472
|
"instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
|
|
@@ -414,11 +544,11 @@ export function invisibleCharAlert(sessionId) {
|
|
|
414
544
|
* Add `text` to the alert the PreToolUse gate surfaces this session, keeping
|
|
415
545
|
* whatever is already there.
|
|
416
546
|
*
|
|
417
|
-
* One O_EXCL-created, randomly-named file per finding.
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
547
|
+
* One O_EXCL-created, randomly-named file per finding. A single file appended
|
|
548
|
+
* through a read-modify-write would let two hooks recording a finding at once
|
|
549
|
+
* silently drop one of them; a fresh file per finding has no shared cell to
|
|
550
|
+
* lose. Symlink-refusing (writeFileNoFollow) because the store sits at a
|
|
551
|
+
* predictable, world-visible $TMPDIR path.
|
|
422
552
|
* @param {string} text
|
|
423
553
|
* @param {string} [sessionId]
|
|
424
554
|
* @returns {boolean} whether the finding was recorded
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { createRequire } from "node:module";
|
|
31
31
|
import { readFileSync } from "node:fs";
|
|
32
|
+
import { dirname } from "node:path";
|
|
32
33
|
import {
|
|
33
34
|
isMain,
|
|
34
35
|
lazyImport,
|
|
@@ -424,6 +425,40 @@ export function preToolUseLayers(rehydrate, env = process.env) {
|
|
|
424
425
|
: layers;
|
|
425
426
|
}
|
|
426
427
|
|
|
428
|
+
// The tools whose path field Claude Code sends as an absolute path by
|
|
429
|
+
// contract, so reading it needs no cwd to resolve against. Bounded to these:
|
|
430
|
+
// Glob/Grep's `path` can be relative or omitted (defaults to a cwd this
|
|
431
|
+
// payload does not carry), and Bash has no reliable target at all — the same
|
|
432
|
+
// carve-out WRITE_SHAPED_TOOLS below takes for Bash writes.
|
|
433
|
+
const PATH_FIELD_BY_TOOL = /** @type {Record<string, string>} */ (
|
|
434
|
+
Object.freeze({
|
|
435
|
+
Read: "file_path",
|
|
436
|
+
Edit: "file_path",
|
|
437
|
+
Write: "file_path",
|
|
438
|
+
MultiEdit: "file_path",
|
|
439
|
+
NotebookEdit: "notebook_path",
|
|
440
|
+
})
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The directory THIS tool call targets, for the InstructionsLoaded gap-notice
|
|
445
|
+
* check — or undefined when the tool carries no reliable absolute path. An
|
|
446
|
+
* imprecise guess here only ever WIDENS coverage (see instructionsLoadedGapNotice's
|
|
447
|
+
* `touchedDir`): missing a real target loses nothing this check did not
|
|
448
|
+
* already lack, and there is no wrong-directory case that suppresses a real
|
|
449
|
+
* finding.
|
|
450
|
+
* @param {string} tool
|
|
451
|
+
* @param {any} toolInput
|
|
452
|
+
* @returns {string | undefined}
|
|
453
|
+
*/
|
|
454
|
+
function toolTargetDir(tool, toolInput) {
|
|
455
|
+
const field = PATH_FIELD_BY_TOOL[tool];
|
|
456
|
+
const path = field && toolInput?.[field];
|
|
457
|
+
return typeof path === "string" && path.startsWith("/")
|
|
458
|
+
? dirname(path)
|
|
459
|
+
: undefined;
|
|
460
|
+
}
|
|
461
|
+
|
|
427
462
|
/**
|
|
428
463
|
* Compose the four protections. Returns the `hookSpecificOutput` fields to
|
|
429
464
|
* emit, or null for a clean no-op. Throws only if a layer's engine throws; the
|
|
@@ -468,7 +503,11 @@ export async function buildPreToolUseResponse(
|
|
|
468
503
|
// the notice was surfaced: a rehydrate deny below returns before the response
|
|
469
504
|
// is assembled, and recording here would burn the session's one report on a
|
|
470
505
|
// call that never carried it.
|
|
471
|
-
const gapNotice = instructionsLoadedGapNotice(
|
|
506
|
+
const gapNotice = instructionsLoadedGapNotice(
|
|
507
|
+
input.session_id,
|
|
508
|
+
undefined,
|
|
509
|
+
toolTargetDir(tool, toolInput),
|
|
510
|
+
);
|
|
472
511
|
if (gapNotice !== null) contexts.push(gapNotice);
|
|
473
512
|
|
|
474
513
|
// Layers 2-4, run by the declared pipeline: the driver — not this call order —
|
|
@@ -680,7 +680,7 @@ export function sanitizerDepsLoaded() {
|
|
|
680
680
|
* otherwise invisible because every later tool call then fails closed with no
|
|
681
681
|
* stated cause — the recorded loader error and its remedy ride along. The text
|
|
682
682
|
* comes from missingPackageMessage so this hook, the PreToolUse gate and the
|
|
683
|
-
* prompt gate
|
|
683
|
+
* prompt gate read the same words for what a missing dependency looks like.
|
|
684
684
|
* @param {() => boolean} [depsLoaded] injectable seam for testing
|
|
685
685
|
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
686
686
|
* @returns {string}
|
|
@@ -801,8 +801,8 @@ export function emitHookFailure(
|
|
|
801
801
|
|
|
802
802
|
/**
|
|
803
803
|
* The suppression placeholder that replaces the tool output under the closed
|
|
804
|
-
* posture. Named so the posture table and {@link emitFailClosed}
|
|
805
|
-
*
|
|
804
|
+
* posture. Named so the posture table and {@link emitFailClosed} read the
|
|
805
|
+
* same wording the model sees.
|
|
806
806
|
* @param {string} cause the scrubbed hook error
|
|
807
807
|
* @returns {string}
|
|
808
808
|
*/
|
|
@@ -963,12 +963,11 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
963
963
|
containsPlaceholder(toolOutput)
|
|
964
964
|
)
|
|
965
965
|
warnings.push(ON_DISK_PLACEHOLDER_WARNING);
|
|
966
|
-
// `notes` is part of the guard, not covered by `modified`.
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
// "do not fetch, relay, or embed these URLs" with it.
|
|
966
|
+
// `notes` is part of the guard, not covered by `modified`. A detect-only
|
|
967
|
+
// tier's note implies no strip: a preserved `<script>` and a plain-link
|
|
968
|
+
// exfil URL change no bytes and raise no warning, so without this clause the
|
|
969
|
+
// walk would return `clean` and the note would not be quieter — it would be
|
|
970
|
+
// GONE, taking "do not fetch, relay, or embed these URLs" with it.
|
|
972
971
|
if (!modified && warnings.length === 0 && notes.length === 0)
|
|
973
972
|
return revealRead
|
|
974
973
|
? emit("flagged", { additional_context: REVEAL_READ_ENVELOPE })
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* false positive the SSOT had already fixed, and its clean path was a bare
|
|
22
22
|
* `writeFileSync` with none of cleanFile's symlink/UTF-8/TOCTOU guards.
|
|
23
23
|
*/
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { relative, resolve } from "node:path";
|
|
26
26
|
import {
|
|
27
27
|
awaitLazyDependency,
|
|
28
28
|
emitHookResponse,
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
alertAckFile,
|
|
46
46
|
alertDir,
|
|
47
47
|
appendAlert,
|
|
48
|
+
launchInstructionFiles,
|
|
48
49
|
sweepStaleSessions,
|
|
49
50
|
} from "./lib/invisible-alert.mjs";
|
|
50
51
|
import { formatReport } from "./lib/invisible-report.mjs";
|
|
@@ -59,11 +60,9 @@ import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
|
|
|
59
60
|
// so importing it statically carries none of the fail-open hazard lazyImport
|
|
60
61
|
// exists to cover.
|
|
61
62
|
import {
|
|
62
|
-
ancestorInstructionFiles,
|
|
63
63
|
CLAUDE_CONTEXT_SUBDIRS,
|
|
64
64
|
CLAUDE_INSTRUCTION_GLOBS,
|
|
65
65
|
CLAUDE_LAUNCH_GLOBS,
|
|
66
|
-
excludeFromContextScan,
|
|
67
66
|
isInsideDir,
|
|
68
67
|
} from "../src/claude-context.mjs";
|
|
69
68
|
|
|
@@ -234,26 +233,14 @@ function decodeRun(run) {
|
|
|
234
233
|
* files load when a tool reads their directory, and scan-loaded-instructions
|
|
235
234
|
* scans each one at that moment.
|
|
236
235
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* rather than
|
|
236
|
+
* lib/invisible-alert.mjs's `launchInstructionFiles` IS this function — shared
|
|
237
|
+
* so its InstructionsLoaded gap-notice check reads the identical target set
|
|
238
|
+
* this scan does, rather than a second enumeration that could drift.
|
|
240
239
|
* @param {string} dir
|
|
241
240
|
* @returns {string[]}
|
|
242
241
|
*/
|
|
243
242
|
function findInstructionFiles(dir) {
|
|
244
|
-
return
|
|
245
|
-
...globSync([...CLAUDE_LAUNCH_GLOBS], {
|
|
246
|
-
cwd: dir,
|
|
247
|
-
exclude: excludeFromContextScan,
|
|
248
|
-
}).map((name) => join(dir, name)),
|
|
249
|
-
// Filtered, unlike the glob's matches: almost every parent directory holds
|
|
250
|
-
// neither memory file, so the unfiltered chain would file ~10 phantom
|
|
251
|
-
// targets per session into the `absent` bucket and bury the one thing that
|
|
252
|
-
// bucket reports — a target that existed when the scan listed it and was
|
|
253
|
-
// gone by the read. A file that appears after this check was not loaded at
|
|
254
|
-
// launch either, so nothing is lost by not listing it.
|
|
255
|
-
...ancestorInstructionFiles(dir).filter((file) => existsSync(file)),
|
|
256
|
-
];
|
|
243
|
+
return launchInstructionFiles(dir);
|
|
257
244
|
}
|
|
258
245
|
|
|
259
246
|
// Scanner
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.47.
|
|
3
|
+
"version": "2.47.14",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/gates.mjs
CHANGED
|
@@ -74,11 +74,11 @@ export function needsUrlScan(text) {
|
|
|
74
74
|
// opaque run for ReDoS-safety. It is NOT derivable from that JSON: inlining the
|
|
75
75
|
// detector regexes would reintroduce the cross-arm polynomial backtracking the
|
|
76
76
|
// two-alternation split below exists to prevent, so this is a distinct
|
|
77
|
-
// representation for a distinct constraint, not a copy.
|
|
78
|
-
// be collapsed to one source, so
|
|
79
|
-
// test/secret-detectors-portability.test.mjs drives from the JSON and fails
|
|
80
|
-
// moment a detector is added/changed without a matching arm here —
|
|
81
|
-
// SECRET_HINT when that fires.
|
|
77
|
+
// representation for a distinct constraint, not a copy. drift-guard-ok: that
|
|
78
|
+
// duplication can't be collapsed to one source, so a test instead guards it —
|
|
79
|
+
// test/secret-detectors-portability.test.mjs drives from the JSON and fails
|
|
80
|
+
// the moment a detector is added/changed without a matching arm here —
|
|
81
|
+
// extend SECRET_HINT when that fires.
|
|
82
82
|
// Split across TWO regexes, combined by matchesSecretHint:
|
|
83
83
|
// one alternation of every arm makes a redos analyzer see cross-arm polynomial
|
|
84
84
|
// backtracking (each arm is linear alone, but the union was a 3rd-degree
|
package/src/html.mjs
CHANGED
|
@@ -2350,10 +2350,10 @@ export function sanitizeHtml(text) {
|
|
|
2350
2350
|
// post-parse walk never runs).
|
|
2351
2351
|
const EXFIL_INDICATORS = [/\$\{[^{}]+\}/, /\{\{[^{}]+\}\}/];
|
|
2352
2352
|
|
|
2353
|
-
// Parameter NAMES
|
|
2354
|
-
//
|
|
2355
|
-
//
|
|
2356
|
-
//
|
|
2353
|
+
// Parameter NAMES that gate WHICH raw params the value-shape test is applied
|
|
2354
|
+
// to before the URL is parsed — presence alone no longer flags. Kept narrow so
|
|
2355
|
+
// the raw pre-parse pass stays cheap; any non-keyword param is still
|
|
2356
|
+
// value-gated post-parse by the walk.
|
|
2357
2357
|
const KEYWORD_PARAM_NAME_RE =
|
|
2358
2358
|
/^(?:data|d|payload|exfil|leak|steal|secret|token|key|env|password|pwd|cookie|session|auth)$/i;
|
|
2359
2359
|
|
package/src/index.mjs
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* is only paid for by callers that ask for it.
|
|
8
8
|
*
|
|
9
9
|
* Layers 1-3 are NOT implemented here: this module is a facade over the single
|
|
10
|
-
* implementation in `./output.mjs` (`sanitizeText`).
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* implementation in `./output.mjs` (`sanitizeText`). A hand-synced copy would
|
|
11
|
+
* drift apart in its warning prose, so the layer bodies live in exactly one
|
|
12
|
+
* place and this file only translates the facade's option/result shape.
|
|
13
|
+
* Importing `./output.mjs` costs nothing at module scope — its graph is
|
|
14
|
+
* dependency-free and it lazy-loads `./html.mjs` on the same terms this facade
|
|
15
|
+
* does.
|
|
16
16
|
*
|
|
17
17
|
* The low-level building blocks stay public via the `./invisible` and `./html`
|
|
18
18
|
* subpath entries; import those directly when you want a single layer without
|
package/src/invisible.mjs
CHANGED
|
@@ -127,11 +127,11 @@ export const STRIP = new RegExp(
|
|
|
127
127
|
|
|
128
128
|
// SGR (Select Graphic Rendition) colour matching. Re-exported from ./ansi.mjs —
|
|
129
129
|
// the ONE ANSI grammar, shared with the Layer-1 stripper, which cannot import
|
|
130
|
-
// this module (layer1.mjs imports invisible.mjs, not the other way round).
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
130
|
+
// this module (layer1.mjs imports invisible.mjs, not the other way round). A
|
|
131
|
+
// second, looser copy here with DIFFERENT parameter rules would read
|
|
132
|
+
// `ESC[12345m` as SGR-only (so the operator got a "display-only colour" note)
|
|
133
|
+
// while the stripper could not match it and would splice a visible `[12345m`
|
|
134
|
+
// into the model's view.
|
|
135
135
|
export { SGR_RE } from "./ansi.mjs";
|
|
136
136
|
|
|
137
137
|
/**
|
|
@@ -252,7 +252,7 @@ export function hasLongRun(text) {
|
|
|
252
252
|
* payload-length invisible run, and a pointer to recover the bytes — a hex dump
|
|
253
253
|
* is ASCII, so it passes through sanitization untouched. The single source of
|
|
254
254
|
* this note, shared by the `sanitize` convenience entry and the tool-output
|
|
255
|
-
* pipeline
|
|
255
|
+
* pipeline.
|
|
256
256
|
* @param {string[]} invisFound CATEGORY codes applyLayer1 reported removing
|
|
257
257
|
* @param {string} deAnsi ANSI-stripped text (invisible runs intact), for the LONG_RUN probe
|
|
258
258
|
* @returns {string}
|
|
@@ -463,9 +463,9 @@ const MAX_TAG_SPEC_CHARS = 6;
|
|
|
463
463
|
//
|
|
464
464
|
// The two Compatibility Ideograph BLOCKS stay literal: they are not
|
|
465
465
|
// Unified_Ideograph, and JS RegExp exposes no \p{Block=…}. Block boundaries are
|
|
466
|
-
// immutable by Unicode's stability policy, so a literal span
|
|
467
|
-
// contract test in test/invisible-unicode-tables.test.mjs pins
|
|
468
|
-
// ASSIGNED code point inside them is a Script=Han letter.
|
|
466
|
+
// immutable by Unicode's stability policy, so a literal span stays correct
|
|
467
|
+
// forever; the contract test in test/invisible-unicode-tables.test.mjs pins
|
|
468
|
+
// that every ASSIGNED code point inside them is a Script=Han letter.
|
|
469
469
|
const IVS_MIN = 0xe0100;
|
|
470
470
|
const IVS_MAX = 0xe01ef;
|
|
471
471
|
const CJK_IDEOGRAPH_RE =
|
package/src/output.mjs
CHANGED
|
@@ -163,10 +163,10 @@ function errMessage(err) {
|
|
|
163
163
|
* but each mutation can BREAK it again: a Layer-5 span deletion joins the
|
|
164
164
|
* bytes on either side of the deleted span and can leave a lone surrogate
|
|
165
165
|
* the model renders as a broken glyph and the redactor reads as U+FFFD.
|
|
166
|
-
* Repairing it inside whichever layer
|
|
167
|
-
*
|
|
168
|
-
* configured
|
|
169
|
-
* option.
|
|
166
|
+
* Repairing it inside whichever layer happens to need it — e.g. only in
|
|
167
|
+
* the post-span-deletion re-redact, so it runs only when a redactor is
|
|
168
|
+
* configured — would make an invariant of Layer 1 conditional on an
|
|
169
|
+
* unrelated option.
|
|
170
170
|
* 2. `modified` is set — the caller's "bytes changed" banner.
|
|
171
171
|
* 3. `unreportedChange` is set. A mutation that pushed no finding — a Layer-5
|
|
172
172
|
* span deletion whose filter returned no warning code — is a change the
|
|
@@ -196,9 +196,8 @@ function applyMutation(state, nextText) {
|
|
|
196
196
|
/**
|
|
197
197
|
* Run Layer 4 (`redact`) over the state's current text and fold any finding
|
|
198
198
|
* back in. The single Layer-4 invocation site FOR THE PIPELINE STATE: the first
|
|
199
|
-
* pass and the re-scan after a Layer-5 span deletion are the same call, so
|
|
200
|
-
* fail-closed handling, warning prose and post-redaction
|
|
201
|
-
* apart.
|
|
199
|
+
* pass and the re-scan after a Layer-5 span deletion are the same call, so they
|
|
200
|
+
* share one fail-closed handling, warning prose and post-redaction invariant.
|
|
202
201
|
*
|
|
203
202
|
* One other site runs Layer 4 deliberately: {@link vetStageValue}, which vets a
|
|
204
203
|
* stage value on its way out and has no `PipelineState` to fold a finding into.
|
|
@@ -714,10 +713,10 @@ export async function sanitizeText(text, options = {}) {
|
|
|
714
713
|
notes,
|
|
715
714
|
modified: state.modified,
|
|
716
715
|
// Kept under its original name (it is a published field, and renaming a
|
|
717
|
-
// published field for a wording win is a breaking change) but
|
|
718
|
-
// rather than tracked: "nothing here rose above a note".
|
|
719
|
-
//
|
|
720
|
-
//
|
|
716
|
+
// published field for a wording win is a breaking change) but derived
|
|
717
|
+
// rather than tracked: "nothing here rose above a note". The inert-ANSI
|
|
718
|
+
// strip is simply the most common way to end up note-only, not the only
|
|
719
|
+
// one this field now covers.
|
|
721
720
|
sgrNote:
|
|
722
721
|
notes.length > 0 && warnings.length === 0 && !state.unreportedChange,
|
|
723
722
|
...(reveal !== undefined && { reveal }),
|
package/src/prompt.mjs
CHANGED
|
@@ -39,9 +39,10 @@ import { CONTROL_INTRODUCER_SOURCE } from "./ansi.mjs";
|
|
|
39
39
|
// legitimate prompt (the SGR color carve-out is applied separately, after SGR
|
|
40
40
|
// removal), so the whole block is gated, not a hand-picked subset that lets
|
|
41
41
|
// DCS/SOS/PM/APC through. Built from the SHARED charset source instead of a
|
|
42
|
-
// third hand-written copy:
|
|
43
|
-
//
|
|
44
|
-
//
|
|
42
|
+
// third hand-written copy: a hand-written third copy has no drift check
|
|
43
|
+
// beyond a prose reminder in a comment, and a spelling difference between two
|
|
44
|
+
// hand-written copies (e.g. how each spells ESC) is exactly what a grep-based
|
|
45
|
+
// drift check would miss.
|
|
45
46
|
const ANSI_INTRODUCER = new RegExp(CONTROL_INTRODUCER_SOURCE);
|
|
46
47
|
|
|
47
48
|
/**
|
package/src/rehydrate.mjs
CHANGED
|
@@ -367,8 +367,8 @@ async function rehydrateEdit(
|
|
|
367
367
|
* The shared anchor-ambiguity refusal: a stripped run abuts kept text it
|
|
368
368
|
* resembles, so greedy deletion alignment cannot prove which bytes the region
|
|
369
369
|
* owns. Edit's searched spans and Write's position-anchored regions hit the
|
|
370
|
-
* same soundness gate and must speak the same language — one builder
|
|
371
|
-
*
|
|
370
|
+
* same soundness gate and must speak the same language — one builder for
|
|
371
|
+
* both deny sentences.
|
|
372
372
|
* @param {string} lead what could not be anchored ("the matched region", …)
|
|
373
373
|
* @param {string} filePath
|
|
374
374
|
* @param {string} guidance the caller-specific way out, without trailing punctuation
|
package/src/view-map.mjs
CHANGED
|
@@ -59,11 +59,11 @@ const FILE_VIEW = Symbol("agent-sanitizer:file-view");
|
|
|
59
59
|
* Wrap a redactor's map-mode result in a frozen, branded view tagged with the
|
|
60
60
|
* space its offsets are in.
|
|
61
61
|
*
|
|
62
|
-
* The redactor's own object is never touched.
|
|
63
|
-
* `view.pairs = pairsToUtf16(view.text, view.pairs)
|
|
62
|
+
* The redactor's own object is never touched. A caller doing
|
|
63
|
+
* `view.pairs = pairsToUtf16(view.text, view.pairs)` would mutate in place a
|
|
64
64
|
* value returned from an INJECTED seam. A redactor that memoizes its map result
|
|
65
65
|
* (a reasonable thing for a caller to build) hands back the same object on the
|
|
66
|
-
* second identical call, which then
|
|
66
|
+
* second identical call, which would then get converted a SECOND time — every
|
|
67
67
|
* placeholder preceded by an astral character shifts again and the same input
|
|
68
68
|
* yields a different verdict.
|
|
69
69
|
*
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* The path prefix every alert artifact of ONE session under this project shares.
|
|
3
3
|
*
|
|
4
4
|
* Session-keying is what makes the gate's one-time ask correct by construction.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Keying by PROJECT alone, reset by a destructive clear at SessionStart, would
|
|
6
|
+
* leave two ways for a session to inherit the previous one's answer: an
|
|
7
|
+
* early-exiting scanner arm (a dep-load failure) returns before the clear, and
|
|
8
|
+
* nothing pins SessionStart against the InstructionsLoaded events fired for
|
|
9
|
+
* the files loaded at launch. A session that cannot see another session's
|
|
10
|
+
* files needs neither the clear nor the ordering — past sessions' artifacts
|
|
11
|
+
* simply age out through {@link sweepStaleSessions}.
|
|
12
12
|
* @param {string} [sessionId] the harness's session identity
|
|
13
13
|
* @returns {string}
|
|
14
14
|
*/
|
|
@@ -74,31 +74,54 @@ export function sweepStaleSessions(sessionId?: string): void;
|
|
|
74
74
|
* @returns {void}
|
|
75
75
|
*/
|
|
76
76
|
export function recordInstructionsLoaded(sessionId?: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* Companion marker: this session already found the LAUNCH set empty — that set
|
|
79
|
+
* (which files load at session start) cannot change mid-session, so a second
|
|
80
|
+
* glob of `dir` can never change that half of the answer. It says nothing about
|
|
81
|
+
* a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
|
|
82
|
+
* covers that half fresh on every call instead.
|
|
83
|
+
* @param {string} [sessionId]
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function launchEmptyFile(sessionId?: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* Every file Claude Code loads as model context AT LAUNCH from `dir`: its own
|
|
89
|
+
* instruction files, its `.claude/` context tree, and the CLAUDE.md /
|
|
90
|
+
* CLAUDE.local.md of every directory above it. THE SSOT scan-invisible-chars.mjs
|
|
91
|
+
* reads too (re-exported there as `findInstructionFiles`) — sharing the one
|
|
92
|
+
* function is what keeps the SessionStart scan's targets and this module's
|
|
93
|
+
* launch-emptiness check from drifting into two different answers for "what
|
|
94
|
+
* loads at launch". See src/claude-context.mjs for why this is the shallow
|
|
95
|
+
* launch scope and not a whole-tree walk.
|
|
96
|
+
* @param {string} dir
|
|
97
|
+
* @returns {string[]}
|
|
98
|
+
*/
|
|
99
|
+
export function launchInstructionFiles(dir: string): string[];
|
|
77
100
|
/**
|
|
78
101
|
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
79
|
-
* or null when the scan has been seen
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* PURE: it does not record that the notice was handed out. The caller records
|
|
83
|
-
* separately, once the notice has actually landed in a response — a deny
|
|
84
|
-
* assembled after this call discards the notice, and a marker written here would
|
|
85
|
-
* have burned the session's one chance to report the loss.
|
|
102
|
+
* or null when the scan has been seen, the notice already ran this session, or
|
|
103
|
+
* neither launch nor `touchedDir` could have fired the event.
|
|
86
104
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
105
|
+
* PURE for the notice: nothing is recorded until the caller confirms it landed
|
|
106
|
+
* in a returned response. Launch-emptiness is cached once found — fixed for the
|
|
107
|
+
* session — but `touchedDir` is re-checked every call: the claim below is about
|
|
108
|
+
* SUBDIRECTORY files, so an empty launch must not silence a later call whose
|
|
109
|
+
* tool touched a directory that DOES carry real, unscanned content.
|
|
90
110
|
*
|
|
91
|
-
*
|
|
92
|
-
* the
|
|
93
|
-
* scan
|
|
94
|
-
* the
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
111
|
+
* SessionStart scans what loads at launch; a subdirectory's file is scanned by
|
|
112
|
+
* the event; no scan means nothing says so — unless nothing touched so far had
|
|
113
|
+
* anything to scan either. The notice names all three remaining causes, since
|
|
114
|
+
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
115
|
+
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
116
|
+
* @param {string} [sessionId] the harness's session identity (see
|
|
117
|
+
* instructionsLoadedFile)
|
|
118
|
+
* @param {string} [dir] the project root to check (injectable; defaults to
|
|
119
|
+
* the real project)
|
|
120
|
+
* @param {string} [touchedDir] this tool call's own target directory, when
|
|
121
|
+
* known — re-checked every call, never cached
|
|
99
122
|
* @returns {string | null}
|
|
100
123
|
*/
|
|
101
|
-
export function instructionsLoadedGapNotice(sessionId?: string): string | null;
|
|
124
|
+
export function instructionsLoadedGapNotice(sessionId?: string, dir?: string, touchedDir?: string): string | null;
|
|
102
125
|
/**
|
|
103
126
|
* Record that the gap notice above was surfaced, so it rides on ONE tool call
|
|
104
127
|
* rather than every one — the per-call repeat is what trains a reader to skip it.
|
|
@@ -134,11 +157,11 @@ export function invisibleCharAlert(sessionId?: string): string | null;
|
|
|
134
157
|
* Add `text` to the alert the PreToolUse gate surfaces this session, keeping
|
|
135
158
|
* whatever is already there.
|
|
136
159
|
*
|
|
137
|
-
* One O_EXCL-created, randomly-named file per finding.
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
160
|
+
* One O_EXCL-created, randomly-named file per finding. A single file appended
|
|
161
|
+
* through a read-modify-write would let two hooks recording a finding at once
|
|
162
|
+
* silently drop one of them; a fresh file per finding has no shared cell to
|
|
163
|
+
* lose. Symlink-refusing (writeFileNoFollow) because the store sits at a
|
|
164
|
+
* predictable, world-visible $TMPDIR path.
|
|
142
165
|
* @param {string} text
|
|
143
166
|
* @param {string} [sessionId]
|
|
144
167
|
* @returns {boolean} whether the finding was recorded
|
|
@@ -163,7 +163,7 @@ export function sanitizerDepsLoaded(): boolean;
|
|
|
163
163
|
* otherwise invisible because every later tool call then fails closed with no
|
|
164
164
|
* stated cause — the recorded loader error and its remedy ride along. The text
|
|
165
165
|
* comes from missingPackageMessage so this hook, the PreToolUse gate and the
|
|
166
|
-
* prompt gate
|
|
166
|
+
* prompt gate read the same words for what a missing dependency looks like.
|
|
167
167
|
* @param {() => boolean} [depsLoaded] injectable seam for testing
|
|
168
168
|
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
169
169
|
* @returns {string}
|
|
@@ -116,9 +116,9 @@ export function decodeRun(run: string): {
|
|
|
116
116
|
* files load when a tool reads their directory, and scan-loaded-instructions
|
|
117
117
|
* scans each one at that moment.
|
|
118
118
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* rather than
|
|
119
|
+
* lib/invisible-alert.mjs's `launchInstructionFiles` IS this function — shared
|
|
120
|
+
* so its InstructionsLoaded gap-notice check reads the identical target set
|
|
121
|
+
* this scan does, rather than a second enumeration that could drift.
|
|
122
122
|
* @param {string} dir
|
|
123
123
|
* @returns {string[]}
|
|
124
124
|
*/
|
package/types/invisible.d.mts
CHANGED
|
@@ -51,7 +51,7 @@ export function hasLongRun(text: string): boolean;
|
|
|
51
51
|
* payload-length invisible run, and a pointer to recover the bytes — a hex dump
|
|
52
52
|
* is ASCII, so it passes through sanitization untouched. The single source of
|
|
53
53
|
* this note, shared by the `sanitize` convenience entry and the tool-output
|
|
54
|
-
* pipeline
|
|
54
|
+
* pipeline.
|
|
55
55
|
* @param {string[]} invisFound CATEGORY codes applyLayer1 reported removing
|
|
56
56
|
* @param {string} deAnsi ANSI-stripped text (invisible runs intact), for the LONG_RUN probe
|
|
57
57
|
* @returns {string}
|
package/types/view-map.d.mts
CHANGED
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
* Wrap a redactor's map-mode result in a frozen, branded view tagged with the
|
|
20
20
|
* space its offsets are in.
|
|
21
21
|
*
|
|
22
|
-
* The redactor's own object is never touched.
|
|
23
|
-
* `view.pairs = pairsToUtf16(view.text, view.pairs)
|
|
22
|
+
* The redactor's own object is never touched. A caller doing
|
|
23
|
+
* `view.pairs = pairsToUtf16(view.text, view.pairs)` would mutate in place a
|
|
24
24
|
* value returned from an INJECTED seam. A redactor that memoizes its map result
|
|
25
25
|
* (a reasonable thing for a caller to build) hands back the same object on the
|
|
26
|
-
* second identical call, which then
|
|
26
|
+
* second identical call, which would then get converted a SECOND time — every
|
|
27
27
|
* placeholder preceded by an astral character shifts again and the same input
|
|
28
28
|
* yields a different verdict.
|
|
29
29
|
*
|