Repository navigation
[TH-9042] fix(harness): keep customers' credential files from the model on every reader - #294
khushalsonawat wants to merge 15 commits into
Conversation
…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>
…nto feat/TH-9042-secret-file-list
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>
…nto feat/TH-9042-secret-file-list
…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>
|
Fix-only checks from the 12 Nov audit. Reports are kept with the review queue. Every round below cleared, with nothing above Medium.
Tests at head (b62feaa): |
| # 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()): |
There was a problem hiding this comment.
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, ...] = ( |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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>
|
Pushed e6efff1, a8da2d1 and 6da229c: Dileep's second review, the base merged in, and one follow-up.
|
Description
A customer's committed credential file could reach the model in four ways:
.kube/config,.docker/config.json,.pgpass,.htpasswd,terraform.tfstateand*-key.json..envincluded, because the gate only looks at a call's arguments.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
Changes
backends/files.py.SECRET_GLOBSis 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..kube/configand.kube/config.<ext>,kubeconfigfiles,.docker/config.jsonand.docker/contexts/,.dockercfg,.pgpass,.htpasswd,.git-credentials,.my.cnf,.s3cfg,.boto,.vault-token, SSH private keys by name,*.tfstateand its backups,*.tfvars,*-key.json,*_key.json,*.pfx,*.jksand*.keystore. Each exact name is also covered as.bak.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, theglobargument, a search rooted at a listed folder's parent, and inside aTaskworker.cwd._copy_prompt,model_problems): each refuses a listed file before reading it..envexception, the Coordinator's call: a model citation may name a.envfile (.env*,*.envor*.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..envline whose setting isn't a model's (AGENT_NAME=…,URL=…) is now refused.settings_local.py -> .pgpass, was readable whole before.*.envand*.env.*(prod.env),*secret*and*credential*as YAML, TOML, INI, CFG, CONF, properties, XML and text, credentials CSV downloads, asecrets/folder's text files,.secrets,*-sa.json, Firebase Admin SDK keys, and the files tools keep keys in (.dev.varsand.dev.vars.<env>,local.settings.json,key.properties,keystore.properties,*.p8and*.pk8(PKCS#8 private keys), AWS'srootkey*.csv,*.ppk, gh'shosts.yml,.databrickscfg,.terraformrc,.cargo/credentials,.gem/credentials).*secret*and*credential*on every reader would hide source a build reads (secret_manager.py, asecrets/package). The end-to-end test names by hand what only the scan hides, so a change on either side fails.tracemalloc.claude-agent-sdktouv.lock's version, so the tests run the CLI that ships. It now runstest_secret_paths.pyandtest_contract_model.py.Contract:
scan-parts.md1.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.pyholds the list against a literal (glob, sample) table and its near misses, and checks that every Claude session carries the rules._options. The kit's tools, the scan's tools and the CLI must hide the same files.Taskworker.51b85344(after the change round: the.envword rule, the source-data review fix), checked late in the 12 Nov audit, two reviewers: both clear, 0 Medium.c35bd7e0, two reviewers: both clear.ce8ab4fdand6e11bdb6, plusb9a1a67a, two reviewers: both clear. A found four Mediums:.dev.vars.<env>,rootkey.csvand Apple's other.p8names leaked, and a timing case sat near its bound.9e1666bb, two reviewers: both clear. Their Medium was*.pk8.47c6d647, two reviewers: both clear. Both found the same test gap.b62feaa3, one reviewer: clear, no findings.9e1666bb's message says the halved timing case catches thenamed_onplant; it guards the block scan's cache instead (corrected in47c6d647's message)..envfiles, env templates and Kubernetes Secret manifests.Known limits
.git/configreaches the source-data review: the listing skips dot folders by the written path only..git/configisn't on the list, and the platform's clone keeps its token out of it. A register row.credentialsfile,secrets.pyor.mcp.json. The scan hides some of these; the other readers don't.ar-SA.json, Helmtemplates/secret.yamland ExternalSecret manifests, and a workflow namedsecret-scan.yml..envsettings 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.kubeconfig/. None was found on any real tree.*-key.jsonis refused, and the refusal calls it a credential..claude/, the CLI's Grep and Glob find nothing there. The gate already refused Read there..envmodel-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.Related Issues
Refs: TH-9042