@sema-agent/core 7.33.4 → 7.33.6

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,10 +1,114 @@
1
+ ## 7.33.6 — 2026-09-28
2
+
3
+ ### Fix — a withheld entry of git's indexed env config keeps its index, holding a placeholder entry; the record is never renumbered and the count never rewritten (#1160; 7.33.5 regression; behavior; +0 exports, +0 wire keys) (@server)
4
+
5
+ - **Before.** 7.33.5 withheld a git config entry whole and, when it sat below `GIT_CONFIG_COUNT`, renumbered the kept entries down to 0..m-1 and rewrote the count to m. A host that hands named variables back verbatim (sema-server's `BAKE_ENV_ALLOW`, an `inheritEnv` allow list on top of the scrub) put the input names back onto the renumbered record: with `COUNT=2`, entry 0 a credential header and entry 1 `core.pager=less`, putting back all four member names left `COUNT=1` and git read only entry 0 — the credential — while `core.pager` was never read (7.33.4 handed the record on unchanged).
6
+ - **Now.** A withheld entry below the count keeps its names: `GIT_CONFIG_KEY_<i>` holds `sema.withheld` and `GIT_CONFIG_VALUE_<i>` holds the empty string (git reads it as an entry nothing uses; checked with git 2.50 and 2.54: `git config --list` shows `sema.withheld=`, `status` / `ls-remote` / `fetch` / an `includeIf` beside it unchanged). The count and every other entry reach the child as the host wrote them, so a host that puts a name back puts back exactly that member. A withheld entry at or above the count, or with no count (git reads none of it), is dropped as before; a withheld entry the host left incomplete keeps its one name (no name is added). A member whose value is `undefined` reaches no child and is left to the name rules. The renumbering rule is gone, and with it the "rebuild" reading of undefined members.
7
+ - **Who needs it.** Hosts that hand git config through the environment and put named variables back after the scrub.
8
+ - **Who is hurt / compensation.** A finding for a record member names a variable that is still in the scrubbed map, holding the placeholder half — a consumer that reads "withheld" as "absent from the output" (`composed[f.key] === undefined`) must read it as "not handed on as written" (`composed[f.key] !== input[f.key]`). A child that lists its git config sees one `sema.withheld=` line per withheld entry. A host that puts back only one half of a withheld entry pairs it with the other half's placeholder (put back both names of an entry, or neither).
9
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1152 / #1160 block, real git: placeholder in place for includeIf / safe.directory / a middle entry, the put-back case with and without the credential's names, second scrub inert, incomplete withheld entry keeps its one name, entries at or above the count dropped without a placeholder, 2^53 indices, undefined members); the 7.33.5 renumbering pins in that file and in `test/backlog1129-git-config-key-family.test.ts` re-pinned to the in-place reading (intended flip, named in each pin).
10
+
11
+ ### Fix — on a POSIX child a mixed-case spelling of a git record member is a variable of its own, judged apart from the record; only a Windows child folds the spellings (#1159; 7.33.5 regression; behavior; `scrubSecretEnv` / `isSecretEnvKey` gain an optional trailing `platform` argument; +0 exports, +0 wire keys) (@server)
12
+
13
+ - **Before.** 7.33.5 compared every member name upper-cased, so beside a canonical `GIT_CONFIG_KEY_0` a variable spelled `Git_Config_Key_0` counted as a second spelling of the same member and the whole record was withheld: with `GIT_CONFIG_COUNT=3`, entry 0 a credential header and the alias `Git_Config_Key_0=user.name` beside it, `core.pager` and `core.editor` disappeared from every git child as well. On Linux and macOS git looks up `GIT_CONFIG_KEY_<i>` by its exact capitalised name — the alias is never read (git 2.54: `git config --get user.name` exits 1 on the unscrubbed map).
14
+ - **Now.** Which names make up git's record depends on the platform the child runs on. On Windows env names are case-insensitive, so any spelling is the member (two spellings of one member still withhold the record, as the child's pick is not knowable). Elsewhere only the capitalised names are the record; any other spelling of a member name — like a suffix git never reads (`_00`, `_x`) — is judged on its own: paired with the other half of the same case-folded suffix among such names, withheld when it carries a credential (`family-rule`) or spells a half twice, otherwise left to the name rules (`Git_Config_Key_0` then drops by the shape rule); it never touches the record. A mixed-case `git_config_count` is a count only on Windows. `scrubSecretEnv(env, findings?, platform?)` and `isSecretEnvKey(key, value?, env?, platform?)` take the child's platform (`NodeJS.Platform`); it defaults to `process.platform` — the answer for a child spawned on this host. The engine's own spawn sites (the shell exec env, the language-server spawn) pass `process.platform` explicitly.
15
+ - **Who needs it.** Hosts whose environment carries both spellings (a deployment layer that sets lower-case aliases, a copied env).
16
+ - **Who is hurt / compensation.** A host whose child runs somewhere else — a remote or sandboxed execution environment on another platform — must pass that environment's platform; the default reads for this host. On a POSIX child a lower-case member spelling that 7.33.1–7.33.5 kept as a record member is now a name of its own: `git_config_key_0` drops by the shape rule (git never read it there), and a clean `git_config_value_0` stays.
17
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1159 block: the reported input with a real-git control and scrubbed reads for three POSIX platforms, the default equals `process.platform`, six alias shapes judged apart from the record, the Windows reading simulated through `platform: "win32"`, the exec env's spawn read through a real shell child); `test/backlog1073-lsp-child-env.test.ts` (the language-server spawn reads for this host); the case-folding pins in `test/backlog1129-git-config-key-family.test.ts` and the two-spellings pin re-pinned per platform (intended flip, named).
18
+
19
+ ### Change — an ssh-family URL whose userinfo is a login name alone is no longer withheld, in a key's subsection or a value (#1136 / #1153; ruling C-R125; behavior widening; +0 exports, +0 wire keys) (@server)
20
+
21
+ - **Before.** Since 7.33.1 (key) / 7.33.2 (value) any `@` after a `:/` made a git config entry a carrier, so `url.ssh://git@github.com/.insteadOf` and `remote.origin.url = ssh://git@github.com/org/repo.git` were withheld (7.33.1–7.33.4: git exited 128; 7.33.5: the entry was dropped), while the scp form of the same remote (`git@github.com:org/repo.git`) passed.
22
+ - **Now.** Ruling C-R125: under `ssh://`, `git+ssh://` and `git://` (git's scheme names, compared as git compares them) a userinfo that is a login name alone — letters, digits, `.` `_` `-`, no password, no percent-encoding — with no other `@` in the URL is not a carrier: git hands it to ssh as the login (checked with a stand-in ssh under git 2.50 / 2.54). It clears where git reads a URL: a whole value, or a key's subsection after a section name git accepts (`url.<base>.insteadOf`, `http.<url>.*`). The scp form keeps its reading (#1153, same ruling): the user position is a login name. Everything else stays a carrier: an `https` / `http` user alone (it can be a token), a password on any scheme (`ssh://u:<secret>@host`), `%40` anywhere, a second `@`, any other character in the name, `SSH://` / `ssh+git://`, a URL that does not start the value or the subsection (`includeIf.hasconfig:remote.*.url:ssh://git@…` is still over-dropped), and any `@` under a proxy key.
23
+ - **Who needs it.** Hosts that route git over ssh through environment config (insteadOf rewrites, remote URLs with the `git@` login).
24
+ - **Who is hurt / compensation.** A model in the child can read an ssh login name it could not before; the ruling treats it as no secret. A token placed in an ssh login position (`ssh://<token>@host`) is no longer withheld.
25
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1136 / #1153 block: real git rewrites and resolves the ssh / git+ssh / git forms and hands the login to a stand-in ssh; scp forms; sixteen carrier spellings with a git control, key and value; subsections that are not a whole URL; proxy key); `test/backlog1129-git-config-key-family.test.ts` ssh login-only pins re-pinned (intended flip, named).
26
+
27
+ ### Fix — `GIT_CONFIG_COUNT` is read as git reads it and handed on as written (#1162; 7.33.5 regression; behavior; +0 exports, +0 wire keys) (@server)
28
+
29
+ - **Before.** 7.33.5 read the count as plain decimal digits up to 2147483647 and withheld the whole record for anything else — also for spellings git reads (` 1`, `+1`, the empty string), so a clean record with such a count lost every entry in the child (7.33.4 handed it on).
30
+ - **Now.** The count is read with git's own reading — C's `strtoul(value, &end, 10)`, refused when anything follows the number ("bogus count") or when it exceeds INT_MAX ("too many entries"): leading C blanks (space, TAB, LF, VT, FF, CR), one optional sign, decimal digits (`010` is ten), the empty string is 0, `-` negates modulo 2^64 (`-0` is 0, `-1` is refused, `-18446744073709551615` is 1), a magnitude past 2^64 − 1 saturates. Each of 36 spellings was measured against git 2.50.1 and 2.54.0 (they agree). A count git reads is handed on as written and decides which withheld entries hold a placeholder; a count git refuses still withholds the whole record, so git starts. For a Windows child C's `unsigned long` is 32 bits (inferred, not run on Windows git).
31
+ - **Who needs it.** Hosts that write the count with a sign, a leading blank or leading zeros, or leave it empty.
32
+ - **Who is hurt / compensation.** None known. A count git refuses is also handed on as written (see the git-reading fixes below).
33
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1162 block: 36 measured spellings with git's unscrubbed reading as the control and the same entries read after the scrub; a wide spelling beside a credential entry; `-0` / `010` placing the placeholder; the 32-bit Windows reading; 1 MiB linearity); the 7.33.5 unreadable-count pin re-pinned (` 1`, `+1`, empty: intended flip).
34
+
35
+ ### Security fix — a member-shaped pair git never reads goes and stays as a pair: when a name rule drops one half, the other half goes with it (#1163; existing since 7.33.5 #1154; behavior narrowing; +0 exports, +0 wire keys) (@server)
36
+
37
+ - **Before.** A `GIT_CONFIG_KEY_<s>` / `GIT_CONFIG_VALUE_<s>` pair with a suffix git never reads (`_00`, `_01`) that did not trip the entry predicate went back to the name rules name by name: the KEY half fell to the shape rule (`…_KEY_00`) and the VALUE half, matching no rule, reached the child alone. `KEY_00 = core.sshCommand` with `VALUE_00 = sshpass -p <password> ssh` handed the password on with its key gone.
38
+ - **Now.** Such a pair — a non-index suffix, or on a POSIX child a mixed-case member spelling — is judged as a pair before any name rule: it is withheld whole when it carries a credential or spells a half twice (as before), and when a name rule drops either half the other half goes with it (`family-rule`; the dropped half keeps its own rule in its finding). A pair no rule touches stays whole. Members git reads (canonical index, capitalised on POSIX) are unchanged: exempt from the name rules, judged by the entry predicate.
39
+ - **Who needs it.** Nobody should lose a value to a leak because its key's name looked like a credential.
40
+ - **Who is hurt / compensation.** User-visible: a clean `GIT_CONFIG_VALUE_01` (or `_00`, or a POSIX alias value) whose `GIT_CONFIG_KEY_01` drops by the shape rule is now withheld too. git never reads these names; a child that does can read the pair through an explicit `inheritEnv` / `shellEnv` hand-off.
41
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1163 block: the reported `core.sshCommand` pair with real git reading the canonical record, five suffix shapes, a POSIX alias pair, a lone VALUE); the #1154 clean-pair pin and the #1159 POSIX alias pin re-pinned (intended flip, named).
42
+
43
+ ### Security fix — an empty value is handed on whatever its key means: an empty `http.extraHeader` keeps clearing the inherited headers (#1164; 7.33.5 regression — fail-open; behavior; +0 exports, +0 wire keys) (@server)
44
+
45
+ - **Before.** 7.33.5 (#1155 / #1151) withheld every entry whose key makes its value a credential — `http.extraHeader`, the password keys — whatever the value, empty included. git reads an empty `http.extraHeader` as "clear the extra headers set so far": a deployment that inherits an `Authorization:` header from a global or system config file and clears it for the child through the environment lost the clearing entry, and the inherited header came back on every request (checked with a loopback listener under git 2.54).
46
+ - **Now.** An empty (or absent) value carries no credential, so the key-meaning readings — password keys, extra headers, credential helpers — do not fire on it; the empty helper value (a reset of the helper list, already handed on) is now the same rule, not a special case of the helper reading. A credential in the KEY (a userinfo URL subsection) still withholds the entry. Any non-empty value, a blank included, is judged as before.
47
+ - **Who needs it.** Deployments that clear inherited headers, helpers or passwords for the child through environment config.
48
+ - **Who is hurt / compensation.** None known: an empty value holds nothing to leak.
49
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1164 block: an inherited header from a temp `GIT_CONFIG_GLOBAL` heard by a loopback listener, cleared by the empty env entry — control, scrubbed map, and the `-c` channel; empty values under seven keys in three sources; a blank still withheld; a credential key with an empty value still withheld); the key-only extra-header predicate pin re-pinned (intended flip, named).
50
+
51
+ ### Security fix — git config values are read for secrets four more ways, in the one entry predicate every source shares; two more proxy variables (#1165; existing, not a 7.33.5 regression; behavior narrowing; +0 exports, +0 wire keys) (@server)
52
+
53
+ - **Before.** The entry predicate read URLs, credential headers, proxy userinfo, the documented password keys and inline helpers. `github.token = ghp_…`, `credential.helper = mytool --token=<value>` and `core.sshCommand = sshpass -p <password> ssh` reached the child whole; so did `ftp_proxy` / `npm_config_proxy` / `npm_config_https_proxy` with a password, and `GIT_SSH_COMMAND` / `GIT_PROXY_COMMAND` carrying one.
54
+ - **Now.** One predicate, four more readings, for the indexed record, `GIT_CONFIG_PARAMETERS`, non-index pairs and the proxy variables alike: ① every non-empty value passes the engine's shared secret scanner (`redactSecrets` in `untrusted-egress.ts`, the same one that keeps secrets out of what the engine shows — vendor-prefixed tokens, JWTs, private-key blocks, `Bearer <token>`, `token=` / `password=` assignments, signed-URL parameters; no new shape table), read with each `:` of the value as structure (a refspec's two sides, a URL's scheme, a `key::` literal — `refs/heads/add-key:refs/…` is no `key:` pair) and asked only whether it changed anything (linear: building finding spans was quadratic on many matches); a key whose value is ref names — `remote.<name>.fetch` / `.push` (refspecs), `branch.<name>.merge` — is not scanner-read (`+refs/heads/pk-refactoring:…`, `api-key=rotation` are branch text); ② a key whose variable is `token` or `secret` joins `pass` / `password` (case-folded, any section); ③ a command line git runs — `core.sshCommand`, `core.gitProxy`, and the env variables `GIT_SSH_COMMAND` / `GIT_PROXY_COMMAND` — carries a password when `sshpass` is given one before the command it runs, read as sshpass's getopt reads its options (`-p` in any cluster, `--password[=<pw>]`; `-f` / `-d` / `-P` take an argument, a quoted prompt included), when an assignment `NAME=…` names a secret by the env name rules (`SSHPASS=… sshpass -e ssh`), or when `plink` is given `-pw` — over the words the shell hands over, read in one pass (quotes and escapes removed as the shell removes them, a backslash-newline joined, split at operators and substitutions: `"sshpass" '-p' <pw>`, `sshpass -P 'Enter password:' -p <pw>`, `$(echo sshpass) -p <pw>`), a word holding blanks read again as the command line it is (`ssh -o 'ProxyCommand=sshpass -p <pw> …'`, `sh -c "sshpass -p <pw> …"`, three levels deep); `ssh -p 22` is a port and stays; a credential helper reference withholds when an option whose name holds token / pass / secret / auth carries a value (`--token=<v>`, `--token <v>`, `--passphrase=<v>`); ④ `ftp_proxy` and npm's `npm_config_proxy` / `npm_config_https_proxy` (any case) are read as proxy addresses like `https_proxy`. Only git-family and proxy variables are value-read; other env names still are not (ruling C-R128).
55
+ - **Checked, not changed.** With an `http.proxy` that names no user, a 407 from the proxy does not make git ask its credential helper for proxy credentials (git 2.50 / 2.54, a loopback 407 proxy and a stand-in helper: only `erase` on the CONNECT path, carrying no password); with a user in the proxy URL git does ask, and that URL is already withheld (#1150).
56
+ - **Who is hurt / compensation.** Over-drops (KL-1129a): a helper option whose name holds auth / token / password / secret even when it names a file (`--auth-file <path>`); a path whose segment the scanner reads as a vendor-prefixed token (`/work/pk-backend-service`). Hand such entries on explicitly (`inheritEnv` / `shellEnv`) or put them in a config file the host owns. Not read (KL-1129a): a `name: value` pair written with a colon in an ordinary value, a command assembled by expansion at run time. A value of 1 MiB now costs the scanner's linear pass (bounded backtracking).
57
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1165 block: the reported shapes read by git before and withheld after in both channels, scanner shapes under any key and on a proxy variable, refspecs / a `key::` signing key / a long ssh login kept and read by git, 1.7 MB of alternating matches in bounded time, secret-named keys and look-alikes, command lines in four keys / variables — quoted, escaped, substituted spellings — with port and file controls, helper options, the three proxy names); the U+2028 bearer and linearity pins re-pinned (the scanner reads `Bearer <token>` anywhere — intended flip, named); `test/secret-env-git-differential.test.ts` (seeded two-way differential against the real git).
58
+
59
+ ### Fix — git config is read closer to git's own reading: user-named variables, alias commands, shell expansions in helpers, counts git refuses (#1165 / #1151 follow-ups; behavior; +0 exports, +0 wire keys) (@server)
60
+
61
+ - **User-named variables are not setting names.** In `alias.<command>`, `pretty.<format>`, `remotes.<group>` and `pager.<command>` the variable is a name the user chose, so the key-name rule (`pass` / `password` / `token` / `secret`) does not read it: `alias.token = !echo hi` stays (the #1165 `token` word withheld it; 7.33.5 kept it) and so do `alias.pass`, `alias.password`, `pretty.token` (withheld since 7.33.5 #1151 — git then answered "not a git command"). The value is read like any other value.
62
+ - **An alias's `!` value is a command line.** `alias.<name>` / `alias.<name>.command` whose value begins with `!` is run by git through the shell, so it is read with the same command-line reading as `core.sshCommand`: `alias.deploy = !sshpass -p <password> ssh host` (handed on whole in 7.33.5 and earlier) is withheld; an alias without `!` is git's own arguments, read as the git command it expands to — its `-c <name>=<value>` is a config entry judged like any other, as is a `-c` given to `git` inside any command line read here (`alias.x = -c imap.pass=<pw> pull`, `!git -c core.sshCommand='sshpass -p <pw> ssh' fetch`).
63
+ - **A shell expansion is not a literal password.** Every value git runs through the shell — command keys, `!` aliases and now credential helpers in every form — is read as the shell reads it (through the engine's one shell lexer, `src/shell/lex.ts`, segment by segment; a command bash cannot delimit runs nothing and is withheld), and only what is written into it counts: `$(…)`, backquotes, `$NAME`, `${NAME}`, `$@` are known when the shell runs, so `credential.helper = !f() { echo "password=$(pass show git/host)"; }; f` (git's documented password-manager form), `password=$GIT_PW`, `!pass-git-helper -m ~/.pass-map`, `!gh auth git-credential` and `!aws codecommit credential-helper $@` stay and git answers through them after the scrub (7.33.5 #1151 withheld every helper that was not a bare helper name, which broke git's authentication in the child). A literal still withholds: a printed `password=<literal>` / `oauth_refresh_token=` / `credential=` line, a parameter the same line assigns a literal to (`pw=<literal>; echo "password=$pw"`), a literal given to an option whose name ends in token / password / passwd / pass / passphrase / secret / pw / auth / oauth / apikey / credential (`--token <v>`, `-pw <v>`; `--password-file`, `--passphrase-fd`, `--auth-file` name where a secret is kept and stay), a literal `sshpass -p`, and a command substitution's body read as a command line of its own. The literal is read in the shell's own spellings: `$'…'` decodes its C escapes, a printed line is also read with printf's and `echo`'s escapes decoded (`passwor\144=<pw>`), and a literal copied through other parameters (`x=<pw>; y=$x; echo password=$y`) is followed. A command line handed on to something that runs it — a shell's `-c` argument, `eval`'s argument, ssh's `-o ProxyCommand` / `LocalCommand` / `KnownHostsCommand` / `RemoteCommand`, plink's `-proxycmd` — is read the same way with the outer line's parameters; any other argument is data (`!printf '%s\n' 'sshpass -p is unsafe'` stays). `sshpass -p "$PW"` now stays too (the same rule). A parameter's written default (`${X:-<text>}`) and a here-document the command prints are read as literals. printf's format is read as printf reads it: each conversion prints its argument, so `printf 'password=%s\n' "$(security find-generic-password -w -s git)"` (the macOS keychain helper form) stays, while `printf 'password=%s\n' <literal>`, an argument assigned a literal in the line, and a name built from arguments (`printf '%s=%s' password <literal>`) withhold; a `*` width or precision takes its argument first, `%%` is a literal percent sign, and printf's reuse of the format for further arguments is followed. Not read (KL-1129a): a program named by an expansion (`$(echo sshpass) -p …`, withheld on the branch before this change), a literal the shell builds at run time (`$(echo <pw>)`, `read`), ssh's remote command.
64
+ - **A count git refuses stays as written.** `GIT_CONFIG_COUNT` = `2abc`, `2 `, `-1` (git: "bogus count" / "too many entries", exit 128) used to withhold the whole record, so git in the child started where the host's git refused; now the count is handed on as written and git refuses it in the child just as in the host. A credential entry beside such a count holds its placeholder in place (git reads none of the record either way). Two env names folding to one member on a Windows child still withhold the whole record (which one the child reads is not knowable). Measured with git 2.50.1 and 2.54.0: the same exit and message before and after the scrub.
65
+
66
+ ## 7.33.5 — 2026-09-27
67
+
68
+ ### Security fix — git's indexed env config is withheld one entry at a time, whole, and the rest is handed on as a record git can read (#1152; 7.33.4 regression; behavior; +0 exports, +0 wire keys) (@server)
69
+
70
+ - **Before.** The scrub judged `GIT_CONFIG_KEY_<i>` and `GIT_CONFIG_VALUE_<i>` as separate names: a KEY whose value tripped the URL screen (`includeIf.gitdir:/work/repo@v2/.path`) or a VALUE that did (`safe.directory = C:/Users/alice@example.com/repo`, an `Authorization:` header) was dropped alone while `GIT_CONFIG_COUNT` stayed, so every git child of the scrubbed environment refused to start (`missing config key` / `missing config value`, exit 128) — 7.33.4's wider screen made that reachable by ordinary path values.
71
+ - **Now.** One entry = one judgment: the KEY and VALUE at an index are judged together by the same screens (userinfo URL in the key or the value, credential header, proxy address with a userinfo) and withheld together (`family-rule`, one finding per input name). When an entry below the count is withheld the record is rebuilt: the kept complete entries are renumbered 0..m-1 in index order (each member keeps its own spelling), `GIT_CONFIG_COUNT` becomes m, and git reads the rest of the host's config. Nothing is rewritten when no entry below the count is withheld (an entry the host left incomplete stays as written); members at or above the count, or with no count, are judged entry by entry and never renumbered. A count that is not decimal digits up to 2147483647, or two env names that fold to one member name, withholds the whole record. `isSecretEnvKey(key, value, env)` gives the same answer for each member (the entry is judged with its index group).
72
+ - **Also (#1154).** A name with the member prefix and a suffix git never reads (`GIT_CONFIG_VALUE_00`, `_01`, `_x`, an empty suffix) is still readable by the child: it is paired with the other half of the same (case-folded) suffix and judged by the same predicate — a carrier pair is withheld on its own (never renumbered, the count untouched), a clean one goes back to the name rules (`GIT_CONFIG_KEY_01` still drops by the shape rule; only a canonical index is exempt from it); a suffix spelled twice withholds its pair.
73
+ - **Who needs it.** Hosts that hand git config through the environment: a disclosed over-drop no longer takes every git child down with it.
74
+ - **Who is hurt / compensation.** A finding names the INPUT name, and after a rebuild a kept entry can sit under an index a withheld one had — a consumer that treats "a finding's key is absent from the output" as an invariant (a put-back check such as `composed[f.key] === undefined`) must compare against the input map instead; a host rule that withholds the whole group whenever core withholds a member keeps working (it only drops more). The over-drops themselves stay (no path-shaped key allow list, KL-1129b); spellings that avoid them, checked with git 2.54: an `includeIf.gitdir:` path's `@` written as the glob `?` (`…/repo?v2/.path`), `safe.directory` moved into a global config file the host owns (`GIT_CONFIG_GLOBAL`; git ignores it in a repository's config), a URL with an `@scope` path written into the repository's config. `%40` is no way out (7.33.4's note is withdrawn): it trips the same screen, and `includeIf.gitdir` does not decode it. A count git also accepts but that is not plain digits (` 1`, `+1`, the empty string) withholds the whole record — git then runs without the host's environment config (over-drop on purpose: one reading of the count, KL-1129b).
75
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1152 block, real git: includeIf / safe.directory / middle-entry renumbering / spelling / untouched incomplete entry / orphans / unreadable counts / undefined members); `test/backlog1129-git-config-key-family.test.ts` half-group pins re-pinned to the whole-entry reading (intended flip, named in each pin).
76
+
77
+ ### Security fix — libcurl's proxy variables carrying a userinfo are withheld (#1150; existing — the scrub never read these values — not a 7.33.4 regression; behavior narrowing; +0 exports, +0 wire keys) (@server)
78
+
79
+ - **Before.** The proxy screen read only a `GIT_CONFIG_VALUE_<i>` whose paired key names a proxy. `https_proxy="http://u:<password>@proxy:3128"` (and `http_proxy`, `all_proxy`, upper-case spellings) reached the child whole: a model could read the password, and git's libcurl sent it to the proxy (git 2.54 curl trace, and a loopback listener receiving `Proxy-Authorization: Basic …`).
80
+ - **Now.** `http_proxy` / `https_proxy` / `all_proxy` and their upper-case spellings (compared case-folded) are judged as the `http.proxy` entry they are to libcurl, by the same entry predicate as git's config record: a userinfo or `%40` anywhere in the value, a userinfo URL, or a credential header drops the variable (`family-rule`). A proxy address with no userinfo stays; `no_proxy` is not a proxy address and is not read.
81
+ - **Who is hurt / compensation.** A host whose tool children reach the network through a proxy that takes a password loses that proxy in the scrubbed child (git then connects directly and fails where egress needs the proxy). Hand it on explicitly: `NodeExecutionEnv` `inheritEnv: [..names]` (a strict allow list copied verbatim, no scrub) or `shellEnv` (passed verbatim); on sema-server, the site's named allow list where one exists (`LSP_ENV_ALLOW`, `BAKE_ENV_ALLOW`) or the host shell's `inheritEnv` setting. Or use a proxy that authenticates the host without a password in the environment. Other `<scheme>_proxy` names (`ftp_proxy`, `npm_config_https_proxy`, …) are outside this list (KL-1150).
82
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1150 block: five libcurl-read spellings with an unscrubbed control that sends the password to a loopback listener and a scrubbed run the listener never hears; `HTTP_PROXY` and mixed spellings; scheme-less / user-only / `socks5h` / single-slash / `%40` forms; `no_proxy` and address-only values stay).
83
+
84
+ ### Security fix — git config entries whose value is a password, and credential helpers that are shell text rather than a helper's name, are withheld (#1151; existing, not a 7.33.4 regression; behavior narrowing; +0 exports, +0 wire keys) (@server)
85
+
86
+ - **Before.** The entry screens read URLs, headers and proxy addresses only. `credential.helper = !f() { printf 'username=u\npassword=<secret>\n'; }; f` reached the child whole and `git credential fill` answered the password; so did `imap.pass` / `sendemail.smtpPass` values.
87
+ - **Now.** The entry's key decides two more readings, in the same predicate: a key whose variable is a password in git's documentation — `imap.pass`, `sendemail[.<identity>].smtpPass`, `gitcvs[.<method>].dbPass`, `git-p4.password`, and `pass` / `password` under any section (case-folded) — withholds its entry whatever the value; a `credential.helper` / `credential.<url>.helper` value (an empty `<url>`, `credential..helper`, included — git runs it too) withholds its entry unless it only NAMES a helper program: empty, a helper name with option words (`--opt`, `--opt=<word>`, `--opt <word>`, a word being path-class text), or an absolute path with no argument. git hands every helper value to `sh -c`, not only the `!` form: an absolute path with arguments (`/usr/bin/printf username=u\\npassword=<secret>\\n`) and a name followed by more shell (`store; printf …`) answered the password in git 2.54 too, so they withhold as well.
88
+ - **Who is hurt / compensation.** Over-drops (KL-1129a): a Windows helper path (`C:/Program Files/…/git-credential-manager.exe`), a quoted path, a positional argument (`x --opt a b`). The entry is withheld whole and the rest of the record is handed on (#1152), so git runs without that helper. Configure such helpers in a git config file the host owns, or pass the group explicitly (`inheritEnv` / `shellEnv`).
89
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1151 block: four helper spellings × three key spellings with an unscrubbed `git credential fill` control that answers the password, helper references that stay, shell-text spellings that do not, documented password keys against real `git config --get`, non-password look-alikes, 1 MiB linearity).
90
+
91
+ ### Security fix — an `http.extraHeader` entry is withheld whatever the header's name (#1155; existing, not a 7.33.4 regression; behavior narrowing; +0 exports, +0 wire keys) (@server)
92
+
93
+ - **Before.** The value screen recognised only `Authorization:` / `Proxy-Authorization:` / `Cookie:` headers. `http.extraHeader = X-Api-Key: <token>` (likewise `Private-Token:`, `X-Auth-Token:`, `X-Amz-Security-Token:`) reached the child whole and `git config --get-all http.extraheader` answered the token.
94
+ - **Now.** The key decides, in the same place as the password keys: an entry whose key is `http.extraHeader` or `http.<url>.extraHeader` (case-folded, an empty `<url>` included) is withheld whole whatever the header text — git sends it on every request and a credential header can carry any name. The same predicate serves the indexed record, `GIT_CONFIG_PARAMETERS` and non-index suffixes. A header-looking value under any other key is still judged by the value screen.
95
+ - **Who is hurt / compensation.** A deployment that sends a non-credential header through `http.extraHeader` in the environment (a trace or routing header, or the empty `Authorization:` that suppresses git's own header) loses that entry in the child; the rest of the record is handed on (#1152). Compensation as for #1151: configure it in a git config file the host owns, or pass the group explicitly (`inheritEnv` / `shellEnv`).
96
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1155 block: four header names × four key spellings × the indexed record and `GIT_CONFIG_PARAMETERS`, real `git config --get-regexp` before and after, a non-index suffix, `http.extraHeaders` control); `test/backlog1129-git-config-key-family.test.ts` empty-`Authorization:` and key-only extra-header pins re-pinned (intended flip).
97
+
98
+ ### Security fix — `GIT_CONFIG_PARAMETERS` is read as git reads it and withheld whole when any entry carries a credential (#1141; existing since before 7.32.1, KL-1129e closed; behavior narrowing; +0 exports, +0 wire keys) (@server)
99
+
100
+ - **Before.** git's second environment config channel — the single variable `git -c` hands its children, a list of single-quoted items — was in no table and matched no name rule: `GIT_CONFIG_PARAMETERS="'http.extraheader'='Authorization: Bearer <token>'"` reached the child whole and `git config --get http.extraheader` answered the token.
101
+ - **Now.** The value is read with git's own grammar — items separated by space / tab / LF / CR, each `'key'='value'`, `'key'=` or the older `'key=value'` (split at the first `=`), `'\''` / `'\!'` escapes inside the quotes — and every entry is judged by the same predicate as the indexed record (userinfo URL in key or value, credential header, proxy userinfo, password key, inline helper). Any entry that carries a credential withholds the variable whole (`family-rule`; it is one variable, nothing is rewritten). A value git refuses as a bogus format is withheld too (git would have refused to start). Keys are read with surrounding blanks trimmed, in every source: git 2.50 trims a padded legacy key (`'imap.pass =<secret>'`) and runs it, git 2.54 refuses it — the trimmed reading is the one that can run. A clean list is handed on byte for byte. The reading is pinned against the real git binary entry by entry (accepted and refused spellings alike).
102
+ - **Who is hurt / compensation.** A `-c` list that mixes a credential with ordinary settings loses all of them in the child; pass the ordinary ones separately (the indexed record, which is withheld entry by entry) and the credential through a helper the host owns.
103
+ - Pins: `test/backlog1152-git-env-config-sources.test.ts` (#1141 block: a 24-spelling differential against `git config --list -z --show-scope`, seven credential shapes with an unscrubbed git control, byte-identical clean list, refused spelling, case-folded name, 1 MiB linearity).
104
+
1
105
  ## 7.33.4 — 2026-09-27
2
106
 
3
107
  ### Security fix — the git-family URL screen stops parsing the authority: any `@` after a `://` is a credential carrier, and on a proxy key any `@` is (#1146; 7.33.3 follow-up; behavior narrowing; +0 exports, +0 wire keys) (@server)
