From 836eac0277d408c5bc1f1d40dc0017d0fd666034 Mon Sep 17 00:00:00 2001 From: Alpha Nury Date: Thu, 16 Jul 2026 23:00:07 +0200 Subject: [PATCH] fix(detect): spare genuine source from the Stage 1 sensitive-dir drop (#1943) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + graphify/detect.py | 52 +++++++++++++++++++++++++++++++++----------- tests/test_detect.py | 36 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 446ed9e652..2a811fa6f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.18 (unreleased) - Fix: the semantic cache no longer replays extractions from an older prompt after an upgrade (#1939, thanks @HunterMcGrew and @SinghAman21). Entries were keyed on `sha256(file content + path)` alone, with no component for the extraction prompt that produced them, so a release that changed the prompt left every unchanged file a cache hit: the run exited 0, `cost.json` looked cheap, and the graph silently carried two prompt generations side by side. Semantic entries are now namespaced by a fingerprint of the extraction prompt (`cache/semantic/p{fingerprint}/`, mirroring the AST cache's `v{version}/` layout), keeping both properties #1252 wanted — entries survive releases that don't touch the prompt, and invalidate only when it actually changed. The fingerprint normalizes line endings so a CRLF checkout doesn't look like a prompt change. Both extraction paths pass their prompt: the Python/CLI path (`llm.py`'s `_EXTRACTION_SYSTEM`, all backends) automatically, and the skill path via a new `prompt_file` argument in Step B0/B3 pointing at the `references/extraction-spec.md` the subagents were handed. Pre-existing entries predate fingerprinting and have unknowable vintage: they are still served rather than re-billing a whole corpus, but `check_semantic_cache` now warns with the count, so the "no signal at all" the report describes becomes a visible one; `--force` (or `GRAPHIFY_FORCE=1`) re-extracts them. Old-fingerprint entries are pruned by liveness only, never swept wholesale the way stale AST versions are — two hosts with different prompts can share one `graphify-out/`, and a wholesale sweep would have each run delete the other's entries. (The two monolith skills, aider and devin, inline their prompt instead of shipping a spec sidecar and stay on the unfingerprinted path for now.) +- Fix: the Stage 1 sensitive-directory check no longer silently drops legitimate source under `secrets/` or `credentials/` directories (#1943, thanks @HerenderKumar). A directory named `secrets/`, `.secrets/`, or `credentials/` is as often a real source package (Go `internal/secrets`, a `credentials/` service module) as a credential store, but `_is_sensitive` pruned everything beneath one wholesale, with no trace and no override. The dir list is now split: dedicated credential stores (`.ssh`, `.gnupg`, `.aws`, `.gcloud`) still drop everything unconditionally, while the ambiguous bare-name dirs spare genuine programming-language source — the same carve-out Stage 3 applies to keyword-named files (#1666), extracted into a shared `_is_graphable_source` predicate so the two stages can't drift. Rescued source still falls through the Stage 2/3 filename screens (`secrets/service_account.py` and `credentials/id_rsa` stay dropped), and data/config formats under those dirs (`secrets/db.json`, `.secrets/token.yaml`) remain flagged — those are exactly the formats credentials ship in. - Fix: PostgreSQL foreign-key `references` edges are no longer dropped when a routine in the same schema is unparseable (#1854, thanks @sekmur). `pg_introspect` builds one synthetic DDL document and parsed it with the function stubs emitted before the FK `ALTER TABLE`s, so a C-language (or otherwise unparseable) routine's stub parsed as a tree-sitter ERROR node that swallowed the trailing FK statements into the error region, losing every FK edge after it. The FK DDL is now emitted before the function stubs, so table-to-table `references` edges are produced first and can't be eaten by a later unparseable routine. ## 0.9.17 (2026-07-16) diff --git a/graphify/detect.py b/graphify/detect.py index ce1aad8568..433f4b0815 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -93,11 +93,21 @@ def _zip_within_caps(path: Path) -> bool: return False return True -# Parent directories whose contents are always sensitive. -# Checked against path.parts[:-1] (parents only) so a root-level file named -# "credentials" or "secrets" is not falsely flagged by this stage. -_SENSITIVE_DIRS = frozenset({ - ".ssh", ".gnupg", ".aws", ".gcloud", "secrets", ".secrets", "credentials", +# Dedicated credential-store directories: everything beneath them is sensitive, +# with no carve-out — a .py inside ~/.ssh or ~/.aws is tooling for key material, +# not a source package, and keys there are routinely extensionless. +# Both sets are checked against path.parts[:-1] (parents only) so a root-level +# file named "credentials" or "secrets" is not falsely flagged by this stage. +_CREDENTIAL_STORE_DIRS = frozenset({ + ".ssh", ".gnupg", ".aws", ".gcloud", +}) + +# Bare-name directories that are as often legitimate source packages (Go +# internal/secrets, a credentials/ service module) as credential stores. Their +# contents are sensitive EXCEPT genuine programming-language source, mirroring +# the Stage 3 keyword carve-out (#1666) at the directory level (#1943). +_AMBIGUOUS_SENSITIVE_DIRS = frozenset({ + "secrets", ".secrets", "credentials", }) # Files that may contain secrets - skip silently. These patterns are specific @@ -126,9 +136,11 @@ def _zip_within_caps(path: Path) -> bool: ] # Data/serialization extensions that commonly ARE secret stores when their name -# hits a generic keyword (credentials.json, secrets.yaml, token.toml). These stay -# subject to the Stage 3 keyword drop even though some route through the CODE path -# for manifest parsing — only real programming-language source is exempt (#1666). +# hits a generic keyword (credentials.json, secrets.yaml, token.toml) or they sit +# in an ambiguous sensitive dir (secrets/db.json). These stay subject to the +# Stage 1 ambiguous-dir drop and the Stage 3 keyword drop even though some route +# through the CODE path for manifest parsing — only real programming-language +# source is exempt (#1666, #1943). _SECRET_PRONE_DATA_EXTS = frozenset({ ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".config", ".xml", ".properties", ".env", ".txt", @@ -182,12 +194,28 @@ def _generic_keyword_hit(name: str) -> bool: _PAPER_SIGNAL_THRESHOLD = 3 # need at least this many signals to call it a paper +def _is_graphable_source(path: Path) -> bool: + """True for genuine programming-language source — the only category exempt + from the ambiguous-dir (Stage 1, #1943) and generic-keyword (Stage 3, #1666) + drops. Data/serialization formats are NOT exempt even though some route + through the CODE path for manifest parsing: credentials.json / secrets.yaml + are exactly the stores those stages must keep catching. + """ + return classify_file(path) == FileType.CODE and path.suffix.lower() not in _SECRET_PRONE_DATA_EXTS + + def _is_sensitive(path: Path) -> bool: """Return True if this file likely contains secrets and should be skipped.""" # Stage 1: any PARENT directory is a known secrets dir (parts[:-1] excludes # the filename itself so a root-level file named "credentials" is not falsely - # skipped — the name patterns in Stage 2 handle the filename). - if any(part in _SENSITIVE_DIRS for part in path.parts[:-1]): + # skipped — the name patterns in Stage 2 handle the filename). Dedicated + # credential stores drop everything unconditionally; ambiguous bare-name dirs + # (secrets/, credentials/) spare genuine source (#1943), which still falls + # through so Stages 2-3 screen its filename like anywhere else. + parents = path.parts[:-1] + if any(part in _CREDENTIAL_STORE_DIRS for part in parents): + return True + if any(part in _AMBIGUOUS_SENSITIVE_DIRS for part in parents) and not _is_graphable_source(path): return True # Stage 2: filename pattern match name = path.name @@ -202,9 +230,7 @@ def _is_sensitive(path: Path) -> bool: # secret stores this stage must catch. The specific Stage 2 patterns (.env, .pem, # id_rsa, ...) still apply to everything regardless of extension. if _generic_keyword_hit(name): - ext = path.suffix.lower() - is_source_code = classify_file(path) == FileType.CODE and ext not in _SECRET_PRONE_DATA_EXTS - return not is_source_code + return not _is_graphable_source(path) return False diff --git a/tests/test_detect.py b/tests/test_detect.py index 498a9aab8b..640d921ac0 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -1072,6 +1072,42 @@ def test_sensitive_token_config_yaml(): assert _is_sensitive(Path("token_config.yaml")) +# ── #1943: Stage 1 dir check gets the same source carve-out as Stage 3 ── +# secrets/ and credentials/ are as often real source packages (Go +# internal/secrets, a credentials/ service module) as credential stores. +# Genuine programming-language source beneath them must be graphed; data and +# config formats — the formats credentials actually ship in — stay dropped, +# and dedicated credential-store dirs (.ssh, .gnupg, .aws, .gcloud) keep +# dropping everything with no carve-out. + +def test_sensitive_does_not_flag_source_under_secrets_dir(): + # #1943 exact cases: real source under ambiguous dir names survives. + assert not _is_sensitive(Path("internal/secrets/vault.go")) + assert not _is_sensitive(Path("app/services/credentials/manager.py")) + +def test_sensitive_still_flags_data_under_secrets_dir(): + # #1943 guard: the carve-out is ONLY for real source — data/config files + # under ambiguous dirs remain flagged, whatever their nesting depth. + assert _is_sensitive(Path("secrets/db.json")) + assert _is_sensitive(Path(".secrets/token.yaml")) + assert _is_sensitive(Path("deploy/credentials/prod.env")) + assert _is_sensitive(Path("internal/secrets/README.md")) # docs are not source + +def test_sensitive_flags_everything_under_credential_store_dirs(): + # #1943: dedicated stores get no carve-out — even source-classified files + # inside .ssh/.gnupg/.aws/.gcloud stay dropped. + assert _is_sensitive(Path("/home/user/.ssh/config")) + assert _is_sensitive(Path(".aws/credentials")) + assert _is_sensitive(Path(".gnupg/helper.py")) + assert _is_sensitive(Path("backup/.gcloud/sync.sh")) + +def test_sensitive_dir_carveout_does_not_bypass_name_screens(): + # #1943: rescued source still falls through to Stages 2-3, so a file whose + # NAME is sensitive stays dropped even though its dir carve-out applied. + assert _is_sensitive(Path("secrets/service_account.py")) # Stage 2 pattern + assert _is_sensitive(Path("credentials/id_rsa")) # extensionless key + + # ── Generic keywords must be load-bearing: topic slugs are not secret stores ── # A keyword buried mid-phrase in a >=3-word descriptive name is a note ABOUT # the topic, not a credential file. It must not be silently dropped.