Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
2b69dbe
fix(harness): keep customers' credential files from the model on ever…
khushalsonawat Nov 11, 2026
fcb1de7
test(harness): hold the credential list on every reader, with the rea…
khushalsonawat Nov 11, 2026
51b8534
fix(harness): answer the change review of the credential list
khushalsonawat Nov 11, 2026
1b0e8dd
Merge remote-tracking branch 'origin/feat/TH-8804-source-subfolder' i…
khushalsonawat Nov 11, 2026
c35bd7e
fix(harness): answer Dileep's review of the credential list
khushalsonawat Nov 11, 2026
ce8ab4f
fix(harness): answer the security check of Dileep's fixes
khushalsonawat Nov 11, 2026
6e11bdb
fix(harness): answer the second reviewer of Dileep's fixes
khushalsonawat Nov 11, 2026
12e3e48
Merge remote-tracking branch 'origin/feat/TH-8804-source-subfolder' i…
khushalsonawat Nov 11, 2026
b9a1a67
test(harness): hold the base's new block-comment check to the shared …
khushalsonawat Nov 11, 2026
9e1666b
fix(harness): hide .dev.vars.<env>, rootkey.csv and every .p8, and ta…
khushalsonawat Nov 12, 2026
47c6d64
fix(harness): hide .pk8 keys and rootkey copies, and accept a default…
khushalsonawat Nov 12, 2026
b62feaa
test(harness): a model-named braced default that holds a secret is st…
khushalsonawat Nov 12, 2026
e6efff1
fix(harness): answer Dileep's second review: two more readers, .bak c…
khushalsonawat Nov 12, 2026
a8da2d1
Merge feat/TH-8804-source-subfolder into feat/TH-9042-secret-file-list
khushalsonawat Nov 12, 2026
6da229c
fix(harness): keep listed files out of runtime-repair evidence, and a…
khushalsonawat Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/sdk-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Comment thread
khushalsonawat marked this conversation as resolved.
Resolved

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.

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
Expand Down
9 changes: 8 additions & 1 deletion src/fi/alk/harness/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
WorkerSpec,
tool_input_schema,
)
from .files import secret_read_rules

# The loop is Claude Code; the model it bills is not.
DEFAULT_MODEL = "gemini-3.7-flash"
Expand Down Expand Up @@ -475,11 +476,17 @@ def _options(self, spec: SessionSpec) -> ClaudeAgentOptions:
# is consulted, so a stage could rewrite an artifact by hand and skip the tool whose
# whole job is to validate that change.
options.permission_mode = "default"
options.disallowed_tools = [name for name in UNWANTED if name not in spec.builtins]
options.hooks = gate_hooks(allowed)
options.can_use_tool = spec.permission_override or permission_gate(
spec.ask, allowed
)
# Every session, gated or not, carries the secret list as Read deny rules: Claude Code's own
# Grep and Glob turn them into exclusions, which the gate (it sees only a call's
# arguments) can't do for a search that starts above a credential file.
options.disallowed_tools = [
*([name for name in UNWANTED if name not in spec.builtins] if spec.gated else []),
*secret_read_rules(),
]
if spec.thinking:
options.thinking = thinking_config()
return options
Expand Down
149 changes: 143 additions & 6 deletions src/fi/alk/harness/backends/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*",
Comment thread
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, ...] = (

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.

*(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 = (
Expand Down
12 changes: 11 additions & 1 deletion src/fi/alk/harness/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ async def change_world(args: dict[str, Any]) -> dict[str, Any]:
)
async def declare_tool(args: dict[str, Any]) -> dict[str, Any]:
from .amend import _persist
from .backends.files import secret_text
from .contract import ToolSpec, validate_contract
from .source_folder import agent_scope, in_agent_scope

Expand All @@ -290,7 +291,16 @@ async def declare_tool(args: dict[str, Any]) -> dict[str, Any]:
quote = str(args.get("evidence_quote") or "").strip()
root = source_root or Path("/nonexistent")
target = (root / str(args.get("evidence_file") or "")).resolve()
if source_root is None or root.resolve() not in target.parents or not target.is_file():
if source_root is None or root.resolve() not in target.parents:
return _err("evidence_file must be a file inside the agent's source")
if secret_text(target.relative_to(root.resolve()).as_posix()):
# Judged where it resolves, before the file is opened: a listed file, a link to one
# and one that is missing get this same answer, and a guess is never checked.
return _err(
f"{args.get('evidence_file')} is kept out of this session because it holds "
"credentials; name a file in the agent's code or configuration"
)
if not target.is_file():
return _err("evidence_file must be a file inside the agent's source")
folder = contract_folder(contract)
scope = agent_scope(root, folder)
Expand Down
13 changes: 8 additions & 5 deletions src/fi/alk/harness/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,10 @@ def read_only_session(
)


def secret_refusal(tool: str, arguments: dict[str, Any]) -> str:
"""Why this call would expose a secret or skip a proof, or empty when it would not."""
def secret_refusal(tool: str, arguments: dict[str, Any], cwd: str | None = None) -> str:
"""Why this call would expose a secret or skip a proof, or empty when it would not. ``cwd`` is
the session's own folder, from the hook's payload: a relative path resolves there, as the
tool itself resolves it, not in the harness's folder."""
if tool in ("Write", "Edit") and _PROVED_FILES.search(str(arguments.get("file_path") or "")):
return (
"That file is part of the proved environment and only changes through the tool that "
Expand All @@ -318,9 +320,10 @@ def secret_refusal(tool: str, arguments: dict[str, Any]) -> str:
if arguments.get(key)
]
pattern = str(arguments.get("pattern") or "") if tool == "Glob" else ""
if any(secret_path(one) for one in [*paths, pattern] if one):
base = Path(cwd) if cwd else None
if any(secret_path(one, base) for one in [*paths, pattern] if one):
return SECRET_REFUSAL
if tool in ("Grep", "Glob") and any(above_secrets(one) for one in paths):
if tool in ("Grep", "Glob") and any(above_secrets(one, base) for one in paths):
return SECRET_REFUSAL
return ""

Expand Down Expand Up @@ -360,7 +363,7 @@ async def refuse(
name = str(payload.get("tool_name") or "")
if name in permitted:
arguments = payload.get("tool_input") or {}
refusal = secret_refusal(name, arguments)
refusal = secret_refusal(name, arguments, payload.get("cwd"))
if refusal:
return {
"hookSpecificOutput": {
Expand Down
93 changes: 93 additions & 0 deletions src/fi/alk/harness/model_citation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):

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.

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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading