agent-sanitizer 2.62.1 → 2.63.0
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/THREAT-MODEL.md +14 -2
- package/claude-hooks/lib/knobs.mjs +1 -0
- package/claude-hooks/lib/redactor-client.mjs +9 -5
- package/package.json +1 -1
- package/python/agent_sanitizer/secrets/data/credential-names.json +9 -2
- package/src/credential-names.mjs +16 -12
- package/src/instructions.mjs +12 -3
- package/src/invisible.mjs +32 -17
- package/types/claude-hooks/lib/knobs.d.mts +3 -0
- package/types/credential-names.d.mts +2 -1
- package/types/invisible.d.mts +4 -1
- package/types/src/invisible.d.mts +4 -1
package/THREAT-MODEL.md
CHANGED
|
@@ -823,7 +823,11 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
823
823
|
merge and that refresh, the default branch pins binaries compiled from the
|
|
824
824
|
previous bundle. The provisioner compares its own bundle against the manifest's
|
|
825
825
|
`# bundle=` pin and refuses on a mismatch, so the window degrades to the node
|
|
826
|
-
path rather than running a binary that is not the code the install ships.
|
|
826
|
+
path rather than running a binary that is not the code the install ships. A
|
|
827
|
+
binary provisioned before the window stays installed but loses its install
|
|
828
|
+
stamp, and the launcher runs a binary whose stamp does not name the shipped
|
|
829
|
+
manifest only on a host where no node is found — there an older sanitizer
|
|
830
|
+
still beats an unsanitized call.
|
|
827
831
|
- **Not covered: an attacker who can write BOTH the repository and the release.**
|
|
828
832
|
There is no signature; the manifest is trusted because it arrives through the
|
|
829
833
|
same reviewed, gated path as the rest of the plugin. Rewriting it is rewriting
|
|
@@ -842,11 +846,19 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
842
846
|
per install instead of once per tool call. V8 does not treat a code cache as
|
|
843
847
|
untrusted input, so those files are code the session runs, exactly like the
|
|
844
848
|
binary above. The launcher creates the directory mode 700 and gates it with
|
|
845
|
-
`
|
|
849
|
+
`trusted_dir` — the same owner-only-write property, refused with a line on
|
|
846
850
|
stderr when the directory does not hold it — so the same user-owned data
|
|
847
851
|
directory is the assumption here too. It carries no digest and needs none:
|
|
848
852
|
anything able to write there can already run code per the bullet above. Nothing
|
|
849
853
|
is fetched for it, so it adds no supply-chain surface.
|
|
854
|
+
- **The same gate covers everything else the launcher takes from a shared
|
|
855
|
+
location.** The provisioned redactor venv (`${CLAUDE_PLUGIN_DATA}/venv` and its
|
|
856
|
+
`bin/`, whose daemon receives every secret-bearing payload) is handed to the
|
|
857
|
+
client only when `trusted_dir` vouches for both directories; otherwise the
|
|
858
|
+
client redacts through the committed zipapp. The degraded-warning marker root
|
|
859
|
+
under `TMPDIR` is read only when this user owns it with owner-only write, so a
|
|
860
|
+
squatted shared-temp entry cannot pre-suppress the warning that a call ran
|
|
861
|
+
unsanitized.
|
|
850
862
|
- **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
|
|
851
863
|
a binary. That leaves the node path running the committed bundle and the
|
|
852
864
|
compile cache above; `AGENT_SANITIZER_COMPILE_CACHE=0` turns the cache off as
|
|
@@ -220,6 +220,7 @@ export const KNOBS = Object.freeze({
|
|
|
220
220
|
_AGENT_SANITIZER_TRACE_FILE: on("internal", text()),
|
|
221
221
|
_AGENT_SANITIZER_REDACTOR_SOCKET: on("internal", text()),
|
|
222
222
|
_AGENT_SANITIZER_REDACTOR_DAEMON: on("internal", text()),
|
|
223
|
+
_AGENT_SANITIZER_REDACTOR_PYZ: on("internal", text()),
|
|
223
224
|
_AGENT_SANITIZER_REDACTOR_WAIT_MS: on("internal", milliseconds(8000)),
|
|
224
225
|
_AGENT_SANITIZER_REDACTOR_REQUEST_MS: on("internal", milliseconds(20000)),
|
|
225
226
|
_AGENT_SANITIZER_SANITIZE_BUDGET_MS: on("internal", milliseconds(120000)),
|
|
@@ -60,10 +60,12 @@ export const DEFAULT_SOCKET_PATH =
|
|
|
60
60
|
* sets it to the provisioned venv's console script when one exists, and
|
|
61
61
|
* tests point it at dead paths to drive the fail-closed arm; a fallback
|
|
62
62
|
* from an explicit setting would turn those arms into silent passes).
|
|
63
|
-
* 2. The committed self-contained zipapp
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
63
|
+
* 2. The committed self-contained zipapp (`plugin/dist/redactor/daemon.pyz`) —
|
|
64
|
+
* the guaranteed floor: it needs only a python3 on PATH, so a cold start
|
|
65
|
+
* with no venv and no network still redacts instead of failing closed on
|
|
66
|
+
* every tool call. At `_AGENT_SANITIZER_REDACTOR_PYZ` when the launcher
|
|
67
|
+
* exports it (the compiled binary's module-relative path names no file on
|
|
68
|
+
* disk), else beside this bundle.
|
|
67
69
|
* 3. The packaged console script from PATH — the un-bundled/dev case, where
|
|
68
70
|
* no sibling dist tree exists.
|
|
69
71
|
* @returns {string[]}
|
|
@@ -74,7 +76,9 @@ function daemonCommand() {
|
|
|
74
76
|
// Inside the built bundle import.meta.url is .../plugin/dist/hooks/<bundle>,
|
|
75
77
|
// so the committed zipapp sits one level up; from the raw sources no such
|
|
76
78
|
// sibling exists and the check falls through.
|
|
77
|
-
const pyz =
|
|
79
|
+
const pyz =
|
|
80
|
+
readKnob("_AGENT_SANITIZER_REDACTOR_PYZ") ||
|
|
81
|
+
fileURLToPath(new URL("../redactor/daemon.pyz", import.meta.url));
|
|
78
82
|
if (existsSync(pyz)) return ["python3", pyz];
|
|
79
83
|
return ["agent-secret-redactor-daemon"];
|
|
80
84
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.63.0",
|
|
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": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "The credential-noun vocabulary: the words that make an identifier name a secret. Published so every consumer derives its own matcher from ONE list — a newly recognized noun reaches them all through a version bump instead of N hand edits. Read it from Python via agent_sanitizer.secrets (credential_name_segments / credential_field_name_patterns / non_secret_name_segments) or from JavaScript via the npm subpath export `agent-sanitizer/credential-names`. `parts` are the lowercase words of the noun; a consumer renders them for its own matcher (underscore-joined `API_KEY` and bare-joined `APIKEY` for an env-var NAME, `api[_-]?key` for a `field = value` regex). `uses` says which matcher may use the noun, because the two are not interchangeable: `env-name` matches a variable NAME and never inspects a value, so a broad noun there costs nothing, while `field-value` redacts whatever follows `noun = ` and a broad noun there mangles ordinary text — `key = <20 chars>` is a false-positive flood, so `key`, `pat`, `credential`, `credentials` and `secrets` are env-name only — `credential`/`credentials` are everyday variable names, and an attribute chain (`credentials = service_account.Credentials.from_service_account_info(info)`) escapes every value-shape skip, so a field-value rendering would redact ordinary source lines. `passphrase` is the exception among them: it is as shape-specific as `password`, and detect-secrets' own KeywordDetector DENYLIST omits it, so name-only would let `passphrase = <secret>` reach the model in cleartext. `nonSecretSuffixes` are the trailing words that make a credential-shaped name hold a NON-secret
|
|
2
|
+
"$comment": "The credential-noun vocabulary: the words that make an identifier name a secret. Published so every consumer derives its own matcher from ONE list — a newly recognized noun reaches them all through a version bump instead of N hand edits. Read it from Python via agent_sanitizer.secrets (credential_name_segments / credential_field_name_patterns / non_secret_name_segments) or from JavaScript via the npm subpath export `agent-sanitizer/credential-names`. `parts` are the lowercase words of the noun; a consumer renders them for its own matcher (underscore-joined `API_KEY` and bare-joined `APIKEY` for an env-var NAME, `api[_-]?key` for a `field = value` regex). `uses` says which matcher may use the noun, because the two are not interchangeable: `env-name` matches a variable NAME and never inspects a value, so a broad noun there costs nothing, while `field-value` redacts whatever follows `noun = ` and a broad noun there mangles ordinary text — `key = <20 chars>` is a false-positive flood, so `key`, `pat`, `credential`, `credentials` and `secrets` are env-name only — `credential`/`credentials` are everyday variable names, and an attribute chain (`credentials = service_account.Credentials.from_service_account_info(info)`) escapes every value-shape skip, so a field-value rendering would redact ordinary source lines. `passphrase` is the exception among them: it is as shape-specific as `password`, and detect-secrets' own KeywordDetector DENYLIST omits it, so name-only would let `passphrase = <secret>` reach the model in cleartext. `nonSecretSuffixes` are the trailing words that make a credential-shaped name hold a NON-secret — a key's identifier, the public half of a keypair, or metadata ABOUT a secret (its type, display name, label, kind, or the path/file it lives at) — which a consumer must not redact. A bare `id` is deliberately absent: Vault's `secret_id` is itself a credential. Every part is restricted to a-z0-9 so it carries no regex metacharacter; the accessors enforce that and fail closed on a violation, an empty list, or an unknown `uses` value. It sits inside the Python package because a wheel can only ship data under its package directory, while npm's `files`/`exports` can name any path — so ONE physical file backs both ecosystems and there is no copy to drift.",
|
|
3
3
|
"nouns": [
|
|
4
4
|
{ "parts": ["api", "key"], "uses": ["env-name", "field-value"] },
|
|
5
5
|
{ "parts": ["access", "key"], "uses": ["env-name", "field-value"] },
|
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
],
|
|
25
25
|
"nonSecretSuffixes": [
|
|
26
26
|
["key", "id"],
|
|
27
|
-
["public", "key"]
|
|
27
|
+
["public", "key"],
|
|
28
|
+
["type"],
|
|
29
|
+
["name"],
|
|
30
|
+
["label"],
|
|
31
|
+
["keyword"],
|
|
32
|
+
["kind"],
|
|
33
|
+
["path"],
|
|
34
|
+
["file"]
|
|
28
35
|
]
|
|
29
36
|
}
|
package/src/credential-names.mjs
CHANGED
|
@@ -178,7 +178,8 @@ export function credentialNames() {
|
|
|
178
178
|
* See this module's header for why that choice belongs to the caller.
|
|
179
179
|
*
|
|
180
180
|
* `declineNonSecret` applies the vocabulary's `nonSecretSuffixes`: a name ending
|
|
181
|
-
* in one holds an identifier
|
|
181
|
+
* in one holds an identifier, a public key, or metadata about a secret
|
|
182
|
+
* (`AWS_ACCESS_KEY_ID`, `SECRET_TYPE`, `TOKEN_FILE`), not the secret itself.
|
|
182
183
|
* Leave it on for a redactor, where redacting an identifier out of output is a
|
|
183
184
|
* visible defect; turn it off for a scrub whose failure to strip is the worse
|
|
184
185
|
* error. It is applied to the name's trailing run under both scopes, because a
|
|
@@ -198,26 +199,29 @@ export function credentialNameMatcher(options = {}) {
|
|
|
198
199
|
const vocabulary = spec ? parseCredentialNames(spec) : credentialNames();
|
|
199
200
|
const nouns = new Set(vocabulary.segments);
|
|
200
201
|
const nonSecret = new Set(vocabulary.nonSecretSegments);
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
202
|
+
// Each walk is bounded by the longest entry of the set it probes: a run longer
|
|
203
|
+
// than that matches nothing there, and the bound is what keeps the walk linear
|
|
204
|
+
// in the name's segment count. The bounds are separate because a two-word
|
|
205
|
+
// non-secret suffix (`PUBLIC_KEY`) must still be seen when every noun is one word.
|
|
206
|
+
/** @param {Iterable<string>} segments */
|
|
207
|
+
const longestRun = (segments) =>
|
|
208
|
+
Math.max(...Array.from(segments, (s) => s.split("_").length));
|
|
209
|
+
const maxRun = longestRun(nouns);
|
|
210
|
+
const maxNonSecretRun = longestRun(nonSecret);
|
|
211
|
+
/** @param {string[]} words @param {number} bound @returns {string[]} */
|
|
212
|
+
const trailingRuns = (words, bound) =>
|
|
213
|
+
Array.from({ length: Math.min(bound, words.length) }, (_, i) =>
|
|
210
214
|
words.slice(words.length - (i + 1)).join("_"),
|
|
211
215
|
);
|
|
212
216
|
return (name) => {
|
|
213
217
|
const words = name.toUpperCase().split("_");
|
|
214
218
|
if (
|
|
215
219
|
declineNonSecret &&
|
|
216
|
-
trailingRuns(words).some((run) => nonSecret.has(run))
|
|
220
|
+
trailingRuns(words, maxNonSecretRun).some((run) => nonSecret.has(run))
|
|
217
221
|
)
|
|
218
222
|
return false;
|
|
219
223
|
if (scope === "trailing")
|
|
220
|
-
return trailingRuns(words).some((run) => nouns.has(run));
|
|
224
|
+
return trailingRuns(words, maxRun).some((run) => nouns.has(run));
|
|
221
225
|
for (let start = 0; start < words.length; start++)
|
|
222
226
|
for (let span = 1; span <= maxRun && start + span <= words.length; span++)
|
|
223
227
|
if (nouns.has(words.slice(start, start + span).join("_"))) return true;
|
package/src/instructions.mjs
CHANGED
|
@@ -232,11 +232,20 @@ export function scanText(content) {
|
|
|
232
232
|
let line = 1;
|
|
233
233
|
let counted = 0;
|
|
234
234
|
// The verdict keeps only the first run; the rest come off the payload view,
|
|
235
|
-
// which is index-aligned with `content` and masks preserved invisibles.
|
|
235
|
+
// which is index-aligned with `content` and masks preserved invisibles. The
|
|
236
|
+
// search resumes past the first run rather than matching it a second time,
|
|
237
|
+
// which on one 256 KB payload run is a regex pass over the whole file.
|
|
238
|
+
const { firstRun } = verdict;
|
|
236
239
|
const runs =
|
|
237
|
-
|
|
240
|
+
firstRun === null
|
|
238
241
|
? []
|
|
239
|
-
:
|
|
242
|
+
: [
|
|
243
|
+
firstRun,
|
|
244
|
+
...findLongRuns(
|
|
245
|
+
payloadInvisibleView(content),
|
|
246
|
+
firstRun.index + firstRun.text.length,
|
|
247
|
+
),
|
|
248
|
+
];
|
|
240
249
|
for (const run of runs) {
|
|
241
250
|
for (; counted < run.index; counted++)
|
|
242
251
|
if (content.charCodeAt(counted) === NEWLINE) line++;
|
package/src/invisible.mjs
CHANGED
|
@@ -218,14 +218,16 @@ const RUN_TAIL_RE = new RegExp(`(?:${STRIP.source}){1,${RUN_CHUNK}}`, "yu");
|
|
|
218
218
|
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
219
219
|
* runs reported are maximal at any length.
|
|
220
220
|
* @param {string} text
|
|
221
|
+
* @param {number} [from] UTF-16 offset to resume at: the end of a run already
|
|
222
|
+
* taken off this text, so a caller holding the first run need not re-measure it
|
|
221
223
|
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
222
224
|
*/
|
|
223
|
-
export function* findLongRuns(text) {
|
|
225
|
+
export function* findLongRuns(text, from = 0) {
|
|
224
226
|
// Both regexes are module-level and carry `lastIndex`, and a generator can be
|
|
225
227
|
// suspended anywhere — including inside another scan of another text. Every
|
|
226
228
|
// exec below therefore sets its own start position first, so no scan can
|
|
227
229
|
// inherit a position from one it interleaved with.
|
|
228
|
-
let pos =
|
|
230
|
+
let pos = from;
|
|
229
231
|
for (;;) {
|
|
230
232
|
LONG_RUN_CHUNK_RE.lastIndex = pos;
|
|
231
233
|
const match = LONG_RUN_CHUNK_RE.exec(text);
|
|
@@ -1231,12 +1233,11 @@ function markTagSequences(cps) {
|
|
|
1231
1233
|
return keep;
|
|
1232
1234
|
}
|
|
1233
1235
|
|
|
1234
|
-
// The
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
...STANDARDIZED_VARIANTS.map(([, selector]) => selector),
|
|
1236
|
+
// The selectors the registered sequences use: a cheap pre-filter. Not their
|
|
1237
|
+
// span — U+180B to U+FE0D also holds U+200B and most other Cf code points, so a
|
|
1238
|
+
// zero-width run paid the keyed sequence lookup once per character.
|
|
1239
|
+
const STANDARDIZED_SELECTORS = new Set(
|
|
1240
|
+
STANDARDIZED_VARIANTS.map(([, selector]) => selector),
|
|
1240
1241
|
);
|
|
1241
1242
|
|
|
1242
1243
|
/**
|
|
@@ -1249,8 +1250,7 @@ const STANDARDIZED_SELECTOR_MAX = Math.max(
|
|
|
1249
1250
|
*/
|
|
1250
1251
|
function isStandardizedVariationSelector(cps, i) {
|
|
1251
1252
|
const cp = cps[i];
|
|
1252
|
-
if (cp
|
|
1253
|
-
return false;
|
|
1253
|
+
if (!STANDARDIZED_SELECTORS.has(cp)) return false;
|
|
1254
1254
|
return i > 0 && isStandardizedVariant(cps[i - 1], cp);
|
|
1255
1255
|
}
|
|
1256
1256
|
|
|
@@ -1892,7 +1892,7 @@ function carveWalk(body, cps, analysis, build) {
|
|
|
1892
1892
|
* @typedef {{ body: string, cps: Int32Array,
|
|
1893
1893
|
* analysis: ReturnType<typeof analyzeCarve>,
|
|
1894
1894
|
* strip: { cleaned: string, found: string[], removed: number } | null,
|
|
1895
|
-
* removed: number | null }} CarveEntry
|
|
1895
|
+
* removed: number | null, view: string | null }} CarveEntry
|
|
1896
1896
|
*/
|
|
1897
1897
|
|
|
1898
1898
|
/** @type {CarveEntry | null} */
|
|
@@ -1900,9 +1900,9 @@ let lastCarve = null;
|
|
|
1900
1900
|
|
|
1901
1901
|
/**
|
|
1902
1902
|
* The carve analysis of `body`, with the most recent one remembered — and its
|
|
1903
|
-
* strip, or
|
|
1904
|
-
* document several times (the payload view, the effective count,
|
|
1905
|
-
* {@link isIncidentalInvisible}); every reading is a pure function of `body`,
|
|
1903
|
+
* strip, removal count or payload view, once asked for. One hook call often
|
|
1904
|
+
* carves one document several times (the payload view, the effective count,
|
|
1905
|
+
* then {@link isIncidentalInvisible}); every reading is a pure function of `body`,
|
|
1906
1906
|
* so the cache changes what a verdict costs, never the verdict. One entry,
|
|
1907
1907
|
* recorded only after the analysis returns.
|
|
1908
1908
|
* @param {string} body
|
|
@@ -1912,7 +1912,7 @@ function carveOf(body) {
|
|
|
1912
1912
|
if (lastCarve !== null && lastCarve.body === body) return lastCarve;
|
|
1913
1913
|
const cps = codePointArray(body);
|
|
1914
1914
|
const analysis = analyzeCarve(cps);
|
|
1915
|
-
lastCarve = { body, cps, analysis, strip: null, removed: null };
|
|
1915
|
+
lastCarve = { body, cps, analysis, strip: null, removed: null, view: null };
|
|
1916
1916
|
return lastCarve;
|
|
1917
1917
|
}
|
|
1918
1918
|
|
|
@@ -1952,10 +1952,13 @@ function needsCarveOut(body) {
|
|
|
1952
1952
|
* @returns {string}
|
|
1953
1953
|
*/
|
|
1954
1954
|
export function payloadInvisibleView(text) {
|
|
1955
|
+
const entry = carveOf(text);
|
|
1956
|
+
// The verdict's run search and a caller listing every run both read it.
|
|
1957
|
+
if (entry.view !== null) return entry.view;
|
|
1955
1958
|
const {
|
|
1956
1959
|
cps,
|
|
1957
1960
|
analysis: { codes, kind },
|
|
1958
|
-
} =
|
|
1961
|
+
} = entry;
|
|
1959
1962
|
const offsets = u16Offsets(cps);
|
|
1960
1963
|
/** @param {number} i */
|
|
1961
1964
|
const isPayload = (i) => codes[i] !== CODE_VISIBLE && kind[i] === KIND_NONE;
|
|
@@ -1974,7 +1977,8 @@ export function payloadInvisibleView(text) {
|
|
|
1974
1977
|
while (i < cps.length && isPayload(i)) i++;
|
|
1975
1978
|
if (i > keepFrom) parts.push(text.slice(offsets[keepFrom], offsets[i]));
|
|
1976
1979
|
}
|
|
1977
|
-
|
|
1980
|
+
entry.view = parts.join("");
|
|
1981
|
+
return entry.view;
|
|
1978
1982
|
}
|
|
1979
1983
|
|
|
1980
1984
|
/**
|
|
@@ -2065,6 +2069,17 @@ export function countEffectiveInvisible(text) {
|
|
|
2065
2069
|
// Otherwise both terms read ONE carve analysis (see carveOf).
|
|
2066
2070
|
const entry = carveOf(text);
|
|
2067
2071
|
const payload = entry.analysis.payloadInvis;
|
|
2072
|
+
// When every invisible is payload, nothing is preservable, so either strip
|
|
2073
|
+
// removes exactly the invisibles and the surplus is zero — unless a removal
|
|
2074
|
+
// closes a lone high surrogate against a lone low one, which the code-point
|
|
2075
|
+
// gap counts too (see carveWalk's `removed`) and which needs a lone low one.
|
|
2076
|
+
// A hidden run or a scatter of soft hyphens lands here, and answering off the
|
|
2077
|
+
// analysis spares it a whole-text strip it would only measure.
|
|
2078
|
+
if (
|
|
2079
|
+
payload === entry.analysis.invisible.length &&
|
|
2080
|
+
!LONE_LOW_SURROGATE_RE.test(text)
|
|
2081
|
+
)
|
|
2082
|
+
return payload;
|
|
2068
2083
|
if (!needsCarveOut(text))
|
|
2069
2084
|
return surplusOver(
|
|
2070
2085
|
payload,
|
|
@@ -93,6 +93,9 @@ export const KNOBS: Readonly<{
|
|
|
93
93
|
_AGENT_SANITIZER_REDACTOR_DAEMON: Readonly<Knob<string | undefined> & {
|
|
94
94
|
surface: Surface;
|
|
95
95
|
}>;
|
|
96
|
+
_AGENT_SANITIZER_REDACTOR_PYZ: Readonly<Knob<string | undefined> & {
|
|
97
|
+
surface: Surface;
|
|
98
|
+
}>;
|
|
96
99
|
_AGENT_SANITIZER_REDACTOR_WAIT_MS: Readonly<Knob<number> & {
|
|
97
100
|
surface: Surface;
|
|
98
101
|
}>;
|
|
@@ -24,7 +24,8 @@ export function credentialNames(): CredentialNames;
|
|
|
24
24
|
* See this module's header for why that choice belongs to the caller.
|
|
25
25
|
*
|
|
26
26
|
* `declineNonSecret` applies the vocabulary's `nonSecretSuffixes`: a name ending
|
|
27
|
-
* in one holds an identifier
|
|
27
|
+
* in one holds an identifier, a public key, or metadata about a secret
|
|
28
|
+
* (`AWS_ACCESS_KEY_ID`, `SECRET_TYPE`, `TOKEN_FILE`), not the secret itself.
|
|
28
29
|
* Leave it on for a redactor, where redacting an identifier out of output is a
|
|
29
30
|
* visible defect; turn it off for a scrub whose failure to strip is the worse
|
|
30
31
|
* error. It is applied to the name's trailing run under both scopes, because a
|
package/types/invisible.d.mts
CHANGED
|
@@ -28,9 +28,11 @@ export function isSgrOnly(text: string): boolean;
|
|
|
28
28
|
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
29
29
|
* runs reported are maximal at any length.
|
|
30
30
|
* @param {string} text
|
|
31
|
+
* @param {number} [from] UTF-16 offset to resume at: the end of a run already
|
|
32
|
+
* taken off this text, so a caller holding the first run need not re-measure it
|
|
31
33
|
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
32
34
|
*/
|
|
33
|
-
export function findLongRuns(text: string): Generator<{
|
|
35
|
+
export function findLongRuns(text: string, from?: number): Generator<{
|
|
34
36
|
index: number;
|
|
35
37
|
text: string;
|
|
36
38
|
charCount: number;
|
|
@@ -252,6 +254,7 @@ export type CarveEntry = {
|
|
|
252
254
|
removed: number;
|
|
253
255
|
} | null;
|
|
254
256
|
removed: number | null;
|
|
257
|
+
view: string | null;
|
|
255
258
|
};
|
|
256
259
|
/**
|
|
257
260
|
* Per-invisible carve-out analysis, shared by carveStrip and
|
|
@@ -28,9 +28,11 @@ export function isSgrOnly(text: string): boolean;
|
|
|
28
28
|
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
29
29
|
* runs reported are maximal at any length.
|
|
30
30
|
* @param {string} text
|
|
31
|
+
* @param {number} [from] UTF-16 offset to resume at: the end of a run already
|
|
32
|
+
* taken off this text, so a caller holding the first run need not re-measure it
|
|
31
33
|
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
32
34
|
*/
|
|
33
|
-
export function findLongRuns(text: string): Generator<{
|
|
35
|
+
export function findLongRuns(text: string, from?: number): Generator<{
|
|
34
36
|
index: number;
|
|
35
37
|
text: string;
|
|
36
38
|
charCount: number;
|
|
@@ -252,6 +254,7 @@ export type CarveEntry = {
|
|
|
252
254
|
removed: number;
|
|
253
255
|
} | null;
|
|
254
256
|
removed: number | null;
|
|
257
|
+
view: string | null;
|
|
255
258
|
};
|
|
256
259
|
/**
|
|
257
260
|
* Per-invisible carve-out analysis, shared by carveStrip and
|