@sema-agent/core 7.33.4 → 7.33.5

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,49 @@
1
+ ## 7.33.5 — 2026-09-27
2
+
3
+ ### 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)
4
+
5
+ - **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.
6
+ - **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).
7
+ - **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.
8
+ - **Who needs it.** Hosts that hand git config through the environment: a disclosed over-drop no longer takes every git child down with it.
9
+ - **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).
10
+ - 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).
11
+
12
+ ### 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)
13
+
14
+ - **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 …`).
15
+ - **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.
16
+ - **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).
17
+ - 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).
18
+
19
+ ### 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)
20
+
21
+ - **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.
22
+ - **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.
23
+ - **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`).
24
+ - 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).
25
+
26
+ ### 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)
27
+
28
+ - **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.
29
+ - **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.
30
+ - **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`).
31
+ - 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).
32
+
33
+ ### 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)
34
+
35
+ - **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.
36
+ - **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).
37
+ - **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.
38
+ - 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).
39
+
1
40
  ## 7.33.4 — 2026-09-27
2
41
 
3
42
  ### 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
43
 
5
44
  - **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
45
  - **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 `:/`.
46
+ - **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
47
  - 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
48
 
10
49
  ## 7.33.3 — 2026-09-26
@@ -18,12 +18,13 @@
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
30
  /** One env entry the `scrub` policy dropped: the key (the VALUE is never carried — this record is meant
@@ -36,21 +37,22 @@ export interface SecretEnvFinding {
36
37
  }
37
38
  /** What the two exact-name lists say about a name, before any shape rule is consulted. */
38
39
  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
40
+ /** The exact-name arm of the classification, taking both lists as arguments. The credential list is read
41
41
  * FIRST: an exemption can never take a name away from it, so a name landing on both lists resolves to the
42
42
  * credential reading — the fail-closed direction (a wrongly-exempted name leaks; a wrongly-dropped one
43
43
  * does not). Module-level export (not part of the package surface): the two real lists are disjoint, so
44
44
  * 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;
45
+ export declare function classifyExactEnvName(key: string, credentialNames: ReadonlySet<string>, exemptNames: ReadonlySet<string>): ExactEnvNameVerdict;
46
46
  /** 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). */
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). */
49
50
  export declare function isSecretEnvKey(key: string, value?: string, env?: NodeJS.ProcessEnv): boolean;
50
51
  /** 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. */
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. */
54
56
  export declare function scrubSecretEnv(env: NodeJS.ProcessEnv, findings?: SecretEnvFinding[]): NodeJS.ProcessEnv;
55
57
  /** Test seam: never called by production code. */
56
58
  export declare function __resetSecretEnvScrubNoticeAnnouncement(): void;
@@ -62,3 +64,4 @@ export declare function __resetSecretEnvScrubNoticeAnnouncement(): void;
62
64
  * findings array is a no-op.
63
65
  */
64
66
  export declare function announceSecretEnvScrub(onNotice: ((notice: EngineNotice) => void) | undefined, findings: readonly SecretEnvFinding[]): void;
67
+ export {};
@@ -38,84 +38,202 @@ function carriesAuthorizationHeader(value) {
38
38
  }
39
39
  return judge();
40
40
  }
41
- const GIT_PROXY_CONFIG_KEY_RE = /^(?:http(?:\..+)?|remote\..+)\.proxy$/is;
41
+ const GIT_PROXY_CONFIG_KEY_RE = /^(?:http(?:\..*)?|remote\..*)\.proxy$/is;
42
42
  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))
51
- 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) {
43
+ const GIT_PASSWORD_VARIABLE_NAMES = new Set(["pass", "password", "smtppass", "dbpass"]);
44
+ 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);
46
+ 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) {
49
+ const key = entry.key?.trim();
50
+ const value = entry.value;
51
+ if (key !== undefined && (carriesUrlUserinfo(key) || gitKeyHoldsCredential(key)))
52
+ return true;
65
53
  if (value === undefined)
54
+ return false;
55
+ if (carriesAuthorizationHeader(value) || carriesUrlUserinfo(value))
56
+ return true;
57
+ 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);
62
+ }
63
+ const STANDARD_PROXY_ENV_NAMES = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]);
64
+ const GIT_BLANK = /[ \t\n\r]/;
65
+ export function readGitConfigParameters(text) {
66
+ const entries = [];
67
+ let at = 0;
68
+ const quoted = () => {
69
+ if (text[at] !== "'")
70
+ return undefined;
71
+ let word = "";
72
+ for (let i = at + 1; i < text.length; i++) {
73
+ if (text[i] !== "'")
74
+ word += text[i];
75
+ else if (text[i + 1] === "\\" && (text[i + 2] === "'" || text[i + 2] === "!") && text[i + 3] === "'") {
76
+ word += text[i + 2];
77
+ i += 3;
78
+ }
79
+ else {
80
+ at = i + 1;
81
+ return word;
82
+ }
83
+ }
66
84
  return undefined;
67
- for (const row of SECRET_ENV_INDEXED_FAMILY_ROWS) {
68
- const member = row.member.exec(upperName);
85
+ };
86
+ const endsItem = () => at === text.length || GIT_BLANK.test(text.charAt(at));
87
+ while (at < text.length) {
88
+ const key = quoted();
89
+ if (key === undefined)
90
+ return undefined;
91
+ if (endsItem()) {
92
+ const eq = key.indexOf("=");
93
+ entries.push(eq === -1 ? { key } : { key: key.slice(0, eq), value: key.slice(eq + 1) });
94
+ }
95
+ else if (text[at] === "=") {
96
+ at++;
97
+ if (endsItem())
98
+ entries.push({ key });
99
+ else {
100
+ const value = quoted();
101
+ if (value === undefined || !endsItem())
102
+ return undefined;
103
+ entries.push({ key, value });
104
+ }
105
+ }
106
+ else
107
+ return undefined;
108
+ while (at < text.length && GIT_BLANK.test(text.charAt(at)))
109
+ at++;
110
+ }
111
+ return entries;
112
+ }
113
+ function gitConfigVariableCarriesCredential(upperName, value) {
114
+ if (upperName === "GIT_CONFIG_PARAMETERS")
115
+ return readGitConfigParameters(value)?.some(gitConfigEntryCarriesCredential) ?? true;
116
+ return STANDARD_PROXY_ENV_NAMES.has(upperName) && gitConfigEntryCarriesCredential({ key: "http.proxy", value });
117
+ }
118
+ const GIT_CONFIG_COUNT_NAME = "GIT_CONFIG_COUNT";
119
+ const GIT_CONFIG_MEMBER_RE = /^GIT_CONFIG_(KEY|VALUE)_(.*)$/s;
120
+ 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)) {
128
+ const upper = name.toUpperCase();
129
+ if (upper === GIT_CONFIG_COUNT_NAME) {
130
+ counts.push(name);
131
+ continue;
132
+ }
133
+ const member = GIT_CONFIG_MEMBER_RE.exec(upper);
69
134
  if (member === null)
70
135
  continue;
71
- const index = member[1];
72
- if (row.credentialWhen(value, { sibling: (stem) => env?.[`${stem}_${index}`] }))
73
- return "credential";
136
+ 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
+ const half = role === "KEY" ? "key" : "value";
144
+ if (group[half] !== undefined)
145
+ group.twice = true;
146
+ group[half] = text;
74
147
  }
75
- if (NON_SECRET_ENV_INDEXED_FAMILY_ROWS.some((row) => row.member.test(upperName) && !row.valueClears(value)))
76
- return "screen-failed";
77
- return undefined;
148
+ for (const [suffix, g] of groups) {
149
+ if (GIT_CONFIG_INDEX_RE.test(suffix))
150
+ continue;
151
+ groups.delete(suffix);
152
+ if (g.twice || gitConfigEntryCarriesCredential(g))
153
+ for (const n of g.names)
154
+ withheld.add(n);
155
+ }
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);
162
+ return { withheld, kept };
163
+ }
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);
189
+ }
190
+ return { withheld, kept };
78
191
  }
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
192
  const SECRET_ENV_KIND_CONFIDENCE = {
84
193
  "exact-name": "high",
85
194
  "suffix-rule": "medium",
86
195
  "namespace-rule": "medium",
87
196
  "family-rule": "high",
88
197
  };
89
- export function classifyExactEnvName(key, credentialNames, exemptNames, value) {
198
+ export function classifyExactEnvName(key, credentialNames, exemptNames) {
90
199
  const upper = key.toUpperCase();
91
- if (credentialNames.has(upper, value))
200
+ if (credentialNames.has(upper))
92
201
  return "credential";
93
- if (exemptNames.has(upper, value))
202
+ if (exemptNames.has(upper))
94
203
  return "exempt";
95
204
  return "unknown";
96
205
  }
97
- function classifySecretEnvKey(key, value, env) {
98
- const byExactName = classifyExactEnvName(key, SECRET_ENV_EXACT_NAMES, NON_SECRET_ENV_NAMES, value);
206
+ function classifySecretEnvKey(key) {
207
+ const byExactName = classifyExactEnvName(key, SECRET_ENV_EXACT_NAMES, NON_SECRET_ENV_EXACT_NAMES);
99
208
  if (byExactName === "credential")
100
209
  return "exact-name";
101
210
  if (byExactName === "exempt")
102
211
  return undefined;
103
- const byFamily = classifyByFamily(key.toUpperCase(), value, env);
104
- if (byFamily === "credential")
105
- return "family-rule";
106
212
  if (SECRET_ENV_RE.test(key))
107
- return byFamily === "screen-failed" ? "family-rule" : "suffix-rule";
213
+ return "suffix-rule";
108
214
  return SEMA_NAMESPACE_SECRET_RE.test(key) ? "namespace-rule" : undefined;
109
215
  }
216
+ function classifyEnvEntry(key, value, record) {
217
+ if (record.withheld.has(key))
218
+ return "family-rule";
219
+ if (record.kept.has(key))
220
+ return undefined;
221
+ if (value !== undefined && gitConfigVariableCarriesCredential(key.toUpperCase(), value))
222
+ return "family-rule";
223
+ return classifySecretEnvKey(key);
224
+ }
110
225
  export function isSecretEnvKey(key, value, env) {
111
- return classifySecretEnvKey(key, value, env) !== undefined;
226
+ const record = readGitEnvConfigRecord(value === undefined ? {} : { ...env, [key]: value });
227
+ return classifyEnvEntry(key, value, record) !== undefined;
112
228
  }
113
229
  export function scrubSecretEnv(env, findings) {
230
+ const record = readGitEnvConfigRecord(env);
114
231
  const out = {};
115
232
  for (const k of Object.keys(env)) {
116
- const kind = classifySecretEnvKey(k, env[k], env);
233
+ const kind = classifyEnvEntry(k, env[k], record);
117
234
  if (kind === undefined) {
118
- out[k] = env[k];
235
+ const moved = record.kept.get(k);
236
+ out[moved?.name ?? k] = moved === undefined ? env[k] : moved.value;
119
237
  continue;
120
238
  }
121
239
  if (findings !== undefined) {
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.5",
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
+ }