Skip to content

[TH-9042] fix(harness): keep customers' credential files from the model on every reader - #294

Open
khushalsonawat wants to merge 15 commits into
feat/TH-8804-source-subfolderfrom
feat/TH-9042-secret-file-list
Open

khushalsonawat wants to merge 15 commits into
feat/TH-8804-source-subfolderfrom
feat/TH-9042-secret-file-list

Conversation

@khushalsonawat

@khushalsonawat khushalsonawat commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Description

A customer's committed credential file could reach the model in four ways:

  • Six well-known names were missing from the shared list: .kube/config, .docker/config.json, .pgpass, .htpasswd, terraform.tfstate and *-key.json.
  • The CLI's own Grep searched hidden files below any folder the gate allowed. That covered every name on the list, .env included, because the gate only looks at a call's arguments.
  • The contract tool's prompt copy copied lines from any file the model named.
  • The model citation (feat(harness): send the model found in the agent's code, and ask once for the prompt's lines (TH-8953) #268) told a listed file's line count and existence, and confirmed a guessed value.

This PR closes all four. Part 1 is the list. Part 2 is the CLI's Grep, the prompt copy and the model citation, which the Coordinator folded into this ticket.

Type of Change

  • Bug fix (security)
  • Tests

Changes

  • One list: backends/files.py.
    • SECRET_GLOBS is one tuple of globs, read as gitignore reads them. A glob matches a path or any folder above it, * stays within one name, and case is ignored.
    • The regex the kit's tools, the scan's tools and the gate use is built from it, so a folder carrying a listed name is hidden whole on every reader.
    • Added: .kube/config and .kube/config.<ext>, kubeconfig files, .docker/config.json and .docker/contexts/, .dockercfg, .pgpass, .htpasswd, .git-credentials, .my.cnf, .s3cfg, .boto, .vault-token, SSH private keys by name, *.tfstate and its backups, *.tfvars, *-key.json, *_key.json, *.pfx, *.jks and *.keystore. Each exact name is also covered as .bak.
  • The CLI's Grep and Glob: every Claude session, gated or not, carries the same list as root-anchored Read deny rules (Read(//**/…)), which the CLI turns into ripgrep exclusions. Measured on the bundled CLI, they close every Grep route found: from the root, with no path, through a link, the glob argument, a search rooted at a listed folder's parent, and inside a Task worker.
  • The gate resolves a relative path in the session's own folder, which is the hook payload's cwd.
  • Prompt copy and model citation (_copy_prompt, model_problems): each refuses a listed file before reading it.
    • The file is judged where it resolves inside the source, so a present file, a missing one and a link to either all get the same answer.
    • The .env exception, the Coordinator's call: a model citation may name a .env file (.env*, *.env or *.env.*), but only when the cited name is the value of a setting that names a model and holds no secret. The setting's words must include MODEL, DEPLOYMENT or LLM, or ENGINE beside OPENAI or AZURE. And none of KEY, TOKEN, SECRET, PASS, PWD, AUTH, CREDENTIAL, URL, URI or DSN may appear anywhere in its name, joined words included. A compose default, ${MODEL:-gpt-4o}, counts as the value only when the braced variable is the setting itself or another model setting with no secret part (${DEFAULT_MODEL:-gpt-4o}); ${DB_PASSWORD:-guess} doesn't. Such a file's line count is never told.
    • Two of feat(harness): send the model found in the agent's code, and ask once for the prompt's lines (TH-8953) #268's test cases change answer on purpose: a .env line whose setting isn't a model's (AGENT_NAME=…, URL=…) is now refused.
  • The source-data review lists no file that resolves to a listed one. A link named like code, such as settings_local.py -> .pgpass, was readable whole before.
  • After Dileep's review:
    • The list gains the settings and key shapes the scan already hid, so every reader hides them: *.env and *.env.* (prod.env), *secret* and *credential* as YAML, TOML, INI, CFG, CONF, properties, XML and text, credentials CSV downloads, a secrets/ folder's text files, .secrets, *-sa.json, Firebase Admin SDK keys, and the files tools keep keys in (.dev.vars and .dev.vars.<env>, local.settings.json, key.properties, keystore.properties, *.p8 and *.pk8 (PKCS#8 private keys), AWS's rootkey*.csv, *.ppk, gh's hosts.yml, .databrickscfg, .terraformrc, .cargo/credentials, .gem/credentials).
    • The scan keeps its wider list. Bare *secret* and *credential* on every reader would hide source a build reads (secret_manager.py, a secrets/ package). The end-to-end test names by hand what only the scan hides, so a change on either side fails.
    • The model check's time bounds are 1 s, and 5 s under tracemalloc.
  • CI pins claude-agent-sdk to uv.lock's version, so the tests run the CLI that ships. It now runs test_secret_paths.py and test_contract_model.py.

Contract: scan-parts.md 1.3o and 1.3q (P4.3 and its §9 row).

Testing

All runs on Python 3.12.3, the hosted image's minor version.

  • test_secret_paths.py holds the list against a literal (glob, sample) table and its near misses, and checks that every Claude session carries the rules.
    • The end-to-end test runs the bundled CLI against a local stand-in for the Messages API (no model, no key, no outside traffic), with the session built by the kit's own _options. The kit's tools, the scan's tools and the CLI must hide the same files.
    • It covers both session layouts (the session's folder is the source, or a separate output folder) and a Task worker.
    • 91 passed on SDK 0.2.156 (CLI 2.1.276, the lock's) and on 0.2.176.
  • Affected files: 1703 passed. CI's list run file by file passes apart from that venv's known gaps (livekit and hypothesis aren't installed).
  • Planted bugs: 32, each caught by its named test.
  • Real trees, names only: 891,087 files. The only new refusals are 9 real Terraform state and variable files.
  • Reviews: two reviewers at every stage.
    • Design: three rounds. Round 2 found that rules written relative to the search root leaked in the builder's layout, fixed with root-anchored rules.
    • Plan: one round. Change: one round, both cleared.
    • Fix-only checks (every commit after a cleared round; results in the comments):
      • 51b85344 (after the change round: the .env word rule, the source-data review fix), checked late in the 12 Nov audit, two reviewers: both clear, 0 Medium.
      • Dileep's review fix c35bd7e0, two reviewers: both clear.
      • Their Mediums, ce8ab4fd and 6e11bdb6, plus b9a1a67a, two reviewers: both clear. A found four Mediums: .dev.vars.<env>, rootkey.csv and Apple's other .p8 names leaked, and a timing case sat near its bound.
      • Fixed in 9e1666bb, two reviewers: both clear. Their Medium was *.pk8.
      • Fixed in 47c6d647, two reviewers: both clear. Both found the same test gap.
      • Fixed in b62feaa3, one reviewer: clear, no findings.
      • 9e1666bb's message says the halved timing case catches the named_on plant; it guards the block scan's cache instead (corrected in 47c6d647's message).
    • That round: 1,149 passed in the touched files; 14 more planted bugs caught; real trees, about 600,000 entries, 125 newly refused, all .env files, env templates and Kubernetes Secret manifests.

Known limits

  • A code-named link to .git/config reaches the source-data review: the listing skips dot folders by the written path only. .git/config isn't on the list, and the platform's clone keeps its token out of it. A register row.
  • Names, not content. A credential under another name, or another backup suffix, is still readable, such as a bare credentials file, secrets.py or .mcp.json. The scan hides some of these; the other readers don't.
  • Some files that hold no secret are hidden: a locale named ar-SA.json, Helm templates/secret.yaml and ExternalSecret manifests, and a workflow named secret-scan.yml.
  • Some innocent .env settings can't be cited as the model, because a secret word appears inside their name: KEYWORD_MODEL, TOKENIZER_MODEL, SECURITY_MODEL, PASSAGE_EMBEDDING_MODEL. A citation is optional, so the cost is a retry.
  • A folder carrying a listed name is hidden whole, for example a Go package folder kubeconfig/. None was found on any real tree.
  • A public *-key.json is refused, and the refusal calls it a credential.
  • Local runs under a listed folder: in a checkout under .claude/, the CLI's Grep and Glob find nothing there. The gate already refused Read there.
  • The .env model-citation exception: a guess can be checked only against the value of a setting that names a model, and a file's length can be found by citing lines one by one.
  • The Bash check isn't built from the list. No stage grants Bash today, but no test holds that.
  • A hard link to a credential under another name passes the kit's Read and the CLI's Read.
  • The fix rests on how this CLI turns deny rules into exclusions. CI runs the end-to-end test at the lock's version, so a change there fails the build.
  • The sandbox-server tests can hang, on the base too: a TestClient request waits on a job task with no timeout, and the worker child starts the real CLI with the real HOME. This is outside this change.

Related Issues

Refs: TH-9042

…y reader

- One list of globs (files.SECRET_GLOBS), read as gitignore reads them,
  builds both the regex the kit's tools, the scan's tools and the gate use
  and the Read deny rules every Claude Code session now carries. It adds
  .kube/config, kubeconfig files, .docker/config.json and contexts,
  .dockercfg, .pgpass, .htpasswd, .git-credentials, .my.cnf, .s3cfg, .boto,
  .vault-token, SSH key names, *.tfstate and backups, *.tfvars, *-key.json,
  *_key.json, *.pfx, *.jks and *.keystore, each exact name also as .bak.
- Claude Code's own Grep and Glob searched hidden files below any folder
  the gate allowed; the root-anchored deny rules now make them skip listed
  files, in workers too.
- The gate resolves a relative path in the session's folder (the hook's cwd).
- The contract tool's prompt copy and the model citation refuse a listed
  file, judged where it resolves inside the source, before anything is
  opened. A .env file may still cite a model, only on a line whose setting
  names one, and never with its line count.
- CI pins the Claude Agent SDK to uv.lock's version and runs the new tests
  and the model citation tests.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…l CLI

test_secret_paths.py holds the glob list against a literal table, its near
misses, the rules on every session, and runs the bundled Claude Code CLI
against a local stand-in for the Messages API (no model, no key) in both
session layouts and in a worker. The gate is tested through its hook with
the payload's cwd; the prompt copy and the model citation get their
refusal cases. Two model citation cases change answer by design: a .env
line whose setting isn't a model's is now refused.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
Both reviewers cleared round 1 (A: 2 Medium, 7 Low; B: 2 Medium, 6 Low).
- The source-data review lists no file that resolves to a listed one, so
  a link named like code (settings_local.py -> .pgpass) can't be read.
- A .env model citation is accepted when its setting's words name a model
  (MODEL, DEPLOYMENT, ENGINE or LLM) and none marks a secret (KEY, TOKEN,
  SECRET, PASSWORD and the like), and a line that sets nothing is refused.
  This keeps Azure's AZURE_OPENAI_DEPLOYMENT and the like, which #268
  accepted, and refuses MODEL_API_KEY.
- Tests: .env in the prompt copy, a .bak copy in a model citation, the
  worker's own tool list, and no error behind the end-to-end Glob cases.
- The CI comment says only the SDK is pinned.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
@khushalsonawat khushalsonawat self-assigned this Nov 11, 2026

@cdileep23 cdileep23 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check the comments.

Comment thread src/fi/alk/harness/backends/files.py Resolved
Comment thread src/fi/alk/harness/model_citation.py Resolved
Comment thread .github/workflows/sdk-smoke.yml Resolved
Add prod.env-style settings files, secrets and credentials YAML and TOML,
and *-sa.json keys to the one list, so every reader hides them, not just
the scan. The end-to-end test now names by hand what only the scan hides.

In a .env file, a setting holding KEY, TOKEN, SECRET, PASS, PWD, AUTH,
CREDENTIAL, URL, URI or DSN anywhere in its name is never a model setting,
and ENGINE counts only beside OPENAI or AZURE. Every kind of .env file
takes that rule.

The model check's time bounds are loosened to 2 s (5 s under
tracemalloc), so a busy CI runner can't fail them.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
In a .env file the cited name must be the value of the one setting that
names the model, so a guess is never checked against a second setting on
the same line (a compose default, ${MODEL:-gpt-4o}, counts as the value).

The list also hides Firebase Admin SDK keys and secrets or credentials
files in INI, CFG, CONF, properties and XML form. The prompt-copy test
covers Dileep's prod.env and config/secrets.yaml.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
The list also hides credentials CSV downloads, secrets and credentials
text files, a secrets folder's text files, .secrets, and the settings files
tools keep keys in (.dev.vars, local.settings.json, key.properties,
keystore.properties, AuthKey_*.p8, *.ppk, gh's hosts.yml, .databrickscfg,
.terraformrc, Cargo's and RubyGems' credentials). The scan's own list drops
the three names the shared list now covers.

The model check's untimed bound is 1 s again, close enough to catch a
search that resumes one character on; and every secret word in a .env
setting's name has its own case.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…bound

The base's merged test kept a 0.5 s bound, which CI's runner passed at
0.68 s. It now uses the file's QUICK_SECONDS like the others.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…ke a compose default only from the setting itself

From the #294 check:
- .dev.vars.<env> (Wrangler's per-environment form), rootkey.csv (the file
  AWS names a root access-key download) and any .p8 (always a PKCS#8 private
  key, not just Apple's AuthKey_) reached every reader. All three are on the
  list now; *.p8 replaces AuthKey_*.p8.
- A .env line like OPENAI_MODEL=${DB_PASSWORD:-guess} confirmed the guess,
  because the default of any braced variable counted as the setting's value.
  It counts now only when the braced variable is the setting itself.
- The a-file-of-openers timing case ran close to its 1 s bound; its input is
  halved. The planted slow path still fails it.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
… from another model setting

From the 9e1666b checks:
- Android's PKCS#8 signing keys (platform.pk8, releasekey.pk8) reached every
  reader, for the same reason .p8 did. **/*.pk8 is on the list.
- A second download of AWS's root key file is named "rootkey (1).csv".
  rootkey.csv becomes rootkey*.csv.
- 9e1666b refused CHAT_MODEL=${DEFAULT_MODEL:-gpt-4o-mini}, which was
  accepted before: a model setting defaulting from another model setting.
  The default counts again when the braced variable names a model and has
  no secret part; another secret's default still doesn't.

9e1666b's message says the halved a-file-of-openers case still fails "the
planted slow path". That case guards the block scan's last-closer cache, not
the named_on resume plant, which the crafted_model_file cases catch.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…ill refused

Both 47c6d64 checks found no test pinned the secret-part check on the
braced name: a check by model word alone passed every test while
OPENAI_MODEL=${MODEL_API_KEY:-guess} confirmed the guess. Two refused cases
now cover it, one in upper and one in lower case.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
@khushalsonawat

khushalsonawat commented Nov 12, 2026 •

Copy link
Copy Markdown
Member Author

Fix-only checks from the 12 Nov audit. Reports are kept with the review queue. Every round below cleared, with nothing above Medium.

  • ce8ab4f, 6e11bdb, b9a1a67, two reviewers:
    • A: clear, 0 High, 4 Medium, 6 Low. The Mediums: .dev.vars.<env>, rootkey.csv and Apple's other .p8 key names reached every reader, and the a-file-of-openers timing case sat near its 1 s bound.
    • B: clear, 0 High, 0 Medium, 4 Low.
    • Both found that a compose default of another variable (OPENAI_MODEL=${DB_PASSWORD:-guess}) confirmed a guess.
  • 9e1666b (all of the above fixed), two reviewers:
    • A: clear, 1 Medium. Android's *.pk8 signing keys reached every reader.
    • B: clear, 0 Medium. A model setting defaulting from another model setting was newly refused.
  • 47c6d64 (*.pk8, rootkey*.csv, model-setting defaults accepted again), two reviewers: both clear, with the same Medium. No test pinned the secret-part check on a braced name.
  • b62feaa (two refused cases), one reviewer: clear, no findings. The reviewers' plant fails exactly those two cases.
  • 51b8534 was fixed after the change round and never had its own check. The audit caught it. Two reviewers, judged at head: both clear, 0 Medium.
    • A listed file can't reach the source-data review through any kind of link, a different case or a relative path.
    • The Lows are recorded: a code-named link to .git/config is listed; a file swapped for a link mid-review; two tests weaker than their comments.

Tests at head (b62feaa): test_secret_paths.py and test_contract_model.py, 774 passed, hermetic, Python 3.12.3.

@cdileep23 cdileep23 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check the comments.

# target; each is charged to the save's budget.
found_at[where.path] = resolved_in_source(root, where.path)
path = found_at[where.path]
if path is not None and secret_text(path.relative_to(root).as_posix()):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2. Two readers still skip this check, so the title's "every reader" isn't true yet.

builder.py:292-305, declare_tool: it takes any evidence_file in the source and answers whether evidence_quote appears anywhere in it. The match is a substring, so the model can grow a guess one letter at a time and get the whole key back. A test recovered OPENAI_API_KEY=sk-live-q7 from .env in under 50 calls. This is a stronger form of the guessed-value leak this PR closes in the model citation.

sources.py:79-86, RepoSource.briefing: the understand stage's file list still names .env.production, .aws/credentials, deploy/gcp-sa.json, .kube/config and infra/prod.tfvars. Names only, but it contradicts "hidden whole on every reader".

Fix: refuse in declare_tool when secret_text(target.relative_to(root.resolve()).as_posix()) is true, with the same answer this line gives, and skip secret_text entries in the briefing. Or narrow the title and description to the readers covered.

"**/*-sa.json",
)
# Exact names whose `.bak` copy is a credential too. `.kube/config.*` already covers its own.
_WITH_BACKUP: tuple[str, ...] = (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. The names added since the first round have no .bak form here, though the description says every exact name gets one. These backups are still readable on every reader: .netrc.bak, .npmrc.bak, .pypirc.bak, .databrickscfg.bak, .terraformrc.bak, .cargo/credentials.bak, local.settings.json.bak (.dev.vars.bak is covered as of the latest push).

Fix: add them, or derive the .bak form for every glob in SECRET_GLOBS with no *, and add a test that each exact name's backup is refused.

if found is None:
return False
setting = found.group(1).upper()
if any(part in setting for part in _SECRET_PARTS):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. The word list still misses settings like ENDPOINT, SERVER, PROXY, HOST and BASE, so MODEL_SERVER=postgres://admin:hunter2@db/app or LLM_ENDPOINT=https://admin:pw@llm.internal/v1 in a .env file counts as a model line. The full URL is accepted as the model name, and a wrong guess gets a different answer, which confirms the value. The PR's own model-url case shows this was meant to be refused.

Fix: in a .env file, refuse any value that contains :// or @. A check on the value's shape won't need a new word each time.

tests/harness/test_authoring_runtime_validation.py
tests/harness/test_failure_file.py
tests/harness/test_contract_tools_subfolder.py
tests/harness/test_contract_model.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. Following up on the timing thread: the reply says the bounds are one constant, but four assert took < 0.25 remain in test_contract_model.py (:2683, :2815, :2957, :3190), and this job now runs them. With 44 busy processes on 11 cores, test_a_crafted_long_word_costs_little failed 4 runs in 5 (0.29 < 0.25); it takes 0.06 s on an idle machine.

Fix: use QUICK_SECONDS in those four.

…opies, URL values, timing bounds

- declare_tool refused nothing: it answered whether a quote appears in any
  source file, so a guess could be grown letter by letter against .env. It
  now gives a listed file (or a link to one, or a missing one) the same
  refusal as the kit's Read, before reading. The understand stage's briefing
  no longer names listed files.
- Every exact name on the list now has its .bak form, derived from the list
  rather than kept by hand (.netrc.bak, .npmrc.bak, .pypirc.bak and the
  rest). The CLI deny rules follow.
- A .env value that is a locator (any "://", a fullwidth at sign, or an "@"
  other than a Vertex version suffix like @20240620 or @latest) is refused,
  so MODEL_SERVER=postgres://admin:pw@db/app can't confirm a guess.
- The four remaining 0.25 s timing asserts use QUICK_SECONDS.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
No conflicts. The base now runs tests/test_harness.py in CI, which e6efff1
adds a test to.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…ccept short Vertex versions

From the e6efff1 checks:
- The runtime repair stage took any evidence path in the repository and
  then checked whether the command's first word appeared in it, so a guess
  at a key in .env could be grown letter by letter. A listed file, a link to
  one and a missing one now get one refusal before anything is read.
- e6efff1 accepted an "@" in a .env model name only before 8 digits or
  "latest", refusing real Vertex names like mistral-large@2407 and
  text-bison@001. Any digits now count.

TH-9042

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
@khushalsonawat

Copy link
Copy Markdown
Member Author

Pushed e6efff1, a8da2d1 and 6da229c: Dileep's second review, the base merged in, and one follow-up.

  • e6efff1 answers all four of Dileep's comments.
    • declare_tool refuses a listed file before reading it, and the briefing leaves listed files out.
    • Every exact name gets a derived .bak form.
    • A .env value that is a locator is refused.
    • The four 0.25 s asserts use QUICK_SECONDS.
  • The check of e6efff1, two reviewers:
    • B: clear, with 1 Medium.
    • A: not clear, with 1 High. The runtime-repair stage took any evidence path and then checked the command against the file's text, so a guess at a .env key could still be grown letter by letter. That code is older than this PR's change.
    • Both found the same Medium: real Vertex names like mistral-large@2407 were refused.
  • 6da229c fixes both. A listed file, a link to one or a missing one now gets a single refusal as runtime-repair evidence, before anything is read. Any digits after @ count as a version.
    • On a plant of the new check, 9 new cases fail. The touched files give 857 passed.
    • 6da229c hasn't had its own fix-only check yet. That's held until 14 Nov, after the weekly usage reset. The thread replies follow then.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants