From db06eaa4729ee3b252d44b9f3d6b2e464122863a Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Thu, 6 Aug 2026 19:48:54 +0300 Subject: [PATCH] feat: block secret externalization --- docs/reference/config.md | 14 ++ pyproject.toml | 1 + src/forge/security/__init__.py | 1 + src/forge/security/secrets.py | 234 ++++++++++++++++++ src/forge/workflow/nodes/implement_review.py | 10 + .../workflow/nodes/post_merge_summary.py | 10 + src/forge/workflow/nodes/pr_creation.py | 9 + src/forge/workflow/nodes/qa_handler.py | 9 + src/forge/workspace/git_ops.py | 3 +- tests/unit/security/test_secrets.py | 191 ++++++++++++++ .../workspace/test_git_ops_secret_scan.py | 34 +++ uv.lock | 17 +- 12 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 src/forge/security/__init__.py create mode 100644 src/forge/security/secrets.py create mode 100644 tests/unit/security/test_secrets.py create mode 100644 tests/unit/workspace/test_git_ops_secret_scan.py diff --git a/docs/reference/config.md b/docs/reference/config.md index af37a33f..a497940e 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -346,3 +346,17 @@ These variables are used by `docker-compose.yml`, `devtools/docker-compose.dev.y ### MCP Servers MCP server configuration lives in `mcp-servers.json`, not `.env`. See the [MCP servers section](https://github.com/forge-sdlc/forge/blob/main/mcp-servers.json) of the repository. +# Secret scanning boundary + +Forge composes secret detection into the common repository-change gate before a +workflow branch is pushed. Workflow nodes also explicitly scan text they know came +from an agent, such as pull-request descriptions, Q&A responses, review objections, +and post-merge summaries. Trusted Jira and GitHub clients remain transport components; +they do not globally classify every system message as agent output. Detection, +timeout, or scanner failure blocks the affected publication, and diagnostics contain +only the detector name, filename, and line number. + +Repositories may commit a `detect-secrets` `.secrets.baseline`. Forge reads the +baseline exclusively from the trusted origin base revision, so an agent cannot alter +the allowlist during a run. Keep baselines narrowly scoped and review baseline changes +as security-sensitive policy changes. diff --git a/pyproject.toml b/pyproject.toml index e0834dc0..27570cb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "opentelemetry-exporter-otlp>=1.27.0", "md-to-adf>=1.1.0", "pyyaml>=6.0.0", + "detect-secrets>=1.5.0,<2", ] [project.optional-dependencies] diff --git a/src/forge/security/__init__.py b/src/forge/security/__init__.py new file mode 100644 index 00000000..926cc197 --- /dev/null +++ b/src/forge/security/__init__.py @@ -0,0 +1 @@ +"""Trusted host-side security boundaries.""" diff --git a/src/forge/security/secrets.py b/src/forge/security/secrets.py new file mode 100644 index 00000000..796a74ff --- /dev/null +++ b/src/forge/security/secrets.py @@ -0,0 +1,234 @@ +"""Fail-closed secret detection for repository output and outbound text.""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeoutError +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from detect_secrets import SecretsCollection +from detect_secrets.settings import default_settings + +if TYPE_CHECKING: + from forge.workspace.output_validation import OutputValidationContext + + +class SecretScanError(RuntimeError): + """A scan could not establish that output is safe to externalize.""" + + +@dataclass(frozen=True) +class SecretFinding: + """Redacted scanner result. The matched value is deliberately unavailable.""" + + rule_id: str + location: str + line: int + + +@dataclass(frozen=True) +class AgentOutputContext: + """Provenance supplied by a workflow node for generated text.""" + + source: str + ticket_key: str = "" + repository: str = "" + workflow_stage: str = "" + + +@dataclass(frozen=True) +class _ScannedFinding: + public: SecretFinding + secret_hash: str + + +class SecretDetectedError(SecretScanError): + """One or more secrets were found.""" + + def __init__(self, findings: list[SecretFinding]): + self.findings = findings + locations = ", ".join(f"{f.location}:{f.line} ({f.rule_id})" for f in findings[:10]) + suffix = "" if len(findings) <= 10 else f" and {len(findings) - 10} more" + super().__init__(f"Secret scan blocked externalization: {locations}{suffix}") + + +def _scan_files(files: list[tuple[Path, str]]) -> list[_ScannedFinding]: + findings: list[_ScannedFinding] = [] + with default_settings(): + for path, display_name in files: + secrets = SecretsCollection() + secrets.scan_file(str(path)) + for detected in secrets.data.get(str(path), set()): + findings.append( + _ScannedFinding( + public=SecretFinding( + rule_id=detected.type, + location=display_name, + line=detected.line_number, + ), + secret_hash=detected.secret_hash, + ) + ) + return findings + + +def _run_bounded(files: list[tuple[Path, str]], timeout_seconds: float) -> list[_ScannedFinding]: + pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="secret-scan") + future = pool.submit(_scan_files, files) + try: + return future.result(timeout=timeout_seconds) + except FutureTimeoutError as exc: + future.cancel() + raise SecretScanError(f"Secret scan timed out after {timeout_seconds:g}s") from exc + except SecretScanError: + raise + except Exception as exc: + raise SecretScanError(f"Secret scanner failed ({type(exc).__name__})") from exc + finally: + pool.shutdown(wait=False, cancel_futures=True) + + +def scan_text(text: str, *, location: str, timeout_seconds: float = 10) -> None: + """Scan outbound text before it is posted. Raises with redacted details.""" + if not text: + return + try: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".txt") as output: + output.write(text) + output.flush() + findings = _run_bounded([(Path(output.name), location)], timeout_seconds) + except SecretScanError: + raise + except Exception as exc: + raise SecretScanError(f"Secret scanner failed ({type(exc).__name__})") from exc + if findings: + raise SecretDetectedError([finding.public for finding in findings]) + + +def scan_agent_output( + text: str, *, context: AgentOutputContext, timeout_seconds: float = 10 +) -> None: + """Scan text explicitly identified by its workflow node as agent-derived.""" + + location = context.source + if context.workflow_stage: + location = f"{location} ({context.workflow_stage})" + scan_text(text, location=location, timeout_seconds=timeout_seconds) + + +def _git(repo: Path, *args: str) -> str: + try: + result = subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, timeout=15, check=True + ) + except (OSError, subprocess.SubprocessError) as exc: + raise SecretScanError(f"Unable to prepare secret scan ({type(exc).__name__})") from exc + return result.stdout + + +def _trusted_base(repo: Path) -> str: + for candidate in ("origin/HEAD", "origin/main", "origin/master"): + try: + return _git(repo, "merge-base", "HEAD", candidate).strip() + except SecretScanError: + continue + raise SecretScanError("Unable to resolve a trusted origin base for secret scanning") + + +def _baseline_hashes(repo: Path, base: str) -> set[str]: + result = subprocess.run( + ["git", "show", f"{base}:.secrets.baseline"], + cwd=repo, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + if result.returncode != 0: + return set() + try: + baseline = json.loads(result.stdout) + return { + item["hashed_secret"] + for entries in baseline.get("results", {}).values() + for item in entries + if isinstance(item.get("hashed_secret"), str) + } + except (TypeError, json.JSONDecodeError, KeyError) as exc: + raise SecretScanError("Trusted .secrets.baseline is invalid") from exc + + +def scan_repository(repo: Path, *, timeout_seconds: float = 30) -> None: + """Scan all changed and untracked output relative to the trusted origin base.""" + repo = repo.resolve() + base = _trusted_base(repo) + changed = set(_git(repo, "diff", "--name-only", "--diff-filter=ACMRT", "-z", base).split("\0")) + changed.update(_git(repo, "ls-files", "--others", "--exclude-standard", "-z").split("\0")) + changed.discard("") + + files: list[tuple[Path, str]] = [] + for name in sorted(changed): + path = repo / name + try: + resolved = path.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise SecretScanError(f"Unable to inspect output path {name!r}") from exc + if not resolved.is_relative_to(repo) or path.is_symlink(): + raise SecretScanError(f"Unsafe output path cannot be scanned: {name!r}") + if resolved.is_file(): + files.append((resolved, name)) + + allowed = _baseline_hashes(repo, base) + findings = [ + finding + for finding in _run_bounded(files, timeout_seconds) + if finding.secret_hash not in allowed + ] + if findings: + raise SecretDetectedError([finding.public for finding in findings]) + + +class RepositorySecretValidator: + """Compose secret detection into Forge's common pre-push gate.""" + + name = "repository_secret_scan" + + def __init__(self, *, timeout_seconds: float = 30): + self.timeout_seconds = timeout_seconds + + def validate(self, context: OutputValidationContext) -> None: + repo = context.repo_path.resolve() + with tempfile.TemporaryDirectory(prefix="forge-secret-scan-") as directory: + files: list[tuple[Path, str]] = [] + for index, entry in enumerate(context.changed_entries): + if entry.status == "D": + continue + try: + result = subprocess.run( + ["git", "show", f"{context.head_ref}:{entry.path}"], + cwd=repo, + capture_output=True, + timeout=15, + check=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise SecretScanError( + f"Unable to inspect output blob {entry.path!r} ({type(exc).__name__})" + ) from exc + scan_path = Path(directory) / str(index) + scan_path.write_bytes(result.stdout) + files.append((scan_path, entry.path)) + + allowed = _baseline_hashes(repo, context.base_ref) + findings = [ + finding + for finding in _run_bounded(files, self.timeout_seconds) + if finding.secret_hash not in allowed + ] + if findings: + raise SecretDetectedError([finding.public for finding in findings]) diff --git a/src/forge/workflow/nodes/implement_review.py b/src/forge/workflow/nodes/implement_review.py index 88dcb1ce..98d112ee 100644 --- a/src/forge/workflow/nodes/implement_review.py +++ b/src/forge/workflow/nodes/implement_review.py @@ -12,6 +12,7 @@ from forge.integrations.jira.client import JiraClient from forge.prompts import load_prompt from forge.sandbox import ContainerRunner +from forge.security.secrets import AgentOutputContext, scan_agent_output from forge.workflow.feature.state import FeatureState as WorkflowState from forge.workflow.nodes.code_review import run_post_change_review, sync_pr_description from forge.workflow.nodes.workspace_setup import prepare_workspace @@ -458,6 +459,15 @@ async def _post_review_objection( f"{objections}\n\n" f"*Please confirm whether to proceed as requested or withdraw.*" ) + scan_agent_output( + comment, + context=AgentOutputContext( + source="review objection", + ticket_key=ticket_key, + repository=f"{owner}/{repo}", + workflow_stage="implement_review", + ), + ) if pr_number: await github.create_issue_comment(owner, repo, pr_number, comment) await post_status_comment( diff --git a/src/forge/workflow/nodes/post_merge_summary.py b/src/forge/workflow/nodes/post_merge_summary.py index 0e6f2a2c..fc1b4790 100644 --- a/src/forge/workflow/nodes/post_merge_summary.py +++ b/src/forge/workflow/nodes/post_merge_summary.py @@ -4,6 +4,7 @@ from forge.config import get_settings from forge.integrations.jira.client import JiraClient +from forge.security.secrets import AgentOutputContext, scan_agent_output from forge.workflow.bug.state import BugState logger = logging.getLogger(__name__) @@ -39,6 +40,15 @@ async def post_merge_summary(state: BugState) -> BugState: current_repo=current_repo, pr_urls=pr_urls, ) + scan_agent_output( + comment, + context=AgentOutputContext( + source="post-merge summary", + ticket_key=ticket_key, + repository=current_repo, + workflow_stage="post_merge_summary", + ), + ) await jira.add_comment(ticket_key, comment) logger.info(f"Posted post-merge summary to {ticket_key}") except Exception as e: diff --git a/src/forge/workflow/nodes/pr_creation.py b/src/forge/workflow/nodes/pr_creation.py index f63ce47b..47798572 100644 --- a/src/forge/workflow/nodes/pr_creation.py +++ b/src/forge/workflow/nodes/pr_creation.py @@ -13,6 +13,7 @@ from forge.models.workflow import ForgeLabel, TicketType from forge.orchestrator.checkpointer import set_pr_ticket_index from forge.prompts import load_prompt +from forge.security.secrets import AgentOutputContext, scan_agent_output from forge.workflow.nodes.code_review import sync_pr_description from forge.workflow.nodes.post_merge_summary import _extract_impact from forge.workflow.pr_state import save_active_pull_request @@ -76,6 +77,14 @@ async def open_pull_request_from_fork( draft: bool = False, ) -> dict: """Open a pull request from the prepared fork branch to upstream.""" + scan_agent_output( + f"{title}\n{body}", + context=AgentOutputContext( + source="pull request title and body", + repository=f"{target.owner}/{target.repo}", + workflow_stage="pr_creation", + ), + ) return await github.create_pull_request( owner=target.owner, repo=target.repo, diff --git a/src/forge/workflow/nodes/qa_handler.py b/src/forge/workflow/nodes/qa_handler.py index 183ef287..2c0bf951 100644 --- a/src/forge/workflow/nodes/qa_handler.py +++ b/src/forge/workflow/nodes/qa_handler.py @@ -7,6 +7,7 @@ from forge.integrations.agents import ForgeAgent from forge.integrations.github.client import GitHubClient from forge.integrations.jira.client import JiraClient +from forge.security.secrets import AgentOutputContext, scan_agent_output from forge.workflow.feature.state import FeatureState as WorkflowState from forge.workflow.utils import update_state_timestamp @@ -29,6 +30,14 @@ async def _post_qa_response( artifact_type: str, body: str, ) -> None: + scan_agent_output( + body, + context=AgentOutputContext( + source=f"{artifact_type} Q&A response", + ticket_key=ticket_key, + workflow_stage="qa_handler", + ), + ) pr_target = _artifact_pr_target(state, artifact_type) if pr_target: repo_full, pr_number = pr_target diff --git a/src/forge/workspace/git_ops.py b/src/forge/workspace/git_ops.py index 4500bd32..6aecdd0f 100644 --- a/src/forge/workspace/git_ops.py +++ b/src/forge/workspace/git_ops.py @@ -6,6 +6,7 @@ from pathlib import Path from forge.config import get_settings +from forge.security.secrets import RepositorySecretValidator from forge.utils.redaction import redact_secrets from forge.workspace.manager import Workspace from forge.workspace.output_validation import ( @@ -36,7 +37,7 @@ def __init__( """ self.workspace = workspace self.settings = get_settings() - self.output_validators = tuple(output_validators) + self.output_validators = (RepositorySecretValidator(), *tuple(output_validators)) self.output_base_ref = output_base_ref # Set by workspace recovery when this instance represents a replacement # clone rather than the workspace recorded in workflow state. The path diff --git a/tests/unit/security/test_secrets.py b/tests/unit/security/test_secrets.py new file mode 100644 index 00000000..cda1d2b2 --- /dev/null +++ b/tests/unit/security/test_secrets.py @@ -0,0 +1,191 @@ +"""Tests for the trusted secret-scanning boundary.""" + +import json +import subprocess +import time +from pathlib import Path + +import pytest +from detect_secrets.core.potential_secret import PotentialSecret + +from forge.security.secrets import ( + AgentOutputContext, + RepositorySecretValidator, + SecretDetectedError, + SecretScanError, + scan_agent_output, + scan_repository, + scan_text, +) +from forge.workspace.output_validation import ChangedEntry, OutputValidationContext + +AWS_KEY = "AKIAIOSFODNN7EXAMPLE" + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True) + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-b", "main") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test") + (repo / "README.md").write_text("safe\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "base") + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + return repo + + +def test_scan_text_blocks_provider_key_without_disclosing_value() -> None: + with pytest.raises(SecretDetectedError) as caught: + scan_text(f"aws_access_key_id = {AWS_KEY}", location="PR body") + + assert caught.value.findings[0].rule_id == "AWS Access Key" + assert caught.value.findings[0].location == "PR body" + assert AWS_KEY not in str(caught.value) + assert AWS_KEY not in repr(caught.value.findings) + + +def test_scan_text_allows_ordinary_content() -> None: + scan_text("Fix request parsing and add regression coverage.", location="comment") + + +def test_agent_output_uses_explicit_provenance() -> None: + with pytest.raises(SecretDetectedError) as caught: + scan_agent_output( + f"aws_access_key_id = {AWS_KEY}", + context=AgentOutputContext( + source="PR body", ticket_key="FORGE-1", workflow_stage="pr_creation" + ), + ) + + assert caught.value.findings[0].location == "PR body (pr_creation)" + + +def test_scan_text_blocks_generic_high_entropy_token() -> None: + token = "v8N2qL7mR4xP9cT6zW3kJ5hF1sD0aB7u" + with pytest.raises(SecretDetectedError) as caught: + scan_text(f'api_token = "{token}"', location="artifact") + + assert token not in str(caught.value) + + +def test_scan_repository_covers_untracked_files(repository: Path) -> None: + (repository / "generated.env").write_text(f"aws_access_key_id={AWS_KEY}\n") + + with pytest.raises(SecretDetectedError) as caught: + scan_repository(repository) + + assert caught.value.findings[0].location == "generated.env" + assert AWS_KEY not in str(caught.value) + + +def test_scan_repository_covers_committed_and_unstaged_changes(repository: Path) -> None: + output = repository / "output.txt" + output.write_text(f"aws_access_key_id={AWS_KEY}\n") + _git(repository, "add", "output.txt") + _git(repository, "commit", "-m", "agent output") + (repository / "README.md").write_text("safe unstaged change\n") + + with pytest.raises(SecretDetectedError): + scan_repository(repository) + + +def test_trusted_base_baseline_allows_known_finding(repository: Path) -> None: + tracked = repository / "example.txt" + tracked.write_text(f"aws_access_key_id={AWS_KEY}\n") + baseline = { + "version": "1.5.0", + "results": { + "example.txt": [ + { + "type": "AWS Access Key", + "filename": "example.txt", + "hashed_secret": PotentialSecret.hash_secret(AWS_KEY), + } + ] + }, + } + (repository / ".secrets.baseline").write_text(json.dumps(baseline)) + _git(repository, "add", ".") + _git(repository, "commit", "-m", "trusted baseline") + _git(repository, "update-ref", "refs/remotes/origin/main", "HEAD") + tracked.write_text(f"note=safe\naws_access_key_id={AWS_KEY}\n") + + scan_repository(repository) + + +def test_binary_file_does_not_crash_scanner(repository: Path) -> None: + (repository / "image.bin").write_bytes(b"\x00\xff\x10\x80") + scan_repository(repository) + + +def test_repository_validator_uses_common_gate_context(repository: Path) -> None: + (repository / "generated.env").write_text(f"aws_access_key_id={AWS_KEY}\n") + _git(repository, "add", "generated.env") + _git(repository, "commit", "-m", "generated") + base = subprocess.run( + ["git", "rev-parse", "origin/main"], + cwd=repository, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + head = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repository, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + # The gate must inspect the exact pushed tree, not a potentially different worktree. + (repository / "generated.env").write_text("safe=true\n") + + with pytest.raises(SecretDetectedError): + RepositorySecretValidator().validate( + OutputValidationContext( + repo_path=repository, + base_ref=base, + head_ref=head, + changed_entries=( + ChangedEntry("A", "100644", "generated.env", len(AWS_KEY) + 19), + ), + ) + ) + + +def test_symlink_output_fails_closed(repository: Path, tmp_path: Path) -> None: + target = tmp_path / "outside.txt" + target.write_text("safe") + (repository / "output-link").symlink_to(target) + + with pytest.raises(SecretScanError, match="Unsafe output path"): + scan_repository(repository) + + +def test_missing_trusted_base_fails_closed(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-b", "main") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test") + (repo / "README").write_text("safe") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "base") + + with pytest.raises(SecretScanError, match="trusted origin base"): + scan_repository(repo) + + +def test_timeout_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + def slow_scan(_files: object) -> list[object]: + time.sleep(0.05) + return [] + + monkeypatch.setattr("forge.security.secrets._scan_files", slow_scan) + with pytest.raises(SecretScanError, match="timed out"): + scan_text("safe", location="comment", timeout_seconds=0.001) diff --git a/tests/unit/workspace/test_git_ops_secret_scan.py b/tests/unit/workspace/test_git_ops_secret_scan.py new file mode 100644 index 00000000..b0d2e108 --- /dev/null +++ b/tests/unit/workspace/test_git_ops_secret_scan.py @@ -0,0 +1,34 @@ +"""Git publication always passes through the trusted secret scan.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +from forge.workspace.git_ops import GitOperations + + +def _operations(tmp_path: Path) -> GitOperations: + operations = GitOperations.__new__(GitOperations) + operations.workspace = SimpleNamespace(path=tmp_path, branch_name="forge/test") + operations.settings = MagicMock() + operations.output_validators = () + operations._run_git = MagicMock() + return operations + + +def test_push_to_fork_uses_common_output_gate(tmp_path: Path) -> None: + operations = _operations(tmp_path) + operations.validate_output_for_push = MagicMock() + operations.push_to_fork() + + operations.validate_output_for_push.assert_called_once_with() + operations._run_git.assert_called_once_with("push", "-u", "fork", "forge/test") + + +def test_push_to_origin_uses_common_output_gate(tmp_path: Path) -> None: + operations = _operations(tmp_path) + operations.validate_output_for_push = MagicMock() + operations.push(check_conflicts=False) + + operations.validate_output_for_push.assert_called_once_with() + operations._run_git.assert_called_once_with("push", "-u", "origin", "forge/test") diff --git a/uv.lock b/uv.lock index b3750e74..7a911269 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -547,6 +547,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, ] +[[package]] +name = "detect-secrets" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -653,6 +666,7 @@ source = { editable = "." } dependencies = [ { name = "anthropic", extra = ["vertex"] }, { name = "deepagents" }, + { name = "detect-secrets" }, { name = "fastapi" }, { name = "gitpython" }, { name = "httpx" }, @@ -698,6 +712,7 @@ docs = [ requires-dist = [ { name = "anthropic", extras = ["vertex"], specifier = ">=0.40.0" }, { name = "deepagents", specifier = ">=0.1.0" }, + { name = "detect-secrets", specifier = ">=1.5.0,<2" }, { name = "factory-boy", marker = "extra == 'dev'", specifier = ">=3.3.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "ghp-import", marker = "extra == 'docs'", specifier = ">=2.1" },