@sema-agent/core 7.33.5 → 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,3 +1,68 @@
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
+
1
66
  ## 7.33.5 — 2026-09-27
2
67
 
3
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)
@@ -27,8 +27,9 @@ interface GitConfigEntry {
27
27
  export declare function readGitConfigParameters(text: string): GitConfigEntry[] | undefined;
28
28
  /** Which rule classified a key as secret-shaped — the env arm's `kind` vocabulary. */
29
29
  export type SecretEnvFindingKind = "suffix-rule" | "namespace-rule" | "exact-name" | "family-rule";
30
- /** One env entry the `scrub` policy dropped: the key (the VALUE is never carried — this record is meant
31
- * 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. */
32
33
  export interface SecretEnvFinding {
33
34
  key: string;
34
35
  kind: SecretEnvFindingKind;
@@ -44,16 +45,23 @@ export type ExactEnvNameVerdict = "credential" | "exempt" | "unknown";
44
45
  * only a controlled collision can tell this precedence from an inverted one. */
45
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` — and the environment it sits in as `env` — to get exactly the scrub's answer for that entry: a git
48
- * config member is judged with its index group, which only the environment holds; without a value the name alone
49
- * decides (a `GIT_CONFIG_KEY_<i>` then reads as secret — the name cannot clear itself). */
50
- 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;
51
53
  /** Drop every secret-pattern key (see {@link isSecretEnvKey}) from an env object — the fail-closed `scrub`
52
- * policy. git's indexed record is handed on whole-entry: when an entry is withheld the kept ones are renumbered and
53
- * `GIT_CONFIG_COUNT` rewritten ({@link readGitEnvConfigRecord}), so a kept member can appear under an index a withheld
54
- * one had. `findings` is an OPTIONAL caller-owned collector: pass an array to learn which entries were dropped (by
55
- * their input names) and why, read it back after the call. */
56
- 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;
57
65
  /** Test seam: never called by production code. */
58
66
  export declare function __resetSecretEnvScrubNoticeAnnouncement(): void;
59
67
  /**
@@ -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;
@@ -40,27 +46,375 @@ function carriesAuthorizationHeader(value) {
40
46
  }
41
47
  const GIT_PROXY_CONFIG_KEY_RE = /^(?:http(?:\..*)?|remote\..*)\.proxy$/is;
42
48
  const proxyValueCarriesUserinfo = (value) => value.includes("@") || value.includes("%40");
43
- const GIT_PASSWORD_VARIABLE_NAMES = new Set(["pass", "password", "smtppass", "dbpass"]);
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;
44
51
  const GIT_EXTRA_HEADER_KEY_RE = /^http\.(?:.*\.)?extraheader$/is;
45
- const gitKeyHoldsCredential = (key) => GIT_PASSWORD_VARIABLE_NAMES.has(key.slice(key.lastIndexOf(".") + 1).toLowerCase()) || GIT_EXTRA_HEADER_KEY_RE.test(key);
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;
46
55
  const GIT_CREDENTIAL_HELPER_KEY_RE = /^credential\.(?:.*\.)?helper$/is;
47
- const GIT_CREDENTIAL_HELPER_REFERENCE_RE = /^(?:[A-Za-z0-9][A-Za-z0-9._-]*(?: +--?[A-Za-z0-9][A-Za-z0-9-]*(?:=[A-Za-z0-9._/~-]*| +[A-Za-z0-9._/~][A-Za-z0-9._/~-]*)?)*|\/[A-Za-z0-9._/~-]*)?$/;
48
- function gitConfigEntryCarriesCredential(entry) {
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))
259
+ return true;
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) {
49
394
  const key = entry.key?.trim();
50
395
  const value = entry.value;
51
- if (key !== undefined && (carriesUrlUserinfo(key) || gitKeyHoldsCredential(key)))
52
- return true;
53
- if (value === undefined)
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 === "")
54
402
  return false;
55
- if (carriesAuthorizationHeader(value) || carriesUrlUserinfo(value))
403
+ if (carriesAuthorizationHeader(value) || carriesUrlUserinfo(value, value))
56
404
  return true;
57
405
  if (key === undefined)
58
- return false;
59
- if (GIT_PROXY_CONFIG_KEY_RE.test(key))
60
- return proxyValueCarriesUserinfo(value);
61
- return GIT_CREDENTIAL_HELPER_KEY_RE.test(key) && !GIT_CREDENTIAL_HELPER_REFERENCE_RE.test(value);
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);
62
415
  }
63
- const STANDARD_PROXY_ENV_NAMES = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]);
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"]]);
64
418
  const GIT_BLANK = /[ \t\n\r]/;
65
419
  export function readGitConfigParameters(text) {
66
420
  const entries = [];
@@ -113,79 +467,86 @@ export function readGitConfigParameters(text) {
113
467
  function gitConfigVariableCarriesCredential(upperName, value) {
114
468
  if (upperName === "GIT_CONFIG_PARAMETERS")
115
469
  return readGitConfigParameters(value)?.some(gitConfigEntryCarriesCredential) ?? true;
116
- return STANDARD_PROXY_ENV_NAMES.has(upperName) && gitConfigEntryCarriesCredential({ key: "http.proxy", value });
470
+ const key = PROXY_ENV_NAMES.has(upperName) ? "http.proxy" : GIT_COMMAND_ENV_KEYS.get(upperName);
471
+ return key !== undefined && gitConfigEntryCarriesCredential({ key, value });
117
472
  }
118
473
  const GIT_CONFIG_COUNT_NAME = "GIT_CONFIG_COUNT";
119
474
  const GIT_CONFIG_MEMBER_RE = /^GIT_CONFIG_(KEY|VALUE)_(.*)$/s;
120
475
  const GIT_CONFIG_INDEX_RE = /^(?:0|[1-9][0-9]*)$/;
121
- const readGitConfigCount = (value) => (/^[0-9]+$/.test(value) && Number(value) <= 2 ** 31 - 1 ? Number(value) : undefined);
122
- function readGitEnvConfigRecord(env) {
123
- const withheld = new Set();
124
- const kept = new Map();
125
- const counts = [];
126
- const groups = new Map();
127
- for (const name of Object.keys(env)) {
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;
128
500
  const upper = name.toUpperCase();
129
- if (upper === GIT_CONFIG_COUNT_NAME) {
130
- counts.push(name);
501
+ const lookedUp = platform === "win32" ? upper : name;
502
+ if (lookedUp === GIT_CONFIG_COUNT_NAME) {
503
+ countNames.push(name);
131
504
  continue;
132
505
  }
133
506
  const member = GIT_CONFIG_MEMBER_RE.exec(upper);
134
507
  if (member === null)
135
508
  continue;
136
509
  const [, role, suffix = ""] = member;
137
- const group = groups.get(suffix) ?? { names: [], twice: false };
138
- groups.set(suffix, group);
139
- group.names.push(name);
140
- const text = env[name];
141
- if (text === undefined)
142
- continue;
143
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]);
144
515
  if (group[half] !== undefined)
145
516
  group.twice = true;
146
517
  group[half] = text;
147
518
  }
148
- for (const [suffix, g] of groups) {
149
- if (GIT_CONFIG_INDEX_RE.test(suffix))
150
- continue;
151
- groups.delete(suffix);
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()) {
152
524
  if (g.twice || gitConfigEntryCarriesCredential(g))
153
- for (const n of g.names)
154
- withheld.add(n);
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
+ }
155
531
  }
156
- const [countText, secondCountText] = counts.flatMap((n) => env[n] ?? []);
157
- const count = countText === undefined ? 0 : secondCountText === undefined ? readGitConfigCount(countText) : undefined;
158
- const ordered = [...groups.entries()].map(([suffix, g]) => ({ index: BigInt(suffix), ...g, carrier: gitConfigEntryCarriesCredential(g) })).sort((a, b) => (a.index < b.index ? -1 : a.index > b.index ? 1 : 0));
159
- if (count === undefined || ordered.some((g) => g.twice)) {
160
- for (const n of [...counts, ...ordered.flatMap((g) => g.names)])
161
- withheld.add(n);
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);
162
539
  return { withheld, kept };
163
540
  }
164
- const limit = BigInt(count);
165
- const rebuild = ordered.some((g) => g.index < limit && g.carrier);
166
- let next = 0;
167
- for (const g of ordered) {
168
- const renumber = rebuild && g.index < limit;
169
- if (g.carrier || (renumber && (g.key === undefined || g.value === undefined))) {
170
- for (const n of g.names)
171
- withheld.add(n);
172
- continue;
173
- }
174
- const index = renumber ? next++ : undefined;
175
- for (const n of g.names) {
176
- const text = env[n];
177
- if (text !== undefined)
178
- kept.set(n, { name: index === undefined ? n : `${n.slice(0, n.lastIndexOf("_"))}_${index}`, value: text });
179
- else if (renumber)
180
- withheld.add(n);
181
- }
182
- }
183
- for (const n of counts) {
184
- const text = env[n];
185
- if (text !== undefined)
186
- kept.set(n, { name: n, value: rebuild ? String(next) : text });
187
- else if (rebuild)
188
- withheld.add(n);
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);
189
550
  }
190
551
  return { withheld, kept };
191
552
  }
@@ -222,20 +583,22 @@ function classifyEnvEntry(key, value, record) {
222
583
  return "family-rule";
223
584
  return classifySecretEnvKey(key);
224
585
  }
225
- export function isSecretEnvKey(key, value, env) {
226
- const record = readGitEnvConfigRecord(value === undefined ? {} : { ...env, [key]: value });
586
+ export function isSecretEnvKey(key, value, env, platform = process.platform) {
587
+ const record = readGitEnvConfigRecord(value === undefined ? {} : { ...env, [key]: value }, platform);
227
588
  return classifyEnvEntry(key, value, record) !== undefined;
228
589
  }
229
- export function scrubSecretEnv(env, findings) {
230
- const record = readGitEnvConfigRecord(env);
590
+ export function scrubSecretEnv(env, findings, platform = process.platform) {
591
+ const record = readGitEnvConfigRecord(env, platform);
231
592
  const out = {};
232
593
  for (const k of Object.keys(env)) {
233
594
  const kind = classifyEnvEntry(k, env[k], record);
234
595
  if (kind === undefined) {
235
- const moved = record.kept.get(k);
236
- out[moved?.name ?? k] = moved === undefined ? env[k] : moved.value;
596
+ out[k] = env[k];
237
597
  continue;
238
598
  }
599
+ const standIn = record.withheld.get(k);
600
+ if (standIn !== undefined)
601
+ out[k] = standIn;
239
602
  if (findings !== undefined) {
240
603
  findings.push({ key: k, kind, confidence: SECRET_ENV_KIND_CONFIDENCE[kind], source: "secret-env" });
241
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.5",
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,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
+ }