From 7e2800989fe05035476f929614b7bfdb78c37c82 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 15:27:15 -0800 Subject: [PATCH 01/23] Plan: AWS host phase 3, connection and permission check Co-Authored-By: Claude Opus 5.5 --- ...6-11-14-aws-connection-permission-check.md | 370 ++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-14-aws-connection-permission-check.md 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`. -- 2.52.0 From e8bbe90327018e91739e5cdc2422c29c621aab6e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 15:37:49 -0800 Subject: [PATCH 02/23] feat(aws): HostConnection model in app settings Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/models/app_settings.rs | 5 + app/src-tauri/src/models/host_connection.rs | 143 ++++++++++++++++++++ app/src-tauri/src/models/mod.rs | 2 + app/src/lib/types.ts | 11 ++ 4 files changed, 161 insertions(+) create mode 100644 app/src-tauri/src/models/host_connection.rs 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..5cc4124 --- /dev/null +++ b/app/src-tauri/src/models/host_connection.rs @@ -0,0 +1,143 @@ +//! 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 }, +} + +/// Trimmed, 1..=64 characters, no control 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(char::is_control) { + return Err("Connection name must not contain control 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())) + } +} + +/// 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 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()); + 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/lib/types.ts b/app/src/lib/types.ts index 32d044e..c2b77f7 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[]; } @@ -1099,3 +1100,13 @@ 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 }; +} -- 2.52.0 From 60dad7b6b684ba8d6ba1e2da78eb7d97008a049d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 15:40:56 -0800 Subject: [PATCH 03/23] feat(aws): keyring storage for AWS access keys, carried by settings export Co-Authored-By: Claude Opus 5.5 --- .../src/commands/settings_export_commands.rs | 216 ++++++++++++++++++ app/src-tauri/src/models/settings_export.rs | 6 + app/src-tauri/src/storage/secure.rs | 110 ++++++++- 3 files changed, 331 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/commands/settings_export_commands.rs b/app/src-tauri/src/commands/settings_export_commands.rs index b219389..0dac02f 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_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_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 @@ -376,6 +479,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,6 +529,11 @@ pub async fn apply_settings_import( secure::store_marketplace_token, )); + secret_restore_warnings.extend(restore_aws_connection_keys( + &aws_keys, + secure::store_aws_keys, + )); + let imported_marketplace = ( settings.marketplace_accounts.clone(), settings.marketplaces.clone(), @@ -827,4 +938,109 @@ 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 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()); + } } diff --git a/app/src-tauri/src/models/settings_export.rs b/app/src-tauri/src/models/settings_export.rs index 85396cd..0631e95 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()) } } diff --git a/app/src-tauri/src/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index ef68da2..4b29dad 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)), } @@ -408,6 +413,74 @@ 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 same rule as marketplace account ids. +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()) +} + +/// Missing entry is success. Unused until connection removal lands. +#[allow(dead_code)] +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 +569,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()); + } } -- 2.52.0 From e42261bac87e8cc93735a9c50704233b6dca94ac Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 15:42:28 -0800 Subject: [PATCH 04/23] refactor(aws): one definition of the keychain id rule Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/commands/marketplace_commands.rs | 5 +---- app/src-tauri/src/storage/secure.rs | 9 ++++----- 2 files changed, 5 insertions(+), 9 deletions(-) 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/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index 4b29dad..7c63c4c 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -384,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)) @@ -440,7 +437,9 @@ impl std::fmt::Debug for AwsKeys { } /// Whether `id` may be used as a connection id / keyring reference: 1..=64 of -/// `[A-Za-z0-9-]`. The same rule as marketplace account ids. +/// `[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 -- 2.52.0 From 664697b15128743d3d2f53167f02459b81049fe4 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 15:43:41 -0800 Subject: [PATCH 05/23] feat(aws): disclose imported AWS connections and keys in the import preview Co-Authored-By: Claude Opus 5.5 --- .../src/commands/settings_export_commands.rs | 3 +- app/src-tauri/src/models/settings_export.rs | 130 ++++++++++++++++++ app/src-tauri/src/storage/secure.rs | 8 ++ .../settings/ImportSettingsModal.test.tsx | 37 +++++ app/src/lib/settingsImportPreview.test.ts | 2 + app/src/lib/settingsImportPreview.ts | 16 +++ app/src/lib/types.ts | 15 ++ 7 files changed, 210 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/commands/settings_export_commands.rs b/app/src-tauri/src/commands/settings_export_commands.rs index 0dac02f..cb42c4b 100644 --- a/app/src-tauri/src/commands/settings_export_commands.rs +++ b/app/src-tauri/src/commands/settings_export_commands.rs @@ -363,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_exists); *state.pending_settings_import.lock().await = Some(PendingSettingsImport { path, diff --git a/app/src-tauri/src/models/settings_export.rs b/app/src-tauri/src/models/settings_export.rs index 0631e95..801f178 100644 --- a/app/src-tauri/src/models/settings_export.rs +++ b/app/src-tauri/src/models/settings_export.rs @@ -177,6 +177,30 @@ 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, +} + +/// 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, } /// A cap on how much of a decrypted, not-yet-trusted string gets echoed back @@ -202,6 +226,45 @@ fn sanitize_for_preview(value: &str) -> String { impl SettingsImportPreview { pub fn from_payload(payload: &SettingsExportPayload) -> Self { + Self::from_payload_against(payload, &[], |_| false) + } + + /// As [`Self::from_payload`], also flagging what the import would + /// overwrite: `local_hosts` are the connections held now and + /// `local_has_keys` says whether a keychain entry exists for an id. + pub fn from_payload_against( + payload: &SettingsExportPayload, + local_hosts: &[crate::models::host_connection::HostConnection], + local_has_keys: impl Fn(&str) -> bool, + ) -> Self { + use crate::models::host_connection::{AwsAuth, HostKind}; + 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 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), + }; + AwsConnectionPreview { + name: sanitize_for_preview(&h.name), + auth: auth_kind.to_string(), + profile_name, + default_region: sanitize_for_preview(default_region), + overwrites_existing: local_hosts.iter().any(|l| l.id == h.id && l != h), + overwrites_keys: carried_keys(&h.id) && local_has_keys(&h.id), + } + }) + .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() @@ -246,6 +309,13 @@ 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(), } } } @@ -483,4 +553,64 @@ 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| 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)]); + } } diff --git a/app/src-tauri/src/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index 7c63c4c..c001221 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -474,6 +474,14 @@ pub fn get_aws_keys(connection_id: &str) -> Result, String> { .map_err(|_| "The stored AWS access keys are unreadable".to_string()) } +/// Whether any entry exists for the connection, readable or not. Never +/// returns or logs a value. +pub fn aws_keys_entry_exists(connection_id: &str) -> bool { + aws_connection_service(connection_id) + .and_then(|svc| read_entry(&svc, "the AWS access keys")) + .is_ok_and(|v| v.is_some()) +} + /// Missing entry is success. Unused until connection removal lands. #[allow(dead_code)] pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> { diff --git a/app/src/components/settings/ImportSettingsModal.test.tsx b/app/src/components/settings/ImportSettingsModal.test.tsx index 34e8753..a1d1bdb 100644 --- a/app/src/components/settings/ImportSettingsModal.test.tsx +++ b/app/src/components/settings/ImportSettingsModal.test.tsx @@ -36,6 +36,8 @@ 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, }; function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome { @@ -80,6 +82,41 @@ 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, + }, + { + name: "Dev", + auth: "profile", + profile_name: "dev-profile", + default_region: "eu-west-1", + overwrites_existing: false, + overwrites_keys: false, + }, + ], + aws_connection_key_count: 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 "Prod"/)).toBeInTheDocument(); + expect(screen.getByText(/Replaces your existing AWS connection.*"Prod"/)).toBeInTheDocument(); + }); + it("warns about a custom Docker image every time, not just on change", async () => { previewSettingsImport.mockResolvedValue({ ...samplePreview, diff --git a/app/src/lib/settingsImportPreview.test.ts b/app/src/lib/settingsImportPreview.test.ts index ddec04a..6973618 100644 --- a/app/src/lib/settingsImportPreview.test.ts +++ b/app/src/lib/settingsImportPreview.test.ts @@ -24,6 +24,8 @@ 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, ...overrides, }; } diff --git a/app/src/lib/settingsImportPreview.ts b/app/src/lib/settingsImportPreview.ts index 0c1ea76..aa31429 100644 --- a/app/src/lib/settingsImportPreview.ts +++ b/app/src/lib/settingsImportPreview.ts @@ -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,14 @@ 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) { + if (c.overwrites_existing) { + warnings.push(`Replaces your existing AWS connection with different settings: "${c.name}".`); + } + if (c.overwrites_keys) { + warnings.push(`Overwrites the AWS access keys stored for "${c.name}".`); + } + } if (preview.image_source === "custom") { warnings.push( `Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`, diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index c2b77f7..e1ff4a7 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -425,6 +425,21 @@ 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; +} + +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; } /** What `apply_settings_import` returns: the settings that were actually -- 2.52.0 From 2dea30f4fb427fb21347cc36b151bd76a698bea0 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 15:46:05 -0800 Subject: [PATCH 06/23] feat(aws): fail-closed overwrite check, name local connections, disclose removals in import preview Co-Authored-By: Claude Opus 5.5 --- .../src/commands/settings_export_commands.rs | 2 +- app/src-tauri/src/models/settings_export.rs | 119 +++++++++++++++--- app/src-tauri/src/storage/secure.rs | 19 ++- .../settings/ImportSettingsModal.test.tsx | 14 ++- app/src/lib/settingsImportPreview.test.ts | 1 + app/src/lib/settingsImportPreview.ts | 28 ++++- app/src/lib/types.ts | 13 ++ 7 files changed, 165 insertions(+), 31 deletions(-) diff --git a/app/src-tauri/src/commands/settings_export_commands.rs b/app/src-tauri/src/commands/settings_export_commands.rs index cb42c4b..6abaccd 100644 --- a/app/src-tauri/src/commands/settings_export_commands.rs +++ b/app/src-tauri/src/commands/settings_export_commands.rs @@ -364,7 +364,7 @@ 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 local_hosts = state.settings_store.get().hosts; - let preview = SettingsImportPreview::from_payload_against(&payload, &local_hosts, secure::aws_keys_entry_exists); + let preview = SettingsImportPreview::from_payload_against(&payload, &local_hosts, secure::aws_keys_entry_state); *state.pending_settings_import.lock().await = Some(PendingSettingsImport { path, diff --git a/app/src-tauri/src/models/settings_export.rs b/app/src-tauri/src/models/settings_export.rs index 801f178..715cbea 100644 --- a/app/src-tauri/src/models/settings_export.rs +++ b/app/src-tauri/src/models/settings_export.rs @@ -185,6 +185,37 @@ pub struct SettingsImportPreview { /// 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. @@ -201,6 +232,14 @@ pub struct AwsConnectionPreview { /// 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 @@ -225,19 +264,20 @@ fn sanitize_for_preview(value: &str) -> String { } impl SettingsImportPreview { + #[cfg(test)] pub fn from_payload(payload: &SettingsExportPayload) -> Self { - Self::from_payload_against(payload, &[], |_| false) + Self::from_payload_against(payload, &[], |_| Some(false)) } - /// As [`Self::from_payload`], also flagging what the import would - /// overwrite: `local_hosts` are the connections held now and - /// `local_has_keys` says whether a keychain entry exists for an id. + /// 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) -> bool, + local_has_keys: impl Fn(&str) -> Option, ) -> Self { - use crate::models::host_connection::{AwsAuth, HostKind}; let carried_keys = |id: &str| { payload .secrets @@ -250,21 +290,29 @@ impl SettingsImportPreview { .hosts .iter() .map(|h| { - 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), - }; + 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: sanitize_for_preview(&h.name), - auth: auth_kind.to_string(), - profile_name, - default_region: sanitize_for_preview(default_region), - overwrites_existing: local_hosts.iter().any(|l| l.id == h.id && l != h), - overwrites_keys: carried_keys(&h.id) && local_has_keys(&h.id), + 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() @@ -316,6 +364,7 @@ impl SettingsImportPreview { .values() .filter(|v| !v.trim().is_empty()) .count(), + removed_aws_connections, } } } @@ -605,7 +654,7 @@ mod tests { ]; let local = vec![aws_host("same", "Same", false), aws_host("changed", "Old name", false)]; let preview = - SettingsImportPreview::from_payload_against(&payload, &local, |id| id == "k1"); + SettingsImportPreview::from_payload_against(&payload, &local, |id| Some(id == "k1")); let flags: Vec<_> = preview .aws_connections .iter() @@ -613,4 +662,38 @@ mod tests { .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 c001221..903cb6e 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -474,12 +474,19 @@ pub fn get_aws_keys(connection_id: &str) -> Result, String> { .map_err(|_| "The stored AWS access keys are unreadable".to_string()) } -/// Whether any entry exists for the connection, readable or not. Never -/// returns or logs a value. -pub fn aws_keys_entry_exists(connection_id: &str) -> bool { - aws_connection_service(connection_id) - .and_then(|svc| read_entry(&svc, "the AWS access keys")) - .is_ok_and(|v| v.is_some()) +/// 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. Unused until connection removal lands. diff --git a/app/src/components/settings/ImportSettingsModal.test.tsx b/app/src/components/settings/ImportSettingsModal.test.tsx index a1d1bdb..5fca998 100644 --- a/app/src/components/settings/ImportSettingsModal.test.tsx +++ b/app/src/components/settings/ImportSettingsModal.test.tsx @@ -38,6 +38,7 @@ const samplePreview: SettingsImportPreview = { marketplace_account_token_count: 0, aws_connections: [], aws_connection_key_count: 0, + removed_aws_connections: [], }; function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome { @@ -93,6 +94,8 @@ describe("ImportSettingsModal", () => { 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", @@ -101,9 +104,14 @@ describe("ImportSettingsModal", () => { 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(); @@ -113,8 +121,10 @@ describe("ImportSettingsModal", () => { 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 "Prod"/)).toBeInTheDocument(); - expect(screen.getByText(/Replaces your existing AWS connection.*"Prod"/)).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 () => { diff --git a/app/src/lib/settingsImportPreview.test.ts b/app/src/lib/settingsImportPreview.test.ts index 6973618..d2fe30d 100644 --- a/app/src/lib/settingsImportPreview.test.ts +++ b/app/src/lib/settingsImportPreview.test.ts @@ -26,6 +26,7 @@ function preview(overrides: Partial = {}): SettingsImport 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 aa31429..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 @@ -91,12 +91,27 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[] ); } for (const c of preview.aws_connections) { - if (c.overwrites_existing) { - warnings.push(`Replaces your existing AWS connection with different settings: "${c.name}".`); + 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(`Overwrites the AWS access keys stored for "${c.name}".`); + 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( @@ -105,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/types.ts b/app/src/lib/types.ts index e1ff4a7..1920d61 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -429,6 +429,15 @@ export interface SettingsImportPreview { 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 { @@ -440,6 +449,10 @@ export interface AwsConnectionPreview { 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 -- 2.52.0 From 4acc36187eb6c0e68e2751807fb0c1ecc49c1ed8 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 15:53:47 -0800 Subject: [PATCH 07/23] feat(aws): SDK config from a connection, caller identity, principal resolution Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/Cargo.lock | 81 ++++++ app/src-tauri/Cargo.toml | 9 + app/src-tauri/src/aws/connection.rs | 199 +++++++++++++++ app/src-tauri/src/aws/error.rs | 136 ++++++++++ app/src-tauri/src/aws/identity.rs | 373 ++++++++++++++++++++++++++++ app/src-tauri/src/aws/mod.rs | 15 ++ app/src-tauri/src/lib.rs | 1 + 7 files changed, 814 insertions(+) create mode 100644 app/src-tauri/src/aws/connection.rs create mode 100644 app/src-tauri/src/aws/error.rs create mode 100644 app/src-tauri/src/aws/identity.rs create mode 100644 app/src-tauri/src/aws/mod.rs diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 0bd80c2..dd71867 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,55 @@ 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-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 +7214,14 @@ dependencies = [ "aes-gcm", "argon2", "aws-config", + "aws-credential-types", + "aws-runtime", + "aws-sdk-cloudformation", "aws-sdk-ecs", + "aws-sdk-iam", + "aws-sdk-secretsmanager", "aws-sdk-ssm", + "aws-sdk-sts", "axum", "base64 0.22.1", "bollard", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index cc2a08e..ee35018 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -62,6 +62,15 @@ 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 } # Linux only: WebKitGTK refuses getUserMedia until the embedder enables media # streams and answers the permission request (see src/linux_media.rs). Same diff --git a/app/src-tauri/src/aws/connection.rs b/app/src-tauri/src/aws/connection.rs new file mode 100644 index 0000000..f7ae6c4 --- /dev/null +++ b/app/src-tauri/src/aws/connection.rs @@ -0,0 +1,199 @@ +//! An `SdkConfig` built from a saved `HostConnection`, never from the +//! process's default credentials chain. + +use aws_config::profile::ProfileFileCredentialsProvider; +use aws_config::provider_config::ProviderConfig; +use aws_config::{BehaviorVersion, Region, SdkConfig}; +use aws_credential_types::provider::error::CredentialsError; +use aws_credential_types::provider::{future, ProvideCredentials}; +use aws_credential_types::Credentials; +use aws_runtime::env_config::file::EnvConfigFiles; + +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 mut loader = aws_config::defaults(BehaviorVersion::latest()).region(region.clone()); + match auth { + AwsAuth::Profile { name } => { + let mut builder = ProfileFileCredentialsProvider::builder() + .configure(&ProviderConfig::default().with_region(Some(region))) + .profile_name(name); + if let Some(files) = &profile_files { + builder = builder.profile_files(files.clone()); + loader = loader.profile_files(files.clone()); + } + // Non-credential settings (retry mode, endpoint overrides) come + // from the same profile the credentials do. + loader = loader + .profile_name(name) + .credentials_provider(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)?; + loader = loader.credentials_provider(Credentials::new( + keys.access_key_id.as_str(), + keys.secret_access_key.as_str(), + keys.session_token.clone(), + None, + "triple-c-connection", + )); + } + } + Ok(loader.load().await) +} + +/// 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")); + } +} diff --git a/app/src-tauri/src/aws/error.rs b/app/src-tauri/src/aws/error.rs new file mode 100644 index 0000000..22204be --- /dev/null +++ b/app/src-tauri/src/aws/error.rs @@ -0,0 +1,136 @@ +//! 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 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(), + }; + } + 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..2a60ff3 --- /dev/null +++ b/app/src-tauri/src/aws/identity.rs @@ -0,0 +1,373 @@ +//! 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::{classify_sdk_error, 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}" + ); + } + } + + #[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/mod.rs b/app/src-tauri/src/aws/mod.rs new file mode 100644 index 0000000..c7d0871 --- /dev/null +++ b/app/src-tauri/src/aws/mod.rs @@ -0,0 +1,15 @@ +//! AWS connection plumbing: an SDK config built from a saved +//! `HostConnection`, the caller's identity, and error classification. + +// The permission check (Task 5) and the Tauri commands (Task 6) wire these in. +#![allow(dead_code, unused_imports)] + +mod connection; +mod error; +mod identity; + +pub use connection::sdk_config; +pub use error::{classify_sdk_error, AwsError}; +pub use identity::{ + caller_identity, principal_for_simulation, resolve_principal, CallerIdentity, PrincipalArn, +}; diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index dd06e6a..de4040b 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; -- 2.52.0 From 92b27a8e32276f851894d62be3274574dd2adf07 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 15:59:31 -0800 Subject: [PATCH 08/23] fix(aws): hand-built SdkConfig ignores env/profile endpoints and settings; revoked SSO session is SsoExpired Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/Cargo.lock | 1 + app/src-tauri/Cargo.toml | 3 + app/src-tauri/src/aws/connection.rs | 141 +++++++++++++++++++++++----- app/src-tauri/src/aws/error.rs | 11 +++ app/src-tauri/src/aws/identity.rs | 39 ++++++++ 5 files changed, 172 insertions(+), 23 deletions(-) diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index dd71867..8276b18 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -7222,6 +7222,7 @@ dependencies = [ "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 ee35018..2cbba1a 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -71,6 +71,9 @@ aws-sdk-secretsmanager = { version = "1.128.0", default-features = false, featur # 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 diff --git a/app/src-tauri/src/aws/connection.rs b/app/src-tauri/src/aws/connection.rs index f7ae6c4..2b5f466 100644 --- a/app/src-tauri/src/aws/connection.rs +++ b/app/src-tauri/src/aws/connection.rs @@ -1,13 +1,17 @@ //! 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}; +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}; @@ -29,39 +33,63 @@ pub(crate) async fn sdk_config_with( ) -> Result { let HostKind::Aws { auth, .. } = &conn.kind; let region = Region::new(region.to_string()); - let mut loader = aws_config::defaults(BehaviorVersion::latest()).region(region.clone()); - match auth { + 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))) + .configure(&ProviderConfig::default().with_region(Some(region.clone()))) .profile_name(name); - if let Some(files) = &profile_files { - builder = builder.profile_files(files.clone()); - loader = loader.profile_files(files.clone()); + if let Some(files) = profile_files { + builder = builder.profile_files(files); } - // Non-credential settings (retry mode, endpoint overrides) come - // from the same profile the credentials do. - loader = loader - .profile_name(name) - .credentials_provider(NamedProfileProvider { - profile: name.clone(), - inner: builder.build(), - }); + 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)?; - loader = loader.credentials_provider(Credentials::new( - keys.access_key_id.as_str(), - keys.secret_access_key.as_str(), - keys.session_token.clone(), - None, - "triple-c-connection", - )); + keys_credentials(&keys) } + }; + Ok(build_sdk_config(region, credentials)) +} + +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); } - Ok(loader.load().await) + builder.build() } /// Puts the profile's name into the source chain of every credentials error, @@ -196,4 +224,71 @@ mod tests { .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 index 22204be..2ec0e8e 100644 --- a/app/src-tauri/src/aws/error.rs +++ b/app/src-tauri/src/aws/error.rs @@ -85,6 +85,10 @@ const SSO_EXPIRED: &[&str] = &[ "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", @@ -118,6 +122,13 @@ pub fn classify_sdk_error(action: &str, err: &(dyn Error + 'static)) -> AwsError 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(), diff --git a/app/src-tauri/src/aws/identity.rs b/app/src-tauri/src/aws/identity.rs index 2a60ff3..98b7fae 100644 --- a/app/src-tauri/src/aws/identity.rs +++ b/app/src-tauri/src/aws/identity.rs @@ -337,6 +337,45 @@ mod tests { } } + /// 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( -- 2.52.0 From 1c337f91986a5f52e0791caf5fb7424f7a590cca Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:06:33 -0800 Subject: [PATCH 09/23] feat(aws): shipped tier policies and the permission check table aws/policies/{workspace-user,environment-admin}.json are the IAM policies for the two tiers; aws::policies embeds them and holds one representative simulator check per action. Tests keep table and JSON in lock-step and run each check through a small IAM-style evaluator, including the isolation rule: owner-scoped actions are denied for someone else's resource. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/mod.rs | 1 + app/src-tauri/src/aws/policies.rs | 765 ++++++++++++++++++++++++++++ aws/README.md | 61 +++ aws/policies/environment-admin.json | 129 +++++ aws/policies/workspace-user.json | 151 ++++++ 5 files changed, 1107 insertions(+) create mode 100644 app/src-tauri/src/aws/policies.rs create mode 100644 aws/README.md create mode 100644 aws/policies/environment-admin.json create mode 100644 aws/policies/workspace-user.json diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index c7d0871..02f47b4 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -7,6 +7,7 @@ mod connection; mod error; mod identity; +pub mod policies; pub use connection::sdk_config; pub use error::{classify_sdk_error, AwsError}; diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs new file mode 100644 index 0000000..37aaa99 --- /dev/null +++ b/app/src-tauri/src/aws/policies.rs @@ -0,0 +1,765 @@ +//! The IAM policies Triple-C ships for each permission tier +//! (`aws/policies/*.json` at the repo root), and the table of +//! representative checks the permission check simulates against them. +//! +//! The table and the JSON are kept in lock-step by the tests below: every +//! action in a policy has a check, every check is allowed by its policy under +//! a small IAM-style evaluator, and every owner-scoped check is *denied* when +//! the resource belongs to someone else. + +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"), + } +} + +/// 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. `{region}` and `{account}` 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; `{region}` and `{account}` are substituted ([`render`]). + pub resource: &'static str, + pub context: &'static [(&'static str, ContextValue)], + pub write: bool, +} + +/// Substitutes `{region}` and `{account}` in a resource or literal template. +pub fn render(template: &str, region: &str, account: &str) -> String { + template + .replace("{region}", region) + .replace("{account}", account) +} + +pub fn checks(tier: Tier) -> &'static [Check] { + match tier { + Tier::WorkspaceUser => WORKSPACE_USER_CHECKS, + Tier::EnvironmentAdmin => ENVIRONMENT_ADMIN_CHECKS, + } +} + +use ContextValue::{Literal, OwnerId}; + +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"; + +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_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 fn write( + action: &'static str, + resource: &'static str, + context: &'static [(&'static str, ContextValue)], +) -> Check { + Check { + action, + resource, + context, + write: true, + } +} + +const fn read(action: &'static str, resource: &'static str) -> Check { + Check { + action, + resource, + context: &[], + write: false, + } +} + +const WORKSPACE_USER_CHECKS: &[Check] = &[ + write( + "ecs:RunTask", + PROBE_TASK_DEFINITION, + &[ + (OWNER_REQUEST_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)], + ), + 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", + "arn:aws:ssm:{region}:{account}:session/probe-0123456789abcdef0", + &[(SESSION_OWNER, OwnerId)], + ), + write( + "ssm:ResumeSession", + "arn:aws:ssm:{region}:{account}:session/probe-0123456789abcdef0", + &[(SESSION_OWNER, OwnerId)], + ), + write( + "secretsmanager:CreateSecret", + PROBE_SECRET, + &[(OWNER_REQUEST_TAG, OwnerId)], + ), + // 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)], + ), + write( + "kms:Encrypt", + PROBE_KEY, + &[( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + )], + ), + write( + "kms:GenerateDataKey", + PROBE_KEY, + &[( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + )], + ), + 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, &[]), + write("iam:DeleteRole", PROBE_ROLE, &[]), + write("iam:PutRolePolicy", PROBE_ROLE, &[]), + write("iam:DeleteRolePolicy", PROBE_ROLE, &[]), + write("iam:AttachRolePolicy", PROBE_ROLE, &[]), + write("iam:DetachRolePolicy", PROBE_ROLE, &[]), + write("iam:TagRole", PROBE_ROLE, &[]), + read("iam:GetRole", PROBE_ROLE), + write( + "iam:PassRole", + PROBE_ROLE, + &[("iam:PassedToService", Literal("ecs-tasks.amazonaws.com"))], + ), + 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", "*", &[]), + write("ec2:CreateSecurityGroup", "*", &[]), + write("ec2:DeleteSecurityGroup", "*", &[]), + write("ec2:AuthorizeSecurityGroupIngress", "*", &[]), + write("ec2:RevokeSecurityGroupIngress", "*", &[]), + write("ec2:CreateTags", "*", &[]), + read("ec2:DescribeSecurityGroups", "*"), + write("elasticfilesystem:CreateFileSystem", PROBE_FILE_SYSTEM, &[]), + write("elasticfilesystem:DeleteFileSystem", PROBE_FILE_SYSTEM, &[]), + write( + "elasticfilesystem:CreateMountTarget", + PROBE_FILE_SYSTEM, + &[], + ), + write( + "elasticfilesystem:DeleteMountTarget", + PROBE_FILE_SYSTEM, + &[], + ), + write( + "elasticfilesystem:PutFileSystemPolicy", + PROBE_FILE_SYSTEM, + &[], + ), + write("elasticfilesystem:TagResource", PROBE_FILE_SYSTEM, &[]), + write("kms:CreateKey", "*", &[]), + write("kms:ScheduleKeyDeletion", PROBE_KEY, &[]), + write("kms:PutKeyPolicy", PROBE_KEY, &[]), + write("kms:TagResource", PROBE_KEY, &[]), + // An alias call is authorized against both the alias and the key. + write( + "kms:CreateAlias", + "arn:aws:kms:{region}:{account}:alias/triple-c-probe", + &[], + ), + write("kms:CreateAlias", PROBE_KEY, &[]), + write( + "kms:DeleteAlias", + "arn:aws:kms:{region}:{account}:alias/triple-c-probe", + &[], + ), + write("kms:DeleteAlias", PROBE_KEY, &[]), + 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, &[]), + read("iam:SimulatePrincipalPolicy", "*"), +]; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use std::collections::BTreeSet; + + const REGION: &str = "us-west-2"; + const ACCOUNT: &str = "111122223333"; + 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 policy(tier: Tier) -> Value { + serde_json::from_str(policy_json(tier)) + .unwrap_or_else(|e| panic!("{tier:?} policy is not JSON: {e}")) + } + + fn statements(tier: Tier) -> Vec { + policy(tier)["Statement"] + .as_array() + .unwrap_or_else(|| panic!("{tier:?} policy has no Statement array")) + .clone() + } + + /// `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, REGION, ACCOUNT) + } + + /// Statements that name `check`'s action and one of whose resources + /// matches `check`'s rendered resource. + fn statements_for(tier: Tier, check: &Check) -> Vec { + let resource = rendered(check); + statements(tier) + .into_iter() + .filter(|s| actions(s).iter().any(|a| glob(a, check.action))) + .filter(|s| resources(s).iter().any(|r| glob(r, &resource))) + .collect() + } + + /// A deliberately small IAM evaluator for Allow statements: the + /// operators the shipped policies use, and nothing else (an unknown + /// operator fails the test rather than being ignored). `caller` is the + /// `${aws:userid}` of the principal; `owner` is what `OwnerId` context + /// entries carry (the resource's or request's owner tag). + fn conditions_hold(stmt: &Value, check: &Check, caller: &str, owner: &str) -> 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 (base, if_exists) = match op.strip_suffix("IfExists") { + Some(b) => (b, true), + None => (op.as_str(), false), + }; + let matches: fn(&str, &str) -> bool = match base { + "StringEquals" => |p, v| p == v, + "StringLike" | "ArnLike" => glob, + other => panic!("{}: unsupported condition operator {other}", sid(stmt)), + }; + entries + .as_object() + .expect("condition block is an object") + .iter() + .all(|(key, want)| { + let supplied = + check + .context + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| match v { + ContextValue::OwnerId => owner.to_string(), + ContextValue::Literal(l) => render(l, REGION, ACCOUNT), + }); + match supplied { + None => if_exists, + Some(value) => strings(want) + .iter() + .map(|p| p.replace("${aws:userid}", caller)) + .any(|p| matches(&p, &value)), + } + }) + }) + } + + fn allowed(tier: Tier, check: &Check, caller: &str, owner: &str) -> bool { + statements_for(tier, check) + .iter() + .any(|s| conditions_hold(s, check, caller, owner)) + } + + #[test] + fn policies_are_valid_iam_json() { + for tier in TIERS { + let p = policy(tier); + assert_eq!(p["Version"], "2012-10-17", "{tier:?}"); + let stmts = statements(tier); + assert!(!stmts.is_empty(), "{tier:?} has no statements"); + let mut sids = BTreeSet::new(); + for s in &stmts { + let id = s["Sid"] + .as_str() + .unwrap_or_else(|| panic!("{tier:?}: statement without Sid: {s}")); + assert!( + !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()), + "{tier:?}: Sid {id:?} must be non-empty alphanumeric" + ); + assert!(sids.insert(id.to_string()), "{tier:?}: duplicate Sid {id}"); + assert_eq!(s["Effect"], "Allow", "{tier:?} {id}"); + assert!(!actions(s).is_empty(), "{tier:?} {id}: no Action"); + assert!(!resources(s).is_empty(), "{tier:?} {id}: no Resource"); + for key in s.as_object().unwrap().keys() { + assert!( + ["Sid", "Effect", "Action", "Resource", "Condition"].contains(&key.as_str()), + "{tier:?} {id}: unexpected key {key} (NotAction/NotResource are not allowed)" + ); + } + } + } + } + + #[test] + fn every_policy_action_has_a_check_and_vice_versa() { + for tier in TIERS { + let policy_actions: BTreeSet = + statements(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 { + for c in checks(tier) { + assert!( + !statements_for(tier, c).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/", + "aws:RequestTag/triple-c:owner", + ), + ("ecs:TagResource", "task/", "aws:RequestTag/triple-c:owner"), + ("ecs:StopTask", "task/", "aws:ResourceTag/triple-c:owner"), + ( + "ecs:ExecuteCommand", + "task/", + "aws:ResourceTag/triple-c:owner", + ), + ( + "ssm:StartSession", + "task/", + "aws:ResourceTag/triple-c:owner", + ), + ( + "ssm:TerminateSession", + "session/", + "ssm:resourceTag/aws:ssmmessages:session-id", + ), + ( + "ssm:ResumeSession", + "session/", + "ssm:resourceTag/aws:ssmmessages:session-id", + ), + ("iam:PassRole", "role/", "iam:ResourceTag/triple-c:owner"), + ( + "secretsmanager:CreateSecret", + "secret:", + "aws:RequestTag/triple-c:owner", + ), + ( + "secretsmanager:TagResource", + "secret:", + "aws:RequestTag/triple-c:owner", + ), + ( + "secretsmanager:PutSecretValue", + "secret:", + "aws:ResourceTag/triple-c:owner", + ), + ( + "secretsmanager:DeleteSecret", + "secret:", + "aws:ResourceTag/triple-c:owner", + ), + ]; + + 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 = statements_for(Tier::WorkspaceUser, check); + assert!( + stmts + .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 by any statement", + 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 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 + }; + // A secret with no owner tag yet is only tag-on-create, which + // still needs the request tag — so every case must be denied. + assert!( + !allowed(Tier::WorkspaceUser, &untagged, ME, ME), + "{} is allowed without any owner context", + check.action + ); + } + } + + #[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: &[("aws:RequestTag/triple-c:owner", ContextValue::OwnerId)], + ..*tag + }; + assert!( + !allowed(Tier::WorkspaceUser, &retag, ME, ME), + "ecs:TagResource must require ecs:CreateAction" + ); + let other_create = Check { + context: &[ + ("aws:RequestTag/triple-c:owner", ContextValue::OwnerId), + ("ecs:CreateAction", ContextValue::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", ContextValue::OwnerId), + ( + "iam:PassedToService", + ContextValue::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", + ContextValue::Literal("s3.{region}.amazonaws.com"), + )], + ..*c + }; + assert!( + !allowed(Tier::WorkspaceUser, &via_s3, ME, ME), + "{} allowed via S3", + c.action + ); + } + } + + #[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); + } + } + } +} diff --git a/aws/README.md b/aws/README.md new file mode 100644 index 0000000..64797e1 --- /dev/null +++ b/aws/README.md @@ -0,0 +1,61 @@ +# Triple-C AWS policies + +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 checks which tier the +signed-in identity has under **Settings → Hosts → Check**. + +| File | Tier | Grants | +|---|---|---| +| `policies/workspace-user.json` | Workspace user | Create, start, stop, connect to and delete **their own** workspaces in an existing environment | +| `policies/environment-admin.json` | Environment admin | Deploy, update and delete the environment stack, and onboard users | + +An environment admin needs **both** files: the admin policy adds environment management on top of +everything a workspace user can do. + +## Workspace user + +Every per-user resource is tagged `triple-c:owner = ${aws:userid}` (the `UserId` that +`aws sts get-caller-identity` prints). The policy uses that tag so a user can only: + +- run tasks from `triple-c-*` task definitions on `triple-c-*` clusters, tagged with their own + owner ID, passing only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks; +- stop, `ecs execute-command` into, or open an SSM session to **their own** tasks, and end or + resume only sessions they started; +- create, update and delete `triple-c/*` secrets tagged with their owner ID, using KMS only + through Secrets Manager; +- read: 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 + +Manage `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, KMS +keys, `/triple-c/*` log groups, ECR repositories and pull-through cache rules, and `/triple-c/*` +SSM parameters. It also grants `iam:SimulatePrincipalPolicy`, which the app's permission check +uses. + +This is an account-level role: it can create and modify `triple-c-*` IAM roles, so grant it only +to people you would trust to administer the account. Consider pairing it with a permissions +boundary. + +## Attaching + +Console: **IAM → Policies → Create policy → JSON**, paste the file, then attach the policy to a +user, group or role. For IAM Identity Center, add it as an inline policy on the permission set. + +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 +``` + +## Verification + +The app's permission-check table (`app/src-tauri/src/aws/policies.rs`) is unit-tested against +these files: every action has a check, and the owner-scoped actions are denied for someone else's +resources. Every statement is also verified against a real account with the IAM policy simulator +by the live permission test before release. The simulator does not apply SCPs, permission +boundaries or 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..4fe394d --- /dev/null +++ b/aws/policies/environment-admin.json @@ -0,0 +1,129 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ManageEnvironmentStacks", + "Effect": "Allow", + "Action": ["cloudformation:*"], + "Resource": ["arn:aws:cloudformation:*:*:stack/triple-c-*/*"] + }, + { + "Sid": "ManageTripleCRoles", + "Effect": "Allow", + "Action": [ + "iam:CreateRole", + "iam:DeleteRole", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "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": "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": "ManageSecurityGroups", + "Effect": "Allow", + "Action": [ + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroup*", + "ec2:RevokeSecurityGroup*", + "ec2:CreateTags", + "ec2:Describe*" + ], + "Resource": ["*"] + }, + { + "Sid": "ManageFileSystems", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:CreateFileSystem", + "elasticfilesystem:DeleteFileSystem", + "elasticfilesystem:CreateMountTarget", + "elasticfilesystem:DeleteMountTarget", + "elasticfilesystem:PutFileSystemPolicy", + "elasticfilesystem:TagResource" + ], + "Resource": ["arn:aws:elasticfilesystem:*:*:file-system/*"] + }, + { + "Sid": "CreateKeys", + "Effect": "Allow", + "Action": ["kms:CreateKey"], + "Resource": ["*"] + }, + { + "Sid": "ManageKeys", + "Effect": "Allow", + "Action": ["kms:ScheduleKeyDeletion", "kms:PutKeyPolicy", "kms:TagResource"], + "Resource": ["arn:aws:kms:*:*:key/*"] + }, + { + "Sid": "ManageKeyAliases", + "Effect": "Allow", + "Action": ["kms:CreateAlias", "kms:DeleteAlias"], + "Resource": ["arn:aws:kms:*:*:alias/triple-c-*", "arn:aws:kms:*:*:key/*"] + }, + { + "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/*"] + }, + { + "Sid": "CheckPermissions", + "Effect": "Allow", + "Action": ["iam:SimulatePrincipalPolicy"], + "Resource": ["*"] + } + ] +} diff --git a/aws/policies/workspace-user.json b/aws/policies/workspace-user.json new file mode 100644 index 0000000..3126809 --- /dev/null +++ b/aws/policies/workspace-user.json @@ -0,0 +1,151 @@ +{ + "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}" }, + "ArnLike": { "ecs:cluster": "arn:aws:ecs:*:*:cluster/triple-c-*" } + } + }, + { + "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}" + } + } + }, + { + "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": "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": "EndOrResumeOwnSessions", + "Effect": "Allow", + "Action": ["ssm:TerminateSession", "ssm:ResumeSession"], + "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}" } + } + }, + { + "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}" } + } + }, + { + "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": "EncryptThroughSecretsManagerOnly", + "Effect": "Allow", + "Action": ["kms:Encrypt", "kms:GenerateDataKey"], + "Resource": ["arn:aws:kms:*:*:key/*"], + "Condition": { + "StringLike": { "kms:ViaService": "secretsmanager.*.amazonaws.com" } + } + }, + { + "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": "WhoAmI", + "Effect": "Allow", + "Action": ["sts:GetCallerIdentity"], + "Resource": ["*"] + } + ] +} -- 2.52.0 From 02d88cb91d1fecaa89780b7224d03c226255e133 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:15:57 -0800 Subject: [PATCH 10/23] fix(aws): bound admin-created roles, tag-scope admin destruction, tighten workspace policy - Ship aws/policies/permissions-boundary.json (triple-c-permissions-boundary): the most any Triple-C role may hold, nothing in IAM. - environment-admin: role writes require iam:PermissionsBoundary = the boundary; explicit Denies keep the boundary on roles and the boundary policy unchanged. Security groups, EFS file systems and KMS keys need triple-c:component on create and to be modified or destroyed; tagging only at create or on already-tagged resources. - workspace-user: RunTask needs an owner-tagged task definition; EFS access points create/delete by owner tag. - Both tiers: iam:GetRole and iam:SimulatePrincipalPolicy on the caller's own ${aws:PrincipalArn} only, so SSO callers can be simulated. - Checks carry the matching context; render() gains {principal}. The test evaluator resolves policy variables and applies explicit Denies. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/policies.rs | 917 +++++++++++++++++++------ aws/README.md | 119 +++- aws/policies/environment-admin.json | 138 +++- aws/policies/permissions-boundary.json | 81 +++ aws/policies/workspace-user.json | 251 +++++-- 5 files changed, 1209 insertions(+), 297 deletions(-) create mode 100644 aws/policies/permissions-boundary.json diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index 37aaa99..284ba08 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -1,11 +1,14 @@ //! The IAM policies Triple-C ships for each permission tier -//! (`aws/policies/*.json` at the repo root), and the table of -//! representative checks the permission check simulates against them. +//! (`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 -//! action in a policy has a check, every check is allowed by its policy under -//! a small IAM-style evaluator, and every owner-scoped check is *denied* when -//! the resource belongs to someone else. +//! 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}; @@ -24,13 +27,23 @@ pub fn policy_json(tier: Tier) -> &'static str { } } +/// The managed policy (no path) every role an environment admin creates must +/// carry as its permissions boundary. +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. +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. `{region}` and `{account}` are substituted exactly as in + /// A fixed value. Placeholders are substituted exactly as in /// [`Check::resource`] — use [`render`]. Literal(&'static str), } @@ -40,17 +53,31 @@ pub enum ContextValue { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Check { pub action: &'static str, - /// An ARN template; `{region}` and `{account}` are substituted ([`render`]). + /// An ARN template ([`render`]). pub resource: &'static str, pub context: &'static [(&'static str, ContextValue)], pub write: bool, } -/// Substitutes `{region}` and `{account}` in a resource or literal template. -pub fn render(template: &str, region: &str, account: &str) -> String { +/// 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`, which is what `${aws:PrincipalArn}` resolves to). + pub principal: &'a str, +} + +/// Substitutes `{region}`, `{account}` and `{principal}` in a resource or +/// [`ContextValue::Literal`] template. +pub fn render(template: &str, p: &Placeholders) -> String { template - .replace("{region}", region) - .replace("{account}", account) + .replace("{region}", p.region) + .replace("{account}", p.account) + .replace("{principal}", p.principal) } pub fn checks(tier: Tier) -> &'static [Check] { @@ -62,11 +89,29 @@ pub fn checks(tier: Tier) -> &'static [Check] { 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"; +/// `${aws:PrincipalArn}` in a Resource resolves from this context entry. +const OWN_PRINCIPAL: Context = &[("aws:PrincipalArn", Literal("{principal}"))]; +/// 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_TAGGED: Context = &[("aws:ResourceTag/triple-c:component", 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"; @@ -76,18 +121,21 @@ 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: &'static [(&'static str, ContextValue)], -) -> Check { +const fn write(action: &'static str, resource: &'static str, context: Context) -> Check { Check { action, resource, @@ -96,11 +144,11 @@ const fn write( } } -const fn read(action: &'static str, resource: &'static str) -> Check { +const fn read(action: &'static str, resource: &'static str, context: Context) -> Check { Check { action, resource, - context: &[], + context, write: false, } } @@ -111,6 +159,8 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ 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)), ], ), @@ -149,12 +199,12 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ ), write( "ssm:TerminateSession", - "arn:aws:ssm:{region}:{account}:session/probe-0123456789abcdef0", + PROBE_SESSION, &[(SESSION_OWNER, OwnerId)], ), write( "ssm:ResumeSession", - "arn:aws:ssm:{region}:{account}:session/probe-0123456789abcdef0", + PROBE_SESSION, &[(SESSION_OWNER, OwnerId)], ), write( @@ -178,6 +228,27 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ PROBE_SECRET, &[(OWNER_RESOURCE_TAG, OwnerId)], ), + write( + "elasticfilesystem:CreateAccessPoint", + PROBE_FILE_SYSTEM, + &[(OWNER_REQUEST_TAG, OwnerId)], + ), + 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, @@ -194,38 +265,43 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ Literal("secretsmanager.{region}.amazonaws.com"), )], ), - 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("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", "*"), + 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, &[]), + 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:PutRolePolicy", PROBE_ROLE, &[]), - write("iam:DeleteRolePolicy", PROBE_ROLE, &[]), - write("iam:AttachRolePolicy", PROBE_ROLE, &[]), - write("iam:DetachRolePolicy", PROBE_ROLE, &[]), write("iam:TagRole", PROBE_ROLE, &[]), - read("iam:GetRole", 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, &[]), @@ -241,47 +317,82 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ ), write("ecs:RegisterTaskDefinition", "*", &[]), write("ecs:DeregisterTaskDefinition", "*", &[]), - write("ec2:CreateSecurityGroup", "*", &[]), - write("ec2:DeleteSecurityGroup", "*", &[]), - write("ec2:AuthorizeSecurityGroupIngress", "*", &[]), - write("ec2:RevokeSecurityGroupIngress", "*", &[]), - write("ec2:CreateTags", "*", &[]), - read("ec2:DescribeSecurityGroups", "*"), - write("elasticfilesystem:CreateFileSystem", PROBE_FILE_SYSTEM, &[]), - write("elasticfilesystem:DeleteFileSystem", PROBE_FILE_SYSTEM, &[]), + // 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", "*", &[]), + write( + "elasticfilesystem:CreateFileSystem", + PROBE_FILE_SYSTEM, + 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("elasticfilesystem:TagResource", PROBE_FILE_SYSTEM, &[]), - write("kms:CreateKey", "*", &[]), - write("kms:ScheduleKeyDeletion", PROBE_KEY, &[]), - write("kms:PutKeyPolicy", PROBE_KEY, &[]), - write("kms:TagResource", PROBE_KEY, &[]), + 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", - "arn:aws:kms:{region}:{account}:alias/triple-c-probe", - &[], - ), - write("kms:CreateAlias", PROBE_KEY, &[]), - write( - "kms:DeleteAlias", - "arn:aws:kms:{region}:{account}:alias/triple-c-probe", - &[], - ), - write("kms:DeleteAlias", PROBE_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, &[]), @@ -298,7 +409,6 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ write("ecr:CreatePullThroughCacheRule", "*", &[]), write("ssm:PutParameter", PROBE_PARAMETER, &[]), write("ssm:DeleteParameter", PROBE_PARAMETER, &[]), - read("iam:SimulatePrincipalPolicy", "*"), ]; #[cfg(test)] @@ -307,8 +417,11 @@ mod tests { use serde_json::Value; use std::collections::BTreeSet; - const REGION: &str = "us-west-2"; - const ACCOUNT: &str = "111122223333"; + 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"; @@ -359,16 +472,45 @@ mod tests { 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 { - serde_json::from_str(policy_json(tier)) - .unwrap_or_else(|e| panic!("{tier:?} policy is not JSON: {e}")) + 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 { - policy(tier)["Statement"] - .as_array() - .unwrap_or_else(|| panic!("{tier:?} policy has no Statement array")) - .clone() + 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. @@ -396,31 +538,90 @@ mod tests { } fn rendered(check: &Check) -> String { - render(check.resource, REGION, ACCOUNT) + render(check.resource, &AT) } - /// Statements that name `check`'s action and one of whose resources - /// matches `check`'s rendered resource. - fn statements_for(tier: Tier, check: &Check) -> Vec { - let resource = rendered(check); - statements(tier) - .into_iter() - .filter(|s| actions(s).iter().any(|a| glob(a, check.action))) - .filter(|s| resources(s).iter().any(|r| glob(r, &resource))) + /// 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.check + .context + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| match v { + ContextValue::OwnerId => self.owner.to_string(), + ContextValue::Literal(l) => render(l, &AT), + }) + } + + /// 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 evaluator for Allow statements: the - /// operators the shipped policies use, and nothing else (an unknown - /// operator fails the test rather than being ignored). `caller` is the - /// `${aws:userid}` of the principal; `owner` is what `OwnerId` context - /// entries carry (the resource's or request's owner tag). - fn conditions_hold(stmt: &Value, check: &Check, caller: &str, owner: &str) -> bool { + /// 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"); + 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), @@ -431,71 +632,88 @@ mod tests { other => panic!("{}: unsupported condition operator {other}", sid(stmt)), }; entries - .as_object() - .expect("condition block is an object") .iter() - .all(|(key, want)| { - let supplied = - check - .context - .iter() - .find(|(k, _)| k == key) - .map(|(_, v)| match v { - ContextValue::OwnerId => owner.to_string(), - ContextValue::Literal(l) => render(l, REGION, ACCOUNT), - }); - match supplied { - None => if_exists, - Some(value) => strings(want) - .iter() - .map(|p| p.replace("${aws:userid}", caller)) - .any(|p| matches(&p, &value)), - } + .all(|(key, want)| match req.context_value(key) { + None => if_exists, + Some(value) => strings(want) + .iter() + .filter_map(|p| req.resolve(p)) + .any(|p| matches(&p, &value)), }) }) } - fn allowed(tier: Tier, check: &Check, caller: &str, owner: &str) -> bool { - statements_for(tier, check) - .iter() - .any(|s| conditions_hold(s, check, caller, owner)) + fn evaluate(tier: Tier, req: Request) -> bool { + let stmts = statements(tier); + 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 } - #[test] - fn policies_are_valid_iam_json() { - for tier in TIERS { - let p = policy(tier); - assert_eq!(p["Version"], "2012-10-17", "{tier:?}"); - let stmts = statements(tier); - assert!(!stmts.is_empty(), "{tier:?} has no statements"); - let mut sids = BTreeSet::new(); - for s in &stmts { - let id = s["Sid"] - .as_str() - .unwrap_or_else(|| panic!("{tier:?}: statement without Sid: {s}")); + 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!( - !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()), - "{tier:?}: Sid {id:?} must be non-empty alphanumeric" + ["Sid", "Effect", "Action", "Resource", "Condition"].contains(&key.as_str()), + "{name} {id}: unexpected key {key} (NotAction/NotResource are not allowed)" ); - assert!(sids.insert(id.to_string()), "{tier:?}: duplicate Sid {id}"); - assert_eq!(s["Effect"], "Allow", "{tier:?} {id}"); - assert!(!actions(s).is_empty(), "{tier:?} {id}: no Action"); - assert!(!resources(s).is_empty(), "{tier:?} {id}: no Resource"); - for key in s.as_object().unwrap().keys() { - assert!( - ["Sid", "Effect", "Action", "Resource", "Condition"].contains(&key.as_str()), - "{tier:?} {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"); + } + assert!( + denies(Tier::WorkspaceUser).is_empty(), + "workspace-user needs no Deny" + ); + validate_document("permissions boundary", &boundary()); + } + #[test] fn every_policy_action_has_a_check_and_vice_versa() { for tier in TIERS { - let policy_actions: BTreeSet = - statements(tier).iter().flat_map(actions).collect(); + 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)), @@ -520,9 +738,15 @@ mod tests { #[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!( - !statements_for(tier, c).is_empty(), + !matching(&stmts, &req).is_empty(), "{tier:?}: {} on {} matches no statement resource", c.action, rendered(c) @@ -539,53 +763,37 @@ mod tests { /// 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/", - "aws:RequestTag/triple-c:owner", - ), - ("ecs:TagResource", "task/", "aws:RequestTag/triple-c:owner"), - ("ecs:StopTask", "task/", "aws:ResourceTag/triple-c:owner"), - ( - "ecs:ExecuteCommand", - "task/", - "aws:ResourceTag/triple-c:owner", - ), - ( - "ssm:StartSession", - "task/", - "aws:ResourceTag/triple-c:owner", - ), - ( - "ssm:TerminateSession", - "session/", - "ssm:resourceTag/aws:ssmmessages:session-id", - ), - ( - "ssm:ResumeSession", - "session/", - "ssm:resourceTag/aws:ssmmessages:session-id", - ), + ("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:", - "aws:RequestTag/triple-c:owner", - ), - ( - "secretsmanager:TagResource", - "secret:", - "aws:RequestTag/triple-c:owner", - ), + ("secretsmanager:CreateSecret", "secret:", OWNER_REQUEST_TAG), + ("secretsmanager:TagResource", "secret:", OWNER_REQUEST_TAG), ( "secretsmanager:PutSecretValue", "secret:", - "aws:ResourceTag/triple-c:owner", + OWNER_RESOURCE_TAG, + ), + ("secretsmanager:DeleteSecret", "secret:", OWNER_RESOURCE_TAG), + ( + "elasticfilesystem:CreateAccessPoint", + "file-system/", + OWNER_REQUEST_TAG, ), ( - "secretsmanager:DeleteSecret", - "secret:", - "aws:ResourceTag/triple-c:owner", + "elasticfilesystem:TagResource", + "access-point/", + OWNER_REQUEST_TAG, + ), + ( + "elasticfilesystem:DeleteAccessPoint", + "access-point/", + OWNER_RESOURCE_TAG, ), ]; @@ -614,9 +822,16 @@ mod tests { check.context ); // ...and that is the key its statement's condition uses. - let stmts = statements_for(Tier::WorkspaceUser, check); + 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!( - stmts + found .iter() .all(|s| s["Condition"] .as_object() @@ -635,7 +850,7 @@ mod tests { for c in checks(tier) { assert!( allowed(tier, c, ME, ME), - "{tier:?}: {} on {} with {:?} is not allowed by any statement", + "{tier:?}: {} on {} with {:?} is not allowed", c.action, rendered(c), c.context @@ -645,8 +860,8 @@ mod tests { } /// The spec's most important rule: nobody may stop, exec into, open a - /// session to, pass the role of, or touch the secrets of a workspace - /// they don't own. + /// 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() { @@ -666,8 +881,6 @@ mod tests { context: &[], ..*check }; - // A secret with no owner tag yet is only tag-on-create, which - // still needs the request tag — so every case must be denied. assert!( !allowed(Tier::WorkspaceUser, &untagged, ME, ME), "{} is allowed without any owner context", @@ -676,6 +889,40 @@ mod tests { } } + #[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) @@ -683,7 +930,7 @@ mod tests { .find(|c| c.action == "ecs:TagResource") .expect("ecs:TagResource check"); let retag = Check { - context: &[("aws:RequestTag/triple-c:owner", ContextValue::OwnerId)], + context: &[(OWNER_REQUEST_TAG, OwnerId)], ..*tag }; assert!( @@ -692,8 +939,8 @@ mod tests { ); let other_create = Check { context: &[ - ("aws:RequestTag/triple-c:owner", ContextValue::OwnerId), - ("ecs:CreateAction", ContextValue::Literal("CreateService")), + (OWNER_REQUEST_TAG, OwnerId), + ("ecs:CreateAction", Literal("CreateService")), ], ..*tag }; @@ -708,11 +955,8 @@ mod tests { .expect("iam:PassRole check"); let to_ec2 = Check { context: &[ - ("iam:ResourceTag/triple-c:owner", ContextValue::OwnerId), - ( - "iam:PassedToService", - ContextValue::Literal("ec2.amazonaws.com"), - ), + ("iam:ResourceTag/triple-c:owner", OwnerId), + ("iam:PassedToService", Literal("ec2.amazonaws.com")), ], ..*pass }; @@ -737,10 +981,7 @@ mod tests { c.action ); let via_s3 = Check { - context: &[( - "kms:ViaService", - ContextValue::Literal("s3.{region}.amazonaws.com"), - )], + context: &[("kms:ViaService", Literal("s3.{region}.amazonaws.com"))], ..*c }; assert!( @@ -751,6 +992,288 @@ mod tests { } } + /// GetRole and SimulatePrincipalPolicy: the caller's own principal only. + #[test] + fn principal_inspection_is_only_of_the_caller() { + 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 other = Check { + resource: "arn:aws:iam::{account}:role/SomeoneElsesRole", + ..*own + }; + assert!( + !allowed(tier, &other, ME, ME), + "{tier:?}: {action} allowed on another principal" + ); + } + } + } + + 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", PROBE_FILE_SYSTEM), + ]; + 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", + "elasticfilesystem:ClientRootAccess", + "sts:GetCallerIdentity", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + ] { + assert!( + granted.iter().any(|a| glob(a, need)), + "boundary does not grant {need}" + ); + } + // Secrets only through Secrets Manager, and only Triple-C's. + 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, "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"]; diff --git a/aws/README.md b/aws/README.md index 64797e1..17de1b7 100644 --- a/aws/README.md +++ b/aws/README.md @@ -4,41 +4,83 @@ IAM policies for running Triple-C workspaces on AWS (ECS). Attach them to the IA roles or SSO permission sets of the people who use Triple-C. The app checks which tier the signed-in identity has under **Settings → Hosts → Check**. -| File | Tier | Grants | -|---|---|---| -| `policies/workspace-user.json` | Workspace user | Create, start, stop, connect to and delete **their own** workspaces in an existing environment | -| `policies/environment-admin.json` | Environment admin | Deploy, update and delete the environment stack, and onboard users | +| File | What it is | +|---|---| +| `policies/workspace-user.json` | **Workspace user** tier: create, start, stop, connect to and delete **their own** workspaces in an existing environment | +| `policies/environment-admin.json` | **Environment admin** tier: deploy, update and delete the environment stack, and onboard users | +| `policies/permissions-boundary.json` | The **permissions boundary** every role Triple-C creates must carry. It is not attached to people | -An environment admin needs **both** files: the admin policy adds environment management on top of -everything a workspace user can do. +An environment admin needs **both** tier files: the admin policy adds environment management on +top of everything a workspace user can do. ## Workspace user Every per-user resource is tagged `triple-c:owner = ${aws:userid}` (the `UserId` that -`aws sts get-caller-identity` prints). The policy uses that tag so a user can only: +`aws sts get-caller-identity` prints). The policy uses that tag so that a user can only do the +following. -- run tasks from `triple-c-*` task definitions on `triple-c-*` clusters, tagged with their own - owner ID, passing only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks; -- stop, `ecs execute-command` into, or open an SSM session to **their own** tasks, and end or - resume only sessions they started; -- create, update and delete `triple-c/*` secrets tagged with their owner ID, using KMS only - through Secrets Manager; -- read: 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. +- **Run tasks:** + - from `triple-c-*` task definitions tagged with their own owner ID, on `triple-c-*` clusters; + - with the new task tagged with their own owner ID; + - passing only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks. +- **Their own tasks:** stop them, `ecs execute-command` into them, or open an SSM session to + them. They can end or resume only sessions they started. +- **Secrets:** create, update and delete `triple-c/*` secrets tagged with their owner ID, using + KMS only through Secrets Manager. +- **EFS access points:** create and delete access points tagged with their owner ID. +- **Their own principal:** read it (`iam:GetRole`) and simulate it + (`iam:SimulatePrincipalPolicy`). The app's permission check uses both. +- **Read:** 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 -Manage `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, KMS -keys, `/triple-c/*` log groups, ECR repositories and pull-through cache rules, and `/triple-c/*` -SSM parameters. It also grants `iam:SimulatePrincipalPolicy`, which the app's permission check -uses. +The admin tier manages `triple-c-*` CloudFormation stacks and the resources they create: -This is an account-level role: it can create and modify `triple-c-*` IAM roles, so grant it only -to people you would trust to administer the account. Consider pairing it with a permissions -boundary. +- `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. -## Attaching +It is fenced in two ways. + +**Roles are bounded.** The admin can create a `triple-c-*` role or change its policies only if 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; +- mounting EFS; +- `sts:GetCallerIdentity`; +- invoking Bedrock models. + +It grants nothing in IAM. Explicit Deny statements stop the admin from removing the boundary from +a role, and from editing or deleting the boundary policy itself. Without these fences, an admin +could create a role with any permissions and become an account administrator. + +**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 modified or deleted only if they carry one, so the +admin tier cannot touch resources that aren't Triple-C's. Tagging is allowed only as part of the +creating call, or on a resource that is already Triple-C's. + +## One-time setup: the boundary policy + +Before anyone can deploy an environment, an **account administrator** creates the boundary policy +once. 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 it **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 (for example, as part of the environment stack's prerequisites). + +## Attaching the tier policies Console: **IAM → Policies → Create policy → JSON**, paste the file, then attach the policy to a user, group or role. For IAM Identity Center, add it as an inline policy on the permission set. @@ -52,10 +94,31 @@ aws iam attach-user-policy --user-name \ --policy-arn arn:aws:iam:::policy/triple-c-workspace-user ``` +## Known gaps (phase 4) + +These arrive with the environment and onboarding templates. + +- **Per-user execution roles.** A workspace user can pass only roles tagged with their own owner + ID, so each user's onboarding stack must create their execution role as well as their task + role. Alternatively, a separate statement can allow the environment's shared execution role. +- **The admin's full deploy permissions.** Deploying a stack will also need: + - 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. + + These will be added, with the same boundary and tag scoping, once the templates exist. + ## Verification The app's permission-check table (`app/src-tauri/src/aws/policies.rs`) is unit-tested against -these files: every action has a check, and the owner-scoped actions are denied for someone else's -resources. Every statement is also verified against a real account with the IAM policy simulator -by the live permission test before release. The simulator does not apply SCPs, permission -boundaries or session policies, so the first real create is the final check. +these files with a small IAM-style evaluator. The tests check that: + +- every allowed action has a check; +- owner-scoped actions are denied for someone else's resources; +- every role write requires the boundary; +- destructive admin actions need the `triple-c:component` tag. + +Before release, every statement is also verified against a real account with the IAM policy +simulator, by the live permission test. The simulator does not apply SCPs, permission boundaries +or 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 index 4fe394d..ece6c13 100644 --- a/aws/policies/environment-admin.json +++ b/aws/policies/environment-admin.json @@ -8,18 +8,26 @@ "Resource": ["arn:aws:cloudformation:*:*:stack/triple-c-*/*"] }, { - "Sid": "ManageTripleCRoles", + "Sid": "ManageBoundedTripleCRoles", "Effect": "Allow", "Action": [ "iam:CreateRole", - "iam:DeleteRole", "iam:PutRolePolicy", - "iam:DeleteRolePolicy", "iam:AttachRolePolicy", "iam:DetachRolePolicy", - "iam:TagRole", - "iam:GetRole" + "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-*"] }, { @@ -33,6 +41,29 @@ } } }, + { + "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": "InspectOwnPrincipal", + "Effect": "Allow", + "Action": ["iam:GetRole", "iam:SimulatePrincipalPolicy"], + "Resource": ["${aws:PrincipalArn}"] + }, { "Sid": "ManageClusters", "Effect": "Allow", @@ -52,48 +83,111 @@ "Resource": ["*"] }, { - "Sid": "ManageSecurityGroups", + "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:CreateSecurityGroup", "ec2:DeleteSecurityGroup", "ec2:AuthorizeSecurityGroup*", "ec2:RevokeSecurityGroup*", - "ec2:CreateTags", - "ec2:Describe*" + "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": "ManageFileSystems", + "Sid": "CreateTaggedFileSystems", + "Effect": "Allow", + "Action": ["elasticfilesystem:CreateFileSystem"], + "Resource": ["arn:aws:elasticfilesystem:*:*:file-system/*"], + "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:CreateFileSystem", "elasticfilesystem:DeleteFileSystem", "elasticfilesystem:CreateMountTarget", "elasticfilesystem:DeleteMountTarget", "elasticfilesystem:PutFileSystemPolicy", "elasticfilesystem:TagResource" ], - "Resource": ["arn:aws:elasticfilesystem:*:*:file-system/*"] + "Resource": ["arn:aws:elasticfilesystem:*:*:file-system/*"], + "Condition": { + "Null": { "aws:ResourceTag/triple-c:component": "false" } + } }, { - "Sid": "CreateKeys", + "Sid": "CreateTaggedKeys", "Effect": "Allow", "Action": ["kms:CreateKey"], - "Resource": ["*"] + "Resource": ["*"], + "Condition": { + "Null": { "aws:RequestTag/triple-c:component": "false" } + } }, { - "Sid": "ManageKeys", + "Sid": "ManageTripleCKeys", "Effect": "Allow", - "Action": ["kms:ScheduleKeyDeletion", "kms:PutKeyPolicy", "kms:TagResource"], - "Resource": ["arn:aws:kms:*:*:key/*"] + "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": "ManageKeyAliases", + "Sid": "ManageTripleCKeyAliases", "Effect": "Allow", "Action": ["kms:CreateAlias", "kms:DeleteAlias"], - "Resource": ["arn:aws:kms:*:*:alias/triple-c-*", "arn:aws:kms:*:*:key/*"] + "Resource": ["arn:aws:kms:*:*:alias/triple-c-*"] }, { "Sid": "ManageLogGroups", @@ -118,12 +212,6 @@ "Effect": "Allow", "Action": ["ssm:PutParameter", "ssm:DeleteParameter"], "Resource": ["arn:aws:ssm:*:*:parameter/triple-c/*"] - }, - { - "Sid": "CheckPermissions", - "Effect": "Allow", - "Action": ["iam:SimulatePrincipalPolicy"], - "Resource": ["*"] } ] } diff --git a/aws/policies/permissions-boundary.json b/aws/policies/permissions-boundary.json new file mode 100644 index 0000000..95be78a --- /dev/null +++ b/aws/policies/permissions-boundary.json @@ -0,0 +1,81 @@ +{ + "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" } + } + }, + { + "Sid": "MountFileSystems", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:ClientMount", + "elasticfilesystem:ClientWrite", + "elasticfilesystem:ClientRootAccess" + ], + "Resource": ["arn:aws:elasticfilesystem:*:*:file-system/*"] + }, + { + "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 index 3126809..71362b7 100644 --- a/aws/policies/workspace-user.json +++ b/aws/policies/workspace-user.json @@ -4,18 +4,31 @@ { "Sid": "RunOwnTasks", "Effect": "Allow", - "Action": ["ecs:RunTask"], - "Resource": ["arn:aws:ecs:*:*:task-definition/triple-c-*"], + "Action": [ + "ecs:RunTask" + ], + "Resource": [ + "arn:aws:ecs:*:*:task-definition/triple-c-*" + ], "Condition": { - "StringEquals": { "aws:RequestTag/triple-c:owner": "${aws:userid}" }, - "ArnLike": { "ecs:cluster": "arn:aws:ecs:*:*:cluster/triple-c-*" } + "StringEquals": { + "aws:RequestTag/triple-c:owner": "${aws:userid}", + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + }, + "ArnLike": { + "ecs:cluster": "arn:aws:ecs:*:*:cluster/triple-c-*" + } } }, { "Sid": "TagOwnTasksAtRunTask", "Effect": "Allow", - "Action": ["ecs:TagResource"], - "Resource": ["arn:aws:ecs:*:*:task/triple-c-*/*"], + "Action": [ + "ecs:TagResource" + ], + "Resource": [ + "arn:aws:ecs:*:*:task/triple-c-*/*" + ], "Condition": { "StringEquals": { "ecs:CreateAction": "RunTask", @@ -26,8 +39,12 @@ { "Sid": "PassOwnTaskRoles", "Effect": "Allow", - "Action": ["iam:PassRole"], - "Resource": ["arn:aws:iam::*:role/triple-c-*"], + "Action": [ + "iam:PassRole" + ], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ], "Condition": { "StringEquals": { "iam:ResourceTag/triple-c:owner": "${aws:userid}", @@ -38,114 +55,254 @@ { "Sid": "StopAndExecOwnTasks", "Effect": "Allow", - "Action": ["ecs:StopTask", "ecs:ExecuteCommand"], - "Resource": ["arn:aws:ecs:*:*:task/triple-c-*/*"], + "Action": [ + "ecs:StopTask", + "ecs:ExecuteCommand" + ], + "Resource": [ + "arn:aws:ecs:*:*:task/triple-c-*/*" + ], "Condition": { - "StringEquals": { "aws:ResourceTag/triple-c:owner": "${aws:userid}" } + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } } }, { "Sid": "StartSessionsToOwnTasks", "Effect": "Allow", - "Action": ["ssm:StartSession"], - "Resource": ["arn:aws:ecs:*:*:task/triple-c-*/*"], + "Action": [ + "ssm:StartSession" + ], + "Resource": [ + "arn:aws:ecs:*:*:task/triple-c-*/*" + ], "Condition": { - "StringEquals": { "aws:ResourceTag/triple-c:owner": "${aws:userid}" } + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } } }, { "Sid": "UsePortForwardingDocument", "Effect": "Allow", - "Action": ["ssm:StartSession"], - "Resource": ["arn:aws:ssm:*::document/AWS-StartPortForwardingSession"] + "Action": [ + "ssm:StartSession" + ], + "Resource": [ + "arn:aws:ssm:*::document/AWS-StartPortForwardingSession" + ] }, { "Sid": "EndOrResumeOwnSessions", "Effect": "Allow", - "Action": ["ssm:TerminateSession", "ssm:ResumeSession"], - "Resource": ["arn:aws:ssm:*:*:session/*"], + "Action": [ + "ssm:TerminateSession", + "ssm:ResumeSession" + ], + "Resource": [ + "arn:aws:ssm:*:*:session/*" + ], "Condition": { - "StringLike": { "ssm:resourceTag/aws:ssmmessages:session-id": "${aws:userid}" } + "StringLike": { + "ssm:resourceTag/aws:ssmmessages:session-id": "${aws:userid}" + } } }, { "Sid": "CreateOwnSecrets", "Effect": "Allow", - "Action": ["secretsmanager:CreateSecret"], - "Resource": ["arn:aws:secretsmanager:*:*:secret:triple-c/*"], + "Action": [ + "secretsmanager:CreateSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ], "Condition": { - "StringEquals": { "aws:RequestTag/triple-c:owner": "${aws:userid}" } + "StringEquals": { + "aws:RequestTag/triple-c:owner": "${aws:userid}" + } } }, { "Sid": "TagOwnSecrets", "Effect": "Allow", - "Action": ["secretsmanager:TagResource"], - "Resource": ["arn:aws:secretsmanager:*:*:secret:triple-c/*"], + "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}" } + "StringEquals": { + "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "StringEqualsIfExists": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } } }, { "Sid": "ManageOwnSecrets", "Effect": "Allow", - "Action": ["secretsmanager:PutSecretValue", "secretsmanager:DeleteSecret"], - "Resource": ["arn:aws:secretsmanager:*:*:secret:triple-c/*"], + "Action": [ + "secretsmanager:PutSecretValue", + "secretsmanager:DeleteSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ], "Condition": { - "StringEquals": { "aws:ResourceTag/triple-c:owner": "${aws:userid}" } + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } + } + }, + { + "Sid": "CreateOwnAccessPoints", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:CreateAccessPoint" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:file-system/*" + ], + "Condition": { + "StringEquals": { + "aws:RequestTag/triple-c:owner": "${aws:userid}" + } + } + }, + { + "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}" + } + } + }, + { + "Sid": "DeleteOwnAccessPoints", + "Effect": "Allow", + "Action": [ + "elasticfilesystem:DeleteAccessPoint" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:access-point/*" + ], + "Condition": { + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } } }, { "Sid": "EncryptThroughSecretsManagerOnly", "Effect": "Allow", - "Action": ["kms:Encrypt", "kms:GenerateDataKey"], - "Resource": ["arn:aws:kms:*:*:key/*"], + "Action": [ + "kms:Encrypt", + "kms:GenerateDataKey" + ], + "Resource": [ + "arn:aws:kms:*:*:key/*" + ], "Condition": { - "StringLike": { "kms:ViaService": "secretsmanager.*.amazonaws.com" } + "StringLike": { + "kms:ViaService": "secretsmanager.*.amazonaws.com" + } } }, { "Sid": "ReadEcs", "Effect": "Allow", - "Action": ["ecs:Describe*", "ecs:List*"], - "Resource": ["*"] + "Action": [ + "ecs:Describe*", + "ecs:List*" + ], + "Resource": [ + "*" + ] }, { "Sid": "ReadEnvironmentParameters", "Effect": "Allow", - "Action": ["ssm:GetParameter*"], - "Resource": ["arn:aws:ssm:*:*:parameter/triple-c/*"] + "Action": [ + "ssm:GetParameter*" + ], + "Resource": [ + "arn:aws:ssm:*:*:parameter/triple-c/*" + ] }, { "Sid": "ReadStacks", "Effect": "Allow", - "Action": ["cloudformation:DescribeStacks", "cloudformation:ListStacks"], - "Resource": ["*"] + "Action": [ + "cloudformation:DescribeStacks", + "cloudformation:ListStacks" + ], + "Resource": [ + "*" + ] }, { "Sid": "ListSecrets", "Effect": "Allow", - "Action": ["secretsmanager:ListSecrets"], - "Resource": ["*"] + "Action": [ + "secretsmanager:ListSecrets" + ], + "Resource": [ + "*" + ] }, { "Sid": "ReadFileSystems", "Effect": "Allow", - "Action": ["elasticfilesystem:Describe*"], - "Resource": ["*"] + "Action": [ + "elasticfilesystem:Describe*" + ], + "Resource": [ + "*" + ] }, { "Sid": "ReadLogs", "Effect": "Allow", - "Action": ["logs:GetLogEvents", "logs:FilterLogEvents"], - "Resource": ["arn:aws:logs:*:*:log-group:/triple-c/*"] + "Action": [ + "logs:GetLogEvents", + "logs:FilterLogEvents" + ], + "Resource": [ + "arn:aws:logs:*:*:log-group:/triple-c/*" + ] + }, + { + "Sid": "InspectOwnPrincipal", + "Effect": "Allow", + "Action": [ + "iam:GetRole", + "iam:SimulatePrincipalPolicy" + ], + "Resource": [ + "${aws:PrincipalArn}" + ] }, { "Sid": "WhoAmI", "Effect": "Allow", - "Action": ["sts:GetCallerIdentity"], - "Resource": ["*"] + "Action": [ + "sts:GetCallerIdentity" + ], + "Resource": [ + "*" + ] } ] -} +} \ No newline at end of file -- 2.52.0 From 8e6983cf5308d91ab604c36bdb947334051ef632 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:25:57 -0800 Subject: [PATCH 11/23] fix(aws): close secret claiming, CFN service-role and boundary-reach gaps - workspace-user: TagOwnSecrets refuses component-tagged secrets, plus an explicit Deny on Tag/Untag/Put/Delete of component-tagged secrets; CreateAccessPoint only on the component-tagged file system. - environment-admin: Deny PutRolePermissionsBoundary on triple-c-* roles to anything but the triple-c boundary; Deny Create/UpdateStack and CreateChangeSet on triple-c-* stacks with a service role. - permissions boundary: kms:Decrypt and EFS client access only on component-tagged keys and file systems; ClientRootAccess dropped (access points fix the POSIX user). - README: invariants for phase 4 templates, the actions that are on *, the phase-6 access-point root note, and the list to verify live. - Tests: negated operators in the evaluator, both tiers evaluated together, secret claiming, service-role and boundary-reach cases. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/policies.rs | 251 +++++++++++++++++++++-- aws/README.md | 201 +++++++++++++------ aws/policies/environment-admin.json | 262 ++++++++++++++++++++----- aws/policies/permissions-boundary.json | 76 +++++-- aws/policies/workspace-user.json | 26 ++- 5 files changed, 661 insertions(+), 155 deletions(-) diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index 284ba08..afc2ecd 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -110,7 +110,8 @@ const WITH_BOUNDARY: Context = &[ /// Created resources carry `triple-c:component`; destroyed ones must. const COMPONENT_REQUESTED: Context = &[("aws:RequestTag/triple-c:component", Literal("environment"))]; -const COMPONENT_TAGGED: Context = &[("aws:ResourceTag/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 = @@ -228,10 +229,14 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ 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)], + &[ + (OWNER_REQUEST_TAG, OwnerId), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ], ), write( "elasticfilesystem:TagResource", @@ -626,31 +631,42 @@ mod tests { Some(b) => (b, true), None => (op.as_str(), false), }; - let matches: fn(&str, &str) -> bool = match base { - "StringEquals" => |p, v| p == v, - "StringLike" | "ArnLike" => glob, + // 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, - Some(value) => strings(want) - .iter() - .filter_map(|p| req.resolve(p)) - .any(|p| matches(&p, &value)), + 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 { - let stmts = statements(tier); + 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) + let allowed = matching(stmts, &req) .into_iter() .filter(|s| effect(s) == "Allow") .any(|s| applies(&s)); - let denied = matching(&stmts, &req) + let denied = matching(stmts, &req) .into_iter() .filter(|s| effect(s) == "Deny") .any(|s| applies(&s)); @@ -703,10 +719,6 @@ mod tests { validate_document(&format!("{tier:?}"), &policy(tier)); assert!(!allows(tier).is_empty(), "{tier:?} allows nothing"); } - assert!( - denies(Tier::WorkspaceUser).is_empty(), - "workspace-user needs no Deny" - ); validate_document("permissions boundary", &boundary()); } @@ -1239,7 +1251,6 @@ mod tests { "kms:Decrypt", "elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite", - "elasticfilesystem:ClientRootAccess", "sts:GetCallerIdentity", "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", @@ -1249,7 +1260,15 @@ mod tests { "boundary does not grant {need}" ); } - // Secrets only through Secrets Manager, and only Triple-C's. + // 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")) { @@ -1260,6 +1279,17 @@ mod tests { 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")) @@ -1285,4 +1315,187 @@ mod tests { } } } + + /// 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))); + } + } } diff --git a/aws/README.md b/aws/README.md index 17de1b7..a751362 100644 --- a/aws/README.md +++ b/aws/README.md @@ -1,37 +1,42 @@ # Triple-C AWS policies -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 checks which tier the -signed-in identity has under **Settings → Hosts → Check**. +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` | **Workspace user** tier: create, start, stop, connect to and delete **their own** workspaces in an existing environment | -| `policies/environment-admin.json` | **Environment admin** tier: deploy, update and delete the environment stack, and onboard users | -| `policies/permissions-boundary.json` | The **permissions boundary** every role Triple-C creates must carry. It is not attached to people | +| `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. +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}` (the `UserId` that -`aws sts get-caller-identity` prints). The policy uses that tag so that a user can only do the -following. +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:** - - from `triple-c-*` task definitions tagged with their own owner ID, on `triple-c-*` clusters; - - with the new task tagged with their own owner ID; - - passing only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks. -- **Their own tasks:** stop them, `ecs execute-command` into them, or open an SSM session to - them. They can end or resume only sessions they started. -- **Secrets:** create, update and delete `triple-c/*` secrets tagged with their owner ID, using - KMS only through Secrets Manager. -- **EFS access points:** create and delete access points tagged with their owner ID. -- **Their own principal:** read it (`iam:GetRole`) and simulate it +- **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. +- **Manage their own secrets.** They can create, update and delete `triple-c/*` secrets tagged with + their owner ID, and use KMS only through Secrets Manager. + - 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`. +- **Inspect their own principal.** They can read it (`iam:GetRole`) and simulate it (`iam:SimulatePrincipalPolicy`). The app's permission check uses both. -- **Read:** 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. +- **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 @@ -43,49 +48,89 @@ The admin tier manages `triple-c-*` CloudFormation stacks and the resources they - `/triple-c/*` log groups and `/triple-c/*` SSM parameters; - ECR repositories and pull-through cache rules. -It is fenced in two ways. +It is fenced in the following ways. -**Roles are bounded.** The admin can create a `triple-c-*` role or change its policies only if 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: +**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. -- ECR image pulls; -- writing `/triple-c/*` logs; -- ECS Exec channels; -- reading `triple-c/*` secrets, decrypting only through Secrets Manager; -- mounting EFS; -- `sts:GetCallerIdentity`; -- invoking Bedrock models. +- 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, with no root access, + because access points fix the POSIX user; + - `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. -It grants nothing in IAM. Explicit Deny statements stop the admin from removing the boundary from -a role, and from editing or deleting the boundary policy itself. Without these fences, an admin -could create a role with any permissions and become an account administrator. +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 modified or deleted only if they carry one, so the -admin tier cannot touch resources that aren't Triple-C's. Tagging is allowed only as part of the -creating call, or on a resource that is already Triple-C's. +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. Narrowing this to `task-definition/triple-c-*` + depends on resource-level support, which is to be verified live. +- `ecr:CreatePullThroughCacheRule` on `*`. The admin can add pull-through cache rules for any + prefix. This cannot delete or overwrite anything. +- `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. ## One-time setup: the boundary policy -Before anyone can deploy an environment, an **account administrator** creates the boundary policy -once. The environment-admin tier deliberately cannot do this. +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 it **without a path**. The admin policy expects exactly +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 (for example, as part of the environment stack's prerequisites). +this step; one option is to list it with the environment stack's prerequisites. ## Attaching the tier policies -Console: **IAM → Policies → Create policy → JSON**, paste the file, then attach the policy to a -user, group or role. For IAM Identity Center, add it as an inline policy on the permission set. +In the console, go to **IAM → Policies → Create policy → JSON**, paste the file, and attach the +policy to a user, group or role. For IAM Identity Center, add it as an inline policy on the +permission set. -CLI: +With the CLI: ```sh aws iam create-policy --policy-name triple-c-workspace-user \ @@ -94,31 +139,61 @@ aws iam attach-user-policy --user-name \ --policy-arn arn:aws:iam:::policy/triple-c-workspace-user ``` -## Known gaps (phase 4) +## Known gaps -These arrive with the environment and onboarding templates. +**Phase 4** - **Per-user execution roles.** A workspace user can pass only roles tagged with their own owner - ID, so each user's onboarding stack must create their execution role as well as their task - role. Alternatively, a separate statement can allow the environment's shared execution role. -- **The admin's full deploy permissions.** Deploying a stack will also need: + 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. - These will be added, with the same boundary and tag scoping, once the templates exist. +**Phase 6** -## Verification +- **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. -The app's permission-check table (`app/src-tauri/src/aws/policies.rs`) is unit-tested against -these files with a small IAM-style evaluator. The tests check that: +## To verify live (Task 8) -- every allowed action has a check; -- owner-scoped actions are denied for someone else's resources; -- every role write requires the boundary; -- destructive admin actions need the `triple-c:component` tag. +The unit tests use a small IAM-style evaluator. These points need the real IAM policy simulator or +a real account: -Before release, every statement is also verified against a real account with the IAM policy -simulator, by the live permission test. The simulator does not apply SCPs, permission boundaries -or session policies, so the first real create is the final check. +- **KMS key tags.** Does `CreateKey` with tags authorize `kms:TagResource` against the new, + untagged key? If so, the tag rule on `kms:TagResource` denies it. Do **not** fix that by removing + the condition from `TagResource`. +- **Principal variables.** Check how `${aws:PrincipalAccount}` and `${aws:PrincipalArn}` resolve. + For an SSO session, does the role ARN include the `/aws-reserved/sso.amazonaws.com//` + path? Does the simulator substitute them in `Resource`? +- **ECS Exec on the cluster.** `ecs:ExecuteCommand` may also be authorized on the cluster ARN. +- **ECS tag keys.** On ECS resources, check `aws:ResourceTag` against `ecs:ResourceTag`. +- **SSM sessions.** Check `ssm:StartSession` on an ECS task with `aws:ResourceTag`. +- **Session ownership for SSO callers.** Check the + `ssm:resourceTag/aws:ssmmessages:session-id` condition when the caller signed in through SSO. +- **EC2 and EFS tagging.** Check the `ec2:CreateAction` and `elasticfilesystem:CreateAction` + tag-on-create keys. Check whether `ec2:AuthorizeSecurityGroup*` and `RevokeSecurityGroup*` are + also authorized against `security-group-rule` resources. +- **Secrets tagging.** Check that `CreateSecret` with tags authorizes `secretsmanager:TagResource`, + and that the `StringEqualsIfExists` and `Null` resource-tag conditions pass at create time. +- **CloudFormation service roles.** Check that `cloudformation:RoleArn` is present in the request + context when `RoleARN` is given. +- **Task definition scoping.** Check whether `ecs:DeregisterTaskDefinition` and + `ecr:CreatePullThroughCacheRule` support resource-level scoping, so they can be narrowed. + +The simulator does not apply SCPs, permission boundaries or 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 index ece6c13..6caf883 100644 --- a/aws/policies/environment-admin.json +++ b/aws/policies/environment-admin.json @@ -4,8 +4,12 @@ { "Sid": "ManageEnvironmentStacks", "Effect": "Allow", - "Action": ["cloudformation:*"], - "Resource": ["arn:aws:cloudformation:*:*:stack/triple-c-*/*"] + "Action": [ + "cloudformation:*" + ], + "Resource": [ + "arn:aws:cloudformation:*:*:stack/triple-c-*/*" + ] }, { "Sid": "ManageBoundedTripleCRoles", @@ -17,7 +21,9 @@ "iam:DetachRolePolicy", "iam:DeleteRolePolicy" ], - "Resource": ["arn:aws:iam::*:role/triple-c-*"], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ], "Condition": { "StringEquals": { "iam:PermissionsBoundary": "arn:aws:iam::${aws:PrincipalAccount}:policy/triple-c-permissions-boundary" @@ -27,25 +33,42 @@ { "Sid": "TagReadAndDeleteTripleCRoles", "Effect": "Allow", - "Action": ["iam:DeleteRole", "iam:TagRole", "iam:GetRole"], - "Resource": ["arn:aws:iam::*:role/triple-c-*"] + "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-*"], + "Action": [ + "iam:PassRole" + ], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ], "Condition": { "StringEquals": { - "iam:PassedToService": ["ecs-tasks.amazonaws.com", "ec2.amazonaws.com"] + "iam:PassedToService": [ + "ecs-tasks.amazonaws.com", + "ec2.amazonaws.com" + ] } } }, { "Sid": "KeepTheBoundaryOnTripleCRoles", "Effect": "Deny", - "Action": ["iam:DeleteRolePermissionsBoundary"], - "Resource": ["arn:aws:iam::*:role/triple-c-*"] + "Action": [ + "iam:DeleteRolePermissionsBoundary" + ], + "Resource": [ + "arn:aws:iam::*:role/triple-c-*" + ] }, { "Sid": "KeepTheBoundaryPolicyUnchanged", @@ -56,54 +79,125 @@ "iam:DeletePolicyVersion", "iam:SetDefaultPolicyVersion" ], - "Resource": ["arn:aws:iam::*:policy/triple-c-permissions-boundary"] + "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": ["${aws:PrincipalArn}"] + "Action": [ + "iam:GetRole", + "iam:SimulatePrincipalPolicy" + ], + "Resource": [ + "${aws:PrincipalArn}" + ] }, { "Sid": "ManageClusters", "Effect": "Allow", - "Action": ["ecs:CreateCluster", "ecs:DeleteCluster", "ecs:PutClusterCapacityProviders"], - "Resource": ["arn:aws:ecs:*:*:cluster/triple-c-*"] + "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-*"] + "Action": [ + "ecs:CreateCapacityProvider", + "ecs:DeleteCapacityProvider" + ], + "Resource": [ + "arn:aws:ecs:*:*:capacity-provider/triple-c-*" + ] }, { "Sid": "ManageTaskDefinitions", "Effect": "Allow", - "Action": ["ecs:RegisterTaskDefinition", "ecs:DeregisterTaskDefinition"], - "Resource": ["*"] + "Action": [ + "ecs:RegisterTaskDefinition", + "ecs:DeregisterTaskDefinition" + ], + "Resource": [ + "*" + ] }, { "Sid": "CreateTaggedSecurityGroups", "Effect": "Allow", - "Action": ["ec2:CreateSecurityGroup"], - "Resource": ["arn:aws:ec2:*:*:security-group/*"], + "Action": [ + "ec2:CreateSecurityGroup" + ], + "Resource": [ + "arn:aws:ec2:*:*:security-group/*" + ], "Condition": { - "Null": { "aws:RequestTag/triple-c:component": "false" } + "Null": { + "aws:RequestTag/triple-c:component": "false" + } } }, { "Sid": "CreateSecurityGroupsInVpcs", "Effect": "Allow", - "Action": ["ec2:CreateSecurityGroup"], - "Resource": ["arn:aws:ec2:*:*:vpc/*"] + "Action": [ + "ec2:CreateSecurityGroup" + ], + "Resource": [ + "arn:aws:ec2:*:*:vpc/*" + ] }, { "Sid": "TagSecurityGroupsAtCreate", "Effect": "Allow", - "Action": ["ec2:CreateTags"], - "Resource": ["arn:aws:ec2:*:*:security-group/*"], + "Action": [ + "ec2:CreateTags" + ], + "Resource": [ + "arn:aws:ec2:*:*:security-group/*" + ], "Condition": { - "StringEquals": { "ec2:CreateAction": "CreateSecurityGroup" } + "StringEquals": { + "ec2:CreateAction": "CreateSecurityGroup" + } } }, { @@ -115,33 +209,53 @@ "ec2:RevokeSecurityGroup*", "ec2:CreateTags" ], - "Resource": ["arn:aws:ec2:*:*:security-group/*"], + "Resource": [ + "arn:aws:ec2:*:*:security-group/*" + ], "Condition": { - "Null": { "aws:ResourceTag/triple-c:component": "false" } + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } } }, { "Sid": "DescribeEc2", "Effect": "Allow", - "Action": ["ec2:Describe*"], - "Resource": ["*"] + "Action": [ + "ec2:Describe*" + ], + "Resource": [ + "*" + ] }, { "Sid": "CreateTaggedFileSystems", "Effect": "Allow", - "Action": ["elasticfilesystem:CreateFileSystem"], - "Resource": ["arn:aws:elasticfilesystem:*:*:file-system/*"], + "Action": [ + "elasticfilesystem:CreateFileSystem" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:file-system/*" + ], "Condition": { - "Null": { "aws:RequestTag/triple-c:component": "false" } + "Null": { + "aws:RequestTag/triple-c:component": "false" + } } }, { "Sid": "TagFileSystemsAtCreate", "Effect": "Allow", - "Action": ["elasticfilesystem:TagResource"], - "Resource": ["arn:aws:elasticfilesystem:*:*:file-system/*"], + "Action": [ + "elasticfilesystem:TagResource" + ], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:file-system/*" + ], "Condition": { - "StringEquals": { "elasticfilesystem:CreateAction": "CreateFileSystem" } + "StringEquals": { + "elasticfilesystem:CreateAction": "CreateFileSystem" + } } }, { @@ -154,18 +268,28 @@ "elasticfilesystem:PutFileSystemPolicy", "elasticfilesystem:TagResource" ], - "Resource": ["arn:aws:elasticfilesystem:*:*:file-system/*"], + "Resource": [ + "arn:aws:elasticfilesystem:*:*:file-system/*" + ], "Condition": { - "Null": { "aws:ResourceTag/triple-c:component": "false" } + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } } }, { "Sid": "CreateTaggedKeys", "Effect": "Allow", - "Action": ["kms:CreateKey"], - "Resource": ["*"], + "Action": [ + "kms:CreateKey" + ], + "Resource": [ + "*" + ], "Condition": { - "Null": { "aws:RequestTag/triple-c:component": "false" } + "Null": { + "aws:RequestTag/triple-c:component": "false" + } } }, { @@ -178,40 +302,70 @@ "kms:CreateAlias", "kms:DeleteAlias" ], - "Resource": ["arn:aws:kms:*:*:key/*"], + "Resource": [ + "arn:aws:kms:*:*:key/*" + ], "Condition": { - "Null": { "aws:ResourceTag/triple-c:component": "false" } + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } } }, { "Sid": "ManageTripleCKeyAliases", "Effect": "Allow", - "Action": ["kms:CreateAlias", "kms:DeleteAlias"], - "Resource": ["arn:aws:kms:*:*:alias/triple-c-*"] + "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/*"] + "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/*"] + "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": ["*"] + "Action": [ + "ecr:CreatePullThroughCacheRule" + ], + "Resource": [ + "*" + ] }, { "Sid": "ManageEnvironmentParameters", "Effect": "Allow", - "Action": ["ssm:PutParameter", "ssm:DeleteParameter"], - "Resource": ["arn:aws:ssm:*:*:parameter/triple-c/*"] + "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 index 95be78a..08454ce 100644 --- a/aws/policies/permissions-boundary.json +++ b/aws/policies/permissions-boundary.json @@ -4,8 +4,12 @@ { "Sid": "EcrAuthorization", "Effect": "Allow", - "Action": ["ecr:GetAuthorizationToken"], - "Resource": ["*"] + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Resource": [ + "*" + ] }, { "Sid": "PullTripleCImages", @@ -17,13 +21,21 @@ "ecr:BatchImportUpstreamImage", "ecr:CreateRepository" ], - "Resource": ["arn:aws:ecr:*:*:repository/triple-c-*", "arn:aws:ecr:*:*:repository/triple-c/*"] + "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/*"] + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": [ + "arn:aws:logs:*:*:log-group:/triple-c/*" + ] }, { "Sid": "EcsExecChannels", @@ -34,43 +46,71 @@ "ssmmessages:OpenControlChannel", "ssmmessages:OpenDataChannel" ], - "Resource": ["*"] + "Resource": [ + "*" + ] }, { "Sid": "ReadTripleCSecrets", "Effect": "Allow", - "Action": ["secretsmanager:GetSecretValue"], - "Resource": ["arn:aws:secretsmanager:*:*:secret:triple-c/*"] + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ] }, { "Sid": "DecryptThroughSecretsManagerOnly", "Effect": "Allow", - "Action": ["kms:Decrypt"], - "Resource": ["arn:aws:kms:*:*:key/*"], + "Action": [ + "kms:Decrypt" + ], + "Resource": [ + "arn:aws:kms:*:*:key/*" + ], "Condition": { - "StringLike": { "kms:ViaService": "secretsmanager.*.amazonaws.com" } + "StringLike": { + "kms:ViaService": "secretsmanager.*.amazonaws.com" + }, + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } } }, { - "Sid": "MountFileSystems", + "Sid": "MountEnvironmentFileSystems", "Effect": "Allow", "Action": [ "elasticfilesystem:ClientMount", - "elasticfilesystem:ClientWrite", - "elasticfilesystem:ClientRootAccess" + "elasticfilesystem:ClientWrite" ], - "Resource": ["arn:aws:elasticfilesystem:*:*:file-system/*"] + "Resource": [ + "arn:aws:elasticfilesystem:*:*:file-system/*" + ], + "Condition": { + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } }, { "Sid": "WhoAmI", "Effect": "Allow", - "Action": ["sts:GetCallerIdentity"], - "Resource": ["*"] + "Action": [ + "sts:GetCallerIdentity" + ], + "Resource": [ + "*" + ] }, { "Sid": "InvokeBedrockModels", "Effect": "Allow", - "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"], + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], "Resource": [ "arn:aws:bedrock:*::foundation-model/*", "arn:aws:bedrock:*:*:inference-profile/*", diff --git a/aws/policies/workspace-user.json b/aws/policies/workspace-user.json index 71362b7..dc44853 100644 --- a/aws/policies/workspace-user.json +++ b/aws/policies/workspace-user.json @@ -139,6 +139,9 @@ }, "StringEqualsIfExists": { "aws:ResourceTag/triple-c:owner": "${aws:userid}" + }, + "Null": { + "aws:ResourceTag/triple-c:component": "true" } } }, @@ -158,6 +161,24 @@ } } }, + { + "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", @@ -170,6 +191,9 @@ "Condition": { "StringEquals": { "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "Null": { + "aws:ResourceTag/triple-c:component": "false" } } }, @@ -305,4 +329,4 @@ ] } ] -} \ No newline at end of file +} -- 2.52.0 From 9d1630eb97fafa0673d79cc8dd605f1596e2cc20 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:31:38 -0800 Subject: [PATCH 12/23] fix(aws): user secrets on the environment key; no component tag from workspace users - CreateOwnSecrets requires secretsmanager:KmsKeyId to be a key ARN, and a workspace user's kms:Encrypt/GenerateDataKey (via Secrets Manager) only works on component-tagged keys, so secrets can't land on the default aws/secretsmanager key that the boundary can't decrypt. - Every workspace statement that sets the owner tag limits aws:TagKeys to triple-c:owner/project/environment (ForAllValues:StringEquals), so a user can never set triple-c:component. - Evaluator: multi-valued context keys and ForAllValues (vacuous when absent); Check documents repeated keys as one StringList entry. - README: environment-key invariant and new verify-live entries. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/policies.rs | 215 ++++++++++++++++++++++++++++-- aws/README.md | 29 +++- aws/policies/workspace-user.json | 50 ++++++- 3 files changed, 282 insertions(+), 12 deletions(-) diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index afc2ecd..bdc8596 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -55,6 +55,8 @@ 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`) and is sent as one `StringList` entry. pub context: &'static [(&'static str, ContextValue)], pub write: bool, } @@ -208,10 +210,15 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ 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)], + &[ + (OWNER_REQUEST_TAG, OwnerId), + ("secretsmanager:KmsKeyId", Literal(PROBE_KEY)), + ], ), // Tag-on-create: the secret has no tags yet, so only the request tag. write( @@ -257,18 +264,24 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ write( "kms:Encrypt", PROBE_KEY, - &[( - "kms:ViaService", - Literal("secretsmanager.{region}.amazonaws.com"), - )], + &[ + ( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + ), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ], ), write( "kms:GenerateDataKey", PROBE_KEY, - &[( - "kms:ViaService", - Literal("secretsmanager.{region}.amazonaws.com"), - )], + &[ + ( + "kms:ViaService", + Literal("secretsmanager.{region}.amazonaws.com"), + ), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ], ), read("iam:GetRole", "{principal}", OWN_PRINCIPAL), read("iam:SimulatePrincipalPolicy", "{principal}", OWN_PRINCIPAL), @@ -558,14 +571,20 @@ mod tests { 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() - .find(|(k, _)| *k == key) + .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 @@ -617,6 +636,24 @@ mod tests { 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(); @@ -1498,4 +1535,162 @@ mod tests { 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")) + ]))); + } } diff --git a/aws/README.md b/aws/README.md index a751362..93d2e39 100644 --- a/aws/README.md +++ b/aws/README.md @@ -26,12 +26,23 @@ following: - **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. - **Manage their own secrets.** They can create, update and delete `triple-c/*` secrets tagged with - their owner ID, and use KMS only through Secrets Manager. + 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`, and the + user can use KMS (`Encrypt`/`GenerateDataKey`) only through Secrets Manager and only with + environment keys. + - A secret on the default `aws/secretsmanager` key is refused. That key can't carry the tag, so + the workspace's own task role, which is limited by the boundary, couldn't decrypt it. - 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.** They can read it (`iam:GetRole`) and simulate it (`iam:SimulatePrincipalPolicy`). The app's permission check uses both. - **Read environment information.** This covers ECS, CloudFormation stacks, the `/triple-c/*` SSM @@ -109,6 +120,8 @@ detection should flag any environment that breaks them. 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 policy refuses any other choice. ## One-time setup: the boundary policy @@ -190,6 +203,20 @@ a real account: also authorized against `security-group-rule` resources. - **Secrets tagging.** Check that `CreateSecret` with tags authorizes `secretsmanager:TagResource`, and that the `StringEqualsIfExists` and `Null` resource-tag conditions pass at create time. +- **Secret keys.** + - Check that `secretsmanager:KmsKeyId` carries the value exactly as the request gave it, so that + `ArnLike arn:aws:kms:*:*:key/*` works. + - Check that Secrets Manager calls `GenerateDataKey`/`Encrypt` as the caller, so the KMS + statement's `aws:ResourceTag/triple-c:component` rule is what enforces the environment key. + - Check that `PutSecretValue` on such a secret passes. +- **Tag keys.** Check that `aws:TagKeys` is populated for `RunTask`, for `CreateSecret` and secret + `TagResource`, for `CreateAccessPoint`, and for the ECS and EFS tag-on-create `TagResource` + calls. Check also whether ECS-managed (`aws:ecs:*`) or propagated task-definition tags appear in + `RunTask`'s `aws:TagKeys` and would break the key allow-list. +- **Resource tags the boundary and access points rely on.** Check `aws:ResourceTag` on: + - `elasticfilesystem:ClientMount`/`ClientWrite` (the file system's tags, at mount); + - `elasticfilesystem:CreateAccessPoint`, which is authorized against the file system; + - `kms:Decrypt` through Secrets Manager (the key's tags). - **CloudFormation service roles.** Check that `cloudformation:RoleArn` is present in the request context when `RoleARN` is given. - **Task definition scoping.** Check whether `ecs:DeregisterTaskDefinition` and diff --git a/aws/policies/workspace-user.json b/aws/policies/workspace-user.json index dc44853..f7ee2b4 100644 --- a/aws/policies/workspace-user.json +++ b/aws/policies/workspace-user.json @@ -17,6 +17,13 @@ }, "ArnLike": { "ecs:cluster": "arn:aws:ecs:*:*:cluster/triple-c-*" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] } } }, @@ -33,6 +40,13 @@ "StringEquals": { "ecs:CreateAction": "RunTask", "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] } } }, @@ -121,6 +135,16 @@ "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/*" } } }, @@ -142,6 +166,13 @@ }, "Null": { "aws:ResourceTag/triple-c:component": "true" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] } } }, @@ -194,6 +225,13 @@ }, "Null": { "aws:ResourceTag/triple-c:component": "false" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] } } }, @@ -210,6 +248,13 @@ "StringEquals": { "elasticfilesystem:CreateAction": "CreateAccessPoint", "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] } } }, @@ -229,7 +274,7 @@ } }, { - "Sid": "EncryptThroughSecretsManagerOnly", + "Sid": "EncryptThroughSecretsManagerWithEnvironmentKeysOnly", "Effect": "Allow", "Action": [ "kms:Encrypt", @@ -241,6 +286,9 @@ "Condition": { "StringLike": { "kms:ViaService": "secretsmanager.*.amazonaws.com" + }, + "Null": { + "aws:ResourceTag/triple-c:component": "false" } } }, -- 2.52.0 From 7fdd5dc2772011ea96f972179b693a5102d3f5b9 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:34:35 -0800 Subject: [PATCH 13/23] fix(aws): explicitly deny Secrets Manager encryption with non-environment keys The AWS-managed aws/secretsmanager key (or a customer key whose key policy grants account principals) needs no identity-policy Allow, so the tagged-key Allow alone did not keep user secrets on environment keys. An explicit Deny on kms:Encrypt / kms:GenerateDataKey* via secretsmanager when the key lacks triple-c:component does. README claim corrected; verify-live entry added. Check.context doc now states the StringList rule as a requirement on the simulation builder. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/policies.rs | 45 ++++++++++++++++++++++++++++++- aws/README.md | 13 ++++++--- aws/policies/workspace-user.json | 19 +++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index bdc8596..7cb99d1 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -56,7 +56,9 @@ pub struct Check { /// 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`) and is sent as one `StringList` entry. + /// 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, } @@ -1693,4 +1695,45 @@ mod tests { ("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/aws/README.md b/aws/README.md index 93d2e39..3ae6d19 100644 --- a/aws/README.md +++ b/aws/README.md @@ -31,8 +31,11 @@ following: `triple-c:component`. `CreateSecret` must name the key by its full ARN in `KmsKeyId`, and the user can use KMS (`Encrypt`/`GenerateDataKey`) only through Secrets Manager and only with environment keys. - - A secret on the default `aws/secretsmanager` key is refused. That key can't carry the tag, so - the workspace's own task role, which is limited by the boundary, couldn't decrypt it. + - 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. A secret on such a + key would be unreadable by the workspace's own task role, which is limited by the boundary. - 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 @@ -121,7 +124,8 @@ detection should flag any environment that breaks them. 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 policy refuses any other choice. + `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 @@ -209,6 +213,9 @@ a real account: - Check that Secrets Manager calls `GenerateDataKey`/`Encrypt` as the caller, so the KMS statement's `aws:ResourceTag/triple-c:component` rule is what enforces the environment key. - Check that `PutSecretValue` on such a secret passes. + - Check that a `CreateSecret` naming the AWS-managed `aws/secretsmanager` key's ARN is denied. + KMS should leave `aws:ResourceTag/triple-c:component` absent for that key, so the + `NeverEncryptSecretsWithOtherKeys` Deny fires even though the key policy allows the call. - **Tag keys.** Check that `aws:TagKeys` is populated for `RunTask`, for `CreateSecret` and secret `TagResource`, for `CreateAccessPoint`, and for the ECS and EFS tag-on-create `TagResource` calls. Check also whether ECS-managed (`aws:ecs:*`) or propagated task-definition tags appear in diff --git a/aws/policies/workspace-user.json b/aws/policies/workspace-user.json index f7ee2b4..9e5e6e7 100644 --- a/aws/policies/workspace-user.json +++ b/aws/policies/workspace-user.json @@ -292,6 +292,25 @@ } } }, + { + "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", -- 2.52.0 From 634d1f5a7ec147e7a02f53c5bead247e24c603e7 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:43:35 -0800 Subject: [PATCH 14/23] feat(aws): tiered permission check with simulation and probe fallback check_permissions simulates every check of both tiers against the caller's resolved principal (bounded to 4 in flight), rendering resources and literal context values, sending OwnerId and aws:userid as the caller's user id, and grouping repeated context keys into one StringList entry. An unsimulatable principal, a denied iam:GetRole or a denied first simulation falls back to four read-only probes. Results are one row per action (worst verdict wins). The trait returns Send futures so the check is Send for Tauri commands. The module-wide dead_code/unused_imports allow is replaced by allows on the Task 6 entry points and the re-exports. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/connection.rs | 1 + app/src-tauri/src/aws/identity.rs | 1 + app/src-tauri/src/aws/mod.rs | 15 +- app/src-tauri/src/aws/permissions.rs | 434 ++++++++++++++++++ app/src-tauri/src/aws/permissions/tests.rs | 484 +++++++++++++++++++++ app/src-tauri/src/aws/policies.rs | 3 + 6 files changed, 934 insertions(+), 4 deletions(-) create mode 100644 app/src-tauri/src/aws/permissions.rs create mode 100644 app/src-tauri/src/aws/permissions/tests.rs diff --git a/app/src-tauri/src/aws/connection.rs b/app/src-tauri/src/aws/connection.rs index 2b5f466..a75b5eb 100644 --- a/app/src-tauri/src/aws/connection.rs +++ b/app/src-tauri/src/aws/connection.rs @@ -20,6 +20,7 @@ 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`). +#[allow(dead_code)] // Called by the Tauri commands (Task 6). pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result { sdk_config_with(conn, region, None).await } diff --git a/app/src-tauri/src/aws/identity.rs b/app/src-tauri/src/aws/identity.rs index 98b7fae..2860e66 100644 --- a/app/src-tauri/src/aws/identity.rs +++ b/app/src-tauri/src/aws/identity.rs @@ -17,6 +17,7 @@ pub struct CallerIdentity { /// `sts:GetCallerIdentity`, which needs no IAM permission: it is the /// connection test itself. +#[allow(dead_code)] // Called by the Tauri commands (Task 6). pub async fn caller_identity(cfg: &SdkConfig) -> Result { const ACTION: &str = "sts:GetCallerIdentity"; let out = aws_sdk_sts::Client::new(cfg) diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 02f47b4..3970a31 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -1,16 +1,23 @@ //! AWS connection plumbing: an SDK config built from a saved -//! `HostConnection`, the caller's identity, and error classification. - -// The permission check (Task 5) and the Tauri commands (Task 6) wire these in. -#![allow(dead_code, unused_imports)] +//! `HostConnection`, the caller's identity, the tier policies and the +//! permission check. +//! +//! Nothing outside this module calls in yet: the Tauri commands (Task 6) +//! will. Until then the entry points they call carry `#[allow(dead_code)]` +//! (which keeps everything they reach live) and the re-exports below carry +//! `unused_imports`; no blanket allow. mod connection; mod error; mod identity; +pub mod permissions; pub mod policies; +#[allow(unused_imports)] pub use connection::sdk_config; +#[allow(unused_imports)] pub use error::{classify_sdk_error, AwsError}; +#[allow(unused_imports)] pub use identity::{ caller_identity, principal_for_simulation, resolve_principal, CallerIdentity, PrincipalArn, }; diff --git a/app/src-tauri/src/aws/permissions.rs b/app/src-tauri/src/aws/permissions.rs new file mode 100644 index 0000000..ac44880 --- /dev/null +++ b/app/src-tauri/src/aws/permissions.rs @@ -0,0 +1,434 @@ +//! 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}; +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 = 4; + +/// 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 { + #[allow(dead_code)] // Called by the Tauri commands (Task 6). + pub fn new(cfg: &aws_config::SdkConfig) -> Self { + SdkPermissionApi { + iam: aws_sdk_iam::Client::new(cfg), + 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, +} + +#[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]; + +/// The worst of `verdicts`: Denied over Unverified over Allowed. +fn aggregate(verdicts: &[Verdict]) -> Verdict { + if verdicts.contains(&Verdict::Denied) { + Verdict::Denied + } else if verdicts.contains(&Verdict::Unverified) { + Verdict::Unverified + } else { + Verdict::Allowed + } +} + +/// Folds per-check verdicts (in `checks(tier)` order) into one row per +/// action, in first-appearance order. +fn tier_result(tier: Tier, verdicts: Vec) -> TierResult { + let mut rows: Vec<(&'static str, Vec)> = Vec::new(); + for (check, verdict) in checks(tier).iter().zip(verdicts) { + match rows.iter_mut().find(|(a, _)| *a == check.action) { + Some((_, vs)) => vs.push(verdict), + None => rows.push((check.action, vec![verdict])), + } + } + TierResult { + tier, + results: rows + .into_iter() + .map(|(action, vs)| ActionResult { + action: action.to_string(), + verdict: aggregate(&vs), + }) + .collect(), + } +} + +fn summary(tiers: &[TierResult]) -> Summary { + let clear = |t: Tier| { + tiers + .iter() + .filter(|r| r.tier == t) + .all(|r| r.results.iter().all(|a| a.verdict != Verdict::Denied)) + }; + match (clear(Tier::WorkspaceUser), clear(Tier::EnvironmentAdmin)) { + (true, true) => Summary::EnvironmentAdmin, + (true, false) => Summary::WorkspaceUser, + (false, _) => Summary::Insufficient, + } +} + +/// Which tiers the caller's credentials can act as, in `region`. +/// +/// Simulates every check of both tiers against the caller's principal. If +/// the principal can't be simulated (root, federated user), can't be +/// resolved (`iam:GetRole` denied), or the first simulation is denied, it +/// falls back to probes: a read check whose service has a probe takes that +/// probe's result, every other check is `Unverified`. +#[allow(dead_code)] // Called by the Tauri commands (Task 6). +pub async fn check_permissions( + api: &A, + identity: &CallerIdentity, + region: &str, +) -> PermissionReport { + let (method, verdicts) = match simulate_all(api, identity, region).await { + Ok(verdicts) => (Method::Simulated, verdicts), + Err(reason) => (Method::Probed { reason }, probe_all(api).await), + }; + let tiers: Vec = TIERS + .into_iter() + .zip(verdicts) + .map(|(tier, vs)| tier_result(tier, vs)) + .collect(); + PermissionReport { + identity: identity.clone(), + region: region.to_string(), + method, + summary: summary(&tiers), + tiers, + } +} + +fn simulated_verdict(r: Result) -> Verdict { + match r { + Ok(true) => Verdict::Allowed, + Ok(false) => Verdict::Denied, + // A simulation that failed (throttled, network) says nothing about + // the permission itself. + Err(_) => Verdict::Unverified, + } +} + +/// Per-tier verdicts by simulation, or why simulation isn't possible. +async fn simulate_all( + api: &A, + identity: &CallerIdentity, + region: &str, +) -> Result<[Vec; 2], String> { + let principal = principal_for_simulation(&identity.arn).map_err(|e| e.to_string())?; + let principal = api + .resolve_principal(principal) + .await + .map_err(|e| e.to_string())?; + let p = Placeholders { + region, + account: &identity.account, + principal: &principal, + }; + let requests: Vec = TIERS + .into_iter() + .flat_map(|t| checks(t).iter()) + .map(|c| simulation_request(c, &p, &identity.user_id)) + .collect(); + let mut requests = requests.into_iter(); + let Some(first) = requests.next() else { + return Ok([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 Err(e.to_string()), + r => simulated_verdict(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_verdict(api.simulate(&r).await) }) + .buffered(CONCURRENCY) + .collect() + .await; + let mut all = std::iter::once(first).chain(rest); + Ok(TIERS.map(|t| all.by_ref().take(checks(t).len()).collect())) +} + +/// Per-tier verdicts from the four probes. +async fn probe_all(api: &A) -> [Vec; 2] { + let results = + futures_util::future::join_all(Probe::ALL.map(|p| async move { (p, api.probe(p).await) })) + .await; + let probe_verdict = |probe: Probe| match results.iter().find(|(p, _)| *p == probe) { + Some((_, Ok(()))) => Verdict::Allowed, + Some((_, Err(AwsError::AccessDenied { .. }))) => Verdict::Denied, + _ => Verdict::Unverified, + }; + TIERS.map(|t| { + checks(t) + .iter() + .map(|c| match Probe::for_action(c.action) { + Some(probe) if !c.write => probe_verdict(probe), + _ => Verdict::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..6ffb668 --- /dev/null +++ b/app/src-tauri/src/aws/permissions/tests.rs @@ -0,0 +1,484 @@ +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, + probes_ok: HashSet, + 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, + probes_ok: ALL_PROBES.into_iter().collect(), + 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(), + }); + } + 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(), + }); + } + 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 { + Err(AwsError::AccessDenied { + action: format!("{probe:?}"), + message: "denied".into(), + }) + } + } +} + +fn run(api: &FakeApi) -> PermissionReport { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(check_permissions(api, &identity(), "eu-west-1")) +} + +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); + assert_eq!( + aggregate(&[Verdict::Allowed, Verdict::Denied, Verdict::Unverified]), + Verdict::Denied + ); + assert_eq!( + aggregate(&[Verdict::Allowed, Verdict::Unverified]), + Verdict::Unverified + ); + assert_eq!(aggregate(&[Verdict::Allowed]), Verdict::Allowed); +} + +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"); + assert_eq!( + context_value(get_role, "aws:PrincipalArn").unwrap().values, + [ROLE_ARN] + ); +} + +#[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 + ); + // Nothing denied: unverified doesn't block the summary. + assert_eq!(report.summary, Summary::EnvironmentAdmin); +} + +#[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); +} + +#[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 = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(check_permissions(&api, &id, "eu-west-1")); + match &report.method { + Method::Probed { reason } => assert!(reason.contains("simulation"), "{reason}"), + m => panic!("expected Probed, got {m:?}"), + } + assert!(api.simulated().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() { + struct Flaky(FakeApi); + impl PermissionApi for Flaky { + async fn resolve_principal(&self, p: PrincipalArn) -> Result { + self.0.resolve_principal(p).await + } + async fn simulate(&self, r: &SimulationRequest) -> Result { + self.0.simulate(r).await + } + async fn probe(&self, _: Probe) -> Result<(), AwsError> { + Err(AwsError::Other { + message: "connection reset".into(), + }) + } + } + let mut inner = FakeApi::allowing(&[Tier::WorkspaceUser]); + inner.simulate_denied = true; + let report = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(check_permissions(&Flaky(inner), &identity(), "eu-west-1")); + assert_eq!( + verdict(&report, Tier::WorkspaceUser, "ecs:ListClusters"), + Verdict::Unverified + ); +} + +#[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, every probe passes: nothing is denied. + assert_eq!(v["summary"], "environment_admin"); + 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 index 7cb99d1..2203e2b 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -20,6 +20,7 @@ pub enum Tier { } /// The policy document an admin attaches for `tier`. +#[allow(dead_code)] // Only tests read the policy text so far. pub fn policy_json(tier: Tier) -> &'static str { match tier { Tier::EnvironmentAdmin => include_str!("../../../../aws/policies/environment-admin.json"), @@ -29,10 +30,12 @@ pub fn policy_json(tier: Tier) -> &'static str { /// 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") } -- 2.52.0 From c82b63f48a1b76f50e3892510632652c3b5d7a39 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:49:34 -0800 Subject: [PATCH 15/23] fix(aws): permission summary can't be claimed through errors or probes check_permissions now returns Result: credential errors from any call, and non-denied failures resolving the principal, are errors rather than a report. Other per-check failures (throttling, network) make the row Unverified with a note, and a noted row blocks its tier. A probed run never claims EnvironmentAdmin (R15), and the own-principal check whose denial forced probing is reported Denied. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/permissions.rs | 272 +++++++++++++++------ app/src-tauri/src/aws/permissions/tests.rs | 227 ++++++++++++++--- 2 files changed, 382 insertions(+), 117 deletions(-) diff --git a/app/src-tauri/src/aws/permissions.rs b/app/src-tauri/src/aws/permissions.rs index ac44880..89d462d 100644 --- a/app/src-tauri/src/aws/permissions.rs +++ b/app/src-tauri/src/aws/permissions.rs @@ -5,7 +5,7 @@ use std::future::Future; -use futures_util::stream::{self, StreamExt}; +use futures_util::stream::{self, StreamExt, TryStreamExt}; use serde::Serialize; use super::error::{classify_sdk_error, AwsError}; @@ -234,13 +234,18 @@ pub enum Verdict { Unverified, } -/// One row per action: an action checked against several resources (or +/// 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)] @@ -279,155 +284,268 @@ pub struct PermissionReport { const TIERS: [Tier; 2] = [Tier::WorkspaceUser, Tier::EnvironmentAdmin]; -/// The worst of `verdicts`: Denied over Unverified over Allowed. -fn aggregate(verdicts: &[Verdict]) -> Verdict { - if verdicts.contains(&Verdict::Denied) { - Verdict::Denied - } else if verdicts.contains(&Verdict::Unverified) { - Verdict::Unverified - } else { - Verdict::Allowed - } +/// 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), } -/// Folds per-check verdicts (in `checks(tier)` order) into one row per +/// 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, verdicts: Vec) -> TierResult { - let mut rows: Vec<(&'static str, Vec)> = Vec::new(); - for (check, verdict) in checks(tier).iter().zip(verdicts) { +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((_, vs)) => vs.push(verdict), - None => rows.push((check.action, vec![verdict])), + Some((_, os)) => os.push(outcome), + None => rows.push((check.action, vec![outcome])), } } TierResult { tier, results: rows .into_iter() - .map(|(action, vs)| ActionResult { - action: action.to_string(), - verdict: aggregate(&vs), + .map(|(action, os)| { + let (verdict, note) = aggregate(&os); + ActionResult { + action: action.to_string(), + verdict, + note, + } }) .collect(), } } -fn summary(tiers: &[TierResult]) -> Summary { +/// 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)) + tiers.iter().filter(|r| r.tier == t).all(|r| { + r.results + .iter() + .all(|a| a.verdict != Verdict::Denied && a.note.is_none()) + }) }; - match (clear(Tier::WorkspaceUser), clear(Tier::EnvironmentAdmin)) { - (true, true) => Summary::EnvironmentAdmin, - (true, false) => Summary::WorkspaceUser, - (false, _) => Summary::Insufficient, + 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. If -/// the principal can't be simulated (root, federated user), can't be -/// resolved (`iam:GetRole` denied), or the first simulation is denied, it -/// falls back to probes: a read check whose service has a probe takes that -/// probe's result, every other check is `Unverified`. +/// 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. #[allow(dead_code)] // Called by the Tauri commands (Task 6). pub async fn check_permissions( api: &A, identity: &CallerIdentity, region: &str, -) -> PermissionReport { - let (method, verdicts) = match simulate_all(api, identity, region).await { - Ok(verdicts) => (Method::Simulated, verdicts), - Err(reason) => (Method::Probed { reason }, probe_all(api).await), +) -> 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(verdicts) - .map(|(tier, vs)| tier_result(tier, vs)) + .zip(outcomes) + .map(|(tier, os)| tier_result(tier, os)) .collect(); - PermissionReport { + Ok(PermissionReport { identity: identity.clone(), region: region.to_string(), method, - summary: summary(&tiers), + 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_verdict(r: Result) -> Verdict { +fn simulated_outcome(r: Result) -> Result { match r { - Ok(true) => Verdict::Allowed, - Ok(false) => Verdict::Denied, - // A simulation that failed (throttled, network) says nothing about - // the permission itself. - Err(_) => Verdict::Unverified, + 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 verdicts by simulation, or why simulation isn't possible. +/// Per-tier outcomes by simulation, or why simulation isn't possible. async fn simulate_all( api: &A, identity: &CallerIdentity, region: &str, -) -> Result<[Vec; 2], String> { - let principal = principal_for_simulation(&identity.arn).map_err(|e| e.to_string())?; - let principal = api - .resolve_principal(principal) - .await - .map_err(|e| e.to_string())?; +) -> 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 requests: Vec = TIERS + let mut requests = TIERS .into_iter() .flat_map(|t| checks(t).iter()) .map(|c| simulation_request(c, &p, &identity.user_id)) - .collect(); - let mut requests = requests.into_iter(); + .collect::>() + .into_iter(); let Some(first) = requests.next() else { - return Ok([Vec::new(), Vec::new()]); + 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 Err(e.to_string()), - r => simulated_verdict(r), + 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_verdict(api.simulate(&r).await) }) + let rest: Vec = stream::iter(requests) + .map(|r| async move { simulated_outcome(api.simulate(&r).await) }) .buffered(CONCURRENCY) - .collect() - .await; + .try_collect() + .await?; let mut all = std::iter::once(first).chain(rest); - Ok(TIERS.map(|t| all.by_ref().take(checks(t).len()).collect())) + Ok(Simulation::Done( + TIERS.map(|t| all.by_ref().take(checks(t).len()).collect()), + )) } -/// Per-tier verdicts from the four probes. -async fn probe_all(api: &A) -> [Vec; 2] { +/// 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; - let probe_verdict = |probe: Probe| match results.iter().find(|(p, _)| *p == probe) { - Some((_, Ok(()))) => Verdict::Allowed, - Some((_, Err(AwsError::AccessDenied { .. }))) => Verdict::Denied, - _ => Verdict::Unverified, + 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, }; - TIERS.map(|t| { + Ok(TIERS.map(|t| { checks(t) .iter() - .map(|c| match Probe::for_action(c.action) { - Some(probe) if !c.write => probe_verdict(probe), - _ => Verdict::Unverified, + .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)] diff --git a/app/src-tauri/src/aws/permissions/tests.rs b/app/src-tauri/src/aws/permissions/tests.rs index 6ffb668..03dd9ce 100644 --- a/app/src-tauri/src/aws/permissions/tests.rs +++ b/app/src-tauri/src/aws/permissions/tests.rs @@ -37,7 +37,13 @@ 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>, } @@ -52,7 +58,10 @@ impl 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()), } @@ -71,6 +80,9 @@ impl PermissionApi for FakeApi { 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(), @@ -90,6 +102,13 @@ impl PermissionApi for FakeApi { 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())) } @@ -97,6 +116,8 @@ impl PermissionApi for FakeApi { 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:?}"), @@ -106,11 +127,29 @@ impl PermissionApi for FakeApi { } } -fn run(api: &FakeApi) -> PermissionReport { +fn try_run(api: &FakeApi, id: &CallerIdentity) -> Result { tokio::runtime::Builder::new_current_thread() .build() .unwrap() - .block_on(check_permissions(api, &identity(), "eu-west-1")) + .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 { @@ -199,15 +238,20 @@ fn an_action_checked_twice_is_one_row_and_denied_if_either_is() { .filter(|r| r.action == "ssm:StartSession") .count(); assert_eq!(rows, 1); + let failed = || Outcome::Failed("Throttling".into()); assert_eq!( - aggregate(&[Verdict::Allowed, Verdict::Denied, Verdict::Unverified]), - Verdict::Denied + aggregate(&[Outcome::Allowed, Outcome::Denied, failed()]), + (Verdict::Denied, None) ); assert_eq!( - aggregate(&[Verdict::Allowed, Verdict::Unverified]), - Verdict::Unverified + aggregate(&[Outcome::Allowed, Outcome::Unverified, failed()]), + (Verdict::Unverified, Some("Throttling".into())) ); - assert_eq!(aggregate(&[Verdict::Allowed]), Verdict::Allowed); + 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> { @@ -380,8 +424,15 @@ fn a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified() { verdict(&report, ws, "sts:GetCallerIdentity"), Verdict::Unverified ); - // Nothing denied: unverified doesn't block the summary. - assert_eq!(report.summary, Summary::EnvironmentAdmin); + // 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] @@ -395,6 +446,12 @@ fn a_denied_get_role_falls_back_to_probes() { } 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] @@ -402,10 +459,7 @@ 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 = tokio::runtime::Builder::new_current_thread() - .build() - .unwrap() - .block_on(check_permissions(&api, &id, "eu-west-1")); + let report = try_run(&api, &id).unwrap(); match &report.method { Method::Probed { reason } => assert!(reason.contains("simulation"), "{reason}"), m => panic!("expected Probed, got {m:?}"), @@ -413,6 +467,105 @@ fn an_unsimulatable_principal_falls_back_to_probes() { 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]); @@ -433,31 +586,21 @@ fn a_failed_probe_marks_its_reads_denied() { } #[test] -fn a_probe_failing_for_another_reason_leaves_its_reads_unverified() { - struct Flaky(FakeApi); - impl PermissionApi for Flaky { - async fn resolve_principal(&self, p: PrincipalArn) -> Result { - self.0.resolve_principal(p).await - } - async fn simulate(&self, r: &SimulationRequest) -> Result { - self.0.simulate(r).await - } - async fn probe(&self, _: Probe) -> Result<(), AwsError> { - Err(AwsError::Other { - message: "connection reset".into(), - }) - } - } - let mut inner = FakeApi::allowing(&[Tier::WorkspaceUser]); - inner.simulate_denied = true; - let report = tokio::runtime::Builder::new_current_thread() - .build() - .unwrap() - .block_on(check_permissions(&Flaky(inner), &identity(), "eu-west-1")); - assert_eq!( - verdict(&report, Tier::WorkspaceUser, "ecs:ListClusters"), - Verdict::Unverified - ); +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] @@ -465,8 +608,12 @@ 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, every probe passes: nothing is denied. - assert_eq!(v["summary"], "environment_admin"); + // 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"] -- 2.52.0 From 1afddc0ba9e184c7fb4dff0c163cd72f05793d49 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:54:47 -0800 Subject: [PATCH 16/23] feat(aws): Tauri commands for AWS connections and permission checks Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/capabilities/default.json | 8 + app/src-tauri/gen/schemas/acl-manifests.json | 2 +- app/src-tauri/gen/schemas/capabilities.json | 2 +- app/src-tauri/gen/schemas/desktop-schema.json | 96 +++++ app/src-tauri/gen/schemas/linux-schema.json | 96 +++++ app/src-tauri/src/aws/connection.rs | 7 +- app/src-tauri/src/aws/identity.rs | 4 +- app/src-tauri/src/aws/mod.rs | 15 +- app/src-tauri/src/aws/permissions.rs | 2 - app/src-tauri/src/aws/policies.rs | 1 - app/src-tauri/src/commands/host_commands.rs | 367 ++++++++++++++++++ app/src-tauri/src/commands/mod.rs | 1 + app/src-tauri/src/lib.rs | 9 + app/src-tauri/src/storage/secure.rs | 3 +- app/src-tauri/src/storage/settings_store.rs | 6 + app/src/lib/aws-error.ts | 11 + app/src/lib/tauri-commands.ts | 36 +- app/src/lib/types.ts | 46 +++ 18 files changed, 689 insertions(+), 23 deletions(-) create mode 100644 app/src-tauri/src/commands/host_commands.rs create mode 100644 app/src/lib/aws-error.ts 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 index a75b5eb..1e14579 100644 --- a/app/src-tauri/src/aws/connection.rs +++ b/app/src-tauri/src/aws/connection.rs @@ -20,7 +20,6 @@ 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`). -#[allow(dead_code)] // Called by the Tauri commands (Task 6). pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result { sdk_config_with(conn, region, None).await } @@ -60,6 +59,12 @@ pub(crate) async fn sdk_config_with( 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(), diff --git a/app/src-tauri/src/aws/identity.rs b/app/src-tauri/src/aws/identity.rs index 2860e66..68671d3 100644 --- a/app/src-tauri/src/aws/identity.rs +++ b/app/src-tauri/src/aws/identity.rs @@ -17,7 +17,6 @@ pub struct CallerIdentity { /// `sts:GetCallerIdentity`, which needs no IAM permission: it is the /// connection test itself. -#[allow(dead_code)] // Called by the Tauri commands (Task 6). pub async fn caller_identity(cfg: &SdkConfig) -> Result { const ACTION: &str = "sts:GetCallerIdentity"; let out = aws_sdk_sts::Client::new(cfg) @@ -123,7 +122,8 @@ pub async fn resolve_principal( #[cfg(test)] mod tests { use super::*; - use crate::aws::{classify_sdk_error, AwsError}; + use crate::aws::error::classify_sdk_error; + use crate::aws::AwsError; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[test] diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 3970a31..5b2acf1 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -2,10 +2,6 @@ //! `HostConnection`, the caller's identity, the tier policies and the //! permission check. //! -//! Nothing outside this module calls in yet: the Tauri commands (Task 6) -//! will. Until then the entry points they call carry `#[allow(dead_code)]` -//! (which keeps everything they reach live) and the re-exports below carry -//! `unused_imports`; no blanket allow. mod connection; mod error; @@ -13,11 +9,6 @@ mod identity; pub mod permissions; pub mod policies; -#[allow(unused_imports)] -pub use connection::sdk_config; -#[allow(unused_imports)] -pub use error::{classify_sdk_error, AwsError}; -#[allow(unused_imports)] -pub use identity::{ - caller_identity, principal_for_simulation, resolve_principal, CallerIdentity, PrincipalArn, -}; +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 index 89d462d..18d2f5c 100644 --- a/app/src-tauri/src/aws/permissions.rs +++ b/app/src-tauri/src/aws/permissions.rs @@ -135,7 +135,6 @@ pub struct SdkPermissionApi { } impl SdkPermissionApi { - #[allow(dead_code)] // Called by the Tauri commands (Task 6). pub fn new(cfg: &aws_config::SdkConfig) -> Self { SdkPermissionApi { iam: aws_sdk_iam::Client::new(cfg), @@ -387,7 +386,6 @@ fn is_credential_error(e: &AwsError) -> bool { /// 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. -#[allow(dead_code)] // Called by the Tauri commands (Task 6). pub async fn check_permissions( api: &A, identity: &CallerIdentity, diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index 2203e2b..c763879 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -20,7 +20,6 @@ pub enum Tier { } /// The policy document an admin attaches for `tier`. -#[allow(dead_code)] // Only tests read the policy text so far. pub fn policy_json(tier: Tier) -> &'static str { match tier { Tier::EnvironmentAdmin => include_str!("../../../../aws/policies/environment-admin.json"), 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..a42ee90 --- /dev/null +++ b/app/src-tauri/src/commands/host_commands.rs @@ -0,0 +1,367 @@ +//! 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_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), + } + + let mut all = settings.get(); + all.hosts.push(conn.clone()); + if let Some(k) = &keys { + store_keys(&conn.id, k).map_err(other)?; + } + if let Err(e) = settings.update(all) { + if keys.is_some() { + let _ = delete_keys(&conn.id); + } + return Err(other(e)); + } + Ok(conn) +} + +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)?; + settings + .hosts + .iter() + .find(|h| h.id == id) + .cloned() + .ok_or_else(|| "Connection not found".to_string()) +} + +fn validated(name: &str, region: &str) -> Result<(String, String), AwsError> { + Ok(( + validate_connection_name(name).map_err(other)?, + validate_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 mut settings = state.settings_store.get(); + aws_conn(&id, &settings).map_err(other)?; + 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; + let updated = conn.clone(); + state.settings_store.update(settings).map_err(other)?; + Ok(updated) +} + +#[tauri::command] +pub async fn remove_host_connection( + id: String, + state: State<'_, AppState>, +) -> Result { + let mut settings = state.settings_store.get(); + let conn = aws_conn(&id, &settings)?; + let HostKind::Aws { auth, .. } = &conn.kind; + if let AwsAuth::Keys { keyring_ref } = auth { + secure::delete_aws_keys(keyring_ref)?; + } + settings.hosts.retain(|h| h.id != id); + state.settings_store.update(settings) +} + +#[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()); + }; + 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_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()]); + } +} 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/lib.rs b/app/src-tauri/src/lib.rs index de4040b..89f59fb 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -594,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/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index 903cb6e..e487103 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -489,8 +489,7 @@ pub fn aws_keys_entry_state(connection_id: &str) -> Option { } } -/// Missing entry is success. Unused until connection removal lands. -#[allow(dead_code)] +/// 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") } diff --git a/app/src-tauri/src/storage/settings_store.rs b/app/src-tauri/src/storage/settings_store.rs index c41dbf1..a21187c 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()) } diff --git a/app/src/lib/aws-error.ts b/app/src/lib/aws-error.ts new file mode 100644 index 0000000..73467a0 --- /dev/null +++ b/app/src/lib/aws-error.ts @@ -0,0 +1,11 @@ +import type { AwsError } from "./types"; + +const KINDS = ["sso_expired", "profile_not_found", "keys_missing", "invalid_credentials", "access_denied", "other"]; + +/** 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 && KINDS.includes((e as { kind: string }).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/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 1920d61..87b93e3 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1138,3 +1138,49 @@ export interface HostConnection { 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[]; +} -- 2.52.0 From 1df7dfc0a8ebad7527dafbd13a326399f5183615 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:57:47 -0800 Subject: [PATCH 17/23] fix(aws): host connections cannot be changed through update_settings; harden sso login and add path Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/commands/aws_commands.rs | 3 +- app/src-tauri/src/commands/host_commands.rs | 102 ++++++++++++++++-- .../src/commands/settings_commands.rs | 31 ++++++ app/src-tauri/src/storage/settings_store.rs | 3 +- app/src/lib/aws-error.test.ts | 18 ++++ app/src/lib/aws-error.ts | 17 ++- 6 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 app/src/lib/aws-error.test.ts 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 index a42ee90..fb59ed1 100644 --- a/app/src-tauri/src/commands/host_commands.rs +++ b/app/src-tauri/src/commands/host_commands.rs @@ -63,11 +63,12 @@ where Err(e) => return Err(e), } - let mut all = settings.get(); - all.hosts.push(conn.clone()); if let Some(k) = &keys { store_keys(&conn.id, k).map_err(other)?; } + // Read after the keychain step to keep the read-modify-write window short. + let mut all = settings.get(); + all.hosts.push(conn.clone()); if let Err(e) = settings.update(all) { if keys.is_some() { let _ = delete_keys(&conn.id); @@ -90,12 +91,26 @@ async fn real_identity(c: Credentials) -> Result { fn aws_conn(id: &str, settings: &AppSettings) -> Result { check_id(id)?; - settings + let conn = settings .hosts .iter() .find(|h| h.id == id) .cloned() - .ok_or_else(|| "Connection not found".to_string()) + .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_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> { @@ -188,12 +203,12 @@ pub async fn remove_host_connection( id: String, state: State<'_, AppState>, ) -> Result { - let mut settings = state.settings_store.get(); - let conn = aws_conn(&id, &settings)?; + let conn = aws_conn(&id, &state.settings_store.get())?; let HostKind::Aws { auth, .. } = &conn.kind; if let AwsAuth::Keys { keyring_ref } = auth { secure::delete_aws_keys(keyring_ref)?; } + let mut settings = state.settings_store.get(); settings.hosts.retain(|h| h.id != id); state.settings_store.update(settings) } @@ -219,8 +234,8 @@ pub async fn aws_connection_sso_login( let AwsAuth::Profile { name } = auth else { return Err("Only profile connections use SSO".to_string()); }; - validate_profile_name(name)?; - super::aws_commands::run_sso_login(name).await + let name = validate_profile_name(name)?; + super::aws_commands::run_sso_login(&name).await } #[tauri::command] @@ -363,5 +378,76 @@ mod tests { .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()); } } 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/storage/settings_store.rs b/app/src-tauri/src/storage/settings_store.rs index a21187c..6705051 100644 --- a/app/src-tauri/src/storage/settings_store.rs +++ b/app/src-tauri/src/storage/settings_store.rs @@ -75,8 +75,9 @@ 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) } } 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 index 73467a0..c2ca7d7 100644 --- a/app/src/lib/aws-error.ts +++ b/app/src/lib/aws-error.ts @@ -1,11 +1,22 @@ import type { AwsError } from "./types"; -const KINDS = ["sso_expired", "profile_not_found", "keys_missing", "invalid_credentials", "access_denied", "other"]; +// 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 && KINDS.includes((e as { kind: string }).kind)) { - return e as 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" }; } -- 2.52.0 From 90b10d48d2b708cc0f4170ccf9e27164b598f585 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 16:59:52 -0800 Subject: [PATCH 18/23] fix(aws): settings import keeps imported host connections Co-Authored-By: Claude Opus 5.5 --- .../src/commands/settings_export_commands.rs | 75 +++++++++++++++---- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/app/src-tauri/src/commands/settings_export_commands.rs b/app/src-tauri/src/commands/settings_export_commands.rs index 6abaccd..08f83ae 100644 --- a/app/src-tauri/src/commands/settings_export_commands.rs +++ b/app/src-tauri/src/commands/settings_export_commands.rs @@ -535,24 +535,15 @@ pub async fn apply_settings_import( secure::store_aws_keys, )); - let imported_marketplace = ( - settings.marketplace_accounts.clone(), - settings.marketplaces.clone(), - settings.global_marketplace_installs.clone(), - ); + 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; @@ -593,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()) } @@ -1044,4 +1063,28 @@ mod tests { 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]); + } } -- 2.52.0 From 1bf9c1c959c26cb1851fc487782151704d99267c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 17:02:26 -0800 Subject: [PATCH 19/23] =?UTF-8?q?feat(aws):=20Settings=20=E2=86=92=20Hosts?= =?UTF-8?q?=20with=20AWS=20connections=20and=20permission=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../settings/HostsSettings.test.tsx | 231 ++++++++ app/src/components/settings/HostsSettings.tsx | 498 ++++++++++++++++++ app/src/components/settings/SettingsPanel.tsx | 5 + 3 files changed, 734 insertions(+) create mode 100644 app/src/components/settings/HostsSettings.test.tsx create mode 100644 app/src/components/settings/HostsSettings.tsx diff --git a/app/src/components/settings/HostsSettings.test.tsx b/app/src/components/settings/HostsSettings.test.tsx new file mode 100644 index 0000000..a202721 --- /dev/null +++ b/app/src/components/settings/HostsSettings.test.tsx @@ -0,0 +1,231 @@ +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, permission boundaries/)).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("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.getByText(/Check again/)).toBeInTheDocument(); + }); + + 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" }); + }); +}); diff --git a/app/src/components/settings/HostsSettings.tsx b/app/src/components/settings/HostsSettings.tsx new file mode 100644 index 0000000..bfdc689 --- /dev/null +++ b/app/src/components/settings/HostsSettings.tsx @@ -0,0 +1,498 @@ +import { useEffect, 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; + onSignIn: () => void; + signingIn: boolean; +}) { + return ( +
+ {awsErrorMessage(failure.error)} + {failure.error.kind === "sso_expired" && ( + + )} +
+ ); +} + +function PermissionReportView({ report }: { report: PermissionReport }) { + const [copyNote, setCopyNote] = useState(null); + const probed = typeof report.method === "object"; + const badge = + report.summary === "workspace_user" && probed + ? "Workspace user (unverified)" + : SUMMARY_LABEL[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} + +
+ {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, permission boundaries 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 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 [pendingId, setPendingId] = useState(null); + + useEffect(() => { + let cancelled = false; + listAwsProfiles() + .then((p) => { + if (!cancelled) setProfiles(p); + }) + .catch(() => { + if (!cancelled) setProfiles([]); + }); + 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) }); + } + }; + + 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 loadSettings(); + await verify(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/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() {
    + + + + -- 2.52.0 From 01eab26a48f579a85bd9ded6f1b440d73fdd42a6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 17:04:26 -0800 Subject: [PATCH 20/23] fix(aws): Hosts UI sso_expired dead end, add retry safety, focus, profile errors Co-Authored-By: Claude Opus 5.5 --- .../settings/HostsSettings.test.tsx | 92 +++++++++++++++++++ app/src/components/settings/HostsSettings.tsx | 62 ++++++++++--- 2 files changed, 142 insertions(+), 12 deletions(-) diff --git a/app/src/components/settings/HostsSettings.test.tsx b/app/src/components/settings/HostsSettings.test.tsx index a202721..b3560d4 100644 --- a/app/src/components/settings/HostsSettings.test.tsx +++ b/app/src/components/settings/HostsSettings.test.tsx @@ -228,4 +228,96 @@ describe("HostsSettings", () => { 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 index bfdc689..b4ee0eb 100644 --- a/app/src/components/settings/HostsSettings.tsx +++ b/app/src/components/settings/HostsSettings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useSettings } from "../../hooks/useSettings"; import { addAwsKeysConnection, @@ -72,13 +72,19 @@ function ErrorLine({ signingIn, }: { failure: Failure; - onSignIn: () => void; + /** Absent when there is no saved connection to sign in with. */ + onSignIn?: () => void; signingIn: boolean; }) { return (
    {awsErrorMessage(failure.error)} - {failure.error.kind === "sso_expired" && ( + {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 && ( @@ -188,6 +194,16 @@ function ConnectionRow({ host }: { host: HostConnection }) { 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); @@ -244,6 +260,7 @@ function ConnectionRow({ host }: { host: HostConnection }) { return (
  • @@ -282,7 +299,7 @@ function ConnectionRow({ host }: { host: HostConnection }) { Edit {!confirmRemove && ( - )} @@ -292,10 +309,10 @@ function ConnectionRow({ host }: { host: HostConnection }) { {confirmRemove && (
    Remove {host.name}? Its stored credentials are deleted. - -
    @@ -321,6 +338,7 @@ function AddConnectionForm() { 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(() => { @@ -329,8 +347,11 @@ function AddConnectionForm() { .then((p) => { if (!cancelled) setProfiles(p); }) - .catch(() => { - if (!cancelled) setProfiles([]); + .catch((e) => { + if (!cancelled) { + setProfiles([]); + setProfilesError(awsErrorMessage(toAwsError(e))); + } }); return () => { cancelled = true; @@ -353,6 +374,19 @@ function AddConnectionForm() { } }; + /** 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); @@ -370,8 +404,7 @@ function AddConnectionForm() { } setName(""); setPendingId(added.id); - await loadSettings(); - await verify(added.id); + await afterAdd(added.id); } catch (e) { setFailure({ error: toAwsError(e), retry: submit }); } finally { @@ -416,7 +449,10 @@ function AddConnectionForm() { ]} /> {auth === "profile" ? ( - + {(id) => (