4
108
 
5
109
  - **Before.** 7.33.2 and 7.33.3 read the URL authority — where it ends, which `:` belongs to a password, whether a `[` opens an IP literal — and each reading lost to an adversarial spelling: `https://[alice:secret/part@host` (a `[` that never closes; host review), `https://[a:dead%25x%oops]/part@host`, `https://[abc:def]secret/part@host`, `https://@user:secret@host`, `ssh://token%40host/r.git`, `https:/u:secret@host` (codex), `https://token/part@host` (no colon). In each the KEY cleared its exemption screen or the VALUE row did not fire, and the git group reached the host subprocess; 7.32.1 had withheld it.
6
110
  - **Now.** One rule, no parsing: a `GIT_CONFIG_KEY_<i>` / `GIT_CONFIG_VALUE_<i>` value holding a `:/` and, anywhere after it, an `@` or its percent-encoding `%40` is a carrier (`family-rule`) — `:/` because curl reads a single slash as a URL (`https:/u:secret@host`), `%40` because git decodes it before handing the user to ssh (`ssh://token%40host/r.git`); under a proxy key (`http.proxy` / `http.<url>.proxy` / `remote.<name>.proxy`) any `@` / `%40` in the value is. Linear.
7
- - **Who is hurt / compensation.** Over-drops, fail-closed (KL-1129b): a path / query / fragment `@` (`https://git.example/@scope/pkg`, `https://[::1]/repo@v2.git`, `https://h/?q=a@b`), an scp absolute path with an `@` (`host:/repo@v2.git`), an empty userinfo, a proxy value with `@` in a query, an `http.extraHeader` like `Referer: https://git.example/@scope`. The git group breaks (git exits 128) and a host's group rule withholds it. Compensation: write such a URL into the repository's config, or percent-encode the `@` (`%40`). Never over-dropped: values with no `:/` (`git@host:org/repo.git`, and `git@host:/abs` where the `@` comes first) and URLs with no `@` / `%40` after the `:/`.
111
+ - **Who is hurt / compensation.** Over-drops, fail-closed (KL-1129b): a path / query / fragment `@` (`https://git.example/@scope/pkg`, `https://[::1]/repo@v2.git`, `https://h/?q=a@b`), an scp absolute path with an `@` (`host:/repo@v2.git`), an empty userinfo, a proxy value with `@` in a query, an `http.extraHeader` like `Referer: https://git.example/@scope`. The git group breaks (git exits 128) and a host's group rule withholds it. Compensation: write such a URL into the repository's config (a `%40` spelling first suggested here is no way out: it trips the same screen — corrected in 7.33.5). Never over-dropped: values with no `:/` (`git@host:org/repo.git`, and `git@host:/abs` where the `@` comes first) and URLs with no `@` / `%40` after the `:/`.
8
112
  - Pins: `test/backlog1129-git-config-key-family.test.ts` (7.33.4 block: the five review spellings on KEY and VALUE, scp / no-`@` controls, proxy `@`, 1 MiB linearity; the 7.33.1–7.33.3 authority pins re-pinned to the over-match reading).
