diff --git a/CLAUDE.md b/CLAUDE.md index 8d5cf63..21feaa6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -304,6 +304,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li `docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session (`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception (`DOCKER_ONLY`); shrink it, never grow it. +- **`aws/`** — AWS connections (Settings → Hosts) and the permission check. `connection.rs` + builds a saved `HostConnection`'s `SdkConfig` **by hand, never with + `aws_config::defaults().load()`**: a profile is read through `ProfileFileCredentialsProvider` + for credentials only, so the app's own `AWS_*` variables, `AWS_ENDPOINT_URL*` and a profile's + endpoint/service settings can't redirect or re-credential a connection's signed requests. + `identity.rs` is `GetCallerIdentity` and the principal to simulate (an assumed role's ARN is + recovered with `iam:GetRole`, because SSO roles have an `/aws-reserved/…` path the session ARN + lacks); `error.rs` classifies SDK errors (SSO expired, access denied naming the action, …); + `policies.rs` embeds `aws/policies/*.json` (the two tier policies and the permissions boundary) + and the table of checks the simulator is asked; `permissions.rs` runs it + (`SimulatePrincipalPolicy`, adaptive retries — it throttles — or read-only probes when the + caller can't simulate itself; a probed run never claims the admin tier). `policies.rs`'s tests + are the drift guard: every allowed action has a check, every check is allowed by a small IAM + evaluator, owner-scoped checks are denied for someone else, admin destruction is tag-scoped, + the boundary fences hold, every Resource is an ARN and every policy fits a 6,144-character + managed policy. **Change a policy JSON, its checks and `aws/README.md` together.** The evaluator + is not IAM: `live_tests.rs` (ignored; `TRIPLEC_AWS_E2E_PROFILE`, `TRIPLEC_AWS_E2E_REGION`, + admin credentials for a disposable account; `cargo test aws::live_tests -- --ignored + --test-threads=1`) runs the check and real calls against throwaway `triple-c-e2e-*` roles, + secrets and keys, and cleans them up. Re-run it after any policy change; the results are in + `aws/README.md` ("Live verification"). - **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server: - `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades - `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect @@ -793,6 +814,12 @@ a project's Docker volumes are deliberately out of scope — this is not a proje inside a generic "settings replaced" summary. Read this as the standing example of the class of thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks like config but is actually a live credential would have the same problem. +- **AWS connection keys travel in `ExportedSecrets::aws_connection_keys`** (keyed by connection id), + exported and restored like the marketplace tokens. `hosts` are **store-owned**: `update_settings` + restores them via `restore_host_fields`, and `apply_settings_import` re-applies them through + `StoreOwned`. `validate_imported_hosts` runs before any keychain write. The import preview + discloses AWS connections, key counts, overwrites (naming the local connection), failed key + checks and removals. - **Marketplace account tokens travel in `ExportedSecrets`, not in `AppSettings`.** `Token` and `GhContainer` accounts' tokens live in the keychain (`triple-c-marketplace-account-`), so they follow the same "carve out of the keychain, restore before the settings replace, only diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 0bd80c2..260bbfd 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -366,6 +366,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-cloudformation" +version = "1.139.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b77def03a5fb2442348afdd6ecd0eafbfa117243865cc78e899beb26edf7fb6d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-ecs" version = "1.159.0" @@ -389,6 +415,78 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-sdk-iam" +version = "1.136.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f53b6d3c4f3f04b734a5ae6eea34491e5c356be99bc677fa7fd84eba0cc671b" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-kms" +version = "1.131.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6309b510ca62e64bafb147a08413d81665fdcaa2b607423d348c576b5d4a89" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "fastrand", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77ef7832d9e7c60fa7b8201bb2038ccc9821b6cf88746caab6dc1c8767882b5" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "fastrand", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-ssm" version = "1.137.0" @@ -7139,8 +7237,16 @@ dependencies = [ "aes-gcm", "argon2", "aws-config", + "aws-credential-types", + "aws-runtime", + "aws-sdk-cloudformation", "aws-sdk-ecs", + "aws-sdk-iam", + "aws-sdk-kms", + "aws-sdk-secretsmanager", "aws-sdk-ssm", + "aws-sdk-sts", + "aws-smithy-async", "axum", "base64 0.22.1", "bollard", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index cc2a08e..8a922ec 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -62,6 +62,18 @@ gix = { version = "0.88", default-features = false, features = ["blocking-networ aws-config = { version = "1.13.2", default-features = false, features = ["default-https-client", "rt-tokio", "credentials-process", "sso", "behavior-version-latest"] } aws-sdk-ssm = { version = "1.137.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } aws-sdk-ecs = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +aws-sdk-sts = { version = "1.126.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +aws-sdk-iam = { version = "1.136.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +aws-sdk-cloudformation = { version = "1.139.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +aws-sdk-secretsmanager = { version = "1.128.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +# Named by src/aws/connection.rs: the credentials-provider trait (its profile +# wrapper) and the profile-file set (aws-config's alias for it is deprecated). +# Both are the versions aws-config already resolves. +aws-credential-types = { version = "1.3.1", default-features = false } +aws-runtime = { version = "1.10.4", default-features = false } +# The default sleep and time source for the hand-built SdkConfig (no env/profile +# loading); the version and feature already in the tree. +aws-smithy-async = { version = "1.3.1", default-features = false, features = ["rt-tokio"] } # Linux only: WebKitGTK refuses getUserMedia until the embedder enables media # streams and answers the permission request (see src/linux_media.rs). Same @@ -74,6 +86,9 @@ webkit2gtk = "2.0.2" # their backoff schedule under a paused clock instead of in real seconds. tokio = { version = "1", features = ["full", "test-util"] } tempfile = "3" +# Only `aws::live_tests` (ignored): it creates a throwaway environment key to +# check that Secrets Manager and the permissions boundary see its tags. +aws-sdk-kms = { version = "1.131.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index ff96e2b..bfc19aa 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -57,6 +57,14 @@ "allow-detect-aws-config", "allow-inspect-ca-cert-path", "allow-list-aws-profiles", + "allow-add-aws-profile-connection", + "allow-add-aws-keys-connection", + "allow-update-aws-connection", + "allow-remove-host-connection", + "allow-aws-connection-identity", + "allow-aws-connection-sso-login", + "allow-check-aws-permissions", + "allow-aws-policy-document", "allow-detect-host-timezone", "allow-export-settings", "allow-preview-settings-import", diff --git a/app/src-tauri/gen/schemas/acl-manifests.json b/app/src-tauri/gen/schemas/acl-manifests.json index ab46895..9e332bb 100644 --- a/app/src-tauri/gen/schemas/acl-manifests.json +++ b/app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-host-connection":{"identifier":"allow-remove-host-connection","description":"Enables the remove_host_connection command without any pre-configured scope.","commands":{"allow":["remove_host_connection"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-update-aws-connection":{"identifier":"allow-update-aws-connection","description":"Enables the update_aws_connection command without any pre-configured scope.","commands":{"allow":["update_aws_connection"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-aws-keys-connection":{"identifier":"deny-add-aws-keys-connection","description":"Denies the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_keys_connection"]}},"deny-add-aws-profile-connection":{"identifier":"deny-add-aws-profile-connection","description":"Denies the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_profile_connection"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-connection-identity":{"identifier":"deny-aws-connection-identity","description":"Denies the aws_connection_identity command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_identity"]}},"deny-aws-connection-sso-login":{"identifier":"deny-aws-connection-sso-login","description":"Denies the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_sso_login"]}},"deny-aws-policy-document":{"identifier":"deny-aws-policy-document","description":"Denies the aws_policy_document command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_policy_document"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-aws-permissions":{"identifier":"deny-check-aws-permissions","description":"Denies the check_aws_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_aws_permissions"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-host-connection":{"identifier":"deny-remove-host-connection","description":"Denies the remove_host_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_host_connection"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-update-aws-connection":{"identifier":"deny-update-aws-connection","description":"Denies the update_aws_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_connection"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/capabilities.json b/app/src-tauri/gen/schemas/capabilities.json index 9dc3cde..012eebb 100644 --- a/app/src-tauri/gen/schemas/capabilities.json +++ b/app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-add-aws-profile-connection","allow-add-aws-keys-connection","allow-update-aws-connection","allow-remove-host-connection","allow-aws-connection-identity","allow-aws-connection-sso-login","allow-check-aws-permissions","allow-aws-policy-document","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/desktop-schema.json b/app/src-tauri/gen/schemas/desktop-schema.json index 1493d4f..e55b0f2 100644 --- a/app/src-tauri/gen/schemas/desktop-schema.json +++ b/app/src-tauri/gen/schemas/desktop-schema.json @@ -350,6 +350,18 @@ "const": "allow-acquire-claude-token", "markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope." }, + { + "description": "Enables the add_aws_keys_connection command without any pre-configured scope.", + "type": "string", + "const": "allow-add-aws-keys-connection", + "markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope." + }, + { + "description": "Enables the add_aws_profile_connection command without any pre-configured scope.", + "type": "string", + "const": "allow-add-aws-profile-connection", + "markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope." + }, { "description": "Enables the add_marketplace command without any pre-configured scope.", "type": "string", @@ -392,6 +404,24 @@ "const": "allow-apply-settings-import", "markdownDescription": "Enables the apply_settings_import command without any pre-configured scope." }, + { + "description": "Enables the aws_connection_identity command without any pre-configured scope.", + "type": "string", + "const": "allow-aws-connection-identity", + "markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope." + }, + { + "description": "Enables the aws_connection_sso_login command without any pre-configured scope.", + "type": "string", + "const": "allow-aws-connection-sso-login", + "markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope." + }, + { + "description": "Enables the aws_policy_document command without any pre-configured scope.", + "type": "string", + "const": "allow-aws-policy-document", + "markdownDescription": "Enables the aws_policy_document command without any pre-configured scope." + }, { "description": "Enables the aws_sso_refresh command without any pre-configured scope.", "type": "string", @@ -428,6 +458,12 @@ "const": "allow-cancel-marketplace-gh-login", "markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope." }, + { + "description": "Enables the check_aws_permissions command without any pre-configured scope.", + "type": "string", + "const": "allow-check-aws-permissions", + "markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope." + }, { "description": "Enables the check_browser_view_support command without any pre-configured scope.", "type": "string", @@ -866,6 +902,12 @@ "const": "allow-regenerate-web-terminal-token", "markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope." }, + { + "description": "Enables the remove_host_connection command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-host-connection", + "markdownDescription": "Enables the remove_host_connection command without any pre-configured scope." + }, { "description": "Enables the remove_marketplace command without any pre-configured scope.", "type": "string", @@ -1082,6 +1124,12 @@ "const": "allow-uninstall-marketplace-item", "markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope." }, + { + "description": "Enables the update_aws_connection command without any pre-configured scope.", + "type": "string", + "const": "allow-update-aws-connection", + "markdownDescription": "Enables the update_aws_connection command without any pre-configured scope." + }, { "description": "Enables the update_marketplace command without any pre-configured scope.", "type": "string", @@ -1160,6 +1208,18 @@ "const": "deny-acquire-claude-token", "markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope." }, + { + "description": "Denies the add_aws_keys_connection command without any pre-configured scope.", + "type": "string", + "const": "deny-add-aws-keys-connection", + "markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope." + }, + { + "description": "Denies the add_aws_profile_connection command without any pre-configured scope.", + "type": "string", + "const": "deny-add-aws-profile-connection", + "markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope." + }, { "description": "Denies the add_marketplace command without any pre-configured scope.", "type": "string", @@ -1202,6 +1262,24 @@ "const": "deny-apply-settings-import", "markdownDescription": "Denies the apply_settings_import command without any pre-configured scope." }, + { + "description": "Denies the aws_connection_identity command without any pre-configured scope.", + "type": "string", + "const": "deny-aws-connection-identity", + "markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope." + }, + { + "description": "Denies the aws_connection_sso_login command without any pre-configured scope.", + "type": "string", + "const": "deny-aws-connection-sso-login", + "markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope." + }, + { + "description": "Denies the aws_policy_document command without any pre-configured scope.", + "type": "string", + "const": "deny-aws-policy-document", + "markdownDescription": "Denies the aws_policy_document command without any pre-configured scope." + }, { "description": "Denies the aws_sso_refresh command without any pre-configured scope.", "type": "string", @@ -1238,6 +1316,12 @@ "const": "deny-cancel-marketplace-gh-login", "markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope." }, + { + "description": "Denies the check_aws_permissions command without any pre-configured scope.", + "type": "string", + "const": "deny-check-aws-permissions", + "markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope." + }, { "description": "Denies the check_browser_view_support command without any pre-configured scope.", "type": "string", @@ -1676,6 +1760,12 @@ "const": "deny-regenerate-web-terminal-token", "markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope." }, + { + "description": "Denies the remove_host_connection command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-host-connection", + "markdownDescription": "Denies the remove_host_connection command without any pre-configured scope." + }, { "description": "Denies the remove_marketplace command without any pre-configured scope.", "type": "string", @@ -1892,6 +1982,12 @@ "const": "deny-uninstall-marketplace-item", "markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope." }, + { + "description": "Denies the update_aws_connection command without any pre-configured scope.", + "type": "string", + "const": "deny-update-aws-connection", + "markdownDescription": "Denies the update_aws_connection command without any pre-configured scope." + }, { "description": "Denies the update_marketplace command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/gen/schemas/linux-schema.json b/app/src-tauri/gen/schemas/linux-schema.json index 1493d4f..e55b0f2 100644 --- a/app/src-tauri/gen/schemas/linux-schema.json +++ b/app/src-tauri/gen/schemas/linux-schema.json @@ -350,6 +350,18 @@ "const": "allow-acquire-claude-token", "markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope." }, + { + "description": "Enables the add_aws_keys_connection command without any pre-configured scope.", + "type": "string", + "const": "allow-add-aws-keys-connection", + "markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope." + }, + { + "description": "Enables the add_aws_profile_connection command without any pre-configured scope.", + "type": "string", + "const": "allow-add-aws-profile-connection", + "markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope." + }, { "description": "Enables the add_marketplace command without any pre-configured scope.", "type": "string", @@ -392,6 +404,24 @@ "const": "allow-apply-settings-import", "markdownDescription": "Enables the apply_settings_import command without any pre-configured scope." }, + { + "description": "Enables the aws_connection_identity command without any pre-configured scope.", + "type": "string", + "const": "allow-aws-connection-identity", + "markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope." + }, + { + "description": "Enables the aws_connection_sso_login command without any pre-configured scope.", + "type": "string", + "const": "allow-aws-connection-sso-login", + "markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope." + }, + { + "description": "Enables the aws_policy_document command without any pre-configured scope.", + "type": "string", + "const": "allow-aws-policy-document", + "markdownDescription": "Enables the aws_policy_document command without any pre-configured scope." + }, { "description": "Enables the aws_sso_refresh command without any pre-configured scope.", "type": "string", @@ -428,6 +458,12 @@ "const": "allow-cancel-marketplace-gh-login", "markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope." }, + { + "description": "Enables the check_aws_permissions command without any pre-configured scope.", + "type": "string", + "const": "allow-check-aws-permissions", + "markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope." + }, { "description": "Enables the check_browser_view_support command without any pre-configured scope.", "type": "string", @@ -866,6 +902,12 @@ "const": "allow-regenerate-web-terminal-token", "markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope." }, + { + "description": "Enables the remove_host_connection command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-host-connection", + "markdownDescription": "Enables the remove_host_connection command without any pre-configured scope." + }, { "description": "Enables the remove_marketplace command without any pre-configured scope.", "type": "string", @@ -1082,6 +1124,12 @@ "const": "allow-uninstall-marketplace-item", "markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope." }, + { + "description": "Enables the update_aws_connection command without any pre-configured scope.", + "type": "string", + "const": "allow-update-aws-connection", + "markdownDescription": "Enables the update_aws_connection command without any pre-configured scope." + }, { "description": "Enables the update_marketplace command without any pre-configured scope.", "type": "string", @@ -1160,6 +1208,18 @@ "const": "deny-acquire-claude-token", "markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope." }, + { + "description": "Denies the add_aws_keys_connection command without any pre-configured scope.", + "type": "string", + "const": "deny-add-aws-keys-connection", + "markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope." + }, + { + "description": "Denies the add_aws_profile_connection command without any pre-configured scope.", + "type": "string", + "const": "deny-add-aws-profile-connection", + "markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope." + }, { "description": "Denies the add_marketplace command without any pre-configured scope.", "type": "string", @@ -1202,6 +1262,24 @@ "const": "deny-apply-settings-import", "markdownDescription": "Denies the apply_settings_import command without any pre-configured scope." }, + { + "description": "Denies the aws_connection_identity command without any pre-configured scope.", + "type": "string", + "const": "deny-aws-connection-identity", + "markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope." + }, + { + "description": "Denies the aws_connection_sso_login command without any pre-configured scope.", + "type": "string", + "const": "deny-aws-connection-sso-login", + "markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope." + }, + { + "description": "Denies the aws_policy_document command without any pre-configured scope.", + "type": "string", + "const": "deny-aws-policy-document", + "markdownDescription": "Denies the aws_policy_document command without any pre-configured scope." + }, { "description": "Denies the aws_sso_refresh command without any pre-configured scope.", "type": "string", @@ -1238,6 +1316,12 @@ "const": "deny-cancel-marketplace-gh-login", "markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope." }, + { + "description": "Denies the check_aws_permissions command without any pre-configured scope.", + "type": "string", + "const": "deny-check-aws-permissions", + "markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope." + }, { "description": "Denies the check_browser_view_support command without any pre-configured scope.", "type": "string", @@ -1676,6 +1760,12 @@ "const": "deny-regenerate-web-terminal-token", "markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope." }, + { + "description": "Denies the remove_host_connection command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-host-connection", + "markdownDescription": "Denies the remove_host_connection command without any pre-configured scope." + }, { "description": "Denies the remove_marketplace command without any pre-configured scope.", "type": "string", @@ -1892,6 +1982,12 @@ "const": "deny-uninstall-marketplace-item", "markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope." }, + { + "description": "Denies the update_aws_connection command without any pre-configured scope.", + "type": "string", + "const": "deny-update-aws-connection", + "markdownDescription": "Denies the update_aws_connection command without any pre-configured scope." + }, { "description": "Denies the update_marketplace command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/src/aws/connection.rs b/app/src-tauri/src/aws/connection.rs new file mode 100644 index 0000000..1e14579 --- /dev/null +++ b/app/src-tauri/src/aws/connection.rs @@ -0,0 +1,300 @@ +//! An `SdkConfig` built from a saved `HostConnection`, never from the +//! process's default credentials chain. + +use aws_config::identity::IdentityCache; +use aws_config::profile::ProfileFileCredentialsProvider; +use aws_config::provider_config::ProviderConfig; +use aws_config::retry::RetryConfig; +use aws_config::{BehaviorVersion, Region, SdkConfig}; +use aws_credential_types::provider::error::CredentialsError; +use aws_credential_types::provider::{future, ProvideCredentials, SharedCredentialsProvider}; +use aws_credential_types::Credentials; +use aws_runtime::env_config::file::EnvConfigFiles; +use aws_smithy_async::rt::sleep::default_async_sleep; +use aws_smithy_async::time::SharedTimeSource; + +use super::error::{AwsError, ProfileCredentialsError}; +use crate::models::host_connection::{AwsAuth, HostConnection, HostKind}; +use crate::storage::secure; + +/// The SDK config for `conn` in `region`. Credentials are resolved lazily, +/// so a missing profile or an expired SSO session surfaces on the first +/// call and is classified there (`classify_sdk_error`). +pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result { + sdk_config_with(conn, region, None).await +} + +/// `sdk_config` with the profile files overridden (tests pass temp files so +/// neither the process environment nor `~/.aws` is consulted). +pub(crate) async fn sdk_config_with( + conn: &HostConnection, + region: &str, + profile_files: Option, +) -> Result { + let HostKind::Aws { auth, .. } = &conn.kind; + let region = Region::new(region.to_string()); + let credentials = match auth { + AwsAuth::Profile { name } => { + // The profile is read for credentials only (static keys, SSO, + // assume-role, credential_process), never for endpoints or + // service settings. + let mut builder = ProfileFileCredentialsProvider::builder() + .configure(&ProviderConfig::default().with_region(Some(region.clone()))) + .profile_name(name); + if let Some(files) = profile_files { + builder = builder.profile_files(files); + } + SharedCredentialsProvider::new(NamedProfileProvider { + profile: name.clone(), + inner: builder.build(), + }) + } + AwsAuth::Keys { keyring_ref } => { + let keys = secure::get_aws_keys(keyring_ref) + .map_err(|message| AwsError::Other { message })? + .ok_or(AwsError::KeysMissing)?; + keys_credentials(&keys) + } + }; + Ok(build_sdk_config(region, credentials)) +} + +/// The SDK config for keys not yet saved (the add-connection check runs +/// before anything touches the keychain). +pub fn sdk_config_for_keys(keys: &secure::AwsKeys, region: &str) -> SdkConfig { + build_sdk_config(Region::new(region.to_string()), keys_credentials(keys)) +} + +fn keys_credentials(keys: &secure::AwsKeys) -> SharedCredentialsProvider { + SharedCredentialsProvider::new(Credentials::new( + keys.access_key_id.as_str(), + keys.secret_access_key.as_str(), + keys.session_token.clone(), + None, + "triple-c-connection", + )) +} + +/// Built by hand rather than with `aws_config::defaults(..).load()`, which +/// would read `AWS_ENDPOINT_URL[_]`, FIPS/dual-stack, retry and +/// other settings from the process environment and the default profile. A +/// stray endpoint there would send every connection's signed requests (and +/// the permission check) somewhere other than AWS. Connections talk to the +/// real regional endpoints; a profile's own `endpoint_url` is ignored too. +/// The HTTP client, timeouts and stalled-stream protection are each service +/// client's defaults (the `default-https-client` stack every AWS crate here +/// is built with). +fn build_sdk_config(region: Region, credentials: SharedCredentialsProvider) -> SdkConfig { + let mut builder = SdkConfig::builder() + .behavior_version(BehaviorVersion::latest()) + .region(region) + .credentials_provider(credentials) + .identity_cache(IdentityCache::lazy().build()) + .retry_config(RetryConfig::standard()) + .time_source(SharedTimeSource::default()); + if let Some(sleep) = default_async_sleep() { + builder = builder.sleep_impl(sleep); + } + builder.build() +} + +/// Puts the profile's name into the source chain of every credentials error, +/// so a failure deep inside the SDK can be reported against the profile. +#[derive(Debug)] +struct NamedProfileProvider { + profile: String, + inner: ProfileFileCredentialsProvider, +} + +impl NamedProfileProvider { + async fn load(&self) -> Result { + self.inner.provide_credentials().await.map_err(|e| { + let not_loaded = matches!(e, CredentialsError::CredentialsNotLoaded(_)); + let timed_out = matches!(e, CredentialsError::ProviderTimedOut(_)); + if timed_out { + return e; + } + let invalid = matches!(e, CredentialsError::InvalidConfiguration(_)); + let wrapped = ProfileCredentialsError { + profile: self.profile.clone(), + not_loaded, + source: e, + }; + // Keep the error's kind; only its context changes. + if not_loaded { + CredentialsError::not_loaded(wrapped) + } else if invalid { + CredentialsError::invalid_configuration(wrapped) + } else { + CredentialsError::provider_error(wrapped) + } + }) + } +} + +impl ProvideCredentials for NamedProfileProvider { + fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a> + where + Self: 'a, + { + future::ProvideCredentials::new(self.load()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::aws::identity::caller_identity; + use crate::aws::AwsError; + use crate::models::host_connection::{AwsAuth, HostConnection, HostKind}; + use aws_runtime::env_config::file::{EnvConfigFileKind, EnvConfigFiles}; + + fn profile_conn(name: &str) -> HostConnection { + HostConnection { + id: "c1".into(), + name: "Test".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { name: name.into() }, + default_region: "us-west-2".into(), + }, + } + } + + /// Empty config and credentials files, passed explicitly so neither the + /// process environment nor ~/.aws is consulted. + #[tokio::test] + async fn a_missing_profile_is_reported_by_name() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + let creds = dir.path().join("credentials"); + std::fs::write(&config, "").unwrap(); + std::fs::write(&creds, "").unwrap(); + let files = EnvConfigFiles::builder() + .include_default_config_file(false) + .include_default_credentials_file(false) + .with_file(EnvConfigFileKind::Config, &config) + .with_file(EnvConfigFileKind::Credentials, &creds) + .build(); + let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files)) + .await + .unwrap(); + let err = caller_identity(&cfg).await.unwrap_err(); + assert_eq!( + err, + AwsError::ProfileNotFound { + profile: "nope".into() + } + ); + } + + /// A profile that the files do define but under another name is still + /// "not found" for the name the connection asked for. + #[tokio::test] + async fn a_profile_absent_from_populated_files_is_reported_by_name() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + std::fs::write( + &config, + "[profile other]\naws_access_key_id = AKIDTEST\naws_secret_access_key = s\n", + ) + .unwrap(); + let files = EnvConfigFiles::builder() + .include_default_config_file(false) + .include_default_credentials_file(false) + .with_file(EnvConfigFileKind::Config, &config) + .build(); + let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files)) + .await + .unwrap(); + let err = caller_identity(&cfg).await.unwrap_err(); + assert_eq!( + err, + AwsError::ProfileNotFound { + profile: "nope".into() + } + ); + } + + #[tokio::test] + async fn the_region_comes_from_the_argument() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + std::fs::write(&config, "[profile p]\nregion = eu-west-1\n").unwrap(); + let files = EnvConfigFiles::builder() + .include_default_config_file(false) + .include_default_credentials_file(false) + .with_file(EnvConfigFileKind::Config, &config) + .build(); + let cfg = sdk_config_with(&profile_conn("p"), "ap-south-1", Some(files)) + .await + .unwrap(); + assert_eq!(cfg.region().map(|r| r.as_ref()), Some("ap-south-1")); + } + + /// Config files that try to redirect every call. A connection always + /// talks to the real regional AWS endpoints, so none of this may reach + /// the config: not the named profile's `endpoint_url`, not `[default]`'s, + /// not a per-service `services` section. + const REDIRECTING_CONFIG: &str = "[default] +endpoint_url = http://127.0.0.1:9 +retry_mode = adaptive +max_attempts = 9 +use_fips_endpoint = true + +[profile p] +endpoint_url = http://127.0.0.1:9 +retry_mode = adaptive +max_attempts = 9 +use_dualstack_endpoint = true +services = redirect +aws_access_key_id = AKIDTEST +aws_secret_access_key = s + +[services redirect] +sts = + endpoint_url = http://127.0.0.1:9 +"; + + fn assert_isolated(cfg: &aws_config::SdkConfig) { + assert_eq!(cfg.endpoint_url(), None); + assert!( + cfg.service_config().is_none(), + "service config loaded from env/profile" + ); + assert_eq!(cfg.use_fips(), None); + assert_eq!(cfg.use_dual_stack(), None); + let retry = cfg.retry_config().expect("retry config"); + assert_eq!(retry.mode(), aws_config::retry::RetryMode::Standard); + assert_eq!(retry.max_attempts(), 3); + assert_eq!(cfg.region().map(|r| r.as_ref()), Some("us-west-2")); + } + + #[tokio::test] + async fn a_profile_connection_ignores_endpoint_and_service_settings_in_the_profile() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + std::fs::write(&config, REDIRECTING_CONFIG).unwrap(); + let files = EnvConfigFiles::builder() + .include_default_config_file(false) + .include_default_credentials_file(false) + .with_file(EnvConfigFileKind::Config, &config) + .build(); + let cfg = sdk_config_with(&profile_conn("p"), "us-west-2", Some(files)) + .await + .unwrap(); + assert_isolated(&cfg); + } + + /// The keys arm reads no files or environment at all: same isolation. + #[test] + fn a_keys_connection_config_is_isolated() { + let keys = crate::storage::secure::AwsKeys { + access_key_id: "AKIDTEST".into(), + secret_access_key: "s".into(), + session_token: None, + }; + let cfg = build_sdk_config(Region::new("us-west-2"), keys_credentials(&keys)); + assert_isolated(&cfg); + assert!(cfg.credentials_provider().is_some()); + } +} diff --git a/app/src-tauri/src/aws/error.rs b/app/src-tauri/src/aws/error.rs new file mode 100644 index 0000000..2ec0e8e --- /dev/null +++ b/app/src-tauri/src/aws/error.rs @@ -0,0 +1,147 @@ +//! What can go wrong talking to AWS, in the terms the UI acts on. + +use std::error::Error; + +use aws_sdk_sts::error::{DisplayErrorContext, ErrorMetadata}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AwsError { + /// The UI offers "Sign in". + SsoExpired { + profile: String, + }, + ProfileNotFound { + profile: String, + }, + /// The keychain entry for a keys connection is gone. + KeysMissing, + InvalidCredentials { + message: String, + }, + AccessDenied { + action: String, + message: String, + }, + Other { + message: String, + }, +} + +impl std::fmt::Display for AwsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AwsError::SsoExpired { profile } if profile.is_empty() => { + write!(f, "The AWS SSO session has expired; sign in again") + } + AwsError::SsoExpired { profile } => write!( + f, + "The AWS SSO session for profile \"{profile}\" has expired; sign in again" + ), + AwsError::ProfileNotFound { profile } => write!( + f, + "AWS profile \"{profile}\" was not found, or has no credentials, in the AWS config and credentials files" + ), + AwsError::KeysMissing => write!( + f, + "The access keys for this connection are no longer in the system keychain; enter them again" + ), + AwsError::InvalidCredentials { message } => { + write!(f, "AWS rejected the credentials: {message}") + } + AwsError::AccessDenied { action, message } => { + write!(f, "Access denied for {action}: {message}") + } + AwsError::Other { message } => f.write_str(message), + } + } +} + +/// Carried in the source chain of every credentials error from a profile +/// connection, so classification knows which profile failed and whether it +/// produced no credentials at all (absent, or present without credentials). +#[derive(Debug)] +pub(crate) struct ProfileCredentialsError { + pub profile: String, + pub not_loaded: bool, + pub source: aws_credential_types::provider::error::CredentialsError, +} + +impl std::fmt::Display for ProfileCredentialsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "credentials for profile `{}`", self.profile) + } +} + +impl Error for ProfileCredentialsError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.source) + } +} + +const SSO_EXPIRED: &[&str] = &[ + "The SSO session associated with this profile has expired", + "Token has expired", + "the SSO token has expired", +]; +const SSO_REVOKED: &[&str] = &[ + "UnauthorizedException", + "Session token not found or invalid", +]; +const DENIED_CODES: &[&str] = &[ + "AccessDeniedException", + "AccessDenied", + "UnauthorizedOperation", +]; +const BAD_CREDENTIAL_CODES: &[&str] = &[ + "ExpiredToken", + "InvalidClientTokenId", + "SignatureDoesNotMatch", + "UnrecognizedClientException", +]; + +/// Classify any SDK error (an `SdkError`, a credentials error, ...) from +/// calling `action` (e.g. `iam:GetRole`). Matching is on the full display +/// chain, which carries both provider messages and service error codes. +pub fn classify_sdk_error(action: &str, err: &(dyn Error + 'static)) -> AwsError { + let text = DisplayErrorContext(err).to_string(); + let mut chain = std::iter::successors(Some(err), |e| (*e).source()); + let profile = chain + .clone() + .find_map(|e| e.downcast_ref::()); + // The service's own message, when one is in the chain. + let service_message = chain + .find_map(|e| e.downcast_ref::()) + .and_then(|m| m.message()) + .map(str::to_string); + let message = service_message.unwrap_or_else(|| text.clone()); + + if SSO_EXPIRED.iter().any(|s| text.contains(s)) { + return AwsError::SsoExpired { + profile: profile.map(|p| p.profile.clone()).unwrap_or_default(), + }; + } + // SSO's GetRoleCredentials on a revoked or signed-out session. Only + // inside a profile's credentials: other services use this code too. + if let Some(p) = profile.filter(|_| SSO_REVOKED.iter().any(|s| text.contains(s))) { + return AwsError::SsoExpired { + profile: p.profile.clone(), + }; + } + if let Some(p) = profile.filter(|p| p.not_loaded) { + return AwsError::ProfileNotFound { + profile: p.profile.clone(), + }; + } + if DENIED_CODES.iter().any(|c| text.contains(c)) { + return AwsError::AccessDenied { + action: action.to_string(), + message, + }; + } + if BAD_CREDENTIAL_CODES.iter().any(|c| text.contains(c)) { + return AwsError::InvalidCredentials { message }; + } + AwsError::Other { message: text } +} diff --git a/app/src-tauri/src/aws/identity.rs b/app/src-tauri/src/aws/identity.rs new file mode 100644 index 0000000..68671d3 --- /dev/null +++ b/app/src-tauri/src/aws/identity.rs @@ -0,0 +1,413 @@ +//! Who the connection's credentials are, and which IAM principal the +//! policy simulator should be asked about. + +use aws_config::SdkConfig; +use serde::Serialize; + +use super::error::{classify_sdk_error, AwsError}; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CallerIdentity { + pub account: String, + pub arn: String, + /// STS `UserId`: the principal's unique id (for a role session, + /// `:`). + pub user_id: String, +} + +/// `sts:GetCallerIdentity`, which needs no IAM permission: it is the +/// connection test itself. +pub async fn caller_identity(cfg: &SdkConfig) -> Result { + const ACTION: &str = "sts:GetCallerIdentity"; + let out = aws_sdk_sts::Client::new(cfg) + .get_caller_identity() + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + let field = |v: Option<&str>, name: &str| { + v.map(str::to_string).ok_or_else(|| AwsError::Other { + message: format!("{ACTION} returned no {name}"), + }) + }; + Ok(CallerIdentity { + account: field(out.account(), "Account")?, + arn: field(out.arn(), "Arn")?, + user_id: field(out.user_id(), "UserId")?, + }) +} + +/// The principal to simulate, as far as the caller's ARN tells us. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrincipalArn { + /// An IAM user or role ARN, usable as `PolicySourceArn` directly. + Exact(String), + /// A role session: the role's ARN carries a path the session ARN lacks, + /// so it has to be looked up (`resolve_principal`). + AssumedRole { account: String, role_name: String }, +} + +/// Map the caller's ARN to a simulatable principal. Root, federated users +/// and anything unrecognised cannot be simulated; the caller falls back to +/// probing instead. +pub fn principal_for_simulation(caller_arn: &str) -> Result { + let unsupported = || AwsError::Other { + message: format!("IAM policy simulation isn't possible for the principal {caller_arn}"), + }; + // arn::::: + let mut parts = caller_arn.splitn(6, ':'); + let (Some("arn"), Some(partition), Some(service), Some(_region), Some(account), Some(resource)) = ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ) else { + return Err(unsupported()); + }; + if partition.is_empty() || account.is_empty() { + return Err(unsupported()); + } + match service { + "iam" if resource.starts_with("user/") || resource.starts_with("role/") => { + Ok(PrincipalArn::Exact(caller_arn.to_string())) + } + "sts" => match resource + .strip_prefix("assumed-role/") + .and_then(|r| r.split_once('/')) + { + Some((role_name, _session)) if !role_name.is_empty() => Ok(PrincipalArn::AssumedRole { + account: account.to_string(), + role_name: role_name.to_string(), + }), + _ => Err(unsupported()), + }, + _ => Err(unsupported()), + } +} + +/// The full ARN to simulate. An assumed role needs `iam:GetRole` to recover +/// the role's path (SSO roles live under `/aws-reserved/...`). +pub async fn resolve_principal( + iam: &aws_sdk_iam::Client, + p: PrincipalArn, +) -> Result { + const ACTION: &str = "iam:GetRole"; + let (account, role_name) = match p { + PrincipalArn::Exact(arn) => return Ok(arn), + PrincipalArn::AssumedRole { account, role_name } => (account, role_name), + }; + let out = iam + .get_role() + .role_name(&role_name) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + let arn = out + .role() + .map(|r| r.arn().to_string()) + .filter(|a| !a.is_empty()) + .ok_or_else(|| AwsError::Other { + message: format!("{ACTION} returned no role for {role_name}"), + })?; + // The role must be the session's own: same account. + if arn.split(':').nth(4) != Some(account.as_str()) { + return Err(AwsError::Other { + message: format!("{ACTION} for {role_name} returned {arn}, outside account {account}"), + }); + } + Ok(arn) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::aws::error::classify_sdk_error; + use crate::aws::AwsError; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn iam_user_arn_is_used_as_is() { + let arn = "arn:aws:iam::111122223333:user/alice"; + assert_eq!( + principal_for_simulation(arn).unwrap(), + PrincipalArn::Exact(arn.into()) + ); + } + + #[test] + fn iam_role_arn_is_used_as_is() { + let arn = "arn:aws:iam::111122223333:role/ops/deployer"; + assert_eq!( + principal_for_simulation(arn).unwrap(), + PrincipalArn::Exact(arn.into()) + ); + } + + #[test] + fn assumed_role_arn_maps_to_role_name() { + let arn = "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Admin_abc/alice@x.com"; + assert_eq!( + principal_for_simulation(arn).unwrap(), + PrincipalArn::AssumedRole { + account: "111122223333".into(), + role_name: "AWSReservedSSO_Admin_abc".into(), + } + ); + } + + #[test] + fn root_and_federated_arns_are_rejected_for_simulation() { + for arn in [ + "arn:aws:iam::1:root", + "arn:aws:sts::1:federated-user/bob", + "not-an-arn", + ] { + match principal_for_simulation(arn) { + Err(AwsError::Other { message }) => { + assert!( + message.contains("simulation isn't possible"), + "{arn}: {message}" + ) + } + other => panic!("{arn}: expected Other, got {other:?}"), + } + } + } + + /// A one-shot-per-connection HTTP server that answers every request with + /// `status` and `body`, and reports each request it saw. + async fn fake_endpoint( + status: &'static str, + body: &'static str, + ) -> (String, tokio::sync::mpsc::UnboundedReceiver) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; + let tx = tx.clone(); + tokio::spawn(async move { + let mut buf = vec![0u8; 64 * 1024]; + let mut req = Vec::new(); + loop { + let n = match sock.read(&mut buf).await { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + req.extend_from_slice(&buf[..n]); + let text = String::from_utf8_lossy(&req).into_owned(); + let Some(head_end) = text.find("\r\n\r\n") else { + continue; + }; + let len = text[..head_end] + .lines() + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case("content-length") + .then(|| v.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if req.len() < head_end + 4 + len { + continue; + } + let _ = tx.send(text); + let resp = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/xml\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + let _ = sock.write_all(resp.as_bytes()).await; + req.clear(); + } + }); + } + }); + (format!("http://127.0.0.1:{port}"), rx) + } + + fn iam_client(endpoint: String) -> aws_sdk_iam::Client { + let conf = aws_sdk_iam::Config::builder() + .behavior_version(aws_sdk_iam::config::BehaviorVersion::latest()) + .region(aws_sdk_iam::config::Region::new("us-east-1")) + .credentials_provider(aws_sdk_iam::config::Credentials::new( + "AKIDTEST", "secret", None, None, "test", + )) + .endpoint_url(endpoint) + .build(); + aws_sdk_iam::Client::from_conf(conf) + } + + const ROLE_ARN: &str = "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc"; + + #[tokio::test] + async fn assumed_role_arn_maps_to_role_arn_with_path() { + const BODY: &str = r#" + + + /aws-reserved/sso.amazonaws.com/us-west-2/ + AWSReservedSSO_Admin_abc + AROAEXAMPLEID + arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc + 2024-01-01T00:00:00Z + + + req-1 +"#; + let (endpoint, mut seen) = fake_endpoint("200 OK", BODY).await; + let p = PrincipalArn::AssumedRole { + account: "111122223333".into(), + role_name: "AWSReservedSSO_Admin_abc".into(), + }; + let arn = resolve_principal(&iam_client(endpoint), p).await.unwrap(); + assert_eq!(arn, ROLE_ARN); + let req = seen.recv().await.unwrap(); + assert!(req.contains("Action=GetRole"), "{req}"); + assert!(req.contains("RoleName=AWSReservedSSO_Admin_abc"), "{req}"); + } + + #[tokio::test] + async fn an_exact_principal_needs_no_lookup() { + // No endpoint answers: any request would fail the test. + let client = iam_client("http://127.0.0.1:9".into()); + let arn = "arn:aws:iam::111122223333:user/alice"; + let got = resolve_principal(&client, PrincipalArn::Exact(arn.into())) + .await + .unwrap(); + assert_eq!(got, arn); + } + + #[tokio::test] + async fn a_denied_get_role_is_access_denied_for_get_role() { + const BODY: &str = r#" + + Sender + AccessDenied + User: arn:aws:sts::111122223333:assumed-role/R/alice is not authorized to perform: iam:GetRole + + req-2 +"#; + let (endpoint, _seen) = fake_endpoint("403 Forbidden", BODY).await; + let p = PrincipalArn::AssumedRole { + account: "111122223333".into(), + role_name: "R".into(), + }; + match resolve_principal(&iam_client(endpoint), p).await { + Err(AwsError::AccessDenied { action, message }) => { + assert_eq!(action, "iam:GetRole"); + assert!( + message.contains("is not authorized to perform: iam:GetRole"), + "{message}" + ); + } + other => panic!("expected AccessDenied, got {other:?}"), + } + } + + #[derive(Debug)] + struct Msg(&'static str, Option>); + impl std::fmt::Display for Msg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } + } + impl std::error::Error for Msg { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.1.as_deref() + } + } + + #[test] + fn an_expired_sso_token_is_reported_as_sso_expired() { + for leaf in [ + "The SSO session associated with this profile has expired or is otherwise invalid", + "Token has expired and refresh failed", + ] { + // Deep in the chain, as the SDK reports it. + let err = Msg("dispatch failure", Some(Box::new(Msg(leaf, None)))); + assert!( + matches!( + classify_sdk_error("sts:GetCallerIdentity", &err), + AwsError::SsoExpired { .. } + ), + "{leaf}" + ); + } + } + + /// SSO's GetRoleCredentials on a revoked or signed-out session. Inside a + /// profile connection's credentials that means "sign in again". + #[test] + fn a_revoked_sso_session_is_reported_as_sso_expired() { + use crate::aws::error::ProfileCredentialsError; + use aws_credential_types::provider::error::CredentialsError; + let leaf = CredentialsError::provider_error( + "service error: unhandled error (UnauthorizedException): Session token not found or invalid", + ); + let err = CredentialsError::provider_error(ProfileCredentialsError { + profile: "dev".into(), + not_loaded: false, + source: leaf, + }); + assert_eq!( + classify_sdk_error( + "sts:GetCallerIdentity", + &Msg("dispatch failure", Some(Box::new(err))) + ), + AwsError::SsoExpired { + profile: "dev".into() + } + ); + } + + /// The same error code from some other service, outside any profile's + /// credentials, is not an SSO problem. + #[test] + fn an_unauthorized_exception_outside_profile_credentials_is_not_sso() { + let err = Msg( + "service error: unhandled error (UnauthorizedException): Session token not found or invalid", + None, + ); + assert!(!matches!( + classify_sdk_error("ecs:ListClusters", &err), + AwsError::SsoExpired { .. } + )); + } + + #[test] + fn service_error_codes_are_classified() { + let denied = Msg( + "service error: unhandled error (AccessDeniedException)", + None, + ); + assert!(matches!( + classify_sdk_error("ecs:ListClusters", &denied), + AwsError::AccessDenied { action, .. } if action == "ecs:ListClusters" + )); + for code in [ + "ExpiredToken", + "InvalidClientTokenId", + "SignatureDoesNotMatch", + ] { + let text: &'static str = + Box::leak(format!("service error: unhandled error ({code})").into_boxed_str()); + assert!( + matches!( + classify_sdk_error("sts:GetCallerIdentity", &Msg(text, None)), + AwsError::InvalidCredentials { .. } + ), + "{code}" + ); + } + assert!(matches!( + classify_sdk_error( + "sts:GetCallerIdentity", + &Msg("io error: connection refused", None) + ), + AwsError::Other { .. } + )); + } +} diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs new file mode 100644 index 0000000..9c5b92b --- /dev/null +++ b/app/src-tauri/src/aws/live_tests.rs @@ -0,0 +1,958 @@ +//! The tier policies against real IAM: `check_permissions` run against +//! throwaway roles that hold the shipped policies, and the few statements +//! whose behaviour only a real request shows (Secrets Manager's KMS calls, +//! the permissions boundary, the admin tier's role and key fences). +//! +//! Ignored; needs credentials for a **disposable** account that may create +//! and delete IAM roles and policies, KMS keys and Secrets Manager secrets: +//! +//! - `TRIPLEC_AWS_E2E_PROFILE` — a profile in the AWS config/credentials +//! files (read the way a saved connection reads it, so `AWS_*` key +//! variables are not used; `AWS_SHARED_CREDENTIALS_FILE` and +//! `AWS_CONFIG_FILE` still choose the files); +//! - `TRIPLEC_AWS_E2E_REGION` — e.g. `us-west-2`. +//! +//! ```sh +//! cd app/src-tauri +//! TRIPLEC_AWS_E2E_PROFILE=… TRIPLEC_AWS_E2E_REGION=us-west-2 \ +//! cargo test aws::live_tests -- --ignored --test-threads=1 +//! ``` +//! +//! Every resource is named `triple-c-e2e-…` (secrets +//! `triple-c/triple-c-e2e-/…`, because the policies only cover +//! `triple-c/*`), tagged `triple-c-test=phase3`, and deleted when the test +//! ends, pass or fail. KMS keys can only be scheduled for deletion (7 days). +//! IAM is free; a run costs a few cents at most (secrets for minutes, keys +//! pending deletion). + +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use aws_config::SdkConfig; +use futures_util::FutureExt; + +use super::error::{classify_sdk_error, AwsError}; +use super::permissions::{ + check_permissions, simulation_request, Method, PermissionApi, PermissionReport, + SdkPermissionApi, Summary, Verdict, +}; +use super::policies::{ + checks, permissions_boundary_json, policy_json, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME, +}; +use super::{caller_identity, sdk_config, sdk_config_for_keys, CallerIdentity}; +use crate::models::host_connection::{AwsAuth, HostConnection, HostKind}; +use crate::storage::secure::AwsKeys; + +const TEST_TAG: (&str, &str) = ("triple-c-test", "phase3"); +const PATH: &str = "/triple-c-e2e/"; +const SESSION: &str = "triple-c-e2e"; + +fn var(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)")) +} + +/// What one test created, for the guard to delete. +#[derive(Default)] +struct Created { + roles: Vec, + policies: Vec, + secrets: Vec, + keys: Vec, +} + +struct E2e { + cfg: SdkConfig, + iam: aws_sdk_iam::Client, + sts: aws_sdk_sts::Client, + secrets: aws_sdk_secretsmanager::Client, + kms: aws_sdk_kms::Client, + region: String, + account: String, + /// `triple-c-e2e-`: the prefix of everything this test creates. + prefix: String, + created: Mutex, +} + +/// A role this test created. +struct Role { + name: String, + arn: String, + /// `RoleId`: an assumed session's `aws:userid` is `:`. + id: String, +} + +impl Role { + fn session_user_id(&self) -> String { + format!("{}:{SESSION}", self.id) + } + + /// The identity `check_permissions` sees for the role itself (not a + /// session of it): an IAM role ARN, simulated as-is. + fn identity(&self, account: &str) -> CallerIdentity { + CallerIdentity { + account: account.to_string(), + arn: self.arn.clone(), + user_id: self.session_user_id(), + } + } +} + +/// Runs `body` with a fresh fixture and deletes what it created afterwards, +/// whether or not `body` panicked. +async fn e2e(body: F) +where + F: FnOnce(Arc) -> Fut, + Fut: Future, +{ + let region = var("TRIPLEC_AWS_E2E_REGION"); + let conn = HostConnection { + id: "e2e".into(), + name: "e2e".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { + name: var("TRIPLEC_AWS_E2E_PROFILE"), + }, + default_region: region.clone(), + }, + }; + let cfg = sdk_config(&conn, ®ion).await.expect("sdk config"); + let me = caller_identity(&cfg) + .await + .expect("the e2e profile's identity"); + let e = Arc::new(E2e { + iam: aws_sdk_iam::Client::new(&cfg), + sts: aws_sdk_sts::Client::new(&cfg), + secrets: aws_sdk_secretsmanager::Client::new(&cfg), + kms: aws_sdk_kms::Client::new(&cfg), + cfg, + region, + account: me.account, + prefix: format!( + "triple-c-e2e-{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ), + created: Mutex::default(), + }); + let result = AssertUnwindSafe(body(e.clone())).catch_unwind().await; + e.cleanup().await; + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} + +fn iam_tag(k: &str, v: &str) -> aws_sdk_iam::types::Tag { + aws_sdk_iam::types::Tag::builder() + .key(k) + .value(v) + .build() + .unwrap() +} + +fn sm_tag(k: &str, v: &str) -> aws_sdk_secretsmanager::types::Tag { + aws_sdk_secretsmanager::types::Tag::builder() + .key(k) + .value(v) + .build() +} + +/// `Err(AccessDenied)` for a denied call; panics on anything else. +fn denied(what: &str, r: Result) { + match r { + Err(e) => match classify_sdk_error(what, &e) { + AwsError::AccessDenied { .. } => {} + other => panic!("{what}: expected AccessDenied, got {other}"), + }, + Ok(v) => panic!("{what}: expected AccessDenied, but it succeeded: {v:?}"), + } +} + +fn ok(what: &str, r: Result) -> T { + r.unwrap_or_else(|e| panic!("{what}: {}", classify_sdk_error(what, &e))) +} + +impl E2e { + fn name(&self, suffix: &str) -> String { + format!("{}-{suffix}", self.prefix) + } + + fn secret_name(&self, suffix: &str) -> String { + format!("triple-c/{}/{suffix}", self.prefix) + } + + /// A role assumable by this account, under `/triple-c-e2e/` (so its ARN + /// has a path, as an SSO role's does), optionally bounded. + async fn role(&self, suffix: &str, boundary: Option<&str>, tags: &[(&str, &str)]) -> Role { + let name = self.name(suffix); + let trust = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#, + self.account + ); + let mut req = self + .iam + .create_role() + .role_name(&name) + .path(PATH) + .assume_role_policy_document(trust) + .tags(iam_tag(TEST_TAG.0, TEST_TAG.1)) + .set_permissions_boundary(boundary.map(str::to_string)); + for (k, v) in tags { + req = req.tags(iam_tag(k, v)); + } + let out = ok("iam:CreateRole", req.send().await); + self.created.lock().unwrap().roles.push(name.clone()); + let r = out.role().unwrap(); + Role { + name, + arn: r.arn().to_string(), + id: r.role_id().to_string(), + } + } + + async fn tag_role(&self, role: &Role, k: &str, v: &str) { + ok( + "iam:TagRole", + self.iam + .tag_role() + .role_name(&role.name) + .tags(iam_tag(k, v)) + .send() + .await, + ); + } + + /// A customer managed policy (the tier policies are too big to hold + /// both inline on one role). + async fn managed_policy(&self, suffix: &str, document: &str) -> String { + let out = ok( + "iam:CreatePolicy", + self.iam + .create_policy() + .policy_name(self.name(suffix)) + .policy_document(document) + .tags(iam_tag(TEST_TAG.0, TEST_TAG.1)) + .send() + .await, + ); + let arn = out.policy().unwrap().arn().unwrap().to_string(); + self.created.lock().unwrap().policies.push(arn.clone()); + arn + } + + async fn attach(&self, role: &Role, policy_arn: &str) { + ok( + "iam:AttachRolePolicy", + self.iam + .attach_role_policy() + .role_name(&role.name) + .policy_arn(policy_arn) + .send() + .await, + ); + } + + async fn put_inline(&self, role: &Role, document: &str) { + ok( + "iam:PutRolePolicy", + self.iam + .put_role_policy() + .role_name(&role.name) + .policy_name("under-test") + .policy_document(document) + .send() + .await, + ); + } + + /// A role holding the given tier policies, tagged as owned by its own + /// sessions (the strict-tenancy task role shape `iam:PassRole` checks). + async fn tier_role(&self, suffix: &str, tiers: &[Tier]) -> Role { + let role = self.role(suffix, None, &[]).await; + self.tag_role(&role, "triple-c:owner", &role.session_user_id()) + .await; + for &tier in tiers { + let name = match tier { + Tier::WorkspaceUser => "workspace", + Tier::EnvironmentAdmin => "admin", + }; + let arn = self + .managed_policy(&format!("{suffix}-{name}"), policy_json(tier)) + .await; + self.attach(&role, &arn).await; + } + role + } + + /// An SDK config for a session of `role`, once IAM lets it be assumed + /// and its policies have propagated. + async fn assume(&self, role: &Role) -> SdkConfig { + let mut last = None; + for _ in 0..20 { + match self + .sts + .assume_role() + .role_arn(&role.arn) + .role_session_name(SESSION) + .send() + .await + { + Ok(out) => { + let c = out.credentials().unwrap(); + let keys = AwsKeys { + access_key_id: c.access_key_id().to_string(), + secret_access_key: c.secret_access_key().to_string(), + session_token: Some(c.session_token().to_string()), + }; + // New sessions see a just-attached policy after a few + // seconds; denials in that window would be noise. + tokio::time::sleep(Duration::from_secs(10)).await; + return sdk_config_for_keys(&keys, &self.region); + } + Err(e) => { + last = Some(classify_sdk_error("sts:AssumeRole", &e).to_string()); + tokio::time::sleep(Duration::from_secs(3)).await; + } + } + } + panic!("could not assume {}: {last:?}", role.arn); + } + + /// `check_permissions` as the e2e profile, simulating `identity`'s + /// principal. Retried once if a check failed (IAM is eventually + /// consistent for a role created a moment ago). + async fn check_as(&self, identity: &CallerIdentity) -> PermissionReport { + let api = SdkPermissionApi::new(&self.cfg); + for attempt in 0..3 { + let report = check_permissions(&api, identity, &self.region) + .await + .expect("check_permissions"); + let failed = report + .tiers + .iter() + .flat_map(|t| &t.results) + .any(|r| r.note.is_some()); + if !failed || attempt == 2 { + return report; + } + tokio::time::sleep(Duration::from_secs(10)).await; + } + unreachable!() + } + + async fn environment_key(&self, kms: &aws_sdk_kms::Client, suffix: &str) -> String { + let tag = |k: &str, v: &str| { + aws_sdk_kms::types::Tag::builder() + .tag_key(k) + .tag_value(v) + .build() + .unwrap() + }; + let r = kms + .create_key() + .description(self.name(suffix)) + .tags(tag("triple-c:component", "environment")) + .tags(tag(TEST_TAG.0, TEST_TAG.1)) + .send() + .await; + let arn = ok("kms:CreateKey", r) + .key_metadata() + .unwrap() + .arn() + .unwrap() + .to_string(); + self.created.lock().unwrap().keys.push(arn.clone()); + arn + } + + fn record_secret(&self, arn: &str) { + self.created.lock().unwrap().secrets.push(arn.to_string()); + } + + /// An admin-created secret encrypted with `key`. + async fn admin_secret(&self, suffix: &str, key: &str, tag: Option<(&str, &str)>) -> String { + let mut req = self + .secrets + .create_secret() + .name(self.secret_name(suffix)) + .kms_key_id(key) + .secret_string("e2e") + .tags(sm_tag(TEST_TAG.0, TEST_TAG.1)); + if let Some((k, v)) = tag { + req = req.tags(sm_tag(k, v)); + } + let arn = ok("secretsmanager:CreateSecret", req.send().await) + .arn() + .unwrap() + .to_string(); + self.record_secret(&arn); + arn + } + + /// Deletes everything recorded, logging (not failing on) errors so one + /// failure doesn't strand the rest. + async fn cleanup(&self) { + let created = std::mem::take(&mut *self.created.lock().unwrap()); + let warn = |what: &str, e: &dyn std::fmt::Display| eprintln!("cleanup: {what}: {e}"); + for arn in &created.secrets { + if let Err(e) = self + .secrets + .delete_secret() + .secret_id(arn) + .force_delete_without_recovery(true) + .send() + .await + { + let e = classify_sdk_error("secretsmanager:DeleteSecret", &e); + if !e.to_string().contains("ResourceNotFound") { + warn(arn, &e); + } + } + } + for arn in &created.keys { + if let Err(e) = self + .kms + .schedule_key_deletion() + .key_id(arn) + .pending_window_in_days(7) + .send() + .await + { + let e = classify_sdk_error("kms:ScheduleKeyDeletion", &e); + if !e.to_string().contains("pending") { + warn(arn, &e); + } + } + } + for name in &created.roles { + if let Ok(out) = self.iam.list_role_policies().role_name(name).send().await { + for p in out.policy_names() { + let _ = self + .iam + .delete_role_policy() + .role_name(name) + .policy_name(p) + .send() + .await; + } + } + if let Ok(out) = self + .iam + .list_attached_role_policies() + .role_name(name) + .send() + .await + { + for p in out.attached_policies() { + let _ = self + .iam + .detach_role_policy() + .role_name(name) + .policy_arn(p.policy_arn().unwrap_or_default()) + .send() + .await; + } + } + if let Err(e) = self.iam.delete_role().role_name(name).send().await { + warn(name, &classify_sdk_error("iam:DeleteRole", &e)); + } + } + for arn in &created.policies { + if let Err(e) = self.iam.delete_policy().policy_arn(arn).send().await { + warn(arn, &classify_sdk_error("iam:DeletePolicy", &e)); + } + } + } +} + +fn verdicts(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> { + report + .tiers + .iter() + .find(|t| t.tier == tier) + .unwrap() + .results + .iter() + .map(|r| (r.action.clone(), r.verdict.clone())) + .collect() +} + +fn not_allowed(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> { + verdicts(report, tier) + .into_iter() + .filter(|(_, v)| *v != Verdict::Allowed) + .collect() +} + +#[tokio::test] +#[ignore] +async fn a_role_with_the_workspace_policy_checks_as_workspace_user() { + e2e(|e| async move { + let role = e.tier_role("ws", &[Tier::WorkspaceUser]).await; + let report = e.check_as(&role.identity(&e.account)).await; + assert_eq!(report.method, Method::Simulated); + assert_eq!( + not_allowed(&report, Tier::WorkspaceUser), + [], + "every workspace check must be allowed" + ); + assert_eq!(report.summary, Summary::WorkspaceUser); + assert!( + verdicts(&report, Tier::EnvironmentAdmin) + .iter() + .any(|(_, v)| *v == Verdict::Denied), + "the workspace policy alone grants admin actions" + ); + }) + .await; +} + +#[tokio::test] +#[ignore] +async fn a_role_with_both_policies_checks_as_environment_admin() { + e2e(|e| async move { + let role = e + .tier_role("admin", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin]) + .await; + let report = e.check_as(&role.identity(&e.account)).await; + assert_eq!(report.method, Method::Simulated); + assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []); + assert_eq!(not_allowed(&report, Tier::EnvironmentAdmin), []); + assert_eq!(report.summary, Summary::EnvironmentAdmin); + }) + .await; +} + +#[tokio::test] +#[ignore] +async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() { + e2e(|e| async move { + let role = e.role("none", None, &[]).await; + let report = e.check_as(&role.identity(&e.account)).await; + assert_eq!(report.method, Method::Simulated); + assert_eq!(report.summary, Summary::Insufficient); + for tier in [Tier::WorkspaceUser, Tier::EnvironmentAdmin] { + let rows = verdicts(&report, tier); + for c in checks(tier) { + assert!( + rows.iter().any(|(a, _)| a == c.action), + "{tier:?}: no row for {}", + c.action + ); + } + for (action, v) in rows { + assert_eq!(v, Verdict::Denied, "{tier:?} {action}"); + } + } + }) + .await; +} + +/// The workspace policy without `InspectOwnPrincipal`, assumed and checked +/// *as* itself: `iam:GetRole` is denied, so the check probes. +#[tokio::test] +#[ignore] +async fn a_role_without_simulate_permission_falls_back_to_probes() { + e2e(|e| async move { + let mut doc: serde_json::Value = + serde_json::from_str(policy_json(Tier::WorkspaceUser)).unwrap(); + doc["Statement"] + .as_array_mut() + .unwrap() + .retain(|s| s["Sid"] != "InspectOwnPrincipal"); + let role = e.role("nosim", None, &[]).await; + let arn = e.managed_policy("nosim", &doc.to_string()).await; + e.attach(&role, &arn).await; + let cfg = e.assume(&role).await; + let me = caller_identity(&cfg).await.expect("identity"); + assert_eq!(me.user_id, role.session_user_id()); + let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region) + .await + .expect("check_permissions"); + let Method::Probed { reason } = &report.method else { + panic!("expected probes, got {:?}", report.method); + }; + assert!(reason.contains("iam:GetRole"), "{reason}"); + let rows = verdicts(&report, Tier::WorkspaceUser); + let verdict = |a: &str| rows.iter().find(|(x, _)| x == a).unwrap().1.clone(); + for probed in [ + "ecs:ListClusters", + "ssm:GetParametersByPath", + "cloudformation:DescribeStacks", + "secretsmanager:ListSecrets", + ] { + assert_eq!(verdict(probed), Verdict::Allowed, "{probed}"); + } + assert_eq!(verdict("iam:GetRole"), Verdict::Denied); + assert_eq!(verdict("ecs:RunTask"), Verdict::Unverified); + // A probed run never claims the admin tier, and the denied + // own-principal check keeps it from claiming the workspace tier. + assert_eq!(report.summary, Summary::Insufficient); + }) + .await; +} + +/// The whole workspace policy, assumed: the real `iam:GetRole` on its own +/// role (whose ARN has a path) and `SimulatePrincipalPolicy` of itself. +#[tokio::test] +#[ignore] +async fn an_assumed_workspace_role_checks_itself_by_simulation() { + e2e(|e| async move { + let role = e.tier_role("self", &[Tier::WorkspaceUser]).await; + let cfg = e.assume(&role).await; + let me = caller_identity(&cfg).await.expect("identity"); + assert!(me.arn.contains(":assumed-role/"), "{}", me.arn); + let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region) + .await + .expect("check_permissions"); + assert_eq!(report.method, Method::Simulated); + assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []); + assert_eq!(report.summary, Summary::WorkspaceUser); + }) + .await; +} + +/// The e2e profile itself (an account administrator): every check allowed. +/// For an SSO profile this resolves the `/aws-reserved/sso.amazonaws.com/` +/// role path through `iam:GetRole`. +#[tokio::test] +#[ignore] +async fn the_e2e_profile_checks_as_environment_admin() { + e2e(|e| async move { + let me = caller_identity(&e.cfg).await.expect("identity"); + let report = e.check_as(&me).await; + assert_eq!(report.method, Method::Simulated); + let failed: Vec<_> = report + .tiers + .iter() + .flat_map(|t| &t.results) + .filter(|r| r.verdict != Verdict::Allowed) + .collect(); + assert!(failed.is_empty(), "{failed:#?}"); + assert_eq!(report.summary, Summary::EnvironmentAdmin); + }) + .await; +} + +/// The spec's most important rule: no shell, stop or session in someone +/// else's task. +#[tokio::test] +#[ignore] +async fn the_owner_condition_denies_another_owners_task() { + e2e(|e| async move { + let role = e.tier_role("owner", &[Tier::WorkspaceUser]).await; + let me = role.session_user_id(); + let api = SdkPermissionApi::new(&e.cfg); + let p = Placeholders { + region: &e.region, + account: &e.account, + principal: &role.arn, + }; + let task_checks = checks(Tier::WorkspaceUser).iter().filter(|c| { + ["ecs:StopTask", "ecs:ExecuteCommand", "ssm:StartSession"].contains(&c.action) + && c.resource.contains(":task/") + }); + let mut n = 0; + for c in task_checks { + n += 1; + let mine = simulation_request(c, &p, &me); + assert!( + api.simulate(&mine).await.unwrap(), + "{} on my own task", + c.action + ); + let mut theirs = mine.clone(); + for entry in &mut theirs.context { + if entry.key == "aws:ResourceTag/triple-c:owner" { + entry.values = vec!["AROAEXAMPLEOTHER:bob@example.com".into()]; + } + } + assert!( + !api.simulate(&theirs).await.unwrap(), + "{} allowed on another owner's task", + c.action + ); + theirs + .context + .retain(|x| x.key != "aws:ResourceTag/triple-c:owner"); + assert!( + !api.simulate(&theirs).await.unwrap(), + "{} allowed on an untagged task", + c.action + ); + } + assert_eq!(n, 3); + }) + .await; +} + +/// SimulatePrincipalPolicy *does* apply the principal's own permissions +/// boundary (it does not apply SCPs or session policies). +#[tokio::test] +#[ignore] +async fn the_simulator_applies_the_principals_permissions_boundary() { + e2e(|e| async move { + let boundary = e.managed_policy("boundary", permissions_boundary_json()).await; + let role = e.role("bounded", Some(&boundary), &[]).await; + e.put_inline( + &role, + r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"*"}]}"#, + ) + .await; + let sim = |name: &str| { + e.iam + .simulate_principal_policy() + .policy_source_arn(&role.arn) + .action_names("secretsmanager:GetSecretValue") + .resource_arns(format!( + "arn:aws:secretsmanager:{}:{}:secret:{name}-AbCdEf", + e.region, e.account + )) + .send() + }; + let decision = |out: aws_sdk_iam::operation::simulate_principal_policy::SimulatePrincipalPolicyOutput| { + out.evaluation_results()[0].eval_decision().as_str().to_string() + }; + assert_eq!(decision(ok("simulate", sim("triple-c/x").await)), "allowed"); + assert_eq!(decision(ok("simulate", sim("other/x").await)), "implicitDeny"); + }) + .await; +} + +/// The secret statements and the KMS rules behind them, with real calls: +/// tag-on-create, the environment key by ARN (and `kms:Decrypt` with it), +/// the explicit Deny on the AWS-managed key, owner and component fences, +/// and the permissions boundary reading `aws:ResourceTag` of the key. +#[tokio::test] +#[ignore] +async fn secrets_use_the_environment_key_and_only_their_owner_can_change_them() { + e2e(|e| async move { + let key = e.environment_key(&e.kms, "env-key").await; + let aws_managed = ok( + "kms:DescribeKey", + e.kms.describe_key().key_id("alias/aws/secretsmanager").send().await, + ) + .key_metadata() + .unwrap() + .arn() + .unwrap() + .to_string(); + let user = e.tier_role("secrets-user", &[Tier::WorkspaceUser]).await; + let boundary = e.managed_policy("secrets-boundary", permissions_boundary_json()).await; + let task = e.role("secrets-task", Some(&boundary), &[]).await; + e.put_inline( + &task, + r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:GetSecretValue","kms:Decrypt"],"Resource":"*"}]}"#, + ) + .await; + let environment = e + .admin_secret("environment", &key, Some(("triple-c:component", "environment"))) + .await; + let someone_elses = e + .admin_secret("someone-else", &key, Some(("triple-c:owner", "AROAEXAMPLEOTHER:bob"))) + .await; + + let as_user = aws_sdk_secretsmanager::Client::new(&e.assume(&user).await); + let me = user.session_user_id(); + let create = |suffix: &str, key: Option<&str>| { + as_user + .create_secret() + .name(e.secret_name(suffix)) + .set_kms_key_id(key.map(str::to_string)) + .secret_string("v1") + .tags(sm_tag("triple-c:owner", &me)) + .send() + }; + + // Own secret on the environment key, named by ARN. + let own = ok("create own secret", create("own", Some(&key)).await) + .arn() + .unwrap() + .to_string(); + e.record_secret(&own); + ok( + "tag for cleanup", + e.secrets + .tag_resource() + .secret_id(&own) + .tags(sm_tag(TEST_TAG.0, TEST_TAG.1)) + .send() + .await, + ); + ok( + "put own", + as_user.put_secret_value().secret_id(&own).secret_string("v2").send().await, + ); + + // Any other key is refused. + for (i, (what, k)) in [("no KmsKeyId", None), ("aws/secretsmanager", Some(aws_managed.as_str()))] + .into_iter() + .enumerate() + { + let r = create(&format!("refused-{i}"), k).await; + if let Ok(out) = &r { + e.record_secret(out.arn().unwrap()); + } + denied(what, r); + } + // Only the ordinary tag keys, and never claiming or changing others'. + denied( + "tag own with triple-c:component", + as_user + .tag_resource() + .secret_id(&own) + .tags(sm_tag("triple-c:owner", &me)) + .tags(sm_tag("triple-c:component", "x")) + .send() + .await, + ); + for (what, arn) in [("environment", &environment), ("someone else's", &someone_elses)] { + denied( + &format!("put {what}"), + as_user.put_secret_value().secret_id(arn).secret_string("x").send().await, + ); + denied( + &format!("delete {what}"), + as_user.delete_secret().secret_id(arn).send().await, + ); + denied( + &format!("claim {what}"), + as_user + .tag_resource() + .secret_id(arn) + .tags(sm_tag("triple-c:owner", &me)) + .send() + .await, + ); + } + + // A task role under the boundary decrypts through Secrets Manager + // only with the component-tagged key. + let as_task = aws_sdk_secretsmanager::Client::new(&e.assume(&task).await); + let value = ok( + "task reads own", + as_task.get_secret_value().secret_id(&own).send().await, + ); + assert_eq!(value.secret_string(), Some("v2")); + + ok( + "delete own", + as_user + .delete_secret() + .secret_id(&own) + .force_delete_without_recovery(true) + .send() + .await, + ); + }) + .await; +} + +/// The admin tier's fences, for real: roles only with the Triple-C +/// boundary, the boundary never removed or swapped or edited, and keys +/// created only with `triple-c:component` (tag-on-create passes the +/// `kms:TagResource` rule) and destroyed only if they carry it. +/// +/// Needs the exact `triple-c-permissions-boundary` ARN: the test creates +/// that policy and deletes it again, unless the account already has one, in +/// which case it uses it and leaves it alone. +#[tokio::test] +#[ignore] +async fn the_admin_tier_creates_only_bounded_roles_and_tagged_keys() { + e2e(|e| async move { + let boundary = format!("arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", e.account); + if e.iam.get_policy().policy_arn(&boundary).send().await.is_err() { + ok( + "create the boundary", + e.iam + .create_policy() + .policy_name(PERMISSIONS_BOUNDARY_NAME) + .policy_document(permissions_boundary_json()) + .tags(iam_tag(TEST_TAG.0, TEST_TAG.1)) + .send() + .await, + ); + e.created.lock().unwrap().policies.push(boundary.clone()); + } + let other_boundary = e.managed_policy("other-boundary", permissions_boundary_json()).await; + let admin = e + .tier_role("fence", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin]) + .await; + let cfg = e.assume(&admin).await; + let iam = aws_sdk_iam::Client::new(&cfg); + let kms = aws_sdk_kms::Client::new(&cfg); + let trust = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#, + e.account + ); + let create = |suffix: &str, b: Option<&str>| { + iam.create_role() + .role_name(e.name(suffix)) + .assume_role_policy_document(&trust) + .tags(iam_tag(TEST_TAG.0, TEST_TAG.1)) + .set_permissions_boundary(b.map(str::to_string)) + .send() + }; + for (suffix, b) in [("unbounded", None), ("other-bounded", Some(other_boundary.as_str()))] { + let r = create(suffix, b).await; + if r.is_ok() { + e.created.lock().unwrap().roles.push(e.name(suffix)); + } + denied(&format!("create {suffix} role"), r); + } + let child = e.name("bounded"); + ok("create bounded role", create("bounded", Some(&boundary)).await); + e.created.lock().unwrap().roles.push(child.clone()); + ok( + "put a policy on the bounded role", + iam.put_role_policy() + .role_name(&child) + .policy_name("p") + .policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:GetCallerIdentity","Resource":"*"}]}"#) + .send() + .await, + ); + denied( + "remove the boundary", + iam.delete_role_permissions_boundary().role_name(&child).send().await, + ); + denied( + "swap the boundary", + iam.put_role_permissions_boundary() + .role_name(&child) + .permissions_boundary(&other_boundary) + .send() + .await, + ); + denied( + "edit the boundary", + iam.create_policy_version() + .policy_arn(&boundary) + .policy_document(permissions_boundary_json()) + .send() + .await, + ); + + // Keys: tagged create passes (tag-on-create authorizes + // kms:TagResource against the new key's requested tags). + let untagged = kms.create_key().description(e.name("untagged")).send().await; + if let Ok(out) = &untagged { + e.created + .lock() + .unwrap() + .keys + .push(out.key_metadata().unwrap().arn().unwrap().to_string()); + } + denied("create an untagged key", untagged); + let key = e.environment_key(&kms, "admin-key").await; + ok( + "schedule the environment key's deletion", + kms.schedule_key_deletion() + .key_id(&key) + .pending_window_in_days(7) + .send() + .await, + ); + }) + .await; +} diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs new file mode 100644 index 0000000..2cc45ab --- /dev/null +++ b/app/src-tauri/src/aws/mod.rs @@ -0,0 +1,16 @@ +//! AWS connection plumbing: an SDK config built from a saved +//! `HostConnection`, the caller's identity, the tier policies and the +//! permission check. +//! + +mod connection; +mod error; +mod identity; +#[cfg(test)] +mod live_tests; +pub mod permissions; +pub mod policies; + +pub use connection::{sdk_config, sdk_config_for_keys}; +pub use error::AwsError; +pub use identity::{caller_identity, CallerIdentity}; diff --git a/app/src-tauri/src/aws/permissions.rs b/app/src-tauri/src/aws/permissions.rs new file mode 100644 index 0000000..52e9f81 --- /dev/null +++ b/app/src-tauri/src/aws/permissions.rs @@ -0,0 +1,557 @@ +//! The permission check: which Triple-C tier the connection's credentials +//! can act as, by simulating each tier's checks (`policies::checks`) with +//! IAM's policy simulator, or — when the caller may not simulate itself — +//! by a few read-only probes. + +use std::future::Future; + +use futures_util::stream::{self, StreamExt, TryStreamExt}; +use serde::Serialize; + +use super::error::{classify_sdk_error, AwsError}; +use super::identity::{self, principal_for_simulation, CallerIdentity, PrincipalArn}; +use super::policies::{checks, render, Check, ContextValue, Placeholders, Tier}; + +/// How many simulations are in flight at once. +const CONCURRENCY: usize = 2; + +/// One `SimulatePrincipalPolicy` request, fully rendered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SimulationRequest { + pub principal: String, + pub action: String, + pub resource: String, + pub context: Vec, +} + +/// A context key with every value it carries in the request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextEntry { + pub key: String, + pub values: Vec, +} + +impl ContextEntry { + /// Sent as `StringList` when the key has more than one value, `String` + /// otherwise. + pub fn is_list(&self) -> bool { + self.values.len() > 1 + } +} + +/// Builds the simulation for `check`: the resource and every literal context +/// value rendered with `p`, `OwnerId` as `user_id`, and `aws:userid` = +/// `user_id` added. A key appearing more than once becomes one entry holding +/// all its values (in order, without duplicates). +pub fn simulation_request(check: &Check, p: &Placeholders, user_id: &str) -> SimulationRequest { + let mut context: Vec = Vec::new(); + let mut add = |key: &str, value: String| match context.iter_mut().find(|e| e.key == key) { + Some(e) if e.values.contains(&value) => {} + Some(e) => e.values.push(value), + None => context.push(ContextEntry { + key: key.to_string(), + values: vec![value], + }), + }; + for (key, value) in check.context { + let value = match value { + ContextValue::OwnerId => user_id.to_string(), + ContextValue::Literal(template) => render(template, p), + }; + add(key, value); + } + add("aws:userid", user_id.to_string()); + SimulationRequest { + principal: p.principal.to_string(), + action: check.action.to_string(), + resource: render(check.resource, p), + context, + } +} + +/// The read-only calls used when simulation isn't possible. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Probe { + EcsListClusters, + SsmGetParametersByPath, + CfnDescribeStacks, + SecretsListSecrets, +} + +impl Probe { + const ALL: [Probe; 4] = [ + Probe::EcsListClusters, + Probe::SsmGetParametersByPath, + Probe::CfnDescribeStacks, + Probe::SecretsListSecrets, + ]; + + /// The probe standing in for a read check's service, if there is one. + fn for_action(action: &str) -> Option { + match action.split_once(':')?.0 { + "ecs" => Some(Probe::EcsListClusters), + "ssm" => Some(Probe::SsmGetParametersByPath), + "cloudformation" => Some(Probe::CfnDescribeStacks), + "secretsmanager" => Some(Probe::SecretsListSecrets), + _ => None, + } + } + + fn action(self) -> &'static str { + match self { + Probe::EcsListClusters => "ecs:ListClusters", + Probe::SsmGetParametersByPath => "ssm:GetParametersByPath", + Probe::CfnDescribeStacks => "cloudformation:DescribeStacks", + Probe::SecretsListSecrets => "secretsmanager:ListSecrets", + } + } +} + +/// What the permission check needs from AWS. The methods return `Send` +/// futures so `check_permissions` is `Send` (a Tauri command needs that) for +/// any implementation; an impl may still write them as `async fn`. +pub trait PermissionApi: Send + Sync { + /// The ARN to simulate for the caller's principal (`iam:GetRole` for a + /// role session). + fn resolve_principal( + &self, + principal: PrincipalArn, + ) -> impl Future> + Send; + /// Whether the simulator says `allowed`. + fn simulate( + &self, + request: &SimulationRequest, + ) -> impl Future> + Send; + fn probe(&self, probe: Probe) -> impl Future> + Send; +} + +/// The real thing, from a connection's `SdkConfig`. +pub struct SdkPermissionApi { + iam: aws_sdk_iam::Client, + ecs: aws_sdk_ecs::Client, + ssm: aws_sdk_ssm::Client, + cfn: aws_sdk_cloudformation::Client, + secrets: aws_sdk_secretsmanager::Client, +} + +impl SdkPermissionApi { + pub fn new(cfg: &aws_config::SdkConfig) -> Self { + // IAM throttles SimulatePrincipalPolicy hard: a full check (about a + // hundred simulations) failed a third of its rows with "Rate + // exceeded" under standard retries (verified live). Adaptive retry + // backs off client-side once throttled, with room to retry. + let iam = aws_sdk_iam::config::Builder::from(cfg) + .retry_config(aws_config::retry::RetryConfig::adaptive().with_max_attempts(10)) + .build(); + SdkPermissionApi { + iam: aws_sdk_iam::Client::from_conf(iam), + ecs: aws_sdk_ecs::Client::new(cfg), + ssm: aws_sdk_ssm::Client::new(cfg), + cfn: aws_sdk_cloudformation::Client::new(cfg), + secrets: aws_sdk_secretsmanager::Client::new(cfg), + } + } +} + +impl PermissionApi for SdkPermissionApi { + async fn resolve_principal(&self, principal: PrincipalArn) -> Result { + identity::resolve_principal(&self.iam, principal).await + } + + async fn simulate(&self, request: &SimulationRequest) -> Result { + use aws_sdk_iam::types::{ContextEntry as SdkEntry, ContextKeyTypeEnum}; + const ACTION: &str = "iam:SimulatePrincipalPolicy"; + let entries = request.context.iter().map(|e| { + SdkEntry::builder() + .context_key_name(&e.key) + .set_context_key_values(Some(e.values.clone())) + .context_key_type(if e.is_list() { + ContextKeyTypeEnum::StringList + } else { + ContextKeyTypeEnum::String + }) + .build() + }); + let out = self + .iam + .simulate_principal_policy() + .policy_source_arn(&request.principal) + .action_names(&request.action) + .resource_arns(&request.resource) + .set_context_entries(Some(entries.collect())) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + let decision = out + .evaluation_results() + .first() + .map(|r| r.eval_decision().as_str()) + .ok_or_else(|| AwsError::Other { + message: format!("{ACTION} returned no result for {}", request.action), + })?; + Ok(decision == "allowed") + } + + async fn probe(&self, probe: Probe) -> Result<(), AwsError> { + let action = probe.action(); + let classify = |e: &(dyn std::error::Error + 'static)| classify_sdk_error(action, e); + match probe { + Probe::EcsListClusters => self + .ecs + .list_clusters() + .max_results(1) + .send() + .await + .map(drop) + .map_err(|e| classify(&e)), + Probe::SsmGetParametersByPath => self + .ssm + .get_parameters_by_path() + .path("/triple-c/environments") + .max_results(1) + .send() + .await + .map(drop) + .map_err(|e| classify(&e)), + Probe::CfnDescribeStacks => self + .cfn + .describe_stacks() + .send() + .await + .map(drop) + .map_err(|e| classify(&e)), + Probe::SecretsListSecrets => self + .secrets + .list_secrets() + .max_results(1) + .send() + .await + .map(drop) + .map_err(|e| classify(&e)), + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + Allowed, + Denied, + Unverified, +} + +/// One row per action. An action checked against several resources (or +/// with several contexts) is `Denied` if any of them is, else `Unverified` +/// if any is, else `Allowed`. +#[derive(Debug, Clone, Serialize)] +pub struct ActionResult { + pub action: String, + pub verdict: Verdict, + /// Set when the row is `Unverified` because a call failed (throttling, + /// network, ...): the error, as `AwsError` displays it. Such a row + /// blocks its tier's claim; an `Unverified` row without a note (a write + /// in probe mode, an unprobed read) does not. + pub note: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TierResult { + pub tier: Tier, + pub results: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Method { + Simulated, + /// Simulation wasn't possible; `reason` says why. + Probed { + reason: String, + }, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Summary { + EnvironmentAdmin, + WorkspaceUser, + Insufficient, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PermissionReport { + pub identity: CallerIdentity, + pub region: String, + pub method: Method, + pub summary: Summary, + /// `[WorkspaceUser, EnvironmentAdmin]`. + pub tiers: Vec, +} + +const TIERS: [Tier; 2] = [Tier::WorkspaceUser, Tier::EnvironmentAdmin]; + +/// One check's result, before checks are folded into per-action rows. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Outcome { + Allowed, + Denied, + /// Not checkable by design (a write in probe mode, an unprobed read). + Unverified, + /// The call failed for a reason that says nothing about the permission. + Failed(String), +} + +/// The worst of `outcomes`: Denied, then Failed (as `Unverified` with the +/// first failure as its note), then Unverified, then Allowed. +fn aggregate(outcomes: &[Outcome]) -> (Verdict, Option) { + if outcomes.contains(&Outcome::Denied) { + return (Verdict::Denied, None); + } + if let Some(note) = outcomes.iter().find_map(|o| match o { + Outcome::Failed(m) => Some(m.clone()), + _ => None, + }) { + return (Verdict::Unverified, Some(note)); + } + if outcomes.contains(&Outcome::Unverified) { + return (Verdict::Unverified, None); + } + (Verdict::Allowed, None) +} + +/// Folds per-check outcomes (in `checks(tier)` order) into one row per +/// action, in first-appearance order. +fn tier_result(tier: Tier, outcomes: Vec) -> TierResult { + let mut rows: Vec<(&'static str, Vec)> = Vec::new(); + for (check, outcome) in checks(tier).iter().zip(outcomes) { + match rows.iter_mut().find(|(a, _)| *a == check.action) { + Some((_, os)) => os.push(outcome), + None => rows.push((check.action, vec![outcome])), + } + } + TierResult { + tier, + results: rows + .into_iter() + .map(|(action, os)| { + let (verdict, note) = aggregate(&os); + ActionResult { + action: action.to_string(), + verdict, + note, + } + }) + .collect(), + } +} + +/// A tier is claimed when none of its rows is `Denied` or failed (has a +/// note). Design-level `Unverified` rows don't block. A probed run never +/// claims the admin tier (R15): it is nearly all writes, which probes can't +/// see, so "nothing denied" would mean nothing. +fn summary(tiers: &[TierResult], probed: bool) -> Summary { + let clear = |t: Tier| { + tiers.iter().filter(|r| r.tier == t).all(|r| { + r.results + .iter() + .all(|a| a.verdict != Verdict::Denied && a.note.is_none()) + }) + }; + if !clear(Tier::WorkspaceUser) { + Summary::Insufficient + } else if !probed && clear(Tier::EnvironmentAdmin) { + Summary::EnvironmentAdmin + } else { + Summary::WorkspaceUser + } +} + +/// Errors that mean the credentials themselves don't work: the UI acts on +/// these (sign in, re-enter keys) rather than showing a report. +fn is_credential_error(e: &AwsError) -> bool { + matches!( + e, + AwsError::SsoExpired { .. } + | AwsError::InvalidCredentials { .. } + | AwsError::ProfileNotFound { .. } + | AwsError::KeysMissing + ) +} + +/// Which tiers the caller's credentials can act as, in `region`. +/// +/// Simulates every check of both tiers against the caller's principal. It +/// falls back to probes when the principal can't be simulated (root, +/// federated user), resolving it is denied (`iam:GetRole`), or the first +/// simulation is denied. In probe mode a read check whose service has a +/// probe takes that probe's result; the check behind the fallback (the +/// caller's own `iam:GetRole` / `iam:SimulatePrincipalPolicy`) is `Denied`; +/// every other check is `Unverified`. +/// +/// A credential error (SSO expired, invalid or missing credentials) from +/// any call is returned as `Err`, as is any other failure resolving the +/// principal. Other failures of individual checks (throttling, network) +/// make that row `Unverified` with a note, which blocks its tier's claim. +/// See [`summary`] for the claim rules. +pub async fn check_permissions( + api: &A, + identity: &CallerIdentity, + region: &str, +) -> Result { + let (method, outcomes) = match simulate_all(api, identity, region).await? { + Simulation::Done(outcomes) => (Method::Simulated, outcomes), + Simulation::Fallback { reason, denied } => ( + Method::Probed { reason }, + probe_all(api, denied.as_deref()).await?, + ), + }; + let failed: Vec<&str> = outcomes + .iter() + .flatten() + .filter_map(|o| match o { + Outcome::Failed(m) => Some(m.as_str()), + _ => None, + }) + .collect(); + if let Some(first) = failed.first() { + let total: usize = outcomes.iter().map(Vec::len).sum(); + log::warn!( + "AWS permission check: {} of {total} checks could not be evaluated (first: {first})", + failed.len() + ); + } + let probed = matches!(method, Method::Probed { .. }); + let tiers: Vec = TIERS + .into_iter() + .zip(outcomes) + .map(|(tier, os)| tier_result(tier, os)) + .collect(); + Ok(PermissionReport { + identity: identity.clone(), + region: region.to_string(), + method, + summary: summary(&tiers, probed), + tiers, + }) +} + +enum Simulation { + Done([Vec; 2]), + /// Simulation isn't possible. `denied` is the action whose denial + /// caused it, if one did. + Fallback { + reason: String, + denied: Option, + }, +} + +impl Simulation { + fn fallback(e: AwsError) -> Self { + let denied = match &e { + AwsError::AccessDenied { action, .. } => Some(action.clone()), + _ => None, + }; + Simulation::Fallback { + reason: e.to_string(), + denied, + } + } +} + +fn simulated_outcome(r: Result) -> Result { + match r { + Ok(true) => Ok(Outcome::Allowed), + Ok(false) => Ok(Outcome::Denied), + Err(e) if is_credential_error(&e) => Err(e), + Err(e) => Ok(Outcome::Failed(e.to_string())), + } +} + +/// Per-tier outcomes by simulation, or why simulation isn't possible. +async fn simulate_all( + api: &A, + identity: &CallerIdentity, + region: &str, +) -> Result { + let principal = match principal_for_simulation(&identity.arn) { + Ok(p) => p, + Err(e) => return Ok(Simulation::fallback(e)), + }; + let principal = match api.resolve_principal(principal).await { + Ok(p) => p, + Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)), + Err(e) => return Err(e), + }; + let p = Placeholders { + region, + account: &identity.account, + principal: &principal, + }; + let mut requests = TIERS + .into_iter() + .flat_map(|t| checks(t).iter()) + .map(|c| simulation_request(c, &p, &identity.user_id)) + .collect::>() + .into_iter(); + let Some(first) = requests.next() else { + return Ok(Simulation::Done([Vec::new(), Vec::new()])); + }; + // The first simulation alone: if the caller may not simulate at all, + // that's the moment to fall back, before spending the rest. + let first = match api.simulate(&first).await { + Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)), + r => simulated_outcome(r)?, + }; + // Owned requests: a closure over borrowed ones makes the future's + // `Send` unprovable (rustc's higher-ranked closure limitation). + let rest: Vec = stream::iter(requests) + .map(|r| async move { simulated_outcome(api.simulate(&r).await) }) + .buffered(CONCURRENCY) + .try_collect() + .await?; + let mut all = std::iter::once(first).chain(rest); + Ok(Simulation::Done( + TIERS.map(|t| all.by_ref().take(checks(t).len()).collect()), + )) +} + +/// Per-tier outcomes from the four probes. `denied` is the action whose +/// denial forced probing; the caller's own-principal checks of it are +/// `Denied`. +async fn probe_all( + api: &A, + denied: Option<&str>, +) -> Result<[Vec; 2], AwsError> { + let results = + futures_util::future::join_all(Probe::ALL.map(|p| async move { (p, api.probe(p).await) })) + .await; + if let Some((_, Err(e))) = results + .iter() + .find(|(_, r)| matches!(r, Err(e) if is_credential_error(e))) + { + return Err(e.clone()); + } + let probe_outcome = |probe: Probe| match results.iter().find(|(p, _)| *p == probe) { + Some((_, Ok(()))) => Outcome::Allowed, + Some((_, Err(AwsError::AccessDenied { .. }))) => Outcome::Denied, + Some((_, Err(e))) => Outcome::Failed(e.to_string()), + None => Outcome::Unverified, + }; + Ok(TIERS.map(|t| { + checks(t) + .iter() + .map(|c| { + if denied == Some(c.action) && c.resource == "{principal}" { + return Outcome::Denied; + } + match Probe::for_action(c.action) { + Some(probe) if !c.write => probe_outcome(probe), + _ => Outcome::Unverified, + } + }) + .collect() + })) +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/aws/permissions/tests.rs b/app/src-tauri/src/aws/permissions/tests.rs new file mode 100644 index 0000000..182bb08 --- /dev/null +++ b/app/src-tauri/src/aws/permissions/tests.rs @@ -0,0 +1,632 @@ +use std::collections::HashSet; +use std::sync::Mutex; + +use super::*; +use crate::aws::policies::{checks, Check, ContextValue, Placeholders, Tier}; + +const USER_ID: &str = "AROAEXAMPLEID:alice@example.com"; +const ROLE_ARN: &str = + "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Ws_abc"; + +fn identity() -> CallerIdentity { + CallerIdentity { + account: "111122223333".into(), + arn: "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Ws_abc/alice@example.com" + .into(), + user_id: USER_ID.into(), + } +} + +const ALL_PROBES: [Probe; 4] = [ + Probe::EcsListClusters, + Probe::SsmGetParametersByPath, + Probe::CfnDescribeStacks, + Probe::SecretsListSecrets, +]; + +/// A simulation call as the fake saw it. +#[derive(Debug, Clone)] +struct Simulated { + principal: String, + action: String, + resource: String, + context: Vec, +} + +struct FakeApi { + allow: HashSet<&'static str>, + simulate_denied: bool, + get_role_denied: bool, + /// Returned by `resolve_principal` (after `get_role_denied`). + resolve_error: Option, + /// Returned by `simulate` for the named action. + simulate_errors: Vec<(&'static str, AwsError)>, + probes_ok: HashSet, + /// Returned by every probe not in `probes_ok` (default: AccessDenied). + probe_error: Option, + simulated: Mutex>, + probed: Mutex>, +} + +impl FakeApi { + fn allowing(tiers: &[Tier]) -> Self { + let allow = tiers + .iter() + .flat_map(|t| checks(*t).iter().map(|c| c.action)) + .collect(); + FakeApi { + allow, + simulate_denied: false, + get_role_denied: false, + resolve_error: None, + simulate_errors: Vec::new(), + probes_ok: ALL_PROBES.into_iter().collect(), + probe_error: None, + simulated: Mutex::new(Vec::new()), + probed: Mutex::new(Vec::new()), + } + } + + fn simulated(&self) -> Vec { + self.simulated.lock().unwrap().clone() + } +} + +impl PermissionApi for FakeApi { + async fn resolve_principal(&self, principal: PrincipalArn) -> Result { + if self.get_role_denied { + return Err(AwsError::AccessDenied { + action: "iam:GetRole".into(), + message: "not authorized to perform: iam:GetRole".into(), + }); + } + if let Some(e) = &self.resolve_error { + return Err(e.clone()); + } + Ok(match principal { + PrincipalArn::Exact(arn) => arn, + PrincipalArn::AssumedRole { .. } => ROLE_ARN.into(), + }) + } + + async fn simulate(&self, request: &SimulationRequest) -> Result { + self.simulated.lock().unwrap().push(Simulated { + principal: request.principal.clone(), + action: request.action.clone(), + resource: request.resource.clone(), + context: request.context.clone(), + }); + if self.simulate_denied { + return Err(AwsError::AccessDenied { + action: "iam:SimulatePrincipalPolicy".into(), + message: "not authorized to perform: iam:SimulatePrincipalPolicy".into(), + }); + } + if let Some((_, e)) = self + .simulate_errors + .iter() + .find(|(a, _)| *a == request.action) + { + return Err(e.clone()); + } + Ok(self.allow.contains(request.action.as_str())) + } + + async fn probe(&self, probe: Probe) -> Result<(), AwsError> { + self.probed.lock().unwrap().push(probe); + if self.probes_ok.contains(&probe) { + Ok(()) + } else if let Some(e) = &self.probe_error { + Err(e.clone()) + } else { + Err(AwsError::AccessDenied { + action: format!("{probe:?}"), + message: "denied".into(), + }) + } + } +} + +fn try_run(api: &FakeApi, id: &CallerIdentity) -> Result { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(check_permissions(api, id, "eu-west-1")) +} + +fn run(api: &FakeApi) -> PermissionReport { + try_run(api, &identity()).unwrap() +} + +fn row<'a>(report: &'a PermissionReport, t: Tier, action: &str) -> &'a ActionResult { + tier(report, t) + .results + .iter() + .find(|r| r.action == action) + .unwrap_or_else(|| panic!("{action} not in {t:?}")) +} + +fn throttled() -> AwsError { + AwsError::Other { + message: "Rate exceeded (Throttling)".into(), + } +} + +fn tier(report: &PermissionReport, t: Tier) -> &TierResult { + report.tiers.iter().find(|r| r.tier == t).unwrap() +} + +fn verdict(report: &PermissionReport, t: Tier, action: &str) -> Verdict { + tier(report, t) + .results + .iter() + .find(|r| r.action == action) + .unwrap_or_else(|| panic!("{action} not in {t:?}")) + .verdict + .clone() +} + +#[test] +fn a_full_workspace_policy_reports_workspace_user() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + let report = run(&api); + assert_eq!(report.method, Method::Simulated); + assert_eq!(report.summary, Summary::WorkspaceUser); + assert_eq!(report.region, "eu-west-1"); + assert_eq!(report.identity, identity()); + let tiers: Vec = report.tiers.iter().map(|t| t.tier).collect(); + assert_eq!(tiers, [Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + assert!(tier(&report, Tier::WorkspaceUser) + .results + .iter() + .all(|r| r.verdict == Verdict::Allowed)); + // Every check was simulated against the resolved role, not the session. + let sims = api.simulated(); + let total = checks(Tier::WorkspaceUser).len() + checks(Tier::EnvironmentAdmin).len(); + assert_eq!(sims.len(), total); + assert!(sims.iter().all(|s| s.principal == ROLE_ARN)); +} + +#[test] +fn admin_and_workspace_policies_report_environment_admin() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + let report = run(&api); + assert_eq!(report.method, Method::Simulated); + assert_eq!(report.summary, Summary::EnvironmentAdmin); +} + +#[test] +fn a_missing_action_reports_insufficient_and_names_it() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + api.allow.remove("iam:PassRole"); + let report = run(&api); + assert_eq!(report.summary, Summary::Insufficient); + assert_eq!( + verdict(&report, Tier::WorkspaceUser, "iam:PassRole"), + Verdict::Denied + ); + assert_eq!( + verdict(&report, Tier::WorkspaceUser, "ecs:RunTask"), + Verdict::Allowed + ); +} + +#[test] +fn an_admin_gap_still_reports_workspace_user() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + api.allow.remove("cloudformation:CreateStack"); + let report = run(&api); + assert_eq!(report.summary, Summary::WorkspaceUser); + assert_eq!( + verdict( + &report, + Tier::EnvironmentAdmin, + "cloudformation:CreateStack" + ), + Verdict::Denied + ); +} + +/// An action checked against several resources is one row; any denial wins. +#[test] +fn an_action_checked_twice_is_one_row_and_denied_if_either_is() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + let report = run(&api); + let rows = tier(&report, Tier::WorkspaceUser) + .results + .iter() + .filter(|r| r.action == "ssm:StartSession") + .count(); + assert_eq!(rows, 1); + let failed = || Outcome::Failed("Throttling".into()); + assert_eq!( + aggregate(&[Outcome::Allowed, Outcome::Denied, failed()]), + (Verdict::Denied, None) + ); + assert_eq!( + aggregate(&[Outcome::Allowed, Outcome::Unverified, failed()]), + (Verdict::Unverified, Some("Throttling".into())) + ); + assert_eq!( + aggregate(&[Outcome::Allowed, Outcome::Unverified]), + (Verdict::Unverified, None) + ); + assert_eq!(aggregate(&[Outcome::Allowed]), (Verdict::Allowed, None)); +} + +fn context_value<'a>(s: &'a Simulated, key: &str) -> Option<&'a ContextEntry> { + s.context.iter().find(|e| e.key == key) +} + +#[test] +fn owner_context_is_sent_with_owner_scoped_checks() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + run(&api); + let sims = api.simulated(); + let run_task = sims.iter().find(|s| s.action == "ecs:RunTask").unwrap(); + let owner = context_value(run_task, "aws:RequestTag/triple-c:owner").unwrap(); + assert_eq!(owner.values, [USER_ID]); + // Literals are rendered like resources. + let cluster = context_value(run_task, "ecs:cluster").unwrap(); + assert_eq!( + cluster.values, + ["arn:aws:ecs:eu-west-1:111122223333:cluster/triple-c-probe"] + ); + assert_eq!( + run_task.resource, + "arn:aws:ecs:eu-west-1:111122223333:task-definition/triple-c-probe:1" + ); + // aws:userid on every simulation. + for s in &sims { + let userid = context_value(s, "aws:userid") + .unwrap_or_else(|| panic!("{} has no aws:userid", s.action)); + assert_eq!(userid.values, [USER_ID], "{}", s.action); + } +} + +#[test] +fn the_principal_placeholder_is_the_resolved_role() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + run(&api); + let sims = api.simulated(); + let get_role = sims + .iter() + .find(|s| s.action == "iam:GetRole" && s.resource == ROLE_ARN) + .expect("iam:GetRole on the caller's own role"); + // The policies name a user's own ARN by `${aws:username}`. + assert_eq!( + context_value(get_role, "aws:username").unwrap().values, + [ROLE_ARN.rsplit('/').next().unwrap()] + ); +} + +#[test] +fn a_repeated_context_key_is_one_string_list_entry() { + static CTX: &[(&str, ContextValue)] = &[ + ("aws:TagKeys", ContextValue::Literal("triple-c:owner")), + ( + "ecs:cluster", + ContextValue::Literal("arn:{region}:{account}"), + ), + ("aws:TagKeys", ContextValue::Literal("triple-c:component")), + ("aws:TagKeys", ContextValue::OwnerId), + ]; + let check = Check { + action: "ecs:TagResource", + resource: "arn:aws:ecs:{region}:{account}:task/x", + context: CTX, + write: true, + }; + let p = Placeholders { + region: "eu-west-1", + account: "111122223333", + principal: ROLE_ARN, + }; + let req = simulation_request(&check, &p, USER_ID); + assert_eq!(req.principal, ROLE_ARN); + assert_eq!(req.action, "ecs:TagResource"); + assert_eq!(req.resource, "arn:aws:ecs:eu-west-1:111122223333:task/x"); + assert_eq!( + req.context, + [ + ContextEntry { + key: "aws:TagKeys".into(), + values: vec![ + "triple-c:owner".into(), + "triple-c:component".into(), + USER_ID.into() + ], + }, + ContextEntry { + key: "ecs:cluster".into(), + values: vec!["arn:eu-west-1:111122223333".into()], + }, + ContextEntry { + key: "aws:userid".into(), + values: vec![USER_ID.into()], + }, + ] + ); + assert!(req.context[0].is_list()); + assert!(!req.context[1].is_list()); +} + +#[test] +fn a_check_naming_aws_userid_itself_is_not_sent_it_twice() { + static CTX: &[(&str, ContextValue)] = &[("aws:userid", ContextValue::OwnerId)]; + let check = Check { + action: "sts:GetCallerIdentity", + resource: "*", + context: CTX, + write: false, + }; + let p = Placeholders { + region: "r", + account: "a", + principal: "p", + }; + let req = simulation_request(&check, &p, USER_ID); + assert_eq!( + req.context, + [ContextEntry { + key: "aws:userid".into(), + values: vec![USER_ID.into()], + }] + ); +} + +#[test] +fn a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + api.simulate_denied = true; + let report = run(&api); + match &report.method { + Method::Probed { reason } => { + assert!(reason.contains("iam:SimulatePrincipalPolicy"), "{reason}") + } + m => panic!("expected Probed, got {m:?}"), + } + // Only the first simulation was tried. + assert_eq!(api.simulated().len(), 1); + let mut probed = api.probed.lock().unwrap().clone(); + probed.sort_by_key(|p| format!("{p:?}")); + let mut all = ALL_PROBES.to_vec(); + all.sort_by_key(|p| format!("{p:?}")); + assert_eq!(probed, all); + + let ws = Tier::WorkspaceUser; + assert_eq!(verdict(&report, ws, "ecs:RunTask"), Verdict::Unverified); + assert_eq!(verdict(&report, ws, "iam:PassRole"), Verdict::Unverified); + // Reads with a probe get its result. + assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Allowed); + assert_eq!( + verdict(&report, ws, "ecs:DescribeClusters"), + Verdict::Allowed + ); + assert_eq!( + verdict(&report, ws, "ssm:GetParametersByPath"), + Verdict::Allowed + ); + assert_eq!( + verdict(&report, ws, "cloudformation:DescribeStacks"), + Verdict::Allowed + ); + assert_eq!( + verdict(&report, ws, "secretsmanager:ListSecrets"), + Verdict::Allowed + ); + // Reads with no probe are unverified. + assert_eq!( + verdict(&report, ws, "logs:FilterLogEvents"), + Verdict::Unverified + ); + assert_eq!(verdict(&report, ws, "iam:GetRole"), Verdict::Unverified); + assert_eq!( + verdict(&report, ws, "sts:GetCallerIdentity"), + Verdict::Unverified + ); + // The fallback itself is evidence: the caller may not simulate itself, + // which both tiers need. + for t in [ws, Tier::EnvironmentAdmin] { + assert_eq!( + verdict(&report, t, "iam:SimulatePrincipalPolicy"), + Verdict::Denied + ); + } + assert_eq!(report.summary, Summary::Insufficient); +} + +#[test] +fn a_denied_get_role_falls_back_to_probes() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + api.get_role_denied = true; + let report = run(&api); + match &report.method { + Method::Probed { reason } => assert!(reason.contains("iam:GetRole"), "{reason}"), + m => panic!("expected Probed, got {m:?}"), + } + assert!(api.simulated().is_empty()); + assert_eq!(api.probed.lock().unwrap().len(), 4); + // Denied on the caller's own role: direct evidence. + assert_eq!( + verdict(&report, Tier::WorkspaceUser, "iam:GetRole"), + Verdict::Denied + ); + assert_eq!(report.summary, Summary::Insufficient); +} + +#[test] +fn an_unsimulatable_principal_falls_back_to_probes() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser]); + let mut id = identity(); + id.arn = "arn:aws:iam::111122223333:root".into(); + let report = try_run(&api, &id).unwrap(); + match &report.method { + Method::Probed { reason } => assert!(reason.contains("simulation"), "{reason}"), + m => panic!("expected Probed, got {m:?}"), + } + assert!(api.simulated().is_empty()); +} + +/// R15: the admin tier is nearly all writes, so a probed run can't vouch +/// for it — every probe passing still caps at WorkspaceUser. +#[test] +fn a_probed_run_never_claims_environment_admin() { + let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + let mut id = identity(); + id.arn = "arn:aws:iam::111122223333:root".into(); + let report = try_run(&api, &id).unwrap(); + assert!(matches!(report.method, Method::Probed { .. })); + assert!(tier(&report, Tier::EnvironmentAdmin) + .results + .iter() + .all(|r| r.verdict != Verdict::Denied)); + assert_eq!(report.summary, Summary::WorkspaceUser); +} + +#[test] +fn throttled_simulations_block_the_tier_and_carry_notes() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + api.simulate_errors = vec![("cloudformation:CreateStack", throttled())]; + let report = run(&api); + assert_eq!(report.method, Method::Simulated); + let r = row( + &report, + Tier::EnvironmentAdmin, + "cloudformation:CreateStack", + ); + assert_eq!(r.verdict, Verdict::Unverified); + assert!(r.note.as_deref().unwrap().contains("Throttling"), "{r:?}"); + // Allowed rows carry no note. + assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None); + assert_eq!(report.summary, Summary::WorkspaceUser); + + // In the workspace tier, it blocks everything. + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + api.simulate_errors = vec![("ecs:RunTask", throttled())]; + let report = run(&api); + assert_eq!(report.summary, Summary::Insufficient); + assert!(row(&report, Tier::WorkspaceUser, "ecs:RunTask") + .note + .is_some()); +} + +#[test] +fn a_throttled_first_simulation_is_a_note_not_a_fallback() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + let first = checks(Tier::WorkspaceUser)[0].action; + api.simulate_errors = vec![(first, throttled())]; + let report = run(&api); + assert_eq!(report.method, Method::Simulated); + assert!(row(&report, Tier::WorkspaceUser, first).note.is_some()); + assert_eq!(report.summary, Summary::Insufficient); +} + +#[test] +fn an_expired_sso_session_mid_check_is_an_error() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); + let sso = AwsError::SsoExpired { + profile: "dev".into(), + }; + api.simulate_errors = vec![("logs:FilterLogEvents", sso.clone())]; + assert_eq!(try_run(&api, &identity()).unwrap_err(), sso); +} + +#[test] +fn credential_errors_anywhere_are_errors() { + let bad = AwsError::InvalidCredentials { + message: "ExpiredToken".into(), + }; + // From resolving the principal. + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + api.resolve_error = Some(bad.clone()); + assert_eq!(try_run(&api, &identity()).unwrap_err(), bad); + // From the first simulation. + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + api.simulate_errors = vec![(checks(Tier::WorkspaceUser)[0].action, bad.clone())]; + assert_eq!(try_run(&api, &identity()).unwrap_err(), bad); + // From a probe. + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + api.get_role_denied = true; + api.probes_ok.remove(&Probe::CfnDescribeStacks); + api.probe_error = Some(AwsError::KeysMissing); + assert_eq!( + try_run(&api, &identity()).unwrap_err(), + AwsError::KeysMissing + ); +} + +#[test] +fn a_transport_error_resolving_the_principal_is_an_error() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + let net = AwsError::Other { + message: "dispatch failure: connection refused".into(), + }; + api.resolve_error = Some(net.clone()); + assert_eq!(try_run(&api, &identity()).unwrap_err(), net); + assert!(api.probed.lock().unwrap().is_empty()); +} + +#[test] +fn a_failed_probe_marks_its_reads_denied() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + api.simulate_denied = true; + api.probes_ok.remove(&Probe::EcsListClusters); + let report = run(&api); + let ws = Tier::WorkspaceUser; + assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Denied); + assert_eq!( + verdict(&report, ws, "ecs:DescribeClusters"), + Verdict::Denied + ); + assert_eq!( + verdict(&report, ws, "secretsmanager:ListSecrets"), + Verdict::Allowed + ); + assert_eq!(report.summary, Summary::Insufficient); +} + +#[test] +fn a_probe_failing_for_another_reason_leaves_its_reads_unverified_and_blocks() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + let mut id = identity(); + id.arn = "arn:aws:iam::111122223333:root".into(); + api.probes_ok.remove(&Probe::EcsListClusters); + api.probe_error = Some(AwsError::Other { + message: "connection reset".into(), + }); + let report = try_run(&api, &id).unwrap(); + let r = row(&report, Tier::WorkspaceUser, "ecs:ListClusters"); + assert_eq!(r.verdict, Verdict::Unverified); + assert!(r.note.as_deref().unwrap().contains("connection reset")); + // Design-level unverified rows carry no note. + assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None); + assert_eq!(report.summary, Summary::Insufficient); +} + +#[test] +fn the_report_serializes_for_the_ui() { + let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); + api.get_role_denied = true; + let v = serde_json::to_value(run(&api)).unwrap(); + // Probed because iam:GetRole is denied, which the workspace tier needs. + assert_eq!(v["summary"], "insufficient"); + assert!(v["tiers"][0]["results"][0] + .as_object() + .unwrap() + .contains_key("note")); + assert_eq!(v["tiers"][0]["tier"], "workspace_user"); + assert!(v["method"]["probed"]["reason"].is_string(), "{v}"); + assert!(["allowed", "denied", "unverified"] + .contains(&v["tiers"][0]["results"][0]["verdict"].as_str().unwrap())); +} + +/// Tauri commands need a `Send` future. +#[allow(dead_code)] +fn check_permissions_is_send() { + fn assert_send(_: T) {} + fn with(api: &A, id: &CallerIdentity) { + assert_send(check_permissions(api, id, "r")); + } + let _ = with::; +} diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs new file mode 100644 index 0000000..583915a --- /dev/null +++ b/app/src-tauri/src/aws/policies.rs @@ -0,0 +1,1842 @@ +//! The IAM policies Triple-C ships for each permission tier +//! (`aws/policies/*.json` at the repo root), the permissions boundary every +//! Triple-C-created role must carry, and the table of representative checks +//! the permission check simulates against the tier policies. +//! +//! The table and the JSON are kept in lock-step by the tests below: every +//! allowed action in a policy has a check, every check is allowed by its +//! policy under a small IAM-style evaluator, every owner-scoped check is +//! *denied* when the resource belongs to someone else, and the admin tier can +//! neither create unbounded roles nor destroy resources that aren't +//! Triple-C's. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum Tier { + EnvironmentAdmin, + WorkspaceUser, +} + +/// The policy document an admin attaches for `tier`. +pub fn policy_json(tier: Tier) -> &'static str { + match tier { + Tier::EnvironmentAdmin => include_str!("../../../../aws/policies/environment-admin.json"), + Tier::WorkspaceUser => include_str!("../../../../aws/policies/workspace-user.json"), + } +} + +/// The managed policy (no path) every role an environment admin creates must +/// carry as its permissions boundary. +#[allow(dead_code)] // Only tests read the policy text so far. +pub const PERMISSIONS_BOUNDARY_NAME: &str = "triple-c-permissions-boundary"; + +/// The document for [`PERMISSIONS_BOUNDARY_NAME`]: the most any +/// Triple-C-created role may ever hold. +#[allow(dead_code)] // Only tests read the policy text so far. +pub fn permissions_boundary_json() -> &'static str { + include_str!("../../../../aws/policies/permissions-boundary.json") +} + +/// A context value sent with a simulated request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextValue { + /// The caller's `aws:userid` (the owner ID every per-user resource is + /// tagged with). + OwnerId, + /// A fixed value. Placeholders are substituted exactly as in + /// [`Check::resource`] — use [`render`]. + Literal(&'static str), +} + +/// One simulated request: "may the caller do `action` on `resource`, given +/// `context`?" +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Check { + pub action: &'static str, + /// An ARN template ([`render`]). + pub resource: &'static str, + /// Context entries. A key that appears more than once is a multi-valued + /// key (e.g. `aws:TagKeys`): whoever builds the simulation request must + /// send all its values as one `StringList` context entry (not enforced + /// here; the permission check's simulation builder owns it). + pub context: &'static [(&'static str, ContextValue)], + pub write: bool, +} + +/// What a resource or literal template's placeholders stand for. +#[derive(Debug, Clone, Copy)] +pub struct Placeholders<'a> { + /// `{region}` + pub region: &'a str, + /// `{account}` + pub account: &'a str, + /// `{principal}`: the caller's IAM user or role ARN (the simulation's + /// `PolicySourceArn`). `{principal_name}` is its last path segment: the + /// user or role name. + pub principal: &'a str, +} + +/// Substitutes `{region}`, `{account}`, `{principal}` and +/// `{principal_name}` in a resource or [`ContextValue::Literal`] template. +pub fn render(template: &str, p: &Placeholders) -> String { + let name = p.principal.rsplit('/').next().unwrap_or(p.principal); + template + .replace("{region}", p.region) + .replace("{account}", p.account) + .replace("{principal_name}", name) + .replace("{principal}", p.principal) +} + +pub fn checks(tier: Tier) -> &'static [Check] { + match tier { + Tier::WorkspaceUser => WORKSPACE_USER_CHECKS, + Tier::EnvironmentAdmin => ENVIRONMENT_ADMIN_CHECKS, + } +} + +use ContextValue::{Literal, OwnerId}; + +type Context = &'static [(&'static str, ContextValue)]; + +const OWNER_REQUEST_TAG: &str = "aws:RequestTag/triple-c:owner"; +const OWNER_RESOURCE_TAG: &str = "aws:ResourceTag/triple-c:owner"; +/// The tag SSM puts on a session; its value is the starter's `aws:userid`. +const SESSION_OWNER: &str = "ssm:resourceTag/aws:ssmmessages:session-id"; + +/// IAM refuses `${aws:PrincipalArn}` as a Resource (verified live), so a +/// user's own ARN is `user/${aws:username}` (or `user/*/${aws:username}` +/// with a path), resolved from this entry. A role cannot be named that way +/// (there is no role-name variable); the policies allow SSO permission-set +/// roles and `triple-c-*` roles (R18). Other roles fall back to probes. +const OWN_PRINCIPAL: Context = &[("aws:username", Literal("{principal_name}"))]; +/// Role writes name the boundary; `${aws:PrincipalAccount}` resolves from +/// the second entry. +const WITH_BOUNDARY: Context = &[ + ( + "iam:PermissionsBoundary", + Literal("arn:aws:iam::{account}:policy/triple-c-permissions-boundary"), + ), + ("aws:PrincipalAccount", Literal("{account}")), +]; +/// Created resources carry `triple-c:component`; destroyed ones must. +const COMPONENT_REQUESTED: Context = + &[("aws:RequestTag/triple-c:component", Literal("environment"))]; +const COMPONENT_RESOURCE_TAG: &str = "aws:ResourceTag/triple-c:component"; +const COMPONENT_TAGGED: Context = &[(COMPONENT_RESOURCE_TAG, Literal("environment"))]; + +const PROBE_CLUSTER: &str = "arn:aws:ecs:{region}:{account}:cluster/triple-c-probe"; +const PROBE_TASK: &str = + "arn:aws:ecs:{region}:{account}:task/triple-c-probe/0123456789abcdef0123456789abcdef"; +const PROBE_TASK_DEFINITION: &str = + "arn:aws:ecs:{region}:{account}:task-definition/triple-c-probe:1"; +const PROBE_ROLE: &str = "arn:aws:iam::{account}:role/triple-c-probe"; +const PROBE_SECRET: &str = + "arn:aws:secretsmanager:{region}:{account}:secret:triple-c/probe/probe-AbCdEf"; +const PROBE_KEY: &str = "arn:aws:kms:{region}:{account}:key/00000000-0000-0000-0000-000000000000"; +const PROBE_ALIAS: &str = "arn:aws:kms:{region}:{account}:alias/triple-c-probe"; +const PROBE_LOG_GROUP: &str = "arn:aws:logs:{region}:{account}:log-group:/triple-c/probe"; +const PROBE_PARAMETER: &str = + "arn:aws:ssm:{region}:{account}:parameter/triple-c/environments/probe"; +const PROBE_STACK: &str = "arn:aws:cloudformation:{region}:{account}:stack/triple-c-probe/00000000-0000-0000-0000-000000000000"; +const PROBE_FILE_SYSTEM: &str = + "arn:aws:elasticfilesystem:{region}:{account}:file-system/fs-00000000"; +const PROBE_ACCESS_POINT: &str = + "arn:aws:elasticfilesystem:{region}:{account}:access-point/fsap-00000000000000000"; +const PROBE_SECURITY_GROUP: &str = + "arn:aws:ec2:{region}:{account}:security-group/sg-00000000000000000"; +const PROBE_VPC: &str = "arn:aws:ec2:{region}:{account}:vpc/vpc-00000000000000000"; +const PROBE_SESSION: &str = "arn:aws:ssm:{region}:{account}:session/probe-0123456789abcdef0"; + +const fn write(action: &'static str, resource: &'static str, context: Context) -> Check { + Check { + action, + resource, + context, + write: true, + } +} + +const fn read(action: &'static str, resource: &'static str, context: Context) -> Check { + Check { + action, + resource, + context, + write: false, + } +} + +const WORKSPACE_USER_CHECKS: &[Check] = &[ + write( + "ecs:RunTask", + PROBE_TASK_DEFINITION, + &[ + (OWNER_REQUEST_TAG, OwnerId), + // The definition names the task role, so it must be the caller's. + (OWNER_RESOURCE_TAG, OwnerId), + ("ecs:cluster", Literal(PROBE_CLUSTER)), + ], + ), + write( + "ecs:TagResource", + PROBE_TASK, + &[ + (OWNER_REQUEST_TAG, OwnerId), + ("ecs:CreateAction", Literal("RunTask")), + ], + ), + write( + "iam:PassRole", + PROBE_ROLE, + &[ + ("iam:ResourceTag/triple-c:owner", OwnerId), + ("iam:PassedToService", Literal("ecs-tasks.amazonaws.com")), + ], + ), + write("ecs:StopTask", PROBE_TASK, &[(OWNER_RESOURCE_TAG, OwnerId)]), + write( + "ecs:ExecuteCommand", + PROBE_TASK, + &[(OWNER_RESOURCE_TAG, OwnerId)], + ), + // ExecuteCommand is authorized against the cluster too (verified live). + write("ecs:ExecuteCommand", PROBE_CLUSTER, &[]), + write( + "ssm:StartSession", + PROBE_TASK, + &[(OWNER_RESOURCE_TAG, OwnerId)], + ), + // Port forwarding needs the document as well as the target. + write( + "ssm:StartSession", + "arn:aws:ssm:{region}::document/AWS-StartPortForwardingSession", + &[], + ), + write( + "ssm:TerminateSession", + PROBE_SESSION, + &[(SESSION_OWNER, OwnerId)], + ), + write( + "ssm:ResumeSession", + PROBE_SESSION, + &[(SESSION_OWNER, OwnerId)], + ), + // The session client opens its data channel on the session. + write( + "ssmmessages:OpenDataChannel", + PROBE_SESSION, + &[(SESSION_OWNER, OwnerId)], + ), + // Encrypted with an environment key, never the default + // `aws/secretsmanager` key (which can't carry `triple-c:component`). + write( + "secretsmanager:CreateSecret", + PROBE_SECRET, + &[ + (OWNER_REQUEST_TAG, OwnerId), + ("secretsmanager:KmsKeyId", Literal(PROBE_KEY)), + ], + ), + // Tag-on-create: the secret has no tags yet, so only the request tag. + write( + "secretsmanager:TagResource", + PROBE_SECRET, + &[(OWNER_REQUEST_TAG, OwnerId)], + ), + write( + "secretsmanager:PutSecretValue", + PROBE_SECRET, + &[(OWNER_RESOURCE_TAG, OwnerId)], + ), + write( + "secretsmanager:DeleteSecret", + PROBE_SECRET, + &[(OWNER_RESOURCE_TAG, OwnerId)], + ), + // Only on the environment's (component-tagged) file system. + write( + "elasticfilesystem:CreateAccessPoint", + PROBE_FILE_SYSTEM, + &[ + (OWNER_REQUEST_TAG, OwnerId), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ], + ), + write( + "elasticfilesystem:TagResource", + PROBE_ACCESS_POINT, + &[ + (OWNER_REQUEST_TAG, OwnerId), + ( + "elasticfilesystem:CreateAction", + Literal("CreateAccessPoint"), + ), + ], + ), + write( + "elasticfilesystem:DeleteAccessPoint", + PROBE_ACCESS_POINT, + &[(OWNER_RESOURCE_TAG, OwnerId)], + ), + write( + "kms:Encrypt", + PROBE_KEY, + &[ + ( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + ), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ], + ), + write( + "kms:GenerateDataKey", + PROBE_KEY, + &[ + ( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + ), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ], + ), + // CreateSecret and PutSecretValue on a customer key also need Decrypt + // (verified live: without it, "Access to KMS is not allowed"). + write( + "kms:Decrypt", + PROBE_KEY, + &[ + ( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + ), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ], + ), + read("iam:GetRole", "{principal}", OWN_PRINCIPAL), + read("iam:SimulatePrincipalPolicy", "{principal}", OWN_PRINCIPAL), + read("ecs:DescribeClusters", PROBE_CLUSTER, &[]), + read("ecs:ListClusters", "*", &[]), + read("ssm:GetParametersByPath", PROBE_PARAMETER, &[]), + read("cloudformation:DescribeStacks", "*", &[]), + read("cloudformation:ListStacks", "*", &[]), + read("secretsmanager:ListSecrets", "*", &[]), + read("elasticfilesystem:DescribeAccessPoints", "*", &[]), + read( + "logs:GetLogEvents", + "arn:aws:logs:{region}:{account}:log-group:/triple-c/probe:log-stream:probe", + &[], + ), + read("logs:FilterLogEvents", PROBE_LOG_GROUP, &[]), + read("sts:GetCallerIdentity", "*", &[]), +]; + +const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ + write("cloudformation:CreateStack", PROBE_STACK, &[]), + write("cloudformation:UpdateStack", PROBE_STACK, &[]), + write("cloudformation:DeleteStack", PROBE_STACK, &[]), + write("iam:CreateRole", PROBE_ROLE, WITH_BOUNDARY), + write("iam:PutRolePolicy", PROBE_ROLE, WITH_BOUNDARY), + write("iam:DeleteRolePolicy", PROBE_ROLE, WITH_BOUNDARY), + write("iam:AttachRolePolicy", PROBE_ROLE, WITH_BOUNDARY), + write("iam:DetachRolePolicy", PROBE_ROLE, WITH_BOUNDARY), + write("iam:DeleteRole", PROBE_ROLE, &[]), + write("iam:TagRole", PROBE_ROLE, &[]), + read("iam:GetRole", PROBE_ROLE, &[]), + write( + "iam:PassRole", + PROBE_ROLE, + &[("iam:PassedToService", Literal("ecs-tasks.amazonaws.com"))], + ), + read("iam:GetRole", "{principal}", OWN_PRINCIPAL), + read("iam:SimulatePrincipalPolicy", "{principal}", OWN_PRINCIPAL), + write("ecs:CreateCluster", PROBE_CLUSTER, &[]), + write("ecs:DeleteCluster", PROBE_CLUSTER, &[]), + write("ecs:PutClusterCapacityProviders", PROBE_CLUSTER, &[]), + write( + "ecs:CreateCapacityProvider", + "arn:aws:ecs:{region}:{account}:capacity-provider/triple-c-probe", + &[], + ), + write( + "ecs:DeleteCapacityProvider", + "arn:aws:ecs:{region}:{account}:capacity-provider/triple-c-probe", + &[], + ), + write("ecs:RegisterTaskDefinition", "*", &[]), + write("ecs:DeregisterTaskDefinition", "*", &[]), + // CreateSecurityGroup is authorized against the new group and the VPC. + write( + "ec2:CreateSecurityGroup", + PROBE_SECURITY_GROUP, + COMPONENT_REQUESTED, + ), + write("ec2:CreateSecurityGroup", PROBE_VPC, &[]), + write( + "ec2:CreateTags", + PROBE_SECURITY_GROUP, + &[("ec2:CreateAction", Literal("CreateSecurityGroup"))], + ), + write("ec2:CreateTags", PROBE_SECURITY_GROUP, COMPONENT_TAGGED), + write( + "ec2:DeleteSecurityGroup", + PROBE_SECURITY_GROUP, + COMPONENT_TAGGED, + ), + write( + "ec2:AuthorizeSecurityGroupIngress", + PROBE_SECURITY_GROUP, + COMPONENT_TAGGED, + ), + write( + "ec2:RevokeSecurityGroupIngress", + PROBE_SECURITY_GROUP, + COMPONENT_TAGGED, + ), + read("ec2:DescribeSecurityGroups", "*", &[]), + // CreateFileSystem has no resource type: IAM authorizes it against `*` + // (verified live; a `file-system/*` Resource never matches). + write( + "elasticfilesystem:CreateFileSystem", + "*", + COMPONENT_REQUESTED, + ), + write( + "elasticfilesystem:TagResource", + PROBE_FILE_SYSTEM, + &[( + "elasticfilesystem:CreateAction", + Literal("CreateFileSystem"), + )], + ), + write( + "elasticfilesystem:TagResource", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), + write( + "elasticfilesystem:DeleteFileSystem", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), + write( + "elasticfilesystem:CreateMountTarget", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), + write( + "elasticfilesystem:DeleteMountTarget", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), + write( + "elasticfilesystem:PutFileSystemPolicy", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), + write("kms:CreateKey", "*", COMPONENT_REQUESTED), + write("kms:ScheduleKeyDeletion", PROBE_KEY, COMPONENT_TAGGED), + write("kms:PutKeyPolicy", PROBE_KEY, COMPONENT_TAGGED), + write("kms:TagResource", PROBE_KEY, COMPONENT_TAGGED), + // An alias call is authorized against both the alias and the key. + write("kms:CreateAlias", PROBE_ALIAS, &[]), + write("kms:CreateAlias", PROBE_KEY, COMPONENT_TAGGED), + write("kms:DeleteAlias", PROBE_ALIAS, &[]), + write("kms:DeleteAlias", PROBE_KEY, COMPONENT_TAGGED), + write("logs:CreateLogGroup", PROBE_LOG_GROUP, &[]), + write("logs:DeleteLogGroup", PROBE_LOG_GROUP, &[]), + write("logs:PutRetentionPolicy", PROBE_LOG_GROUP, &[]), + write( + "ecr:CreateRepository", + "arn:aws:ecr:{region}:{account}:repository/triple-c/probe", + &[], + ), + write( + "ecr:DeleteRepository", + "arn:aws:ecr:{region}:{account}:repository/triple-c/probe", + &[], + ), + write("ecr:CreatePullThroughCacheRule", "*", &[]), + write("ssm:PutParameter", PROBE_PARAMETER, &[]), + write("ssm:DeleteParameter", PROBE_PARAMETER, &[]), +]; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use std::collections::BTreeSet; + + const AT: Placeholders = Placeholders { + region: "us-west-2", + account: "111122223333", + principal: "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Dev_0123456789abcdef", + }; + const ME: &str = "AROAEXAMPLEME:alice@example.com"; + const SOMEONE_ELSE: &str = "AROAEXAMPLEOTHER:bob@example.com"; + + const TIERS: [Tier; 2] = [Tier::WorkspaceUser, Tier::EnvironmentAdmin]; + + /// IAM wildcard match: `*` is any run of characters (including `:` and + /// `/`), `?` is exactly one. + fn glob(pattern: &str, text: &str) -> bool { + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + let (mut pi, mut ti) = (0, 0); + let (mut star, mut mark) = (None, 0); + while ti < t.len() { + if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == '*' { + star = Some(pi); + mark = ti; + pi += 1; + } else if let Some(s) = star { + pi = s + 1; + mark += 1; + ti = mark; + } else { + return false; + } + } + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() + } + + #[test] + fn glob_behaves_like_iam_wildcards() { + assert!(glob("ecs:Describe*", "ecs:DescribeClusters")); + assert!(!glob("ecs:Describe*", "ecs:ListClusters")); + assert!(glob( + "arn:aws:ecs:*:*:task/triple-c-*/*", + "arn:aws:ecs:us-west-2:1:task/triple-c-x/abc" + )); + assert!(!glob( + "arn:aws:ecs:*:*:task/triple-c-*/*", + "arn:aws:ecs:us-west-2:1:task/other/abc" + )); + assert!(glob("a?c", "abc")); + assert!(!glob("a?c", "ac")); + } + + fn parse(name: &str, json: &str) -> Value { + serde_json::from_str(json).unwrap_or_else(|e| panic!("{name} is not JSON: {e}")) + } + + fn policy(tier: Tier) -> Value { + parse(&format!("{tier:?} policy"), policy_json(tier)) + } + + fn boundary() -> Value { + parse("permissions boundary", permissions_boundary_json()) + } + + fn statements_of(doc: &Value) -> Vec { + doc["Statement"] + .as_array() + .expect("policy has no Statement array") + .clone() + } + + fn statements(tier: Tier) -> Vec { + statements_of(&policy(tier)) + } + + fn effect(stmt: &Value) -> &str { + stmt["Effect"].as_str().unwrap_or("") + } + + fn allows(tier: Tier) -> Vec { + statements(tier) + .into_iter() + .filter(|s| effect(s) == "Allow") + .collect() + } + + fn denies(tier: Tier) -> Vec { + statements(tier) + .into_iter() + .filter(|s| effect(s) == "Deny") + .collect() + } + + /// `Action` / `Resource` / condition values: a string or an array of them. + fn strings(v: &Value) -> Vec { + match v { + Value::String(s) => vec![s.clone()], + Value::Array(a) => a + .iter() + .map(|x| x.as_str().expect("expected a string").to_string()) + .collect(), + other => panic!("expected a string or array, got {other}"), + } + } + + fn actions(stmt: &Value) -> Vec { + strings(&stmt["Action"]) + } + + fn resources(stmt: &Value) -> Vec { + strings(&stmt["Resource"]) + } + + fn sid(stmt: &Value) -> &str { + stmt["Sid"].as_str().unwrap_or("") + } + + fn rendered(check: &Check) -> String { + render(check.resource, &AT) + } + + /// The request: who calls, and whose the resource / request tag is. + #[derive(Clone, Copy)] + struct Request<'a> { + check: &'a Check, + /// `${aws:userid}` of the caller. + caller: &'a str, + /// What `OwnerId` context entries carry. + owner: &'a str, + } + + impl Request<'_> { + fn context_value(&self, key: &str) -> Option { + self.context_values(key).into_iter().next() + } + + /// All values of a (possibly multi-valued) key. + fn context_values(&self, key: &str) -> Vec { + self.check + .context + .iter() + .filter(|(k, _)| *k == key) + .map(|(_, v)| match v { + ContextValue::OwnerId => self.owner.to_string(), + ContextValue::Literal(l) => render(l, &AT), + }) + .collect() + } + + /// Resolves `${…}` policy variables the way the simulator does: from + /// the request context. An unresolvable variable makes the string + /// match nothing (`None`). + fn resolve(&self, s: &str) -> Option { + let mut out = String::new(); + let mut rest = s; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let end = rest[start..].find('}').expect("unterminated ${") + start; + let name = &rest[start + 2..end]; + let value = if name == "aws:userid" { + Some(self.caller.to_string()) + } else { + self.context_value(name) + }; + out.push_str(&value?); + rest = &rest[end + 1..]; + } + out.push_str(rest); + Some(out) + } + } + + /// Statements that name the action and one of whose resources matches + /// the rendered resource. + fn matching<'s>(stmts: &'s [Value], req: &Request) -> Vec<&'s Value> { + let resource = rendered(req.check); + stmts + .iter() + .filter(|s| actions(s).iter().any(|a| glob(a, req.check.action))) + .filter(|s| { + resources(s) + .iter() + .filter_map(|r| req.resolve(r)) + .any(|r| glob(&r, &resource)) + }) + .collect() + } + + /// A deliberately small IAM condition evaluator: the operators the + /// shipped policies use, and nothing else (an unknown operator fails the + /// test rather than being ignored). + fn conditions_hold(stmt: &Value, req: &Request) -> bool { + let Some(cond) = stmt.get("Condition") else { + return true; + }; + let cond = cond.as_object().expect("Condition is an object"); + cond.iter().all(|(op, entries)| { + let entries = entries.as_object().expect("condition block is an object"); + // ForAllValues: every value of the (multi-valued) key matches + // one of the policy's values. Vacuously true when the key is + // absent, which is why statements pair it with a required key. + if let Some(base) = op.strip_prefix("ForAllValues:") { + assert_eq!( + base, + "StringEquals", + "{}: unsupported ForAllValues operator", + sid(stmt) + ); + return entries.iter().all(|(key, want)| { + let allowed: Vec = strings(want) + .iter() + .filter_map(|p| req.resolve(p)) + .collect(); + req.context_values(key).iter().all(|v| allowed.contains(v)) + }); + } + if op == "Null" { + return entries.iter().all(|(key, want)| { + let absent = req.context_value(key).is_none(); + match want.as_str() { + Some("true") => absent, + Some("false") => !absent, + _ => panic!("{}: Null takes \"true\" or \"false\"", sid(stmt)), + } + }); + } + let (base, if_exists) = match op.strip_suffix("IfExists") { + Some(b) => (b, true), + None => (op.as_str(), false), + }; + // A negated operator holds when the key is absent, and + // otherwise when no value matches. + let (matches, negated): (fn(&str, &str) -> bool, bool) = match base { + "StringEquals" => (|p, v| p == v, false), + "StringLike" | "ArnLike" => (glob, false), + "StringNotEquals" => (|p, v| p == v, true), + "StringNotLike" => (glob, true), + other => panic!("{}: unsupported condition operator {other}", sid(stmt)), + }; + entries + .iter() + .all(|(key, want)| match req.context_value(key) { + None => if_exists || negated, + Some(value) => { + let any = strings(want) + .iter() + .filter_map(|p| req.resolve(p)) + .any(|p| matches(&p, &value)); + any != negated + } + }) + }) + } + + fn evaluate(tier: Tier, req: Request) -> bool { + evaluate_in(&statements(tier), req) + } + + /// Allowed by some Allow and by no Deny among `stmts`. + fn evaluate_in(stmts: &[Value], req: Request) -> bool { + let applies = |s: &&Value| conditions_hold(s, &req); + let allowed = matching(stmts, &req) + .into_iter() + .filter(|s| effect(s) == "Allow") + .any(|s| applies(&s)); + let denied = matching(stmts, &req) + .into_iter() + .filter(|s| effect(s) == "Deny") + .any(|s| applies(&s)); + allowed && !denied + } + + fn allowed(tier: Tier, check: &Check, caller: &str, owner: &str) -> bool { + evaluate( + tier, + Request { + check, + caller, + owner, + }, + ) + } + + fn validate_document(name: &str, doc: &Value) { + assert_eq!(doc["Version"], "2012-10-17", "{name}"); + let stmts = statements_of(doc); + assert!(!stmts.is_empty(), "{name} has no statements"); + let mut sids = BTreeSet::new(); + for s in &stmts { + let id = s["Sid"] + .as_str() + .unwrap_or_else(|| panic!("{name}: statement without Sid: {s}")); + assert!( + !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()), + "{name}: Sid {id:?} must be non-empty alphanumeric" + ); + assert!(sids.insert(id.to_string()), "{name}: duplicate Sid {id}"); + assert!( + ["Allow", "Deny"].contains(&effect(s)), + "{name} {id}: Effect must be Allow or Deny" + ); + assert!(!actions(s).is_empty(), "{name} {id}: no Action"); + assert!(!resources(s).is_empty(), "{name} {id}: no Resource"); + for key in s.as_object().unwrap().keys() { + assert!( + ["Sid", "Effect", "Action", "Resource", "Condition"].contains(&key.as_str()), + "{name} {id}: unexpected key {key} (NotAction/NotResource are not allowed)" + ); + } + } + } + + #[test] + fn policies_are_valid_iam_json() { + for tier in TIERS { + validate_document(&format!("{tier:?}"), &policy(tier)); + assert!(!allows(tier).is_empty(), "{tier:?} allows nothing"); + } + validate_document("permissions boundary", &boundary()); + } + + #[test] + fn every_policy_action_has_a_check_and_vice_versa() { + for tier in TIERS { + let policy_actions: BTreeSet = allows(tier).iter().flat_map(actions).collect(); + for a in &policy_actions { + assert!( + checks(tier).iter().any(|c| glob(a, c.action)), + "{tier:?}: policy action {a} has no check" + ); + } + for c in checks(tier) { + assert!( + policy_actions.iter().any(|a| glob(a, c.action)), + "{tier:?}: check {} is for an action the policy does not grant", + c.action + ); + assert!( + !c.action.contains('*'), + "{tier:?}: check action {} must be concrete", + c.action + ); + } + } + } + + #[test] + fn every_check_resource_matches_a_statement_resource() { + for tier in TIERS { + let stmts = allows(tier); + for c in checks(tier) { + let req = Request { + check: c, + caller: ME, + owner: ME, + }; + assert!( + !matching(&stmts, &req).is_empty(), + "{tier:?}: {} on {} matches no statement resource", + c.action, + rendered(c) + ); + assert!( + !rendered(c).contains('{'), + "{tier:?}: unsubstituted placeholder in {}", + c.resource + ); + } + } + } + + /// The owner-scoped actions, and the condition key each statement must + /// carry the owner in. + const OWNER_SCOPED: &[(&str, &str, &str)] = &[ + ("ecs:RunTask", "task-definition/", OWNER_REQUEST_TAG), + ("ecs:RunTask", "task-definition/", OWNER_RESOURCE_TAG), + ("ecs:TagResource", "task/", OWNER_REQUEST_TAG), + ("ecs:StopTask", "task/", OWNER_RESOURCE_TAG), + ("ecs:ExecuteCommand", "task/", OWNER_RESOURCE_TAG), + ("ssm:StartSession", "task/", OWNER_RESOURCE_TAG), + ("ssm:TerminateSession", "session/", SESSION_OWNER), + ("ssm:ResumeSession", "session/", SESSION_OWNER), + ("iam:PassRole", "role/", "iam:ResourceTag/triple-c:owner"), + ("secretsmanager:CreateSecret", "secret:", OWNER_REQUEST_TAG), + ("secretsmanager:TagResource", "secret:", OWNER_REQUEST_TAG), + ( + "secretsmanager:PutSecretValue", + "secret:", + OWNER_RESOURCE_TAG, + ), + ("secretsmanager:DeleteSecret", "secret:", OWNER_RESOURCE_TAG), + ( + "elasticfilesystem:CreateAccessPoint", + "file-system/", + OWNER_REQUEST_TAG, + ), + ( + "elasticfilesystem:TagResource", + "access-point/", + OWNER_REQUEST_TAG, + ), + ( + "elasticfilesystem:DeleteAccessPoint", + "access-point/", + OWNER_RESOURCE_TAG, + ), + ]; + + fn owner_scoped_checks() -> Vec<(&'static Check, &'static str)> { + OWNER_SCOPED + .iter() + .map(|(action, res, key)| { + let check = checks(Tier::WorkspaceUser) + .iter() + .find(|c| c.action == *action && c.resource.contains(res)) + .unwrap_or_else(|| { + panic!("no workspace-user check for {action} on a {res} resource") + }); + (check, *key) + }) + .collect() + } + + #[test] + fn owner_scoped_actions_carry_the_owner_context() { + for (check, key) in owner_scoped_checks() { + assert!( + check.context.contains(&(key, ContextValue::OwnerId)), + "{} must send ({key}, OwnerId); has {:?}", + check.action, + check.context + ); + // ...and that is the key its statement's condition uses. + let stmts = allows(Tier::WorkspaceUser); + let req = Request { + check, + caller: ME, + owner: ME, + }; + let found = matching(&stmts, &req); + assert!(!found.is_empty(), "{}: no statement", check.action); + assert!( + found + .iter() + .all(|s| s["Condition"] + .as_object() + .is_some_and(|ops| ops.values().any(|e| e + .get(key) + .is_some_and(|v| strings(v).contains(&"${aws:userid}".to_string()))))), + "{}: every matching statement must condition {key} on ${{aws:userid}}", + check.action + ); + } + } + + #[test] + fn every_check_is_allowed_by_its_policy() { + for tier in TIERS { + for c in checks(tier) { + assert!( + allowed(tier, c, ME, ME), + "{tier:?}: {} on {} with {:?} is not allowed", + c.action, + rendered(c), + c.context + ); + } + } + } + + /// The spec's most important rule: nobody may stop, exec into, open a + /// session to, pass the role of, or touch the secrets or access points + /// of a workspace they don't own. + #[test] + fn owner_scoped_checks_are_denied_for_someone_elses_resource() { + for (check, _) in owner_scoped_checks() { + assert!( + !allowed(Tier::WorkspaceUser, check, ME, SOMEONE_ELSE), + "{} on {} is allowed for a resource owned by someone else", + check.action, + rendered(check) + ); + } + } + + #[test] + fn owner_scoped_checks_are_denied_without_an_owner_tag() { + for (check, _) in owner_scoped_checks() { + let untagged = Check { + context: &[], + ..*check + }; + assert!( + !allowed(Tier::WorkspaceUser, &untagged, ME, ME), + "{} is allowed without any owner context", + check.action + ); + } + } + + #[test] + fn run_task_needs_an_owned_task_definition() { + let run = checks(Tier::WorkspaceUser) + .iter() + .find(|c| c.action == "ecs:RunTask") + .expect("ecs:RunTask check"); + let someone_elses_definition = Check { + context: &[ + (OWNER_REQUEST_TAG, OwnerId), + (OWNER_RESOURCE_TAG, Literal(SOMEONE_ELSE)), + ("ecs:cluster", Literal(PROBE_CLUSTER)), + ], + ..*run + }; + assert!(!allowed( + Tier::WorkspaceUser, + &someone_elses_definition, + ME, + ME + )); + let other_cluster = Check { + context: &[ + (OWNER_REQUEST_TAG, OwnerId), + (OWNER_RESOURCE_TAG, OwnerId), + ( + "ecs:cluster", + Literal("arn:aws:ecs:{region}:{account}:cluster/production"), + ), + ], + ..*run + }; + assert!(!allowed(Tier::WorkspaceUser, &other_cluster, ME, ME)); + } + + #[test] + fn ecs_tag_resource_only_tags_at_run_task() { + let tag = checks(Tier::WorkspaceUser) + .iter() + .find(|c| c.action == "ecs:TagResource") + .expect("ecs:TagResource check"); + let retag = Check { + context: &[(OWNER_REQUEST_TAG, OwnerId)], + ..*tag + }; + assert!( + !allowed(Tier::WorkspaceUser, &retag, ME, ME), + "ecs:TagResource must require ecs:CreateAction" + ); + let other_create = Check { + context: &[ + (OWNER_REQUEST_TAG, OwnerId), + ("ecs:CreateAction", Literal("CreateService")), + ], + ..*tag + }; + assert!(!allowed(Tier::WorkspaceUser, &other_create, ME, ME)); + } + + #[test] + fn workspace_pass_role_is_only_to_ecs_tasks() { + let pass = checks(Tier::WorkspaceUser) + .iter() + .find(|c| c.action == "iam:PassRole") + .expect("iam:PassRole check"); + let to_ec2 = Check { + context: &[ + ("iam:ResourceTag/triple-c:owner", OwnerId), + ("iam:PassedToService", Literal("ec2.amazonaws.com")), + ], + ..*pass + }; + assert!(!allowed(Tier::WorkspaceUser, &to_ec2, ME, ME)); + let not_triple_c = Check { + resource: "arn:aws:iam::{account}:role/Admin", + ..*pass + }; + assert!(!allowed(Tier::WorkspaceUser, ¬_triple_c, ME, ME)); + } + + #[test] + fn workspace_kms_is_only_through_secrets_manager() { + for c in checks(Tier::WorkspaceUser) + .iter() + .filter(|c| c.action.starts_with("kms:")) + { + let direct = Check { context: &[], ..*c }; + assert!( + !allowed(Tier::WorkspaceUser, &direct, ME, ME), + "{} allowed without kms:ViaService", + c.action + ); + let via_s3 = Check { + context: &[("kms:ViaService", Literal("s3.{region}.amazonaws.com"))], + ..*c + }; + assert!( + !allowed(Tier::WorkspaceUser, &via_s3, ME, ME), + "{} allowed via S3", + c.action + ); + } + } + + /// GetRole and SimulatePrincipalPolicy: an IAM user only on itself. IAM + /// has no role-name variable and refuses `${aws:PrincipalArn}` as a + /// Resource (verified live), so roles are limited by path and name + /// instead (R18): SSO permission-set roles and `triple-c-*` roles only. + #[test] + fn principal_inspection_is_of_the_caller_sso_roles_or_triple_c_roles() { + for tier in TIERS { + for action in ["iam:GetRole", "iam:SimulatePrincipalPolicy"] { + let own = checks(tier) + .iter() + .find(|c| c.action == action && c.resource == "{principal}") + .unwrap_or_else(|| panic!("{tier:?}: no own-principal check for {action}")); + assert!(allowed(tier, own, ME, ME)); + let as_alice: Context = &[("aws:username", Literal("alice"))]; + let at = |resource: &'static str| Check { + resource, + context: as_alice, + ..*own + }; + for mine in [ + "arn:aws:iam::{account}:user/alice", + "arn:aws:iam::{account}:user/team/alice", + "arn:aws:iam::{account}:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Dev_0123", + "arn:aws:iam::{account}:role/triple-c-task-alice", + ] { + assert!( + allowed(tier, &at(mine), ME, ME), + "{tier:?}: {action} on {mine}" + ); + } + for theirs in [ + "arn:aws:iam::{account}:role/SomeoneElsesRole", + "arn:aws:iam::{account}:role/ops/deployer", + "arn:aws:iam::{account}:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS", + "arn:aws:iam::{account}:user/bob", + "arn:aws:iam::{account}:user/team/xalice", + "arn:aws:iam::{account}:group/alice", + ] { + assert!( + !allowed(tier, &at(theirs), ME, ME), + "{tier:?}: {action} allowed on {theirs}" + ); + } + } + } + } + + /// IAM rejects a Resource that is not `*` or an ARN, even one that is a + /// policy variable resolving to an ARN (`${aws:PrincipalArn}`, verified + /// live: "Resource ${aws:PrincipalArn} must be in ARN format or \"*\""). + #[test] + fn every_resource_is_an_arn_or_star() { + let docs = [ + ("workspace-user", policy(Tier::WorkspaceUser)), + ("environment-admin", policy(Tier::EnvironmentAdmin)), + ( + "permissions-boundary", + serde_json::from_str(permissions_boundary_json()).unwrap(), + ), + ]; + for (name, doc) in &docs { + for s in statements_of(doc) { + for r in resources(&s) { + assert!( + r == "*" || r.starts_with("arn:"), + "{name} {}: Resource {r} is not an ARN", + sid(&s) + ); + } + } + } + } + + /// A customer managed policy may hold 6,144 characters, not counting + /// whitespace (verified live: the workspace policy is close). An + /// environment admin needs both tier policies, which together exceed a + /// role's 10,240-character inline budget, so they ship as managed + /// policies. + #[test] + fn every_policy_fits_a_managed_policy() { + for (name, text) in [ + ("workspace-user", policy_json(Tier::WorkspaceUser)), + ("environment-admin", policy_json(Tier::EnvironmentAdmin)), + ("permissions-boundary", permissions_boundary_json()), + ] { + let size = text.chars().filter(|c| !c.is_whitespace()).count(); + assert!( + size <= 6144, + "{name} is {size} characters without whitespace (max 6144)" + ); + } + } + + const ROLE_WRITES: &[&str] = &[ + "iam:CreateRole", + "iam:PutRolePolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:DeleteRolePolicy", + ]; + /// Role and policy changes no tier may make at all. + const NEVER_ALLOWED: &[&str] = &[ + "iam:PutRolePermissionsBoundary", + "iam:DeleteRolePermissionsBoundary", + "iam:UpdateAssumeRolePolicy", + "iam:CreatePolicy", + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + "iam:CreateUser", + "iam:AttachUserPolicy", + "iam:PutUserPolicy", + ]; + + #[test] + fn admin_role_management_requires_the_boundary() { + let tier = Tier::EnvironmentAdmin; + let boundary_arn = + format!("arn:aws:iam::${{aws:PrincipalAccount}}:policy/{PERMISSIONS_BOUNDARY_NAME}"); + // Every statement granting a role write pins the boundary. + for s in allows(tier) { + if !actions(&s) + .iter() + .any(|a| ROLE_WRITES.iter().any(|w| glob(a, w))) + { + continue; + } + let pinned = s["Condition"]["StringEquals"]["iam:PermissionsBoundary"].clone(); + assert_eq!( + pinned, + Value::String(boundary_arn.clone()), + "{}: role writes must require iam:PermissionsBoundary = {boundary_arn}", + sid(&s) + ); + } + for action in ROLE_WRITES { + let c = checks(tier) + .iter() + .find(|c| c.action == *action) + .unwrap_or_else(|| panic!("no check for {action}")); + assert!( + c.context + .iter() + .any(|(k, _)| *k == "iam:PermissionsBoundary"), + "{action} check must send iam:PermissionsBoundary" + ); + for context in [ + &[][..], + &[ + ( + "iam:PermissionsBoundary", + Literal("arn:aws:iam::{account}:policy/SomethingElse"), + ), + ("aws:PrincipalAccount", Literal("{account}")), + ][..], + &[ + ( + "iam:PermissionsBoundary", + Literal("arn:aws:iam::999999999999:policy/triple-c-permissions-boundary"), + ), + ("aws:PrincipalAccount", Literal("{account}")), + ][..], + ] { + let unbounded = Check { context, ..*c }; + assert!( + !allowed(tier, &unbounded, ME, ME), + "{action} allowed with boundary context {context:?}" + ); + } + } + // Nothing grants the escape hatches... + for t in TIERS { + for s in allows(t) { + for a in actions(&s) { + for never in NEVER_ALLOWED { + assert!(!glob(&a, never), "{t:?} {}: {a} grants {never}", sid(&s)); + } + } + } + } + // ...and explicit Denies hold even if another policy grants them. + let denied = |action: &'static str, resource: &'static str| { + let c = Check { + action, + resource, + context: &[], + write: true, + }; + let stmts = denies(tier); + let req = Request { + check: &c, + caller: ME, + owner: ME, + }; + matching(&stmts, &req) + .iter() + .any(|s| conditions_hold(s, &req)) + }; + assert!( + denied("iam:DeleteRolePermissionsBoundary", PROBE_ROLE), + "missing Deny of iam:DeleteRolePermissionsBoundary on triple-c roles" + ); + for action in [ + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + ] { + assert!( + denied( + action, + "arn:aws:iam::{account}:policy/triple-c-permissions-boundary" + ), + "missing Deny of {action} on the boundary policy" + ); + } + } + + #[test] + fn destructive_admin_actions_are_tag_scoped() { + let tier = Tier::EnvironmentAdmin; + const CREATES: &[(&str, &str)] = &[ + ("kms:CreateKey", "*"), + ("ec2:CreateSecurityGroup", PROBE_SECURITY_GROUP), + ("elasticfilesystem:CreateFileSystem", "*"), + ]; + const DESTROYS: &[(&str, &str)] = &[ + ("kms:ScheduleKeyDeletion", PROBE_KEY), + ("kms:PutKeyPolicy", PROBE_KEY), + ("kms:TagResource", PROBE_KEY), + ("kms:CreateAlias", PROBE_KEY), + ("kms:DeleteAlias", PROBE_KEY), + ("ec2:DeleteSecurityGroup", PROBE_SECURITY_GROUP), + ("ec2:AuthorizeSecurityGroupIngress", PROBE_SECURITY_GROUP), + ("ec2:RevokeSecurityGroupIngress", PROBE_SECURITY_GROUP), + ("ec2:CreateTags", PROBE_SECURITY_GROUP), + ("elasticfilesystem:DeleteFileSystem", PROBE_FILE_SYSTEM), + ("elasticfilesystem:PutFileSystemPolicy", PROBE_FILE_SYSTEM), + ("elasticfilesystem:CreateMountTarget", PROBE_FILE_SYSTEM), + ("elasticfilesystem:DeleteMountTarget", PROBE_FILE_SYSTEM), + ("elasticfilesystem:TagResource", PROBE_FILE_SYSTEM), + ]; + for (list, context) in [(CREATES, COMPONENT_REQUESTED), (DESTROYS, COMPONENT_TAGGED)] { + for (action, resource) in list { + let c = checks(tier) + .iter() + .find(|c| c.action == *action && c.resource == *resource) + .unwrap_or_else(|| panic!("no admin check for {action} on {resource}")); + if c.context == context { + assert!(allowed(tier, c, ME, ME), "{action} with {context:?}"); + } + // Without the component tag, it's someone else's resource. + let untagged = Check { context: &[], ..*c }; + assert!( + !allowed(tier, &untagged, ME, ME), + "{action} on {resource} is allowed without a triple-c:component tag" + ); + } + } + // Tagging is allowed on create only through the creating call. + for (action, key, wrong) in [ + ("ec2:CreateTags", "ec2:CreateAction", "RunInstances"), + ( + "elasticfilesystem:TagResource", + "elasticfilesystem:CreateAction", + "CreateAccessPoint", + ), + ] { + let c = checks(tier) + .iter() + .find(|c| c.action == action && c.context.iter().any(|(k, _)| *k == key)) + .unwrap_or_else(|| panic!("no tag-on-create check for {action}")); + let ctx: Context = match action { + "ec2:CreateTags" => &[("ec2:CreateAction", Literal("RunInstances"))], + _ => &[( + "elasticfilesystem:CreateAction", + Literal("CreateAccessPoint"), + )], + }; + let other_create = Check { context: ctx, ..*c }; + assert!( + !allowed(tier, &other_create, ME, ME), + "{action} allowed with {key} = {wrong}" + ); + } + } + + #[test] + fn the_boundary_grants_what_tasks_need_and_nothing_iam() { + let doc = boundary(); + let stmts = statements_of(&doc); + let granted: Vec = stmts + .iter() + .filter(|s| effect(s) == "Allow") + .flat_map(actions) + .collect(); + for a in &granted { + assert!( + !a.starts_with("iam:") && a != "*" && !a.ends_with(":*"), + "boundary grants {a}" + ); + assert!(!glob(a, "sts:AssumeRole"), "boundary grants {a}"); + } + for need in [ + "ecr:GetAuthorizationToken", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "logs:CreateLogStream", + "logs:PutLogEvents", + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel", + "secretsmanager:GetSecretValue", + "kms:Decrypt", + "elasticfilesystem:ClientMount", + "elasticfilesystem:ClientWrite", + "sts:GetCallerIdentity", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + ] { + assert!( + granted.iter().any(|a| glob(a, need)), + "boundary does not grant {need}" + ); + } + // Access points fix the POSIX user; nothing needs root on EFS. + assert!( + !granted + .iter() + .any(|a| glob(a, "elasticfilesystem:ClientRootAccess")), + "boundary grants elasticfilesystem:ClientRootAccess" + ); + // Secrets only through Secrets Manager, and only Triple-C's; keys + // and file systems only the environment's (component-tagged). + for s in &stmts { + let acts = actions(s); + if acts.iter().any(|a| glob(a, "kms:Decrypt")) { + assert_eq!( + s["Condition"]["StringLike"]["kms:ViaService"], + "secretsmanager.*.amazonaws.com", + "{}", + sid(s) + ); + } + if acts + .iter() + .any(|a| glob(a, "kms:Decrypt") || glob("elasticfilesystem:Client*", a)) + { + assert_eq!( + s["Condition"]["Null"][COMPONENT_RESOURCE_TAG], + "false", + "{}: must require the triple-c:component resource tag", + sid(s) + ); + } + if acts + .iter() + .any(|a| glob(a, "secretsmanager:GetSecretValue")) + { + assert_eq!( + resources(s), + vec!["arn:aws:secretsmanager:*:*:secret:triple-c/*".to_string()], + "{}", + sid(s) + ); + } + } + } + + #[test] + fn writes_are_marked_and_reads_are_not() { + const READ_PREFIXES: &[&str] = &["Describe", "List", "Get", "Filter", "Simulate"]; + for tier in TIERS { + for c in checks(tier) { + let verb = c.action.split(':').nth(1).unwrap(); + let read = READ_PREFIXES.iter().any(|p| verb.starts_with(p)); + assert_eq!(c.write, !read, "{tier:?}: {} write flag", c.action); + } + } + } + + /// A secret owned by the environment (component-tagged) or by another + /// user can't be claimed, overwritten or deleted by a workspace user. + #[test] + fn workspace_users_cannot_claim_secrets_they_dont_own() { + let tier = Tier::WorkspaceUser; + const ENVIRONMENT_SECRET: Context = &[ + (OWNER_REQUEST_TAG, OwnerId), + (OWNER_RESOURCE_TAG, OwnerId), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ]; + const ENVIRONMENT_SECRET_UNOWNED: Context = &[ + (OWNER_REQUEST_TAG, OwnerId), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ]; + for action in [ + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "secretsmanager:PutSecretValue", + "secretsmanager:DeleteSecret", + ] { + for context in [ENVIRONMENT_SECRET, ENVIRONMENT_SECRET_UNOWNED] { + let c = write(action, PROBE_SECRET, context); + assert!( + !allowed(tier, &c, ME, ME), + "{action} allowed on a component-tagged secret with {context:?}" + ); + } + } + // The Allow alone already refuses to let a component-tagged secret + // be claimed; the Deny is defense in depth. + let claim = write( + "secretsmanager:TagResource", + PROBE_SECRET, + ENVIRONMENT_SECRET_UNOWNED, + ); + assert!( + !evaluate_in( + &allows(tier), + Request { + check: &claim, + caller: ME, + owner: ME + } + ), + "TagOwnSecrets alone allows claiming an environment secret" + ); + // Retagging another user's secret to yourself. + let steal = write( + "secretsmanager:TagResource", + PROBE_SECRET, + &[ + (OWNER_REQUEST_TAG, OwnerId), + (OWNER_RESOURCE_TAG, Literal(SOMEONE_ELSE)), + ], + ); + assert!(!allowed(tier, &steal, ME, ME)); + // Tag-on-create (no resource tags yet) still works. + let create = checks(tier) + .iter() + .find(|c| c.action == "secretsmanager:TagResource") + .unwrap(); + assert!(allowed(tier, create, ME, ME)); + } + + #[test] + fn access_points_only_on_the_environments_file_system() { + let c = checks(Tier::WorkspaceUser) + .iter() + .find(|c| c.action == "elasticfilesystem:CreateAccessPoint") + .unwrap(); + let foreign_fs = Check { + context: &[(OWNER_REQUEST_TAG, OwnerId)], + ..*c + }; + assert!(!allowed(Tier::WorkspaceUser, &foreign_fs, ME, ME)); + } + + const CFN_WRITES: &[&str] = &[ + "cloudformation:CreateStack", + "cloudformation:UpdateStack", + "cloudformation:CreateChangeSet", + ]; + const WITH_SERVICE_ROLE: Context = &[( + "cloudformation:RoleArn", + Literal("arn:aws:iam::{account}:role/AnyServiceRole"), + )]; + const OTHER_BOUNDARY: Context = &[ + ( + "iam:PermissionsBoundary", + Literal("arn:aws:iam::{account}:policy/SomethingElse"), + ), + ("aws:PrincipalAccount", Literal("{account}")), + ]; + + #[test] + fn triple_c_stacks_cannot_use_a_service_role() { + for action in CFN_WRITES { + let with_role = write(action, PROBE_STACK, WITH_SERVICE_ROLE); + assert!( + !allowed(Tier::EnvironmentAdmin, &with_role, ME, ME), + "{action} allowed with a CloudFormation service role" + ); + let plain = write(action, PROBE_STACK, &[]); + assert!(allowed(Tier::EnvironmentAdmin, &plain, ME, ME), "{action}"); + } + } + + /// True if some Deny statement in `tier` applies to `check`. + fn explicitly_denied(tier: Tier, check: &Check) -> bool { + let stmts = denies(tier); + let req = Request { + check, + caller: ME, + owner: ME, + }; + matching(&stmts, &req) + .iter() + .any(|s| conditions_hold(s, &req)) + } + + #[test] + fn a_triple_c_role_boundary_can_only_be_set_to_the_triple_c_boundary() { + let put = |context| write("iam:PutRolePermissionsBoundary", PROBE_ROLE, context); + assert!(explicitly_denied( + Tier::EnvironmentAdmin, + &put(OTHER_BOUNDARY) + )); + assert!(explicitly_denied(Tier::EnvironmentAdmin, &put(&[]))); + assert!(!explicitly_denied( + Tier::EnvironmentAdmin, + &put(WITH_BOUNDARY) + )); + } + + /// Admins attach both tiers; together they must still never yield a + /// `triple-c-*` role without the boundary. + #[test] + fn both_tiers_together_cannot_obtain_an_unbounded_role() { + let mut both = statements(Tier::WorkspaceUser); + both.extend(statements(Tier::EnvironmentAdmin)); + let denied = |c: &Check| { + !evaluate_in( + &both, + Request { + check: c, + caller: ME, + owner: ME, + }, + ) + }; + for action in ROLE_WRITES { + for context in [&[][..], OTHER_BOUNDARY] { + assert!( + denied(&write(action, PROBE_ROLE, context)), + "{action} with {context:?}" + ); + } + assert!( + !denied(&write(action, PROBE_ROLE, WITH_BOUNDARY)), + "{action}" + ); + } + for action in NEVER_ALLOWED { + for resource in [ + PROBE_ROLE, + "arn:aws:iam::{account}:policy/triple-c-permissions-boundary", + "*", + ] { + for context in [&[][..], WITH_BOUNDARY, OTHER_BOUNDARY] { + assert!( + denied(&write(action, resource, context)), + "{action} on {resource} with {context:?}" + ); + } + } + } + // A stack deployed with a service role would act outside the + // boundary. + for action in CFN_WRITES { + assert!(denied(&write(action, PROBE_STACK, WITH_SERVICE_ROLE))); + } + } + + const ALLOWED_TAG_KEYS: [&str; 3] = + ["triple-c:owner", "triple-c:project", "triple-c:environment"]; + + /// A check like `c` whose request also carries these tag keys. + fn with_tag_keys(c: &Check, keys: &[&'static str]) -> Check { + let mut context: Vec<(&'static str, ContextValue)> = c.context.to_vec(); + context.extend(keys.iter().map(|k| ("aws:TagKeys", Literal(k)))); + Check { + context: Box::leak(context.into_boxed_slice()), + ..*c + } + } + + /// The workspace checks that set tags: those carrying the owner request tag. + fn tagging_checks() -> Vec<&'static Check> { + let found: Vec<_> = checks(Tier::WorkspaceUser) + .iter() + .filter(|c| c.context.contains(&(OWNER_REQUEST_TAG, OwnerId))) + .collect(); + assert!( + found.len() >= 6, + "expected the six tagging checks, got {found:?}" + ); + found + } + + /// Every workspace statement that lets a user set the owner tag also + /// limits which tag keys they may set. + #[test] + fn workspace_tagging_statements_restrict_tag_keys() { + for s in allows(Tier::WorkspaceUser) { + let sets_owner = s["Condition"] + .as_object() + .is_some_and(|ops| ops.values().any(|e| e.get(OWNER_REQUEST_TAG).is_some())); + if !sets_owner { + continue; + } + let mut got = strings(&s["Condition"]["ForAllValues:StringEquals"]["aws:TagKeys"]); + got.sort(); + let mut want: Vec = ALLOWED_TAG_KEYS.iter().map(|k| k.to_string()).collect(); + want.sort(); + assert_eq!(got, want, "{}: aws:TagKeys", sid(&s)); + } + } + + /// `triple-c:component` marks environment resources, which detection + /// and the Denies trust; a workspace user must never be able to set it. + #[test] + fn workspace_users_cannot_set_the_component_tag() { + let tier = Tier::WorkspaceUser; + for c in tagging_checks() { + let spoofed = with_tag_keys(c, &["triple-c:owner", "triple-c:component"]); + assert!( + !allowed(tier, &spoofed, ME, ME), + "{} on {} allowed with a triple-c:component tag", + c.action, + c.resource + ); + let ordinary = with_tag_keys(c, &["triple-c:owner", "triple-c:project"]); + assert!( + allowed(tier, &ordinary, ME, ME), + "{} with owner and project tags", + c.action + ); + // The key restriction is not what grants: without the owner + // request tag, allowed keys alone are still denied. + let ownerless = Check { + context: Box::leak( + ordinary + .context + .iter() + .filter(|(k, _)| *k != OWNER_REQUEST_TAG) + .copied() + .collect::>() + .into_boxed_slice(), + ), + ..ordinary + }; + assert!( + !allowed(tier, &ownerless, ME, ME), + "{} allowed without the owner request tag", + c.action + ); + } + } + + #[test] + fn user_secrets_use_an_environment_key() { + let tier = Tier::WorkspaceUser; + let create = checks(tier) + .iter() + .find(|c| c.action == "secretsmanager:CreateSecret") + .unwrap(); + for context in [ + &[(OWNER_REQUEST_TAG, OwnerId)][..], + &[ + (OWNER_REQUEST_TAG, OwnerId), + ( + "secretsmanager:KmsKeyId", + Literal("alias/aws/secretsmanager"), + ), + ][..], + ] { + let c = Check { context, ..*create }; + assert!( + !allowed(tier, &c, ME, ME), + "CreateSecret allowed with {context:?}" + ); + } + for c in checks(tier).iter().filter(|c| c.action.starts_with("kms:")) { + let untagged_key = Check { + context: &[( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + )], + ..*c + }; + assert!( + !allowed(tier, &untagged_key, ME, ME), + "{} allowed on a key without triple-c:component", + c.action + ); + } + } + + #[test] + fn for_all_values_is_vacuous_on_an_absent_key() { + let stmt: Value = serde_json::json!({ + "Sid": "T", "Effect": "Allow", "Action": "x:Y", "Resource": "*", + "Condition": { "ForAllValues:StringEquals": { "aws:TagKeys": ["a", "b"] } } + }); + let check = |context| Check { + action: "x:Y", + resource: "*", + context, + write: true, + }; + let holds = |c: &Check| { + conditions_hold( + &stmt, + &Request { + check: c, + caller: ME, + owner: ME, + }, + ) + }; + assert!(holds(&check(&[]))); + assert!(holds(&check(&[ + ("aws:TagKeys", Literal("a")), + ("aws:TagKeys", Literal("b")) + ]))); + assert!(!holds(&check(&[ + ("aws:TagKeys", Literal("a")), + ("aws:TagKeys", Literal("c")) + ]))); + } + + /// A key whose *key policy* grants account principals through Secrets + /// Manager (the AWS-managed `aws/secretsmanager` key, or any such customer + /// key) needs no identity-policy Allow, so only an explicit Deny keeps a + /// workspace user's secrets on environment keys. + #[test] + fn untagged_keys_are_explicitly_denied_through_secrets_manager() { + let tier = Tier::WorkspaceUser; + const VIA_SECRETS_MANAGER: Context = &[( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + )]; + const VIA_SECRETS_MANAGER_ENVIRONMENT_KEY: Context = &[ + ( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + ), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ]; + for action in [ + "kms:Encrypt", + "kms:GenerateDataKey", + "kms:GenerateDataKeyWithoutPlaintext", + ] { + assert!( + explicitly_denied(tier, &write(action, PROBE_KEY, VIA_SECRETS_MANAGER)), + "{action} on an untagged key via Secrets Manager is not explicitly denied" + ); + assert!( + !explicitly_denied( + tier, + &write(action, PROBE_KEY, VIA_SECRETS_MANAGER_ENVIRONMENT_KEY) + ), + "{action} on an environment key must not be denied" + ); + } + // The checks themselves (environment key) stay allowed. + for c in checks(tier).iter().filter(|c| c.action.starts_with("kms:")) { + assert!(allowed(tier, c, ME, ME), "{}", c.action); + } + } +} diff --git a/app/src-tauri/src/commands/aws_commands.rs b/app/src-tauri/src/commands/aws_commands.rs index 73ba069..70be2e6 100644 --- a/app/src-tauri/src/commands/aws_commands.rs +++ b/app/src-tauri/src/commands/aws_commands.rs @@ -52,7 +52,8 @@ pub async fn run_sso_login(profile: &str) -> Result<(), String> { log::info!("Running host-side AWS SSO login for profile '{}'", profile); let status = tokio::process::Command::new("aws") - .args(["sso", "login", "--profile", profile]) + // One argv element, so a name starting with `-` is never an option. + .args(["sso", "login", &format!("--profile={profile}")]) .status() .await .map_err(|e| format!("Failed to run aws sso login: {}", e))?; diff --git a/app/src-tauri/src/commands/host_commands.rs b/app/src-tauri/src/commands/host_commands.rs new file mode 100644 index 0000000..a099795 --- /dev/null +++ b/app/src-tauri/src/commands/host_commands.rs @@ -0,0 +1,525 @@ +//! Commands for host connections (AWS): add, update, remove, identity, +//! sign-in and the permission check. + +use std::future::Future; + +use tauri::State; + +use crate::aws::permissions::{check_permissions, PermissionReport, SdkPermissionApi}; +use crate::aws::policies::{policy_json, Tier}; +use crate::aws::{caller_identity, sdk_config, sdk_config_for_keys, AwsError, CallerIdentity}; +use crate::models::{ + validate_connection_name, validate_profile_name, validate_supported_region, AppSettings, AwsAuth, + HostConnection, HostKind, +}; +use crate::storage::secure::{self, AwsKeys}; +use crate::storage::settings_store::SettingsStore; +use crate::AppState; + +fn other(message: impl Into) -> AwsError { + AwsError::Other { message: message.into() } +} + +fn check_id(id: &str) -> Result<(), String> { + if secure::is_valid_connection_id(id) { + Ok(()) + } else { + Err("Invalid connection id".to_string()) + } +} + +/// What the identity check is asked to authenticate with. +pub(crate) enum Credentials { + /// A named host profile (the connection itself). + Profile(HostConnection), + /// Keys not yet in the keychain, and the region to call in. + Keys(AwsKeys, String), +} + +/// Validate-free core of both add commands: `GetCallerIdentity` first, and +/// only on success keys are stored and the connection saved. An expired SSO +/// session on a profile still saves (the UI then offers "Sign in"). +pub(crate) async fn add_connection_with( + conn: HostConnection, + keys: Option, + identity: I, + store_keys: impl Fn(&str, &AwsKeys) -> Result<(), String>, + delete_keys: impl Fn(&str) -> Result<(), String>, + settings: &SettingsStore, +) -> Result +where + I: FnOnce(Credentials) -> Fut, + Fut: Future>, +{ + let HostKind::Aws { auth, default_region } = &conn.kind; + let credentials = match (&keys, auth) { + (Some(k), AwsAuth::Keys { .. }) => Credentials::Keys(k.clone(), default_region.clone()), + (None, AwsAuth::Profile { .. }) => Credentials::Profile(conn.clone()), + _ => return Err(other("Connection credentials do not match its auth method")), + }; + match identity(credentials).await { + Ok(_) => {} + Err(AwsError::SsoExpired { .. }) if keys.is_none() => {} + Err(e) => return Err(e), + } + + if let Some(k) = &keys { + store_keys(&conn.id, k).map_err(other)?; + } + if let Err(e) = settings.modify(|all| { + all.hosts.push(conn.clone()); + Ok(()) + }) { + if keys.is_some() { + let _ = delete_keys(&conn.id); + } + return Err(other(e)); + } + Ok(conn) +} + +/// Removes the stored connection with this id, however malformed its other +/// fields are (a broken entry must stay removable). Its keychain entry is +/// deleted only for a `Keys` connection whose `keyring_ref` is its own valid id. +pub(crate) fn remove_connection_with( + id: &str, + delete_keys: impl Fn(&str) -> Result<(), String>, + settings: &SettingsStore, +) -> Result { + check_id(id)?; + let existing = settings + .get() + .hosts + .into_iter() + .find(|h| h.id == id) + .ok_or_else(|| "Connection not found".to_string())?; + let HostKind::Aws { auth, .. } = &existing.kind; + if let AwsAuth::Keys { keyring_ref } = auth { + if keyring_ref == id && secure::is_valid_connection_id(keyring_ref) { + delete_keys(keyring_ref)?; + } + } + settings + .modify(|s| { + s.hosts.retain(|h| h.id != id); + Ok(()) + }) + .map(|(saved, ())| saved) +} + +async fn real_identity(c: Credentials) -> Result { + let cfg = match c { + Credentials::Profile(conn) => { + let HostKind::Aws { default_region, .. } = &conn.kind; + sdk_config(&conn, default_region).await? + } + Credentials::Keys(keys, region) => sdk_config_for_keys(&keys, ®ion), + }; + caller_identity(&cfg).await +} + +fn aws_conn(id: &str, settings: &AppSettings) -> Result { + check_id(id)?; + let conn = settings + .hosts + .iter() + .find(|h| h.id == id) + .cloned() + .ok_or_else(|| "Connection not found".to_string())?; + // Defense in depth: stored values are re-checked before they reach the + // keychain, the SDK or a subprocess. + let HostKind::Aws { auth, default_region } = &conn.kind; + validate_supported_region(default_region)?; + match auth { + AwsAuth::Profile { name } => { + validate_profile_name(name)?; + } + AwsAuth::Keys { keyring_ref } if keyring_ref != id => { + return Err("Connection's keychain reference does not match its id".to_string()); + } + AwsAuth::Keys { .. } => {} + } + Ok(conn) +} + +fn validated(name: &str, region: &str) -> Result<(String, String), AwsError> { + Ok(( + validate_connection_name(name).map_err(other)?, + validate_supported_region(region).map_err(other)?, + )) +} + +#[tauri::command] +pub async fn add_aws_profile_connection( + name: String, + profile: String, + default_region: String, + state: State<'_, AppState>, +) -> Result { + let (name, default_region) = validated(&name, &default_region)?; + let profile = validate_profile_name(&profile).map_err(other)?; + let conn = HostConnection { + id: uuid::Uuid::new_v4().to_string(), + name, + kind: HostKind::Aws { auth: AwsAuth::Profile { name: profile }, default_region }, + }; + add_connection_with( + conn, + None, + real_identity, + secure::store_aws_keys, + secure::delete_aws_keys, + &state.settings_store, + ) + .await +} + +#[tauri::command] +pub async fn add_aws_keys_connection( + name: String, + access_key_id: String, + secret_access_key: String, + session_token: Option, + default_region: String, + state: State<'_, AppState>, +) -> Result { + let (name, default_region) = validated(&name, &default_region)?; + let access_key_id = access_key_id.trim().to_string(); + let secret_access_key = secret_access_key.trim().to_string(); + let session_token = session_token.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + if access_key_id.is_empty() || secret_access_key.is_empty() { + return Err(other("Access key ID and secret access key are required")); + } + let id = uuid::Uuid::new_v4().to_string(); + let conn = HostConnection { + id: id.clone(), + name, + kind: HostKind::Aws { auth: AwsAuth::Keys { keyring_ref: id }, default_region }, + }; + let keys = AwsKeys { access_key_id, secret_access_key, session_token }; + add_connection_with( + conn, + Some(keys), + real_identity, + secure::store_aws_keys, + secure::delete_aws_keys, + &state.settings_store, + ) + .await +} + +#[tauri::command] +pub async fn update_aws_connection( + id: String, + name: String, + default_region: String, + state: State<'_, AppState>, +) -> Result { + let (name, region) = validated(&name, &default_region)?; + let (_, updated) = state + .settings_store + .modify(|settings| { + aws_conn(&id, settings)?; + let conn = settings.hosts.iter_mut().find(|h| h.id == id).expect("checked above"); + conn.name = name; + let HostKind::Aws { default_region, .. } = &mut conn.kind; + *default_region = region; + Ok(conn.clone()) + }) + .map_err(other)?; + Ok(updated) +} + +#[tauri::command] +pub async fn remove_host_connection( + id: String, + state: State<'_, AppState>, +) -> Result { + remove_connection_with(&id, secure::delete_aws_keys, &state.settings_store) +} + +#[tauri::command] +pub async fn aws_connection_identity( + id: String, + state: State<'_, AppState>, +) -> Result { + let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?; + let HostKind::Aws { default_region, .. } = &conn.kind; + let cfg = sdk_config(&conn, default_region).await?; + caller_identity(&cfg).await +} + +#[tauri::command] +pub async fn aws_connection_sso_login( + id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let conn = aws_conn(&id, &state.settings_store.get())?; + let HostKind::Aws { auth, .. } = &conn.kind; + let AwsAuth::Profile { name } = auth else { + return Err("Only profile connections use SSO".to_string()); + }; + let name = validate_profile_name(name)?; + super::aws_commands::run_sso_login(&name).await +} + +#[tauri::command] +pub async fn check_aws_permissions( + id: String, + region: Option, + state: State<'_, AppState>, +) -> Result { + let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?; + let HostKind::Aws { default_region, .. } = &conn.kind; + let region = match region { + Some(r) => validate_supported_region(&r).map_err(other)?, + None => default_region.clone(), + }; + let cfg = sdk_config(&conn, ®ion).await?; + let identity = caller_identity(&cfg).await?; + let api = SdkPermissionApi::new(&cfg); + check_permissions(&api, &identity, ®ion).await +} + +#[tauri::command] +pub fn aws_policy_document(tier: Tier) -> String { + policy_json(tier).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + fn profile_conn() -> HostConnection { + HostConnection { + id: "c1".into(), + name: "Prod".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { name: "dev".into() }, + default_region: "us-west-2".into(), + }, + } + } + + fn keys_conn() -> HostConnection { + HostConnection { + id: "c2".into(), + name: "K".into(), + kind: HostKind::Aws { + auth: AwsAuth::Keys { keyring_ref: "c2".into() }, + default_region: "us-west-2".into(), + }, + } + } + + fn keys() -> AwsKeys { + AwsKeys { access_key_id: "AKIA".into(), secret_access_key: "s".into(), session_token: None } + } + + fn ident() -> CallerIdentity { + CallerIdentity { account: "1".into(), arn: "arn:aws:iam::1:user/u".into(), user_id: "U".into() } + } + + fn store_in(dir: &tempfile::TempDir) -> SettingsStore { + SettingsStore::at_path(dir.path().join("settings.json")) + } + + #[tokio::test] + async fn adding_saves_nothing_when_identity_fails() { + let dir = tempfile::tempdir().unwrap(); + let settings = store_in(&dir); + let calls = RefCell::new(Vec::::new()); + let err = add_connection_with( + keys_conn(), + Some(keys()), + |_| async { Err(AwsError::InvalidCredentials { message: "bad".into() }) }, + |id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) }, + |id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) }, + &settings, + ) + .await + .unwrap_err(); + assert!(matches!(err, AwsError::InvalidCredentials { .. })); + assert!(settings.get().hosts.is_empty()); + assert!(!dir.path().join("settings.json").exists()); + assert!(calls.borrow().is_empty(), "{:?}", calls.borrow()); + } + + #[tokio::test] + async fn an_sso_expired_profile_is_still_saved() { + let dir = tempfile::tempdir().unwrap(); + let settings = store_in(&dir); + let saved = add_connection_with( + profile_conn(), + None, + |_| async { Err(AwsError::SsoExpired { profile: "dev".into() }) }, + |_, _| Ok(()), + |_| Ok(()), + &settings, + ) + .await + .unwrap(); + assert_eq!(settings.get().hosts, vec![saved]); + } + + #[tokio::test] + async fn a_successful_keys_add_stores_keys_then_settings() { + let dir = tempfile::tempdir().unwrap(); + let settings = store_in(&dir); + let calls = RefCell::new(Vec::::new()); + let saved = add_connection_with( + keys_conn(), + Some(keys()), + |c| async move { + assert!(matches!(c, Credentials::Keys(..))); + Ok(ident()) + }, + |id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) }, + |id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) }, + &settings, + ) + .await + .unwrap(); + assert_eq!(*calls.borrow(), vec!["store c2".to_string()]); + assert_eq!(settings.get().hosts, vec![saved]); + } + + #[tokio::test] + async fn keys_are_removed_if_saving_settings_fails() { + let dir = tempfile::tempdir().unwrap(); + // The parent directory does not exist, so saving fails. + let settings = SettingsStore::at_path(dir.path().join("missing/settings.json")); + let calls = RefCell::new(Vec::::new()); + let err = add_connection_with( + keys_conn(), + Some(keys()), + |_| async { Ok(ident()) }, + |id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) }, + |id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) }, + &settings, + ) + .await + .unwrap_err(); + assert!(matches!(err, AwsError::Other { .. })); + assert_eq!(*calls.borrow(), vec!["store c2".to_string(), "delete c2".to_string()]); + assert!(settings.get().hosts.is_empty(), "no unsaved connection in memory"); + } + + #[tokio::test] + async fn a_non_sso_identity_error_on_a_profile_saves_nothing() { + let dir = tempfile::tempdir().unwrap(); + let settings = store_in(&dir); + let err = add_connection_with( + profile_conn(), + None, + |_| async { Err(AwsError::ProfileNotFound { profile: "dev".into() }) }, + |_, _| Ok(()), + |_| Ok(()), + &settings, + ) + .await + .unwrap_err(); + assert!(matches!(err, AwsError::ProfileNotFound { .. })); + assert!(settings.get().hosts.is_empty()); + assert!(!dir.path().join("settings.json").exists()); + } + + #[tokio::test] + async fn a_failing_store_keys_saves_nothing() { + let dir = tempfile::tempdir().unwrap(); + let settings = store_in(&dir); + let deletes = RefCell::new(0); + let err = add_connection_with( + keys_conn(), + Some(keys()), + |_| async { Ok(ident()) }, + |_, _| Err("keychain locked".to_string()), + |_| { *deletes.borrow_mut() += 1; Ok(()) }, + &settings, + ) + .await + .unwrap_err(); + assert!(matches!(err, AwsError::Other { .. })); + assert!(settings.get().hosts.is_empty()); + assert!(!dir.path().join("settings.json").exists()); + } + + #[test] + fn a_keys_connection_whose_keyring_ref_differs_from_its_id_is_refused() { + let mut s = AppSettings::default(); + let mut c = keys_conn(); + c.kind = HostKind::Aws { + auth: AwsAuth::Keys { keyring_ref: "other".into() }, + default_region: "us-west-2".into(), + }; + s.hosts.push(c); + assert!(aws_conn("c2", &s).is_err()); + s.hosts = vec![keys_conn()]; + assert!(aws_conn("c2", &s).is_ok()); + } + + #[test] + fn stored_regions_and_profiles_are_revalidated() { + let with = |profile: &str, region: &str| { + let mut c = profile_conn(); + c.kind = HostKind::Aws { + auth: AwsAuth::Profile { name: profile.into() }, + default_region: region.into(), + }; + let mut s = AppSettings::default(); + s.hosts.push(c); + s + }; + assert!(aws_conn("c1", &with("dev", "us-west-2")).is_ok()); + assert!(aws_conn("c1", &with("dev", "not a region")).is_err()); + assert!(aws_conn("c1", &with("a b", "us-west-2")).is_err()); + } + + #[test] + fn a_connection_with_an_invalid_stored_region_can_still_be_removed() { + let dir = tempfile::tempdir().unwrap(); + let settings = store_in(&dir); + let mut c = keys_conn(); + c.kind = HostKind::Aws { + auth: AwsAuth::Keys { keyring_ref: "c2".into() }, + default_region: "not a region".into(), + }; + settings.update(AppSettings { hosts: vec![c, profile_conn()], ..AppSettings::default() }).unwrap(); + let deleted = RefCell::new(Vec::::new()); + let after = remove_connection_with( + "c2", + |id| { deleted.borrow_mut().push(id.to_string()); Ok(()) }, + &settings, + ) + .unwrap(); + assert_eq!(after.hosts, vec![profile_conn()]); + assert_eq!(settings.get().hosts, vec![profile_conn()]); + assert_eq!(*deleted.borrow(), vec!["c2".to_string()]); + } + + #[test] + fn removal_leaves_the_keychain_alone_when_keyring_ref_is_not_the_id() { + let dir = tempfile::tempdir().unwrap(); + let settings = store_in(&dir); + let mut c = keys_conn(); + c.kind = HostKind::Aws { + auth: AwsAuth::Keys { keyring_ref: "someone-else".into() }, + default_region: "us-west-2".into(), + }; + settings.update(AppSettings { hosts: vec![c], ..AppSettings::default() }).unwrap(); + remove_connection_with("c2", |_| panic!("must not touch the keychain"), &settings).unwrap(); + assert!(settings.get().hosts.is_empty()); + assert!(remove_connection_with("c2", |_| Ok(()), &settings).is_err()); + assert!(remove_connection_with("bad id!", |_| Ok(()), &settings).is_err()); + } + + #[test] + fn china_and_govcloud_regions_are_refused_for_connections() { + for r in ["cn-north-1", "us-gov-west-1"] { + let e = validated("Prod", r).unwrap_err(); + assert!(matches!(&e, AwsError::Other { message } if message == "China and GovCloud regions aren't supported yet."), "{e:?}"); + } + assert!(validated("Prod", "eu-west-1").is_ok()); + } +} diff --git a/app/src-tauri/src/commands/marketplace_commands.rs b/app/src-tauri/src/commands/marketplace_commands.rs index c59a127..b7dfca4 100644 --- a/app/src-tauri/src/commands/marketplace_commands.rs +++ b/app/src-tauri/src/commands/marketplace_commands.rs @@ -173,10 +173,7 @@ pub(crate) mod ops { /// directories, so an id from outside (an import) must be the shape the /// commands mint: a UUID-like `[A-Za-z0-9-]{1,64}`. pub fn validate_id(id: &str) -> Result<(), String> { - let ok = !id.is_empty() - && id.len() <= 64 - && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'); - if ok { + if crate::storage::secure::is_valid_connection_id(id) { Ok(()) } else { Err("An account or marketplace id is malformed.".to_string()) diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 565cfc7..b7660e0 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod file_commands; pub mod file_viewer_commands; pub mod gateway_commands; pub mod help_commands; +pub mod host_commands; pub mod inspect_commands; pub mod install_helper_commands; pub mod marketplace_commands; diff --git a/app/src-tauri/src/commands/settings_commands.rs b/app/src-tauri/src/commands/settings_commands.rs index 850de0a..9fc4d9a 100644 --- a/app/src-tauri/src/commands/settings_commands.rs +++ b/app/src-tauri/src/commands/settings_commands.rs @@ -78,6 +78,13 @@ pub(crate) fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &Ap incoming.global_marketplace_installs = stored.global_marketplace_installs.clone(); } +/// Host connections change only through `host_commands` (which validates +/// them and owns their keychain entries) and settings import, never through +/// a settings save the frontend posts back. +pub(crate) fn restore_host_fields(incoming: &mut AppSettings, stored: &AppSettings) { + incoming.hosts = stored.hosts.clone(); +} + #[tauri::command] pub async fn update_settings( mut settings: AppSettings, @@ -87,6 +94,7 @@ pub async fn update_settings( validate_settings_update(&before, &settings)?; restore_marketplace_fields(&mut settings, &before); + restore_host_fields(&mut settings, &before); let saved = state.settings_store.update(settings)?; @@ -466,4 +474,27 @@ mod tests { "the edit the save was for still applies" ); } + + #[test] + fn update_settings_cannot_change_hosts() { + use crate::models::{AwsAuth, HostConnection, HostKind}; + let conn = |id: &str, region: &str| HostConnection { + id: id.into(), + name: "Prod".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { name: "dev".into() }, + default_region: region.into(), + }, + }; + let mut stored = AppSettings::default(); + stored.hosts.push(conn("c1", "us-west-2")); + let mut incoming = AppSettings::default(); + incoming.hosts = vec![conn("c1", "not a region"), conn("evil", "us-east-1")]; + incoming.auto_check_updates = false; + + restore_host_fields(&mut incoming, &stored); + + assert_eq!(incoming.hosts, stored.hosts); + assert!(!incoming.auto_check_updates); + } } diff --git a/app/src-tauri/src/commands/settings_export_commands.rs b/app/src-tauri/src/commands/settings_export_commands.rs index b219389..b3be7e7 100644 --- a/app/src-tauri/src/commands/settings_export_commands.rs +++ b/app/src-tauri/src/commands/settings_export_commands.rs @@ -43,6 +43,7 @@ use zeroize::Zeroizing; use std::collections::BTreeMap; +use crate::models::host_connection::{AwsAuth, HostConnection, HostKind}; use crate::models::marketplace::{AccountMethod, MarketplaceAccount}; use crate::models::{ AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome, @@ -138,6 +139,7 @@ fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSec &settings.marketplace_accounts, secure::get_marketplace_token, ), + aws_connection_keys: exported_aws_connection_keys(&settings.hosts, secure::get_aws_keys), }; (settings, secrets) @@ -184,6 +186,107 @@ fn restore_marketplace_tokens( warnings } +/// The stored keys of every `Keys` host connection, by connection id, as the +/// `AwsKeys` JSON. A `Profile` connection holds none. A missing or unreadable +/// entry is left out, like the other keychain secrets above. +fn exported_aws_connection_keys( + hosts: &[HostConnection], + get: impl Fn(&str) -> Result, String>, +) -> BTreeMap { + hosts + .iter() + .filter(|h| matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. })) + .filter_map(|h| { + let keys = get(&h.id).unwrap_or_default()?; + Some((h.id.clone(), serde_json::to_string(&keys).ok()?)) + }) + .collect() +} + +/// Write each imported set of AWS keys to the keychain, returning a warning +/// (naming the connection id, never the value) for each that could not be. +fn restore_aws_connection_keys( + keys: &BTreeMap, + mut store: impl FnMut(&str, &secure::AwsKeys) -> Result<(), String>, +) -> Vec { + let mut warnings = Vec::new(); + for (id, json) in keys { + if json.trim().is_empty() { + continue; + } + let Ok(parsed) = serde_json::from_str::(json) else { + log::warn!("Settings import: the AWS keys of connection {} are malformed", id); + warnings.push(format!( + "The AWS access keys of connection {} in the file are malformed; enter them again.", + id + )); + continue; + }; + if let Err(e) = store(id, &parsed) { + log::warn!( + "Settings import: could not restore the AWS keys of connection {}: {}", + id, + e + ); + warnings.push(format!( + "Could not restore the AWS access keys of connection {} ({}); enter them again.", + id, e + )); + } + } + warnings +} + +/// Checks the imported host connections with the rules the connection +/// commands use, before anything is written; refuses the import (like +/// `validate_imported_marketplace_state`) rather than applying a bad entry. +/// A `Keys` connection's `keyring_ref` must be its own (valid) id, and keys +/// may only be carried for a `Keys` connection in the file. +fn validate_imported_hosts( + settings: &mut AppSettings, + keys: &BTreeMap, +) -> Result<(), String> { + use crate::models::host_connection::{ + validate_connection_name, validate_profile_name, validate_supported_region, + }; + let wrap = |e: String| format!("The file's host connections were refused: {e}"); + + let mut seen = std::collections::HashSet::new(); + for h in &mut settings.hosts { + if !secure::is_valid_connection_id(&h.id) { + return Err(wrap("a connection has an invalid id.".into())); + } + if !seen.insert(h.id.clone()) { + return Err(wrap("a connection appears twice.".into())); + } + h.name = validate_connection_name(&h.name).map_err(wrap)?; + let HostKind::Aws { auth, default_region } = &h.kind; + validate_supported_region(default_region).map_err(wrap)?; + match auth { + AwsAuth::Profile { name } => { + validate_profile_name(name).map_err(wrap)?; + } + AwsAuth::Keys { keyring_ref } => { + if !secure::is_valid_connection_id(keyring_ref) || *keyring_ref != h.id { + return Err(wrap(format!( + "the connection \"{}\" has an invalid key reference.", + h.name + ))); + } + } + } + } + for id in keys.keys() { + let is_keys_connection = settings.hosts.iter().any(|h| { + h.id == *id && matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. }) + }); + if !is_keys_connection { + return Err(wrap("access keys are present for an unknown connection.".into())); + } + } + Ok(()) +} + /// Export the current global settings and secrets to a password-encrypted /// file. `Ok(false)` means the save dialog was dismissed — not an error, and /// deliberately distinguishable from one so the frontend shows nothing @@ -260,7 +363,8 @@ pub async fn preview_settings_import( let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?; let payload = read_and_decrypt_bytes(&encrypted, &password)?; - let preview = SettingsImportPreview::from_payload(&payload); + let local_hosts = state.settings_store.get().hosts; + let preview = SettingsImportPreview::from_payload_against(&payload, &local_hosts, secure::aws_keys_entry_state); *state.pending_settings_import.lock().await = Some(PendingSettingsImport { path, @@ -376,6 +480,9 @@ pub async fn apply_settings_import( &marketplace_tokens, )?; + let aws_keys = payload.secrets.aws_connection_keys; + validate_imported_hosts(&mut settings, &aws_keys)?; + let mut secret_restore_warnings = Vec::new(); let mut gateway_secret_changed = false; @@ -423,24 +530,20 @@ pub async fn apply_settings_import( secure::store_marketplace_token, )); - let imported_marketplace = ( - settings.marketplace_accounts.clone(), - settings.marketplaces.clone(), - settings.global_marketplace_installs.clone(), - ); + secret_restore_warnings.extend(restore_aws_connection_keys( + &aws_keys, + secure::store_aws_keys, + )); + + let imported_owned = StoreOwned::of(&settings); let saved = crate::commands::settings_commands::update_settings(settings, state.clone()).await?; - // `update_settings` keeps marketplace state store-owned. An import is the - // one caller entitled to replace it wholesale. - let saved = { - let mut s = saved; - ( - s.marketplace_accounts, - s.marketplaces, - s.global_marketplace_installs, - ) = imported_marketplace; - state.settings_store.update(s)? - }; + // `update_settings` keeps marketplace state and host connections + // store-owned. An import is the one caller entitled to replace them + // wholesale. + let saved = state + .settings_store + .update(imported_owned.applied_to(saved))?; // Caches of marketplaces the import dropped are dead weight now, and // the pins must match the imported installs. use crate::commands::marketplace_commands as mc; @@ -481,6 +584,34 @@ pub async fn apply_settings_import( }) } +/// The settings `update_settings` keeps store-owned (marketplace state and +/// host connections), which an import re-applies after that call. +struct StoreOwned { + marketplace_accounts: Vec, + marketplaces: Vec, + global_marketplace_installs: Vec, + hosts: Vec, +} + +impl StoreOwned { + fn of(s: &AppSettings) -> Self { + Self { + marketplace_accounts: s.marketplace_accounts.clone(), + marketplaces: s.marketplaces.clone(), + global_marketplace_installs: s.global_marketplace_installs.clone(), + hosts: s.hosts.clone(), + } + } + + fn applied_to(self, mut s: AppSettings) -> AppSettings { + s.marketplace_accounts = self.marketplace_accounts; + s.marketplaces = self.marketplaces; + s.global_marketplace_installs = self.global_marketplace_installs; + s.hosts = self.hosts; + s + } +} + fn non_blank(value: Option) -> Option { value.filter(|v| !v.trim().is_empty()) } @@ -827,4 +958,141 @@ mod tests { assert_eq!(warnings.len(), 1); assert!(!warnings[0].contains("test-token-not-real")); } + + fn keys_host(id: &str) -> HostConnection { + HostConnection { + id: id.into(), + name: "Prod".into(), + kind: HostKind::Aws { + auth: AwsAuth::Keys { keyring_ref: id.into() }, + default_region: "us-west-2".into(), + }, + } + } + + fn profile_host(id: &str) -> HostConnection { + HostConnection { + id: id.into(), + name: "Dev".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { name: "default".into() }, + default_region: "us-west-2".into(), + }, + } + } + + fn test_keys() -> secure::AwsKeys { + secure::AwsKeys { + access_key_id: "AKIATESTNOTREAL".into(), + secret_access_key: "test-secret-not-real".into(), + session_token: None, + } + } + + #[test] + fn aws_connection_keys_round_trip_through_export() { + let hosts = vec![keys_host("k1"), profile_host("p1")]; + let exported = exported_aws_connection_keys(&hosts, |id| match id { + "k1" => Ok(Some(test_keys())), + other => panic!("only a Keys connection is read, got {other}"), + }); + assert_eq!(exported.keys().collect::>(), vec!["k1"]); + assert_eq!(serde_json::from_str::(&exported["k1"]).unwrap(), test_keys()); + + let mut stored = Vec::new(); + let warnings = restore_aws_connection_keys(&exported, |id, keys| { + stored.push((id.to_string(), keys.clone())); + Ok(()) + }); + assert!(warnings.is_empty()); + assert_eq!(stored, vec![("k1".to_string(), test_keys())]); + } + + #[test] + fn aws_connection_keys_restore_failures_name_the_id_not_the_value() { + let keys = BTreeMap::from([ + ("bad".to_string(), "not json test-secret-not-real".to_string()), + ("locked".to_string(), serde_json::to_string(&test_keys()).unwrap()), + ]); + let warnings = restore_aws_connection_keys(&keys, |_, _| Err("keychain locked".into())); + assert_eq!(warnings.len(), 2); + assert!(warnings[0].contains("bad")); + assert!(warnings[1].contains("locked")); + assert!(warnings.iter().all(|w| !w.contains("test-secret-not-real"))); + } + + #[test] + fn imported_hosts_are_validated() { + let check = |hosts: Vec, keys: BTreeMap| { + let mut s = AppSettings::default(); + s.hosts = hosts; + validate_imported_hosts(&mut s, &keys) + }; + let none = BTreeMap::new(); + assert!(check(vec![keys_host("k1"), profile_host("p1")], none.clone()).is_ok()); + assert!(check(vec![keys_host("k1")], BTreeMap::from([("k1".into(), "{}".into())])).is_ok()); + + let mut bad_name = profile_host("p1"); + bad_name.name = " ".into(); + assert!(check(vec![bad_name], none.clone()).is_err()); + + let mut bad_region = profile_host("p1"); + bad_region.kind = HostKind::Aws { + auth: AwsAuth::Profile { name: "default".into() }, + default_region: "nowhere".into(), + }; + assert!(check(vec![bad_region], none.clone()).is_err()); + + let mut cn = profile_host("p1"); + cn.kind = HostKind::Aws { + auth: AwsAuth::Profile { name: "default".into() }, + default_region: "cn-north-1".into(), + }; + let err = check(vec![cn], none.clone()).unwrap_err(); + assert!(err.contains("China and GovCloud regions aren't supported yet."), "{err}"); + + let mut bad_profile = profile_host("p1"); + bad_profile.kind = HostKind::Aws { + auth: AwsAuth::Profile { name: "a b".into() }, + default_region: "us-west-2".into(), + }; + assert!(check(vec![bad_profile], none.clone()).is_err()); + + let mut other_ref = keys_host("k1"); + other_ref.kind = HostKind::Aws { + auth: AwsAuth::Keys { keyring_ref: "someone-else".into() }, + default_region: "us-west-2".into(), + }; + assert!(check(vec![other_ref], none.clone()).is_err()); + + assert!(check(vec![profile_host("../x")], none.clone()).is_err()); + assert!(check(vec![profile_host("p1"), profile_host("p1")], none.clone()).is_err()); + // Keys carried for a connection that is not a Keys connection. + assert!(check(vec![profile_host("p1")], BTreeMap::from([("p1".into(), "{}".into())])).is_err()); + assert!(check(vec![], BTreeMap::from([("ghost".into(), "{}".into())])).is_err()); + } + + #[test] + fn imported_hosts_survive_apply() { + use crate::models::{AwsAuth, HostConnection, HostKind}; + let host = HostConnection { + id: "h1".into(), + name: "Imported".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { name: "dev".into() }, + default_region: "us-west-2".into(), + }, + }; + let stored = AppSettings::default(); + let mut imported = AppSettings::default(); + imported.hosts.push(host.clone()); + + let owned = StoreOwned::of(&imported); + // What `update_settings` does to the incoming settings. + let mut after_update = imported.clone(); + crate::commands::settings_commands::restore_host_fields(&mut after_update, &stored); + assert!(after_update.hosts.is_empty(), "update_settings drops hosts"); + + assert_eq!(owned.applied_to(after_update).hosts, vec![host]); + } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index dd06e6a..89f59fb 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod auth_bridge; +mod aws; mod browser_view; #[cfg(test)] mod command_census; @@ -593,6 +594,15 @@ pub fn run() { commands::settings_commands::detect_aws_config, commands::settings_commands::inspect_ca_cert_path, commands::settings_commands::list_aws_profiles, + // AWS host connections + commands::host_commands::add_aws_profile_connection, + commands::host_commands::add_aws_keys_connection, + commands::host_commands::update_aws_connection, + commands::host_commands::remove_host_connection, + commands::host_commands::aws_connection_identity, + commands::host_commands::aws_connection_sso_login, + commands::host_commands::check_aws_permissions, + commands::host_commands::aws_policy_document, commands::settings_commands::detect_host_timezone, // Settings export/import commands::settings_export_commands::export_settings, diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 18ffa3a..e7df06f 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use super::gateway_settings::GatewaySettings; +use super::host_connection::HostConnection; use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall}; use super::project::{ClaudeCodeSettings, EnvVar}; @@ -143,6 +144,9 @@ pub struct AppSettings { /// Marketplace git repos the user added. #[serde(default)] pub marketplaces: Vec, + /// Remote host connections (AWS). Secrets live in the OS keychain, never here. + #[serde(default)] + pub hosts: Vec, /// Items installed for every project (projects may opt out per item). #[serde(default)] pub global_marketplace_installs: Vec, @@ -259,6 +263,7 @@ impl Default for AppSettings { global_claude_code_settings: None, marketplace_accounts: Vec::new(), marketplaces: Vec::new(), + hosts: Vec::new(), global_marketplace_installs: Vec::new(), terminal_gpu_rendering: None, } diff --git a/app/src-tauri/src/models/host_connection.rs b/app/src-tauri/src/models/host_connection.rs new file mode 100644 index 0000000..5936d78 --- /dev/null +++ b/app/src-tauri/src/models/host_connection.rs @@ -0,0 +1,181 @@ +//! Remote host connections (currently AWS). Only non-secret data lives here; +//! access keys are held in the OS keychain and referenced by `keyring_ref`. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostConnection { + pub id: String, + pub name: String, + pub kind: HostKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HostKind { + Aws { auth: AwsAuth, default_region: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "method", rename_all = "snake_case")] +pub enum AwsAuth { + /// A named profile from the host's AWS config. + Profile { name: String }, + /// Static keys stored in the OS keychain under this reference. + Keys { keyring_ref: String }, +} + +/// Unicode format characters (category Cf): invisible, but able to reorder or +/// hide text (bidi overrides, zero-width spaces). `char::is_control` is Cc only. +fn is_format_char(c: char) -> bool { + matches!(c as u32, + 0x00AD | 0x0600..=0x0605 | 0x061C | 0x06DD | 0x070F | 0x180E + | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x206F + | 0xFEFF | 0xFFF9..=0xFFFB | 0xE0001 | 0xE0020..=0xE007F) +} + +/// Trimmed, 1..=64 characters, no control or invisible format characters. +pub fn validate_connection_name(name: &str) -> Result { + let name = name.trim(); + let len = name.chars().count(); + if len == 0 || len > 64 { + return Err("Connection name must be 1 to 64 characters".into()); + } + if name.chars().any(|c| c.is_control() || is_format_char(c)) { + return Err("Connection name must not contain control or invisible formatting characters".into()); + } + Ok(name.to_string()) +} + +/// An AWS region id such as `us-west-2` or `us-gov-west-1`. +pub fn validate_region(region: &str) -> Result { + let b = region.as_bytes(); + let ok = (|| { + // ^[a-z]{2}(-[a-z]+)+-\d+$ + if b.len() < 2 || !b[..2].iter().all(u8::is_ascii_lowercase) { + return false; + } + let mut parts = region[2..].split('-'); + if parts.next() != Some("") { + return false; + } + let rest: Vec<&str> = parts.collect(); + let Some((last, words)) = rest.split_last() else { + return false; + }; + !words.is_empty() + && words + .iter() + .all(|w| !w.is_empty() && w.bytes().all(|c| c.is_ascii_lowercase())) + && !last.is_empty() + && last.bytes().all(|c| c.is_ascii_digit()) + })(); + if ok { + Ok(region.to_string()) + } else { + Err(format!("\"{}\" is not a valid AWS region", region.escape_debug())) + } +} + +/// A region Triple-C's policies and checks can work with. Every policy and check +/// template hard-codes the `aws` partition (`arn:aws:`), so China, GovCloud and +/// the isolated partitions would always check as Insufficient. `validate_region` +/// stays general (it only checks the shape); connection add/update/import use this. +pub fn validate_supported_region(region: &str) -> Result { + let region = validate_region(region)?; + if ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"] + .iter() + .any(|p| region.starts_with(p)) + { + return Err("China and GovCloud regions aren't supported yet.".into()); + } + Ok(region) +} + +/// Non-empty, at most 128 characters of `[A-Za-z0-9_.@+=,-]`. +pub fn validate_profile_name(name: &str) -> Result { + if name.is_empty() || name.len() > 128 { + return Err("Profile name must be 1 to 128 characters".into()); + } + if !name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_.@+=,-".contains(&c)) + { + return Err("Profile name contains characters AWS profiles do not allow".into()); + } + Ok(name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::AppSettings; + + #[test] + fn settings_without_hosts_deserialize_to_empty() { + let mut json = serde_json::to_value(AppSettings::default()).unwrap(); + json.as_object_mut().unwrap().remove("hosts"); + let s: AppSettings = serde_json::from_value(json).unwrap(); + assert_eq!(s.hosts, vec![]); + } + + #[test] + fn a_profile_connection_round_trips() { + let c = HostConnection { + id: "h1".into(), + name: "Prod".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { name: "default".into() }, + default_region: "us-west-2".into(), + }, + }; + let s = serde_json::to_string(&c).unwrap(); + assert!(s.contains("\"type\":\"aws\""), "{s}"); + assert!(s.contains("\"method\":\"profile\""), "{s}"); + assert_eq!(serde_json::from_str::(&s).unwrap(), c); + } + + #[test] + fn regions_are_validated() { + for ok in ["us-west-2", "eu-central-1", "cn-north-1", "us-gov-west-1"] { + assert_eq!(validate_region(ok).unwrap(), ok); + } + for ok in ["us-west-2", "eu-central-1", "ap-southeast-4"] { + assert_eq!(validate_supported_region(ok).unwrap(), ok); + } + for other in ["cn-north-1", "cn-northwest-1", "us-gov-west-1", "us-gov-east-1"] { + assert_eq!( + validate_supported_region(other).unwrap_err(), + "China and GovCloud regions aren't supported yet." + ); + } + assert!(validate_supported_region("nope").unwrap_err().contains("not a valid")); + for bad in ["", "US-WEST-2", "us-west", "us-west-2; rm"] { + assert!(validate_region(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn profile_names_are_validated() { + for ok in ["default", "340315884407_StaffAdministratorAccess"] { + assert_eq!(validate_profile_name(ok).unwrap(), ok); + } + for bad in ["", "a b", "x\n"] { + assert!(validate_profile_name(bad).is_err(), "{bad:?}"); + } + assert!(validate_profile_name(&"a".repeat(129)).is_err()); + assert!(validate_profile_name(&"a".repeat(128)).is_ok()); + } + + #[test] + fn connection_names_are_validated() { + assert_eq!(validate_connection_name(" Prod ").unwrap(), "Prod"); + assert!(validate_connection_name(" ").is_err()); + assert!(validate_connection_name("a\tb").is_err()); + for c in ['\u{202E}', '\u{200B}', '\u{2066}', '\u{2069}', '\u{FEFF}'] { + assert!(validate_connection_name(&format!("ab{c}cd")).is_err(), "{c:?}"); + } + assert!(validate_connection_name("Prod é 日本").is_ok()); + assert!(validate_connection_name(&"a".repeat(65)).is_err()); + } +} diff --git a/app/src-tauri/src/models/mod.rs b/app/src-tauri/src/models/mod.rs index bd6b344..fa8065d 100644 --- a/app/src-tauri/src/models/mod.rs +++ b/app/src-tauri/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod app_settings; pub mod container_config; pub mod gateway_settings; +pub mod host_connection; pub mod marketplace; pub mod migration; pub mod note; @@ -11,6 +12,7 @@ pub mod update_info; pub use app_settings::*; pub use container_config::*; pub use gateway_settings::*; +pub use host_connection::*; pub use migration::*; pub use note::*; pub use project::*; diff --git a/app/src-tauri/src/models/settings_export.rs b/app/src-tauri/src/models/settings_export.rs index 85396cd..715cbea 100644 --- a/app/src-tauri/src/models/settings_export.rs +++ b/app/src-tauri/src/models/settings_export.rs @@ -64,6 +64,11 @@ pub struct ExportedSecrets { /// here or an imported account could never fetch. #[serde(default)] pub marketplace_account_tokens: BTreeMap, + /// Access keys of `AwsAuth::Keys` host connections, keyed by connection + /// id; each value is the `AwsKeys` JSON. Like the tokens above they live + /// in the keychain, not in `AppSettings::hosts`. + #[serde(default)] + pub aws_connection_keys: BTreeMap, } impl ExportedSecrets { @@ -74,6 +79,7 @@ impl ExportedSecrets { && blank(&self.gateway_master_key) && blank(&self.web_terminal_access_token) && self.marketplace_account_tokens.values().all(|v| v.trim().is_empty()) + && self.aws_connection_keys.values().all(|v| v.trim().is_empty()) } } @@ -171,6 +177,69 @@ pub struct SettingsImportPreview { /// Non-blank marketplace account tokens the import restores. #[serde(default)] pub marketplace_account_token_count: usize, + /// The AWS connections the import brings. Applied wholesale, so a crafted + /// export could otherwise add or replace one unannounced. Names and + /// profile names are cleaned; no key values, only the count below. + #[serde(default)] + pub aws_connections: Vec, + /// Non-blank AWS access-key entries the import restores to the keychain. + #[serde(default)] + pub aws_connection_key_count: usize, + /// Local connections the import drops, because it replaces the whole list. + /// Their keychain entries, if any, are left behind. + #[serde(default)] + pub removed_aws_connections: Vec, +} + +/// Cleaned, non-secret description of one connection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AwsConnectionSummary { + pub name: String, + /// `"profile"` or `"keys"`. + pub auth: String, + pub profile_name: Option, + pub default_region: String, +} + +impl AwsConnectionSummary { + fn of(h: &crate::models::host_connection::HostConnection) -> Self { + use crate::models::host_connection::{AwsAuth, HostKind}; + let HostKind::Aws { auth, default_region } = &h.kind; + let (auth_kind, profile_name) = match auth { + AwsAuth::Profile { name } => ("profile", Some(sanitize_for_preview(name))), + AwsAuth::Keys { .. } => ("keys", None), + }; + Self { + name: sanitize_for_preview(&h.name), + auth: auth_kind.to_string(), + profile_name, + default_region: sanitize_for_preview(default_region), + } + } +} + +/// One imported AWS connection as the confirmation dialog shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AwsConnectionPreview { + pub name: String, + /// `"profile"` or `"keys"`. + pub auth: String, + /// The profile name, for a `profile` connection. + pub profile_name: Option, + pub default_region: String, + /// A connection with this id exists here with different content. + pub overwrites_existing: bool, + /// The import carries keys for this id and a keychain entry for it + /// already exists here (its value is never read into the preview). + pub overwrites_keys: bool, + /// The import carries keys for this id and the keychain could not be + /// asked whether some are stored already. + #[serde(default)] + pub keys_check_failed: bool, + /// The local connection with this id, when the import replaces it, so an + /// imported name cannot hide which connection is being replaced. + #[serde(default)] + pub local: Option, } /// A cap on how much of a decrypted, not-yet-trusted string gets echoed back @@ -195,7 +264,55 @@ fn sanitize_for_preview(value: &str) -> String { } impl SettingsImportPreview { + #[cfg(test)] pub fn from_payload(payload: &SettingsExportPayload) -> Self { + Self::from_payload_against(payload, &[], |_| Some(false)) + } + + /// Builds the preview, also flagging what the import would overwrite or + /// drop: `local_hosts` are the connections held now and `local_has_keys` + /// says whether a keychain entry exists for an id (`None`: could not be + /// determined). + pub fn from_payload_against( + payload: &SettingsExportPayload, + local_hosts: &[crate::models::host_connection::HostConnection], + local_has_keys: impl Fn(&str) -> Option, + ) -> Self { + let carried_keys = |id: &str| { + payload + .secrets + .aws_connection_keys + .get(id) + .is_some_and(|v| !v.trim().is_empty()) + }; + let aws_connections = payload + .settings + .hosts + .iter() + .map(|h| { + let summary = AwsConnectionSummary::of(h); + let local = local_hosts.iter().find(|l| l.id == h.id); + let overwrites_existing = local.is_some_and(|l| l != h); + let key_state = if carried_keys(&h.id) { local_has_keys(&h.id) } else { Some(false) }; + AwsConnectionPreview { + name: summary.name, + auth: summary.auth, + profile_name: summary.profile_name, + default_region: summary.default_region, + overwrites_existing, + overwrites_keys: key_state == Some(true), + keys_check_failed: key_state.is_none(), + local: local + .filter(|_| overwrites_existing || key_state != Some(false)) + .map(AwsConnectionSummary::of), + } + }) + .collect(); + let removed_aws_connections = local_hosts + .iter() + .filter(|l| !payload.settings.hosts.iter().any(|h| h.id == l.id)) + .map(AwsConnectionSummary::of) + .collect(); let non_blank = |s: &Option| s.as_deref().is_some_and(|v| !v.trim().is_empty()); let sanitized_non_blank = |s: &Option| { s.as_deref() @@ -240,6 +357,14 @@ impl SettingsImportPreview { .values() .filter(|v| !v.trim().is_empty()) .count(), + aws_connections, + aws_connection_key_count: payload + .secrets + .aws_connection_keys + .values() + .filter(|v| !v.trim().is_empty()) + .count(), + removed_aws_connections, } } } @@ -477,4 +602,98 @@ mod tests { assert_eq!(preview.global_plugin_install_count, 2); assert_eq!(preview.global_hook_install_count, 1); } + + fn aws_host(id: &str, name: &str, keys: bool) -> crate::models::host_connection::HostConnection { + use crate::models::host_connection::*; + HostConnection { + id: id.into(), + name: name.into(), + kind: HostKind::Aws { + auth: if keys { + AwsAuth::Keys { keyring_ref: id.into() } + } else { + AwsAuth::Profile { name: "prod-profile".into() } + }, + default_region: "us-west-2".into(), + }, + } + } + + #[test] + fn import_preview_discloses_aws_connections_and_key_count() { + let mut payload = payload_with(ExportedSecrets { + aws_connection_keys: BTreeMap::from([ + ("k1".to_string(), "{\"secret_access_key\":\"test-secret-not-real\"}".to_string()), + ("k2".to_string(), " ".to_string()), + ]), + ..Default::default() + }); + payload.settings.hosts = vec![aws_host("k1", "Ke\nys", true), aws_host("p1", "Prof", false)]; + let preview = SettingsImportPreview::from_payload(&payload); + assert_eq!(preview.aws_connection_key_count, 1); + assert_eq!(preview.aws_connections.len(), 2); + assert_eq!(preview.aws_connections[0].name, "Keys"); + assert_eq!(preview.aws_connections[0].auth, "keys"); + assert_eq!(preview.aws_connections[1].auth, "profile"); + assert_eq!(preview.aws_connections[1].profile_name.as_deref(), Some("prod-profile")); + assert_eq!(preview.aws_connections[1].default_region, "us-west-2"); + assert!(!serde_json::to_string(&preview).unwrap().contains("test-secret-not-real")); + } + + #[test] + fn import_preview_flags_aws_overwrites() { + let mut payload = payload_with(ExportedSecrets { + aws_connection_keys: BTreeMap::from([("k1".to_string(), "{}".to_string())]), + ..Default::default() + }); + payload.settings.hosts = vec![ + aws_host("k1", "Keys", true), + aws_host("same", "Same", false), + aws_host("changed", "New name", false), + aws_host("fresh", "Fresh", false), + ]; + let local = vec![aws_host("same", "Same", false), aws_host("changed", "Old name", false)]; + let preview = + SettingsImportPreview::from_payload_against(&payload, &local, |id| Some(id == "k1")); + let flags: Vec<_> = preview + .aws_connections + .iter() + .map(|c| (c.overwrites_existing, c.overwrites_keys)) + .collect(); + assert_eq!(flags, vec![(false, true), (false, false), (true, false), (false, false)]); + } + + #[test] + fn aws_preview_names_the_local_connection_and_discloses_removals_and_failed_checks() { + let mut payload = payload_with(ExportedSecrets { + aws_connection_keys: BTreeMap::from([ + ("k1".to_string(), "{}".to_string()), + ("k2".to_string(), "{}".to_string()), + ]), + ..Default::default() + }); + payload.settings.hosts = vec![ + aws_host("k1", "Innocent", true), + aws_host("k2", "Other", true), + aws_host("p1", "Fresh Name", false), + ]; + let local = vec![ + aws_host("k1", "Real Prod", true), + aws_host("p1", "Real Dev", false), + aws_host("gone", "Dropped\n", false), + ]; + let preview = SettingsImportPreview::from_payload_against(&payload, &local, |id| match id { + "k1" => Some(true), + _ => None, + }); + let c = &preview.aws_connections; + assert!(c[0].overwrites_keys && !c[0].keys_check_failed); + assert_eq!(c[0].local.as_ref().unwrap().name, "Real Prod"); + assert!(c[1].keys_check_failed && !c[1].overwrites_keys && c[1].local.is_none()); + assert!(c[2].overwrites_existing); + assert_eq!(c[2].local.as_ref().unwrap().name, "Real Dev"); + assert_eq!(c[2].local.as_ref().unwrap().profile_name.as_deref(), Some("prod-profile")); + assert_eq!(preview.removed_aws_connections.len(), 1); + assert_eq!(preview.removed_aws_connections[0].name, "Dropped"); + } } diff --git a/app/src-tauri/src/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index ef68da2..e487103 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -167,7 +167,12 @@ fn read_entry(service: &str, label: &str) -> Result, String> { fn delete_entry(service: &str, label: &str) -> Result<(), String> { let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT) .map_err(|e| format!("Keyring error: {}", e))?; - match entry.delete_credential() { + delete_outcome(entry.delete_credential(), label) +} + +/// "Wasn't there" is success; any other failure names the entry, not its value. +fn delete_outcome(result: Result<(), keyring::Error>, label: &str) -> Result<(), String> { + match result { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(format!("Failed to delete {}: {}", label, e)), } @@ -379,10 +384,7 @@ const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-"; /// The service name for one account. Ids are uuids; anything else is refused /// before a keychain entry is constructed. fn marketplace_token_service(account_id: &str) -> Result { - let ok = !account_id.is_empty() - && account_id.len() <= 64 - && account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'); - if !ok { + if !is_valid_connection_id(account_id) { return Err(format!("Invalid marketplace account id {:?}", account_id)); } Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id)) @@ -408,6 +410,90 @@ pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> { delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token") } +// ───────────────────────────────────────────────────────────────────────────── +// AWS connection access keys (global, one entry per connection) +// ───────────────────────────────────────────────────────────────────────────── + +/// Keychain service prefix; the connection id completes it. +const AWS_CONNECTION_SERVICE_PREFIX: &str = "triple-c-aws-connection-"; + +/// Static AWS credentials for a `HostConnection` using `AwsAuth::Keys`. Held in +/// the keychain as one JSON value. `Debug` redacts the secret and the token. +#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct AwsKeys { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: Option, +} + +impl std::fmt::Debug for AwsKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AwsKeys") + .field("access_key_id", &self.access_key_id) + .field("secret_access_key", &"") + .field("session_token", &self.session_token.as_ref().map(|_| "")) + .finish() + } +} + +/// Whether `id` may be used as a connection id / keyring reference: 1..=64 of +/// `[A-Za-z0-9-]`. The single definition of the keychain-id rule: marketplace +/// account ids, AWS connection ids and `commands::marketplace_commands`'s +/// `validate_id` all go through it. +pub fn is_valid_connection_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') +} + +fn aws_connection_service(connection_id: &str) -> Result { + if !is_valid_connection_id(connection_id) { + return Err(format!("Invalid AWS connection id {:?}", connection_id)); + } + Ok(format!("{}{}", AWS_CONNECTION_SERVICE_PREFIX, connection_id)) +} + +pub fn store_aws_keys(connection_id: &str, keys: &AwsKeys) -> Result<(), String> { + let service = aws_connection_service(connection_id)?; + let json = serde_json::to_string(keys) + .map_err(|_| "Failed to encode the AWS access keys".to_string())?; + let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + entry + .set_password(&json) + .map_err(|e| format!("Failed to store the AWS access keys: {}", e)) +} + +pub fn get_aws_keys(connection_id: &str) -> Result, String> { + let Some(json) = read_entry(&aws_connection_service(connection_id)?, "the AWS access keys")? + else { + return Ok(None); + }; + serde_json::from_str(&json) + .map(Some) + .map_err(|_| "The stored AWS access keys are unreadable".to_string()) +} + +/// Whether an entry exists for the connection, readable or not. `None` means +/// the keychain could not be asked, so callers must not read it as "absent". +/// Never returns or logs a value. +pub fn aws_keys_entry_state(connection_id: &str) -> Option { + let svc = aws_connection_service(connection_id).ok()?; + let entry = keyring::Entry::new(&svc, KEYCHAIN_ACCOUNT).ok()?; + match entry.get_password() { + Ok(_) => Some(true), + Err(keyring::Error::NoEntry) => Some(false), + // Present but not a plain string, etc.: something is there. + Err(keyring::Error::BadEncoding(_)) => Some(true), + Err(_) => None, + } +} + +/// Missing entry is success. +pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> { + delete_entry(&aws_connection_service(connection_id)?, "the AWS access keys") +} + #[cfg(test)] mod tests { use super::*; @@ -496,4 +582,39 @@ mod tests { // Pasted credentials routinely carry a trailing newline. assert_eq!(secret_to_store(Some(" ghp_abc123\n")), Some("ghp_abc123")); } + + #[test] + fn aws_keys_debug_redacts_secrets() { + let keys = AwsKeys { + access_key_id: "AKIATESTNOTREAL".into(), + secret_access_key: "test-secret-not-real".into(), + session_token: Some("test-session-token-not-real".into()), + }; + let shown = format!("{:?}", keys); + assert!(!shown.contains("test-secret-not-real"), "{shown}"); + assert!(!shown.contains("test-session-token-not-real"), "{shown}"); + } + + #[test] + fn aws_key_service_names_reject_bad_ids() { + for bad in ["../x", "", &"a".repeat(65)] { + assert!(aws_connection_service(bad).is_err(), "{bad:?}"); + assert!(get_aws_keys(bad).is_err()); + assert!(delete_aws_keys(bad).is_err()); + } + assert_eq!( + aws_connection_service("0b9e6a2c-1111").unwrap(), + "triple-c-aws-connection-0b9e6a2c-1111" + ); + } + + /// There is no mock keyring here, so the "no entry is fine" rule is tested + /// as the pure mapping `delete_entry` applies. + #[test] + fn removing_a_keys_connection_with_no_keyring_entry_succeeds() { + assert_eq!(delete_outcome(Err(keyring::Error::NoEntry), "x"), Ok(())); + assert_eq!(delete_outcome(Ok(()), "x"), Ok(())); + let err = delete_outcome(Err(keyring::Error::NoStorageAccess("denied".into())), "x"); + assert!(err.is_err()); + } } diff --git a/app/src-tauri/src/storage/settings_store.rs b/app/src-tauri/src/storage/settings_store.rs index c41dbf1..0c970d2 100644 --- a/app/src-tauri/src/storage/settings_store.rs +++ b/app/src-tauri/src/storage/settings_store.rs @@ -47,6 +47,12 @@ impl SettingsStore { }) } + /// A store over an explicit file, for tests (no data-dir lookup). + #[cfg(test)] + pub(crate) fn at_path(file_path: PathBuf) -> Self { + Self { settings: Mutex::new(AppSettings::default()), file_path } + } + fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> { self.settings.lock().unwrap_or_else(|e| e.into_inner()) } @@ -69,8 +75,77 @@ impl SettingsStore { pub fn update(&self, new_settings: AppSettings) -> Result { let mut settings = self.lock(); + // Save first: a failed save must not leave the unsaved copy in memory. + self.save(&new_settings)?; *settings = new_settings.clone(); - self.save(&settings)?; Ok(new_settings) } + + /// Read-modify-save under the store's lock, so concurrent callers cannot + /// lose each other's updates. `f` edits a copy; if it fails, or the save + /// fails, memory and disk are unchanged. Returns the saved settings and + /// whatever `f` returned. + pub fn modify(&self, f: F) -> Result<(AppSettings, R), String> + where + F: FnOnce(&mut AppSettings) -> Result, + { + let mut settings = self.lock(); + let mut copy = settings.clone(); + let out = f(&mut copy)?; + self.save(©)?; + *settings = copy.clone(); + Ok((copy, out)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn modify_saves_and_returns_the_closure_result() { + let dir = tempfile::tempdir().unwrap(); + let store = SettingsStore::at_path(dir.path().join("settings.json")); + let (saved, n) = store + .modify(|s| { + s.hosts.clear(); + Ok(7) + }) + .unwrap(); + assert_eq!(n, 7); + assert_eq!( + serde_json::to_value(&saved).unwrap(), + serde_json::to_value(store.get()).unwrap() + ); + assert!(dir.path().join("settings.json").exists()); + } + + #[test] + fn modify_does_not_change_memory_when_save_fails() { + let dir = tempfile::tempdir().unwrap(); + let store = SettingsStore::at_path(dir.path().join("missing/settings.json")); + let before = serde_json::to_value(store.get()).unwrap(); + let err = store.modify(|s| { + s.hosts.push(crate::models::HostConnection { + id: "x".into(), + name: "x".into(), + kind: crate::models::HostKind::Aws { + auth: crate::models::AwsAuth::Profile { name: "p".into() }, + default_region: "us-west-2".into(), + }, + }); + Ok(()) + }); + assert!(err.is_err()); + assert_eq!(serde_json::to_value(store.get()).unwrap(), before); + } + + #[test] + fn modify_does_not_save_when_the_closure_fails() { + let dir = tempfile::tempdir().unwrap(); + let store = SettingsStore::at_path(dir.path().join("settings.json")); + let err = store.modify(|_| -> Result<(), String> { Err("no".into()) }).unwrap_err(); + assert_eq!(err, "no"); + assert!(!dir.path().join("settings.json").exists()); + } } diff --git a/app/src/components/settings/HostsSettings.test.tsx b/app/src/components/settings/HostsSettings.test.tsx new file mode 100644 index 0000000..75444fb --- /dev/null +++ b/app/src/components/settings/HostsSettings.test.tsx @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import HostsSettings from "./HostsSettings"; +import { useAppState } from "../../store/appState"; +import type { AppSettings, HostConnection, PermissionReport } from "../../lib/types"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args?: unknown) => invoke(cmd, args), +})); + +const conn: HostConnection = { + id: "h1", + name: "Prod", + kind: { type: "aws", auth: { method: "profile", name: "prod-sso" }, default_region: "eu-west-1" }, +}; +const keysConn: HostConnection = { + id: "h2", + name: "Keys box", + kind: { type: "aws", auth: { method: "keys", keyring_ref: "r" }, default_region: "us-east-1" }, +}; +const identity = { account: "123456789012", arn: "arn:aws:iam::123456789012:user/me", user_id: "U" }; + +function report(over: Partial): PermissionReport { + return { + identity, + region: "eu-west-1", + method: "simulated", + summary: "insufficient", + tiers: [ + { tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "denied", note: null }] }, + { tier: "environment_admin", results: [{ action: "iam:CreateRole", verdict: "denied", note: null }] }, + ], + ...over, + }; +} + +function setHosts(hosts: HostConnection[]) { + useAppState.setState({ appSettings: { hosts } as unknown as AppSettings }); +} + +describe("HostsSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + setHosts([conn, keysConn]); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return ["dev", "prod-sso"]; + if (cmd === "get_settings") return { hosts: [conn, keysConn] }; + throw new Error(`unexpected ${cmd}`); + }); + }); + + it("shows Local Docker and existing AWS connections", async () => { + render(); + await screen.findByRole("option", { name: "dev" }); + expect(screen.getByText("Local Docker")).toBeInTheDocument(); + expect(screen.getByText("Prod")).toBeInTheDocument(); + expect(screen.getByText("Profile: prod-sso")).toBeInTheDocument(); + expect(within(screen.getByTestId("host-h2")).getByText("Access keys")).toBeInTheDocument(); + expect(within(screen.getByTestId("host-h1")).getByText("eu-west-1")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /^Check/ })).toHaveLength(2); + expect(screen.queryByRole("button", { name: "Remove Local Docker" })).toBeNull(); + }); + + it("adds a profile connection and shows the signed-in identity", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return ["dev", "prod-sso"]; + if (cmd === "add_aws_profile_connection") return conn; + if (cmd === "aws_connection_identity") return identity; + if (cmd === "get_settings") return { hosts: [conn] }; + throw new Error(`unexpected ${cmd}`); + }); + render(); + await screen.findByRole("option", { name: "dev" }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } }); + fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } }); + fireEvent.change(screen.getByLabelText("Default region"), { target: { value: "eu-west-1" } }); + fireEvent.click(screen.getByRole("button", { name: "Add connection" })); + expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("add_aws_profile_connection", { + name: "Dev", + profile: "dev", + defaultRegion: "eu-west-1", + }); + }); + + it("offers Sign in when the profile's SSO session expired", async () => { + let identityCalls = 0; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return ["dev"]; + if (cmd === "add_aws_profile_connection") return conn; + if (cmd === "aws_connection_identity") { + if (identityCalls++ === 0) throw { kind: "sso_expired", profile: "dev" }; + return identity; + } + if (cmd === "aws_connection_sso_login") return undefined; + if (cmd === "get_settings") return { hosts: [conn] }; + throw new Error(`unexpected ${cmd}`); + }); + render(); + await screen.findByRole("option", { name: "dev" }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } }); + fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } }); + fireEvent.click(screen.getByRole("button", { name: "Add connection" })); + expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("aws_connection_sso_login", { id: "h1" }); + // The connection was added once; only the failed step was retried. + expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1); + }); + + it("access key inputs are password fields and the session token is optional", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "add_aws_keys_connection") return keysConn; + if (cmd === "aws_connection_identity") return identity; + if (cmd === "get_settings") return { hosts: [keysConn] }; + throw new Error(`unexpected ${cmd}`); + }); + render(); + fireEvent.click(screen.getByRole("radio", { name: "Access keys" })); + const id = screen.getByLabelText("Access key ID"); + const secret = screen.getByLabelText("Secret access key"); + const token = screen.getByLabelText(/Session token/); + for (const el of [id, secret, token]) { + expect(el).toHaveAttribute("type", "password"); + expect(el).toHaveAttribute("autocomplete", "off"); + } + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "K" } }); + fireEvent.change(id, { target: { value: "AKIA" } }); + fireEvent.change(secret, { target: { value: "shh" } }); + fireEvent.click(screen.getByRole("button", { name: "Add connection" })); + await screen.findByText(`Signed in as ${identity.arn}`); + expect(invoke).toHaveBeenCalledWith( + "add_aws_keys_connection", + expect.objectContaining({ accessKeyId: "AKIA", secretAccessKey: "shh", sessionToken: null }), + ); + expect(screen.getByLabelText("Access key ID")).toHaveValue(""); + expect(screen.getByLabelText("Secret access key")).toHaveValue(""); + }); + + it("check shows Insufficient with the denied actions and the copy-policy buttons", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "check_aws_permissions") return report({}); + if (cmd === "aws_policy_document") return '{"Version":"2012-10-17"}'; + throw new Error(`unexpected ${cmd}`); + }); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); + expect(await screen.findByText("Insufficient")).toBeInTheDocument(); + expect(screen.getByText("ecs:RunTask")).toBeInTheDocument(); + expect(screen.getByText(/SCPs or session policies/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Copy workspace-user policy" })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"Version":"2012-10-17"}')); + expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "workspace_user" }); + expect(screen.getByRole("button", { name: "Copy admin policy" })).toBeInTheDocument(); + }); + + it("shows a neutral Incomplete badge when the only blockers are failed checks", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "check_aws_permissions") + return report({ + tiers: [ + { tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "throttled" }] }, + { tier: "environment_admin", results: [] }, + ], + }); + throw new Error(`unexpected ${cmd}`); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); + const badge = await screen.findByText("Incomplete — check again"); + expect(badge.className).not.toContain("--error"); + expect(screen.queryByText("Insufficient")).not.toBeInTheDocument(); + expect(screen.getByText(/Some checks couldn't complete/)).toBeInTheDocument(); + }); + + it("explains denied own-principal checks as the first thing to ask an admin for", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "check_aws_permissions") + return report({ + tiers: [ + { tier: "workspace_user", results: [{ action: "iam:SimulatePrincipalPolicy", verdict: "denied", note: null }] }, + { tier: "environment_admin", results: [] }, + ], + }); + throw new Error(`unexpected ${cmd}`); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); + expect(await screen.findByText(/couldn't inspect the caller's own permissions/)).toBeInTheDocument(); + }); + + it("shows throttling notes with a check-again hint", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "check_aws_permissions") + return report({ + tiers: [ + { tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "Throttling" }] }, + { tier: "environment_admin", results: [] }, + ], + }); + throw new Error(`unexpected ${cmd}`); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); + expect(await screen.findByText(/Throttling/)).toBeInTheDocument(); + expect(screen.getAllByText(/Check again/).length).toBeGreaterThan(0); + }); + + it("a probed check says writes are unverified", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "check_aws_permissions") + return report({ + method: { probed: { reason: "iam:SimulatePrincipalPolicy denied" } }, + summary: "workspace_user", + }); + throw new Error(`unexpected ${cmd}`); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); + expect(await screen.findByText("Workspace user (unverified)")).toBeInTheDocument(); + expect( + screen.getByText( + "Simulation not permitted (iam:SimulatePrincipalPolicy denied). Read access was probed; write actions are unverified and will be confirmed on first use.", + ), + ).toBeInTheDocument(); + }); + + it("asks for confirmation before removing", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "remove_host_connection") return { hosts: [keysConn] }; + throw new Error(`unexpected ${cmd}`); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Remove Prod" })); + expect(invoke).not.toHaveBeenCalledWith("remove_host_connection", expect.anything()); + fireEvent.click(screen.getByRole("button", { name: "Confirm remove Prod" })); + await waitFor(() => expect(screen.queryByText("Prod")).toBeNull()); + expect(invoke).toHaveBeenCalledWith("remove_host_connection", { id: "h1" }); + }); + + it("never renders a no-op Sign in when the add itself rejects with sso_expired", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return ["dev"]; + if (cmd === "add_aws_profile_connection") throw { kind: "sso_expired", profile: "dev" }; + throw new Error(`unexpected ${cmd}`); + }); + render(); + await screen.findByRole("option", { name: "dev" }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } }); + fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } }); + fireEvent.click(screen.getByRole("button", { name: "Add connection" })); + expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Sign in" })).toBeNull(); + expect(screen.getByText("aws sso login --profile dev")).toBeInTheDocument(); + }); + + it("never re-runs the add when the settings refresh fails after a successful add", async () => { + let settingsCalls = 0; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return ["dev"]; + if (cmd === "add_aws_profile_connection") return conn; + if (cmd === "get_settings") { + if (settingsCalls++ === 0) throw "boom"; + return { hosts: [conn] }; + } + if (cmd === "aws_connection_identity") return identity; + throw new Error(`unexpected ${cmd}`); + }); + render(); + await screen.findByRole("option", { name: "dev" }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } }); + fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } }); + fireEvent.click(screen.getByRole("button", { name: "Add connection" })); + expect(await screen.findByText(/could not be refreshed/)).toBeInTheDocument(); + expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1); + }); + + it("shows a message when the profile list cannot be read", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") throw "no config"; + throw new Error(`unexpected ${cmd}`); + }); + render(); + expect(await screen.findByText(/Couldn't read your AWS profiles: no config/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "Access keys" })); + expect(screen.getByLabelText("Access key ID")).toBeInTheDocument(); + }); + + it("shows a message when copying the policy fails", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "check_aws_permissions") return report({}); + if (cmd === "aws_policy_document") return "{}"; + throw new Error(`unexpected ${cmd}`); + }); + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); + fireEvent.click(await screen.findByRole("button", { name: "Copy admin policy" })); + expect(await screen.findByText(/Could not copy the policy: denied/)).toBeInTheDocument(); + }); + + it.each([ + [{ kind: "profile_not_found", profile: "ghost" }, "Profile ghost was not found in your AWS config, or has no credentials."], + [ + { kind: "keys_missing" }, + "The access keys for this connection are missing from the keychain. Remove and re-add the connection.", + ], + ])("a failed Check shows its error: %j", async (err, text) => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "check_aws_permissions") throw err; + throw new Error(`unexpected ${cmd}`); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); + expect(await screen.findByText(text)).toBeInTheDocument(); + expect(screen.queryByTestId("permission-report")).toBeNull(); + }); + + it("moves focus to Confirm when removing, and back to Remove on cancel", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + throw new Error(`unexpected ${cmd}`); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Remove Prod" })); + expect(screen.getByRole("button", { name: "Confirm remove Prod" })).toHaveFocus(); + fireEvent.click(within(screen.getByTestId("host-h1")).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Remove Prod" })).toHaveFocus()); + }); +}); diff --git a/app/src/components/settings/HostsSettings.tsx b/app/src/components/settings/HostsSettings.tsx new file mode 100644 index 0000000..5cd885a --- /dev/null +++ b/app/src/components/settings/HostsSettings.tsx @@ -0,0 +1,554 @@ +import { useEffect, useRef, useState } from "react"; +import { useSettings } from "../../hooks/useSettings"; +import { + addAwsKeysConnection, + addAwsProfileConnection, + awsConnectionIdentity, + awsConnectionSsoLogin, + awsPolicyDocument, + checkAwsPermissions, + listAwsProfiles, + removeHostConnection, + updateAwsConnection, +} from "../../lib/tauri-commands"; +import { toAwsError } from "../../lib/aws-error"; +import type { + AwsError, + HostConnection, + PermissionReport, + PermissionSummary, + Tier, +} from "../../lib/types"; +import Button from "../ui/Button"; +import SegmentedControl from "../ui/SegmentedControl"; +import Field, { inputClass, selectClass } from "../ui/Field"; + +const COMMON_REGIONS = [ + "us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1", + "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1", + "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1", + "sa-east-1", +]; + +const OWN_PRINCIPAL_ACTIONS = ["iam:GetRole", "iam:SimulatePrincipalPolicy"]; + +/** Human-readable copy for a failed AWS command; never raw JSON. */ +export function awsErrorMessage(e: AwsError): string { + switch (e.kind) { + case "sso_expired": + return `Your SSO session for profile ${e.profile} has expired.`; + case "profile_not_found": + return `Profile ${e.profile} was not found in your AWS config, or has no credentials.`; + case "keys_missing": + return "The access keys for this connection are missing from the keychain. Remove and re-add the connection."; + default: + return e.message; + } +} + +const SUMMARY_LABEL: Record = { + environment_admin: "Environment admin", + workspace_user: "Workspace user", + insufficient: "Insufficient", +}; + +const SUMMARY_STYLE: Record = { + environment_admin: "text-[var(--success)] border-[var(--success)]/40", + workspace_user: "text-[var(--accent)] border-[var(--accent)]/40", + insufficient: "text-[var(--error)] border-[var(--error)]/40", +}; + +type AuthChoice = "profile" | "keys"; + +/** An error with the step that failed, so Sign in can retry just that step. */ +interface Failure { + error: AwsError; + retry: () => Promise; +} + +function ErrorLine({ + failure, + onSignIn, + signingIn, +}: { + failure: Failure; + /** Absent when there is no saved connection to sign in with. */ + onSignIn?: () => void; + signingIn: boolean; +}) { + return ( +
+ {awsErrorMessage(failure.error)} + {failure.error.kind === "sso_expired" && !onSignIn && ( + + Sign in with aws sso login --profile {failure.error.profile} and try again. + + )} + {failure.error.kind === "sso_expired" && onSignIn && ( + + )} +
+ ); +} + +function PermissionReportView({ report }: { report: PermissionReport }) { + const [copyNote, setCopyNote] = useState(null); + const probed = typeof report.method === "object"; + // Insufficient only because checks couldn't complete (no denied row in the + // workspace tier, only rows with a note): don't claim the permissions are missing. + const workspaceTier = report.tiers.find((t) => t.tier === "workspace_user"); + const incomplete = + report.summary === "insufficient" && + !!workspaceTier && + !workspaceTier.results.some((r) => r.verdict === "denied") && + workspaceTier.results.some((r) => r.note); + const badge = incomplete + ? "Incomplete — check again" + : report.summary === "workspace_user" && probed + ? "Workspace user (unverified)" + : SUMMARY_LABEL[report.summary]; + const badgeStyle = incomplete + ? "text-[var(--text-secondary)] border-[var(--border-color)]" + : SUMMARY_STYLE[report.summary]; + const flagged = report.tiers + .map((t) => ({ ...t, results: t.results.filter((r) => r.verdict !== "allowed") })) + .filter((t) => t.results.length > 0); + const ownPrincipalDenied = + report.summary === "insufficient" && + report.tiers.some((t) => + t.results.some((r) => r.verdict === "denied" && OWN_PRINCIPAL_ACTIONS.includes(r.action)), + ); + + const copy = async (tier: Tier) => { + try { + await navigator.clipboard.writeText(await awsPolicyDocument(tier)); + setCopyNote("Policy copied to clipboard."); + } catch (e) { + setCopyNote(`Could not copy the policy: ${awsErrorMessage(toAwsError(e))}`); + } + }; + + return ( +
+
+ + {badge} + + + {report.identity.arn} · {report.region} + +
+ {incomplete && ( +

+ Some checks couldn't complete (for example because AWS throttled the requests), so the + permissions couldn't be fully assessed. Check again in a moment. +

+ )} + {typeof report.method === "object" && ( +

+ {`Simulation not permitted (${report.method.probed.reason}). Read access was probed; write actions are unverified and will be confirmed on first use.`} +

+ )} + {ownPrincipalDenied && ( +

+ Triple-C couldn't inspect the caller's own permissions. Both policies grant iam:GetRole and + iam:SimulatePrincipalPolicy on the caller's own principal, so this is the first thing to ask + an admin for. +

+ )} + {flagged.map((t) => ( +
+ + {t.tier === "workspace_user" ? "Workspace user" : "Environment admin"} ({t.results.length} not + allowed) + +
    + {t.results.map((r) => ( +
  • + {r.action} — {r.verdict} + {r.note && ( + <> + {" "} + ({r.note}). Check again in a moment. + + )} +
  • + ))} +
+
+ ))} +
+ + +
+ {copyNote && ( +

+ {copyNote} +

+ )} +

+ The simulation doesn't account for SCPs or session policies. +

+
+ ); +} + +function ConnectionRow({ host }: { host: HostConnection }) { + const { loadSettings, setAppSettings } = useSettings(); + const [editing, setEditing] = useState(false); + const [name, setName] = useState(host.name); + const [region, setRegion] = useState(host.kind.default_region); + const [confirmRemove, setConfirmRemove] = useState(false); + const [busy, setBusy] = useState(false); + const [signingIn, setSigningIn] = useState(false); + const [report, setReport] = useState(null); + const [failure, setFailure] = useState(null); + const auth = host.kind.auth; + const rowRef = useRef(null); + const focusIn = (key: string) => + rowRef.current?.querySelector(`[data-focus="${key}"]`)?.focus(); + useEffect(() => { + if (confirmRemove) focusIn("confirm-remove"); + }, [confirmRemove]); + const cancelRemove = () => { + setConfirmRemove(false); + requestAnimationFrame(() => focusIn("remove")); + }; + + const runCheck = async () => { + setBusy(true); + setFailure(null); + try { + setReport(await checkAwsPermissions(host.id)); + } catch (e) { + setReport(null); + setFailure({ error: toAwsError(e), retry: runCheck }); + } finally { + setBusy(false); + } + }; + + const signIn = async () => { + if (!failure) return; + const retry = failure.retry; + setSigningIn(true); + try { + await awsConnectionSsoLogin(host.id); + setFailure(null); + await retry(); + } catch (e) { + setFailure({ error: toAwsError(e), retry }); + } finally { + setSigningIn(false); + } + }; + + const save = async () => { + setBusy(true); + setFailure(null); + try { + await updateAwsConnection(host.id, name.trim(), region.trim()); + await loadSettings(); + setEditing(false); + } catch (e) { + setFailure({ error: toAwsError(e), retry: save }); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + setBusy(true); + try { + setAppSettings(await removeHostConnection(host.id)); + } catch (e) { + setFailure({ error: toAwsError(e), retry: remove }); + setConfirmRemove(false); + setBusy(false); + } + }; + + return ( +
  • + {editing ? ( +
    + {(id) => setName(e.target.value)} />} + + {(id) => ( + setRegion(e.target.value)} /> + )} + +
    + + +
    +
    + ) : ( +
    +
    +
    {host.name}
    +
    + {auth.method === "profile" ? `Profile: ${auth.name}` : "Access keys"} + {" · "} + {host.kind.default_region} +
    +
    +
    + + + {!confirmRemove && ( + + )} +
    +
    + )} + {confirmRemove && ( +
    + Remove {host.name}? Its stored credentials are deleted. + + +
    + )} + {busy && !editing && !confirmRemove &&

    Working…

    } + {failure && } + {report && } +
  • + ); +} + +function AddConnectionForm() { + const { loadSettings } = useSettings(); + const [profiles, setProfiles] = useState([]); + const [auth, setAuth] = useState("profile"); + const [name, setName] = useState(""); + const [profile, setProfile] = useState(""); + const [accessKeyId, setAccessKeyId] = useState(""); + const [secret, setSecret] = useState(""); + const [token, setToken] = useState(""); + const [region, setRegion] = useState("us-east-1"); + const [busy, setBusy] = useState(false); + const [signingIn, setSigningIn] = useState(false); + const [signedInAs, setSignedInAs] = useState(null); + const [failure, setFailure] = useState(null); + const [profilesError, setProfilesError] = useState(null); + const [pendingId, setPendingId] = useState(null); + + useEffect(() => { + let cancelled = false; + listAwsProfiles() + .then((p) => { + if (!cancelled) setProfiles(p); + }) + .catch((e) => { + if (!cancelled) { + setProfiles([]); + setProfilesError(awsErrorMessage(toAwsError(e))); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const ready = + name.trim() !== "" && + region.trim() !== "" && + (auth === "profile" ? profile !== "" : accessKeyId.trim() !== "" && secret !== ""); + + /** Second step: confirm the new connection can sign in. Retried alone after an SSO login. */ + const verify = async (id: string) => { + try { + const who = await awsConnectionIdentity(id); + setSignedInAs(who.arn); + setFailure(null); + } catch (e) { + setFailure({ error: toAwsError(e), retry: () => verify(id) }); + } + }; + + /** Everything after a successful add. Retrying never re-runs the add. */ + const afterAdd = async (id: string) => { + const refreshed = await loadSettings(); + if (!refreshed) { + setFailure({ + error: { kind: "other", message: "The connection was added, but the settings list could not be refreshed." }, + retry: () => afterAdd(id), + }); + return; + } + await verify(id); + }; + + const submit = async () => { + setBusy(true); + setFailure(null); + setSignedInAs(null); + try { + setPendingId(null); + const added = + auth === "profile" + ? await addAwsProfileConnection(name.trim(), profile, region.trim()) + : await addAwsKeysConnection(name.trim(), accessKeyId.trim(), secret, token.trim() || null, region.trim()); + if (auth === "keys") { + setAccessKeyId(""); + setSecret(""); + setToken(""); + } + setName(""); + setPendingId(added.id); + await afterAdd(added.id); + } catch (e) { + setFailure({ error: toAwsError(e), retry: submit }); + } finally { + setBusy(false); + } + }; + + const signIn = async () => { + if (!failure || !pendingId) return; + const retry = failure.retry; + setSigningIn(true); + try { + await awsConnectionSsoLogin(pendingId); + setFailure(null); + await retry(); + } catch (e) { + setFailure({ error: toAwsError(e), retry }); + } finally { + setSigningIn(false); + } + }; + + return ( +
    { + e.preventDefault(); + if (ready && !busy) void submit(); + }} + > +

    Add AWS connection

    + + {(id) => setName(e.target.value)} />} + + + label="Authentication" + value={auth} + onChange={setAuth} + segments={[ + { value: "profile", label: "Profile" }, + { value: "keys", label: "Access keys" }, + ]} + /> + {auth === "profile" ? ( + + {(id) => ( + + )} + + ) : ( + <> + + {(id) => ( + setAccessKeyId(e.target.value)} /> + )} + + + {(id) => ( + setSecret(e.target.value)} /> + )} + + + {(id) => ( + setToken(e.target.value)} /> + )} + +

    + Stored in the OS keychain and never shown again. +

    + + )} + + {(id) => ( + setRegion(e.target.value)} /> + )} + + + {signedInAs && ( +

    + Signed in as {signedInAs} +

    + )} + {failure && ( + + )} + + ); +} + +export default function HostsSettings() { + const { appSettings } = useSettings(); + const hosts = appSettings?.hosts ?? []; + return ( +
    +

    + Where project environments run. AWS connections are used to check what your credentials can do. +

    +
      +
    • +
      Local Docker
      +
      Always available
      +
    • + {hosts.map((h) => ( + + ))} +
    +
    + +
    + + {COMMON_REGIONS.map((r) => ( + +
    + ); +} diff --git a/app/src/components/settings/ImportSettingsModal.test.tsx b/app/src/components/settings/ImportSettingsModal.test.tsx index 34e8753..5fca998 100644 --- a/app/src/components/settings/ImportSettingsModal.test.tsx +++ b/app/src/components/settings/ImportSettingsModal.test.tsx @@ -36,6 +36,9 @@ const samplePreview: SettingsImportPreview = { global_hook_install_count: 0, global_plugin_install_count: 0, marketplace_account_token_count: 0, + aws_connections: [], + aws_connection_key_count: 0, + removed_aws_connections: [], }; function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome { @@ -80,6 +83,50 @@ describe("ImportSettingsModal", () => { expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument(); }); + it("shows the AWS connections and key count, and flags overwrites", async () => { + previewSettingsImport.mockResolvedValue({ + ...samplePreview, + aws_connections: [ + { + name: "Prod", + auth: "keys", + profile_name: null, + default_region: "us-west-2", + overwrites_existing: true, + overwrites_keys: true, + keys_check_failed: false, + local: { name: "Real Prod", auth: "keys", profile_name: null, default_region: "us-east-1" }, + }, + { + name: "Dev", + auth: "profile", + profile_name: "dev-profile", + default_region: "eu-west-1", + overwrites_existing: false, + overwrites_keys: false, + keys_check_failed: true, + local: null, + }, + ], + aws_connection_key_count: 1, + removed_aws_connections: [ + { name: "Old", auth: "profile", profile_name: "old-p", default_region: "ap-south-1" }, + ], + }); + render(); + + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } }); + fireEvent.click(screen.getByRole("button", { name: /choose file/i })); + + expect(await screen.findByText(/AWS connection "Prod" \(access keys, us-west-2\)/)).toBeInTheDocument(); + expect(screen.getByText(/AWS connection "Dev" \(profile dev-profile, eu-west-1\)/)).toBeInTheDocument(); + expect(screen.getByText(/1 AWS access key set$/)).toBeInTheDocument(); + expect(screen.getByText(/Overwrites the AWS access keys stored for your connection "Real Prod"/)).toBeInTheDocument(); + expect(screen.getByText(/Replaces your connection "Real Prod" \(access keys, us-east-1\) with "Prod"/)).toBeInTheDocument(); + expect(screen.getByText(/Could not check whether keys are stored for "Dev".*may overwrite them/)).toBeInTheDocument(); + expect(screen.getByText(/Removes your AWS connection "Old" \(profile old-p, ap-south-1\)/)).toBeInTheDocument(); + }); + it("warns about a custom Docker image every time, not just on change", async () => { previewSettingsImport.mockResolvedValue({ ...samplePreview, diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 21e0ab4..a16174b 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -21,6 +21,7 @@ import WebTerminalSettings from "./WebTerminalSettings"; import SttSettings from "./SttSettings"; import SharedAuthSettings from "./SharedAuthSettings"; import MarketplaceSettings from "./MarketplaceSettings"; +import HostsSettings from "./HostsSettings"; import CertificateSettings from "./CertificateSettings"; import ExportSettingsModal from "./ExportSettingsModal"; import ImportSettingsModal from "./ImportSettingsModal"; @@ -188,6 +189,10 @@ export default function SettingsPanel() { + + + + diff --git a/app/src/lib/aws-error.test.ts b/app/src/lib/aws-error.test.ts new file mode 100644 index 0000000..3b65427 --- /dev/null +++ b/app/src/lib/aws-error.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { toAwsError } from "./aws-error"; + +describe("toAwsError", () => { + it("passes a tagged AwsError through", () => { + const e = { kind: "sso_expired", profile: "dev" }; + expect(toAwsError(e)).toBe(e); + }); + it("wraps strings and Errors as other", () => { + expect(toAwsError("boom")).toEqual({ kind: "other", message: "boom" }); + expect(toAwsError(new Error("bad"))).toEqual({ kind: "other", message: "bad" }); + }); + it("rejects unknown kinds, inherited names and null", () => { + expect(toAwsError({ kind: "nope" }).kind).toBe("other"); + expect(toAwsError({ kind: "toString" }).kind).toBe("other"); + expect(toAwsError(null).kind).toBe("other"); + }); +}); diff --git a/app/src/lib/aws-error.ts b/app/src/lib/aws-error.ts new file mode 100644 index 0000000..c2ca7d7 --- /dev/null +++ b/app/src/lib/aws-error.ts @@ -0,0 +1,22 @@ +import type { AwsError } from "./types"; + +// A `Record` over every kind: adding or removing a kind in `AwsError` fails to compile here. +const KINDS: Record = { + sso_expired: true, + profile_not_found: true, + keys_missing: true, + invalid_credentials: true, + access_denied: true, + other: true, +}; + +/** Narrow what an AWS command rejected with. Anything that is not a tagged `AwsError` becomes `other`. */ +export function toAwsError(e: unknown): AwsError { + if (e && typeof e === "object" && "kind" in e) { + const kind = (e as { kind: unknown }).kind; + if (typeof kind === "string" && Object.prototype.hasOwnProperty.call(KINDS, kind)) { + return e as AwsError; + } + } + return { kind: "other", message: typeof e === "string" ? e : e instanceof Error ? e.message : "Unknown error" }; +} diff --git a/app/src/lib/settingsImportPreview.test.ts b/app/src/lib/settingsImportPreview.test.ts index ddec04a..d2fe30d 100644 --- a/app/src/lib/settingsImportPreview.test.ts +++ b/app/src/lib/settingsImportPreview.test.ts @@ -24,6 +24,9 @@ function preview(overrides: Partial = {}): SettingsImport global_hook_install_count: 0, global_plugin_install_count: 0, marketplace_account_token_count: 0, + aws_connections: [], + aws_connection_key_count: 0, + removed_aws_connections: [], ...overrides, }; } diff --git a/app/src/lib/settingsImportPreview.ts b/app/src/lib/settingsImportPreview.ts index 0c1ea76..2ef338b 100644 --- a/app/src/lib/settingsImportPreview.ts +++ b/app/src/lib/settingsImportPreview.ts @@ -1,4 +1,4 @@ -import type { SettingsImportPreview } from "./types"; +import type { AwsConnectionSummary, SettingsImportPreview } from "./types"; /** Named things a `SettingsImportPreview` says an import will change, for * `ImportSettingsModal`'s confirmation list. Does not include anything @@ -35,6 +35,14 @@ export function describeImport(preview: SettingsImportPreview): string[] { const n = preview.marketplace_account_token_count; items.push(`${n} marketplace account token${n === 1 ? "" : "s"}`); } + for (const c of preview.aws_connections) { + const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim(); + items.push(`AWS connection "${c.name}" (${auth}, ${c.default_region})`); + } + if (preview.aws_connection_key_count > 0) { + const n = preview.aws_connection_key_count; + items.push(`${n} AWS access key set${n === 1 ? "" : "s"}`); + } return items; } @@ -82,6 +90,29 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[] `Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.`, ); } + for (const c of preview.aws_connections) { + const localDesc = c.local ? describeAws(c.local) : null; + if (c.overwrites_existing && c.local) { + warnings.push(`Replaces your connection ${localDesc} with "${c.name}" (${c.auth === "keys" ? "access keys" : "profile " + (c.profile_name ?? "")}, ${c.default_region}).`); + } + if (c.overwrites_keys) { + warnings.push( + c.local + ? `Overwrites the AWS access keys stored for your connection ${localDesc} (imported as "${c.name}").` + : `Overwrites AWS access keys already stored for the imported connection "${c.name}".`, + ); + } + if (c.keys_check_failed) { + warnings.push( + `Could not check whether keys are stored for "${c.name}"${c.local ? ` (your connection ${localDesc})` : ""}; importing may overwrite them.`, + ); + } + } + for (const r of preview.removed_aws_connections) { + warnings.push( + `Removes your AWS connection ${describeAws(r)}, which is not in the file. Any access keys stored for it stay in your keychain.`, + ); + } if (preview.image_source === "custom") { warnings.push( `Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`, @@ -89,3 +120,8 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[] } return warnings; } + +function describeAws(c: AwsConnectionSummary): string { + const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim(); + return `"${c.name}" (${auth}, ${c.default_region})`; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 479f28e..9bada24 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport } from "./types"; +import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport, HostConnection, CallerIdentity, PermissionReport, Tier } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -44,6 +44,40 @@ export const detectAwsConfig = () => invoke("detect_aws_config"); export const listAwsProfiles = () => invoke("list_aws_profiles"); + +// AWS host connections. The commands returning `Promise<…>` below reject with an `AwsError` +// object (not a string); narrow a caught value with `toAwsError` from `./aws-error`. +export const addAwsProfileConnection = (name: string, profile: string, defaultRegion: string) => + invoke("add_aws_profile_connection", { name, profile, defaultRegion }); +export const addAwsKeysConnection = ( + name: string, + accessKeyId: string, + secretAccessKey: string, + sessionToken: string | null, + defaultRegion: string, +) => + invoke("add_aws_keys_connection", { + name, + accessKeyId, + secretAccessKey, + sessionToken, + defaultRegion, + }); +export const updateAwsConnection = (id: string, name: string, defaultRegion: string) => + invoke("update_aws_connection", { id, name, defaultRegion }); +/** Rejects with a string. Returns the settings after removal. */ +export const removeHostConnection = (id: string) => + invoke("remove_host_connection", { id }); +export const awsConnectionIdentity = (id: string) => + invoke("aws_connection_identity", { id }); +/** Profile connections only; rejects with a string. */ +export const awsConnectionSsoLogin = (id: string) => + invoke("aws_connection_sso_login", { id }); +/** `region` omitted: the connection's default region. */ +export const checkAwsPermissions = (id: string, region?: string) => + invoke("check_aws_permissions", { id, region: region ?? null }); +export const awsPolicyDocument = (tier: Tier) => + invoke("aws_policy_document", { tier }); /** Check a corporate CA path and report what would be installed. Never * rejects for a bad path — the reason comes back in `error`. */ export const inspectCaCertPath = (path: string) => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 32d044e..87b93e3 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -300,6 +300,7 @@ export interface AppSettings { terminal_gpu_rendering: boolean | null; marketplace_accounts: MarketplaceAccount[]; marketplaces: Marketplace[]; + hosts: HostConnection[]; global_marketplace_installs: MarketplaceInstall[]; } @@ -424,6 +425,34 @@ export interface SettingsImportPreview { global_plugin_install_count: number; /** Marketplace account tokens the import restores to the keychain. */ marketplace_account_token_count: number; + /** AWS connections the import brings (names cleaned; no key values). */ + aws_connections: AwsConnectionPreview[]; + /** AWS access-key entries the import restores to the keychain. */ + aws_connection_key_count: number; + /** Local AWS connections the import drops (it replaces the whole list). */ + removed_aws_connections: AwsConnectionSummary[]; +} + +export interface AwsConnectionSummary { + name: string; + auth: "profile" | "keys"; + profile_name: string | null; + default_region: string; +} + +export interface AwsConnectionPreview { + name: string; + auth: "profile" | "keys"; + profile_name: string | null; + default_region: string; + /** A connection with this id exists here with different content. */ + overwrites_existing: boolean; + /** The import carries keys for an id that already has keys stored here. */ + overwrites_keys: boolean; + /** The keychain could not be asked whether keys are already stored. */ + keys_check_failed: boolean; + /** The local connection with this id, when the import replaces it. */ + local: AwsConnectionSummary | null; } /** What `apply_settings_import` returns: the settings that were actually @@ -1099,3 +1128,59 @@ export interface ViewerPoll { hash: string | null; size: number | null; } + +export type AwsAuth = + | { method: "profile"; name: string } + | { method: "keys"; keyring_ref: string }; + +export interface HostConnection { + id: string; + name: string; + kind: { type: "aws"; auth: AwsAuth; default_region: string }; +} + +export type AwsErrorKind = AwsError["kind"]; + +/** What a failed AWS command rejects with (`aws::AwsError`, tagged on `kind`). */ +export type AwsError = + | { kind: "sso_expired"; profile: string } + | { kind: "profile_not_found"; profile: string } + | { kind: "keys_missing" } + | { kind: "invalid_credentials"; message: string } + | { kind: "access_denied"; action: string; message: string } + | { kind: "other"; message: string }; + +export interface CallerIdentity { + account: string; + arn: string; + user_id: string; +} + +export type Tier = "environment_admin" | "workspace_user"; + +export type Verdict = "allowed" | "denied" | "unverified"; + +export interface ActionResult { + action: string; + verdict: Verdict; + /** Set when the row is unverified because a call failed; blocks the tier's claim. */ + note: string | null; +} + +export interface TierResult { + tier: Tier; + results: ActionResult[]; +} + +export type PermissionMethod = "simulated" | { probed: { reason: string } }; + +export type PermissionSummary = "environment_admin" | "workspace_user" | "insufficient"; + +export interface PermissionReport { + identity: CallerIdentity; + region: string; + method: PermissionMethod; + summary: PermissionSummary; + /** `[workspace_user, environment_admin]`. */ + tiers: TierResult[]; +} diff --git a/aws/README.md b/aws/README.md new file mode 100644 index 0000000..573bebd --- /dev/null +++ b/aws/README.md @@ -0,0 +1,278 @@ +# Triple-C AWS policies + +These are the IAM policies for running Triple-C workspaces on AWS (ECS). Attach them to the IAM +users, groups, roles or SSO permission sets of the people who use Triple-C. The app reports which +tier the signed-in identity has under **Settings → Hosts → Check**. + +| File | What it is | +|---|---| +| `policies/workspace-user.json` | The **workspace user** tier: create, start, stop, connect to and delete **their own** workspaces in an existing environment | +| `policies/environment-admin.json` | The **environment admin** tier: deploy, update and delete the environment stack, and onboard users | +| `policies/permissions-boundary.json` | The **permissions boundary** every Triple-C role must carry. It is never attached to a person | + +An environment admin needs **both** tier files. The admin policy adds environment management on +top of everything a workspace user can do. Explicit Deny statements in either file apply to anyone +who has it attached. + +## Workspace user + +Every per-user resource is tagged `triple-c:owner = ${aws:userid}`. That value is the `UserId` +printed by `aws sts get-caller-identity`. The policy uses this tag so that a user can only do the +following: + +- **Run tasks.** The task definition must be a `triple-c-*` one tagged with the user's own owner + ID, and the cluster must be a `triple-c-*` one. The new task is tagged with the user's owner ID. + The user can pass only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks. +- **Work with their own tasks.** They can stop them, `ecs execute-command` into them, and open an + SSM session to them. They can end or resume only sessions they started, and open data channels + (`ssmmessages:OpenDataChannel`) only on those. + - `ecs:ExecuteCommand` is authorized against the **cluster** as well as the task, so the policy + also allows it on `triple-c-*` clusters with no condition. The owner rule is on the task, and + both must pass. +- **Manage their own secrets.** They can create, update and delete `triple-c/*` secrets tagged with + their owner ID. + - A secret must be encrypted with an **environment KMS key**, meaning one tagged + `triple-c:component`. `CreateSecret` must name the key by its full ARN in `KmsKeyId` (a key + ID, an alias or no key at all is refused), and the user can use KMS + (`Encrypt`/`GenerateDataKey`/`Decrypt`) only through Secrets Manager and only with environment + keys. Secrets Manager needs `kms:Decrypt` as well as `GenerateDataKey` to create or update a + secret on a customer key. + - A secret on any other key is refused by an **explicit Deny**: `kms:Encrypt` and + `kms:GenerateDataKey*` through Secrets Manager on a key without `triple-c:component`. The Deny + is needed because the AWS-managed `aws/secretsmanager` key, or any customer key whose key + policy grants account principals directly, needs no identity-policy Allow; without the Deny + such a `CreateSecret` succeeds. Keeping every user secret on the environment key keeps it + under the key policy the environment stack controls. (The permissions boundary does *not* + stop a task role decrypting an `aws/secretsmanager` secret: that key's policy grants the + caller directly.) + - Every `TagResource` on a secret must carry `triple-c:owner = ${aws:userid}` in the request, + even when it only adds `triple-c:project`. + - A secret tagged `triple-c:component` belongs to the environment. A workspace user can never + tag, untag, overwrite or delete one; an explicit Deny enforces this. +- **Manage their own access points.** They can create and delete EFS access points tagged with + their owner ID, and only on the environment's file system, which is the one tagged + `triple-c:component`. +- **Set only the ordinary tag keys.** Every request that tags something may set only + `triple-c:owner`, `triple-c:project` and `triple-c:environment` (`aws:TagKeys`). This applies to + `RunTask`, the ECS and EFS tag-on-create calls, `CreateSecret`, and secret `TagResource`. A + workspace user can never set `triple-c:component`, which marks environment resources and which + the Denies and phase-4 detection trust. +- **Inspect their own principal, as far as IAM can say so.** `iam:GetRole` and + `iam:SimulatePrincipalPolicy`, which the app's permission check uses on the caller's own + principal. An IAM user may inspect only itself (`user/${aws:username}`, with or without a path). + "My own role" cannot be written: IAM refuses `${aws:PrincipalArn}` as a Resource (verified live, + "must be in ARN format or \"*\"") and has no role-name variable. So the `InspectOwnPrincipal` + statement names the roles people actually sign in with: + - `role/aws-reserved/sso.amazonaws.com/*`, the IAM Identity Center permission-set roles; + - `role/triple-c-*`. + + A caller on any other role cannot read or simulate its role, so the permission check falls back + to probes and reports **Insufficient**, naming `iam:GetRole`. If your users sign in through other + roles, add those roles' ARNs or path prefixes (for example `arn:aws:iam::*:role/engineers/*`) + to `InspectOwnPrincipal` in both tier policies. Everything matched there can be read and + simulated by every holder of the policy, so keep it to the sign-in roles. +- **Read environment information.** This covers ECS, CloudFormation stacks, the `/triple-c/*` SSM + parameters, the secret list, EFS, and the `/triple-c/*` log groups. Listing is not a secret; the + app filters by owner for display. + +## Environment admin + +The admin tier manages `triple-c-*` CloudFormation stacks and the resources they create: + +- `triple-c-*` IAM roles; +- ECS clusters, capacity providers and task definitions; +- security groups, EFS file systems and KMS keys; +- `/triple-c/*` log groups and `/triple-c/*` SSM parameters; +- ECR repositories and pull-through cache rules. + +It is fenced in the following ways. + +**Roles are bounded.** The admin can create a `triple-c-*` role, or change its policies, only when +the role carries the `triple-c-permissions-boundary` managed policy as its permissions boundary. + +- The boundary is the most a Triple-C role can ever hold: + - ECR image pulls; + - writing `/triple-c/*` logs; + - ECS Exec channels; + - reading `triple-c/*` secrets; + - decrypting only through Secrets Manager, and only with environment (component-tagged) keys; + - mounting and writing only environment (component-tagged) file systems; whether that excludes + root access depends on access points fixing the POSIX user and root directory, which is only + true once phase 6 enforces them (see "Enforced access-point roots" under Phase 6 gaps); + - `sts:GetCallerIdentity`; + - invoking Bedrock models. +- The boundary grants nothing in IAM. +- Explicit Deny statements stop the admin from: + - removing the boundary from a `triple-c-*` role; + - setting any other boundary on one; + - editing or deleting the boundary policy itself. + +Without these fences an admin could create a role with any permissions and become an account +administrator. + +**No CloudFormation service roles.** The admin cannot create or update a `triple-c-*` stack, or +create a change set for one, with a service role (`RoleARN`). + +**Destruction is tag-scoped.** Security groups, EFS file systems and KMS keys can be created only +with a `triple-c:component` tag. They can be changed or deleted only if they carry that tag. +Tagging is allowed only as part of the creating call, or on a resource that is already +Triple-C's. + +**What is not scoped.** The scoping above does not cover every action. These actions apply +account-wide: + +- `ecs:RegisterTaskDefinition` and `ecs:DeregisterTaskDefinition` on `*`. The admin can deregister + *any* task definition revision in the account. `DeregisterTaskDefinition` has no resource type + (IAM authorizes it against `*`), so it cannot be narrowed. +- `ecr:CreatePullThroughCacheRule` on `*`, which also has no resource type. The admin can add + pull-through cache rules for any prefix. This cannot delete or overwrite anything. +- `elasticfilesystem:CreateFileSystem` on `*` (no resource type), still only with a + `triple-c:component` request tag. +- `ec2:CreateSecurityGroup` in any VPC (the new group itself must be tagged), and `ec2:Describe*`. +- `cloudformation:*` on `triple-c-*` stacks. Whatever a template creates is limited by the actions + above, because CloudFormation runs with the caller's own permissions (see the invariants below). + +## Invariants the templates must keep (phase 4) + +The policies rely on the following. Phase 4's templates must establish them, and phase 4's +detection should flag any environment that breaks them. + +1. **Every `triple-c-*` role carries `triple-c-permissions-boundary`.** The admin tier cannot + create one without it. A role created some other way, such as by an account administrator or by + hand, must still have the boundary. Detection should flag a `triple-c-*` role that has no + boundary. +2. **`triple-c-*` stacks are never deployed with a CloudFormation service role.** `UpdateStack` + and change sets on a stack that already has a service role reuse it without `iam:PassRole`, + and that role is not under the boundary. The admin tier denies setting `RoleARN`. Detection + should flag a `triple-c-*` stack whose `RoleARN` is set. +3. **Every environment-level `triple-c/*` secret carries `triple-c:component`.** A secret with + neither an owner tag nor a component tag could be claimed by any workspace user. +4. **Every environment KMS key and EFS file system carries `triple-c:component`.** The boundary + and the admin tier both rely on this tag. +5. **User secrets use the environment key.** The app must pass the environment KMS key's ARN as + `KmsKeyId` when it creates a secret. The workspace policy explicitly denies Secrets Manager + encryption with any key that lacks `triple-c:component`, including `aws/secretsmanager`. + +## One-time setup: the boundary policy + +An **account administrator** creates the boundary policy once, before anyone deploys an +environment. The environment-admin tier deliberately cannot do this. + +```sh +aws iam create-policy --policy-name triple-c-permissions-boundary \ + --policy-document file://policies/permissions-boundary.json +``` + +Create the policy **without a path**. The admin policy expects exactly +`arn:aws:iam:::policy/triple-c-permissions-boundary`. Phase 4 decides how the app offers +this step; one option is to list it with the environment stack's prerequisites. + +## Attaching the tier policies + +Create each file as a **customer managed policy** and attach it. In the console, go to +**IAM → Policies → Create policy → JSON**, paste the file, and attach the policy to a user, group +or role. Inline policies do not fit: a role's inline policies share 10,240 characters and an +environment admin needs both tiers (about 11,500), and a user's share 2,048. A managed policy may +hold 6,144 characters, not counting whitespace; the workspace policy is about 6,110 (roughly 30 +characters of headroom, so phase 4 must split `workspace-user.json` into two policies before +adding anything to it), and a unit test (`every_policy_fits_a_managed_policy`) fails before it +outgrows that. For IAM Identity Center, add the files as inline policies on the permission set +(the "up to 32,768 characters" limit is unverified) or reference the managed policies. + +With the CLI: + +```sh +aws iam create-policy --policy-name triple-c-workspace-user \ + --policy-document file://policies/workspace-user.json +aws iam attach-user-policy --user-name \ + --policy-arn arn:aws:iam:::policy/triple-c-workspace-user +``` + +## Known gaps + +**Phase 4** + +- **Per-user execution roles.** A workspace user can pass only roles tagged with their own owner + ID. Each user's onboarding stack must therefore create their execution role as well as their + task role. The alternative is a separate statement that allows the environment's shared + execution role. +- **The admin's full deploy permissions.** Deploying needs the following, added with the same + boundary and tag scoping once the templates exist: + - tag-on-create permissions for ECS, CloudWatch Logs, SSM and ECR; + - autoscaling and instance-profile actions for EC2 capacity; + - `cloudformation:ValidateTemplate`; + - whatever else the templates create. +- **Admin access to user data.** The admin can retag `triple-c-*` roles (`iam:TagRole`). The + boundary also allows every `triple-c/*` secret and every environment file system. As a result, + isolating users *from the admin* depends on the onboarding template's per-user policies, not on + IAM fences. +- **The workspace tier's secret Deny also applies to admins.** If the environment stack ever + manages component-tagged secrets through the admin's credentials, that Deny will block + `PutSecretValue`/`DeleteSecret` for them. + +**Phase 5** + +- **No secrets in task definitions or `RunTask` overrides.** Nothing sensitive (the agent token, + git credentials) may travel in task-definition environment variables or in `RunTask` + `containerOverrides`: every workspace user can read them through `ecs:Describe*` on `*`. Use + Secrets Manager (`triple-c/*`) references instead. + +**Phase 6** + +- **Enforced access-point roots.** A workspace user creates their own access points. The + `RootDirectory` and `PosixUser` must be enforced, so that an access point rooted at `/` cannot + read other users' data. Two ways to do this: an EFS file-system policy that pins each access + point to its owner's task role, or access points created by the onboarding stack rather than by + the user. + +## Live verification (2026-11-14) + +Every point the unit tests' small evaluator could not settle was checked against real IAM in a +disposable account (us-west-2): with the policy simulator, with calls made as throwaway roles that +hold the policies, and with deny probes (a call made as a role with no permissions names the +resource it was authorized against, and creates nothing). The repeatable part is +`app/src-tauri/src/aws/live_tests.rs`: + +```sh +cd app/src-tauri +TRIPLEC_AWS_E2E_PROFILE= TRIPLEC_AWS_E2E_REGION=us-west-2 \ + cargo test aws::live_tests -- --ignored --test-threads=1 +``` + +It creates `triple-c-e2e--*` roles and policies, `triple-c/triple-c-e2e-/*` secrets and +`triple-c-e2e-` KMS keys, all tagged `triple-c-test=phase3`, and deletes them when each test +ends (keys are scheduled for deletion, 7 days). One test creates the real +`triple-c-permissions-boundary` policy if the account has none, and deletes it again. + +✓ verified as written · ✗→fixed: the policy changed · — could not verify, and why. + +| Point | Result | +|---|---| +| `${aws:PrincipalArn}` as a Resource | ✗→fixed. IAM refuses it ("must be in ARN format or \"*\""), so neither tier policy could be attached at all. Now SSO permission-set roles, `triple-c-*` roles and `user/${aws:username}`, `user/*/${aws:username}` (see the workspace user's own principal) | +| SSO role path | ✓ `iam:GetRole` returns `role/aws-reserved/sso.amazonaws.com//AWSReservedSSO_…`, and the check simulates that ARN. The SSO admin session checks as Environment admin, Simulated | +| `${aws:PrincipalAccount}` in `iam:PermissionsBoundary` | ✓ `CreateRole` with `triple-c-permissions-boundary` succeeds as the admin tier; without a boundary, or with another one, it is denied | +| Boundary fences | ✓ removing, replacing or editing the boundary is an explicit deny | +| Simulator and context | ✓ the simulator substitutes `${aws:userid}` and `${aws:username}` from context entries. It **does** apply the simulated principal's own permissions boundary (verified); it does not apply session policies. SCPs: not verifiable here | +| Simulator rate limit | ✗→fixed (app). `SimulatePrincipalPolicy` throttles ("Rate exceeded") within a full check; the check now uses adaptive retries and two requests at a time | +| KMS `CreateKey` with tags | ✓ allowed: tag-on-create passes the tag rule on `kms:TagResource`. Untagged `CreateKey`, and `CreateAlias`/`TagResource`/`ScheduleKeyDeletion` on an untagged key, are denied | +| ECS Exec on the cluster | ✗→fixed. `ecs:ExecuteCommand` is authorized against the cluster, then the task. Added `ExecOnTripleCClusters`; with only the cluster allowed, the call is denied on the task, so the owner rule still decides | +| ECS tag keys | ✓ (service reference) `aws:ResourceTag` is supported on tasks, clusters and task definitions. Live: — needs phase 4/5 ECS resources | +| SSM `StartSession` to a task | ✓ authorized against `arn:aws:ecs:::task//`, which `task/triple-c-*/*` matches. The tag condition on a real task: — needs phase 5 | +| SSO session ownership | ✓ (AWS docs) the `aws:ssmmessages:session-id` tag is the starter's `aws:userid` for users and role sessions alike. Live: — needs a session. Added `ssmmessages:OpenDataChannel` on own sessions, which AWS's current examples require | +| EC2 tag-on-create | ✓ `CreateSecurityGroup` (DryRun) is allowed with a `triple-c:component` tag and denied without one or with only other tags. `security-group-rule` resources on `AuthorizeSecurityGroup*`: — needs a security group | +| EFS | ✗→fixed: `CreateFileSystem` has no resource type, so `Resource` is now `*`. ✓ a tagged `CreateFileSystem`/`CreateAccessPoint` authorizes `elasticfilesystem:TagResource` first. `elasticfilesystem:CreateAction`, `CreateAccessPoint` against the file system's tags, `ClientMount`/`ClientWrite`: — need phase 6 file systems | +| Secrets tag-on-create | ✓ `CreateSecret` with tags authorizes `secretsmanager:TagResource` separately (refused without `TagOwnSecrets`), and that statement's `StringEqualsIfExists`/`Null` conditions pass at create | +| `secretsmanager:KmsKeyId` | ✓ carries the request's value: a key ARN passes, a bare key ID, an alias or no key is refused | +| Secrets Manager's KMS calls | ✓ made as the caller with `kms:ViaService`. ✗→fixed: `CreateSecret`/`PutSecretValue` on a customer key also need `kms:Decrypt`, which the policy lacked ("Access to KMS is not allowed"). `PutSecretValue` on an own secret now passes | +| `aws/secretsmanager` key | ✓ denied, and the Deny is what denies it: the same policy without `NeverEncryptSecretsWithOtherKeys` creates the secret | +| Secret ownership | ✓ another owner's or a component-tagged secret cannot be written, deleted or claimed; `triple-c:component` cannot be set. An untagged `triple-c/*` secret *can* be claimed (invariant 3) | +| `aws:TagKeys` | ✓ for `CreateSecret` and secret `TagResource`. `RunTask` (including `aws:ecs:*` and propagated tags), `CreateAccessPoint`, ECS/EFS tag-on-create: — need phase 4/5/6 resources | +| Boundary and key tags | ✓ a role under the boundary decrypts through Secrets Manager with a component-tagged key and is refused with an untagged customer key, so KMS supplies the key's `aws:ResourceTag` | +| `cloudformation:RoleArn` | ✓ present when `RoleARN` is given; the Deny fires. The admin tier cannot pass any role to CloudFormation anyway (`PassRole` is only to ECS tasks and EC2) | +| Task definition / pull-through scoping | ✓ both authorize against `*`; they cannot be narrowed | + +Still open for the phases that create the resources: `RunTask`'s tag keys and `iam:PassRole`'s +`iam:ResourceTag` on a real run, the owner tag on a real task for `StopTask`/`ExecuteCommand`/ +`StartSession`, session ownership for an SSO-started session, and the EFS and security-group-rule +points above. The simulator does not apply session policies, so the first real create is the final +check. diff --git a/aws/policies/environment-admin.json b/aws/policies/environment-admin.json new file mode 100644 index 0000000..d6cfbde --- /dev/null +++ b/aws/policies/environment-admin.json @@ -0,0 +1,374 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ManageEnvironmentStacks", + "Effect": "Allow", + "Action": [ + "cloudformation:*" + ], + "Resource": [ + "arn:aws:cloudformation:*:*:stack/triple-c-*/*" + ] + }, + { + "Sid": "ManageBoundedTripleCRoles", + "Effect": "Allow", + "Action": [ + "iam:CreateRole", + "iam:PutRolePolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:DeleteRolePolicy" + ], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ], + "Condition": { + "StringEquals": { + "iam:PermissionsBoundary": "arn:aws:iam::${aws:PrincipalAccount}:policy/triple-c-permissions-boundary" + } + } + }, + { + "Sid": "TagReadAndDeleteTripleCRoles", + "Effect": "Allow", + "Action": [ + "iam:DeleteRole", + "iam:TagRole", + "iam:GetRole" + ], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ] + }, + { + "Sid": "PassTripleCRoles", + "Effect": "Allow", + "Action": [ + "iam:PassRole" + ], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ], + "Condition": { + "StringEquals": { + "iam:PassedToService": [ + "ecs-tasks.amazonaws.com", + "ec2.amazonaws.com" + ] + } + } + }, + { + "Sid": "KeepTheBoundaryOnTripleCRoles", + "Effect": "Deny", + "Action": [ + "iam:DeleteRolePermissionsBoundary" + ], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ] + }, + { + "Sid": "KeepTheBoundaryPolicyUnchanged", + "Effect": "Deny", + "Action": [ + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion" + ], + "Resource": [ + "arn:aws:iam::*:policy/triple-c-permissions-boundary" + ] + }, + { + "Sid": "OnlyTheTripleCBoundaryOnTripleCRoles", + "Effect": "Deny", + "Action": [ + "iam:PutRolePermissionsBoundary" + ], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ], + "Condition": { + "StringNotEquals": { + "iam:PermissionsBoundary": "arn:aws:iam::${aws:PrincipalAccount}:policy/triple-c-permissions-boundary" + } + } + }, + { + "Sid": "NoServiceRoleForTripleCStacks", + "Effect": "Deny", + "Action": [ + "cloudformation:CreateStack", + "cloudformation:UpdateStack", + "cloudformation:CreateChangeSet" + ], + "Resource": [ + "arn:aws:cloudformation:*:*:stack/triple-c-*/*" + ], + "Condition": { + "Null": { + "cloudformation:RoleArn": "false" + } + } + }, + { + "Sid": "InspectOwnPrincipal", + "Effect": "Allow", + "Action": [ + "iam:GetRole", + "iam:SimulatePrincipalPolicy" + ], + "Resource": [ + "arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*", + "arn:aws:iam::*:role/triple-c-*", + "arn:aws:iam::*:user/${aws:username}", + "arn:aws:iam::*:user/*/${aws:username}" + ] + }, + { + "Sid": "ManageClusters", + "Effect": "Allow", + "Action": [ + "ecs:CreateCluster", + "ecs:DeleteCluster", + "ecs:PutClusterCapacityProviders" + ], + "Resource": [ + "arn:aws:ecs:*:*:cluster/triple-c-*" + ] + }, + { + "Sid": "ManageCapacityProviders", + "Effect": "Allow", + "Action": [ + "ecs:CreateCapacityProvider", + "ecs:DeleteCapacityProvider" + ], + "Resource": [ + "arn:aws:ecs:*:*:capacity-provider/triple-c-*" + ] + }, + { + "Sid": "ManageTaskDefinitions", + "Effect": "Allow", + "Action": [ + "ecs:RegisterTaskDefinition", + "ecs:DeregisterTaskDefinition" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "CreateTaggedSecurityGroups", + "Effect": "Allow", + "Action": [ + "ec2:CreateSecurityGroup" + ], + "Resource": [ + "arn:aws:ec2:*:*:security-group/*" + ], + "Condition": { + "Null": { + "aws:RequestTag/triple-c:component": "false" + } + } + }, + { + "Sid": "CreateSecurityGroupsInVpcs", + "Effect": "Allow", + "Action": [ + "ec2:CreateSecurityGroup" + ], + "Resource": [ + "arn:aws:ec2:*:*:vpc/*" + ] + }, + { + "Sid": "TagSecurityGroupsAtCreate", + "Effect": "Allow", + "Action": [ + "ec2:CreateTags" + ], + "Resource": [ + "arn:aws:ec2:*:*:security-group/*" + ], + "Condition": { + "StringEquals": { + "ec2:CreateAction": "CreateSecurityGroup" + } + } + }, + { + "Sid": "ManageTripleCSecurityGroups", + "Effect": "Allow", + "Action": [ + "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroup*", + "ec2:RevokeSecurityGroup*", + "ec2:CreateTags" + ], + "Resource": [ + "arn:aws:ec2:*:*:security-group/*" + ], + "Condition": { + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } + }, + { + "Sid": "DescribeEc2", + "Effect": "Allow", + "Action": [ + "ec2:Describe*" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "CreateTaggedFileSystems", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:CreateFileSystem" + ], + "Resource": [ + "*" + ], + "Condition": { + "Null": { + "aws:RequestTag/triple-c:component": "false" + } + } + }, + { + "Sid": "TagFileSystemsAtCreate", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:TagResource" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:file-system/*" + ], + "Condition": { + "StringEquals": { + "elasticfilesystem:CreateAction": "CreateFileSystem" + } + } + }, + { + "Sid": "ManageTripleCFileSystems", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:DeleteFileSystem", + "elasticfilesystem:CreateMountTarget", + "elasticfilesystem:DeleteMountTarget", + "elasticfilesystem:PutFileSystemPolicy", + "elasticfilesystem:TagResource" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:file-system/*" + ], + "Condition": { + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } + }, + { + "Sid": "CreateTaggedKeys", + "Effect": "Allow", + "Action": [ + "kms:CreateKey" + ], + "Resource": [ + "*" + ], + "Condition": { + "Null": { + "aws:RequestTag/triple-c:component": "false" + } + } + }, + { + "Sid": "ManageTripleCKeys", + "Effect": "Allow", + "Action": [ + "kms:ScheduleKeyDeletion", + "kms:PutKeyPolicy", + "kms:TagResource", + "kms:CreateAlias", + "kms:DeleteAlias" + ], + "Resource": [ + "arn:aws:kms:*:*:key/*" + ], + "Condition": { + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } + }, + { + "Sid": "ManageTripleCKeyAliases", + "Effect": "Allow", + "Action": [ + "kms:CreateAlias", + "kms:DeleteAlias" + ], + "Resource": [ + "arn:aws:kms:*:*:alias/triple-c-*" + ] + }, + { + "Sid": "ManageLogGroups", + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:DeleteLogGroup", + "logs:PutRetentionPolicy" + ], + "Resource": [ + "arn:aws:logs:*:*:log-group:/triple-c/*" + ] + }, + { + "Sid": "ManageRepositories", + "Effect": "Allow", + "Action": [ + "ecr:CreateRepository", + "ecr:DeleteRepository" + ], + "Resource": [ + "arn:aws:ecr:*:*:repository/triple-c-*", + "arn:aws:ecr:*:*:repository/triple-c/*" + ] + }, + { + "Sid": "CreatePullThroughCacheRules", + "Effect": "Allow", + "Action": [ + "ecr:CreatePullThroughCacheRule" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "ManageEnvironmentParameters", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:DeleteParameter" + ], + "Resource": [ + "arn:aws:ssm:*:*:parameter/triple-c/*" + ] + } + ] +} diff --git a/aws/policies/permissions-boundary.json b/aws/policies/permissions-boundary.json new file mode 100644 index 0000000..08454ce --- /dev/null +++ b/aws/policies/permissions-boundary.json @@ -0,0 +1,121 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "EcrAuthorization", + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "PullTripleCImages", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability", + "ecr:BatchImportUpstreamImage", + "ecr:CreateRepository" + ], + "Resource": [ + "arn:aws:ecr:*:*:repository/triple-c-*", + "arn:aws:ecr:*:*:repository/triple-c/*" + ] + }, + { + "Sid": "WriteTripleCLogs", + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": [ + "arn:aws:logs:*:*:log-group:/triple-c/*" + ] + }, + { + "Sid": "EcsExecChannels", + "Effect": "Allow", + "Action": [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "ReadTripleCSecrets", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ] + }, + { + "Sid": "DecryptThroughSecretsManagerOnly", + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": [ + "arn:aws:kms:*:*:key/*" + ], + "Condition": { + "StringLike": { + "kms:ViaService": "secretsmanager.*.amazonaws.com" + }, + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } + }, + { + "Sid": "MountEnvironmentFileSystems", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:ClientMount", + "elasticfilesystem:ClientWrite" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:file-system/*" + ], + "Condition": { + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } + }, + { + "Sid": "WhoAmI", + "Effect": "Allow", + "Action": [ + "sts:GetCallerIdentity" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "InvokeBedrockModels", + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Resource": [ + "arn:aws:bedrock:*::foundation-model/*", + "arn:aws:bedrock:*:*:inference-profile/*", + "arn:aws:bedrock:*:*:application-inference-profile/*" + ] + } + ] +} diff --git a/aws/policies/workspace-user.json b/aws/policies/workspace-user.json new file mode 100644 index 0000000..00426f7 --- /dev/null +++ b/aws/policies/workspace-user.json @@ -0,0 +1,414 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "RunOwnTasks", + "Effect": "Allow", + "Action": [ + "ecs:RunTask" + ], + "Resource": [ + "arn:aws:ecs:*:*:task-definition/triple-c-*" + ], + "Condition": { + "StringEquals": { + "aws:RequestTag/triple-c:owner": "${aws:userid}", + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + }, + "ArnLike": { + "ecs:cluster": "arn:aws:ecs:*:*:cluster/triple-c-*" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] + } + } + }, + { + "Sid": "TagOwnTasksAtRunTask", + "Effect": "Allow", + "Action": [ + "ecs:TagResource" + ], + "Resource": [ + "arn:aws:ecs:*:*:task/triple-c-*/*" + ], + "Condition": { + "StringEquals": { + "ecs:CreateAction": "RunTask", + "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] + } + } + }, + { + "Sid": "PassOwnTaskRoles", + "Effect": "Allow", + "Action": [ + "iam:PassRole" + ], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ], + "Condition": { + "StringEquals": { + "iam:ResourceTag/triple-c:owner": "${aws:userid}", + "iam:PassedToService": "ecs-tasks.amazonaws.com" + } + } + }, + { + "Sid": "StopAndExecOwnTasks", + "Effect": "Allow", + "Action": [ + "ecs:StopTask", + "ecs:ExecuteCommand" + ], + "Resource": [ + "arn:aws:ecs:*:*:task/triple-c-*/*" + ], + "Condition": { + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } + } + }, + { + "Sid": "ExecOnTripleCClusters", + "Effect": "Allow", + "Action": [ + "ecs:ExecuteCommand" + ], + "Resource": [ + "arn:aws:ecs:*:*:cluster/triple-c-*" + ] + }, + { + "Sid": "StartSessionsToOwnTasks", + "Effect": "Allow", + "Action": [ + "ssm:StartSession" + ], + "Resource": [ + "arn:aws:ecs:*:*:task/triple-c-*/*" + ], + "Condition": { + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } + } + }, + { + "Sid": "UsePortForwardingDocument", + "Effect": "Allow", + "Action": [ + "ssm:StartSession" + ], + "Resource": [ + "arn:aws:ssm:*::document/AWS-StartPortForwardingSession" + ] + }, + { + "Sid": "UseEndOrResumeOwnSessions", + "Effect": "Allow", + "Action": [ + "ssm:TerminateSession", + "ssm:ResumeSession", + "ssmmessages:OpenDataChannel" + ], + "Resource": [ + "arn:aws:ssm:*:*:session/*" + ], + "Condition": { + "StringLike": { + "ssm:resourceTag/aws:ssmmessages:session-id": "${aws:userid}" + } + } + }, + { + "Sid": "CreateOwnSecrets", + "Effect": "Allow", + "Action": [ + "secretsmanager:CreateSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ], + "Condition": { + "StringEquals": { + "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] + }, + "ArnLike": { + "secretsmanager:KmsKeyId": "arn:aws:kms:*:*:key/*" + } + } + }, + { + "Sid": "TagOwnSecrets", + "Effect": "Allow", + "Action": [ + "secretsmanager:TagResource" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ], + "Condition": { + "StringEquals": { + "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "StringEqualsIfExists": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + }, + "Null": { + "aws:ResourceTag/triple-c:component": "true" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] + } + } + }, + { + "Sid": "ManageOwnSecrets", + "Effect": "Allow", + "Action": [ + "secretsmanager:PutSecretValue", + "secretsmanager:DeleteSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ], + "Condition": { + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } + } + }, + { + "Sid": "NeverTouchEnvironmentSecrets", + "Effect": "Deny", + "Action": [ + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "secretsmanager:PutSecretValue", + "secretsmanager:DeleteSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ], + "Condition": { + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } + }, + { + "Sid": "CreateOwnAccessPoints", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:CreateAccessPoint" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:file-system/*" + ], + "Condition": { + "StringEquals": { + "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "Null": { + "aws:ResourceTag/triple-c:component": "false" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] + } + } + }, + { + "Sid": "TagOwnAccessPointsAtCreate", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:TagResource" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:access-point/*" + ], + "Condition": { + "StringEquals": { + "elasticfilesystem:CreateAction": "CreateAccessPoint", + "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] + } + } + }, + { + "Sid": "DeleteOwnAccessPoints", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:DeleteAccessPoint" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:access-point/*" + ], + "Condition": { + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } + } + }, + { + "Sid": "EncryptThroughSecretsManagerWithEnvironmentKeysOnly", + "Effect": "Allow", + "Action": [ + "kms:Encrypt", + "kms:GenerateDataKey", + "kms:Decrypt" + ], + "Resource": [ + "arn:aws:kms:*:*:key/*" + ], + "Condition": { + "StringLike": { + "kms:ViaService": "secretsmanager.*.amazonaws.com" + }, + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } + }, + { + "Sid": "NeverEncryptSecretsWithOtherKeys", + "Effect": "Deny", + "Action": [ + "kms:Encrypt", + "kms:GenerateDataKey*" + ], + "Resource": [ + "arn:aws:kms:*:*:key/*" + ], + "Condition": { + "StringLike": { + "kms:ViaService": "secretsmanager.*.amazonaws.com" + }, + "Null": { + "aws:ResourceTag/triple-c:component": "true" + } + } + }, + { + "Sid": "ReadEcs", + "Effect": "Allow", + "Action": [ + "ecs:Describe*", + "ecs:List*" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "ReadEnvironmentParameters", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter*" + ], + "Resource": [ + "arn:aws:ssm:*:*:parameter/triple-c/*" + ] + }, + { + "Sid": "ReadStacks", + "Effect": "Allow", + "Action": [ + "cloudformation:DescribeStacks", + "cloudformation:ListStacks" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "ListSecrets", + "Effect": "Allow", + "Action": [ + "secretsmanager:ListSecrets" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "ReadFileSystems", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:Describe*" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "ReadLogs", + "Effect": "Allow", + "Action": [ + "logs:GetLogEvents", + "logs:FilterLogEvents" + ], + "Resource": [ + "arn:aws:logs:*:*:log-group:/triple-c/*" + ] + }, + { + "Sid": "InspectOwnPrincipal", + "Effect": "Allow", + "Action": [ + "iam:GetRole", + "iam:SimulatePrincipalPolicy" + ], + "Resource": [ + "arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*", + "arn:aws:iam::*:role/triple-c-*", + "arn:aws:iam::*:user/${aws:username}", + "arn:aws:iam::*:user/*/${aws:username}" + ] + }, + { + "Sid": "WhoAmI", + "Effect": "Allow", + "Action": [ + "sts:GetCallerIdentity" + ], + "Resource": [ + "*" + ] + } + ] +} diff --git a/docs/superpowers/plans/2026-11-14-aws-connection-permission-check.md b/docs/superpowers/plans/2026-11-14-aws-connection-permission-check.md new file mode 100644 index 0000000..18f70f5 --- /dev/null +++ b/docs/superpowers/plans/2026-11-14-aws-connection-permission-check.md @@ -0,0 +1,370 @@ +# AWS Connection + Permission Check (Phase 3) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A user can add an AWS connection in Settings → Hosts (an AWS profile, including SSO, or access keys, plus a default region). They see "Signed in as ``", and can run a permission check that reports Environment admin, Workspace user or Insufficient. For anything missing, it names the exact actions and offers the policy JSON to send to an admin. + +**Architecture:** +- A new `aws/` Rust module builds SDK configs from a `HostConnection`: an explicit profile provider, or static keys read from the keyring. +- It calls `sts:GetCallerIdentity` and runs a table-driven permission check. That check uses `iam:SimulatePrincipalPolicy`, falling back to read-only probes when simulation is denied. +- The two tier policies ship as JSON in `aws/policies/` and are compiled in with `include_str!`. A test keeps the check table and the policy files identical action-for-action. +- Tauri commands in `commands/host_commands.rs`. The React UI is `components/settings/HostsSettings.tsx`. + +**Tech Stack:** +- Rust: Tauri v2. Already present: `aws-config` 1.13 (`sso`, `credentials-process`), `aws-sdk-ecs`, `aws-sdk-ssm`. Added: `aws-sdk-sts`, `aws-sdk-iam`, `aws-sdk-cloudformation`, `aws-sdk-secretsmanager`, all with `default-features = false` and features `default-https-client`, `rt-tokio`, `behavior-version-latest`, the same as the existing ones. +- `keyring` through `storage/secure.rs`. +- React + TypeScript + vitest. + +**Spec:** `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`. Relevant sections: "User flow" §1 and §3, "Multi-user accounts", "Data model", "Shipped AWS artefacts", "Connection model". + +## Global Constraints + +- Connection model **C**: a connection is credentials plus a **default** region. Region and environment belong to a project later (phase 5), not to the connection. +- Auth options are the same as the AWS CLI's: `AwsAuth::Profile { name }` (any profile in `~/.aws/config`, SSO included) or `AwsAuth::Keys { keyring_ref }`. +- Access keys are stored in the OS keyring through `storage/secure.rs`, **never in `settings.json`**. +- `AppSettings.hosts: Vec`. Local is implicit and is not stored. Existing `settings.json` files must deserialize unchanged (`#[serde(default)]`). +- The owner ID is `UserId` from `GetCallerIdentity`, i.e. `aws:userid`. Every per-user resource is tagged `triple-c:owner = ${aws:userid}`. +- Tags: `triple-c:owner`, `triple-c:project`, `triple-c:environment`, `triple-c:component`. +- Resource naming fixed by this phase, which phase 4's templates must use: + - clusters `triple-c-*`; + - task definition families `triple-c-*`; + - secrets `triple-c/*`; + - SSM parameters `/triple-c/*`; + - stacks `triple-c-*`; + - IAM roles `triple-c-*`. +- The simulator does not apply SCPs, permission boundaries or session policies. The UI must say so, and write actions that were only probed are shown as **"unverified — will be confirmed on first use"**, never as OK. +- AWS calls must not inherit the app process's `AWS_*` environment for a **profile** connection. Use `ProfileFileCredentialsProvider` with the explicit profile name, not the default chain. +- Every new Tauri command is registered in `lib.rs` and granted in `capabilities/default.json`. `src/test/capabilities.test.ts` must pass. +- Deferred on purpose: + - **EFS.** The spec's `elasticfilesystem:DescribeAccessPoints` probe waits for the EFS crate, which arrives with EFS mode in phase 6. Until then, EFS read checks are `Unverified` in probe mode. + - **Role policies.** `task-execution-role.json` and `task-role-standard.json` ship with the phase 4 templates that use them. + - **Per-environment caching.** Results are cached per `(connection, region, environment)` and re-run on a denial. Environments arrive in phase 4, so for now the check runs on demand and the UI keeps the last result. +- No AWS spending. The permission check uses only IAM simulation, STS and read-only list/describe calls. + +## Review Focus + +1. **SSO profile with an expired token.** Expect a typed `AwsError::SsoExpired` and a "Sign in" button that runs `aws sso login --profile `, not a raw SDK error. Test: `an_expired_sso_token_is_reported_as_sso_expired` (Task 3). +2. **An SSO or assumed-role caller.** `GetCallerIdentity` returns `arn:aws:sts:::assumed-role//`, but simulation needs the role ARN *with its path*. SSO roles live under `/aws-reserved/sso.amazonaws.com//`. Look the path up with `iam:GetRole`. If that is denied, fall back to probes rather than simulating a wrong ARN. Tests: `assumed_role_arn_maps_to_role_arn_with_path` and `a_denied_get_role_falls_back_to_probes` (Tasks 3 and 5). +3. **The caller can't run `iam:SimulatePrincipalPolicy`** (common for locked-down SSO roles). Expect the probe fallback: probed reads are marked allowed or denied, writes unverified, and the tier labelled "unverified". Test: `a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified` (Task 5). +4. **Removing a connection whose keyring entry is already gone** (deleted by hand, or another OS user). Expect removal to still succeed, because a missing entry is not an error. Test: `removing_a_keys_connection_with_no_keyring_entry_succeeds` (Task 2). +5. **Settings import on another machine.** A `Keys` connection must arrive with its keys (exported like marketplace tokens), and a `Profile` connection arrives as-is even if that profile doesn't exist there. Checking it then says "profile not found", not a panic. Tests: `aws_connection_keys_round_trip_through_export` (Task 2) and `a_missing_profile_is_reported_by_name` (Task 3). + +--- + +### Task 1: Data model — `HostConnection` in settings (Rust + TS) + +**Files:** +- Create: `app/src-tauri/src/models/host_connection.rs` +- Modify: `app/src-tauri/src/models/mod.rs` (add `pub mod host_connection;` and re-export) +- Modify: `app/src-tauri/src/models/app_settings.rs` (add `#[serde(default)] pub hosts: Vec` after `marketplaces`, plus `Default`) +- Modify: `app/src/lib/types.ts` (add `HostConnection`, `AwsAuth`; add `hosts: HostConnection[]` to `AppSettings`) + +**Interfaces:** +- Produces: + ```rust + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct HostConnection { pub id: String, pub name: String, pub kind: HostKind } + #[derive(..)] #[serde(tag = "type", rename_all = "snake_case")] + pub enum HostKind { Aws { auth: AwsAuth, default_region: String } } + #[derive(..)] #[serde(tag = "method", rename_all = "snake_case")] + pub enum AwsAuth { Profile { name: String }, Keys { keyring_ref: String } } + pub fn validate_connection_name(name: &str) -> Result // trimmed, 1..=64 chars, no control chars + pub fn validate_region(region: &str) -> Result // ^[a-z]{2}(-[a-z]+)+-\d+$ + pub fn validate_profile_name(name: &str) -> Result // non-empty, ≤128, [A-Za-z0-9_.@+=,-] + ``` + TS: `type AwsAuth = { method: "profile"; name: string } | { method: "keys"; keyring_ref: string }`; `interface HostConnection { id: string; name: string; kind: { type: "aws"; auth: AwsAuth; default_region: string } }`. + +- [ ] **Step 1: Write failing tests** in `host_connection.rs` `mod tests`: + - `settings_without_hosts_deserialize_to_empty`: a minimal `AppSettings` JSON with no `hosts` key gives `hosts == vec![]`. + - `a_profile_connection_round_trips`: serialize then deserialize gives an equal value, and the JSON contains `"type":"aws"` and `"method":"profile"`. + - `regions_are_validated`: accepts `us-west-2`, `eu-central-1`, `cn-north-1`, `us-gov-west-1`; rejects `""`, `US-WEST-2`, `us-west`, `us-west-2; rm`. + - `profile_names_are_validated`: accepts `default` and `340315884407_StaffAdministratorAccess`; rejects `""`, `a b`, `x\n`, and 129 chars. +- [ ] **Step 2: Run them:** `cd app/src-tauri && cargo test host_connection`. Expected: FAIL (module missing). +- [ ] **Step 3: Implement the types and validators.** Add `hosts` to `AppSettings` and its `Default`. +- [ ] **Step 4: Run them:** `cargo test host_connection` passes. Run `cargo test` (whole crate) to catch any other `AppSettings { .. }` literal that needs the field. +- [ ] **Step 5: Add the TS types.** `cd app && npx tsc --noEmit` passes. Update any test fixture that builds an `AppSettings` with `hosts: []`. +- [ ] **Step 6: Commit** `feat(aws): HostConnection model in app settings`. + +### Task 2: Keyring storage for access keys, and settings export + +**Files:** +- Modify: `app/src-tauri/src/storage/secure.rs` (new section modelled on the marketplace-token one) +- Modify: `app/src-tauri/src/models/settings_export.rs` (`ExportedSecrets.aws_connection_keys: BTreeMap`, `#[serde(default)]`) +- Modify: `app/src-tauri/src/commands/settings_export_commands.rs` (export and restore, mirroring `exported_marketplace_tokens` / `restore_marketplace_tokens`) + +**Interfaces:** +- Produces: + ```rust + #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] // Debug impl redacts the secret and token + pub struct AwsKeys { pub access_key_id: String, pub secret_access_key: String, pub session_token: Option } + pub fn store_aws_keys(connection_id: &str, keys: &AwsKeys) -> Result<(), String> // service "triple-c-aws-connection-", JSON value + pub fn get_aws_keys(connection_id: &str) -> Result, String> + pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> // missing entry = Ok + ``` + `keyring_ref` in `AwsAuth::Keys` is the connection id. Ids are validated like `marketplace_token_service`. + +- [ ] **Step 1: Write failing tests:** + - `aws_keys_debug_redacts_secrets`: `format!("{:?}", keys)` contains neither the secret nor the token. + - `aws_key_service_names_reject_bad_ids`: rejects `"../x"`, `""`, and a 65-char id. + - `removing_a_keys_connection_with_no_keyring_entry_succeeds`: run against the existing mock-keyring test pattern in `secure.rs` if there is one; otherwise as a unit test on `delete_entry`'s `NoEntry` → `Ok` mapping. + - `aws_connection_keys_round_trip_through_export`, in `settings_export_commands.rs` tests: a `Keys` connection's stored keys appear in `ExportedSecrets.aws_connection_keys` (as JSON), a `Profile` connection contributes nothing, and restore calls the store fn with the same id and keys. +- [ ] **Step 2: Run** `cargo test aws_key` and `cargo test aws_connection_keys`. Expected: FAIL. +- [ ] **Step 3: Implement.** On restore, a value that fails to parse as `AwsKeys` gives a warning naming the connection id, never the value. +- [ ] **Step 4: Run** those tests, then all of `cargo test settings_export`. All pass. +- [ ] **Step 5: Commit** `feat(aws): keyring storage for AWS access keys, carried by settings export`. + +### Task 3: SDK config and identity (`aws::connection`, `aws::identity`) + +**Files:** +- Modify: `app/src-tauri/Cargo.toml` (add `aws-sdk-sts`, `aws-sdk-iam`, `aws-sdk-cloudformation`, `aws-sdk-secretsmanager` at the current versions matching `aws-config 1.13.x`, with the feature set above) +- Create: `app/src-tauri/src/aws/mod.rs`, `app/src-tauri/src/aws/connection.rs`, `app/src-tauri/src/aws/identity.rs`, `app/src-tauri/src/aws/error.rs` +- Modify: `app/src-tauri/src/lib.rs` (`mod aws;`) + +**Interfaces:** +- Consumes: `HostConnection`, `AwsAuth` (Task 1); `secure::get_aws_keys` (Task 2). +- Produces: + ```rust + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] + pub enum AwsError { + SsoExpired { profile: String }, // the UI offers "Sign in" + ProfileNotFound { profile: String }, + KeysMissing, // keyring entry gone + InvalidCredentials { message: String }, + AccessDenied { action: String, message: String }, + Other { message: String }, + } + impl std::fmt::Display for AwsError + pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] + pub struct CallerIdentity { pub account: String, pub arn: String, pub user_id: String /* owner id */ } + pub async fn caller_identity(cfg: &aws_config::SdkConfig) -> Result + pub enum PrincipalArn { Exact(String), AssumedRole { account: String, role_name: String } } + pub fn principal_for_simulation(caller_arn: &str) -> Result + pub async fn resolve_principal(iam: &aws_sdk_iam::Client, p: PrincipalArn) -> Result // GetRole for the path; denied → AccessDenied{action:"iam:GetRole"} + pub fn classify_sdk_error(action: &str, err: &(dyn std::error::Error + 'static)) -> AwsError + ``` + `sdk_config`: + - `Profile` uses `ProfileFileCredentialsProvider::builder().profile_name(name)`, plus `.region(Region::new(region))`. It does not use the default chain. + - `Keys` uses `Credentials::new(akid, secret, token, None, "triple-c-connection")`. + - The config is built lazily, so profile problems surface on the first call and are classified there. + +- [ ] **Step 1: Write failing tests** (pure, no network): + - `iam_user_arn_is_used_as_is`: `arn:aws:iam::111122223333:user/alice` gives `Exact`. + - `assumed_role_arn_maps_to_role_name`: `arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Admin_abc/alice@x.com` gives `AssumedRole{account:"111122223333", role_name:"AWSReservedSSO_Admin_abc"}`. + - `root_and_federated_arns_are_rejected_for_simulation`: `arn:aws:iam::1:root` and `arn:aws:sts::1:federated-user/bob` give `Err(AwsError::Other{..})`, with a message saying simulation isn't possible for this principal (the caller then falls back to probes). + - `assumed_role_arn_maps_to_role_arn_with_path`: `resolve_principal` against a fake IAM endpoint. Use an `aws_smithy_runtime` `StaticReplayClient`, or a local `tokio` HTTP listener as in `host/ssm/tests.rs` `a_dropped_open_still_ends_its_session`. The fake returns GetRole XML with `/aws-reserved/sso.amazonaws.com/us-west-2/` and ``; expect that `Arn`. + - `an_expired_sso_token_is_reported_as_sso_expired`: `classify_sdk_error` on an error whose display chain contains "The SSO session associated with this profile has expired" or "Token has expired" gives `SsoExpired`. + - `a_missing_profile_is_reported_by_name`: `sdk_config` + `caller_identity` with `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE` pointing at an empty temp file gives `ProfileNotFound{profile}`. Use the provider's `ProfileFileCredentialsProvider` profile-files builder to point at the temp files, not process env, so tests don't race. +- [ ] **Step 2: Run** `cargo test aws::`. Expected: FAIL. +- [ ] **Step 3: Implement.** Keep `classify_sdk_error` substring-based on `DisplayErrorContext` text, plus the SDK error code (`AccessDenied`, `AccessDeniedException`, `ExpiredToken`, `InvalidClientTokenId`, `SignatureDoesNotMatch`). +- [ ] **Step 4: Run** `cargo test aws::`. Passes. Run `cargo tree -i rustls@0.21` and confirm it is still absent: no legacy TLS stack. +- [ ] **Step 5: Commit** `feat(aws): SDK config from a connection, caller identity, principal resolution`. + +### Task 4: Shipped tier policies and the check table + +**Files:** +- Create: `aws/policies/workspace-user.json`, `aws/policies/environment-admin.json` (repo root) +- Create: `aws/README.md` (short: what each file grants and how to attach it) +- Create: `app/src-tauri/src/aws/policies.rs` + +**Interfaces:** +- Produces: + ```rust + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] + pub enum Tier { EnvironmentAdmin, WorkspaceUser } + pub fn policy_json(tier: Tier) -> &'static str // include_str!("../../../../aws/policies/") + pub struct Check { + pub action: &'static str, // "ecs:RunTask" + pub resource: &'static str, // template; {region} {account} substituted + pub context: &'static [(&'static str, ContextValue)],// e.g. ("aws:RequestTag/triple-c:owner", OwnerId) + pub write: bool, + } + pub enum ContextValue { OwnerId, Literal(&'static str) } + pub fn checks(tier: Tier) -> &'static [Check] + ``` + + **Workspace-user policy.** These statements follow spec "Multi-user accounts": + - `ecs:RunTask` on `arn:aws:ecs:*:*:task-definition/triple-c-*`, with `aws:RequestTag/triple-c:owner = ${aws:userid}`; + - `ecs:TagResource` on create; + - `iam:PassRole` on `arn:aws:iam::*:role/triple-c-*`, with `iam:ResourceTag/triple-c:owner = ${aws:userid}` and `iam:PassedToService = ecs-tasks.amazonaws.com`; + - `ecs:StopTask` and `ecs:ExecuteCommand` on `arn:aws:ecs:*:*:task/triple-c-*/*`, with `aws:ResourceTag/triple-c:owner = ${aws:userid}`; + - `ssm:StartSession` on `arn:aws:ecs:*:*:task/triple-c-*/*` (with the same tag) and on `arn:aws:ssm:*::document/AWS-StartPortForwardingSession`; + - `ssm:TerminateSession` and `ssm:ResumeSession` on `arn:aws:ssm:*:*:session/*` with `ssm:resourceTag/aws:ssmmessages:session-id = ${aws:userid}` (the AWS-documented owner condition; session IDs are prefixed by the role session name, not `aws:userid`, so an ARN pattern can't scope them); + - `secretsmanager:CreateSecret`, `TagResource`, `PutSecretValue` and `DeleteSecret` on `arn:aws:secretsmanager:*:*:secret:triple-c/*`, with the request tag (create) or resource tag (others); + - `kms:Encrypt` and `kms:GenerateDataKey` with `kms:ViaService = secretsmanager.*.amazonaws.com`; + - read-only: `ecs:Describe*`, `ecs:List*`, `ssm:GetParameter*` on `arn:aws:ssm:*:*:parameter/triple-c/*`, `cloudformation:DescribeStacks`, `cloudformation:ListStacks`, `secretsmanager:ListSecrets`, `elasticfilesystem:Describe*`, `logs:GetLogEvents` and `logs:FilterLogEvents` on `log-group:/triple-c/*`, and `sts:GetCallerIdentity`. + + **Environment-admin policy:** + - `cloudformation:*` on `arn:aws:cloudformation:*:*:stack/triple-c-*/*`; + - `iam:CreateRole`, `DeleteRole`, `PutRolePolicy`, `DeleteRolePolicy`, `AttachRolePolicy`, `DetachRolePolicy`, `TagRole`, `GetRole` and `PassRole` on `role/triple-c-*`; + - `ecs:CreateCluster`, `DeleteCluster`, `RegisterTaskDefinition`, `DeregisterTaskDefinition`, `PutClusterCapacityProviders`, `CreateCapacityProvider` and `DeleteCapacityProvider`; + - `ec2:CreateSecurityGroup`, `DeleteSecurityGroup`, `AuthorizeSecurityGroup*`, `RevokeSecurityGroup*`, `CreateTags` and `Describe*`; + - `elasticfilesystem:CreateFileSystem`, `DeleteFileSystem`, `CreateMountTarget`, `DeleteMountTarget`, `PutFileSystemPolicy` and `TagResource`; + - `kms:CreateKey`, `ScheduleKeyDeletion`, `CreateAlias`, `DeleteAlias`, `PutKeyPolicy` and `TagResource`; + - `logs:CreateLogGroup`, `DeleteLogGroup` and `PutRetentionPolicy` on `/triple-c/*`; + - `ecr:CreateRepository`, `DeleteRepository` and `CreatePullThroughCacheRule`; + - `ssm:PutParameter` and `DeleteParameter` on `parameter/triple-c/*`; + - `iam:SimulatePrincipalPolicy` on `*`. + + The check table holds one representative `Check` per action. Each resource template is a concrete name inside the pattern, for example `arn:aws:ecs:{region}:{account}:task-definition/triple-c-probe:1`. Wildcard actions like `ecs:Describe*` are checked as one concrete action, `ecs:DescribeClusters`. + +- [ ] **Step 1: Write failing tests** in `policies.rs`: + - `policies_are_valid_iam_json`: each file parses. `Version == "2012-10-17"`, and every statement has `Sid`, `Effect: "Allow"`, `Action` and `Resource`. + - `every_policy_action_has_a_check_and_vice_versa`: the set of actions in the JSON (wildcard actions matched by glob against the check's action) equals the set covered by `checks(tier)`. This is the drift guard. + - `every_check_resource_matches_a_statement_resource`: after substituting `{region}`=`us-west-2` and `{account}`=`111122223333`, each check's resource glob-matches one of the `Resource` patterns of a statement that contains its action. + - `owner_scoped_actions_carry_the_owner_context`: `ecs:RunTask`, `ecs:StopTask`, `ecs:ExecuteCommand`, `ssm:StartSession` (task resource), `iam:PassRole` and `secretsmanager:CreateSecret` each have an `OwnerId` context entry with the key their statement's condition uses. +- [ ] **Step 2: Run** `cargo test aws::policies`. Expected: FAIL. +- [ ] **Step 3: Write the two JSON files and the table.** Write `aws/README.md`, and state there that statements are verified against a real account by Task 7's live test. +- [ ] **Step 4: Run** `cargo test aws::policies`. Passes. +- [ ] **Step 5: Commit** `feat(aws): shipped tier policies and the permission check table`. + +### Task 5: The permission check + +**Files:** +- Create: `app/src-tauri/src/aws/permissions.rs`, `app/src-tauri/src/aws/permissions/tests.rs` + +**Interfaces:** +- Consumes: `Tier`, `checks`, `Check` (Task 4); `CallerIdentity`, `PrincipalArn`, `resolve_principal`, `AwsError` (Task 3). +- Produces: + ```rust + #[async_trait::async_trait] // or native async fn in trait if the crate already avoids async_trait + pub trait PermissionApi: Send + Sync { + async fn resolve_principal(&self, caller_arn: &str) -> Result; + async fn simulate(&self, principal: &str, action: &str, resource: &str, context: &[(String, String)]) -> Result; + async fn probe(&self, probe: Probe) -> Result<(), AwsError>; + } + pub enum Probe { EcsListClusters, SsmGetParametersByPath, CfnDescribeStacks, SecretsListSecrets } + pub struct SdkPermissionApi { /* iam, ecs, ssm, cfn, secrets clients */ } + impl SdkPermissionApi { pub fn new(cfg: &aws_config::SdkConfig) -> Self } + + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] + pub enum Verdict { Allowed, Denied, Unverified } + #[derive(Debug, Clone, Serialize)] + pub struct ActionResult { pub action: String, pub verdict: Verdict } + #[derive(Debug, Clone, Serialize)] + pub struct TierResult { pub tier: Tier, pub results: Vec } + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] + pub enum Method { Simulated, Probed { reason: String } } + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] + pub enum Summary { EnvironmentAdmin, WorkspaceUser, Insufficient } + #[derive(Debug, Clone, Serialize)] + pub struct PermissionReport { + pub identity: CallerIdentity, pub region: String, pub method: Method, + pub summary: Summary, pub tiers: Vec, // [WorkspaceUser, EnvironmentAdmin] + } + pub async fn check_permissions(api: &dyn PermissionApi, identity: &CallerIdentity, region: &str) -> PermissionReport + ``` + Rules: + - **Simulated:** one `simulate` per check. Context values: `OwnerId` → `identity.user_id`, plus `aws:userid` → `identity.user_id` on every call. `EvalDecision == "allowed"` → Allowed, anything else → Denied. + - **Fallback to Probed:** if `resolve_principal` or the *first* `simulate` returns `AccessDenied`, or `principal_for_simulation` errors, run all four probes. + - A read check whose service has a probe gets that probe's result. + - A read check with no probe, and every `write: true` check, is `Unverified`. + - **Summary:** WorkspaceUser needs no `Denied` in the workspace tier. EnvironmentAdmin additionally needs no `Denied` in the admin tier. `Unverified` doesn't block a summary; the UI shows the method. + - Simulations run with bounded concurrency (4). + +- [ ] **Step 1: Write failing tests** against a `FakeApi { allow: HashSet<&str>, simulate_denied: bool, get_role_denied: bool, probes_ok: HashSet }`: + - `a_full_workspace_policy_reports_workspace_user`. + - `admin_and_workspace_policies_report_environment_admin`. + - `a_missing_action_reports_insufficient_and_names_it`: drop `iam:PassRole`. The summary is `Insufficient`, and the workspace tier lists `iam:PassRole` as `Denied`. + - `owner_context_is_sent_with_owner_scoped_checks`: the fake records contexts. `ecs:RunTask` was simulated with `aws:RequestTag/triple-c:owner` = identity.user_id. + - `a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified`. + - `a_denied_get_role_falls_back_to_probes`: the method is `Probed`, and its reason mentions `iam:GetRole`. + - `a_failed_probe_marks_its_reads_denied`. +- [ ] **Step 2: Run** `cargo test aws::permissions`. Expected: FAIL. +- [ ] **Step 3: Implement** `check_permissions` and `SdkPermissionApi`. + - `SimulatePrincipalPolicy`: set `policy_source_arn`, `action_names` = [action], `resource_arns` = [resource], and `context_entries` with `ContextKeyTypeEnum::String`. Read `evaluation_results[0].eval_decision`. + - Probes: + - `ecs list_clusters` with `max_results(1)`; + - `ssm get_parameters_by_path("/triple-c/environments")` with `max_results(1)`; + - `cfn describe_stacks` with no name, first page only; + - `secretsmanager list_secrets` with `max_results(1)`. +- [ ] **Step 4: Run** `cargo test aws::`. All pass. +- [ ] **Step 5: Commit** `feat(aws): tiered permission check with simulation and probe fallback`. + +### Task 6: Tauri commands + +**Files:** +- Create: `app/src-tauri/src/commands/host_commands.rs` +- Modify: `app/src-tauri/src/commands/mod.rs`, `app/src-tauri/src/lib.rs` (`generate_handler!`), `app/src-tauri/capabilities/default.json` (or the census location CLAUDE.md names), and `app/src/lib/tauri-commands.ts`, plus the `PermissionReport`, `CallerIdentity` and `AwsError` TS types in `types.ts` + +**Interfaces:** +- Consumes: Tasks 1–5. +- Produces these commands. TS wrappers are camelCase with the same arguments. + ```rust + add_aws_profile_connection(name: String, profile: String, default_region: String) -> Result + add_aws_keys_connection(name: String, access_key_id: String, secret_access_key: String, session_token: Option, default_region: String) -> Result + update_aws_connection(id: String, name: String, default_region: String) -> Result + remove_host_connection(id: String) -> Result + aws_connection_identity(id: String) -> Result + aws_connection_sso_login(id: String) -> Result<(), String> // Profile only; wraps aws_commands::run_sso_login + check_aws_permissions(id: String, region: Option) -> Result // None → default_region + aws_policy_document(tier: Tier) -> String + ``` + - **Add (both kinds):** validate → call `GetCallerIdentity` with the new credentials → only on success store the keys and save settings. Return `AwsError`, and save nothing, if the identity call fails. + - Exception: `SsoExpired` for a profile still saves. The UI then shows "Sign in". + - **Keys:** if saving settings fails after storing the keys, delete the keyring entry. + - **Remove:** delete the keyring entry first; a missing entry is fine. Then drop the connection from settings. + +- [ ] **Step 1: Write failing tests** for the pure parts, in `host_commands.rs` tests: + - `adding_saves_nothing_when_identity_fails`: inject an identity fn returning `InvalidCredentials`. Afterwards there are no settings changes and no store call. Structure the commands as thin wrappers over `fn add_connection_with(…, identity: impl AsyncFn, store: impl Fn, settings: &SettingsStore)` so this is testable without AWS. + - `an_sso_expired_profile_is_still_saved`. + - `keys_are_removed_if_saving_settings_fails`. + - `capabilities.test.ts` (existing) still passes with the new commands granted. +- [ ] **Step 2: Run them.** Expected: FAIL. +- [ ] **Step 3: Implement and register the commands.** Add the TS wrappers and types. +- [ ] **Step 4: Run** `cargo test host_commands`, `cd app && npx vitest run src/test/capabilities.test.ts`, and `npx tsc --noEmit`. All pass. +- [ ] **Step 5: Commit** `feat(aws): Tauri commands for AWS connections and permission checks`. + +### Task 7: Settings → Hosts UI + +**Files:** +- Create: `app/src/components/settings/HostsSettings.tsx`, `app/src/components/settings/HostsSettings.test.tsx` +- Modify: `app/src/components/settings/SettingsPanel.tsx` (new `` after "Backends") + +**Interfaces:** +- Consumes: the Task 6 TS wrappers and types; `listAwsProfiles` (existing); `useSettings().setAppSettings`. + +Behaviour: +- **List.** "Local Docker" is always first and can't be removed. Each AWS connection shows its name, `Profile: ` or `Access keys`, and its default region, with **Check**, **Edit** and **Remove** buttons. +- **Add AWS connection form:** + - name; + - auth (segmented: Profile | Access keys). Profile is a dropdown from `listAwsProfiles()`; Access keys are three password-type inputs, the session token optional; + - default region (a text input with a datalist of common regions). + - Saving shows "Signed in as ``" on success, or the error. `sso_expired` shows a **Sign in** button that calls `awsConnectionSsoLogin` and then retries. +- **Check.** Runs `checkAwsPermissions`, then shows: + - the summary as a badge (Environment admin / Workspace user / Insufficient); + - when the method is `probed`, a note: "Simulation not permitted (). Read access was probed; write actions are unverified and will be confirmed on first use."; + - a collapsible per-tier list of the denied and unverified actions; + - **Copy workspace-user policy** and **Copy admin policy** buttons (`awsPolicyDocument` → clipboard); + - a one-line caveat that the simulation doesn't account for SCPs, permission boundaries or session policies. +- **Remove** asks for confirmation first. + +- [ ] **Step 1: Write failing tests** in `HostsSettings.test.tsx`, mocking `@tauri-apps/api/core` `invoke` as the existing `MarketplaceSettings.test.tsx` does: + - `shows Local Docker and existing AWS connections`. + - `adds a profile connection and shows the signed-in identity`. + - `offers Sign in when the profile's SSO session expired`. + - `access key inputs are password fields and the session token is optional`. + - `check shows Insufficient with the denied actions and the copy-policy buttons`. + - `a probed check says writes are unverified`. +- [ ] **Step 2: Run** `cd app && npx vitest run src/components/settings/HostsSettings.test.tsx`. Expected: FAIL. +- [ ] **Step 3: Implement**, following `MarketplaceSettings.tsx` for structure and styling. +- [ ] **Step 4: Run** `npx vitest run` (the whole suite) and `npx tsc --noEmit`. Both pass. +- [ ] **Step 5: Commit** `feat(aws): Settings → Hosts with AWS connections and permission check`. + +### Task 8: Live verification against a real account, and docs + +**Files:** +- Create: `app/src-tauri/src/aws/live_tests.rs` (`#[ignore]`, env-gated like `host/ssm/live_tests.rs`) +- Modify: `CLAUDE.md` (a new `aws/` module paragraph next to `host/`), `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md` (mark phase 3 done and record the verification results) + +The live tests need `TRIPLEC_AWS_E2E_PROFILE` (admin creds for a disposable account) and `TRIPLEC_AWS_E2E_REGION`. Each test creates IAM roles named `triple-c-e2e-`, each assumable by the caller's account, with the policy under test inline, and deletes them in a guard on exit. IAM is free. + +- [ ] **Step 1: Write the ignored tests:** + - `a_role_with_the_workspace_policy_checks_as_workspace_user`: create the role with `workspace-user.json`, tag it `triple-c:owner=`, and run `check_permissions` with an `SdkPermissionApi` whose principal is that role ARN. Expect `WorkspaceUser`, with no `Denied` in the workspace tier. + - `a_role_with_both_policies_checks_as_environment_admin`. + - `a_role_with_no_policy_checks_as_insufficient_and_lists_every_action`. + - `a_role_without_simulate_permission_falls_back_to_probes`: assume the role (STS `AssumeRole`) and run the full `check_permissions` *as* it. + - `the_owner_condition_denies_another_owners_task`: simulate `ecs:StopTask` with `aws:ResourceTag/triple-c:owner` = a different id, and expect Denied. This is the spec's "most important rule". +- [ ] **Step 2: Run them** with fresh credentials from the user: `cargo test aws::live_tests -- --ignored --test-threads=1`. All pass. Fix the policy JSON if any statement doesn't evaluate as the spec intends, and re-run Task 4's tests. +- [ ] **Step 3: Confirm no `triple-c-e2e-*` roles remain:** `aws iam list-roles --query "Roles[?starts_with(RoleName,'triple-c-e2e-')]"` returns `[]`. +- [ ] **Step 4: Update the docs** and commit `test(aws): live IAM verification of the tier policies; docs`. diff --git a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md index e19e04e..69ea4aa 100644 --- a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md +++ b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md @@ -137,8 +137,11 @@ How the check works: probes (`ecs:DescribeClusters`, `ssm:GetParameter`, `secretsmanager:ListSecrets` filtered by tag, `elasticfilesystem:DescribeAccessPoints`). Write actions are then marked **"unverified — will be confirmed on first use"**, not "OK". -3. Be honest about the gaps: the simulation does not apply SCPs, permission boundaries or session - policies. The first real create is the final check, and its error must name the denied action. +3. Be honest about the gaps: the simulation does not apply SCPs or session policies (it does + apply the principal's own permissions boundary — verified 2026-11-14). The first real create is + the final check, and its error must name the denied action. +4. A probed run never claims more than **Workspace user**: probes see reads, and the admin tier is + nearly all writes. The exact policies for each tier ship as files with the app (`aws/policies/*.json`). Settings links to them so an admin can grant them without reading this doc. @@ -395,8 +398,8 @@ user, and the account is already safe to share. Strict tenancy's per-user `iam:PassRole` is scoped by a tag on the role (`iam:ResourceTag/triple-c:owner = ${aws:userid}`), not by role name, because `aws:userid` -contains a `:`, which role names don't allow. **Verify** that `iam:PassRole` evaluates -`iam:ResourceTag` as expected before release. +contains a `:`, which role names don't allow. That `iam:PassRole` evaluates `iam:ResourceTag` this +way is verified in the simulator (phase 3); confirm it on the first real `RunTask` (phase 5). Why EC2 privileged mode needs dedicated capacity: a privileged container, or one with the Docker socket, has root on its EC2 host, and so access to every other tenant's task on that host @@ -565,6 +568,7 @@ aws/ policies/ environment-admin.json workspace-user.json + permissions-boundary.json # every triple-c-* role carries it (phase 3) task-execution-role.json task-role-standard.json ``` @@ -576,7 +580,8 @@ aws/ 2. **Agent.** `triple-c-agent` in the image. Optionally use it locally too behind a flag, to harden it before AWS depends on it. 3. **AWS connection + permission check.** Settings UI, SDK wiring, `GetCallerIdentity`, the tiered - simulation and fallback probes, policy files. + simulation and fallback probes, policy files. **Done 2026-11-14**; the policies were verified + against real IAM (see "Phase 3: policy verification and rulings"). 4. **Environment + onboarding stacks.** CloudFormation templates for both, detection (SSM parameters + tagged stacks, including half-deployed and outdated ones), join or deploy, self-onboarding or an admin request, **strict tenancy**. @@ -621,6 +626,45 @@ aws/ - That GHCR pull-through cache works for the public image. - That the `SIGTERM` deadline on Fargate retirement and Spot leaves enough time for a WIP push. +### Phase 3: policy verification and rulings (2026-11-14) + +Rulings made while writing the tier policies, which phase 4 must build on: + +- **Environment admins are fenced by a permissions boundary.** They create or change `triple-c-*` + roles only with the `triple-c-permissions-boundary` managed policy as the boundary, and cannot + remove, replace or edit it. An account administrator creates the boundary policy once. +- **Destructive admin actions are tag-scoped.** Security groups, EFS file systems and KMS keys are + created only with `triple-c:component`, and changed or deleted only if they carry it. +- **A probed permission check caps at Workspace user** (above). +- **Strict tenancy needs per-user execution roles**, not only task roles: a workspace user can pass + only roles tagged with their own owner ID. +- **Environment-level secrets carry `triple-c:component`**; workspace users can never write, + delete or retag those, and an untagged `triple-c/*` secret could be claimed by any of them. +- **User secrets use the environment KMS key, named by ARN** in `KmsKeyId`; any other key, + including `aws/secretsmanager`, is explicitly denied. + +`aws/README.md` ("Live verification") has the per-statement results, and +`app/src-tauri/src/aws/live_tests.rs` repeats them. What reality changed: + +- IAM refuses `${aws:PrincipalArn}` as a Resource, so self-inspection is `user/${aws:username}` + for IAM users and, for role sessions (no role-name variable exists), SSO permission-set roles + and `triple-c-*` roles only (ruling R18). Callers on other roles are probed and reported + Insufficient; an admin can add their roles to `InspectOwnPrincipal`. +- `ecs:ExecuteCommand` is also authorized against the cluster; the owner rule stays on the task. +- Secrets Manager needs `kms:Decrypt` as well as `GenerateDataKey` on a customer key. +- `elasticfilesystem:CreateFileSystem` has no resource type (`Resource: "*"`). +- The session client needs `ssmmessages:OpenDataChannel` on its own session (AWS docs). +- The tier policies must be managed policies (both together exceed a role's inline budget); the + workspace policy is close to the 6,144-character limit (about 6,110), and a unit test guards it. +- `SimulatePrincipalPolicy` throttles within one check; the app retries adaptively. +- The `aws/secretsmanager` Deny is load-bearing, but not for the reason first given: a bounded task + role *can* decrypt such a secret, because that key's policy grants the caller directly. + +Left for the phases with real ECS/EFS/EC2 resources: `RunTask`'s `aws:TagKeys` (and any +`aws:ecs:*` or propagated tags), `iam:PassRole` with `iam:ResourceTag` on a real run, the owner +tag on a real task for stop/exec/session, session ownership for an SSO-started session, EFS +`CreateAction`/`CreateAccessPoint`/`ClientMount`, and `security-group-rule` authorization. + ## Connection model **Decided: C** (user, 2026-11-13). The comparison below is kept for the record.