Repository navigation
[TH-9042] fix(harness): keep customers' credential files from the model on every reader #294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/TH-8804-source-subfolder
Are you sure you want to change the base?
Changes from all commits
2b69dbe
fcb1de7
51b8534
1b0e8dd
c35bd7e
ce8ab4f
6e11bdb
12e3e48
b9a1a67
9e1666b
47c6d64
b62feaa
e6efff1
a8da2d1
6da229c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,6 +44,13 @@ jobs: | |
| 'livekit-plugins-google==1.8.3' | ||
| 'livekit-plugins-openai==1.8.3' | ||
| 'livekit-plugins-silero==1.8.3' | ||
| - name: Pin the Claude Agent SDK to the lock's version | ||
| # The image installs the SDK at uv.lock's version, so the tests run the Claude Code CLI | ||
| # that ships (test_secret_paths.py drives it; TH-9042). Only the SDK is pinned here. | ||
| run: >- | ||
| python -m pip install "claude-agent-sdk==$(python -c "import tomllib; | ||
| print(next(p['version'] for p in tomllib.load(open('uv.lock', 'rb'))['package'] | ||
| if p['name'] == 'claude-agent-sdk'))")" | ||
| - name: Run engine + dispatch + acceptance tests | ||
| # Scoped to the suites this PR adds/relies on. The broader tests/ tree has | ||
| # pre-existing failures unrelated to the voice engine (CLI golden-path, | ||
|
|
@@ -91,6 +98,8 @@ jobs: | |
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Fix: use |
||
| tests/harness/test_secret_paths.py | ||
| tests/harness/test_import_roots.py | ||
| tests/harness/test_source_data_invariants_folder.py | ||
| tests/harness/test_hosted_authoring_entrypoint.py | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,13 +39,150 @@ def _resolved(raw: str, cwd: Path) -> Path: | |
|
|
||
| # Where credentials live in a sandbox or a checkout, and what reads them. A session never needs | ||
| # any of them: the platform hands secrets to processes as files and environment, not to a model. | ||
| _SECRET_PATHS = re.compile( | ||
| r"(^|/)(\.env[^/]*|\.claude|\.ssh|\.aws|\.config/gcloud|\.netrc|\.npmrc|\.pypirc" | ||
| r"|\.authoring-credentials)(/|$)" | ||
| r"|^/proc(/|$)|^/run/futureagi(/|$)|^/run/secrets(/|$)" | ||
| r"|(credential|secret|token|service[-_]?account)[^/]*\.json$|\.(pem|key|p12)$", | ||
| re.IGNORECASE, | ||
| # | ||
| # One list of globs, read the way gitignore and ripgrep read them: a glob matches a path when it | ||
| # matches the path or any folder above it, `*` stands for any run of characters within one name, | ||
| # `**/` at the start means "in any folder", and case is ignored. The kit's own tools, the scan's | ||
| # tools and the gate match paths against the regex built from it (`_SECRET_PATHS`); Claude Code | ||
| # gets the same list as Read deny rules (`secret_read_rules`), which its Grep and Glob turn into | ||
| # exclusions. A glob uses only letters, digits and `.-_*/`: the CLI skips a rule holding `[`, and | ||
| # the SDK splits its rules on commas and spaces. | ||
| SECRET_GLOBS: tuple[str, ...] = ( | ||
| "**/.env*", | ||
|
khushalsonawat marked this conversation as resolved.
Resolved
|
||
| "**/*.env", | ||
| "**/*.env.*", | ||
| "**/.claude", | ||
| "**/.ssh", | ||
| "**/.aws", | ||
| "**/.config/gcloud", | ||
| "**/.authoring-credentials", | ||
| "**/.netrc", | ||
| "**/.npmrc", | ||
| "**/.pypirc", | ||
| "**/*credential*.json", | ||
| "**/*secret*.json", | ||
| "**/*token*.json", | ||
| "**/*service-account*.json", | ||
| "**/*service_account*.json", | ||
| "**/*serviceaccount*.json", | ||
| "**/*secret*.yaml", | ||
| "**/*secret*.yml", | ||
| "**/*secret*.toml", | ||
| "**/*credential*.yaml", | ||
| "**/*credential*.yml", | ||
| "**/*credential*.toml", | ||
| "**/*secret*.ini", | ||
| "**/*secret*.cfg", | ||
| "**/*secret*.conf", | ||
| "**/*secret*.properties", | ||
| "**/*secret*.xml", | ||
| "**/*credential*.ini", | ||
| "**/*credential*.cfg", | ||
| "**/*credential*.conf", | ||
| "**/*credential*.properties", | ||
| "**/*credential*.xml", | ||
| "**/*firebase-adminsdk*.json", | ||
| "**/*credential*.csv", | ||
| "**/*accesskeys*.csv", | ||
| "**/rootkey*.csv", | ||
| "**/*secret*.txt", | ||
| "**/*credential*.txt", | ||
| "**/secrets/*.txt", | ||
| "**/.secrets", | ||
| "**/.dev.vars", | ||
| "**/.dev.vars.*", | ||
| "**/local.settings.json", | ||
| "**/key.properties", | ||
| "**/keystore.properties", | ||
| "**/*.p8", | ||
| "**/*.pk8", | ||
| "**/*.ppk", | ||
| "**/.config/gh/hosts.yml", | ||
| "**/.databrickscfg", | ||
| "**/.terraformrc", | ||
| "**/.cargo/credentials", | ||
| "**/.gem/credentials", | ||
| "**/*.pem", | ||
| "**/*.key", | ||
| "**/*.p12", | ||
| "**/*.pfx", | ||
| "**/*.jks", | ||
| "**/*.keystore", | ||
| "**/.kube/config", | ||
| "**/.kube/config.*", | ||
| "**/kubeconfig", | ||
| "**/kubeconfig.yaml", | ||
| "**/kubeconfig.yml", | ||
| "**/*.kubeconfig", | ||
| "**/.docker/config.json", | ||
| "**/.docker/contexts", | ||
| "**/.dockercfg", | ||
| "**/.pgpass", | ||
| "**/.htpasswd", | ||
| "**/.git-credentials", | ||
| "**/.my.cnf", | ||
| "**/.s3cfg", | ||
| "**/.boto", | ||
| "**/.vault-token", | ||
| "**/id_rsa", | ||
| "**/id_dsa", | ||
| "**/id_ecdsa", | ||
| "**/id_ed25519", | ||
| "**/id_ecdsa_sk", | ||
| "**/id_ed25519_sk", | ||
| "**/*.tfstate", | ||
| "**/*.tfstate.backup", | ||
| "**/*.tfstate.*.backup", | ||
| "**/*.tfvars", | ||
| "**/*.tfvars.json", | ||
| "**/*-key.json", | ||
| "**/*_key.json", | ||
| "**/*-sa.json", | ||
| ) | ||
| # Every glob with no `*` after its `**/` names one exact file or folder, and its `.bak` copy is a | ||
| # credential too (`.netrc.bak`), so those forms are derived from the list rather than written | ||
| # out: a name added to SECRET_GLOBS gets its backup form with it. `*.kubeconfig` has a `*` but is | ||
| # kept as well. `.kube/config.*` already covers its own. | ||
| _WITH_BACKUP: tuple[str, ...] = ( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3. The names added since the first round have no Fix: add them, or derive the |
||
| *(glob for glob in SECRET_GLOBS if "*" not in glob.removeprefix("**/")), | ||
| "**/*.kubeconfig", | ||
| ) | ||
| ALL_SECRET_GLOBS: tuple[str, ...] = SECRET_GLOBS + tuple( | ||
| dict.fromkeys(f"{glob}.bak" for glob in _WITH_BACKUP) | ||
| ) | ||
| # The settings files a model citation may name, under its own rule (`model_citation`). | ||
| _ENV_GLOBS = ("**/.env*", "**/*.env", "**/*.env.*") | ||
| # The platform's own roots, which are paths, not names. | ||
| _ROOTS = r"^/proc(/|$)|^/run/futureagi(/|$)|^/run/secrets(/|$)" | ||
|
|
||
|
|
||
| def _glob_pattern(glob: str) -> str: | ||
| """The regex for one `**/` glob: the name parts at any depth, as the path or a folder above it.""" | ||
| body = glob.removeprefix("**/") | ||
| return "(^|/)" + "".join("[^/]*" if char == "*" else re.escape(char) for char in body) + "(/|$)" | ||
|
|
||
|
|
||
| def _compiled(globs: tuple[str, ...]) -> re.Pattern[str]: | ||
| return re.compile("|".join([*(_glob_pattern(glob) for glob in globs), _ROOTS]), re.IGNORECASE) | ||
|
|
||
|
|
||
| _SECRET_PATHS = _compiled(ALL_SECRET_GLOBS) | ||
| # The same, less those settings files: what a model citation may not name. | ||
| _SECRET_BUT_ENV = _compiled(tuple(glob for glob in ALL_SECRET_GLOBS if glob not in _ENV_GLOBS)) | ||
|
|
||
|
|
||
| def secret_text(text: str, *, env_allowed: bool = False) -> bool: | ||
| """Whether a path, as given and never resolved, names a place on the list. Callers pass a | ||
| path relative to the source, so the folders the source sits in don't count.""" | ||
| return bool((_SECRET_BUT_ENV if env_allowed else _SECRET_PATHS).search(text)) | ||
|
|
||
|
|
||
| def secret_read_rules() -> list[str]: | ||
| """Claude Code's Read deny rules for the list, anchored at the file system's root (`//`), so | ||
| they hold wherever a search starts and whatever the session's folder is.""" | ||
| return [f"Read(//{glob})" for glob in ALL_SECRET_GLOBS] | ||
|
|
||
|
|
||
| # A search rooted here would reach into where credentials live. | ||
| _SECRET_ROOTS = re.compile(r"^/(proc|run/futureagi|run/secrets)/") | ||
| SECRET_REFUSAL = ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ | |
| import unicodedata | ||
| from pathlib import Path | ||
|
|
||
| from .backends.files import secret_text | ||
| from .contract import AgentContract | ||
| from .runtime_selection import charged_path | ||
| from .source_folder import is_within | ||
|
|
@@ -570,6 +571,73 @@ def _reads_as_a_setting(name: str) -> bool: | |
| return "." in name and not has_digit | ||
|
|
||
|
|
||
| _ENV_SETTING = re.compile(r"\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*[=:]") | ||
| # A setting that names a model, by its words. ENGINE alone also names databases (DB_ENGINE), so it | ||
| # counts only beside a provider's word (OPENAI_ENGINE, AZURE_OPENAI_ENGINE). | ||
| _MODEL_WORDS = frozenset({"MODEL", "DEPLOYMENT", "LLM"}) | ||
| _ENGINE_PROVIDERS = frozenset({"OPENAI", "AZURE"}) | ||
| # What marks a setting that holds a secret, anywhere in its name, joined words included | ||
| # (LLM_APIKEY). A URL can carry a password, and a model's name is never one. | ||
| _SECRET_PARTS = ("KEY", "TOKEN", "SECRET", "PASS", "PWD", "AUTH", "CREDENTIAL", "URL", "URI", "DSN") | ||
|
|
||
|
|
||
| def _names_a_model(line: str) -> bool: | ||
| """Whether a `.env` line sets a model: a setting with the word MODEL, DEPLOYMENT or LLM, or | ||
| ENGINE beside OPENAI or AZURE (OPENAI_MODEL, AZURE_OPENAI_DEPLOYMENT, LLM, OPENAI_ENGINE), and | ||
| nothing anywhere in its name that marks a secret (MODEL_API_KEY, LLM_APIKEY, DB_ENGINE_URL). | ||
| A line that sets nothing (no `=` or `:` after a name) doesn't.""" | ||
| found = _ENV_SETTING.match(line) | ||
| if found is None: | ||
| return False | ||
| setting = found.group(1).upper() | ||
| if any(part in setting for part in _SECRET_PARTS): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Fix: in a |
||
| return False | ||
| words = set(re.split(r"[_.-]+", setting)) | ||
| return bool(words & _MODEL_WORDS) or ("ENGINE" in words and bool(words & _ENGINE_PROVIDERS)) | ||
|
|
||
|
|
||
| _ENV_DEFAULT = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*):?-([^{}$]*)\}") | ||
|
|
||
|
|
||
| def _env_value(line: str) -> str | None: | ||
| """The value a `.env` line gives its setting: quoted, or up to the first space, `;` or `,`. | ||
| None when the line sets nothing.""" | ||
| found = _ENV_SETTING.match(line) | ||
| if found is None: | ||
| return None | ||
| rest = line[found.end():].lstrip() | ||
| if rest[:1] in ("'", '"'): | ||
| end = rest.find(rest[0], 1) | ||
| if end < 0: | ||
| return None | ||
| value = rest[1:end] | ||
| else: | ||
| value = re.split(r"[\s;,]", rest, maxsplit=1)[0] | ||
| # Compose's default, ${MODEL:-gpt-4o}: the default is what the setting holds when unset. Only | ||
| # when the braced variable is the setting itself or another model setting | ||
| # (${DEFAULT_MODEL:-gpt-4o}): ${DB_PASSWORD:-guess} is a secret's. | ||
| default = _ENV_DEFAULT.fullmatch(value) | ||
| if default and ( | ||
| default.group(1) == found.group(1) or _names_a_model(f"{default.group(1)}=") | ||
| ): | ||
| return default.group(2) | ||
| return value | ||
|
|
||
|
|
||
| # A Vertex model name carries its version after an @ (claude-3-5-sonnet@20240620); any other @ in | ||
| # a value is a login (user@host, user:password@host). | ||
| _VERSIONED_NAME = re.compile(r"[^@:]+@(?:\d+|latest)", re.IGNORECASE) | ||
|
|
||
|
|
||
| def _env_value_is_a_locator(value: str) -> bool: | ||
| """A .env value that is a URL, or holds a login: MODEL_SERVER=postgres://admin:pw@db/app is | ||
| where a model is served from, with its credentials, not which model. `:` alone is a model | ||
| name's (anthropic.claude-3-5-sonnet-20240620-v1:0), and so is a version after an @.""" | ||
| if "://" in value or "\uff20" in value: | ||
| return True | ||
| return "@" in value and not _VERSIONED_NAME.fullmatch(value) | ||
|
|
||
|
|
||
| def model_problems(contract: AgentContract, source_root: Path) -> list[str]: | ||
| """Problems with contract.model, checked against the cited line. On success, strips name and | ||
| provider and stores path as the resolved file relative to the root, in POSIX form. Resolves | ||
|
|
@@ -616,6 +684,17 @@ def model_problems(contract: AgentContract, source_root: Path) -> list[str]: | |
| resolved = resolved_in_source(root, path) | ||
| if resolved is None: | ||
| return [f"model: {shown_path} is not a file in the source"] | ||
| # Judged where it resolves, inside the source, before anything is opened, so a file that | ||
| # exists and one that doesn't get the same answer. A `.env` file is the one listed place a | ||
| # model may be cited from (it is where OPENAI_MODEL and the like are set), but only on a | ||
| # line whose setting names a model, and never with its length told. | ||
| relative = resolved.relative_to(root).as_posix() | ||
| if secret_text(relative, env_allowed=True): | ||
| return [ | ||
| f"model: {shown_path} is kept out of this session because it holds credentials; cite " | ||
| "the line in the agent's code or configuration the model is set on, or leave model out" | ||
| ] | ||
| env_file = secret_text(relative) | ||
| if is_prose_file(resolved): | ||
| # A README, a text file or a prompt template says which model is used; the code or its | ||
| # configuration sets it. | ||
|
|
@@ -632,7 +711,21 @@ def model_problems(contract: AgentContract, source_root: Path) -> list[str]: | |
| ] | ||
| lines = source_lines(raw) | ||
| if model.line > len(lines): | ||
| if env_file: | ||
| return [f"model: line {model.line} is outside {shown_path}"] | ||
| return [f"model: line {model.line} is outside {shown_path}, which has {len(lines)} lines"] | ||
| # In a .env file the name must be the value of the one setting that names the model, so a | ||
| # guess is never checked against another setting on the same line. | ||
| if env_file and ( | ||
| not _names_a_model(lines[model.line - 1]) | ||
| or _env_value(lines[model.line - 1]) != name | ||
| or _env_value_is_a_locator(name) | ||
| ): | ||
| return [ | ||
| f"model: in a .env file, cite the line whose setting names the model (such as " | ||
| f"OPENAI_MODEL), with the name as its value; {shown_path} line {model.line} sets " | ||
| "another setting or another value" | ||
| ] | ||
| suffix = kind_suffix(resolved) | ||
| dashes, ini = suffix in _DASH_COMMENT_SUFFIXES, suffix in _INI_SUFFIXES | ||
| triple = suffix in _TRIPLE_SUFFIXES | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.