9
113
 
10
114
  ## 7.33.3 — 2026-09-26
@@ -18,16 +18,18 @@
18
18
  */
19
19
  import type { RedactionConfidence, RedactionSource } from "./arg-summary.js";
20
20
  import { type EngineNotice } from "./engine-notice.js";
21
- /** A name table as the exact-name arm reads it: membership of an UPPER-CASED name, given the entry's value when
22
- * the caller has one. A `Set` of exact names is one (it ignores the value); the exemption side is the union of
23
- * its two tables. */
24
- export interface EnvNameTable {
25
- has(upperName: string, value?: string): boolean;
21
+ /** One git config entry as git reads it from the environment. A half is undefined when its source lacks it (a member
22
+ * missing from its index group). */
23
+ interface GitConfigEntry {
24
+ readonly key?: string;
25
+ readonly value?: string;
26
26
  }
27
+ export declare function readGitConfigParameters(text: string): GitConfigEntry[] | undefined;
27
28
  /** Which rule classified a key as secret-shaped — the env arm's `kind` vocabulary. */
28
29
  export type SecretEnvFindingKind = "suffix-rule" | "namespace-rule" | "exact-name" | "family-rule";
29
- /** One env entry the `scrub` policy dropped: the key (the VALUE is never carried — this record is meant
30
- * to be loggable), the rule that classified it, how strong that claim is, and the minting module. */
30
+ /** One env entry the `scrub` policy withheld — dropped, or, for a member of git's config record, handed on holding a
31
+ * placeholder half: the key (the VALUE is never carried — this record is meant to be loggable), the rule that
32
+ * classified it, how strong that claim is, and the minting module. */
31
33
  export interface SecretEnvFinding {
32
34
  key: string;
33
35
  kind: SecretEnvFindingKind;
@@ -36,22 +38,30 @@ export interface SecretEnvFinding {
36
38
  }
37
39
  /** What the two exact-name lists say about a name, before any shape rule is consulted. */
38
40
  export type ExactEnvNameVerdict = "credential" | "exempt" | "unknown";
39
- /** The exact-name arm of the classification, taking both lists as arguments (the exemption side is the exact
40
- * names plus the named families). The credential list is read
41
+ /** The exact-name arm of the classification, taking both lists as arguments. The credential list is read
41
42
  * FIRST: an exemption can never take a name away from it, so a name landing on both lists resolves to the
42
43
  * credential reading — the fail-closed direction (a wrongly-exempted name leaks; a wrongly-dropped one
43
44
  * does not). Module-level export (not part of the package surface): the two real lists are disjoint, so
44
45
  * only a controlled collision can tell this precedence from an inverted one. */
45
- export declare function classifyExactEnvName(key: string, credentialNames: EnvNameTable, exemptNames: EnvNameTable, value?: string): ExactEnvNameVerdict;
46
+ export declare function classifyExactEnvName(key: string, credentialNames: ReadonlySet<string>, exemptNames: ReadonlySet<string>): ExactEnvNameVerdict;
46
47
  /** Is `key` a secret-pattern env var (one the fail-closed `scrub` policy drops before spawning a shell)? Pass the
47
- * entry's `value` to get exactly the scrub's answer for that entry: a named-family exemption holds only for a value
48
- * that clears the family's screen, so without a value such a member reads as secret (the name alone cannot clear). */
49
- export declare function isSecretEnvKey(key: string, value?: string, env?: NodeJS.ProcessEnv): boolean;
48
+ * entry's `value` — and the environment it sits in as `env`, and the child's `platform` — to get exactly the scrub's
49
+ * answer for that entry: a git config member is judged with its index group, which only the environment holds; without
50
+ * a value the name alone decides (a `GIT_CONFIG_KEY_<i>` then reads as secret — the name cannot clear itself).
51
+ * `platform` is as for {@link scrubSecretEnv}. */
52
+ export declare function isSecretEnvKey(key: string, value?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
50
53
  /** Drop every secret-pattern key (see {@link isSecretEnvKey}) from an env object — the fail-closed `scrub`
51
- * policy. `findings` is an OPTIONAL caller-owned collector: pass an array to learn which entries were
52
- * dropped and why, read it back after the call. Omitting it leaves this function byte-for-byte what it
53
- * was before the channel existed. */
54
- export declare function scrubSecretEnv(env: NodeJS.ProcessEnv, findings?: SecretEnvFinding[]): NodeJS.ProcessEnv;
54
+ * policy. git's indexed record is withheld entry by entry and never renumbered ({@link readGitEnvConfigRecord}): a
55
+ * withheld entry git reads keeps its names, each holding its half of the placeholder entry `sema.withheld` (key) / an
56
+ * empty string (value), so `GIT_CONFIG_COUNT` and every other index reach the child as the host wrote them. `findings`
57
+ * is an OPTIONAL caller-owned collector: pass an array to learn which entries were withheld (by name) and why, read it
58
+ * back after the call. A finding's name is absent from the result unless it is such a record member, which is present
59
+ * holding the placeholder half. `platform` is the platform the child runs on — where git reads this environment:
60
+ * Windows reads env names case-insensitively, so there any spelling of a record member is that member; elsewhere git
61
+ * looks up the capitalised names only and every other spelling is judged on its own, apart from the record. It defaults
62
+ * to this process's own platform, the answer for a child spawned on this host; a host whose child runs elsewhere (a
63
+ * remote or sandboxed execution environment) passes that environment's platform. */
64
+ export declare function scrubSecretEnv(env: NodeJS.ProcessEnv, findings?: SecretEnvFinding[], platform?: NodeJS.Platform): NodeJS.ProcessEnv;
55
65
  /** Test seam: never called by production code. */
56
66
  export declare function __resetSecretEnvScrubNoticeAnnouncement(): void;
57
67
  /**
@@ -62,3 +72,4 @@ export declare function __resetSecretEnvScrubNoticeAnnouncement(): void;
62
72
  * findings array is a no-op.
63
73
  */
64
74
  export declare function announceSecretEnvScrub(onNotice: ((notice: EngineNotice) => void) | undefined, findings: readonly SecretEnvFinding[]): void;
75
+ export {};
@@ -1,4 +1,6 @@
1
1
  import { deliverEngineNotice } from "./engine-notice.js";
2
+ import { redactSecrets } from "./untrusted-egress.js";
3
+ import { ANSI_C_ESCAPES, argvOf, lexShell, substitutionsInHeredocBody } from "../shell/lex.js";
2
4
  const TOKEN_COUNT_WORDS = ["MAX", "OUTPUT", "INPUT", "THINKING", "NUM", "TOTAL"];
3
5
  const CREDENTIAL_WORD = `KEY|KEYS|TOKEN|(?<!(?:^|_)(?:${TOKEN_COUNT_WORDS.join("|")})_)TOKENS|SECRET|SECRETS|PASSWORD|PASSWORDS|PASSWD|CREDENTIAL|CREDENTIALS|PRIVATEKEY|APIKEY`;
4
6
  const SECRET_ENV_RE = new RegExp(`(^|_)(${CREDENTIAL_WORD})(?:_(?:\\d+|ID))?$`, "i");
@@ -15,11 +17,15 @@ const NON_SECRET_ENV_EXACT_NAMES = new Set([
15
17
  "MAX_BATCH_PREFILL_TOKENS", "MAX_WAITING_TOKENS", "MAX_TOP_N_TOKENS", "RATE_LIMIT_TOKENS",
16
18
  "PROMPT_TOKENS", "CONTEXT_TOKENS",
17
19
  ]);
18
- function carriesUrlUserinfo(value) {
19
- const scheme = value.indexOf(":/");
20
+ const SSH_LOGIN_ONLY_URL_RE = /^(?:ssh|git\+ssh|git):\/\/[A-Za-z0-9._-]+@[^@]*$/;
21
+ const GIT_KEY_SECTION_RE = /^[A-Za-z0-9-]+\./;
22
+ function carriesUrlUserinfo(text, url) {
23
+ const scheme = text.indexOf(":/");
20
24
  if (scheme === -1)
21
25
  return false;
22
- return value.indexOf("@", scheme + 2) !== -1 || value.indexOf("%40", scheme + 2) !== -1;
26
+ if (text.indexOf("@", scheme + 2) === -1 && text.indexOf("%40", scheme + 2) === -1)
27
+ return false;
28
+ return url === undefined || url.includes("%40") || !SSH_LOGIN_ONLY_URL_RE.test(url);
23
29
  }
24
30
  const HEADER_LINE_BREAK_RE = /\r\n|\r|\n/;
25
31
  const AUTHORIZATION_HEADER_NAME_RE = /^[ \t]*(?:(?:proxy-)?authorization|cookie)[ \t]*:/i;
@@ -38,86 +44,561 @@ function carriesAuthorizationHeader(value) {
38
44
  }
39
45
  return judge();
40
46
  }
41
- const GIT_PROXY_CONFIG_KEY_RE = /^(?:http(?:\..+)?|remote\..+)\.proxy$/is;
47
+ const GIT_PROXY_CONFIG_KEY_RE = /^(?:http(?:\..*)?|remote\..*)\.proxy$/is;
42
48
  const proxyValueCarriesUserinfo = (value) => value.includes("@") || value.includes("%40");
43
- const NON_SECRET_ENV_INDEXED_FAMILIES = [
44
- { stem: "GIT_CONFIG_KEY", valueClears: (value) => !carriesUrlUserinfo(value) },
45
- ];
46
- const SECRET_ENV_INDEXED_FAMILIES = [
47
- {
48
- stem: "GIT_CONFIG_VALUE",
49
- credentialWhen: (value, group) => {
50
- if (carriesAuthorizationHeader(value) || carriesUrlUserinfo(value))
49
+ const GIT_SECRET_VARIABLE_NAMES = new Set(["pass", "password", "smtppass", "dbpass", "token", "secret"]);
50
+ const GIT_USER_NAMED_VARIABLE_KEY_RE = /^(?:alias|pretty|remotes|pager)\.[^.]*$/is;
51
+ const GIT_EXTRA_HEADER_KEY_RE = /^http\.(?:.*\.)?extraheader$/is;
52
+ const gitKeyHoldsCredential = (key) => (!GIT_USER_NAMED_VARIABLE_KEY_RE.test(key) && GIT_SECRET_VARIABLE_NAMES.has(key.slice(key.lastIndexOf(".") + 1).toLowerCase())) || GIT_EXTRA_HEADER_KEY_RE.test(key);
53
+ const GIT_COMMAND_KEY_RE = /^core\.(?:sshcommand|gitproxy)$/i;
54
+ const GIT_ALIAS_KEY_RE = /^alias\.(?:[^.]*|.*\.command)$/is;
55
+ const GIT_CREDENTIAL_HELPER_KEY_RE = /^credential\.(?:.*\.)?helper$/is;
56
+ function gitShellCommandOf(key, value) {
57
+ if (GIT_COMMAND_KEY_RE.test(key))
58
+ return value;
59
+ if (GIT_CREDENTIAL_HELPER_KEY_RE.test(key))
60
+ return value.startsWith("!") ? value.slice(1) : value;
61
+ if (!GIT_ALIAS_KEY_RE.test(key))
62
+ return undefined;
63
+ return value.startsWith("!") ? value.slice(1) : `git ${value}`;
64
+ }
65
+ const EXPANSION_OPEN = "\u0002";
66
+ const EXPANSION_CLOSE = "\u0003";
67
+ const EXPANSION_RE = /\u0002([^\u0003]*)\u0003/g;
68
+ function decodeCEscapes(text, octal) {
69
+ if (!text.includes("\\"))
70
+ return text;
71
+ const simple = ANSI_C_ESCAPES;
72
+ const run = (re, from, max) => {
73
+ let j = from;
74
+ while (j < text.length && j - from < max && re.test(text.charAt(j)))
75
+ j++;
76
+ return text.slice(from, j);
77
+ };
78
+ let out = "";
79
+ for (let i = 0; i < text.length; i++) {
80
+ const c = text.charAt(i);
81
+ if (c !== "\\" || i + 1 >= text.length) {
82
+ out += c;
83
+ continue;
84
+ }
85
+ const e = text.charAt(i + 1);
86
+ let digits;
87
+ if (simple[e] !== undefined) {
88
+ out += simple[e];
89
+ i++;
90
+ }
91
+ else if (e === "c" && i + 2 < text.length) {
92
+ out += String.fromCharCode(text.charCodeAt(i + 2) & 0x1f);
93
+ i += 2;
94
+ }
95
+ else if ((e === "x" || e === "u" || e === "U") && (digits = run(/[0-9A-Fa-f]/, i + 2, e === "x" ? 2 : e === "u" ? 4 : 8)) !== "") {
96
+ out += String.fromCodePoint(Math.min(parseInt(digits, 16), 0x10ffff));
97
+ i += 1 + digits.length;
98
+ }
99
+ else if (/[0-7]/.test(e)) {
100
+ const from = octal === "echo" && e === "0" ? i + 2 : i + 1;
101
+ digits = run(/[0-7]/, from, 3);
102
+ out += digits === "" ? "\0" : String.fromCharCode(parseInt(digits, 8) & 0xff);
103
+ i = from + digits.length - 1;
104
+ }
105
+ else
106
+ out += c;
107
+ }
108
+ return out;
109
+ }
110
+ function markedText(word, source) {
111
+ let out = "";
112
+ for (const piece of word.pieces) {
113
+ if (piece.literal !== undefined)
114
+ out += piece.literal;
115
+ else if (piece.expansion !== undefined) {
116
+ const { kind, name, body = "" } = piece.expansion;
117
+ const written = kind === "parameter" ? /^[A-Za-z_][A-Za-z0-9_]*:?[-=](.+)$/s.exec(body)?.[1] : undefined;
118
+ out += written ?? EXPANSION_OPEN + (kind === "parameter" && name !== undefined && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : "") + EXPANSION_CLOSE;
119
+ }
120
+ else {
121
+ const text = source.slice(piece.span[0], piece.span[1]);
122
+ out += decodeCEscapes(/^\$'.*'$/s.test(text) ? text.slice(2, -1) : text, "printf");
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+ const SECRET_OPTION_WORDS = new Set(["token", "password", "passwd", "pass", "passphrase", "secret", "pw", "auth", "oauth", "apikey", "credential"]);
128
+ const ASSIGNMENT_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
129
+ const PRINTF_CONVERSION_RE = /%(?:%|[-+ #0']*(\*|[0-9]*)(?:\.(\*|[0-9]*))?[A-Za-z])/g;
130
+ function printfFormatIn(words) {
131
+ const program = words.findIndex((w) => w.slice(w.lastIndexOf("/") + 1) === "printf");
132
+ if (program === -1)
133
+ return undefined;
134
+ let at = program + 1;
135
+ while (words[at] === "-v" || words[at] === "--")
136
+ at += words[at] === "-v" ? 2 : 1;
137
+ const format = words[at];
138
+ if (format === undefined)
139
+ return undefined;
140
+ let count = 0;
141
+ const marked = format.replace(PRINTF_CONVERSION_RE, (conversion, width, precision) => {
142
+ if (conversion === "%%")
143
+ return "%";
144
+ count += (width === "*" ? 1 : 0) + (precision === "*" ? 1 : 0);
145
+ return `${EXPANSION_OPEN}#${count++}${EXPANSION_CLOSE}`;
146
+ });
147
+ return { at, marked: decodeCEscapes(marked, "printf"), count };
148
+ }
149
+ const SHELL_PROGRAMS = new Set(["sh", "bash", "dash", "zsh", "ksh", "mksh", "ash"]);
150
+ const SSH_ARGUMENT_OPTIONS = "BbcDEeFIiJLlmOoPpQRSWw";
151
+ const SSH_COMMAND_OPTION_RE = /^(?:proxy|local|knownhosts|remote)command(?:\s*=\s*|\s+)(.*)$/is;
152
+ function commandCarriesCredential(command, depth = 0, inherited = new Set()) {
153
+ const source = depth === 0 ? command.replace(/[\u0002\u0003]/g, "\ufffd") : command;
154
+ const lexed = lexShell(source);
155
+ if ("parseFailure" in lexed)
156
+ return true;
157
+ const segments = lexed.segments.map((segment) => argvOf(segment).map((word) => markedText(word, source)));
158
+ const formats = segments.map((words) => {
159
+ const format = printfFormatIn(words);
160
+ if (format !== undefined)
161
+ words[format.at] = format.marked;
162
+ return format;
163
+ });
164
+ const bodies = [];
165
+ const lines = [];
166
+ for (const segment of lexed.segments) {
167
+ for (const token of segment.tokens) {
168
+ const word = token.kind === "word" ? token : token.kind === "redirect" ? token.target : undefined;
169
+ for (const piece of word?.expansions ?? [])
170
+ if ((piece.expansion?.kind === "command" || piece.expansion?.kind === "process") && piece.expansion.body !== undefined)
171
+ bodies.push(piece.expansion.body);
172
+ if (token.kind !== "heredoc")
173
+ continue;
174
+ lines.push(...token.body.split(/[\n\r]/));
175
+ if (!token.quoted)
176
+ bodies.push(...(substitutionsInHeredocBody(token.body) ?? []).filter((s) => s.kind === "command").map((s) => s.body));
177
+ }
178
+ }
179
+ const words = segments.flat();
180
+ const shapeText = (text) => text.replace(EXPANSION_RE, ";").replace(/[\n\r]/g, ";");
181
+ if (carriesSecretShape(`${words.map(shapeText).join(" ")};${lines.map(shapeText).join(";")}`))
182
+ return true;
183
+ const literalNames = new Set(inherited);
184
+ const dependents = new Map();
185
+ const reached = [...inherited];
186
+ for (const word of words) {
187
+ const assigned = ASSIGNMENT_RE.exec(word);
188
+ if (assigned === null)
189
+ continue;
190
+ const [, name = "", value = ""] = assigned;
191
+ if (value.replace(EXPANSION_RE, "") !== "") {
192
+ if (!literalNames.has(name))
193
+ reached.push(name);
194
+ literalNames.add(name);
195
+ }
196
+ else {
197
+ for (const [, ref = ""] of value.matchAll(EXPANSION_RE)) {
198
+ const list = dependents.get(ref);
199
+ if (list === undefined)
200
+ dependents.set(ref, [name]);
201
+ else
202
+ list.push(name);
203
+ }
204
+ }
205
+ }
206
+ for (let q = 0; q < reached.length; q++) {
207
+ for (const name of dependents.get(reached[q] ?? "") ?? []) {
208
+ if (!literalNames.has(name))
209
+ reached.push(name);
210
+ literalNames.add(name);
211
+ }
212
+ }
213
+ const written = (text) => text.replace(EXPANSION_RE, "") !== "" || [...text.matchAll(EXPANSION_RE)].some((m) => literalNames.has(m[1] ?? ""));
214
+ const runs = (line) => depth < 3 && commandCarriesCredential(line, depth + 1, literalNames);
215
+ const secretLine = (line) => {
216
+ const assigned = ASSIGNMENT_RE.exec(line);
217
+ return assigned !== null && written(assigned[2] ?? "") && classifySecretEnvKey(assigned[1] ?? "") !== undefined;
218
+ };
219
+ if (lines.some(secretLine))
220
+ return true;
221
+ const printfCarries = (format, words) => {
222
+ const argument = (n) => words[format.at + 1 + n] ?? "";
223
+ const firstPass = format.marked.replace(EXPANSION_RE, (mark, ref) => {
224
+ const conversion = /^#([0-9]+)$/.exec(ref);
225
+ return conversion === null ? mark : argument(Number(conversion[1]));
226
+ });
227
+ if (firstPass.split(/[\n\r]/).some(secretLine))
228
+ return true;
229
+ for (const line of format.marked.split(/[\n\r]/)) {
230
+ const assigned = ASSIGNMENT_RE.exec(line);
231
+ if (assigned === null || classifySecretEnvKey(assigned[1] ?? "") === undefined)
232
+ continue;
233
+ for (const [, ref = ""] of (assigned[2] ?? "").matchAll(EXPANSION_RE)) {
234
+ const conversion = /^#([0-9]+)$/.exec(ref);
235
+ if (conversion === null)
236
+ continue;
237
+ for (let n = Number(conversion[1]) + format.count; format.count > 0 && format.at + 1 + n < words.length; n += format.count)
238
+ if (written(argument(n)))
239
+ return true;
240
+ }
241
+ }
242
+ return false;
243
+ };
244
+ if (segments.some((words, i) => formats[i] !== undefined && printfCarries(formats[i], words)))
245
+ return true;
246
+ for (const [segment, segmentWords] of segments.entries()) {
247
+ const formatAt = formats[segment]?.at;
248
+ let sshpassOptions = false;
249
+ let shellOptions = false;
250
+ let sshOptions = false;
251
+ let gitOptions = false;
252
+ let pending;
253
+ for (const [position, word] of segmentWords.entries()) {
254
+ const waiting = pending;
255
+ pending = undefined;
256
+ if (waiting === "skip")
257
+ continue;
258
+ if (waiting === "command" && runs(word))
51
259
  return true;
52
- const key = group.sibling("GIT_CONFIG_KEY");
53
- return key !== undefined && GIT_PROXY_CONFIG_KEY_RE.test(key) && proxyValueCarriesUserinfo(value);
54
- },
55
- },
56
- ];
57
- const indexedFamilyMember = (stem) => {
58
- if (!/^[A-Z0-9_]+$/.test(stem))
59
- throw new Error(`secret-env: indexed family stem ${JSON.stringify(stem)} must match [A-Z0-9_]+`);
60
- return new RegExp(`^${stem}_(0|[1-9][0-9]*)$`);
61
- };
62
- const NON_SECRET_ENV_INDEXED_FAMILY_ROWS = NON_SECRET_ENV_INDEXED_FAMILIES.map((f) => ({ member: indexedFamilyMember(f.stem), valueClears: f.valueClears }));
63
- const SECRET_ENV_INDEXED_FAMILY_ROWS = SECRET_ENV_INDEXED_FAMILIES.map((f) => ({ member: indexedFamilyMember(f.stem), credentialWhen: f.credentialWhen }));
64
- function classifyByFamily(upperName, value, env) {
65
- if (value === undefined)
260
+ if (waiting === "ssh-option") {
261
+ const run = SSH_COMMAND_OPTION_RE.exec(word);
262
+ if (run !== null && runs(run[1] ?? ""))
263
+ return true;
264
+ continue;
265
+ }
266
+ if (waiting === "git-config") {
267
+ const eq = word.indexOf("=");
268
+ if (depth < 3 && eq > 0 && gitConfigEntryCarriesCredential({ key: word.slice(0, eq), value: word.slice(eq + 1) }, depth + 1))
269
+ return true;
270
+ continue;
271
+ }
272
+ if (waiting === "sshpass-password") {
273
+ if (written(word))
274
+ return true;
275
+ continue;
276
+ }
277
+ if (waiting === "option-value" && !word.startsWith("-")) {
278
+ if (written(word))
279
+ return true;
280
+ continue;
281
+ }
282
+ for (const view of position === formatAt ? [word] : [word, decodeCEscapes(word, "printf"), decodeCEscapes(word, "echo")]) {
283
+ if (view.split(/[\n\r]/).some(secretLine))
284
+ return true;
285
+ }
286
+ if (sshpassOptions) {
287
+ if (word === "--") {
288
+ sshpassOptions = false;
289
+ continue;
290
+ }
291
+ if (word.startsWith("--")) {
292
+ if (word === "--password")
293
+ pending = "sshpass-password";
294
+ else if (word.startsWith("--password=") && written(word.slice("--password=".length)))
295
+ return true;
296
+ continue;
297
+ }
298
+ if (word.startsWith("-") && word.length > 1) {
299
+ for (let k = 1; k < word.length; k++) {
300
+ const letter = word.charAt(k);
301
+ if (letter === "p") {
302
+ if (k === word.length - 1)
303
+ pending = "sshpass-password";
304
+ else if (written(word.slice(k + 1)))
305
+ return true;
306
+ break;
307
+ }
308
+ if (letter === "f" || letter === "d" || letter === "P") {
309
+ if (k === word.length - 1)
310
+ pending = "skip";
311
+ break;
312
+ }
313
+ }
314
+ continue;
315
+ }
316
+ sshpassOptions = false;
317
+ }
318
+ if (shellOptions) {
319
+ if (/^--[A-Za-z-]+$/.test(word))
320
+ continue;
321
+ if (/^[-+][A-Za-z]+$/.test(word)) {
322
+ if (word.startsWith("-") && word.includes("c"))
323
+ pending = "command";
324
+ else if (/o$/.test(word))
325
+ pending = "skip";
326
+ continue;
327
+ }
328
+ shellOptions = false;
329
+ }
330
+ if (gitOptions) {
331
+ if (word === "-c") {
332
+ pending = "git-config";
333
+ continue;
334
+ }
335
+ if (word.startsWith("-")) {
336
+ if (["-C", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env", "--super-prefix", "--list-cmds", "--attr-source"].includes(word))
337
+ pending = "skip";
338
+ continue;
339
+ }
340
+ gitOptions = false;
341
+ }
342
+ if (sshOptions) {
343
+ if (/^-[A-Za-z0-9]+/.test(word) && !word.startsWith("--")) {
344
+ for (let k = 1; k < word.length; k++) {
345
+ const letter = word.charAt(k);
346
+ if (!SSH_ARGUMENT_OPTIONS.includes(letter))
347
+ continue;
348
+ const attached = word.slice(k + 1);
349
+ if (letter === "o") {
350
+ if (attached === "")
351
+ pending = "ssh-option";
352
+ else {
353
+ const run = SSH_COMMAND_OPTION_RE.exec(attached);
354
+ if (run !== null && runs(run[1] ?? ""))
355
+ return true;
356
+ }
357
+ }
358
+ else if (attached === "")
359
+ pending = "skip";
360
+ break;
361
+ }
362
+ continue;
363
+ }
364
+ sshOptions = false;
365
+ }
366
+ const option = /^--?([A-Za-z0-9][A-Za-z0-9_-]*)(?:=(.*))?$/s.exec(word);
367
+ if (option !== null && SECRET_OPTION_WORDS.has((option[1] ?? "").toLowerCase().split(/[-_]/).pop() ?? "")) {
368
+ if (option[2] === undefined)
369
+ pending = "option-value";
370
+ else if (written(option[2]))
371
+ return true;
372
+ }
373
+ const program = word.slice(word.lastIndexOf("/") + 1).replace(/\.exe$/i, "");
374
+ if (program === "sshpass")
375
+ sshpassOptions = true;
376
+ else if (SHELL_PROGRAMS.has(program))
377
+ shellOptions = true;
378
+ else if (program === "ssh")
379
+ sshOptions = true;
380
+ else if (program === "git")
381
+ gitOptions = true;
382
+ else if (program === "eval" || word.toLowerCase() === "-proxycmd")
383
+ pending = "command";
384
+ }
385
+ }
386
+ return bodies.some((body) => runs(body));
387
+ }
388
+ const GIT_REF_VALUE_KEY_RE = /^(?:remote\..*\.(?:fetch|push)|branch\..*\.merge)$/is;
389
+ function carriesSecretShape(value) {
390
+ const view = value.replaceAll(":", "\u0001");
391
+ return redactSecrets(view) !== view;
392
+ }
393
+ function gitConfigEntryCarriesCredential(entry, depth = 0) {
394
+ const key = entry.key?.trim();
395
+ const value = entry.value;
396
+ if (key !== undefined) {
397
+ const section = GIT_KEY_SECTION_RE.exec(key);
398
+ if (carriesUrlUserinfo(key, section === null ? undefined : key.slice(section[0].length)))
399
+ return true;
400
+ }
401
+ if (value === undefined || value === "")
402
+ return false;
403
+ if (carriesAuthorizationHeader(value) || carriesUrlUserinfo(value, value))
404
+ return true;
405
+ if (key === undefined)
406
+ return carriesSecretShape(value);
407
+ if (gitKeyHoldsCredential(key))
408
+ return true;
409
+ const command = gitShellCommandOf(key, value);
410
+ if (command !== undefined)
411
+ return commandCarriesCredential(command, depth);
412
+ if (!GIT_REF_VALUE_KEY_RE.test(key) && carriesSecretShape(value))
413
+ return true;
414
+ return GIT_PROXY_CONFIG_KEY_RE.test(key) && proxyValueCarriesUserinfo(value);
415
+ }
416
+ const PROXY_ENV_NAMES = new Set(["HTTP_PROXY", "HTTPS_PROXY", "FTP_PROXY", "ALL_PROXY", "NPM_CONFIG_PROXY", "NPM_CONFIG_HTTPS_PROXY"]);
417
+ const GIT_COMMAND_ENV_KEYS = new Map([["GIT_SSH_COMMAND", "core.sshCommand"], ["GIT_PROXY_COMMAND", "core.gitProxy"]]);
418
+ const GIT_BLANK = /[ \t\n\r]/;
419
+ export function readGitConfigParameters(text) {
420
+ const entries = [];
421
+ let at = 0;
422
+ const quoted = () => {
423
+ if (text[at] !== "'")
424
+ return undefined;
425
+ let word = "";
426
+ for (let i = at + 1; i < text.length; i++) {
427
+ if (text[i] !== "'")
428
+ word += text[i];
429
+ else if (text[i + 1] === "\\" && (text[i + 2] === "'" || text[i + 2] === "!") && text[i + 3] === "'") {
430
+ word += text[i + 2];
431
+ i += 3;
432
+ }
433
+ else {
434
+ at = i + 1;
435
+ return word;
436
+ }
437
+ }
66
438
  return undefined;
67
- for (const row of SECRET_ENV_INDEXED_FAMILY_ROWS) {
68
- const member = row.member.exec(upperName);
439
+ };
440
+ const endsItem = () => at === text.length || GIT_BLANK.test(text.charAt(at));
441
+ while (at < text.length) {
442
+ const key = quoted();
443
+ if (key === undefined)
444
+ return undefined;
445
+ if (endsItem()) {
446
+ const eq = key.indexOf("=");
447
+ entries.push(eq === -1 ? { key } : { key: key.slice(0, eq), value: key.slice(eq + 1) });
448
+ }
449
+ else if (text[at] === "=") {
450
+ at++;
451
+ if (endsItem())
452
+ entries.push({ key });
453
+ else {
454
+ const value = quoted();
455
+ if (value === undefined || !endsItem())
456
+ return undefined;
457
+ entries.push({ key, value });
458
+ }
459
+ }
460
+ else
461
+ return undefined;
462
+ while (at < text.length && GIT_BLANK.test(text.charAt(at)))
463
+ at++;
464
+ }
465
+ return entries;
466
+ }
467
+ function gitConfigVariableCarriesCredential(upperName, value) {
468
+ if (upperName === "GIT_CONFIG_PARAMETERS")
469
+ return readGitConfigParameters(value)?.some(gitConfigEntryCarriesCredential) ?? true;
470
+ const key = PROXY_ENV_NAMES.has(upperName) ? "http.proxy" : GIT_COMMAND_ENV_KEYS.get(upperName);
471
+ return key !== undefined && gitConfigEntryCarriesCredential({ key, value });
472
+ }
473
+ const GIT_CONFIG_COUNT_NAME = "GIT_CONFIG_COUNT";
474
+ const GIT_CONFIG_MEMBER_RE = /^GIT_CONFIG_(KEY|VALUE)_(.*)$/s;
475
+ const GIT_CONFIG_INDEX_RE = /^(?:0|[1-9][0-9]*)$/;
476
+ const GIT_CONFIG_COUNT_RE = /^[ \t\n\v\f\r]*([+-]?)([0-9]+)$/;
477
+ function readGitConfigCount(value, platform) {
478
+ if (value === "")
479
+ return 0n;
480
+ const number = GIT_CONFIG_COUNT_RE.exec(value);
481
+ if (number === null)
482
+ return undefined;
483
+ const [, sign, digits = ""] = number;
484
+ const significant = digits.replace(/^0+/, "");
485
+ const unsignedLongMax = platform === "win32" ? 2n ** 32n - 1n : 2n ** 64n - 1n;
486
+ const magnitude = significant.length > 20 ? unsignedLongMax : BigInt(significant === "" ? "0" : significant);
487
+ const read = magnitude > unsignedLongMax ? unsignedLongMax : sign === "-" ? (unsignedLongMax + 1n - magnitude) % (unsignedLongMax + 1n) : magnitude;
488
+ return read <= 2n ** 31n - 1n ? read : undefined;
489
+ }
490
+ const GIT_CONFIG_PLACEHOLDER = { key: "sema.withheld", value: "" };
491
+ function readGitEnvConfigRecord(env, platform) {
492
+ const withheld = new Map();
493
+ const kept = new Set();
494
+ const countNames = [];
495
+ const indexed = new Map();
496
+ const loose = new Map();
497
+ for (const [name, text] of Object.entries(env)) {
498
+ if (text === undefined)
499
+ continue;
500
+ const upper = name.toUpperCase();
501
+ const lookedUp = platform === "win32" ? upper : name;
502
+ if (lookedUp === GIT_CONFIG_COUNT_NAME) {
503
+ countNames.push(name);
504
+ continue;
505
+ }
506
+ const member = GIT_CONFIG_MEMBER_RE.exec(upper);
69
507
  if (member === null)
70
508
  continue;
71
- const index = member[1];
72
- if (row.credentialWhen(value, { sibling: (stem) => env?.[`${stem}_${index}`] }))
73
- return "credential";
509
+ const [, role, suffix = ""] = member;
510
+ const half = role === "KEY" ? "key" : "value";
511
+ const groups = GIT_CONFIG_MEMBER_RE.test(lookedUp) && GIT_CONFIG_INDEX_RE.test(suffix) ? indexed : loose;
512
+ const group = groups.get(suffix) ?? { members: [], twice: false };
513
+ groups.set(suffix, group);
514
+ group.members.push([name, half]);
515
+ if (group[half] !== undefined)
516
+ group.twice = true;
517
+ group[half] = text;
518
+ }
519
+ const withhold = (g, standIn) => {
520
+ for (const [n, half] of g.members)
521
+ withheld.set(n, standIn ? GIT_CONFIG_PLACEHOLDER[half] : undefined);
522
+ };
523
+ for (const g of loose.values()) {
524
+ if (g.twice || gitConfigEntryCarriesCredential(g))
525
+ withhold(g, false);
526
+ else if (g.members.some(([n]) => classifySecretEnvKey(n) !== undefined)) {
527
+ for (const [n] of g.members)
528
+ if (classifySecretEnvKey(n) === undefined)
529
+ withheld.set(n, undefined);
530
+ }
531
+ }
532
+ const record = [...indexed].map(([suffix, g]) => [BigInt(suffix), g]);
533
+ const [countName, secondCountName] = countNames;
534
+ if (secondCountName !== undefined || record.some(([, g]) => g.twice)) {
535
+ for (const n of countNames)
536
+ withheld.set(n, undefined);
537
+ for (const [, g] of record)
538
+ withhold(g, false);
539
+ return { withheld, kept };
74
540
  }
75
- if (NON_SECRET_ENV_INDEXED_FAMILY_ROWS.some((row) => row.member.test(upperName) && !row.valueClears(value)))
76
- return "screen-failed";
77
- return undefined;
541
+ const count = countName === undefined ? 0n : readGitConfigCount(env[countName] ?? "", platform);
542
+ for (const n of countNames)
543
+ kept.add(n);
544
+ for (const [index, g] of record) {
545
+ if (gitConfigEntryCarriesCredential(g))
546
+ withhold(g, count === undefined || index < count);
547
+ else
548
+ for (const [n] of g.members)
549
+ kept.add(n);
550
+ }
551
+ return { withheld, kept };
78
552
  }
79
- const NON_SECRET_ENV_NAMES = {
80
- has: (upperName, value) => NON_SECRET_ENV_EXACT_NAMES.has(upperName) ||
81
- (value !== undefined && NON_SECRET_ENV_INDEXED_FAMILY_ROWS.some((row) => row.member.test(upperName) && row.valueClears(value))),
82
- };
83
553
  const SECRET_ENV_KIND_CONFIDENCE = {
84
554
  "exact-name": "high",
85
555
  "suffix-rule": "medium",
86
556
  "namespace-rule": "medium",
87
557
  "family-rule": "high",
88
558
  };
89
- export function classifyExactEnvName(key, credentialNames, exemptNames, value) {
559
+ export function classifyExactEnvName(key, credentialNames, exemptNames) {
90
560
  const upper = key.toUpperCase();
91
- if (credentialNames.has(upper, value))
561
+ if (credentialNames.has(upper))
92
562
  return "credential";
93
- if (exemptNames.has(upper, value))
563
+ if (exemptNames.has(upper))
94
564
  return "exempt";
95
565
  return "unknown";
96
566
  }
97
- function classifySecretEnvKey(key, value, env) {
98
- const byExactName = classifyExactEnvName(key, SECRET_ENV_EXACT_NAMES, NON_SECRET_ENV_NAMES, value);
567
+ function classifySecretEnvKey(key) {
568
+ const byExactName = classifyExactEnvName(key, SECRET_ENV_EXACT_NAMES, NON_SECRET_ENV_EXACT_NAMES);
99
569
  if (byExactName === "credential")
100
570
  return "exact-name";
101
571
  if (byExactName === "exempt")
102
572
  return undefined;
103
- const byFamily = classifyByFamily(key.toUpperCase(), value, env);
104
- if (byFamily === "credential")
105
- return "family-rule";
106
573
  if (SECRET_ENV_RE.test(key))
107
- return byFamily === "screen-failed" ? "family-rule" : "suffix-rule";
574
+ return "suffix-rule";
108
575
  return SEMA_NAMESPACE_SECRET_RE.test(key) ? "namespace-rule" : undefined;
109
576
  }
110
- export function isSecretEnvKey(key, value, env) {
111
- return classifySecretEnvKey(key, value, env) !== undefined;
577
+ function classifyEnvEntry(key, value, record) {
578
+ if (record.withheld.has(key))
579
+ return "family-rule";
580
+ if (record.kept.has(key))
581
+ return undefined;
582
+ if (value !== undefined && gitConfigVariableCarriesCredential(key.toUpperCase(), value))
583
+ return "family-rule";
584
+ return classifySecretEnvKey(key);
585
+ }
586
+ export function isSecretEnvKey(key, value, env, platform = process.platform) {
587
+ const record = readGitEnvConfigRecord(value === undefined ? {} : { ...env, [key]: value }, platform);
588
+ return classifyEnvEntry(key, value, record) !== undefined;
112
589
  }
113
- export function scrubSecretEnv(env, findings) {
590
+ export function scrubSecretEnv(env, findings, platform = process.platform) {
591
+ const record = readGitEnvConfigRecord(env, platform);
114
592
  const out = {};
115
593
  for (const k of Object.keys(env)) {
116
- const kind = classifySecretEnvKey(k, env[k], env);
594
+ const kind = classifyEnvEntry(k, env[k], record);
117
595
  if (kind === undefined) {
118
596
  out[k] = env[k];
119
597
  continue;
120
598
  }
599
+ const standIn = record.withheld.get(k);
600
+ if (standIn !== undefined)
601
+ out[k] = standIn;
121
602
  if (findings !== undefined) {
122
603
  findings.push({ key: k, kind, confidence: SECRET_ENV_KIND_CONFIDENCE[kind], source: "secret-env" });
123
604
  }
@@ -215,7 +215,7 @@ function getShellEnv(inheritEnv, baseEnv, extraEnv, onScrub, onNotice) {
215
215
  }
216
216
  else {
217
217
  const scrubbed = [];
218
- inherited = scrubSecretEnv(process.env, scrubbed);
218
+ inherited = scrubSecretEnv(process.env, scrubbed, process.platform);
219
219
  const findings = scrubbed.filter((f) => baseEnv?.[f.key] === undefined && extraEnv?.[f.key] === undefined);
220
220
  if (findings.length > 0) {
221
221
  if (typeof onScrub === "function") {
@@ -336,7 +336,7 @@ const lspSpawnWithEnv = (env, onNotice) => (command, args, cwd, signal) => new P
336
336
  let scrubbedEnv = env;
337
337
  if (scrubbedEnv == null) {
338
338
  const findings = [];
339
- scrubbedEnv = scrubSecretEnv(process.env, findings);
339
+ scrubbedEnv = scrubSecretEnv(process.env, findings, process.platform);
340
340
  announceSecretEnvScrub(onNotice, findings);
341
341
  }
342
342
  child = spawn(command, args, { ...lspSpawnOptions(cwd), env: scrubbedEnv });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.33.4",
3
+ "version": "7.33.6",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,56 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.33.5",
4
+ "from": "4de94d5942047def6b1f2a28251b21d7180a2167",
5
+ "to": "v7.33.5",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [],
10
+ "closedSetMembers": [],
11
+ "closedSetsUnreadable": [],
12
+ "typeKeys": [],
13
+ "typeKeysUnreadableArms": [],
14
+ "typeKeysFlattenedCount": 217,
15
+ "declarationMoves": [],
16
+ "mintMoves": [],
17
+ "criteriaObserves": [
18
+ {
19
+ "kind": "typeMember",
20
+ "name": "SecretEnvFinding.key",
21
+ "assertions": [
22
+ "G1.a",
23
+ "G2.a",
24
+ "G4.a",
25
+ "G5.a"
26
+ ]
27
+ },
28
+ {
29
+ "kind": "typeMember",
30
+ "name": "SecretEnvFinding.kind",
31
+ "assertions": [
32
+ "G1.a",
33
+ "G1.b",
34
+ "G2.a",
35
+ "G2.b",
36
+ "G3.a",
37
+ "G3.b",
38
+ "G4.a",
39
+ "G4.b",
40
+ "G5.a",
41
+ "G5.b",
42
+ "G6.a",
43
+ "G6.b"
44
+ ]
45
+ }
46
+ ],
47
+ "consumersHint": {
48
+ "cli": [],
49
+ "server": [],
50
+ "sdk": [],
51
+ "note": "scanned cli@4e593722 server@20df4e92 sdk@10cf14c — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
52
+ },
53
+ "breakingCount": 0,
54
+ "breakingItems": [],
55
+ "toResolvedAt": "97f5096b12ed068bb937b1f5981bd0fb82bb3584"
56
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.33.6",
4
+ "from": "2341b7826a837ce1786280ed3a9b1e1d656b1d41",
5
+ "to": "v7.33.6",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [],
10
+ "closedSetMembers": [],
11
+ "closedSetsUnreadable": [],
12
+ "typeKeys": [],
13
+ "typeKeysUnreadableArms": [],
14
+ "typeKeysFlattenedCount": 217,
15
+ "declarationMoves": [],
16
+ "mintMoves": [],
17
+ "criteriaObserves": [
18
+ {
19
+ "kind": "typeMember",
20
+ "name": "SecretEnvFinding.key",
21
+ "assertions": [
22
+ "G1.a",
23
+ "G2.a",
24
+ "G5.a"
25
+ ]
26
+ },
27
+ {
28
+ "kind": "typeMember",
29
+ "name": "SecretEnvFinding.kind",
30
+ "assertions": [
31
+ "G1.a",
32
+ "G1.b",
33
+ "G2.a",
34
+ "G2.b",
35
+ "G3.a",
36
+ "G3.b",
37
+ "G4.a",
38
+ "G4.b",
39
+ "G5.b",
40
+ "G6.a",
41
+ "G6.b",
42
+ "G7.a",
43
+ "G7.b",
44
+ "G8.a",
45
+ "G9.a",
46
+ "G10.a",
47
+ "G10.b",
48
+ "G10.c",
49
+ "G10.d",
50
+ "G11.a"
51
+ ]
52
+ }
53
+ ],
54
+ "consumersHint": {
55
+ "cli": [],
56
+ "server": [],
57
+ "sdk": [],
58
+ "note": "scanned cli@4e593722 server@46b7fe60 sdk@10cf14c — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
59
+ },
60
+ "breakingCount": 0,
61
+ "breakingItems": [],
62
+ "toResolvedAt": "2f7a1e0a72b2b8c6af18f9fb9de3620ec80c0cf6"
63
+ }