diff --git a/guards/github-guard/docs/INTEGRITY_TAG_SPEC.md b/guards/github-guard/docs/INTEGRITY_TAG_SPEC.md index addb8a06..5bd5c6f3 100644 --- a/guards/github-guard/docs/INTEGRITY_TAG_SPEC.md +++ b/guards/github-guard/docs/INTEGRITY_TAG_SPEC.md @@ -106,9 +106,16 @@ Resource labels are coarse pre-check labels by tool call. | `get_commit` | start at max(author_association floor, approved); if default-branch reachable => merged | start at author_association floor; if default-branch reachable => merged; otherwise remain floor unless other endorsement applies | | `list_commits` | if ref is default/no-ref: merged; else max(author_association floor, approved) | if ref is default/no-ref: merged; else author_association floor (response items refine per commit) | | `get_file_contents` | default/no-ref: merged; otherwise approved (author floor does not usually apply to blob metadata) | default/no-ref: merged; otherwise approved | -| `list_branches`, `list_tags`, `get_tag`, `list_releases`, `get_latest_release`, `get_release_by_tag`, `get_label`, `actions_get`, `actions_list`, `search_code`, `get_repository`, `search_repositories` | approved | approved | +| `list_branches`, `list_tags`, `get_tag`, `list_releases`, `get_latest_release`, `get_release_by_tag`, `get_label`, `list_label`, `actions_get`, `actions_list`, `search_code`, `get_repository`, `search_repositories`, `get_repository_tree`, `list_discussion_categories` | approved | approved | +| `get_job_logs` | approved | approved | +| `list_discussions`, `get_discussion`, `get_discussion_comments` | max(author_association floor, approved) | author_association floor (user content) | +| `list_gists`, `get_gist` | unapproved:user | unapproved:user | +| `list_notifications`, `get_notification_details` | none | none | | `list_secret_scanning_alerts`, `get_secret_scanning_alert`, `list_code_scanning_alerts`, `get_code_scanning_alert`, `list_dependabot_alerts`, `get_dependabot_alert` | approved | approved | -| `list_issue_types`, `search_users` (GitHub-global metadata) | approved:github | approved:github | +| `list_issue_types`, `search_users`, `search_orgs`, `get_me`, `get_teams`, `get_team_members`, `list_starred_repositories` (GitHub-global/user metadata) | approved:github | approved:github | +| `list_global_security_advisories`, `get_global_security_advisory` (public CVE data) | approved:github | approved:github | +| `list_repository_security_advisories`, `list_org_repository_security_advisories` | approved | approved | +| `projects_list`, `projects_get`, `list_projects`, `get_project`, `list_project_fields`, `list_project_items` | approved: | approved: | Notes: - Resource labels are intentionally coarse for collection/list/search tools; response labeling performs per-item refinement. @@ -129,11 +136,19 @@ Response labels are fine-grained per item and are authoritative when available. | Commit item (`list_commits`, `get_commit`) | max(author_association floor, approved); if default-branch reachable => merged | author_association floor; if default-branch reachable => merged; otherwise stay at floor unless other endorsement evidence applies | | File content item (`get_file_contents`) | default/no-ref: merged; otherwise approved | default/no-ref: merged; otherwise approved | | Branch/tag/release metadata item (`list_branches`, `list_tags`, `get_tag`, `list_releases`, `get_latest_release`, `get_release_by_tag`) | merged if tied to default branch, otherwise approved | merged if tied to default branch, otherwise approved | -| Label metadata (`get_label`) | approved | approved | +| Label metadata (`get_label`, `list_label`) | approved | approved | | GitHub Actions workflow/artifact metadata (`actions_get`, `actions_list`) | approved | approved | +| Job logs (`get_job_logs`) | approved | approved | | Security alert item | approved | approved | +| Global security advisory (`list_global_security_advisories`, `get_global_security_advisory`) | approved:github | approved:github | +| Repo/org security advisory (`list_repository_security_advisories`, `list_org_repository_security_advisories`) | approved | approved | +| Discussion item (`list_discussions`, `get_discussion`, `get_discussion_comments`) | max(author_association floor, approved) | author_association floor (user content) | +| Discussion category metadata (`list_discussion_categories`) | approved | approved | | Gist item | unapproved:user | unapproved:user | | Notification item | currently empty integrity in path-label mode | currently empty integrity in path-label mode | +| Project item (`projects_list`, `projects_get`, `list_project_items`) | approved: | approved: | +| User/org metadata (`get_me`, `get_teams`, `get_team_members`, `search_orgs`, `list_starred_repositories`) | approved:github | approved:github | +| Repository tree (`get_repository_tree`) | approved | approved | Notes: diff --git a/guards/github-guard/docs/SECRECY_TAG_SPEC.md b/guards/github-guard/docs/SECRECY_TAG_SPEC.md index dacb3a25..7d3aff3f 100644 --- a/guards/github-guard/docs/SECRECY_TAG_SPEC.md +++ b/guards/github-guard/docs/SECRECY_TAG_SPEC.md @@ -70,9 +70,19 @@ Resource labels are coarse pre-check labels by tool call. | Tool / Resource Type | Private Repo | Public Repo | |---|---|---| -| Repo-scoped read tools (`get_issue`, `list_issues`, `get_pull_request`, `list_pull_requests`, `get_commit`, `list_commits`, `get_file_contents`, `list_branches`, `list_tags`, `get_tag`, `list_releases`, `get_latest_release`, `get_release_by_tag`, `get_label`, `actions_get`, `actions_list`, `search_code`, `get_repository`) | `private:`, `private:/` | `[]` | -| Security alert tools (`list_secret_scanning_alerts`, `get_secret_scanning_alert`, `list_code_scanning_alerts`, `get_code_scanning_alert`, `list_dependabot_alerts`, `get_dependabot_alert`) | `private:`, `private:/` (or stricter tool-specific secrecy where configured) | `[]` (or stricter tool-specific secrecy where configured) | -| Cross-repo search tools (`search_issues`, `search_pull_requests`, `search_repositories`, `search_users`) | coarse `[]` (response items refine) | coarse `[]` (response items refine) | +| Repo-scoped read tools (`get_issue`, `list_issues`, `get_pull_request`, `list_pull_requests`, `get_commit`, `list_commits`, `get_file_contents`, `list_branches`, `list_tags`, `get_tag`, `list_releases`, `get_latest_release`, `get_release_by_tag`, `get_label`, `list_label`, `actions_get`, `actions_list`, `search_code`, `get_repository`, `get_repository_tree`, `list_discussions`, `get_discussion`, `get_discussion_comments`, `list_discussion_categories`) | `private:`, `private:/` | `[]` | +| Job logs (`get_job_logs`) | `secret` | `secret` | +| Sensitive file content (`get_file_contents` with sensitive paths) | `secret` | `secret` | +| Secret scanning alerts (`list_secret_scanning_alerts`, `get_secret_scanning_alert`) | `secret` | `secret` | +| Code scanning & Dependabot alerts (`list_code_scanning_alerts`, `get_code_scanning_alert`, `list_dependabot_alerts`, `get_dependabot_alert`) | `private:`, `private:/` | `private:`, `private:/` | +| Repo/org security advisories (`list_repository_security_advisories`, `list_org_repository_security_advisories`) | `private:`, `private:/` | `private:`, `private:/` | +| Artifact downloads (`actions_get` with method `download_workflow_run_artifact`) | `secret` | `secret` | +| User-scoped tools (`get_me`, `get_teams`, `get_team_members`, `list_starred_repositories`) | `private:user` | `private:user` | +| Gist tools (`list_gists`, `get_gist`) | `private:user` (conservative; response refines per-item) | `private:user` (conservative; response refines per-item) | +| Notification tools (`list_notifications`, `get_notification_details`) | `private:user` | `private:user` | +| Cross-repo search tools (`search_issues`, `search_pull_requests`, `search_repositories`, `search_users`, `search_orgs`) | coarse `[]` (response items refine) | coarse `[]` (response items refine) | +| Global security advisories (`list_global_security_advisories`, `get_global_security_advisory`) | `[]` (public CVE data) | `[]` (public CVE data) | +| Project tools (`projects_list`, `projects_get`, `list_projects`, `get_project`, `list_project_fields`, `list_project_items`) | `[]` (response items refine per-item) | `[]` (response items refine per-item) | Notes: @@ -94,7 +104,16 @@ Response labels are fine-grained per item and should be treated as authoritative | File content item (`get_file_contents`) | `private:`, `private:/` | `[]` | | Branch/tag/release metadata item | `private:`, `private:/` | `[]` | | GitHub Actions workflow/artifact metadata | `private:`, `private:/` | `[]` | +| Job logs (`get_job_logs`) | `secret` | `secret` | | Security alert item | `private:`, `private:/` (or stricter tool-specific secrecy where configured) | `[]` (or stricter tool-specific secrecy where configured) | +| Global security advisory | `[]` (public CVE data) | `[]` (public CVE data) | +| Repo/org security advisory | `private:`, `private:/` | `private:`, `private:/` | +| Discussion item (`list_discussions`, `get_discussion`, `get_discussion_comments`) | `private:`, `private:/` | `[]` | +| Discussion category metadata (`list_discussion_categories`) | `private:`, `private:/` | `[]` | +| Gist item (`list_gists`, `get_gist`) | `private:user` (secret gists) / `[]` (public gists) | `private:user` (secret gists) / `[]` (public gists) | +| Notification item (`list_notifications`, `get_notification_details`) | `private:user` | `private:user` | +| Project item (`projects_list`, `list_project_items`) | per-item from referenced repo | per-item from referenced repo | +| User/org metadata (`get_me`, `get_teams`, `get_team_members`, `list_starred_repositories`, `search_orgs`) | `private:user` (user-scoped) / `[]` (org search) | `private:user` / `[]` | --- diff --git a/guards/github-guard/rust-guard/src/labels/mod.rs b/guards/github-guard/rust-guard/src/labels/mod.rs index 271ca19a..9910a094 100644 --- a/guards/github-guard/rust-guard/src/labels/mod.rs +++ b/guards/github-guard/rust-guard/src/labels/mod.rs @@ -1692,4 +1692,926 @@ mod tests { writer_integrity(repo, &ctx) ); } + + // ========================================================================= + // Tests for 22 new tools added in feat/guard-tool-coverage + // + // Each tool is tested for: + // - apply_tool_labels (label_resource): correct secrecy and integrity + // - label_response_items / label_response_paths where applicable + // + // Note on repo_id selection: ensure_integrity_baseline inside apply_tool_labels + // uses baseline_scope = repo_id. For tools that set integrity scoped to a + // different scope (e.g., "user", "github"), the repo_id must match that scope + // to avoid the baseline downgrading labels to none. + // ========================================================================= + + // ------------------------------------------------------------------------- + // Actions: get_job_logs + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_job_logs() { + let ctx = default_ctx(); + let tool_args = json!({ + "owner": "github", + "repo": "copilot", + "job_id": 12345 + }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_job_logs", + &tool_args, + "github/copilot", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, secret_label(), "get_job_logs must carry secret secrecy (logs may leak tokens)"); + assert_eq!(integrity, writer_integrity("github/copilot", &ctx), "get_job_logs must have approved integrity (system-generated output)"); + } + + // ------------------------------------------------------------------------- + // Context: get_me + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_me() { + let ctx = default_ctx(); + let tool_args = json!({}); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_me", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, private_user_label(), "get_me must carry private:user secrecy (PII)"); + assert_eq!(integrity, project_github_label(&ctx), "get_me must have project:github integrity (GitHub-controlled)"); + } + + // ------------------------------------------------------------------------- + // Context: get_teams + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_teams() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_teams", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, private_user_label(), "get_teams must carry private:user secrecy (org structure is sensitive)"); + assert_eq!(integrity, project_github_label(&ctx), "get_teams must have project:github integrity"); + } + + // ------------------------------------------------------------------------- + // Context: get_team_members + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_team_members() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github", "team_slug": "engineering" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_team_members", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, private_user_label(), "get_team_members must carry private:user secrecy"); + assert_eq!(integrity, project_github_label(&ctx), "get_team_members must have project:github integrity"); + } + + // ------------------------------------------------------------------------- + // Discussions: list_discussions + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_list_discussions_secrecy_inherits_repo_visibility() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github", "repo": "copilot" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "list_discussions", + &tool_args, + "github/copilot", + vec![], + vec![], + String::new(), + &ctx, + ); + + // In test mode backend returns None → secrecy stays [] (public assumption) + assert_eq!(secrecy, vec![] as Vec, "list_discussions secrecy inherits repo visibility"); + // writer_integrity is used regardless of repo visibility — approved at resource level + assert_eq!(integrity, writer_integrity("github/copilot", &ctx), "list_discussions integrity is approved at resource level"); + } + + // ------------------------------------------------------------------------- + // Discussions: get_discussion + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_discussion_secrecy_inherits_repo_visibility() { + let ctx = default_ctx(); + let tool_args = json!({ + "owner": "github", + "repo": "copilot", + "discussion_number": 42 + }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_discussion", + &tool_args, + "github/copilot", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, vec![] as Vec); + assert_eq!(integrity, writer_integrity("github/copilot", &ctx)); + } + + // ------------------------------------------------------------------------- + // Discussions: get_discussion_comments + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_discussion_comments_secrecy_inherits_repo_visibility() { + let ctx = default_ctx(); + let tool_args = json!({ + "owner": "github", + "repo": "copilot", + "discussion_number": 42 + }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_discussion_comments", + &tool_args, + "github/copilot", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, vec![] as Vec); + assert_eq!(integrity, writer_integrity("github/copilot", &ctx)); + } + + // ------------------------------------------------------------------------- + // Discussions: list_discussion_categories + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_list_discussion_categories_approved_integrity() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github", "repo": "copilot" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "list_discussion_categories", + &tool_args, + "github/copilot", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, vec![] as Vec, "list_discussion_categories secrecy inherits repo visibility"); + assert_eq!(integrity, writer_integrity("github/copilot", &ctx), "list_discussion_categories must have approved integrity (maintainer-managed)"); + } + + // ------------------------------------------------------------------------- + // Gists: list_gists + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_list_gists() { + let ctx = default_ctx(); + let tool_args = json!({ "username": "octocat" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "list_gists", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, private_user_label(), "list_gists must carry private:user secrecy (mix of public/secret gists)"); + assert_eq!(integrity, reader_integrity("user", &ctx), "list_gists must have reader (unapproved) integrity (user content)"); + } + + // ------------------------------------------------------------------------- + // Gists: get_gist + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_gist() { + let ctx = default_ctx(); + let tool_args = json!({ "gist_id": "abc123def456" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_gist", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, private_user_label(), "get_gist must carry private:user secrecy"); + assert_eq!(integrity, reader_integrity("user", &ctx), "get_gist must have reader integrity"); + } + + // ------------------------------------------------------------------------- + // Git: get_repository_tree + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_repository_tree_approved_integrity() { + let ctx = default_ctx(); + let tool_args = json!({ + "owner": "github", + "repo": "copilot", + "tree_sha": "main" + }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_repository_tree", + &tool_args, + "github/copilot", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, vec![] as Vec, "get_repository_tree secrecy inherits repo visibility"); + assert_eq!(integrity, writer_integrity("github/copilot", &ctx), "get_repository_tree must have approved integrity (repo metadata)"); + } + + // ------------------------------------------------------------------------- + // Labels: list_label + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_list_label_approved_integrity() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github", "repo": "copilot" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "list_label", + &tool_args, + "github/copilot", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, vec![] as Vec, "list_label secrecy inherits repo visibility"); + assert_eq!(integrity, writer_integrity("github/copilot", &ctx), "list_label must have approved integrity (maintainer-managed metadata)"); + } + + // ------------------------------------------------------------------------- + // Notifications: list_notifications + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_list_notifications() { + let ctx = default_ctx(); + let tool_args = json!({}); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "list_notifications", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, private_user_label(), "list_notifications must carry private:user secrecy"); + // integrity = vec![] in rule → ensure_integrity_baseline("", [], ctx) = none_integrity("", ctx) = ["none"] + assert_eq!(integrity, none_integrity("", &ctx), "list_notifications must have none-level integrity (references external content of unknown trust)"); + } + + // ------------------------------------------------------------------------- + // Notifications: get_notification_details + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_notification_details() { + let ctx = default_ctx(); + let tool_args = json!({ "thread_id": "12345" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_notification_details", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, private_user_label(), "get_notification_details must carry private:user secrecy"); + assert_eq!(integrity, none_integrity("", &ctx), "get_notification_details must have none-level integrity"); + } + + // ------------------------------------------------------------------------- + // Projects: projects_list (new canonical name for list_project_items) + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_projects_list_owner_scoped_integrity() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github" }); + + // projects_list sets baseline_scope = owner = "github" + let (_secrecy, integrity, _desc) = apply_tool_labels( + "projects_list", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(integrity, writer_integrity("github", &ctx), "projects_list must have approved:owner integrity"); + } + + #[test] + fn test_apply_tool_labels_projects_list_with_owner_scoped_ctx() { + let ctx = owner_scoped_ctx("github"); + let tool_args = json!({ "owner": "github" }); + + let (_secrecy, integrity, _desc) = apply_tool_labels( + "projects_list", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert!( + integrity.contains(&"approved:github".to_string()), + "projects_list with scoped ctx must have 'approved:github', got: {:?}", + integrity + ); + } + + // ------------------------------------------------------------------------- + // Projects: projects_get (new canonical name for get_project) + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_projects_get_owner_scoped_integrity() { + let ctx = owner_scoped_ctx("myorg"); + let tool_args = json!({ "owner": "myorg", "project_number": 5 }); + + let (_secrecy, integrity, _desc) = apply_tool_labels( + "projects_get", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert!( + integrity.contains(&"approved:myorg".to_string()), + "projects_get must have 'approved:myorg' integrity, got: {:?}", + integrity + ); + } + + // ------------------------------------------------------------------------- + // Repos: list_starred_repositories + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_list_starred_repositories() { + let ctx = default_ctx(); + let tool_args = json!({ "username": "octocat" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "list_starred_repositories", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, private_user_label(), "list_starred_repositories must carry private:user secrecy (personal preferences)"); + assert_eq!(integrity, project_github_label(&ctx), "list_starred_repositories must have project:github integrity (GitHub-controlled metadata)"); + } + + // ------------------------------------------------------------------------- + // Search: search_orgs + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_search_orgs() { + let ctx = default_ctx(); + let tool_args = json!({ "query": "github" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "search_orgs", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, vec![] as Vec, "search_orgs must have public (empty) secrecy"); + assert_eq!(integrity, project_github_label(&ctx), "search_orgs must have project:github integrity"); + } + + // ------------------------------------------------------------------------- + // Security Advisories: list_global_security_advisories + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_list_global_security_advisories() { + let ctx = default_ctx(); + let tool_args = json!({ "severity": "critical" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "list_global_security_advisories", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, vec![] as Vec, "global advisories are public CVE data — empty secrecy"); + assert_eq!(integrity, project_github_label(&ctx), "global advisories curated by GitHub security team — project:github integrity"); + } + + // ------------------------------------------------------------------------- + // Security Advisories: get_global_security_advisory + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_get_global_security_advisory() { + let ctx = default_ctx(); + let tool_args = json!({ "ghsa_id": "GHSA-xxxx-yyyy-zzzz" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "get_global_security_advisory", + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!(secrecy, vec![] as Vec); + assert_eq!(integrity, project_github_label(&ctx)); + } + + // ------------------------------------------------------------------------- + // Security Advisories: list_repository_security_advisories + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_list_repository_security_advisories() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github", "repo": "copilot" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "list_repository_security_advisories", + &tool_args, + "github/copilot", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!( + secrecy, + vec!["private:github/copilot".to_string()], + "repo security advisories may contain embargoed vulnerability info — private:repo secrecy" + ); + assert_eq!(integrity, writer_integrity("github/copilot", &ctx), "repo security advisories maintained by repo security contacts — approved integrity"); + } + + // ------------------------------------------------------------------------- + // Security Advisories: list_org_repository_security_advisories + // ------------------------------------------------------------------------- + + #[test] + fn test_apply_tool_labels_list_org_repo_security_advisories() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github", "repo": "copilot" }); + + let (secrecy, integrity, _desc) = apply_tool_labels( + "list_org_repository_security_advisories", + &tool_args, + "github/copilot", + vec![], + vec![], + String::new(), + &ctx, + ); + + assert_eq!( + secrecy, + vec!["private:github/copilot".to_string()], + "org repo security advisories must carry private:repo secrecy" + ); + assert_eq!(integrity, writer_integrity("github/copilot", &ctx)); + } + + // ========================================================================= + // label_response_items tests for new tools + // ========================================================================= + + // ------------------------------------------------------------------------- + // list_gists — public gist + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_items_list_gists_public_gist_empty_secrecy() { + let ctx = default_ctx(); + let tool_args = json!({ "username": "octocat" }); + let response = json!([ + { "id": "abc123def456", "public": true, "description": "A public gist" } + ]); + + let items = label_response_items("list_gists", &tool_args, &response, &ctx); + + assert_eq!(items.len(), 1, "should label one gist item"); + let item = &items[0]; + assert_eq!(item.labels.secrecy, vec![] as Vec, "public gist must have empty secrecy"); + assert_eq!(item.labels.integrity, reader_integrity("user", &ctx), "gist must have reader integrity"); + assert_eq!(item.labels.description, "gist:abc123def456"); + } + + // ------------------------------------------------------------------------- + // list_gists — private gist + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_items_list_gists_private_gist_private_user_secrecy() { + let ctx = default_ctx(); + let tool_args = json!({ "username": "octocat" }); + let response = json!([ + { "id": "secret789xyz", "public": false, "description": "A secret gist" } + ]); + + let items = label_response_items("list_gists", &tool_args, &response, &ctx); + + assert_eq!(items.len(), 1); + let item = &items[0]; + assert_eq!(item.labels.secrecy, private_user_label(), "secret gist must carry private:user secrecy"); + assert_eq!(item.labels.integrity, reader_integrity("user", &ctx), "secret gist still has reader integrity"); + assert_eq!(item.labels.description, "gist:secret789xyz"); + } + + // ------------------------------------------------------------------------- + // list_gists — mixed public and private + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_items_list_gists_mixed_public_and_private() { + let ctx = default_ctx(); + let tool_args = json!({}); + let response = json!([ + { "id": "pub1", "public": true }, + { "id": "sec2", "public": false }, + { "id": "pub3", "public": true } + ]); + + let items = label_response_items("list_gists", &tool_args, &response, &ctx); + + assert_eq!(items.len(), 3); + assert_eq!(items[0].labels.secrecy, vec![] as Vec, "first item is public → empty secrecy"); + assert_eq!(items[1].labels.secrecy, private_user_label(), "second item is private → private:user"); + assert_eq!(items[2].labels.secrecy, vec![] as Vec, "third item is public → empty secrecy"); + // All gists share the same reader integrity level + for item in &items { + assert_eq!(item.labels.integrity, reader_integrity("user", &ctx)); + } + } + + // ------------------------------------------------------------------------- + // get_gist — public gist + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_items_get_gist_public_secrecy_reader_integrity() { + let ctx = default_ctx(); + let tool_args = json!({ "gist_id": "abc123def456" }); + let response = json!({ "id": "abc123def456", "public": true }); + + let items = label_response_items("get_gist", &tool_args, &response, &ctx); + + assert_eq!(items.len(), 1, "single-object response must produce one labeled item"); + assert_eq!(items[0].labels.secrecy, vec![] as Vec); + assert_eq!(items[0].labels.integrity, reader_integrity("user", &ctx)); + } + + // ------------------------------------------------------------------------- + // get_gist — private (secret) gist + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_items_get_gist_private() { + let ctx = default_ctx(); + let tool_args = json!({ "gist_id": "secret789xyz" }); + let response = json!({ "id": "secret789xyz", "public": false }); + + let items = label_response_items("get_gist", &tool_args, &response, &ctx); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].labels.secrecy, private_user_label()); + assert_eq!(items[0].labels.integrity, reader_integrity("user", &ctx)); + } + + // ------------------------------------------------------------------------- + // list_notifications — response items + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_items_list_notifications_private_user_secrecy() { + let ctx = default_ctx(); + let tool_args = json!({}); + let response = json!([ + { + "id": "n1", + "subject": { "title": "Fix login bug", "type": "Issue" }, + "reason": "mention" + }, + { + "id": "n2", + "subject": { "title": "Add feature X", "type": "PullRequest" }, + "reason": "review_requested" + } + ]); + + let items = label_response_items("list_notifications", &tool_args, &response, &ctx); + + assert_eq!(items.len(), 2, "should label both notification items"); + for item in &items { + assert_eq!(item.labels.secrecy, private_user_label(), "notifications are always private:user"); + // none_integrity("", ctx) = ["none"] + assert_eq!(item.labels.integrity, none_integrity("", &ctx), "notifications carry no trust — none integrity"); + } + assert_eq!(items[0].labels.description, "notification:n1"); + assert_eq!(items[1].labels.description, "notification:n2"); + } + + // ------------------------------------------------------------------------- + // get_notification_details — response items (MCP-wrapped single object) + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_items_get_notification_details_mcp_wrapped() { + let ctx = default_ctx(); + let tool_args = json!({ "thread_id": "12345" }); + // get_notification_details returns an array response in the items handler + let inner = json!([{"id": "12345", "subject": {"title": "Security alert", "type": "RepositoryVulnerabilityAlert"}}]).to_string(); + let response = json!({ + "content": [{ "type": "text", "text": inner }] + }); + + let items = label_response_items("get_notification_details", &tool_args, &response, &ctx); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].labels.secrecy, private_user_label()); + assert_eq!(items[0].labels.integrity, none_integrity("", &ctx)); + assert_eq!(items[0].labels.description, "notification:12345"); + } + + // ========================================================================= + // label_response_paths tests for new tools + // ========================================================================= + + // ------------------------------------------------------------------------- + // list_gists — path labels with public gist + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_paths_list_gists_public_gist_empty_secrecy() { + let ctx = default_ctx(); + let tool_args = json!({}); + let response = json!([ + { "id": "pub1", "public": true } + ]); + + let result = label_response_paths("list_gists", &tool_args, &response, &ctx) + .expect("list_gists should produce path labels"); + + assert_eq!(result.labeled_paths.len(), 1); + assert_eq!(result.labeled_paths[0].path, "/0"); + assert_eq!(result.labeled_paths[0].labels.secrecy, vec![] as Vec, "public gist path must have empty secrecy"); + assert_eq!(result.labeled_paths[0].labels.integrity, reader_integrity("user", &ctx)); + assert_eq!(result.labeled_paths[0].labels.description, "gist:pub1"); + } + + // ------------------------------------------------------------------------- + // list_gists — path labels with private gist + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_paths_list_gists_private_gist_private_user_secrecy() { + let ctx = default_ctx(); + let tool_args = json!({}); + let response = json!([ + { "id": "sec1", "public": false } + ]); + + let result = label_response_paths("list_gists", &tool_args, &response, &ctx) + .expect("list_gists should produce path labels"); + + assert_eq!(result.labeled_paths.len(), 1); + assert_eq!(result.labeled_paths[0].labels.secrecy, private_user_label(), "private gist path must carry private:user secrecy"); + assert_eq!(result.labeled_paths[0].labels.integrity, reader_integrity("user", &ctx)); + } + + // ------------------------------------------------------------------------- + // list_gists — path labels mixed public/private + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_paths_list_gists_mixed_visibility() { + let ctx = default_ctx(); + let tool_args = json!({}); + let response = json!([ + { "id": "pub1", "public": true }, + { "id": "sec2", "public": false } + ]); + + let result = label_response_paths("list_gists", &tool_args, &response, &ctx) + .expect("list_gists should produce path labels"); + + assert_eq!(result.labeled_paths.len(), 2); + assert_eq!(result.labeled_paths[0].labels.secrecy, vec![] as Vec); + assert_eq!(result.labeled_paths[1].labels.secrecy, private_user_label()); + // Default labels for the collection use conservative reader integrity + let default_labels = result.default_labels.as_ref().expect("should have default labels"); + assert_eq!(default_labels.secrecy, vec![] as Vec); + assert_eq!(default_labels.integrity, reader_integrity("user", &ctx)); + } + + // ------------------------------------------------------------------------- + // list_notifications — path labels + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_paths_list_notifications_private_empty_integrity() { + let ctx = default_ctx(); + let tool_args = json!({}); + let response = json!([ + { "id": "n1", "reason": "mention" }, + { "id": "n2", "reason": "review_requested" } + ]); + + let result = label_response_paths("list_notifications", &tool_args, &response, &ctx) + .expect("list_notifications should produce path labels"); + + assert_eq!(result.labeled_paths.len(), 2); + for entry in &result.labeled_paths { + assert_eq!(entry.labels.secrecy, private_user_label(), "all notification paths must be private:user"); + // response_paths.rs uses vec![] directly (not none_integrity) + assert_eq!(entry.labels.integrity, vec![] as Vec, "notification paths carry no integrity tags"); + } + assert_eq!(result.labeled_paths[0].path, "/0"); + assert_eq!(result.labeled_paths[1].path, "/1"); + + let default_labels = result.default_labels.as_ref().expect("should have default labels"); + assert_eq!(default_labels.secrecy, private_user_label()); + assert_eq!(default_labels.integrity, vec![] as Vec); + } + + // ------------------------------------------------------------------------- + // projects_list — path labels (new canonical name for list_project_items) + // ------------------------------------------------------------------------- + + #[test] + fn test_label_response_paths_projects_list_issue_item() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github", "project_number": 1 }); + let response = json!({ + "items": [ + { + "type": "ISSUE", + "content": { + "repository_url": "https://api.github.com/repos/github/copilot", + "author_association": "MEMBER" + } + } + ] + }); + + let result = label_response_paths("projects_list", &tool_args, &response, &ctx) + .expect("projects_list should produce path labels"); + + assert_eq!(result.labeled_paths.len(), 1); + let entry = &result.labeled_paths[0]; + assert_eq!(entry.labels.description, "project-item:issue"); + assert!( + entry.labels.integrity.iter().any(|l| l.starts_with("approved:")), + "MEMBER association must yield approved-level integrity, got: {:?}", + entry.labels.integrity + ); + } + + #[test] + fn test_label_response_paths_projects_list_draft_issue_item() { + let ctx = owner_scoped_ctx("github"); + let tool_args = json!({ "owner": "github", "project_number": 1 }); + let response = json!({ + "items": [ + { + "type": "DRAFT_ISSUE", + "creator": { "login": "some-admin" } + } + ] + }); + + let result = label_response_paths("projects_list", &tool_args, &response, &ctx) + .expect("projects_list should produce path labels for DRAFT_ISSUE"); + + assert_eq!(result.labeled_paths.len(), 1); + let entry = &result.labeled_paths[0]; + assert_eq!(entry.labels.description, "project-item:draft_issue"); + assert_eq!(entry.labels.secrecy, vec![] as Vec, "draft issues have no repo — empty secrecy"); + assert!( + entry.labels.integrity.contains(&"approved:github".to_string()), + "DRAFT_ISSUE must have approved:github integrity, got: {:?}", + entry.labels.integrity + ); + } + + #[test] + fn test_label_response_paths_projects_list_pull_request_item() { + let ctx = default_ctx(); + let tool_args = json!({ "owner": "github", "project_number": 2 }); + let response = json!({ + "items": [ + { + "type": "PULL_REQUEST", + "content": { + "repository_url": "https://api.github.com/repos/github/copilot", + "author_association": "CONTRIBUTOR" + } + } + ] + }); + + let result = label_response_paths("projects_list", &tool_args, &response, &ctx) + .expect("projects_list should produce path labels for PULL_REQUEST"); + + assert_eq!(result.labeled_paths.len(), 1); + let entry = &result.labeled_paths[0]; + assert_eq!(entry.labels.description, "project-item:pull_request"); + assert!( + entry.labels.integrity.iter().any(|l| l.starts_with("unapproved:")), + "CONTRIBUTOR association must yield unapproved-level integrity, got: {:?}", + entry.labels.integrity + ); + } } diff --git a/guards/github-guard/rust-guard/src/labels/response_paths.rs b/guards/github-guard/rust-guard/src/labels/response_paths.rs index b65b7df2..bfcd6a85 100644 --- a/guards/github-guard/rust-guard/src/labels/response_paths.rs +++ b/guards/github-guard/rust-guard/src/labels/response_paths.rs @@ -513,7 +513,8 @@ pub fn label_response_paths( } // === GitHub Project Items - heterogeneous ISSUE / PULL_REQUEST / DRAFT_ISSUE === - "list_project_items" => { + // projects_list is the new canonical name (replaces list_project_items) + "list_project_items" | "projects_list" => { let (arg_owner, _, _) = extract_repo_info(tool_args); let (items, items_path) = extract_items_array(&actual_response); diff --git a/guards/github-guard/rust-guard/src/labels/tool_rules.rs b/guards/github-guard/rust-guard/src/labels/tool_rules.rs index c2b2b6f3..043b8a14 100644 --- a/guards/github-guard/rust-guard/src/labels/tool_rules.rs +++ b/guards/github-guard/rust-guard/src/labels/tool_rules.rs @@ -10,8 +10,8 @@ use super::helpers::{ author_association_floor_from_str, ensure_integrity_baseline, extract_number_as_string, extract_repo_info, extract_repo_info_from_search_query, is_default_branch_commit_context, is_default_branch_ref, is_trusted_first_party_bot, max_integrity, merged_integrity, - policy_private_scope_label, project_github_label, reader_integrity, secret_label, - writer_integrity, PolicyContext, + policy_private_scope_label, private_user_label, project_github_label, reader_integrity, + secret_label, writer_integrity, PolicyContext, }; fn apply_repo_visibility_secrecy( @@ -396,7 +396,9 @@ pub fn apply_tool_labels( } // === GitHub Projects (org-scoped) === - "list_projects" | "get_project" | "list_project_fields" | "list_project_items" => { + // Canonical names (projects_list, projects_get) plus deprecated aliases + "list_projects" | "get_project" | "list_project_fields" | "list_project_items" + | "projects_list" | "projects_get" => { // Projects are org-scoped; creating/managing projects requires org membership. // I = approved: — equivalent to MEMBER author_association // S = empty by default (public project); per-item secrecy for items is refined in @@ -407,6 +409,137 @@ pub fn apply_tool_labels( } } + // === Job Logs (Actions) === + "get_job_logs" => { + // Job logs may contain secrets (environment variables, tokens leaked in output). + // S = secret (conservative — logs can leak any secret) + // I = approved — CI output is system-generated, not user-controlled + secrecy = secret_label(); + integrity = writer_integrity(repo_id, ctx); + } + + // === Discussions (repo-scoped, user content) === + "list_discussions" | "get_discussion" => { + // Discussions are user-submitted content, similar to issues. + // S = inherits from repo visibility + // I = approved — treat discussion content as approved at the resource level + secrecy = apply_repo_visibility_secrecy(&owner, &repo, repo_id, secrecy, ctx); + integrity = writer_integrity(repo_id, ctx); + } + + "get_discussion_comments" => { + // Discussion comments are user-submitted, lowest-trust user content. + // S = inherits from repo visibility + // I = approved — treat discussion comments as approved at the resource level + secrecy = apply_repo_visibility_secrecy(&owner, &repo, repo_id, secrecy, ctx); + integrity = writer_integrity(repo_id, ctx); + } + + "list_discussion_categories" => { + // Discussion categories are maintainer-managed metadata. + // S = inherits from repo visibility + // I = approved — managed by maintainers + secrecy = apply_repo_visibility_secrecy(&owner, &repo, repo_id, secrecy, ctx); + integrity = writer_integrity(repo_id, ctx); + } + + // === Gists (user-scoped) === + "list_gists" | "get_gist" => { + // Gists are user content; secrecy depends on public/secret flag. + // Resource-level: conservative labeling; response labeling refines per-item. + // S = private:user (conservative — some gists may be secret) + // I = unapproved (user content, no repo-level trust signal) + secrecy = private_user_label(); + baseline_scope = "user".to_string(); + integrity = reader_integrity("user", ctx); + } + + // === Notifications (user-scoped, private) === + "list_notifications" | "get_notification_details" => { + // Notifications are private to the authenticated user. + // S = private:user + // I = none (notifications reference external content of unknown trust) + secrecy = private_user_label(); + integrity = vec![]; + } + + // === Context: User & Org Identity === + "get_me" => { + // Current user profile — private to the authenticated user. + // May contain private email, name, and other PII. + // S = private:user + // I = project:github (GitHub-controlled metadata) + secrecy = private_user_label(); + baseline_scope = "github".to_string(); + integrity = project_github_label(ctx); + } + + "get_teams" | "get_team_members" => { + // Org team membership — may reveal internal org structure. + // S = private:user (org membership is sensitive) + // I = project:github (GitHub-controlled metadata) + secrecy = private_user_label(); + baseline_scope = "github".to_string(); + integrity = project_github_label(ctx); + } + + // === Repository Tree === + "get_repository_tree" => { + // Tree listing shows file structure; inherits from repo + branch. + // S = inherits from repo visibility + // I = approved (repo metadata, maintained by repo team) + secrecy = apply_repo_visibility_secrecy(&owner, &repo, repo_id, secrecy, ctx); + integrity = writer_integrity(repo_id, ctx); + } + + // === Labels (list) === + "list_label" => { + // Label listing — maintainer-managed metadata. + // S = inherits from repo visibility + // I = approved (managed by maintainers) + secrecy = apply_repo_visibility_secrecy(&owner, &repo, repo_id, secrecy, ctx); + integrity = writer_integrity(repo_id, ctx); + } + + // === Starred Repositories === + "list_starred_repositories" => { + // User's starred repos — reveals user preferences/interests. + // S = private:user (personal data) + // I = project:github (GitHub-controlled metadata) + secrecy = private_user_label(); + baseline_scope = "github".to_string(); + integrity = project_github_label(ctx); + } + + // === Organization Search === + "search_orgs" => { + // Public organization profiles. + // S = public (empty) + // I = project:github (GitHub-controlled metadata) + secrecy = vec![]; + baseline_scope = "github".to_string(); + integrity = project_github_label(ctx); + } + + // === Security Advisories === + "list_global_security_advisories" | "get_global_security_advisory" => { + // Global security advisories are public CVE data from GHSA. + // S = public (empty) — these are published advisories + // I = project:github — curated by GitHub security team + secrecy = vec![]; + baseline_scope = "github".to_string(); + integrity = project_github_label(ctx); + } + + "list_repository_security_advisories" | "list_org_repository_security_advisories" => { + // Repository/org security advisories may include draft advisories + // with non-public vulnerability details. + // S = private:repo — may contain embargoed vulnerability info + // I = approved — maintained by repo security contacts + secrecy = policy_private_scope_label(&owner, &repo, repo_id, ctx); + integrity = writer_integrity(repo_id, ctx); + } + _ => { // Default: inherit provided labels } diff --git a/guards/github-guard/rust-guard/src/tools.rs b/guards/github-guard/rust-guard/src/tools.rs index 928db189..fb1ccb4b 100644 --- a/guards/github-guard/rust-guard/src/tools.rs +++ b/guards/github-guard/rust-guard/src/tools.rs @@ -13,6 +13,7 @@ pub const WRITE_OPERATIONS: &[&str] = &[ "fork_repository", "create_pull_request", "add_comment_to_pending_review", + "add_reply_to_pull_request_comment", "request_copilot_review", "add_issue_comment", "assign_copilot_to_issue", @@ -21,6 +22,7 @@ pub const WRITE_OPERATIONS: &[&str] = &[ "rerun_failed_jobs", "cancel_workflow_run", "delete_workflow_run_logs", + "actions_run_trigger", "create_gist", "dismiss_notification", "mark_all_notifications_read", @@ -28,6 +30,7 @@ pub const WRITE_OPERATIONS: &[&str] = &[ "manage_repository_notification_subscription", "add_project_item", "delete_project_item", + "projects_write", "star_repository", "unstar_repository", "label_write", diff --git a/internal/proxy/graphql.go b/internal/proxy/graphql.go index 9ce3edb7..ea05b150 100644 --- a/internal/proxy/graphql.go +++ b/internal/proxy/graphql.go @@ -43,17 +43,26 @@ var graphqlPatterns = []graphqlPattern{ {queryPattern: regexp.MustCompile(`(?i)repository\s*\([^)]*\)\s*\{[^}]*\bpullRequest\s*\(`), toolName: "pull_request_read"}, {queryPattern: regexp.MustCompile(`(?i)repository\s*\([^)]*\)\s*\{[^}]*\bpullRequests\s*[\({]`), toolName: "list_pull_requests"}, + // Discussion operations + {queryPattern: regexp.MustCompile(`(?i)repository\s*\([^)]*\)\s*\{[^}]*\bdiscussion\s*\(`), toolName: "list_discussions"}, + {queryPattern: regexp.MustCompile(`(?i)repository\s*\([^)]*\)\s*\{[^}]*\bdiscussions\s*[\({]`), toolName: "list_discussions"}, + {queryPattern: regexp.MustCompile(`(?i)repository\s*\([^)]*\)\s*\{[^}]*\bdiscussionCategories\s*[\({]`), toolName: "list_discussion_categories"}, + // Search operations {queryPattern: regexp.MustCompile(`(?i)\bsearch\s*\(`), toolName: "search_issues"}, // Project operations {queryPattern: regexp.MustCompile(`(?i)projectV2`), toolName: "list_projects"}, - // Repository info + // Viewer / user profile + {queryPattern: regexp.MustCompile(`(?i)\bviewer\s*\{`), toolName: "get_me"}, + + // Organization queries + {queryPattern: regexp.MustCompile(`(?i)\borganization\s*\(`), toolName: "search_orgs"}, + + // Repository info (catch-all for repo-scoped queries) {queryPattern: regexp.MustCompile(`(?i)\brepository\s*\(`), toolName: "get_file_contents"}, - // viewer { ... } is intentionally not mapped — the guard does not recognize a tool name - // with equivalent semantics for user/account data, and it may include private fields. // Unknown GraphQL queries are blocked by the handler. } diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index ba1278b0..c0d297fe 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -188,6 +188,108 @@ func TestMatchRoute(t *testing.T) { wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "list_workflow_runs"}, }, + // Actions — individual resources + { + name: "get workflow", + path: "/repos/org/repo/actions/workflows/42", + wantTool: "actions_get", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "get_workflow", "resource_id": "42"}, + }, + { + name: "get workflow run", + path: "/repos/org/repo/actions/runs/12345", + wantTool: "actions_get", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "get_workflow_run", "resource_id": "12345"}, + }, + { + name: "get workflow job", + path: "/repos/org/repo/actions/jobs/99", + wantTool: "actions_get", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "get_workflow_job", "resource_id": "99"}, + }, + { + name: "list workflow-specific runs", + path: "/repos/org/repo/actions/workflows/42/runs", + wantTool: "actions_list", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "list_workflow_runs", "resource_id": "42"}, + }, + { + name: "list run attempt jobs", + path: "/repos/org/repo/actions/runs/100/attempts/1/jobs", + wantTool: "actions_list", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "list_workflow_jobs", "resource_id": "100"}, + }, + { + name: "get run logs", + path: "/repos/org/repo/actions/runs/100/logs", + wantTool: "get_job_logs", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "run_id": "100"}, + }, + { + name: "get run attempt logs", + path: "/repos/org/repo/actions/runs/100/attempts/2/logs", + wantTool: "get_job_logs", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "run_id": "100"}, + }, + { + name: "list run artifacts", + path: "/repos/org/repo/actions/runs/100/artifacts", + wantTool: "actions_list", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "list_workflow_run_artifacts", "resource_id": "100"}, + }, + { + name: "list repo artifacts", + path: "/repos/org/repo/actions/artifacts", + wantTool: "actions_list", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "list_workflow_run_artifacts"}, + }, + { + name: "list caches", + path: "/repos/org/repo/actions/caches", + wantTool: "actions_list", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "list_caches"}, + }, + { + name: "list secrets", + path: "/repos/org/repo/actions/secrets", + wantTool: "actions_list", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "list_secrets"}, + }, + { + name: "list variables", + path: "/repos/org/repo/actions/variables", + wantTool: "actions_list", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "method": "list_variables"}, + }, + // Check runs/suites + { + name: "check runs for commit", + path: "/repos/org/repo/commits/abc123/check-runs", + wantTool: "pull_request_read", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo", "sha": "abc123", "method": "get_check_runs"}, + }, + // Notifications + { + name: "list notifications", + path: "/notifications", + wantTool: "list_notifications", + wantArgs: map[string]interface{}{}, + }, + // Discussions + { + name: "list discussions", + path: "/repos/org/repo/discussions", + wantTool: "list_discussions", + wantArgs: map[string]interface{}{"owner": "org", "repo": "repo"}, + }, + // User keys + { + name: "user SSH keys", + path: "/user/keys", + wantTool: "get_me", + wantArgs: map[string]interface{}{}, + }, + // Search { name: "search code", @@ -216,11 +318,12 @@ func TestMatchRoute(t *testing.T) { wantArgs: map[string]interface{}{"owner": "org", "repo": "repo"}, }, - // User — not mapped; unknown paths are blocked (fail closed) + // User API { - name: "get me", - path: "/user", - wantNil: true, + name: "get me", + path: "/user", + wantTool: "get_me", + wantArgs: map[string]interface{}{}, }, // Query string stripping @@ -308,9 +411,19 @@ func TestMatchGraphQL(t *testing.T) { wantTool: "get_file_contents", }, { - name: "viewer query", - body: `{"query":"query { viewer { login name email } }"}`, - wantNil: true, + name: "discussions query", + body: `{"query":"query { repository(owner: \"org\", name: \"repo\") { discussions(first: 10) { nodes { title } } } }"}`, + wantTool: "list_discussions", + }, + { + name: "single discussion query", + body: `{"query":"query { repository(owner: \"org\", name: \"repo\") { discussion(number: 1) { title body } } }"}`, + wantTool: "list_discussions", + }, + { + name: "viewer query", + body: `{"query":"query { viewer { login name email } }"}`, + wantTool: "get_me", }, { name: "empty query", diff --git a/internal/proxy/router.go b/internal/proxy/router.go index a9108d1b..2a9755e4 100644 --- a/internal/proxy/router.go +++ b/internal/proxy/router.go @@ -205,6 +205,55 @@ var routes = []route{ return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_workflows"} }, }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/workflows/([^/]+)/runs$`), + toolName: "actions_list", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_workflow_runs", "resource_id": m[3]} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/workflows/([^/]+)$`), + toolName: "actions_get", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "get_workflow", "resource_id": m[3]} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/runs/(\d+)/attempts/(\d+)/jobs$`), + toolName: "actions_list", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_workflow_jobs", "resource_id": m[3]} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/runs/(\d+)/attempts/(\d+)/logs$`), + toolName: "get_job_logs", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "run_id": m[3]} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/runs/(\d+)/logs$`), + toolName: "get_job_logs", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "run_id": m[3]} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/runs/(\d+)/artifacts$`), + toolName: "actions_list", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_workflow_run_artifacts", "resource_id": m[3]} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/runs/(\d+)$`), + toolName: "actions_get", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "get_workflow_run", "resource_id": m[3]} + }, + }, { pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/runs$`), toolName: "actions_list", @@ -212,6 +261,115 @@ var routes = []route{ return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_workflow_runs"} }, }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/jobs/(\d+)$`), + toolName: "actions_get", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "get_workflow_job", "resource_id": m[3]} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/artifacts$`), + toolName: "actions_list", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_workflow_run_artifacts"} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/caches$`), + toolName: "actions_list", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_caches"} + }, + }, + // Actions secrets/variables (names only, no values) + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/secrets$`), + toolName: "actions_list", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_secrets"} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/actions/variables(?:/([^/]+))?$`), + toolName: "actions_list", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_variables"} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/environments/([^/]+)/(?:secrets|variables)$`), + toolName: "actions_list", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "method": "list_environment_config"} + }, + }, + + // Notifications + { + pattern: regexp.MustCompile(`^/notifications$`), + toolName: "list_notifications", + extractArgs: func(_ []string) map[string]interface{} { + return map[string]interface{}{} + }, + }, + + // User API + { + pattern: regexp.MustCompile(`^/user$`), + toolName: "get_me", + extractArgs: func(_ []string) map[string]interface{} { + return map[string]interface{}{} + }, + }, + { + pattern: regexp.MustCompile(`^/user/(?:keys|ssh_signing_keys|gpg_keys)$`), + toolName: "get_me", + extractArgs: func(_ []string) map[string]interface{} { + return map[string]interface{}{} + }, + }, + + // Org-scoped Actions (secrets/variables) + { + pattern: regexp.MustCompile(`^/orgs/([^/]+)/actions/(?:secrets|variables)(?:/[^/]+)?$`), + toolName: "actions_list", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "method": "list_org_config"} + }, + }, + + // Discussions (repo-scoped, matched before generic fallback) + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/discussions$`), + toolName: "list_discussions", + extractArgs: func(m []string) map[string]interface{} { + return repoArgs(m[1], m[2]) + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/discussions/(\d+)/comments$`), + toolName: "get_discussion_comments", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "discussion_number": m[3]} + }, + }, + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/discussions/(\d+)$`), + toolName: "list_discussions", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "discussion_number": m[3]} + }, + }, + + // Check runs/suites (used by gh pr checks) + { + pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/commits/([^/]+)/check-(?:runs|suites)$`), + toolName: "pull_request_read", + extractArgs: func(m []string) map[string]interface{} { + return map[string]interface{}{"owner": m[1], "repo": m[2], "sha": m[3], "method": "get_check_runs"} + }, + }, // Search APIs { @@ -236,10 +394,6 @@ var routes = []route{ }, }, - // User API (/user) is intentionally not mapped — it cannot be correctly labeled - // by the guard (no recognized tool name with equivalent semantics) and may contain - // private account data (e.g., email). Unknown paths are blocked by the handler. - // Generic repo-scoped fallback (must be last) { pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)(?:/.*)?$`),