v1.94.1.0 fix(redact): a lookup call's file-name or key argument is not a secret (GST-1064) (#3893)

* fix(redact): a name-shaped literal inside a call is not a secret (GST-1064)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (GST-1064: passphrases passed to a call still report; only file names and short snake_case keys are names)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: only a call's first argument is a lookup name (GST-1064: getenv defaults and fallbacks are still checked; file names need a known extension)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: only a lookup call's first argument is a name (GST-1064: make_password, hashpw, grouping parens and later statements still report)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: env.kv's lookup-call first-argument exemption (GST-1064 docs audit)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-03 04:12:32 +00:00
1 parent 4ec1722ecb
commit 466689bbbf
8 files changed
+86 -4

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## [1.94.1.0] - 2026-11-03
### Fixed
- **The secrets check no longer reads a file name or dict key inside a call as a credential (GST-1064).** `key = json.loads(files.get("key.json", ""))` failed pregate with `PREGATE_SECRETS` (`env.kv`): eight distinct letters in `"key.json"` reach the entropy bar, so the call's argument looked like a secret. So did `token = resp.json().get("access_token")`. The first argument of a lookup call (`get`, `getenv`, `read`, `load`, `loads`, `open`, `pop`, `fetch`) that is a lowercase file name or a snake_case key of two or three words now counts as a name. Any other call still reports a password-shaped literal (`make_password("orange_monkey_pass")`, `bcrypt.hashpw(...)`), and so do a later argument or fallback (`os.getenv("SECRET_KEY", "orange_monkey_pass")`), a real key in quotes or in a `.env` line, and a kebab passphrase (`decrypt("prod-signing-key")`). The entropy bar is unchanged.
## [1.94.0.0] - 2026-11-03 ## [1.94.0.0] - 2026-11-03
- CI is now the one full test run. When a verified CI run already passed at the exact, clean tree, `gstack-pregate`'s tests stage and /ship's test lanes use it instead of running the suite again (GSTA-258, R65 item 1). The tests stage looks up the newest verified run at HEAD under the base branch's `run_profiles` by itself (`CI_CARRY: accepted …`; `--no-ci-carry` turns it off). A /ship lane carries only when the base branch's `.gstack/pregate.json` maps its exact command in the new `ship_lanes_from` key, and only when the run's `tree.json` covers the lane's inventory; gstack maps `bun run test` to its Free Tests run. A carried lane prints `LANE: <label> carried run=<id>/<attempt>` and is recorded as an explicit CI-sourced evidence row (`gstack-evidence record-ci`), never as a local run. Dirty trees, fork runs, stale runs and a newer red or running run never carry. - CI is now the one full test run. When a verified CI run already passed at the exact, clean tree, `gstack-pregate`'s tests stage and /ship's test lanes use it instead of running the suite again (GSTA-258, R65 item 1). The tests stage looks up the newest verified run at HEAD under the base branch's `run_profiles` by itself (`CI_CARRY: accepted …`; `--no-ci-carry` turns it off). A /ship lane carries only when the base branch's `.gstack/pregate.json` maps its exact command in the new `ship_lanes_from` key, and only when the run's `tree.json` covers the lane's inventory; gstack maps `bun run test` to its Free Tests run. A carried lane prints `LANE: <label> carried run=<id>/<attempt>` and is recorded as an explicit CI-sourced evidence row (`gstack-evidence record-ci`), never as a local run. Dirty trees, fork runs, stale runs and a newer red or running run never carry.
+1 -1
View File
@@ -1 +1 @@
1.94.0.0 1.94.1.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.94.0.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.94.1.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+7
View File
@@ -1137,6 +1137,13 @@ those, each with its scope and its reason:
end the outer string (GST-738). The pre-push hook sees only added lines, end the outer string (GST-738). The pre-push hook sees only added lines,
so it scans Python and Dart lines without this exemption and may still warn so it scans Python and Dart lines without this exemption and may still warn
(MEDIUM, non-blocking) on them. (MEDIUM, non-blocking) on them.
- The first argument of a lookup call (`get`, `getenv`, `read`, `load`,
`loads`, `open`, `pop`, `fetch`) that is a lowercase file name
(`files.get("key.json")`) or a snake_case key of two or three words
(`resp.json().get("access_token")`) is a name, not a credential (GST-1064).
Any other call's literal (`make_password("orange_monkey_pass")`), a later
argument or fallback (`os.getenv("SECRET_KEY", "orange_monkey_pass")`) and a
kebab passphrase still report.
The `secrets` row says `N allowlisted (<ids>)` and lists each allowlisted The `secrets` row says `N allowlisted (<ids>)` and lists each allowlisted
finding with the entry (`allowlisted env.kv (evals/receipts/t1.json, added line finding with the entry (`allowlisted env.kv (evals/receipts/t1.json, added line
+15 -1
View File
@@ -661,13 +661,27 @@ const SCHEMA_FIELD_VALUE = /^[a-z][a-z0-9]*(?:[_.-][a-z][a-z0-9]*)+$/;
function isSchemaFieldIdentifier(span: string, match: RegExpExecArray): boolean { function isSchemaFieldIdentifier(span: string, match: RegExpExecArray): boolean {
return SCHEMA_FIELD_NAME.test(match[0]) && SCHEMA_FIELD_VALUE.test(span); return SCHEMA_FIELD_NAME.test(match[0]) && SCHEMA_FIELD_VALUE.test(span);
} }
/**
* GST-1064: the first argument of a lookup call (`get`, `getenv`, `read`,
* `load`, `loads`, `open`, `pop`, `fetch`) that is a lowercase file name
* (`files.get("key.json")`) or a snake_case key of two or three words
* (`resp.json().get("access_token")`) names what the call reads. Eight distinct
* letters already reach 3.0 bits, so entropy alone cannot tell it from a
* secret. Any other callee (`make_password("orange_monkey_pass")`), a grouping
* paren, a later argument or a fallback (`os.getenv("X", "orange_monkey_pass")`)
* is a value and is still checked, and so is a first argument with a digit, an
* uppercase letter, kebab words or four or more snake_case words.
*/
const LOOKUP_ARGUMENT =
/(?<![\w$])(get|getenv|read|load|loads|open|pop|fetch)\([ \t]*(["'])(?:[a-z]+(?:_[a-z]+){1,2}|[a-z]+(?:[_-][a-z]+)*\.(?:json|ya?ml|toml|ini|cfg|conf|env|pem|crt|cer|key|txt|csv|xml|cookie))\2/g;
function isCallExpression(span: string, match: RegExpExecArray): boolean { function isCallExpression(span: string, match: RegExpExecArray): boolean {
if (!CALL_SHAPED_VALUE.test(span)) return false; if (!CALL_SHAPED_VALUE.test(span)) return false;
const { start } = spanBounds(match); const { start } = spanBounds(match);
if (match.input[start - 1] === '"' || match.input[start - 1] === "'") return false; if (match.input[start - 1] === '"' || match.input[start - 1] === "'") return false;
const call = match.input.slice(start + span.indexOf("(")).split("\n", 1)[0]; const call = match.input.slice(start + span.indexOf("(")).split("\n", 1)[0];
if (!call.includes(")") && call.trim() !== "(") return false; if (!call.includes(")") && call.trim() !== "(") return false;
return !carriesSecretLiteral(call); const line = match.input.slice(start).split("\n", 1)[0];
return !carriesSecretLiteral(line.replace(LOOKUP_ARGUMENT, "$1("));
} }
/** /**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.94.0", "version": "1.94.1",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -54,6 +54,15 @@ describe('pregate secrets: source context and diff integrity', () => {
expect([...parseAddedLines(diff)]).toEqual([['a.ts', ['one', '++two']], ['b.env', ['three']]]); expect([...parseAddedLines(diff)]).toEqual([['a.ts', ['one', '++two']], ['b.env', ['three']]]);
}); });
test('a Python call that reads key.json is code; the same key name holding a literal key still fails (GST-1064)', () => {
expect(repo({ 'attach.py': 'import json\nkey = json.loads(files.get("key.json", ""))\n' }).secrets().status).toBe('pass');
const fake = 'Zx9Q' + 'w8Er7Ty6Ui5Op4As3Df2';
const r = repo({ 'attach.py': `key = json.loads(files.get("key.json")) or "${fake}"\n`, '.env': `KEY=${fake}\n` }).secrets();
expect(r.status).toBe('fail');
expect(r.lines.join('\n')).toContain('attach.py');
expect(r.lines.join('\n')).toContain('.env');
});
test('a TS object property that reads a credential at runtime is code, not a secret (#3211)', () => { test('a TS object property that reads a credential at runtime is code, not a secret (#3211)', () => {
const f = repo({ 'app.ts': 'const cfg = {\n token: credentials.token,\n apiKey: settings.apiKey,\n};\n' }); const f = repo({ 'app.ts': 'const cfg = {\n token: credentials.token,\n apiKey: settings.apiKey,\n};\n' });
expect(f.secrets().status).toBe('pass'); expect(f.secrets().status).toBe('pass');
+47
View File
@@ -106,6 +106,53 @@ describe("env.kv: a function call assigned to a credential name is code (#2899)"
); );
}); });
describe("env.kv: a name-shaped literal inside a call is a file or dict key, not a secret (GST-1064)", () => {
const FAKE_KEY = "Zx9Qw8Er7Ty6Ui5Op4As3Df2";
const FAKE_STRIPE_TAIL = "4eC39HqLyjWDarjtT1zdp7dc";
table(
"env.kv",
"MEDIUM",
[
String.raw`key = json.loads(files.get("key.json", ""))`,
String.raw`token = resp.json().get("access_token")`,
String.raw` session = store.load('session.cookie')`,
String.raw`secret = vault.read("client_secret")`,
],
[
String.raw`key = "${FAKE_KEY}"`,
String.raw`KEY=${FAKE_KEY}`,
String.raw`key = json.loads("sk_live_${FAKE_STRIPE_TAIL}")`,
String.raw`token = resp.json().get("access_token", "${FAKE_KEY}")`,
String.raw`password = os.getenv("DB_PASSWORD", "hunter2.Zx9Qw8")`,
String.raw`key = json.loads(files.get("key.json")) or "${FAKE_KEY}"`,
String.raw`password = os.getenv("DB_PASSWORD", "correct-horse-battery-staple")`,
String.raw`api_key = decrypt("prod-signing-key")`,
String.raw`token = decrypt("correct_horse_battery_staple")`,
String.raw`key = decrypt("qwhzxkvbnmplor")`,
String.raw`SECRET_KEY = os.getenv("SECRET_KEY", "orange_monkey_pass")`,
String.raw`password = os.environ.get("DB_PASSWORD", "purple_walrus")`,
String.raw`token = resp.json().get("access_token", "orange_monkey_pass")`,
String.raw`key = derive_secret("tigers.horse")`,
String.raw`password = os.getenv("DB_PASSWORD", "qwhzxkvbnmplor.txt")`,
String.raw`password = make_password("orange_monkey_pass")`,
String.raw`password = bcrypt.hashpw("orange_monkey_pass", salt)`,
String.raw`token = jwt.encode("purple_walrus_pie", payload)`,
String.raw`api_key = settings.Secret("correct_horse_battery")`,
String.raw`password = hash_password("purple_walrus")`,
String.raw`password=hash_password("purple_walrus")`,
String.raw`+password = hash_password( "purple_walrus" )`,
String.raw`password = hash_password("purple_walrus").encode()`,
String.raw`password = hash_password("purple_walrus") # seed user`,
String.raw`password = config.get("db_password") or ("orange_monkey_pass")`,
String.raw`password = config.get("db_password", ("orange_monkey_pass"))`,
String.raw`password = login(user, ("purple_walrus"))`,
String.raw`password = load_cfg("app.yaml"); login("purple_walrus")`,
String.raw`password = login(user, str("purple_walrus"))`,
String.raw`password = encrypt(str("orange_monkey_pass"))`,
],
);
});
describe("internal.hostname: per-machine config filenames are not hosts (#2962, #2740)", () => { describe("internal.hostname: per-machine config filenames are not hosts (#2962, #2740)", () => {
table( table(
"internal.hostname", "internal.hostname",