v0.2.1 AI Drafted AI Validated

Ona Hardening Guide

AI/ML Platform Last updated: 2026-08-20

Security hardening for Ona (formerly Gitpod) — the cloud platform for autonomous AI software-engineering agents: SSO/SCIM identity, agent guardrails (Veto, command deny list, MCP), environment and network policy, secrets, self-hosted runners, and audit logging.

View:

Overview

Ona (formerly Gitpod) runs autonomous AI software-engineering agents in cloud development environments with access to source code, credentials, package registries, and outbound network. That capability profile makes it a first-class security surface: an over-permissioned agent, a poisoned dev environment, or a leaked scoped credential can read or exfiltrate an entire codebase, and the platform’s own history of session/origin/token-boundary vulnerabilities (five Gitpod-era CVEs, one critical workspace-takeover) shows the blast radius is real.

This guide hardens Ona’s admin surfaces: identity (OIDC SSO, SCIM, roles), agent guardrails (the kernel-level Veto policy engine, command deny lists, MCP governance), environment and network policy, secrets scoping, deployment architecture, and audit logging.

No third-party benchmark exists yet. Cloud development environments and coding agents are not covered by CIS Benchmarks, DISA STIGs, or CISA SCuBA baselines as of this writing. Controls here are derived from Ona’s official documentation (the authoritative source for what settings exist) and grounded in the platform’s disclosed vulnerability history. Compliance mappings use control-family catalogs (NIST 800-53, SOC 2, CIS Controls v8, NIST AI RMF), which map and justify controls but do not originate configuration steps.

Intended Audience

  • Security engineers governing AI coding-agent platforms
  • Platform/DevSecOps teams administering an Ona organization
  • GRC professionals assessing autonomous-agent risk
  • Incident responders covering cloud-dev-environment compromise

How to Use This Guide

  • L1 (Crawl): Essential controls for all organizations
  • L2 (Walk): Enhanced controls for security-sensitive environments
  • L3 (Run): Strictest controls for regulated industries

Scope

This guide covers Ona organization administration: OIDC SSO and SCIM provisioning, roles and groups, service-account and token hygiene, the Veto executable-policy engine and agent command deny lists, MCP and SCM-tool governance, automation limits, port-admission and in-environment browser policy, environment lifetime/retention, dotfiles supply-chain risk, secrets scoping, OIDC workload identity, repository-access scoping, self-hosted runners, and audit logging. Model-behavior configuration (prompts, agent reasoning) is out of scope.

How the console is laid out (verified live, 2026-08-19). Ona’s own docs give inconsistent locations for the policy surface, so every navigation path in this guide was read off the live console rather than transcribed from the docs. The console served at app.gitpod.io; Settings is one page with a sidebar grouped as Organization (General, Terms of Service, Members, Integrations, Billing, Cost & Budgets), Infrastructure (All Environments, Runners, Secrets, Policies, Security), Agents (Policies, Skills), and Login & Identity (Login Configuration, SCIM, OIDC Tokens); personal settings live under the user menu → Account. Many controls below are Enterprise-gated (the page renders, the toggle is locked behind “Upgrade”) — the paths still hold.

Hosts and names. app.ona.com and app.gitpod.io are both live: https://app.ona.com/api answers 308 to https://app.gitpod.io/api (and curl -L drops the bearer token on that cross-host hop), the OIDC issuer (iss) is https://app.gitpod.io, and the API namespace remains gitpod.v1.* — none of that is legacy debris, so do not “correct” it. Audit-log retention and the LLM training-use posture remain undocumented — treat them as open questions with your Ona account team.


Table of Contents

  1. Identity & Access Controls
  2. Agent Governance & Guardrails
  3. Environment & Network Security
  4. Data & Secrets
  5. Deployment Architecture
  6. Monitoring & Detection
  7. Compliance Quick Reference

1. Identity & Access Controls

1.1 Enforce SSO with Domain Verification

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 6.7, 12.5
NIST 800-53 IA-2, IA-8
SOC 2 CC6.1

Description

Configure OIDC single sign-on (Ona supports OIDC only — no SAML is documented) against your identity provider (Okta, Google, GitLab, Entra ID, Cognito, or PingFederate), verify your email domain, and use claims expressions (CEL) for conditional access. Enterprise plan only.

Rationale

Why This Matters:

  • Federated sign-in brings Ona under your IdP’s MFA, conditional access, and lifecycle controls instead of standalone accounts
  • Ona’s disclosed CVEs are dominated by session/token-boundary bugs reachable from a single malicious link — centralizing authentication shrinks the standing credential surface those attacks target
  • CEL claims rules let you gate login on verified email, domain, or group membership at the moment of authentication

Attack Prevented: Credential-based account takeover, unmanaged accounts surviving offboarding

Prerequisites

  • Enterprise plan
  • OIDC application registered in your IdP
  • DNS access to publish a domain-verification TXT record

ClickOps Implementation

Step 1: Verify Your Email Domain

  1. Publish the DNS TXT record Ona provides for your domain — “Sign in with SSO” does not appear on the login screen until the domain is verified.

Step 2: Configure the OIDC Provider

  1. Navigate to: SettingsLogin & IdentityLogin Configuration — the Login domains section (Add domain) is where the email domain is verified, and Single Sign On (New SSO) is where the provider is added
  2. Add your OIDC provider (issuer URL, client ID, client secret) and assign the verified email domain(s) to it
  3. Optionally add a claims expression (CEL) for conditional access (e.g., claims.email_verified && claims.email.endsWith("@example.com"))

Time to Complete: ~1 hour

Code Implementation

Code Pack: Terraform
hth-ona-1.01-configure-sso.tf View source on GitHub ↗
terraform {
  # The provider publishes Linux amd64/arm64 packages only and requires CLI >= 1.14.
  required_version = ">= 1.14"

  required_providers {
    ona = {
      # gitpod-io/ona is the vendor's provider. An unrelated `combor/ona` also
      # exists in the registry — the source line is what tells them apart.
      source = "gitpod-io/ona"
      # Exact pin: every published version is a beta. Review each upgrade.
      version = "0.4.0-beta.1"
    }
  }
}

# Reads ONA_TOKEN from the environment. Use a read-write personal access token:
# service-account tokens are documented for reads and automation starts only,
# unless Ona has confirmed write support for your organization.
# Set ONA_HOST only for a non-default Ona application host.
provider "ona" {}

resource "ona_sso_configuration" "corp" {
  display_name = var.sso_display_name
  issuer_url   = var.sso_issuer_url
  client_id    = var.sso_client_id

  # Write-only argument: sent to Ona, absent from plan and state. Rotation is
  # driven by client_secret_version, not by the value changing.
  client_secret         = var.sso_client_secret
  client_secret_version = var.sso_client_secret_version

  email_domains     = var.sso_email_domains
  additional_scopes = var.sso_additional_scopes

  # Conditional access evaluated at login. Example requires a verified email.
  claims_expression = var.sso_claims_expression

  # "active" is what makes the configuration usable; "inactive" leaves it defined
  # but unusable. provider_type is read-only and reads `custom` for anything
  # Terraform manages.
  state = "active"
}
Code Pack: API Script
hth-ona-1.01-audit-sso-and-domain-verification.sh View source on GitHub ↗
# The evidence pass. Both halves of 1.1 are asserted: an ACTIVE non-BUILTIN SSO
# configuration (TRAP 2) AND at least one VERIFIED domain (TRAP 3).
audit() {
  resolve_org
  echo "Ona 1.1 — SSO enforcement and domain verification"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  paginate "OrganizationService/ListSSOConfigurations" \
           "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o}')" "ssoConfigurations"
  local sso="${PAGE_ITEMS}"

  paginate "OrganizationService/ListDomainVerifications" \
           "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o}')" "domainVerifications"
  local doms="${PAGE_ITEMS}"

  # TRAP 4. clientId/clientSecret are deliberately absent from this projection.
  echo "  sso configurations: $(jq 'length' <<<"${sso}")"
  jq -r '.[] |
    "    - providerType=\(.providerType // "PROVIDER_TYPE_UNSPECIFIED")" +
    " state=\(.state // "SSO_CONFIGURATION_STATE_UNSPECIFIED")" +
    " emailDomains=\((.emailDomains // []) | length)" +
    " claimsExpression=\(if (.claimsExpression // "") == "" then "unset" else "set" end)"' <<<"${sso}"

  # TRAP 2. Only a non-BUILTIN ACTIVE configuration counts as enforced SSO.
  local active_custom
  active_custom=$(jq '[.[] | select((.state // "") == "SSO_CONFIGURATION_STATE_ACTIVE")
                            | select((.providerType // "PROVIDER_TYPE_UNSPECIFIED") != "PROVIDER_TYPE_BUILTIN")] | length' <<<"${sso}")
  echo "  active non-BUILTIN sso configurations: ${active_custom}"

  local verified pending
  verified=$(jq '[.[] | select((.state // "") == "DOMAIN_VERIFICATION_STATE_VERIFIED")] | length' <<<"${doms}")
  pending=$(jq  '[.[] | select((.state // "") == "DOMAIN_VERIFICATION_STATE_PENDING")]  | length' <<<"${doms}")
  echo "  domain verifications: total=$(jq 'length' <<<"${doms}") verified=${verified} pending=${pending}"

  local rc=0
  if [ "${active_custom}" -eq 0 ]; then
    echo "FINDING: no non-BUILTIN SSO configuration is SSO_CONFIGURATION_STATE_ACTIVE."
    echo "  Members can still authenticate outside the IdP, so no IdP policy (MFA, device, session) applies."
    rc=1
  fi
  if [ "${verified}" -eq 0 ]; then
    echo "FINDING: no domain is DOMAIN_VERIFICATION_STATE_VERIFIED."
    echo "  Without a verified domain the organization cannot bind its email domain to the IdP,"
    echo "  so the non-SSO signup path stays open even when SSO is active. TRAP 3."
    rc=1
  fi
  if [ "${rc}" -eq 0 ]; then
    echo "COMPLIANT: SSO is active on a non-BUILTIN provider and at least one domain is verified."
  fi
  return "${rc}"
}
# The two write branches. CreateDomainVerification returns `verificationToken`,
# which is the value you publish as a public DNS TXT record — printing it is
# intended, it is not a secret (TRAP 4). Neither branch touches SSO client
# credentials. Explicit -X POST marks the mutation for anyone reading the pack.
apply_domain() {
  local domain="$1"
  resolve_org
  local body code
  code=$(curl -sS -o "${BODY_FILE}" -w '%{http_code}' -X POST \
    "${ONA_API_BASE}/gitpod.v1.OrganizationService/CreateDomainVerification" \
    -H "Authorization: Bearer ${ONA_TOKEN}" \
    -H "Content-Type: application/json" \
    -d "$(jq -nc --arg o "${ORG_ID}" --arg d "${domain}" '{organizationId: $o, domain: $d}')")
  body=$(cat "${BODY_FILE}")
  if [ "${code}" != "200" ]; then
    echo "PRECONDITION: CreateDomainVerification returned HTTP ${code} — $(jq -r '.message // "no message"' <<<"${body}")" >&2
    exit 2
  fi
  echo "created domain verification for ${domain}"
  echo "  id:    $(jq -r '.domainVerification.id // ""' <<<"${body}" | tail -c 7)"
  echo "  state: $(jq -r '.domainVerification.state // "DOMAIN_VERIFICATION_STATE_UNSPECIFIED"' <<<"${body}")"
  echo "  publish this TXT record on ${domain}, then re-run with --verify-domain <id>:"
  echo "  token: $(jq -r '.domainVerification.verificationToken // "(none returned)"' <<<"${body}")"
}

verify_domain() {
  local dvid="$1" body code
  code=$(curl -sS -o "${BODY_FILE}" -w '%{http_code}' -X POST \
    "${ONA_API_BASE}/gitpod.v1.OrganizationService/VerifyDomain" \
    -H "Authorization: Bearer ${ONA_TOKEN}" \
    -H "Content-Type: application/json" \
    -d "$(jq -nc --arg i "${dvid}" '{domainVerificationId: $i}')")
  body=$(cat "${BODY_FILE}")
  if [ "${code}" != "200" ]; then
    echo "PRECONDITION: VerifyDomain returned HTTP ${code} — $(jq -r '.message // "no message"' <<<"${body}")" >&2
    echo "  A PENDING state usually means the TXT record has not propagated yet." >&2
    exit 2
  fi
  echo "domain …${dvid: -6} now reads state=$(jq -r '.domainVerification.state // "DOMAIN_VERIFICATION_STATE_UNSPECIFIED"' <<<"${body}")"
}

Validation & Testing

  1. A user on the verified domain is redirected to your IdP at login
  2. A CEL rule denies a test principal that fails its condition
  3. Note the platform limitation: Ona documents no org-wide SSO-enforcement toggle, and a built-in provider (Google/GitHub) can stay active alongside your IdP — pair this control with SCIM account restriction (1.2) to close the non-SSO join path.

Expected result: Domain-verified OIDC sign-in active. (SSO overview)

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access security
NIST 800-53 IA-2 Identification and authentication

1.2 Enforce SCIM Provisioning and Restrict Account Creation

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 5.3, 6.2
NIST 800-53 AC-2
SOC 2 CC6.2

Description

Enable SCIM provisioning against your IdP, then enable the Restrict account creation to SCIM policy so SSO users who were not SCIM-provisioned cannot join the organization. Because Ona cannot mandate SSO org-wide (see 1.1), this policy is what actually closes the uncontrolled-join path.

Rationale

Why This Matters:

  • SCIM automatically deprovisions members when they leave the IdP — without it, departed engineers keep agent and source-code access
  • The “restrict account creation to SCIM” toggle blocks the gap left by SSO being non-mandatory: a valid SSO login alone can otherwise create an org membership
  • Directory-driven membership keeps agent-capable accounts tied to employment status

Attack Prevented: Orphaned accounts with standing code/agent access, uncontrolled organization joins

Prerequisites

  • An active SSO provider (1.1) — the SCIM restriction toggle stays disabled until SCIM is configured and enabled

ClickOps Implementation

Step 1: Configure SCIM

  1. Navigate to: SettingsLogin & IdentitySCIMConfigurationSet up SCIM
  2. Generate the SCIM endpoint and bearer token. The token is shown once and is unrecoverable — store it in your secrets manager immediately.
  3. Configure the SCIM integration in your IdP with the endpoint and token. (An SCIM configuration created through the API is stored disabled until UpdateSCIMConfiguration sets enabled: true.)

Step 2: Restrict Account Creation

  1. On the same SCIM page, enable Require SCIM provisioning — “Only SCIM-provisioned members can access this organization. SSO accounts without SCIM provisioning are blocked.” (API/Terraform name: restrictAccountCreationToScim)

Time to Complete: ~45 minutes

Code Implementation

Code Pack: Terraform
hth-ona-1.02-scim-and-account-restriction.tf View source on GitHub ↗
resource "ona_scim_configuration" "corp" {
  sso_configuration_id = var.scim_sso_configuration_id
  name                 = var.scim_name

  # SCIM is inert until enabled. The API has no `enabled` field on create, so a
  # one-call provisioning script leaves SCIM created-but-off; Terraform's
  # create-then-update flow is what closes that gap.
  enabled = true

  # Shortest lifetime the directory integration can live with. Replacing the
  # resource mints a new token — plan rotation windows deliberately.
  token_expires_in = var.scim_token_expires_in

  # Do not let SCIM claim an existing account on an email the IdP never verified.
  allow_unverified_email_account_linking = false
}

# The toggle that actually closes the uncontrolled-join path. SSO alone cannot be
# mandated org-wide, so without this a valid SSO login still creates a membership.
# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "scim_account_restriction" {
  restrict_account_creation_to_scim = true

  # Enabling the restriction before SCIM is live locks out legitimate joins.
  depends_on = [ona_scim_configuration.corp]
}
Code Pack: API Script
hth-ona-1.02-audit-scim-restriction.sh View source on GitHub ↗
# Two independent facts make control 1.2: at least one SCIM configuration with
# enabled == true (TRAP 3), and restrictAccountCreationToScim == true (TRAP 1).
# Neither is sufficient alone (TRAP 4).
audit_scim() {
  # TRAP 2: no organizationId on this request — the token scopes it.
  paginate "OrganizationService/ListSCIMConfigurations" '{}' "scimConfigurations"
  local scim="${PAGE_ITEMS}" total enabled
  total=$(jq 'length' <<<"${scim}")
  enabled=$(jq '[.[] | select((.enabled // false) == true)] | length' <<<"${scim}")

  echo "  scim configurations: total=${total} enabled=${enabled}"
  jq -r '.[] |
    "    - enabled=\(.enabled // false)" +
    " ssoConfiguration=\(if (.ssoConfigurationId // "") == "" then "unlinked" else "linked" end)" +
    " tokenExpiresAt=\(.tokenExpiresAt // "(none)")" +
    " allowUnverifiedEmailAccountLinking=\(.allowUnverifiedEmailAccountLinking // false)"' <<<"${scim}"

  local unverified
  unverified=$(jq '[.[] | select((.allowUnverifiedEmailAccountLinking // false) == true)] | length' <<<"${scim}")
  if [ "${unverified}" -gt 0 ]; then
    echo "  NOTE: ${unverified} configuration(s) allow SCIM to link accounts on an UNVERIFIED email."
    echo "        That weakens the identity binding SCIM is supposed to guarantee — review it."
  fi

  SCIM_ENABLED_COUNT="${enabled}"
}

audit() {
  get_policies
  echo "Ona 1.2 — SCIM provisioning and account-creation restriction"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  audit_scim

  # TRAP 1. Absent boolean == false. Never treat a missing key as compliant.
  local restrict
  restrict=$(jq -r '.restrictAccountCreationToScim // false' <<<"${POLICIES}")
  echo "  restrictAccountCreationToScim: ${restrict} (absent in the JSON means false)"

  local rc=0
  if [ "${SCIM_ENABLED_COUNT}" -eq 0 ]; then
    echo "FINDING: no SCIM configuration has enabled == true."
    echo "  Joiner/mover/leaver events are not propagated from the IdP, so deprovisioning is manual."
    rc=1
  fi
  if [ "${restrict}" != "true" ]; then
    echo "FINDING: restrictAccountCreationToScim is false."
    echo "  Anyone who can authenticate through the IdP can self-provision an account that the"
    echo "  IdP never assigned to this organization."
    rc=1
  fi
  if [ "${rc}" -eq 0 ]; then
    echo "COMPLIANT: SCIM is enabled and account creation is restricted to SCIM-provisioned users."
  fi
  return "${rc}"
}
Code Pack: Sigma Detection Rule
hth-ona-1.02-sso-scim-configuration-changed.yml View source on GitHub ↗
detection:
    selection:
        subjectType:
            - 'RESOURCE_TYPE_SSO_CONFIG'
            - 'RESOURCE_TYPE_SCIM_CONFIGURATION'
            - 'RESOURCE_TYPE_DOMAIN_VERIFICATION'
        kind: 'AUDIT_LOG_ENTRY_KIND_RESOURCE_CHANGE'
    condition: selection
fields:
    - createdAt
    - actorId
    - actorPrincipal
    - subjectId
    - subjectType
    - action

Validation & Testing

  1. Remove a test user from the IdP and confirm deprovisioning in Ona
  2. Attempt to join with an SSO account not present in SCIM — the join must be blocked

Expected result: Membership mirrors the directory; non-SCIM SSO joins refused. (SCIM overview · SCIM account restriction)

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.2 User registration and deregistration
NIST 800-53 AC-2 Account management

1.3 Apply Least-Privilege Organization Roles and Groups

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 5.4, 6.8
NIST 800-53 AC-6(1)
SOC 2 CC6.3

Description

Use Ona’s delegated organization roles (Runners Admin, Projects Admin, Groups Admin, Automations Admin, plus read-only Insights Viewer, Audit Log Reader, and Billing Viewer) and groups to grant the minimum needed, rather than making everyone a full organization admin.

Rationale

Why This Matters:

  • Delegated roles let a runner operator manage runners without also controlling identity, policy, and secrets
  • Group permissions are a union with highest-level-wins, so an over-broad group silently elevates everyone in it — audit membership deliberately
  • Read-only roles (Audit Log Reader, Insights Viewer) enable oversight without granting change rights

Attack Prevented: Privilege sprawl enabling org-wide policy, secret, or runner tampering from any one account

ClickOps Implementation

Step 1: Assign Delegated Roles

  1. Navigate to: SettingsOrganizationMembersGroups tab (delegated roles are Enterprise-tier)
  2. Toggle the specific role columns per group — grant the narrowest admin role that covers the duty. Under the hood these are group role assignments over the organization (RESOURCE_ROLE_ORG_RUNNERS_ADMIN, …_PROJECTS_ADMIN, …_GROUPS_ADMIN, …_AUTOMATIONS_ADMIN, …_AUDIT_LOG_READER, …_BILLING_VIEWER, …_INSIGHTS_VIEWER); the member-level role itself is only Admin or Member. Two roles worth knowing that the console list omits: RESOURCE_ROLE_ORG_SECURITY_ADMIN and RESOURCE_ROLE_SECURITY_POLICY_ADMIN/_VIEWER — the least-privilege way to delegate the Veto and port policies (2.1, 3.1) without full org admin.

Step 2: Audit Group Unions

  1. Review each group’s effective permissions, remembering the highest level across a user’s groups wins; the derivedFromOrgRole field on a role assignment separates inherited grants from ad-hoc shares
  2. Keep full organization admin to a small named set

Time to Complete: ~30 minutes

Code Implementation

Code Pack: Terraform
hth-ona-1.03-groups-and-delegated-roles.tf View source on GitHub ↗
# One group per duty. Delegated roles attach to groups, never to individual users.
resource "ona_group" "runner_admins" {
  name        = "Runner Admins"
  description = "Administers runners only. No identity, policy, or secret rights."
}

resource "ona_group" "audit_log_readers" {
  name        = "Audit Log Readers"
  description = "Read-only oversight. Can read audit logs, cannot change configuration."
}

# Resolve each human to a stable user_id. email alone is not enough — the same
# address under a different login_provider is a different Ona user.
data "ona_user" "runner_admins" {
  for_each = var.runner_admins

  email          = each.value.email
  login_provider = each.value.login_provider
}

data "ona_user" "audit_log_readers" {
  for_each = var.audit_log_readers

  email          = each.value.email
  login_provider = each.value.login_provider
}

resource "ona_group_membership" "runner_admins" {
  for_each = data.ona_user.runner_admins

  group_id = ona_group.runner_admins.id
  user_id  = each.value.user_id
}

resource "ona_group_membership" "audit_log_readers" {
  for_each = data.ona_user.audit_log_readers

  group_id = ona_group.audit_log_readers.id
  user_id  = each.value.user_id
}

# The narrowest admin role that covers the duty — NOT organization_admin.
resource "ona_organization_role_assignment" "runner_admins" {
  group_id = ona_group.runner_admins.id
  role     = "runners_admin"
}

# Read-only oversight: sees the audit trail, cannot alter configuration.
resource "ona_organization_role_assignment" "audit_log_readers" {
  group_id = ona_group.audit_log_readers.id
  role     = "audit_log_reader"
}
# Standing evidence for the "keep full organization admin to a small named set"
# half of the control. This lists org ADMINS, so a review reads the count and the
# names rather than trusting that nobody was promoted in the console.
data "ona_users" "org_admins" {
  search   = var.user_search_domain
  statuses = ["active"]
  roles    = ["admin"]
}

output "ona_active_org_admin_count" {
  description = "Number of active full organization admins. Investigate any growth."
  value       = length(data.ona_users.org_admins.users)
}
Code Pack: API Script
hth-ona-1.03-audit-org-roles-and-groups.sh View source on GitHub ↗
# Three reads, one picture: who is an org ADMIN (ListMembers), what groups exist
# (ListGroups), and what privileges those groups hold (ListRoleAssignments).
# Control 1.3 is not provable from any one of them (TRAP 1).
audit() {
  resolve_org
  echo "Ona 1.3 — organization roles, groups and role assignments"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  # TRAP 5: paginate to exhaustion; the default ordering is not evidence.
  paginate "OrganizationService/ListMembers" \
           "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o}')" "members"
  local members="${PAGE_ITEMS}" admins total
  total=$(jq 'length' <<<"${members}")
  admins=$(jq '[.[] | select((.role // "ORGANIZATION_ROLE_UNSPECIFIED") == "ORGANIZATION_ROLE_ADMIN")] | length' <<<"${members}")
  echo "  members: total=${total} ORGANIZATION_ROLE_ADMIN=${admins} ORGANIZATION_ROLE_MEMBER=$((total - admins))"

  # TRAP 4: counts by login provider, never the addresses themselves.
  jq -r 'group_by(.loginProvider // "(unset)") | .[] |
    "    - loginProvider=\(.[0].loginProvider // "(unset)") members=\(length)"' <<<"${members}"

  if [ "${SHOW_ADMINS}" -eq 1 ]; then
    echo "  admin userId prefixes:"
    jq -r '.[] | select((.role // "") == "ORGANIZATION_ROLE_ADMIN")
           | "    - …\(((.userId // "unknown")[-6:])) status=\(.status // "USER_STATUS_UNSPECIFIED")"' <<<"${members}"
  fi

  paginate "GroupService/ListGroups" '{}' "groups"
  local groups="${PAGE_ITEMS}"
  echo "  groups: total=$(jq 'length' <<<"${groups}")" \
       "systemManaged=$(jq '[.[] | select((.systemManaged // false) == true)] | length' <<<"${groups}")" \
       "directShare=$(jq '[.[] | select((.directShare // false) == true)] | length' <<<"${groups}")"
  # TRAP 3: only the groups a human actually created are worth listing.
  jq -r '.[] | select((.systemManaged // false) == false) | select((.directShare // false) == false)
         | "    - name=\(.name // "(unnamed)") memberCount=\(.memberCount // 0)"' <<<"${groups}"

  paginate "GroupService/ListRoleAssignments" '{}' "assignments"
  local ras="${PAGE_ITEMS}"
  echo "  role assignments: total=$(jq 'length' <<<"${ras}")"

  # TRAP 1 + TRAP 2. Join each assignment to its group name, then split the list
  # by whether it was derived from an org role or shared directly.
  local joined
  joined=$(jq -c --argjson g "${groups}" '
    ($g | map({key: (.id // ""), value: (.name // "(unknown group)")}) | from_entries) as $names
    | map(. + {groupName: ($names[.groupId // ""] // "(group not visible)")})' <<<"${ras}")

  jq -r 'group_by((.resourceType // "RESOURCE_TYPE_UNSPECIFIED") + "|" + (.resourceRole // "RESOURCE_ROLE_UNSPECIFIED"))
         | .[] | "    - \(.[0].resourceType // "RESOURCE_TYPE_UNSPECIFIED") \(.[0].resourceRole // "RESOURCE_ROLE_UNSPECIFIED") count=\(length)"' <<<"${joined}"

  local direct
  direct=$(jq '[.[] | select((.derivedFromOrgRole // "RESOURCE_ROLE_UNSPECIFIED") == "RESOURCE_ROLE_UNSPECIFIED")] | length' <<<"${joined}")
  echo "  direct (manually created) assignments: ${direct} — these are the ones a review must justify"

  local org_admin_grants
  org_admin_grants=$(jq -c '[.[] | select((.resourceRole // "") == "RESOURCE_ROLE_ORG_ADMIN")]' <<<"${joined}")
  local org_admin_count
  org_admin_count=$(jq 'length' <<<"${org_admin_grants}")
  if [ "${org_admin_count}" -gt 0 ]; then
    echo "  RESOURCE_ROLE_ORG_ADMIN grants: ${org_admin_count}"
    jq -r '.[] | "    ! group=\(.groupName) resourceType=\(.resourceType // "RESOURCE_TYPE_UNSPECIFIED") derivedFromOrgRole=\(.derivedFromOrgRole // "RESOURCE_ROLE_UNSPECIFIED")"' <<<"${org_admin_grants}"
    echo "    Every member of those groups holds organization administrator privilege."
  fi

  if [ "${admins}" -gt "${ONA_MAX_ADMINS}" ]; then
    echo "FINDING: ${admins} organization administrators exceeds ONA_MAX_ADMINS=${ONA_MAX_ADMINS}."
    echo "  ORGANIZATION_ROLE_ADMIN is all-or-nothing. The seven RESOURCE_ROLE_ORG_* roles"
    echo "  (Runners/Projects/Groups/Automations Admin, Insights Viewer, Audit Log Reader,"
    echo "  Billing Viewer) exist so that day-to-day work does not need it — assign those to a"
    echo "  group instead and demote the surplus admins to ORGANIZATION_ROLE_MEMBER."
    return 1
  fi
  echo "COMPLIANT: ${admins} organization administrator(s), at or under ONA_MAX_ADMINS=${ONA_MAX_ADMINS}."
  return 0
}

Validation & Testing

  1. A Runners Admin cannot change identity or policy settings
  2. An Audit Log Reader can read logs but cannot alter configuration

Expected result: Capability follows role; admin count minimized. (Organization roles · Groups)

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.3 Role-based access
NIST 800-53 AC-6(1) Least privilege

1.4 Harden Service Accounts and Personal Access Tokens

Profile Level: L2 (Walk)

Framework Control
CIS Controls 5.4, 16.9
NIST 800-53 IA-5, AC-2
SOC 2 CC6.1

Description

Constrain machine credentials: never issue service-account tokens with indefinite validity, prefer the shortest workable token lifetime, and keep personal access tokens read-only unless write is required. Token scope is immutable after creation, so scope tightly up front.

Rationale

Why This Matters:

  • Service-account tokens can be set to indefinite validity — a leaked indefinite token is a permanent foothold that outlives every rotation policy
  • PAT actions are logged with the token ID, so short-lived, narrowly-scoped tokens give both containment and attribution
  • The Bitbucket-OAuth token-leak CVE (CVE-2025-55750) shows token exposure is a live risk on this platform — minimizing token lifetime and scope caps the damage

Attack Prevented: Persistent access via leaked long-lived tokens, over-scoped credential abuse

ClickOps Implementation

Step 1: Constrain Service Accounts

  1. Navigate to: SettingsOrganizationMembersService Accounts tab (Core tier and above)
  2. Set token validity to the shortest that works (30/60/90 days or 1 year) — never “no expiry”. The API contract itself requires validUntil on the service account; only the token can be minted without an expiry, so audit expiresAt on every token.
  3. Service-account tokens can start automations and perform read (GET/LIST) API operations; Ona documents no read/write access level for them — keep automations to what a service account can do and use PATs (below) where write access must be scoped.

Step 2: Govern Personal Access Tokens

  1. In the user menu → AccountPersonal access tokensNew token (or app.ona.com/settings/personal-access-tokens): expiry is 30 / 60 / 90 days and access is Read-only or Read & Write Access — the form defaults to Read & Write, so choose Read-only deliberately unless write is justified
  2. Because access level is immutable after creation, review and re-issue rather than widening an existing token; a read-only token is enforced at the data layer (“Mutations will be denied”)

Time to Complete: ~30 minutes plus recurring review

Code Implementation

Code Pack: Terraform
hth-ona-1.04-service-account.tf View source on GitHub ↗
resource "ona_service_account" "automation" {
  name        = var.service_account_name
  description = var.service_account_description

  # REQUIRED by the schema — there is no way to express "indefinite" here, which
  # is exactly why this control belongs in Terraform. Keep it short; changing it
  # replaces the account.
  valid_until = var.service_account_valid_until
}

# Ephemeral: the token value is returned once and is NOT written to plan or state.
# This is the only credential-issuing surface on the provider that keeps the
# secret out of the state file.
ephemeral "ona_service_account_token" "automation" {
  service_account_id = ona_service_account.automation.id
  description        = var.service_account_description
  valid_for          = var.service_account_token_valid_for
}

# Consume the token ONLY from an ephemeral context. Uncomment and point `source`
# at a module of yours that writes to a secrets manager through an ephemeral
# variable or a write-only argument:
#
# module "automation_token_secret" {
#   source                = "./modules/service-account-token-secret"
#   service_account_token = ephemeral.ona_service_account_token.automation.token
# }
#
# It cannot be surfaced as a normal output, and that restriction is the feature.
Code Pack: API Script
hth-ona-1.04-audit-service-accounts-and-pats.sh View source on GitHub ↗
# Inventory pass. Three populations, one question each: does this credential
# have an end date, and is it broader than it needs to be?
audit_service_accounts() {
  # TRAP 3: ask for suspended accounts explicitly, then split them out.
  paginate "ServiceAccountService/ListServiceAccounts" \
           '{"filter":{"includeSuspended":true}}' "serviceAccounts"
  local sas="${PAGE_ITEMS}" total suspended sysmanaged nolimit
  total=$(jq 'length' <<<"${sas}")
  suspended=$(jq '[.[] | select((.suspended // false) == true)] | length' <<<"${sas}")
  sysmanaged=$(jq '[.[] | select((.systemManaged // false) == true)] | length' <<<"${sas}")
  echo "  service accounts: total=${total} suspended=${suspended} systemManaged=${sysmanaged}"
  SA_ACTIVE_COUNT=$((total - suspended))

  # TRAP 5: absent validUntil == no expiry.
  nolimit=$(jq '[.[] | select((.suspended // false) == false) | select((.validUntil // "") == "")] | length' <<<"${sas}")
  jq -r '.[] | select((.suspended // false) == false)
         | "    - id=…\(((.id // "unknown")[-6:])) validUntil=\(.validUntil // "(none — never expires)") systemManaged=\(.systemManaged // false)"' <<<"${sas}"
  if [ "${nolimit}" -gt 0 ]; then
    echo "FINDING: ${nolimit} active service account(s) have no validUntil — they never expire."
    FINDINGS=$((FINDINGS + 1))
  fi
}

audit_service_account_tokens() {
  # TRAP 2: probe once without the strict classifier so an impersonation refusal
  # is reported as an evidence gap rather than crashing the sweep.
  api "ServiceAccountService/ListServiceAccountTokens" '{"pagination":{"pageSize":100}}'
  if [ "${RPC_CODE}" != "200" ]; then
    SA_TOKENS_ENUMERABLE=0
    echo "  service account tokens: NOT ENUMERABLE (HTTP ${RPC_CODE} $(rpc_err)) — $(rpc_msg)"
    echo "    ListServiceAccountTokens derives the account from the CALLER's identity and needs a"
    echo "    service-account impersonation token. This pack refuses to mint one, so service-account"
    echo "    token expiry is UNPROVEN here — read it in the console, or re-run authenticated AS the"
    echo "    service account (ONA_TOKEN = that account's own token)."
    return 0
  fi
  paginate "ServiceAccountService/ListServiceAccountTokens" '{}' "tokens"
  local toks="${PAGE_ITEMS}" total noexp
  total=$(jq 'length' <<<"${toks}")
  noexp=$(jq '[.[] | select((.expiresAt // "") == "")] | length' <<<"${toks}")
  echo "  service account tokens (for the calling identity): total=${total} without expiresAt=${noexp}"
  jq -r '.[] | "    - id=…\(((.id // "unknown")[-6:])) expiresAt=\(.expiresAt // "(none — never expires)") lastUsed=\(.lastUsed // "(never)")"' <<<"${toks}"
  if [ "${noexp}" -gt 0 ]; then
    echo "FINDING: ${noexp} service account token(s) have no expiresAt."
    FINDINGS=$((FINDINGS + 1))
  fi
}

audit_pats() {
  paginate "UserService/ListPersonalAccessTokens" '{}' "personalAccessTokens"
  local pats="${PAGE_ITEMS}" total noexp rw stale
  total=$(jq 'length' <<<"${pats}")
  noexp=$(jq '[.[] | select((.expiresAt // "") == "")] | length' <<<"${pats}")
  # TRAP 4: absent readOnly == read-write.
  rw=$(jq '[.[] | select((.readOnly // false) == false)] | length' <<<"${pats}")
  stale=$(jq '[.[] | select((.lastUsed // "") == "")] | length' <<<"${pats}")
  echo "  personal access tokens: total=${total} read-write=${rw} without expiresAt=${noexp} never-used=${stale}"
  jq -r '.[] | "    - id=…\(((.id // "unknown")[-6:])) readOnly=\(.readOnly // false) expiresAt=\(.expiresAt // "(none — never expires)") lastUsed=\(.lastUsed // "(never)")"' <<<"${pats}"
  if [ "${noexp}" -gt 0 ]; then
    echo "FINDING: ${noexp} personal access token(s) have no expiresAt."
    echo "  A read-write PAT with no end date is a standing key to the whole organization API."
    FINDINGS=$((FINDINGS + 1))
  fi
}

audit() {
  resolve_org
  echo "Ona 1.4 — service account and personal access token lifetimes"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"
  audit_service_accounts
  audit_service_account_tokens
  audit_pats

  if [ "${FINDINGS}" -gt 0 ]; then
    echo "RESULT: ${FINDINGS} finding group(s) — unbounded credentials exist in this organization."
    return 1
  fi
  # The impersonation gap only matters when there is something behind it: with zero
  # active service accounts there are no service-account tokens to miss, so the
  # audit is complete even though the method refused.
  if [ "${SA_TOKENS_ENUMERABLE}" -eq 0 ] && [ "${SA_ACTIVE_COUNT}" -gt 0 ]; then
    echo "RESULT: every credential this token could read is bounded, but the ${SA_ACTIVE_COUNT} active"
    echo "        service account(s) above have tokens this identity cannot enumerate (TRAP 2)."
    echo "        Exiting 2: the audit is incomplete, not clean."
    return 2
  fi
  if [ "${SA_TOKENS_ENUMERABLE}" -eq 0 ]; then
    echo "  (the impersonation refusal above hides nothing: this organization has no active"
    echo "   service accounts, so there are no service-account tokens to enumerate)"
  fi
  echo "COMPLIANT: every service account, service-account token and personal access token has an expiry."
  return 0
}
Code Pack: Sigma Detection Rule
hth-ona-1.04-personal-access-token-created.yml View source on GitHub ↗
detection:
    selection:
        subjectType: 'RESOURCE_TYPE_PERSONAL_ACCESS_TOKEN'
        kind: 'AUDIT_LOG_ENTRY_KIND_RESOURCE_CHANGE'
    condition: selection
fields:
    - createdAt
    - actorId
    - actorPrincipal
    - subjectId
    - action
    - kind

Validation & Testing

  1. No service account carries an indefinite token
  2. Token IDs appear in audit logs for a test action (6.1)

Expected result: Short-lived, least-privilege machine credentials with attribution. (Service accounts · Personal access tokens)

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access security
NIST 800-53 IA-5 Authenticator management

1.5 Control Member Invitations and Remove Domain Auto-Admit

Profile Level: L2 (Walk)

Framework Control
CIS Controls 6.1, 6.2
NIST 800-53 AC-2
SOC 2 CC6.2

Description

Review the organization’s invitation surface: the shareable invite link, email invites, and especially the email-domain whitelist that auto-admits any matching address. In directory-driven orgs, disable the domain auto-admit and rely on SCIM (1.2).

Rationale

Why This Matters:

  • An email-domain whitelist auto-admits anyone with a matching address — including a compromised or contractor account you never intended to grant agent access
  • A leaked shareable invite link admits strangers until it is reset
  • Membership should be driven by the directory, not by domain-string matching

Attack Prevented: Unauthorized organization joins via domain matching or leaked invite links

ClickOps Implementation

Step 1: Remove Domain Auto-Admit

  1. The domain allow-list is the organization’s inviteDomains (API: UpdateOrganization with inviteDomains: {domains: []}; readable via GetOrganization). Where your tier exposes it in the console (SettingsOrganizationMembers), clear every entry so matching addresses no longer auto-join; the API pack below audits and clears it on any tier.

Step 2: Control the Invite Link

  1. Navigate to: SettingsLogin & IdentityLogin ConfigurationOrganization login link — this is the shareable join link; regenerate it if it may have leaked (API: CreateOrganizationInvite issues a new id, killing the old link). Email invitations are console-only (no public API method); prefer SCIM (1.2).

Time to Complete: ~15 minutes

Code Implementation

Code Pack: API Script
hth-ona-1.05-audit-invite-surface.sh View source on GitHub ↗
# The whole API-addressable surface of control 1.5: the domain allow-list on the
# organization object, and whether a shared invite link exists at all.
audit() {
  resolve_org
  api_strict "OrganizationService/GetOrganization" \
    "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o}')"
  local org domains dcount
  org=$(printf '%s' "${RPC_BODY}" | jq -c '.organization // {}')

  echo "Ona 1.5 — invite surface (domain auto-admit and the shared invite link)"
  echo "  organization: …${ORG_ID: -6} tier=$(jq -r '.tier // "ORGANIZATION_TIER_UNSPECIFIED"' <<<"${org}")"

  # TRAP 1: absent inviteDomains == empty == hardened.
  domains=$(jq -c '.inviteDomains.domains // []' <<<"${org}")
  dcount=$(jq 'length' <<<"${domains}")
  echo "  inviteDomains.domains: ${dcount} entr$( [ "${dcount}" -eq 1 ] && echo y || echo ies )"
  if [ "${dcount}" -gt 0 ]; then
    jq -r '.[] | "    ! \(.)"' <<<"${domains}"
  fi

  # TRAP 2: prove the link exists without printing the id that IS the link.
  api "OrganizationService/GetOrganizationInvite" \
      "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o}')"
  local invite_state
  if [ "${RPC_CODE}" = "200" ]; then
    if [ -n "$(printf '%s' "${RPC_BODY}" | jq -r '.invite.inviteId // ""')" ]; then
      invite_state="present"
    else
      invite_state="absent"
    fi
  elif [ "${RPC_CODE}" = "404" ]; then
    invite_state="absent"
  else
    invite_state="unreadable (HTTP ${RPC_CODE} $(rpc_err))"
  fi
  echo "  shared invite link: ${invite_state} (the id is withheld — it is a join credential)"
  if [ "${invite_state}" = "present" ]; then
    echo "    Anyone holding it can call JoinOrganization, and GetOrganizationInviteSummary"
    echo "    already discloses the organization name and member count to a link-holder."
    echo "    Rotate it with --reset-invite-link whenever it may have been shared outside."
  fi

  if [ "${dcount}" -gt 0 ]; then
    echo "FINDING: ${dcount} domain(s) auto-admit new members to this organization."
    echo "  Every current and future mailbox in those domains — contractors, shared aliases,"
    echo "  a re-registered ex-employee address — can join without an approval step."
    echo "  Clear the list with --clear-invite-domains and admit members through SSO/SCIM (1.1, 1.2)."
    return 1
  fi
  echo "COMPLIANT: inviteDomains is empty — no domain auto-admits into this organization."
  return 0
}
# UpdateOrganization echoes the organization back, so unlike the policies write
# this one IS self-verifying — but it is read back anyway, because the echo is
# the server's copy of the request and a separate GET is the honest proof.
clear_invite_domains() {
  resolve_org
  local code
  code=$(curl -sS -o "${BODY_FILE}" -w '%{http_code}' -X POST \
    "${ONA_API_BASE}/gitpod.v1.OrganizationService/UpdateOrganization" \
    -H "Authorization: Bearer ${ONA_TOKEN}" \
    -H "Content-Type: application/json" \
    -d "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o, inviteDomains: {domains: []}}')")
  if [ "${code}" != "200" ]; then
    echo "PRECONDITION: UpdateOrganization returned HTTP ${code} — $(jq -r '.message // "no message"' < "${BODY_FILE}")" >&2
    exit 2
  fi
  api_strict "OrganizationService/GetOrganization" "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o}')"
  local remaining
  remaining=$(printf '%s' "${RPC_BODY}" | jq '(.organization.inviteDomains.domains // []) | length')
  echo "read-back: inviteDomains.domains now has ${remaining} entries"
  [ "${remaining}" -eq 0 ] || { echo "the write did not stick — ${remaining} domain(s) remain" >&2; exit 1; }
}

# TRAP 4: a new inviteId invalidates the old link; it removes nobody.
reset_invite_link() {
  resolve_org
  local code
  code=$(curl -sS -o "${BODY_FILE}" -w '%{http_code}' -X POST \
    "${ONA_API_BASE}/gitpod.v1.OrganizationService/CreateOrganizationInvite" \
    -H "Authorization: Bearer ${ONA_TOKEN}" \
    -H "Content-Type: application/json" \
    -d "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o}')")
  if [ "${code}" != "200" ]; then
    echo "PRECONDITION: CreateOrganizationInvite returned HTTP ${code} — $(jq -r '.message // "no message"' < "${BODY_FILE}")" >&2
    exit 2
  fi
  if [ -n "$(jq -r '.invite.inviteId // ""' < "${BODY_FILE}")" ]; then
    echo "invite link rotated: a new invite id was issued, so the previous link no longer joins."
    echo "  The id is withheld here on purpose — retrieve it from the console when you distribute it."
    echo "  Members who already joined through the old link are NOT removed; review them under 1.3."
  else
    echo "CreateOrganizationInvite returned 200 but no inviteId — treat the rotation as unproven" >&2
    exit 1
  fi
}

Validation & Testing

  1. A new address on your domain is not auto-admitted after the whitelist is removed
  2. The prior invite link no longer grants access after reset

Expected result: Joins are explicit or directory-driven. (Manage members)

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.2 User registration
NIST 800-53 AC-2 Account management

2. Agent Governance & Guardrails

2.1 Enforce an Executable Policy with Veto

Profile Level: L2 (Walk)

Framework Control
CIS Controls 2.5, 2.7
NIST 800-53 CM-7, SI-3
NIST AI RMF MANAGE-2.3

Description

Use Veto — Ona’s Linux Security Module that “runs as a Linux Security Module (LSM) inside the environment kernel, below the agent”; per Ona, “the LLM cannot bypass or disable it” and the agent cannot unload it, modify its configuration, or observe whether an action was flagged — to define an executable policy. Veto Exec rules match by absolute path (/usr/bin/curl) or bare name (npx), resolve to SHA-256 content identity (rename- and symlink-resistant), and block execution AND read/copy/modify of the target.

Rationale

Why This Matters:

  • Guardrails defined in the agent’s own context can be reasoned around; a kernel LSM below the agent cannot be evaded by the agent
  • SHA-256 content identity defeats the rename/symlink tricks an agent (or injected instruction) would use to dodge a name-based block
  • Blocking read/copy/modify — not just exec — stops an agent from staging a renamed copy of a blocked tool

Attack Prevented: Malicious or prompt-injected agent executing exfiltration/attack tooling (curl, scp, package installers) inside the environment

Prerequisites

  • Understanding of which executables agents legitimately need (over-blocking breaks builds)

ClickOps Implementation

Step 1: Define Veto Executable Rules

  1. Navigate to: SettingsInfrastructureSecurity (Enterprise tier — on lower tiers the page shows only the security-agents section)
  2. Add rules by absolute path or bare name (./../relative paths are rejected); leave Block unchecked to audit a rule or check it to block execution (EFFECT_AUDIT / EFFECT_BLOCK in the YAML/API)
  3. Leave the default effect at allow (defaultEffect omitted or EFFECT_ALLOW) and block specific high-risk binaries; start in audit, then move to block once the audit trail is clean
  4. Ona runtime binaries are on a server-populated safelist (vetoExecPolicy.safelist, output-only) and cannot be blocked — a rule naming one silently does not take effect

Automation surface: manageable as code. CLI: ona organization security-policy init policy.yaml --validate-onlycreate policy.yaml -o yamlset-default <security-policy-id> (a created policy is inert until assigned as the org default; set-default --clear removes every control in the policy, and running environments keep the policy from their last start). API: SecurityService/CreateSecurityPolicy + OrganizationService/UpdateOrganizationPolicies.securityPolicyId. Terraform: ona_security_policy + ona_organization_policies.security_policy_id.

Time to Complete: ~1 hour plus an audit-mode observation window

Code Implementation

Code Pack: Terraform
hth-ona-2.01-veto-security-policy.tf View source on GitHub ↗
resource "ona_security_policy" "veto" {
  organization_id = var.organization_id
  name            = var.veto_policy_name

  # `spec` is a BLOCK. No `=`.
  spec {
    executables {
      # Effect applied to anything no rule matches.
      default_effect = var.veto_default_effect

      # `rule` is a repeatable BLOCK, one per executable path, and it must be
      # LITERAL — a dynamic block makes the provider fail with a Value Conversion
      # Error (TRAP 8).
      #
      # Reverse-shell and arbitrary-egress tooling: audit first, then flip these
      # to "block" once the audit trail proves nothing legitimate invokes them.
      rule {
        path   = "/usr/bin/nc"
        effect = "audit"
      }

      rule {
        path   = "/usr/bin/ncat"
        effect = "audit"
      }

      rule {
        path   = "/usr/bin/socat"
        effect = "audit"
      }

      # Credential-adjacent tooling an agent has no reason to invoke.
      rule {
        path   = "/usr/bin/ssh-keygen"
        effect = "audit"
      }
    }
  }
}

# WITHOUT THIS, THE POLICY ABOVE ENFORCES NOTHING. Assigning it as the
# organization default is what materializes it for newly created environments.
# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "veto_default" {
  security_policy_id = ona_security_policy.veto.id
}
# Standing inventory of every security policy in the org. Use it to catch a
# second, console-created policy that someone assigned instead of this one.
data "ona_security_policies" "all" {
  organization_id = var.organization_id
}

output "ona_security_policy_names" {
  description = "Every security policy defined in the organization. More than one is fine; only the assigned one enforces."
  value       = [for p in data.ona_security_policies.all.policies : p.name]
}

output "ona_assigned_security_policy_id" {
  description = "The policy ID actually assigned as the organization default. This is the enforcing one."
  value       = ona_organization_policies.veto_default.security_policy_id
}
Code Pack: Config
hth-ona-2.01-veto-exec-policy.yml View source on GitHub ↗
metadata:
  name: HTH Veto Exec audit-first
spec:
  executables:
    # Executables matching no rule are allowed. Omitting this key means the same.
    defaultEffect: EFFECT_ALLOW
    rules:
      # --- Network fetch / exfiltration primitives -------------------------
      - path: /usr/bin/curl
        effect: EFFECT_AUDIT
      - path: /usr/bin/wget
        effect: EFFECT_AUDIT
      - path: nc
        effect: EFFECT_AUDIT
      - path: ncat
        effect: EFFECT_AUDIT
      - path: socat
        effect: EFFECT_AUDIT

      # --- Remote copy / remote shell --------------------------------------
      - path: /usr/bin/scp
        effect: EFFECT_AUDIT
      - path: /usr/bin/ssh
        effect: EFFECT_AUDIT
      - path: /usr/bin/sftp
        effect: EFFECT_AUDIT
      - path: rsync
        effect: EFFECT_AUDIT

      # --- Arbitrary-package execution: the agent's fastest route to running
      #     third-party code that was never reviewed in a PR.
      - path: npx
        effect: EFFECT_AUDIT
      - path: pnpx
        effect: EFFECT_AUDIT
      - path: bunx
        effect: EFFECT_AUDIT
      - path: pipx
        effect: EFFECT_AUDIT
      - path: uvx
        effect: EFFECT_AUDIT

      # --- Interpreters that can host a server or an inline exfil one-liner.
      #     AUDIT ONLY. Promoting an interpreter to EFFECT_BLOCK also blocks
      #     every script that uses it (T1).
      - path: python3
        effect: EFFECT_AUDIT
      - path: /usr/bin/base64
        effect: EFFECT_AUDIT
Code Pack: API Script
hth-ona-2.01-audit-veto-executable-policy.sh View source on GitHub ↗
# Control 2.1 is a two-part question: is there a policy worth enforcing, and is
# it actually the organization default (TRAP 1)?
audit() {
  get_policies
  echo "Ona 2.1 — Veto executable policy"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  paginate "SecurityService/ListSecurityPolicies" \
           "$(jq -nc --arg o "${ORG_ID}" '{filter: {organizationId: $o}}')" "securityPolicies"
  local pols="${PAGE_ITEMS}"
  echo "  security policies defined: $(jq 'length' <<<"${pols}")"

  # TRAP 2(a) + TRAP 4: report defaultEffect, do not fail on EFFECT_ALLOW.
  jq -r '.[] |
    ((.spec.executables.rules // [])) as $r |
    "    - name=\(.metadata.name // "(unnamed)") id=…\(((.id // "unknown")[-6:]))" +
    " defaultEffect=\(.spec.executables.defaultEffect // "EFFECT_UNSPECIFIED (normalized to EFFECT_ALLOW)")" +
    " rules=\($r | length)" +
    " block=\([$r[] | select((.effect // "") == "EFFECT_BLOCK")] | length)" +
    " audit=\([$r[] | select((.effect // "") == "EFFECT_AUDIT")] | length)" +
    " ports.maxAdmissionLevel=\(.spec.ports.maxAdmissionLevel // "ADMISSION_LEVEL_UNSPECIFIED (no cap)")"' <<<"${pols}"

  # TRAP 5: warn on the deprecated enum the vendor's own example uses.
  local deprecated
  deprecated=$(jq '[.[] | select((.spec.ports.maxAdmissionLevel // "") == "ADMISSION_LEVEL_OWNER_ONLY")] | length' <<<"${pols}")
  if [ "${deprecated}" -gt 0 ]; then
    echo "  WARNING: ${deprecated} policy/policies use ADMISSION_LEVEL_OWNER_ONLY, which is deprecated."
    echo "           Use ADMISSION_LEVEL_CREATOR_ONLY — the vendor's own cURL example is stale."
  fi

  # TRAP 1: the assignment, not the definition, is the control.
  local assigned assigned_name
  assigned=$(jq -r '.securityPolicyId // ""' <<<"${POLICIES}")
  if [ -n "${assigned}" ]; then
    assigned_name=$(jq -r --arg id "${assigned}" '.[] | select(.id == $id) | .metadata.name // "(unnamed)"' <<<"${pols}")
    [ -n "${assigned_name}" ] || assigned_name="(id not in this organization's policy list)"
    echo "  organization default securityPolicyId: …${assigned: -6} name=${assigned_name}"
  else
    echo "  organization default securityPolicyId: (absent — no policy is assigned)"
  fi

  # TRAP 2(b) + TRAP 3: the legacy inline surface, still live and still settable.
  local veto
  veto=$(jq -c '.vetoExecPolicy // {}' <<<"${POLICIES}")
  echo "  legacy vetoExecPolicy: enabled=$(jq -r '.enabled // false' <<<"${veto}")" \
       "action=$(jq -r '.action // "KERNEL_CONTROLS_ACTION_UNSPECIFIED (defaults to BLOCK)"' <<<"${veto}")" \
       "executables=$(jq '(.executables // []) | length' <<<"${veto}")" \
       "safelist=$(jq '(.safelist // []) | length' <<<"${veto}")"
  echo "    safelist entries are server-populated and CANNOT be blocked by the deny list (TRAP 3)."

  if [ -z "${assigned}" ]; then
    echo "FINDING: no SecurityPolicy is assigned as the organization default."
    echo "  Creation stores an inactive definition; only assignment to"
    echo "  OrganizationPolicies.securityPolicyId materializes it into new environments."
    echo "  Until then every policy listed above enforces nothing."
    return 1
  fi
  echo "COMPLIANT: a SecurityPolicy is assigned as the organization default and will materialize"
  echo "           into newly created environments."
  return 0
}
Code Pack: CLI Script
hth-ona-2.01-veto-policy-lifecycle.sh View source on GitHub ↗
# Apply the Veto Exec executable policy and assign it as the organization
# default. Audit-first: every rule in the shipped policy is EFFECT_AUDIT.
apply_policy() {
  local file="$1"
  [ -r "${file}" ] || { echo "FATAL: policy file not readable: ${file}" >&2; exit 2; }

  # Step 1 — client-side validation. Cheap, and it catches T4/T5 before the
  # management plane ever sees the document.
  echo "==> validating ${file}"
  "${ONA_BIN}" organization security-policy init "${file}" --validate-only

  # Step 2 — create. This stores an INACTIVE definition (T1) and returns it.
  # `-o yaml` is the form the vendor runbook uses; `-o json` is parseable, so
  # that is what we ask for here in order to lift the id deterministically.
  echo "==> creating policy"
  local created policy_id
  created="$("${ONA_BIN}" organization security-policy create "${file}" -o json)"
  policy_id="$(printf '%s' "${created}" \
    | jq -r '.. | objects | (.securityPolicyId? // .id?) | select(type=="string" and test("^[0-9a-fA-F-]{36}$"))' \
    | head -n1)"
  [ -n "${policy_id}" ] || {
    echo "FATAL: could not read a policy id out of the create response." >&2
    echo "       Re-run 'ona organization security-policy create ${file} -o yaml'" >&2
    echo "       and assign the id by hand." >&2
    exit 1
  }
  echo "    created policy …${policy_id: -6}"

  # Step 3 — assignment. The server's materializability check runs here (T1): a
  # policy that created cleanly can still be rejected by set-default, whose non-zero
  # exit (set -e) is the only assignment signal this CLI surface gives.
  echo "==> assigning as organization default"
  "${ONA_BIN}" organization security-policy set-default "${policy_id}"

  # Step 4 — presence check. This proves the policy still exists after set-default;
  # it does NOT prove the assignment. `security-policy list` shows inactive policies
  # too, and no `ona` verb reads the org default. The assignment itself is proven by
  # GetOrganizationPolicies.securityPolicyId — api pack hth-ona-2.01 reads it.
  verify_default "${policy_id}"

  # T2: nothing above touches a running environment.
  echo "NOTE: restart environments to pick this up — a running environment keeps"
  echo "      the policy it had at its last start."
}

# Presence check (not an assignment proof — see Step 4). `list -o json` field
# spellings are not published, so match the id anywhere in the document.
verify_default() {
  local policy_id="$1" listing
  listing="$("${ONA_BIN}" organization security-policy list -o json)"
  if printf '%s' "${listing}" | jq -e --arg id "${policy_id}" \
       '[.. | strings] | any(. == $id)' >/dev/null; then
    echo "PRESENT: policy …${policy_id: -6} is in the organization policy list; confirm the"
    echo "         default assignment with api pack hth-ona-2.01 (GetOrganizationPolicies.securityPolicyId)."
    return 0
  fi
  echo "FINDING: policy …${policy_id: -6} not visible in 'security-policy list'." >&2
  exit 1
}
# Clear the default assignment. Read T3 before using this: it removes EVERY
# control in that SecurityPolicy from newly created environments, not only the
# executable rules. The documented rollback for Veto Exec alone is to set each
# rule back to `effect: EFFECT_AUDIT` and run `update` instead.
# Guarded by a literal CONFIRM argument so it cannot be reached by a typo.
clear_default() {
  [ "${1:-}" = "CONFIRM" ] || {
    echo "REFUSING: --clear removes the entire default SecurityPolicy assignment" >&2
    echo "          (all controls, not just executables) from newly created" >&2
    echo "          environments. Re-run with: $0 --clear CONFIRM" >&2
    exit 2
  }
  "${ONA_BIN}" organization security-policy set-default --clear
  echo "Default SecurityPolicy assignment cleared."
  echo "NOTE: already-running environments are unchanged; restart them to drop it."
}
Code Pack: Sigma Detection Rules (2)
hth-ona-2.01-veto-exec-audited-b.yml View source on GitHub ↗
detection:
    selection_kind:
        kind: 'AUDIT_LOG_ENTRY_KIND_ENVIRONMENT_VETO'
    selection_action:
        action|startswith: 'Veto Exec audited'
    condition: selection_kind and selection_action
fields:
    - createdAt
    - actorPrincipal
    - actorId
    - subjectId
    - subjectType
    - action
    - details.vetoExec.filename
    - details.vetoExec.executable
    - details.vetoExec.action
    - details.vetoExec.process.name

hth-ona-2.01-veto-exec-blocked.yml View source on GitHub ↗
detection:
    selection_kind:
        kind: 'AUDIT_LOG_ENTRY_KIND_ENVIRONMENT_VETO'
    selection_action:
        action|startswith: 'Veto Exec blocked'
    condition: selection_kind and selection_action
fields:
    - createdAt
    - actorPrincipal
    - actorId
    - subjectId
    - subjectType
    - action
    - details.vetoExec.filename
    - details.vetoExec.executable
    - details.vetoExec.action
    - details.vetoExec.process.name

Validation & Testing

  1. In audit mode, confirm the intended binaries appear as AUDIT_LOG_ENTRY_KIND_ENVIRONMENT_VETO audit events (6.1) — Veto Exec enforcement entries are in preview and appear only for organizations where that preview is enabled
  2. In block mode, an agent attempt to run a blocked binary fails and is logged
  3. A renamed copy of a blocked binary is still blocked (content-identity check)

Expected result: Kernel-enforced executable policy on agent environments. (Veto · Executable deny list)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 CM-7 Least functionality
NIST AI RMF MANAGE-2.3 Mechanisms to supersede/deactivate AI behavior

2.2 Configure the Agent Command Deny List

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 2.7
NIST 800-53 CM-7
NIST AI RMF MANAGE-2.3

Description

Block dangerous agent-issued bash commands with wildcard patterns (e.g., shutdown, shutdown*, rm *). This applies to commands the AGENT runs, not to a user’s own terminal, and takes effect for new sessions.

Rationale

Why This Matters:

  • Autonomous agents chain shell commands; a single destructive or exfiltrating command in that chain is the highest-frequency agent failure mode
  • The command deny list is a fast, readable first line that complements the kernel-level Veto policy (2.1)
  • Scoping to agent commands (not user terminals) keeps human workflows unimpeded

Attack Prevented: Destructive or exfiltrating commands executed autonomously by a compromised or prompt-injected agent

ClickOps Implementation

Step 1: Add Deny Patterns

  1. Navigate to: SettingsAgentsPoliciesCommand deny list
  2. Add wildcard patterns one per line (start with destructive and network-exfil patterns) and Save
  3. Confirm the list applies to new agent sessions

Time to Complete: ~20 minutes

Code Implementation

Code Pack: Terraform
hth-ona-2.02-command-deny-list.tf View source on GitHub ↗
# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "agent_command_deny_list" {
  # ATTRIBUTE syntax (`=`), not a block.
  agent_policy = {
    # Declared explicitly so a console addition or deletion shows up as drift.
    command_deny_list = var.agent_command_deny_list
  }
}
Code Pack: API Script
hth-ona-2.02-audit-command-deny-list.sh View source on GitHub ↗
# One field, one question — but the field is usually missing rather than empty,
# which is exactly the case a naive check gets wrong (TRAP 1).
audit() {
  get_policies
  echo "Ona 2.2 — agent command deny list"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  local deny count
  deny=$(jq -c '.agentPolicy.commandDenyList // []' <<<"${POLICIES}")
  count=$(jq 'length' <<<"${deny}")
  echo "  agentPolicy.commandDenyList: ${count} entr$( [ "${count}" -eq 1 ] && echo y || echo ies )" \
       "(absent in the JSON means empty, not unset)"

  # TRAP 2: verbatim, uninterpreted.
  if [ "${count}" -gt 0 ]; then
    jq -r '.[] | "    - \(.)"' <<<"${deny}"
  fi

  if [ "${count}" -eq 0 ]; then
    echo "FINDING: the agent command deny list is empty."
    echo "  Nothing stops an agent from invoking credential-reading, network-egress or"
    echo "  history-rewriting commands on its own initiative. Seed the list with the commands"
    echo "  your environments never legitimately need, then re-run this pack to confirm the"
    echo "  read-back — UpdateOrganizationPolicies returns an empty message and proves nothing."
    return 1
  fi
  echo "COMPLIANT: ${count} command(s) are denied to agents organization-wide."
  echo "           Cross-check against control 2.1 — a command denied here can still be reachable"
  echo "           if the corresponding binary sits on Veto's server-populated safelist (TRAP 3)."
  return 0
}
Code Pack: Sigma Detection Rule
hth-ona-2.02-organization-policy-changed.yml View source on GitHub ↗
detection:
    selection:
        subjectType: 'RESOURCE_TYPE_ORGANIZATION_POLICY'
    condition: selection
fields:
    - createdAt
    - actorId
    - actorPrincipal
    - subjectId
    - action
    - kind

Validation & Testing

  1. Start a new agent session and confirm a denied command is refused
  2. Confirm a user’s own terminal is unaffected (by design)

Expected result: High-risk agent commands blocked at session start. (Command deny list)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 CM-7 Least functionality
NIST AI RMF MANAGE-2.3 Mechanisms to supersede AI behavior

2.3 Restrict MCP Server Access

Profile Level: L2 (Walk)

Framework Control
CIS Controls 2.5
NIST 800-53 CM-7, AC-3

Description

Decide organization policy on Model Context Protocol (MCP) servers. Org owners can disable MCP entirely — repo .ona/mcp-config.json files are then ignored and external MCP is blocked. The org toggle is all-or-nothing: there is no org-level allowlist constraining repo-local configs. What Ona does document is admin-curated organization MCP integrations (HTTP servers added under Settings → Organization → Integrations and enabled per integration) and a per-server toolDenyList in repo-local configs.

Rationale

Why This Matters:

  • MCP servers extend an agent’s reach into external systems; each one is delegated, prompt-injectable access to whatever it connects to
  • Because the control is all-or-nothing, an org that cannot vet individual MCP servers should disable MCP rather than accept unbounded repo-defined connections
  • Repo-level MCP configs are attacker-influenceable (anyone who can commit to a repo can add one) unless the org disables MCP

Attack Prevented: Prompt-injection-driven data movement through unvetted, repo-defined MCP connections

ClickOps Implementation

Step 1: Set the Org MCP Policy

  1. Navigate to: SettingsAgentsPoliciesAgent capabilitiesModel Context Protocol (MCP)
  2. If your organization cannot vet MCP servers individually, turn MCP off — repo .ona/mcp-config.json files will be ignored and external MCP blocked
  3. If MCP stays enabled, curate the org-wide integrations at SettingsOrganizationIntegrations (Add MCP integration), treat every repo MCP config as untrusted input, use toolDenyList per server, and pair with the command deny list (2.2) and Veto (2.1)

Time to Complete: ~15 minutes

Code Implementation

Code Pack: Terraform
hth-ona-2.03-disable-mcp.tf View source on GitHub ↗
# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "agent_mcp" {
  agent_policy = {
    # true removes the whole MCP tool surface from agents in this organization.
    mcp_disabled = var.mcp_disabled
  }
}
Code Pack: Config
hth-ona-2.03-mcp-config-tool-denylist.jsonc View source on GitHub ↗
{
  "mcpServers": {
    // ---- HTTP transport: remote server, token injected at runtime ---------
    "issue-tracker": {
      "name": "issue-tracker",
      "url": "https://mcp.example.com/mcp",
      "headers": {
        // T4: read the token from a file the environment provisions. Never a
        // literal value. `${exec:printenv MCP_API_TOKEN}` is the equivalent
        // form when the secret arrives as an environment variable.
        "Authorization": "Bearer ${file:/run/secrets/issue-tracker-token}"
      },
      // T3: exact tool names. Deny the write side of the server and keep the
      // read side, so the agent can look things up but not act unattended.
      "toolDenyList": [
        "delete_issue",
        "update_issue_state",
        "add_comment"
      ],
      "timeout": 30
    },

    // ---- stdio transport: local process ----------------------------------
    "github": {
      "name": "github",
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
        "ghcr.io/github/github-mcp-server"
      ],
      "env": {
        // T4/T5: expansion happens at runtime, and this command is code —
        // review it as such.
        "GITHUB_PERSONAL_ACCESS_TOKEN": "${exec:printenv GITHUB_MCP_TOKEN}"
      },
      // Deny the tools that turn a read-only integration into a write path
      // into the SCM, plus the org-wide code search that widens the agent's
      // reach far beyond the repository it was invited into.
      "toolDenyList": [
        "search_code",
        "create_or_update_file",
        "push_files",
        "merge_pull_request",
        "delete_file"
      ],
      "workingDir": "/workspaces",
      "timeout": 30
    },

    // ---- Kept in the diff, switched off (T7) ------------------------------
    "playwright": {
      "name": "playwright",
      "command": "npx",
      "args": ["-y", "@executeautomation/playwright-mcp-server"],
      "timeout": 60,
      "disabled": true
    }
  },
  // Default per-server timeout when a server does not set its own.
  "globalTimeout": 30
}
Code Pack: API Script
hth-ona-2.03-audit-mcp-policy.sh View source on GitHub ↗
# The org kill-switch, then — because it is all-or-nothing (TRAP 2) — the
# inventory that a reviewer needs when the switch is left off.
audit() {
  get_policies
  echo "Ona 2.3 — MCP organization policy"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  # TRAP 1: absent == false == enabled.
  local disabled
  disabled=$(jq -r '.agentPolicy.mcpDisabled // false' <<<"${POLICIES}")
  echo "  agentPolicy.mcpDisabled: ${disabled} (absent in the JSON means false — MCP enabled)"

  paginate "IntegrationService/ListIntegrations" '{}' "integrations"
  local ints="${PAGE_ITEMS}" mcp_total mcp_enabled
  # TRAP 3: union of the category enum and the capability message.
  # TRAP 4: absent enabled == false.
  mcp_total=$(jq '[.[] | select(((.categories // []) | index("INTEGRATION_CATEGORY_MCP")) != null
                                or (.capabilities.mcp != null))] | length' <<<"${ints}")
  mcp_enabled=$(jq '[.[] | select(((.categories // []) | index("INTEGRATION_CATEGORY_MCP")) != null
                                  or (.capabilities.mcp != null))
                         | select((.enabled // false) == true)] | length' <<<"${ints}")
  echo "  integrations: total=$(jq 'length' <<<"${ints}") mcp-capable=${mcp_total} mcp-capable AND enabled=${mcp_enabled}"
  jq -r '.[] | select(((.categories // []) | index("INTEGRATION_CATEGORY_MCP")) != null or (.capabilities.mcp != null))
         | "    - id=…\(((.id // "unknown")[-6:])) enabled=\(.enabled // false)" +
           " host=\(if (.host // "") == "" then "(none)" else .host end)" +
           " viaCategory=\(((.categories // []) | index("INTEGRATION_CATEGORY_MCP")) != null)" +
           " viaCapability=\(.capabilities.mcp != null)"' <<<"${ints}"

  if [ "${disabled}" = "true" ]; then
    echo "COMPLIANT: agentPolicy.mcpDisabled is true — the organization-wide MCP kill-switch is on."
    return 0
  fi
  echo "FINDING: MCP is enabled organization-wide, with ${mcp_enabled} enabled MCP-capable integration(s)."
  echo "  There is no org-level MCP allow-list (TRAP 2): the only settings that exist are this"
  echo "  boolean and each integration's own enabled flag. Either set mcpDisabled=true, or treat"
  echo "  the ${mcp_enabled} enabled integration(s) above as a standing compensating review —"
  echo "  each one is a tool surface an agent can reach without a human in the loop."
  return 1
}

Validation & Testing

  1. With MCP disabled, an agent in a repo carrying .ona/mcp-config.json does not load the external server

Expected result: MCP disabled org-wide, or consciously accepted with compensating guardrails. (MCP)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 CM-7 Least functionality
NIST 800-53 AC-3 Access enforcement

2.4 Govern SCM Tools and LLM Provider Access

Profile Level: L2 (Walk)

Framework Control
CIS Controls 2.5, 3.3
NIST 800-53 AC-3, CM-7

Description

Scope what agents can do against source-control hosts and which model providers they use. The SCM-tools policy chooses whether agents get PR/issue API tools (vs. git commands only), for all members / a specific group / disabled. Provider governance is narrower than it once was: on Ona Cloud, OpenAI model access for Codex Agent is the default; Ona Agent and Ona-managed Anthropic model access are no longer available on Ona Cloud; customer-managed runners require Ona Intelligence or an approved OpenAI-compatible provider; custom token providers (BYOK) are available by exception to Enterprise customers; and the AWS Bedrock Runtime, Google Vertex AI, and direct Anthropic API integrations are deprecated and maintenance-only.

Rationale

Why This Matters:

  • Disabling SCM API tools limits an agent to git operations, removing its ability to open/merge PRs or manipulate issues autonomously where that is not intended
  • Provider choice determines where prompts and code are sent; the training-use posture of the Ona-managed default is undocumented, so regulated orgs should evaluate BYOK/self-managed backends deliberately
  • Scoping SCM tools to a specific group confines high-capability agents to teams that need them

Attack Prevented: Autonomous repository manipulation via SCM API tools; unintended code/prompt egress to an unassessed model backend

ClickOps Implementation

Step 1: Scope SCM Tools

  1. Navigate to: SettingsAgentsPoliciesAgent capabilitiesSource Code Management Tools (SCM)Restrict access to
  2. Set it to the narrowest that works: all members, a specific group, or disabled (git commands only, no PR/issue API tools). API: agentPolicy.scmToolsDisabled × agentPolicy.scmToolsAllowedGroupId.

Step 2: Govern Model Providers

  1. On the same page, Agents available governs which agents and which Codex models, reasoning-effort ceilings, and service tiers members may use (agentPolicy.allowedAgentIds, codexModelPolicy.modelStates, allowedCodexReasoningEfforts, allowedCodexServiceTiers)
  2. Review the LLM providers documentation; for regulated data, confirm the training-use and residency posture with your Ona account team (undocumented as of this writing) and treat any remaining BYOK/self-managed backend as an Enterprise exception to request, not a menu to pick from

Time to Complete: ~30 minutes

Code Implementation

Code Pack: Terraform
hth-ona-2.04-scm-tools-and-agent-allowlist.tf View source on GitHub ↗
# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "agent_scm_and_models" {
  agent_policy = {
    # State 1: no PR/issue API tools at all.
    scm_tools_disabled = var.scm_tools_disabled

    # State 2: confine SCM tools to one group. "" here means EVERYONE — the empty
    # value is the permissive one, so set a real group id to actually restrict.
    scm_tools_allowed_group_id = var.scm_tools_allowed_group_id

    # Fail-open: an empty set allows every agent. Name the approved agent ids to
    # make this restrictive.
    allowed_agent_ids = var.allowed_agent_ids

    # Fail-open and future-open: unlisted models, including ones released after
    # this apply, are allowed. This can express a deny list, never an allow list.
    codex_model_states = var.codex_model_states

    # Bound the fan-out one agent can create inside a single environment.
    max_subagents_per_environment = var.max_subagents_per_environment

    # "disabled" keeps agent conversations out of org-wide sharing.
    conversation_sharing_policy = var.conversation_sharing_policy
  }
}
Code Pack: API Script
hth-ona-2.04-audit-scm-tools-and-llm.sh View source on GitHub ↗
# TRAP 1: collapse the two fields into the three states the product actually has.
scm_tools_state() {
  local disabled group
  disabled=$(jq -r '.agentPolicy.scmToolsDisabled // false' <<<"${POLICIES}")
  group=$(jq -r '.agentPolicy.scmToolsAllowedGroupId // ""' <<<"${POLICIES}")
  if [ "${disabled}" = "true" ]; then
    SCM_STATE="disabled"
  elif [ -n "${group}" ]; then
    SCM_STATE="group"
    SCM_GROUP="${group}"
  else
    SCM_STATE="all"
  fi
}

audit_llm() {
  paginate "RunnerService/ListRunners" '{}' "runners"
  local runners="${PAGE_ITEMS}" ids id
  echo "  runners: $(jq 'length' <<<"${runners}")"
  ids=$(jq -r '.[] | .runnerId // empty' <<<"${runners}")
  if [ -z "${ids}" ]; then
    echo "    (no runners — no LLM integration surface to read)"
    return 0
  fi
  # TRAP 4: fan out per runner; there is no org-scoped LLM read.
  while IFS= read -r id; do
    [ -n "${id}" ] || continue
    api_strict "RunnerConfigurationService/ListLLMIntegrations" \
      "$(jq -nc --arg r "${id}" '{filter: {runnerIds: [$r]}}')"
    local body
    body="${RPC_BODY}"
    echo "    runner …${id: -6}" \
         "llmManagedByOna=$(jq -r '.llmManagedByOna // false' <<<"${body}")" \
         "onaIntelligenceProviders=$(jq -r '((.onaIntelligenceProviders // []) | join(",")) | if . == "" then "(none)" else . end' <<<"${body}")" \
         "integrations=$(jq '(.integrations // []) | length' <<<"${body}")"
    # TRAP 3: host only — the endpoint can carry a key in its query string.
    jq -r '(.integrations // [])[] |
      "      - provider=\(.provider // "LLM_PROVIDER_UNSPECIFIED")" +
      " phase=\(.phase // "LLM_INTEGRATION_PHASE_UNSPECIFIED")" +
      " models=\((.models // []) | length)" +
      " endpointHost=\((.endpoint // "") | sub("^[a-zA-Z]+://";"") | sub("[/?#].*$";"") | if . == "" then "(none)" else . end)" +
      " apiKey=\(if (.encryptedApiKey // "") == "" then "(unset)" else "(encrypted, never returned in plaintext)" end)"' <<<"${body}"
  done <<<"${ids}"
}

audit() {
  get_policies
  echo "Ona 2.4 — SCM tools policy and LLM provider governance"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  scm_tools_state
  case "${SCM_STATE}" in
    disabled) echo "  SCM tools: DISABLED for all members (scmToolsDisabled=true)" ;;
    group)    echo "  SCM tools: restricted to group …${SCM_GROUP: -6} (scmToolsAllowedGroupId set)" ;;
    all)      echo "  SCM tools: available to EVERY member (scmToolsDisabled absent/false AND scmToolsAllowedGroupId empty)" ;;
  esac

  # TRAP 2: read both the current and the deprecated model-allowlist fields.
  echo "  agent/model policy:" \
       "allowedAgentIds=$(jq '(.agentPolicy.allowedAgentIds // []) | length' <<<"${POLICIES}")" \
       "codexModelPolicy.modelStates=$(jq '(.agentPolicy.codexModelPolicy.modelStates // {}) | length' <<<"${POLICIES}")" \
       "allowedCodexModels(deprecated)=$(jq '(.agentPolicy.allowedCodexModels // []) | length' <<<"${POLICIES}")" \
       "allowedCodexReasoningEfforts=$(jq '(.agentPolicy.allowedCodexReasoningEfforts // []) | length' <<<"${POLICIES}")" \
       "allowedCodexServiceTiers=$(jq '(.agentPolicy.allowedCodexServiceTiers // []) | length' <<<"${POLICIES}")" \
       "goalModeDisabled=$(jq -r '.agentPolicy.goalModeDisabled // false' <<<"${POLICIES}")"
  echo "    (every count of 0 means the field was absent — no allow-list, so nothing is constrained)"

  audit_llm

  if [ "${SCM_STATE}" = "all" ] && [ "${ONA_SCM_TOOLS_EXPECT}" != "all" ]; then
    echo "FINDING: SCM tools are unrestricted — every organization member can drive them through an agent."
    echo "  Expected posture ONA_SCM_TOOLS_EXPECT=${ONA_SCM_TOOLS_EXPECT}. Set scmToolsDisabled=true, or"
    echo "  name a group in scmToolsAllowedGroupId; an empty group id means NO restriction (TRAP 1)."
    return 1
  fi
  if [ "${SCM_STATE}" = "group" ] && [ "${ONA_SCM_TOOLS_EXPECT}" = "disabled" ]; then
    echo "FINDING: SCM tools are group-restricted but ONA_SCM_TOOLS_EXPECT=disabled was requested."
    return 1
  fi
  echo "COMPLIANT: SCM tools posture '${SCM_STATE}' satisfies ONA_SCM_TOOLS_EXPECT=${ONA_SCM_TOOLS_EXPECT}."
  return 0
}

Validation & Testing

  1. With SCM tools disabled, an agent cannot open a PR via API
  2. Confirm the active model backend matches your approved provider

Expected result: Agent SCM reach and model backend deliberately scoped. (SCM tools · LLM providers)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 CM-7 Least functionality
NIST 800-53 AC-3 Access enforcement

2.5 Constrain Automations

Profile Level: L2 (Walk)

Framework Control
CIS Controls 2.7
NIST 800-53 AC-6, CM-7

Description

Set the automation guardrails that cap how much autonomous work members can run: active automations per member, projects per automation, and concurrent actions. Remember org and automation admins bypass these limits.

Rationale

Why This Matters:

  • Automations run agents on triggers across codebases — unbounded, they multiply the blast radius of a single poisoned trigger or injected instruction
  • Per-member and concurrency caps contain both accidental runaway automation and deliberate abuse
  • Because admins bypass the caps, admin-role hygiene (1.3) is a prerequisite for these limits to mean anything

Attack Prevented: Blast-radius amplification through mass or highly-concurrent autonomous automation runs

ClickOps Implementation

Step 1: Set Automation Limits

  1. Navigate to: SettingsAgentsPoliciesAutomationsNote: automation organization policy controls are in preview and available only to selected Enterprise organizations; the section is absent on other tiers
  2. Set active automations per member (default 5, Enterprise maximum 50), projects per automation (up to 100), and concurrent actions (up to 25 on Enterprise) to values matched to your risk tolerance

Automation: ClickOps only — Ona exposes no write interface for this setting (Automation guardrails, 2026-08-19). The limits are visible on the GetOrganizationPolicies read path as agentPolicy.automationPolicy{maxAutomationsPerUser, maxParallelActions, maxProjectsPerAutomation} (observed live), but that field is undocumented and no method sets it.

Time to Complete: ~15 minutes

Validation & Testing

  1. A non-admin member is blocked from exceeding the active-automation cap
  2. Confirm admins’ bypass is acceptable given your admin-role assignments

Expected result: Autonomous automation volume bounded for non-admins. (Automation guardrails)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 AC-6 Least privilege
NIST 800-53 CM-7 Least functionality

2.6 Deploy Runtime EDR to Agent Environments

Profile Level: L3 (Run)

Framework Control
CIS Controls 10.1, 13.7
NIST 800-53 SI-3, SI-4

Description

For regulated or high-sensitivity estates, enable the CrowdStrike Falcon integration, which deploys the falcon-sensor as a privileged sidecar with host-level visibility into all environments. Users cannot disable it.

Rationale

Why This Matters:

  • Agent environments execute untrusted code and autonomous commands — runtime EDR gives detection and response coverage the platform’s own guardrails do not provide
  • A privileged sidecar with host-level visibility observes what an agent-scoped control cannot
  • Enforced (user-non-disableable) deployment ensures coverage is uniform across every environment

Attack Prevented: Undetected malware execution and post-exploitation activity inside agent environments

Prerequisites

  • CrowdStrike Falcon subscription with a CID and a sensor image
  • Acceptance of a privileged sidecar in every environment (resource and trust implications)

ClickOps Implementation

Step 1: Enable Falcon

  1. Navigate to: SettingsInfrastructureSecuritySecurity agentsCrowdStrike Falcon (Enterprise tier)
  2. Provide the CID (stored as an organization secret and referenced by cidSecretId) and the sensor image reference; the sensor deploys as a privileged sidecar to all environments and cannot be disabled by users. API: UpdateOrganizationPolicies.securityAgentPolicy.crowdstrike{enabled, image, cidSecretId, tags, additionalOptions} — the read-back type is not expanded in the API docs, so audit scripts must treat its shape defensively.

Time to Complete: ~1 hour

Code Implementation

Code Pack: API Script
hth-ona-2.06-audit-security-agents.sh View source on GitHub ↗
# TRAP 1 governs the whole function: the read-back type is undocumented, so the
# checks lean on presence and on the one field name the write side shares.
audit() {
  get_policies
  echo "Ona 2.6 — runtime EDR (security agent) policy"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  local sap present cs cs_present enabled custom
  sap=$(jq -c '.securityAgentPolicy // null' <<<"${POLICIES}")
  if [ "${sap}" = "null" ]; then
    present="absent"
    sap='{}'
  else
    present="present"
  fi
  echo "  securityAgentPolicy: ${present} (absent means no security agent deploys to any environment)"

  cs=$(jq -c '.crowdstrike // null' <<<"${sap}")
  if [ "${cs}" = "null" ]; then
    cs_present="absent"
    cs='{}'
  else
    cs_present="present"
  fi
  # TRAP 1: `enabled` is read defensively and labelled INFERRED — the response
  # type is not published, so this is the write-side field name, not a contract.
  enabled=$(jq -r '.enabled // false' <<<"${cs}")
  echo "  securityAgentPolicy.crowdstrike: ${cs_present}; enabled=${enabled} [INFERRED — CrowdStrikeConfig is undocumented on the read path]"
  # TRAP 2: report the reference, never resolve it.
  echo "    cidSecretId: $(jq -r 'if (.cidSecretId // "") == "" then "(unset)" else "(set — an organization secret holds the CID; not resolved here)" end' <<<"${cs}")"
  echo "    image:       $(jq -r 'if (.image // "") == "" then "(default)" else "(pinned)" end' <<<"${cs}")"

  # TRAP 3: the count remediation must preserve.
  custom=$(jq '(.customAgents // []) | length' <<<"${sap}")
  echo "  securityAgentPolicy.customAgents: ${custom}"
  if [ "${custom}" -gt 0 ]; then
    jq -r '(.customAgents // [])[] | "    - name=\(.name // "(unnamed)") enabled=\(.enabled // false)"' <<<"${sap}"
    echo "    Any update MUST resend this whole list — omitting an entry deletes it (TRAP 3)."
  fi

  if [ "${cs_present}" = "present" ] && [ "${enabled}" = "true" ]; then
    echo "COMPLIANT: a CrowdStrike Falcon security agent is configured and reads back enabled."
    return 0
  fi
  if [ "${custom}" -gt 0 ]; then
    local custom_enabled
    custom_enabled=$(jq '[(.customAgents // [])[] | select((.enabled // false) == true)] | length' <<<"${sap}")
    if [ "${custom_enabled}" -gt 0 ]; then
      echo "FINDING: CrowdStrike is not enabled; ${custom_enabled} custom security agent(s) are."
      echo "  A custom agent may well be the right answer — but this control asserts the documented"
      echo "  CrowdStrike path, so record the custom agent as a compensating control explicitly"
      echo "  rather than letting it pass silently."
      return 1
    fi
  fi
  echo "FINDING: no security agent is enabled for this organization."
  echo "  Agent environments run untrusted, model-selected code with no runtime detection."
  echo "  When enabled, security agents are deployed automatically to ALL environments — so this"
  echo "  is one write that covers the whole estate rather than a per-environment rollout."
  return 1
}

Validation & Testing

  1. Confirm the falcon-sensor is present in a newly created environment
  2. Confirm a test detection surfaces in your CrowdStrike console

Expected result: Enforced runtime EDR across agent environments. (Security agents)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 SI-3 Malicious code protection
NIST 800-53 SI-4 System monitoring

3. Environment & Network Security

3.1 Restrict Port Admission Levels

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 4.1, 12.2
NIST 800-53 AC-3, SC-7
SOC 2 CC6.6

Description

Cap how widely environment ports can be exposed. Admission levels run creator_onlyorganizationeveryone (public); set the organization’s Maximum port admission level so members cannot expose ports beyond your ceiling (options above the cap show “restricted by policy”), or disable port sharing entirely.

Rationale

Why This Matters:

  • A public port URL exposes an in-development service on a shared domain — and three of Ona/Gitpod’s five CVEs turn on subdomain/redirect trust on exactly such shared domains
  • Defaulting to creator_only keeps a service reachable only by its creator unless a wider level is deliberately chosen
  • An org-wide ceiling prevents any single member from publishing a port to the internet

Attack Prevented: Exposure of in-development services and subdomain-trust abuse via public port URLs

ClickOps Implementation

Step 1: Set the Maximum Admission Level

  1. Navigate to: SettingsInfrastructurePoliciesEnvironment accessPort sharing / Maximum port admission level (Enterprise tier; the observed default is Anyone (no login required), i.e. no cap)
  2. Set Maximum port admission level to the lowest that supports collaboration needs (prefer creator_only or organization), or turn Port sharing off entirely (portSharingDisabled: true takes precedence over the level)

Automation surface: manageable as code — org policy maxPortAdmissionLevel / portSharingDisabled (OrganizationService/UpdateOrganizationPolicies, Terraform ona_organization_policies), and per security policy spec.ports.maxAdmissionLevel (SecurityService, Terraform ona_security_policy). Enum ADMISSION_LEVEL_{UNSPECIFIED, CREATOR_ONLY, ORGANIZATION, EVERYONE}ADMISSION_LEVEL_OWNER_ONLY is deprecated in favor of CREATOR_ONLY (the vendor’s own cURL example still uses it), and UNSPECIFIED applies no cap. See the Security Policy API.

Time to Complete: ~15 minutes

Code Implementation

Code Pack: Terraform
hth-ona-3.01-port-admission.tf View source on GitHub ↗
resource "ona_security_policy" "ports" {
  organization_id = var.organization_id
  name            = var.port_policy_name

  spec {
    # REQUIRED even for a ports-only policy — see TRAP 4b. "allow" is the no-op
    # executable posture; if you run pack 2.01, merge these into one policy so
    # this line cannot relax a Veto baseline.
    executables {
      default_effect = "allow"
    }

    # Omitting this block leaves port admission UNRESTRICTED by this policy.
    ports {
      # creator_only is the tightest level the provider exposes. `owner_only` is
      # the deprecated API-side spelling and is not accepted here.
      max_admission_level = var.max_port_admission_level
    }
  }
}

# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "port_admission" {
  # Turns the policy above from a stored definition into enforcement.
  security_policy_id = ona_security_policy.ports.id

  # Separate lever: kills the user-initiated port-sharing affordance outright.
  port_sharing_disabled = var.port_sharing_disabled
}
Code Pack: API Script
hth-ona-3.01-audit-port-admission.sh View source on GitHub ↗
# TRAP 4: an explicit acceptable set, never a numeric comparison.
level_acceptable() { # level_acceptable <ADMISSION_LEVEL_*>
  case "$1" in
    ADMISSION_LEVEL_CREATOR_ONLY|ADMISSION_LEVEL_ORGANIZATION|ADMISSION_LEVEL_OWNER_ONLY) return 0 ;;
    *) return 1 ;;
  esac
}

audit() {
  get_policies
  echo "Ona 3.1 — port admission level"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  # TRAP 1: absent enum == ADMISSION_LEVEL_UNSPECIFIED == no cap at all.
  local level sharing assigned
  level=$(jq -r '.maxPortAdmissionLevel // "ADMISSION_LEVEL_UNSPECIFIED"' <<<"${POLICIES}")
  sharing=$(jq -r '.portSharingDisabled // false' <<<"${POLICIES}")
  assigned=$(jq -r '.securityPolicyId // ""' <<<"${POLICIES}")
  echo "  maxPortAdmissionLevel: ${level}$( [ "${level}" = "ADMISSION_LEVEL_UNSPECIFIED" ] && echo "  ← absent means NO CAP" )"
  echo "  portSharingDisabled:   ${sharing} (takes precedence over the cap when true)"

  # The assigned SecurityPolicy carries its own cap for environments it governs.
  local policy_level="(no policy assigned)"
  if [ -n "${assigned}" ]; then
    paginate "SecurityService/ListSecurityPolicies" \
             "$(jq -nc --arg o "${ORG_ID}" '{filter: {organizationId: $o}}')" "securityPolicies"
    policy_level=$(jq -r --arg id "${assigned}" '
      [ .[] | select(.id == $id)
            | (.spec.ports.maxAdmissionLevel // "ADMISSION_LEVEL_UNSPECIFIED (no additional cap)") ]
      | if length == 0 then "(assigned policy not visible in this organization)" else .[0] end' <<<"${PAGE_ITEMS}")
  fi
  echo "  assigned SecurityPolicy spec.ports.maxAdmissionLevel: ${policy_level}"

  # TRAP 3: deprecated but still tight — warn, do not fail.
  if [ "${level}" = "ADMISSION_LEVEL_OWNER_ONLY" ] || [ "${policy_level}" = "ADMISSION_LEVEL_OWNER_ONLY" ]; then
    echo "  WARNING: ADMISSION_LEVEL_OWNER_ONLY is deprecated in favour of ADMISSION_LEVEL_CREATOR_ONLY."
    echo "           It is still as tight, so this is not a finding — but migrate it, and note that"
    echo "           the vendor's own CreateSecurityPolicy example is the likely source."
  fi

  # TRAP 2: precedence, evaluated in order.
  if [ "${sharing}" = "true" ]; then
    echo "COMPLIANT: portSharingDisabled=true blocks all user-initiated port sharing,"
    echo "           which takes precedence over maxPortAdmissionLevel entirely."
    return 0
  fi
  if level_acceptable "${level}"; then
    echo "COMPLIANT: the organization caps user-opened ports at ${level}."
    return 0
  fi
  echo "FINDING: user-opened ports are not capped to the creator or the organization."
  echo "  Effective org-wide cap: ${level}. A port opened at ADMISSION_LEVEL_EVERYONE is reachable"
  echo "  by anyone on the internet who has the URL, and an agent can open one on its own"
  echo "  initiative. Set portSharingDisabled=true, or maxPortAdmissionLevel to"
  echo "  ADMISSION_LEVEL_CREATOR_ONLY (or ADMISSION_LEVEL_ORGANIZATION if teammates need access)."
  return 1
}
Code Pack: CLI Script
hth-ona-3.01-audit-port-admission.sh View source on GitHub ↗
# Report every open port whose admission level is `everyone` — an unauthenticated
# public URL. Exit 1 on any finding. Nothing is opened or closed.
findings=0
ports_seen=0
envs_seen=0

# `-o json` is the documented machine-readable flag for any command. The envelope
# key is not published, so accept either {"environments":[…]} or a bare array.
env_ids="$("${ONA_BIN}" environment list -o json \
  | jq -r '(.environments? // .items? // .) | if type=="array" then .[] else empty end | .id // empty')"

if [ -z "${env_ids}" ]; then
  echo "COMPLIANT: no environments visible to this token — nothing is shared."
  exit 0
fi

while IFS= read -r env_id; do
  [ -n "${env_id}" ] || continue
  envs_seen=$((envs_seen + 1))

  # An environment that is stopped (or that this token cannot read ports on)
  # yields no port list; that is not a finding, so do not fail the whole run.
  ports_json="$("${ONA_BIN}" environment port list "${env_id}" -o json 2>/dev/null || true)"
  [ -n "${ports_json}" ] || continue

  # T2/T5: match "everyone" case-insensitively against both the CLI spelling and
  # the API enum, and treat a port carrying NO admission value as public, because
  # a runner without port authentication behaves like `everyone`.
  while IFS='|' read -r port admission; do
    [ -n "${port}${admission}" ] || continue
    ports_seen=$((ports_seen + 1))
    case "$(printf '%s' "${admission}" | tr 'A-Z' 'a-z')" in
      *everyone*)
        echo "FINDING: env …${env_id: -6} port ${port} admission=${admission} (public, unauthenticated)"
        findings=$((findings + 1))
        ;;
      ""|*unspecified*)
        echo "FINDING: env …${env_id: -6} port ${port} has no admission level — on a runner"
        echo "         without port authentication this behaves like 'everyone'."
        findings=$((findings + 1))
        ;;
      *owner_only*)
        echo "LEGACY:  env …${env_id: -6} port ${port} admission=${admission} (private, but the"
        echo "         OWNER_ONLY value is deprecated — reopen as creator_only)"
        ;;
      *)
        echo "ok:      env …${env_id: -6} port ${port} admission=${admission}"
        ;;
    esac
  done < <(printf '%s' "${ports_json}" \
    | jq -r '(.ports? // .items? // .) | if type=="array" then .[] else empty end
             | "\(.port // "?")|\(.admission // "")"')
done <<< "${env_ids}"

echo "---"
echo "environments checked: ${envs_seen} | open ports: ${ports_seen} | public findings: ${findings}"

if [ "${findings}" -gt 0 ]; then
  echo "Remediate by reopening each port fail-closed:"
  echo "  ona environment port open <port> --name <name> --admission creator_only"
  echo "or closing it: ona environment port close <port>"
  exit 1
fi
echo "COMPLIANT: no port is admitted to 'everyone'."
exit 0

Validation & Testing

  1. A member attempting to expose a port above the cap sees “restricted by policy”
  2. ona environment port open <port> --admission everyone is refused when the ceiling is lower

Expected result: Port exposure bounded org-wide. (Ports)

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6 Boundary protection
NIST 800-53 SC-7 Boundary protection

3.2 Control the In-Environment Web Browser

Profile Level: L2 (Walk)

Framework Control
CIS Controls 9.2
NIST 800-53 SC-7, AC-4

Description

Govern the built-in environment web browser and the agent’s browse-web skill, which is enabled by default on all tiers. The browser can reach environment-local services including http://localhost:3000. Only Enterprise can change this policy.

Rationale

Why This Matters:

  • An agent that can browse the web is a two-way channel: it can pull instructions from attacker-controlled pages (prompt injection) and reach internal environment services
  • Default-on across all tiers means this capability is live unless explicitly reviewed
  • Reachability of localhost services means the browser is inside the environment’s trust boundary, not merely an external fetch tool

Attack Prevented: Prompt injection via agent web browsing and access to environment-local services

ClickOps Implementation

Step 1: Review the Browser Policy

  1. Navigate to: SettingsInfrastructurePoliciesEnvironment accessWeb browser (“Allow members to open the built-in browser panel. VS Code Browser is not affected.”)
  2. On Enterprise, disable it where agents have no legitimate browsing need (API/Terraform: webBrowserDisabled: true); where enabled, treat browsed content as untrusted agent input and pair with command/executable guardrails (2.1, 2.2)

Time to Complete: ~15 minutes

Code Implementation

Code Pack: Terraform
hth-ona-3.02-disable-web-browser.tf View source on GitHub ↗
# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "web_browser" {
  web_browser_disabled = var.web_browser_disabled
}
Code Pack: API Script
hth-ona-3.02-audit-web-browser-policy.sh View source on GitHub ↗
audit() {
  get_policies
  echo "Ona 3.2 — in-environment web browser"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  # TRAP 1: `required=true` is a proto constraint, not a promise that the JSON
  # will carry the key. Absent == false == the browser is available.
  local disabled
  disabled=$(jq -r '.webBrowserDisabled // false' <<<"${POLICIES}")
  echo "  webBrowserDisabled: ${disabled} (absent in the JSON means false)"

  if [ "${disabled}" = "true" ]; then
    echo "COMPLIANT: the built-in web browser cannot be opened from environment pages."
    echo "  Scope note: this does NOT disable VS Code Browser (TRAP 2) — do not record this"
    echo "  evidence as 'browsing disabled in environments'."
    return 0
  fi
  echo "FINDING: the in-environment web browser is available to every member."
  echo "  It is a full browser inside the environment, so an agent-driven or human session can"
  echo "  reach an internal service, authenticate to it with environment-resident credentials,"
  echo "  and move data out without leaving the environment's network path."
  return 1
}

Validation & Testing

  1. With the browser disabled, the agent’s browse-web skill is unavailable
  2. Confirm the setting’s effect on a test environment

Expected result: Agent web browsing consciously scoped. (Web browser policy)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 SC-7 Boundary protection
NIST 800-53 AC-4 Information flow enforcement

3.3 Enforce Environment Lifetime, Timeout, and Retention

Profile Level: L2 (Walk)

Framework Control
CIS Controls 4.1
NIST 800-53 CM-6, AC-12

Description

Bound how long environments live and linger: set a maximum environment lifetime with strict enforcement, an auto-stop timeout ceiling, and archive/auto-delete retention. These limit the window a stale, credentialed environment stays exploitable.

Rationale

Why This Matters:

  • The maximum-lifetime policy defaults to OFF (warning only) — without strict enforcement, expired environments can be restarted indefinitely
  • Auto-stop defaults to 30 minutes only when a user sets no preference; an explicit ceiling caps idle credentialed compute
  • Retention (archive then auto-delete) determines how long source and secrets persist in a dormant environment

Attack Prevented: Long-lived stale environments retaining source and credentials as a standing target

ClickOps Implementation

Step 1: Enforce Maximum Lifetime

  1. Navigate to: SettingsInfrastructurePoliciesEnvironment lifecycle
  2. Set Maximum lifetime (1h–1mo) and enable Strict enforcement (“Block non-compliant environments from restarting”) so expired environments cannot be restarted

Step 2: Cap Timeout and Retention

  1. Set the Auto-stop timeout ceiling (default user value is 30 min if unset; the observed org default is “No max timeout”)
  2. Set Archive inactive and Auto-delete archived to the shortest that meets your workflow — deletion is irreversible. API trap: on UpdateOrganizationPolicies a duration of 0s means no limit for maximumEnvironmentTimeout, maximumEnvironmentLifetime, and deleteArchivedEnvironmentsAfter — zeroing these fields weakens the org; archiveEnvironmentsAfter must be a whole number of days.

Time to Complete: ~20 minutes

Code Implementation

Code Pack: Terraform
hth-ona-3.03-environment-lifetime.tf View source on GitHub ↗
# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "environment_lifetime" {
  # Bound how long an environment may be reused at all. Not "0s" — that is the
  # no-maximum value.
  maximum_environment_lifetime = var.maximum_environment_lifetime

  # Ceiling on idle credentialed compute. Non-zero values must be at least 30m.
  maximum_environment_timeout = var.maximum_environment_timeout

  # Whole days only, 24h-720h. Enterprise-only on the API side.
  archive_environments_after = var.archive_environments_after

  # Irreversible once it fires. Max 672h; "0s" would mean never delete.
  delete_archived_environments_after = var.delete_archived_environments_after

  # NOT AVAILABLE HERE: the strict-enforcement flag
  # (maximumEnvironmentLifetimeStrict in the API) has no attribute in provider
  # 0.4.0-beta.1. Without it the lifetime above is advisory. Set it in the console
  # or via UpdateOrganizationPolicies and re-verify after each provider upgrade.
}
Code Pack: API Script
hth-ona-3.03-audit-environment-lifetime.sh View source on GitHub ↗
# Duration strings arrive like "172800s". Absent == "0s" == no limit (TRAP 1).
dur_seconds() { # dur_seconds <duration-string-or-empty> -> integer seconds
  local d="${1:-}"
  d="${d%s}"
  case "${d}" in
    ''|*[!0-9.]*) echo 0 ;;
    *) echo "${d%%.*}" ;;
  esac
}

audit() {
  get_policies
  echo "Ona 3.3 — environment lifetime, timeout and retention"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  local lifetime strict timeout archive delete_after
  lifetime=$(jq -r '.maximumEnvironmentLifetime // "0s"' <<<"${POLICIES}")
  strict=$(jq -r '.maximumEnvironmentLifetimeStrict // false' <<<"${POLICIES}")
  timeout=$(jq -r '.maximumEnvironmentTimeout // "0s"' <<<"${POLICIES}")
  archive=$(jq -r '.archiveEnvironmentsAfter // "0s"' <<<"${POLICIES}")
  delete_after=$(jq -r '.deleteArchivedEnvironmentsAfter // "0s"' <<<"${POLICIES}")

  local l_s t_s a_s d_s
  l_s=$(dur_seconds "${lifetime}"); t_s=$(dur_seconds "${timeout}")
  a_s=$(dur_seconds "${archive}");  d_s=$(dur_seconds "${delete_after}")

  echo "  maximumEnvironmentLifetime:       ${lifetime}  ($( [ "${l_s}" -eq 0 ] && echo "0 == NO MAXIMUM LIFETIME" || echo "$((l_s / 86400)) day(s); ceiling is 15552000s / 180d" ))"
  echo "  maximumEnvironmentLifetimeStrict: ${strict}  (false means the lifetime does not block a start)"
  echo "  maximumEnvironmentTimeout:        ${timeout}  ($( [ "${t_s}" -eq 0 ] && echo "0 == NO LIMIT, not 'immediate'" || echo "$((t_s / 60)) minute(s); floor is 1800s / 30m" ))"
  echo "  archiveEnvironmentsAfter:         ${archive}  ($( [ "${a_s}" -eq 0 ] && echo "unset — Enterprise-only field" || echo "$((a_s / 86400)) day(s), whole days only" ))"
  echo "  deleteArchivedEnvironmentsAfter:  ${delete_after}  ($( [ "${d_s}" -eq 0 ] && echo "0 == NO AUTOMATIC DELETION" || echo "$((d_s / 86400)) day(s); ceiling is 2419200s / 4w" ))"
  echo "  (every value above that reads 0s was ABSENT from the JSON — absent means unlimited, TRAP 1)"

  local rc=0
  if [ "${l_s}" -eq 0 ]; then
    echo "FINDING: maximumEnvironmentLifetime is unset — environments can be reused indefinitely."
    echo "  A long-lived environment accumulates cloned repositories, minted tokens and agent"
    echo "  state that no rebuild ever clears."
    rc=1
  fi
  if [ "${strict}" != "true" ]; then
    echo "FINDING: maximumEnvironmentLifetimeStrict is false."
    echo "  Environments past their lockdown_at timestamp are NOT blocked from starting, so the"
    echo "  lifetime above is advisory (TRAP 4)."
    rc=1
  fi
  if [ "${t_s}" -eq 0 ]; then
    echo "FINDING: maximumEnvironmentTimeout is unset (0s == no limit, TRAP 1/2)."
    echo "  Idle environments stay running with their credentials mounted. The minimum accepted"
    echo "  non-zero value is 1800s — anything smaller is rejected, so do not 'tighten' toward 0."
    rc=1
  fi
  if [ "${d_s}" -eq 0 ]; then
    echo "NOTE: deleteArchivedEnvironmentsAfter is 0 — archived environments are never deleted."
    echo "  Not scored here (retention windows are a policy decision), but it means archived"
    echo "  environment data persists indefinitely. Ceiling is 4 weeks if you set it."
  fi
  if [ "${rc}" -eq 0 ]; then
    echo "COMPLIANT: lifetime, strict enforcement and idle timeout are all set."
  fi
  return "${rc}"
}

Validation & Testing

  1. An expired environment cannot be restarted under strict enforcement
  2. An idle environment auto-stops at the configured ceiling

Expected result: Environments are time-bounded and reclaimed. (Lifetime · Timeout · Archive/auto-delete)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 AC-12 Session termination
NIST 800-53 CM-6 Configuration settings

3.4 Restrict Environment Creation

Profile Level: L2 (Walk)

Framework Control
CIS Controls 4.1
NIST 800-53 CM-7, AC-6

Description

Limit who can spin up blank environments with the Only admins can start from scratch environment policy, so members work from vetted project configurations rather than arbitrary from-scratch environments.

Rationale

Why This Matters:

  • A from-scratch environment bypasses the guardrails and configuration baked into a project’s setup
  • Restricting blank creation channels members into reviewed project templates where secrets scope and repo access are already governed
  • It reduces the surface of unmanaged, ad-hoc environments an attacker or careless user could stand up

Attack Prevented: Unmanaged ad-hoc environments that sidestep project-level controls

ClickOps Implementation

Step 1: Restrict Blank Creation

  1. Navigate to: SettingsInfrastructurePoliciesEnvironment setup
  2. Enable Only admins can start from scratch (API/Terraform disableFromScratch); consider Only admins can create projects and membersRequireProjects alongside it

Time to Complete: ~10 minutes

Code Implementation

Code Pack: Terraform
hth-ona-3.04-restrict-environment-creation.tf View source on GitHub ↗
# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "environment_creation" {
  # "Only admins can start from scratch".
  disable_from_scratch = var.disable_from_scratch

  # Channel members into reviewed project configurations...
  members_require_projects = var.members_require_projects

  # ...and close the escape hatch of creating a project to satisfy the rule.
  # These two are documented as fields to configure together.
  members_create_projects = var.members_create_projects
}
Code Pack: API Script
hth-ona-3.04-audit-environment-creation.sh View source on GitHub ↗
audit() {
  get_policies
  echo "Ona 3.4 — environment creation restrictions"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  # TRAP 1: absent == false for every one of these.
  local scratch require_projects create_projects local_runners
  scratch=$(jq -r '.disableFromScratch // false' <<<"${POLICIES}")
  require_projects=$(jq -r '.membersRequireProjects // false' <<<"${POLICIES}")
  create_projects=$(jq -r '.membersCreateProjects // false' <<<"${POLICIES}")
  local_runners=$(jq -r '.allowLocalRunners // false' <<<"${POLICIES}")

  # TRAP 2: direction stated per field so nobody reads the block as a scoreboard.
  echo "  disableFromScratch:     ${scratch}   [restriction — true is hardened]"
  echo "  membersRequireProjects: ${require_projects}   [restriction — true is hardened]"
  echo "  membersCreateProjects:  ${create_projects}   [permission  — false is hardened]"
  echo "  allowLocalRunners:      ${local_runners}   [permission  — false is hardened; scored in pack 5.01]"
  echo "  (every value shown as false was absent from the JSON — absent means false, TRAP 1)"

  if [ "${require_projects}" != "true" ]; then
    echo "NOTE: membersRequireProjects is false — non-admin members can create environments outside"
    echo "  any project, so project-scoped policy and secret scoping do not apply to them."
  fi
  if [ "${create_projects}" = "true" ]; then
    echo "NOTE: membersCreateProjects is true — members can define their own projects, which means"
    echo "  they can also define the repository and configuration those projects carry."
  fi

  if [ "${scratch}" != "true" ]; then
    echo "FINDING: disableFromScratch is false — non-admin members can create blank environments"
    echo "  with no Git or URL initializer."
    echo "  A from-scratch environment has no reviewed repository behind it, so nothing ties the"
    echo "  workload to code anyone approved: it is a general-purpose compute and network foothold"
    echo "  inside the organization's runner. Admins are unaffected by this setting (TRAP 3), so"
    echo "  pair it with the administrator count from control 1.3."
    return 1
  fi
  echo "COMPLIANT: disableFromScratch is true — non-admin members must start from a Git or URL initializer."
  return 0
}

Validation & Testing

  1. A non-admin member cannot create a from-scratch environment and must use a project

Expected result: Blank environment creation restricted to admins. (Environment creation)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 CM-7 Least functionality
NIST 800-53 AC-6 Least privilege

3.5 Mitigate Dotfiles Auto-Execution Supply-Chain Risk

Profile Level: L2 (Walk)

Framework Control
CIS Controls 2.5, 16.4
NIST 800-53 CM-7, SI-3

Description

Ona clones a user’s configured dotfiles repository (user menu → AccountPreferencesDotfiles repository, or ona user dotfiles set) into every environment and auto-executes the first of install.sh / install / bootstrap.sh / bootstrap / setup.sh / setup. Ona documents no admin control to restrict dotfiles — and no admin visibility either: GetDotfilesConfiguration reads only the caller’s own setting — so treat this as a supply-chain gap and enforce it through the guardrails that do exist.

Rationale

Why This Matters:

  • Auto-executing a user-controlled bootstrap script in every environment is arbitrary code execution at environment start, from a source outside your review
  • A compromised dotfiles repo (or a compromised developer account pointing at a malicious one) runs before the developer touches anything
  • Because there is no admin off-switch, the Veto executable policy and command deny list are the only enforcement points

Attack Prevented: Environment-start code execution via a malicious or compromised dotfiles bootstrap script

ClickOps Implementation

Step 1: Enforce via Existing Guardrails (no direct admin toggle exists)

  1. Use the Veto executable policy (2.1) to AUDIT then BLOCK high-risk binaries the bootstrap might invoke (network and package tooling)
  2. Use the command deny list (2.2) for destructive/exfil patterns
  3. Publish a policy requiring dotfiles repositories to be organization-controlled and reviewed; monitor for dotfiles-driven execution in audit logs (6.1)

Automation: ClickOps only — Ona exposes no write interface for this setting at the organization level (Dotfiles, 2026-08-19); the only surfaces are per-user (UserService/SetDotfilesConfiguration, ona user dotfiles set).

Time to Complete: ~30 minutes (plus the Veto/command-deny setup they depend on)

Validation & Testing

  1. A Veto-blocked binary invoked by a dotfiles script is blocked and logged
  2. Confirm the audit trail surfaces dotfiles bootstrap execution

Expected result: Dotfiles auto-execution risk contained by kernel and command guardrails. (Dotfiles)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 SI-3 Malicious code protection
NIST 800-53 CM-7 Least functionality

4. Data & Secrets

4.1 Scope Secrets and Restrict Organization Secrets

Profile Level: L2 (Walk)

Framework Control
CIS Controls 3.3, 16.4
NIST 800-53 AC-3, SC-28
SOC 2 CC6.1

Description

Use the tightest secret scope (user > project > organization precedence) and treat organization secrets with caution: they are available to everyone in the organization with no documented granular access control. Agents pull credentials from secrets, so scope is a direct agent-access decision.

Rationale

Why This Matters:

  • An organization secret is readable by every member’s environments and agents — one org-scoped cloud key is a shared, broadly-reachable credential
  • Project and user scope confine a secret to where it is actually needed
  • Because agents pull credentials from secrets, over-broad secret scope directly widens what a compromised agent can reach

Attack Prevented: Broad credential exposure via org-wide secrets reachable by every agent

ClickOps Implementation

Step 1: Prefer Narrow Scope

  1. Define secrets at user (user menu → AccountSecrets) or project scope wherever possible rather than organization scope
  2. For unavoidable org secrets (SettingsInfrastructureSecrets, Enterprise tier), document that they are readable org-wide and reserve them for genuinely shared, lower-sensitivity values
  3. Prefer the credential proxy mount where the target is an HTTPS API: “the credential proxy intercepts HTTPS traffic to the target hosts and replaces the dummy mounted value with the real value in the specified HTTP header. The real secret value is never exposed in the environment.” (Secret.credentialProxy) — an agent that cannot read the value cannot exfiltrate it

Time to Complete: ~30 minutes plus inventory

Code Implementation

Code Pack: API Script
hth-ona-4.01-audit-org-secrets.sh View source on GitHub ↗
# Metadata only. TRAP 1: GetSecretValue is never called from this pack.
audit() {
  resolve_org
  echo "Ona 4.1 — organization-scoped secrets"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  # TRAP 2: filter on scope, never on the deprecated projectIds.
  paginate "SecretService/ListSecrets" \
           "$(jq -nc --arg o "${ORG_ID}" '{filter: {scope: {organizationId: $o}}}')" "secrets"
  local secrets="${PAGE_ITEMS}" total proxied unproxied envvar
  total=$(jq 'length' <<<"${secrets}")
  proxied=$(jq '[.[] | select(.credentialProxy != null)] | length' <<<"${secrets}")
  unproxied=$((total - proxied))
  envvar=$(jq '[.[] | select((.environmentVariable // false) == true)] | length' <<<"${secrets}")

  echo "  organization-scoped secrets: ${total} (credentialProxy set on ${proxied}, absent on ${unproxied})"
  echo "  mounted as an environment variable: ${envvar}"
  # TRAP 3: the mount is a oneof — detect the member that is present.
  jq -r '.[] |
    "    - name=\(.name // "(unnamed)")" +
    " mount=\(if (.filePath // "") != "" then "filePath"
              elif (.environmentVariable // false) == true then "environmentVariable"
              elif (.containerRegistryBasicAuthHost // "") != "" then "containerRegistryBasicAuthHost"
              elif (.apiOnly // false) == true then "apiOnly"
              else "(none set)" end)" +
    " credentialProxy=\(if .credentialProxy == null then "absent" else "present" end)" +
    " source=\(.source // "(unset)")"' <<<"${secrets}"
  echo "  (values are never returned by ListSecrets, and GetSecretValue is not called — TRAP 1)"

  if [ "${total}" -eq 0 ]; then
    echo "COMPLIANT: no organization-scoped secrets exist — nothing is shared across every environment."
    return 0
  fi
  echo "REVIEW: an organization-scoped secret is mounted into EVERY environment in the organization,"
  echo "  including from-scratch environments and agent sessions. Each one above should be justified"
  echo "  against a narrower scope (project, user or service account) before it stays."
  if [ "${unproxied}" -gt 0 ] && [ "${ONA_STRICT_SECRETS:-0}" = "1" ]; then
    echo "FINDING: ${unproxied} organization-scoped secret(s) have no credentialProxy (ONA_STRICT_SECRETS=1)."
    echo "  Without the proxy the real value is materialised inside the environment and any process"
    echo "  there — an agent included — can read it. With it, only a dummy value is mounted (TRAP 4)."
    return 1
  fi
  if [ "${unproxied}" -gt 0 ]; then
    echo "NOTE: ${unproxied} of them have no credentialProxy. Set ONA_STRICT_SECRETS=1 to score that"
    echo "  as a finding once your organization has adopted the credential proxy."
  fi
  return 0
}
Code Pack: Sigma Detection Rule
hth-ona-4.01-credential-access.yml View source on GitHub ↗
detection:
    selection:
        kind: 'AUDIT_LOG_ENTRY_KIND_CREDENTIAL_ACCESS'
    condition: selection
fields:
    - createdAt
    - actorId
    - actorPrincipal
    - subjectId
    - subjectType
    - action

Validation & Testing

  1. A project-scoped secret is not visible to environments outside that project
  2. Inventory org secrets and confirm each is acceptable as an org-readable value

Expected result: Secrets scoped to least exposure; org secrets minimized. (Secrets overview · Organization secrets)

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access security
NIST 800-53 SC-28 Protection of information at rest

4.2 Use OIDC Workload Identity for Keyless Cloud Access

Profile Level: L2 (Walk)

Framework Control
CIS Controls 3.3, 16.9
NIST 800-53 IA-5, AC-3

Description

Instead of storing long-lived cloud credentials as secrets, use Ona’s OIDC workload identity to exchange an Ona-issued ID token for short-lived cloud credentials, with custom subject claims (organization_id, project_id, creator_email, environment_id, runner_name) to scope trust in your cloud IAM.

Rationale

Why This Matters:

  • Keyless federation removes the standing long-lived cloud key that would otherwise sit in secrets as a theft target
  • Custom sub claims let your cloud IAM trust policy scope access to specific projects/environments rather than “any Ona workload”
  • Short-lived credentials shrink the window a leaked token is useful

Attack Prevented: Theft of long-lived cloud credentials stored as environment secrets

ClickOps Implementation

Step 1: Configure Workload Identity

  1. In your cloud IAM, register https://app.gitpod.io as the OIDC issuer (that is the iss claim even for an org that only ever sees ona.com branding) and constrain the trust policy using the sub claims you selected (organization_id, project_id, runner_id, environment_id, creator_email, …)
  2. In Ona, navigate to SettingsLogin & IdentityOIDC Tokens (Enterprise tier; API UpdateOIDCConfig): choose v3 tokens and set extraSubFields so the sub pins a specific project/runner/environment rather than the whole org
  3. From agents/automations, retrieve a token with ona idp token --audience <audience> or authenticate directly with ona idp login aws --role-arn <arn> / ona idp login vault --role <role> instead of stored keys

Time to Complete: ~1-2 hours with cloud IAM changes

Code Implementation

Code Pack: Terraform
hth-ona-4.02-oidc-config.tf View source on GitHub ↗
resource "ona_oidc_config" "org" {
  # These fields become part of the OIDC V3 `sub` claim. Your cloud IAM trust
  # policy conditions on them — change this set and the cloud side in one change,
  # never separately.
  custom_claim_fields = var.oidc_custom_claim_fields
}
Code Pack: API Script
hth-ona-4.02-audit-oidc-config.sh View source on GitHub ↗
audit() {
  resolve_org
  echo "Ona 4.2 — OIDC workload identity configuration"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  # TRAP 1: 404 is the answer, not an error — handle it before the strict classifier.
  api "OrganizationService/GetOIDCConfig" "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o}')"
  if [ "${RPC_CODE}" = "404" ]; then
    echo "  oidcConfig: NOT CONFIGURED (HTTP 404 $(rpc_err) — $(rpc_msg))"
    echo "FINDING: this organization has no OIDC configuration."
    echo "  Every cloud credential an environment uses must therefore be a long-lived static key"
    echo "  stored as a secret — the thing OIDC workload identity exists to remove."
    return 1
  fi
  if [ "${RPC_CODE}" != "200" ]; then
    api_strict "OrganizationService/GetOIDCConfig" "$(jq -nc --arg o "${ORG_ID}" '{organizationId: $o}')"
  fi

  local cfg version fields count
  cfg=$(printf '%s' "${RPC_BODY}" | jq -c '.oidcConfig // {}')
  # TRAP 2: V2 is an EMPTY message, so test for the KEY, never for truthiness.
  if jq -e 'has("v3")' >/dev/null <<<"${cfg}"; then
    version="v3"
  elif jq -e 'has("v2")' >/dev/null <<<"${cfg}"; then
    version="v2"
  else
    version="(neither v2 nor v3 present)"
  fi
  fields=$(jq -c '.v3.extraSubFields // []' <<<"${cfg}")
  count=$(jq 'length' <<<"${fields}")
  echo "  oidcConfig version: ${version}"
  echo "  v3.extraSubFields:  ${count}"
  if [ "${count}" -gt 0 ]; then
    jq -r '.[] | "    - \(.)"' <<<"${fields}"
  fi

  if [ "${version}" = "v2" ]; then
    echo "FINDING: this organization issues V2 OIDC tokens."
    echo "  V2 has no sub-claim customisation at all (OIDCConfigV2 is an empty message), so a cloud"
    echo "  trust policy cannot scope beyond the organization. V3 is the default for new"
    echo "  organizations — migrate, then add extraSubFields."
    return 1
  fi
  if [ "${version}" != "v3" ]; then
    echo "FINDING: GetOIDCConfig returned neither a v2 nor a v3 member."
    echo "  Treat the configuration as unproven rather than assuming a safe default."
    return 1
  fi
  if [ "${count}" -eq 0 ]; then
    echo "FINDING: V3 is selected but extraSubFields is empty (TRAP 3)."
    echo "  The sub claim carries nothing to pin an AWS/GCP condition against, so any environment,"
    echo "  project or runner in the organization satisfies the trust policy equally. Add the keys"
    echo "  your condition needs — project_id, runner_id, environment_id, creator_id and so on."
    return 1
  fi
  echo "COMPLIANT: V3 OIDC tokens with ${count} extra sub field(s) — cloud trust policies can pin"
  echo "           a specific project, runner or environment rather than the whole organization."
  return 0
}
Code Pack: CLI Script
hth-ona-4.02-verify-oidc-subject.sh View source on GitHub ↗
# Mint an OIDC token for an audience and report its issuer and subject shape.
# The token itself is never printed; ids are truncated to 8 characters.

# base64url -> JSON. Used only if `--decode` does not hand back JSON (T4).
b64url_json() {
  local seg="$1" pad=$(( 4 - ${#1} % 4 ))
  [ "${pad}" -eq 4 ] || seg="${seg}$(printf '=%.0s' $(seq 1 "${pad}"))"
  printf '%s' "${seg}" | tr '_-' '/+' | jq -Rr '@base64d' 2>/dev/null || true
}

decode_token() {
  local out raw payload
  # Documented form. If this fails we are not a principal that can mint here.
  if out="$("${ONA_BIN}" idp token --audience "${AUD}" --decode 2>/dev/null)"; then
    # Take the first JSON object out of whatever it printed.
    if printf '%s' "${out}" | sed -n '/{/,$p' | jq -e . >/dev/null 2>&1; then
      printf '%s' "${out}" | sed -n '/{/,$p'
      return 0
    fi
  fi
  # Fallback: decode the JWT payload ourselves. The raw token stays in this
  # function and is never echoed.
  raw="$("${ONA_BIN}" idp token --audience "${AUD}" 2>/dev/null | tr -d '[:space:]')" || return 1
  [ -n "${raw}" ] || return 1
  payload="$(printf '%s' "${raw}" | cut -d. -f2)"
  [ -n "${payload}" ] || return 1
  b64url_json "${payload}"
}

claims="$(decode_token || true)"
if [ -z "${claims}" ] || ! printf '%s' "${claims}" | jq -e . >/dev/null 2>&1; then
  echo "PRECONDITION: could not mint or decode an OIDC token for audience '${AUD}'." >&2
  echo "  Most likely you are not inside an Ona environment. 'ona idp token' mints" >&2
  echo "  for the calling principal, so run this from a task, a terminal in the" >&2
  echo "  environment, or 'ona environment exec <id> -- ...'." >&2
  echo "  Other causes: OIDC is Enterprise-plan only, and the org may not have it" >&2
  echo "  enabled (Settings > Security > OIDC Token Configuration)." >&2
  exit 2
fi

iss="$(printf '%s' "${claims}" | jq -r '.iss // ""')"
sub="$(printf '%s' "${claims}" | jq -r '.sub // ""')"

# Redact email-shaped values (T3), then truncate every id-looking token to 8
# characters so the subject SHAPE is legible without publishing identifiers.
sub_shape="$(printf '%s' "${sub}" \
  | sed -E 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/<redacted-email>/g' \
  | sed -E 's/([0-9a-fA-F]{8})[0-9a-fA-F-]{4,}/\1…/g')"

case "${sub}" in
  *"/"*) sub_format="V2 (legacy path format — see T1)" ;;
  *)     sub_format="V3 (current key:value format)" ;;
esac

echo "audience:      ${AUD}"
echo "issuer:        ${iss}"
echo "subject shape: ${sub_shape}"
echo "subject format: ${sub_format}"
echo "claims present: $(printf '%s' "${claims}" | jq -r '[keys[]] | join(", ")')"

if [ "${iss}" != "${EXPECTED_ISS}" ]; then
  echo "FINDING: issuer is '${iss}', expected '${EXPECTED_ISS}'." >&2
  echo "         Register '${EXPECTED_ISS}' as the OIDC issuer in your cloud IAM," >&2
  echo "         or correct the custom management-plane domain in your trust policy." >&2
  exit 1
fi

echo "COMPLIANT: issuer is ${EXPECTED_ISS}. Scope the cloud trust policy on the"
echo "           subject above, then authenticate keylessly with:"
echo "             ona idp login aws   --role-arn <role-arn>"
echo "             ona idp login vault --role <role>"
exit 0

Validation & Testing

  1. An agent obtains short-lived cloud credentials via token exchange with no stored long-lived key
  2. The cloud trust policy rejects a token whose claims fall outside the allowed project/environment

Expected result: Cloud access is keyless and claim-scoped. (OIDC configuration)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 IA-5 Authenticator management
NIST 800-53 AC-3 Access enforcement

4.3 Scope Repository Access to Least Privilege

Profile Level: L2 (Walk)

Framework Control
CIS Controls 3.3, 6.8
NIST 800-53 AC-3, AC-6

Description

Configure per-runner repository access deliberately. The GitHub integration requests broad, all-repository access with scopes repo, read:user, user:email, workflow (per-repo scoping is roadmap only), so choose the connection method and account carefully and remove access when unused.

Rationale

Why This Matters:

  • Broad all-repository access means a compromised runner or agent reaches every repo the connected account can, not just the one in play
  • Connection credentials are stored encrypted and removed when the method is disabled or the integration deleted — so disabling unused integrations actually revokes access
  • Choosing a least-privilege service identity for the SCM connection limits blast radius where per-repo scoping is unavailable

Attack Prevented: Whole-org source exposure via an over-broad SCM integration on a compromised runner

ClickOps Implementation

Step 1: Configure Repository Access Deliberately

  1. Navigate to: SettingsInfrastructureRunners → open the runner → Configure repository accessNew provider (Ona Cloud runners ship github/gitlab/bitbucket via OAuth; private or self-hosted Git servers require Enterprise)
  2. Prefer OAuth over PAT where possible (SCMIntegration.pat allows PAT auth — leave it off when supportsOauth2 is true); where a PAT is used, mint it on a least-privilege service identity
  3. Remove providers/integrations that are no longer needed to revoke their stored credentials; CheckRepositoryAccess and ListSCMOrganizations answer “what can this runner actually reach”

Time to Complete: ~30 minutes

Code Implementation

Code Pack: Terraform
hth-ona-4.03-scm-integration.tf View source on GitHub ↗
resource "ona_scm_integration" "primary" {
  runner_id = var.scm_runner_id

  kind = var.scm_kind
  host = var.scm_host

  # OAuth over PAT. `pat` mode requires attaching a long-lived personal access
  # token via ona_git_authentication — a standing credential this control exists
  # to avoid. Only fall back to `pat` where the platform requires it (Azure DevOps
  # Server), and then mint it on a least-privilege service identity.
  auth_mode = "oauth"

  oauth_client_id = var.scm_oauth_client_id

  # Write-only: never enters plan or state. Rotation is driven by the version
  # marker below, not by this value changing.
  oauth_client_secret         = var.scm_oauth_client_secret
  oauth_client_secret_version = var.scm_oauth_client_secret_version
}
Code Pack: API Script
hth-ona-4.03-audit-runner-repo-access.sh View source on GitHub ↗
# For every runner: which SCM integrations it holds, and for each integration's
# host, which authentication methods the host actually offers (TRAP 2).
audit_runner() {
  local rid="$1" phase="$2"
  api_strict "RunnerConfigurationService/ListSCMIntegrations" \
    "$(jq -nc --arg r "${rid}" '{filter: {runnerIds: [$r]}}')"
  local ints
  ints=$(printf '%s' "${RPC_BODY}" | jq -c '.integrations // []')
  echo "    scm integrations: $(jq 'length' <<<"${ints}")"

  local rows host pat oauth scmid
  rows=$(jq -r '.[] | [(.host // ""), ((.pat // false) | tostring), (if .oauth == null then "false" else "true" end), (.scmId // "")] | @tsv' <<<"${ints}")
  [ -n "${rows}" ] || return 0
  while IFS=$'\t' read -r host pat oauth scmid; do
    [ -n "${host}${scmid}" ] || continue
    local host_oauth="unknown" host_pat="unknown" host_patbool="unknown" why=""
    # TRAP 5: the probe needs a LIVE runner. Skip it on anything that is not
    # reporting RUNNER_PHASE_ACTIVE rather than aborting the whole sweep.
    if [ "${phase}" != "RUNNER_PHASE_ACTIVE" ]; then
      why="runner reports ${phase} — CheckAuthenticationForHost needs an active runner"
    else
      api "RunnerService/CheckAuthenticationForHost" \
        "$(jq -nc --arg r "${rid}" --arg h "${host}" '{runnerId: $r, host: $h}')"
      if [ "${RPC_CODE}" = "200" ]; then
        # TRAP 2: presence of the message, not its truthiness.
        host_oauth=$(printf '%s' "${RPC_BODY}" | jq -r 'if has("supportsOauth2") then "true" else "false" end')
        host_pat=$(printf '%s' "${RPC_BODY}"   | jq -r 'if has("supportsPat")    then "true" else "false" end')
        host_patbool=$(printf '%s' "${RPC_BODY}" | jq -r '.patSupported // false')
      else
        why="probe returned HTTP ${RPC_CODE} $(rpc_err) — $(rpc_msg)"
      fi
    fi
    echo "      - host=${host:-(unset)} scmId=${scmid:-(unset)} oauthConfigured=${oauth} patAllowed=${pat}"
    if [ -n "${why}" ]; then
      echo "        host capabilities UNKNOWN: ${why}"
    else
      echo "        host offers: supportsOauth2=${host_oauth} supportsPat=${host_pat} patSupported=${host_patbool}"
    fi
    # TRAP 1: PAT allowed on a host that speaks OAuth is the finding. When the
    # probe could not run, a CONFIGURED oauth block on the integration itself is
    # sufficient proof that the host speaks OAuth — the fallback never invents
    # capability, it only uses what the integration already records.
    if [ "${pat}" = "true" ] && [ "${ONA_ALLOW_PAT_SCM:-0}" != "1" ] \
       && { [ "${host_oauth}" = "true" ] || { [ "${host_oauth}" = "unknown" ] && [ "${oauth}" = "true" ]; }; }; then
      echo "        FINDING: PAT authentication is allowed on a host that supports OAuth2."
      echo "          A member-chosen personal access token carries whatever scope that member gave"
      echo "          it, is not bounded by the OAuth app's grant, and does not die when the OAuth"
      echo "          app is revoked. Disable pat on this integration, or set ONA_ALLOW_PAT_SCM=1"
      echo "          and record why this host needs it."
      FINDINGS=$((FINDINGS + 1))
      return 0
    fi
    if [ "${pat}" = "true" ] && [ "${host_oauth}" = "unknown" ] && [ "${oauth}" != "true" ]; then
      echo "        UNPROVEN: pat is allowed and this integration has no OAuth block, but the host's"
      echo "          own capabilities could not be read — do not record this integration as clean."
      UNPROVEN=$((UNPROVEN + 1))
    fi
  done <<<"${rows}"
}

audit() {
  resolve_org
  echo "Ona 4.3 — runner repository access and SCM integrations"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  paginate "RunnerService/ListRunners" '{}' "runners"
  local runners="${PAGE_ITEMS}" ids rid rphase
  echo "  runners: $(jq 'length' <<<"${runners}")"
  # TRAP 5: carry each runner's REPORTED phase, not its desired one.
  ids=$(jq -r '.[] | [(.runnerId // ""), (.status.phase // "RUNNER_PHASE_UNSPECIFIED")] | @tsv' <<<"${runners}")
  if [ -z "${ids}" ]; then
    echo "  (no runners — no SCM integration surface to read)"
  else
    while IFS=$'\t' read -r rid rphase; do
      [ -n "${rid}" ] || continue
      echo "  runner …${rid: -6} reportedPhase=${rphase}"
      audit_runner "${rid}" "${rphase}"
      # TRAP 4: the reach probe is opt-in.
      if [ -n "${CHECK_REPO}" ]; then
        api "RunnerService/CheckRepositoryAccess" \
          "$(jq -nc --arg r "${rid}" --arg u "${CHECK_REPO}" '{runnerId: $r, repositoryUrl: $u}')"
        if [ "${RPC_CODE}" = "200" ]; then
          echo "    CheckRepositoryAccess: hasAccess=$(printf '%s' "${RPC_BODY}" | jq -r '.hasAccess // false')" \
               "error=$(printf '%s' "${RPC_BODY}" | jq -r 'if (.errorMessage // "") == "" then "(none)" else .errorMessage end')"
        else
          echo "    CheckRepositoryAccess: NOT PROBED (HTTP ${RPC_CODE} $(rpc_err) — $(rpc_msg))"
        fi
      fi
    done <<<"${ids}"
  fi

  if [ "${FINDINGS}" -gt 0 ]; then
    echo "RESULT: ${FINDINGS} SCM integration(s) allow PAT authentication on an OAuth-capable host."
    return 1
  fi
  if [ "${UNPROVEN}" -gt 0 ]; then
    echo "RESULT: no finding proven, but ${UNPROVEN} PAT-enabled integration(s) sit on hosts whose"
    echo "        capabilities could not be probed (TRAP 5). Exiting 2: incomplete, not clean."
    return 2
  fi
  echo "COMPLIANT: no SCM integration allows PAT authentication where the host supports OAuth2."
  return 0
}

Validation & Testing

  1. Deleting an unused integration removes its stored credentials (access no longer works)
  2. Inventory which repositories the connected account can reach and confirm it is the minimum acceptable

Expected result: SCM access scoped to the least the platform allows, unused integrations revoked. (Configuring repository access · GitHub integration)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 AC-3 Access enforcement
NIST 800-53 AC-6 Least privilege

5. Deployment Architecture

5.1 Run Self-Hosted Runners for Sensitive Source Code

Profile Level: L2 (Walk)

Framework Control
CIS Controls 12.2
NIST 800-53 SC-7, SA-9
SOC 2 CC6.6

Description

For sensitive codebases, run self-hosted runners in your own AWS/GCP VPC rather than Ona Cloud. With self-hosted runners, source code and SCM credentials stay on the runner in your infrastructure and never reach Ona’s management plane; guardrails are still defined centrally but enforced at the runner.

Rationale

Why This Matters:

  • The management/runner plane separation means a self-hosted runner keeps source and secrets inside your VPC — the management plane handles only auth, policy, and coordination
  • Ona Cloud is limited to two regions (eu-central-1, us-east-1) with no private networking and cannot reach self-hosted Git providers; self-hosting is required for private-network and residency needs
  • Guardrails (Veto, deny lists, policies) still apply, so self-hosting adds isolation without losing central governance

Attack Prevented: Exposure of sensitive source and credentials to a shared multi-tenant control plane

Prerequisites

  • AWS or GCP account and VPC; capacity for Fargate-based runners
  • Network path for the runner’s required egress (see below)

ClickOps Implementation

Step 1: Provision a Self-Hosted Runner

  1. Navigate to: SettingsInfrastructureRunnersSet up a new runnerAWS or GCP (Enterprise tier; Azure is waitlisted) and deploy it in your VPC
  2. Provision the documented network access. Gotcha: the AWS runner requires direct TCP 443 to Secrets Manager, CloudWatch Logs, ECR API, ECR Docker, and S3 — these bypass HTTP proxies; use PrivateLink/VPC endpoints. Allow Ona AMIs by owner account ID 995913728426, not by AMI ID.
  3. Configure repository access on the runner (4.3); block the deprecated local-runner path with the org policy allowLocalRunners: false (or set the system-managed RUNNER_KIND_LOCAL_CONFIGURATION runner’s desired phase to STOPPED)

Time to Complete: ~half day including cloud networking

Code Implementation

Code Pack: Terraform
hth-ona-5.01-self-hosted-runner.tf View source on GitHub ↗
resource "ona_runner" "selfhosted" {
  name = var.runner_name

  # aws_ec2 or gcp. Ona Cloud is the alternative this control exists to avoid for
  # sensitive source.
  runner_provider = var.runner_provider

  # `configuration` is a BLOCK, not an attribute.
  configuration {
    region = var.runner_region

    # "stable", not "latest": take reviewed builds only.
    release_channel = "stable"
    auto_update     = true

    devcontainer_image_cache_enabled = true
    log_level                        = "info"

    # Ona-managed metrics keep the telemetry path inside the vendor relationship
    # you already assessed. A custom remote-write URL here would be an additional
    # egress path out of your VPC — review it like any other.
    metrics {
      managed {
        enabled = true
      }
    }

    # Confine auto-updates to a maintenance window you actually watch. Times are
    # HH:00 UTC.
    update_window {
      start = "02:00"
      end   = "04:00"
    }
  }
}

# Closing the back door: a permitted local runner runs agents on a laptop, outside
# every network control the self-hosted runner just bought you.
# SINGLETON: at most one ona_organization_policies per organization (import id "current") —
# merge these attributes with any other ona pack you adopt; two resources drift forever.
resource "ona_organization_policies" "local_runners" {
  allow_local_runners = var.allow_local_runners
}

# Registration token. OPT-IN because applying it writes a live, single-use,
# 24-hour credential into Terraform STATE — this resource is NOT ephemeral.
resource "ona_runner_token" "bootstrap" {
  count = var.mint_registration_token ? 1 : 0

  runner_id     = ona_runner.selfhosted.runner_id
  token_version = var.runner_token_version
}
# Standing inventory of every runner the token can see. Read it to catch a runner
# someone stood up in the console outside this configuration.
data "ona_runners" "all" {}

output "ona_runner_inventory" {
  description = "Every runner visible to the token, with its provider and release channel. Investigate anything not declared here."
  value = [
    for r in data.ona_runners.all.runners : {
      name            = r.name
      runner_provider = r.runner_provider
      kind            = r.kind
      release_channel = r.configuration.release_channel
    }
  ]
}

output "ona_aws_cloudformation_template_url" {
  description = "CloudFormation template for deploying the AWS runner in YOUR account. Null for GCP runners. Registration is not deployment."
  value       = ona_runner.selfhosted.cloudformation_template_url
}
Code Pack: API Script
hth-ona-5.01-audit-runners.sh View source on GitHub ↗
audit() {
  get_policies
  echo "Ona 5.1 — runner fleet and local-runner policy"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  paginate "RunnerService/ListRunners" '{}' "runners"
  local runners="${PAGE_ITEMS}" total self_hosted managed
  total=$(jq 'length' <<<"${runners}")
  # TRAP 2: self-hosted means AWS_EC2 or GCP; MANAGED is the Ona-operated fleet.
  self_hosted=$(jq '[.[] | select((.provider // "") == "RUNNER_PROVIDER_AWS_EC2" or (.provider // "") == "RUNNER_PROVIDER_GCP")] | length' <<<"${runners}")
  managed=$(jq '[.[] | select((.provider // "") == "RUNNER_PROVIDER_MANAGED")] | length' <<<"${runners}")
  echo "  runners: total=${total} self-hosted(AWS_EC2|GCP)=${self_hosted} Ona-managed=${managed}"

  # TRAP 3: desired vs reported, side by side.
  jq -r '.[] |
    "    - id=\…((.runnerId // "unknown")[-6:])" +
    " kind=\(.kind // "RUNNER_KIND_UNSPECIFIED")" +
    " provider=\(.provider // "RUNNER_PROVIDER_UNSPECIFIED")" +
    " desiredPhase=\(.spec.desiredPhase // "RUNNER_PHASE_UNSPECIFIED")" +
    " reportedPhase=\(.status.phase // "RUNNER_PHASE_UNSPECIFIED")"' <<<"${runners}"

  # TRAP 1: the second lever — the system-managed LOCAL_CONFIGURATION singleton.
  local lc_phase
  lc_phase=$(jq -r '[.[] | select((.kind // "") == "RUNNER_KIND_LOCAL_CONFIGURATION")
                         | (.spec.desiredPhase // "RUNNER_PHASE_UNSPECIFIED")]
                    | if length == 0 then "(no LOCAL_CONFIGURATION runner visible)" else .[0] end' <<<"${runners}")
  echo "  RUNNER_KIND_LOCAL_CONFIGURATION desiredPhase: ${lc_phase}"
  echo "    (set that singleton to RUNNER_PHASE_STOPPED to disable all local runners — the second"
  echo "     lever alongside allowLocalRunners)"

  local allow_local
  allow_local=$(jq -r '.allowLocalRunners // false' <<<"${POLICIES}")
  echo "  allowLocalRunners: ${allow_local} (absent in the JSON means false)"

  local rc=0
  if [ "${allow_local}" = "true" ]; then
    echo "FINDING: allowLocalRunners is true."
    echo "  Members can run environments on a machine the organization does not control, which puts"
    echo "  cloned source and mounted secrets on unmanaged hardware. Local runners are deprecated at"
    echo "  the enum level anyway — RUNNER_KIND_LOCAL says 'no longer supported'."
    rc=1
  fi
  if [ "${self_hosted}" -eq 0 ] && [ "${ONA_REQUIRE_SELF_HOSTED:-0}" = "1" ]; then
    echo "FINDING: no self-hosted runner exists (ONA_REQUIRE_SELF_HOSTED=1)."
    echo "  Every environment runs on Ona-operated infrastructure, so sensitive source is cloned"
    echo "  outside your own network boundary. Deploy an AWS_EC2 (CloudFormation) or GCP"
    echo "  (Terraform) runner and bind the sensitive projects to it."
    rc=1
  fi
  if [ "${self_hosted}" -eq 0 ] && [ "${ONA_REQUIRE_SELF_HOSTED:-0}" != "1" ]; then
    echo "NOTE: no self-hosted runner exists. Set ONA_REQUIRE_SELF_HOSTED=1 to score that as a"
    echo "  finding once your organization has decided sensitive source must stay on its own"
    echo "  infrastructure."
  fi
  if [ "${rc}" -eq 0 ]; then
    echo "COMPLIANT: local runners are not allowed and the fleet posture matches the expected profile."
  fi
  return "${rc}"
}

Validation & Testing

  1. Confirm source code and SCM credentials remain in your VPC (never transit the management plane)
  2. Confirm centrally-defined guardrails enforce at the self-hosted runner

Expected result: Sensitive code processed in your own VPC under central governance. (Runners overview · Ona Cloud regions · Architecture · AWS setup · AWS networking)

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6 Boundary protection
NIST 800-53 SA-9 External system services

6. Monitoring & Detection

6.1 Enable Audit Logging and SIEM Streaming

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 8.2, 8.9
NIST 800-53 AU-2, AU-6
SOC 2 CC7.2

Description

Operationalize Ona’s audit logs — covering infrastructure, execution, security (including environment Veto enforcement events), organization, and integration activity — and stream them to your SIEM. Assign the read-only Audit Log Reader role for oversight without change rights.

Rationale

Why This Matters:

  • Veto-enforcement events are how you confirm the kernel guardrails (2.1) are actually blocking — audit logging is what makes the guardrails observable
  • Regular members cannot read audit logs (not even their own resources), so oversight requires deliberately assigning the Audit Log Reader role
  • Real-time streaming to a SIEM enables correlation and alerting beyond the console

Attack Prevented: Undetected agent/admin misuse; unobserved guardrail bypass attempts

ClickOps Implementation

Step 1: Assign Oversight and Stream Logs

  1. Assign the Audit Log Reader role to your security team (1.3); audit logs are Enterprise-only, and there is no console page for them — the surfaces are the CLI and the API
  2. Export via CLI (ona audit-logs --format=json --limit=1000) or the API (POST /api/gitpod.v1.EventService/ListAuditLogs, base https://app.gitpod.io — or your custom dashboard domain), filtering on actorIds, actorPrincipals (USER/SERVICE_ACCOUNT/RUNNER/…), subjectTypes, and from/to (RFC 3339, [from, to) half-open; max 100 entries per page, 25 values per filter)
  3. For SIEM ingestion, poll ListAuditLogs on a schedule — it is the only surface carrying actor attribution. WatchEvents streams resource changes only ({operation, resourceType, resourceId}, no actor, readable by anyone with resource read access) and is a live-dashboard feed, not the audit trail; there is no method to enable logging, set retention, or register a SIEM destination (logging is always-on and pull-only, and Ona documents no named SIEM connector)

Automation surface: genuinely API-manageable for the read side — EventService/ListAuditLogs + GetAuditLog (the typed details.vetoExec payload lives on GetAuditLog), plus the ona audit-logs CLI (see audit-log API).

Time to Complete: ~1 hour

Code Implementation

Code Pack: API Script
hth-ona-6.01-export-audit-logs.sh View source on GitHub ↗
# Portable RFC 3339. GNU and BSD date disagree on relative arithmetic and this
# repo runs on both, so try each in turn.
iso_now()        { date -u '+%Y-%m-%dT%H:%M:%SZ'; }
iso_hours_ago()  {
  date -u -d "$1 hours ago" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \
    || date -u -v-"$1"H     '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \
    || { echo "PRECONDITION: neither GNU nor BSD date syntax worked — set ONA_AUDIT_FROM explicitly" >&2; exit 2; }
}

export_logs() {
  resolve_org
  local from to token pages=0 emitted=0
  # TRAP 2: [from, to). Carry `to` forward verbatim as the next run's `from`.
  to="${ONA_AUDIT_TO:-$(iso_now)}"
  from="${ONA_AUDIT_FROM:-$(iso_hours_ago 24)}"
  echo "window [from,to) = [${from}, ${to})  (half-open: reuse 'to' verbatim as the next 'from')" >&2

  # TRAP 3: probe once so the plan boundary is named, not swallowed.
  api "EventService/ListAuditLogs" \
      "$(jq -nc --arg f "${from}" --arg t "${to}" '{filter: {from: $f, to: $t}, pagination: {pageSize: 1}}')"
  if [ "${RPC_CODE}" = "400" ] && [ "$(rpc_err)" = "failed_precondition" ]; then
    echo "PRECONDITION: audit logs are Enterprise-only on this organization — $(rpc_msg)" >&2
    echo "  Nothing to remediate at the configuration layer: there is no method to enable audit" >&2
    echo "  logging, and no retention or SIEM-destination field anywhere in the API. The gate is" >&2
    echo "  the plan tier." >&2
    exit 2
  fi
  if [ "${RPC_CODE}" != "200" ]; then
    api_strict "EventService/ListAuditLogs" \
      "$(jq -nc --arg f "${from}" --arg t "${to}" '{filter: {from: $f, to: $t}, pagination: {pageSize: 1}}')"
  fi

  token=""
  while :; do
    api_strict "EventService/ListAuditLogs" \
      "$(jq -nc --arg f "${from}" --arg t "${to}" --arg tok "${token}" \
         '{filter: {from: $f, to: $t},
           pagination: ({pageSize: 100} + (if $tok == "" then {} else {token: $tok} end))}')"
    local page n
    if [ "${VETO_ONLY}" -eq 1 ]; then
      # TRAP 4: `kind` is not a server-side filter field, so this is client-side.
      page=$(printf '%s' "${RPC_BODY}" | jq -c '(.entries // [])[] | select((.kind // "") == "AUDIT_LOG_ENTRY_KIND_ENVIRONMENT_VETO")')
    else
      page=$(printf '%s' "${RPC_BODY}" | jq -c '(.entries // [])[]')
    fi
    if [ -n "${page}" ]; then
      printf '%s\n' "${page}"
      n=$(printf '%s\n' "${page}" | wc -l | tr -d ' ')
      emitted=$((emitted + n))
    fi
    token=$(printf '%s' "${RPC_BODY}" | jq -r '.pagination.nextToken // ""')
    pages=$((pages + 1))
    [ -n "${token}" ] && [ "${pages}" -lt 500 ] || break
  done

  echo "exported ${emitted} entr$( [ "${emitted}" -eq 1 ] && echo y || echo ies ) across ${pages} page(s)$( [ "${VETO_ONLY}" -eq 1 ] && echo ' (kind=AUDIT_LOG_ENTRY_KIND_ENVIRONMENT_VETO only)' )" >&2
  echo "next run: ONA_AUDIT_FROM='${to}'   # verbatim, no rounding (TRAP 2)" >&2
}
Code Pack: CLI Script
hth-ona-6.01-export-audit-logs.sh View source on GitHub ↗
# Export Ona audit logs as NDJSON for SIEM ingestion.
#
#   ./hth-ona-6.01-export-audit-logs.sh              > audit.ndjson
#   FROM=2026-08-01T00:00:00Z TO=2026-08-19T00:00:00Z ./…            > window.ndjson
#   SUBJECT_TYPE=personal_access_token ./…                           > pat.ndjson
#   ACTOR_PRINCIPAL=service_account ./…                              > sa.ndjson
#   ./hth-ona-6.01-export-audit-logs.sh --veto       > veto.ndjson
#
# Environment: LIMIT (default 1000), FROM, TO (RFC 3339), SUBJECT_TYPE,
# ACTOR_PRINCIPAL. Subject types: environment, project, secret, user_secret,
# organization_secret, sso_config, personal_access_token, group, runner,
# workflow_execution. Actor principals: user, service_account.

VETO_PRESET=0
[ "${1:-}" = "--veto" ] && VETO_PRESET=1

# T1: audit-logs takes --format, not -o.
args=(audit-logs --format=json "--limit=${LIMIT}")
[ -n "${FROM:-}" ] && args+=("--from=${FROM}")
[ -n "${TO:-}" ]   && args+=("--to=${TO}")
[ -n "${ACTOR_PRINCIPAL:-}" ] && args+=("--actor-principal=${ACTOR_PRINCIPAL}")

if [ "${VETO_PRESET}" -eq 1 ]; then
  # T3: environment is the only server-side narrowing available for Veto.
  args+=("--subject-type=environment")
elif [ -n "${SUBJECT_TYPE:-}" ]; then
  args+=("--subject-type=${SUBJECT_TYPE}")
fi

echo "==> ona ${args[*]}" >&2

raw=""
if ! raw="$("${ONA_BIN}" "${args[@]}" 2>&1)"; then
  case "${raw}" in
    *enterprise*|*failed_precondition*|*FailedPrecondition*)
      echo "PRECONDITION: audit logs are an Enterprise-plan feature and this org is not" >&2
      echo "  entitled — the management plane answered failed_precondition." >&2
      ;;
    *permission*|*Permission*|*forbidden*|*Forbidden*|*403*)
      echo "PRECONDITION: this identity cannot read audit logs. Grant Organization Admin" >&2
      echo "  or the Audit Log Reader role; regular members are refused even for their" >&2
      echo "  own resources." >&2
      ;;
    *)
      echo "PRECONDITION: 'ona audit-logs' failed." >&2
      ;;
  esac
  exit 2
fi

# The CLI's JSON envelope is not published: accept {"entries":[…]}, a bare array,
# or already-NDJSON, and normalise all three to one object per line.
entries="$(printf '%s' "${raw}" \
  | jq -c '(.entries? // .items? // .) | if type=="array" then .[] else . end' 2>/dev/null || true)"

if [ -z "${entries}" ]; then
  echo "0 entries returned for this window. An empty window is a legitimate result," >&2
  echo "not a finding — audit entries are only written for security-relevant and" >&2
  echo "human-meaningful activity." >&2
  exit 0
fi

if [ "${VETO_PRESET}" -eq 1 ]; then
  # T4: match the current Veto kind and both deprecated historical kinds, plus
  # entries whose kind was lost (UNSPECIFIED) but whose action still carries the
  # documented Veto Exec grammar (T5).
  filtered="$(printf '%s\n' "${entries}" | jq -c 'select(
      (.kind // "") == "AUDIT_LOG_ENTRY_KIND_ENVIRONMENT_VETO"
      or (.kind // "") == "AUDIT_LOG_ENTRY_KIND_AGENT_SECURITY_EXEC_BLOCKED"
      or (.kind // "") == "AUDIT_LOG_ENTRY_KIND_AGENT_SECURITY_EXEC_AUDITED"
      or ((.action // "") | startswith("Veto Exec"))
    )')"
  n="$(printf '%s' "${filtered}" | grep -c . || true)"
  echo "veto entries: ${n} (of $(printf '%s' "${entries}" | grep -c . || true) exported)" >&2
  if [ "${n}" -eq 0 ]; then
    echo "NOTE: zero Veto entries does NOT prove the policy is not enforcing — Veto Exec" >&2
    echo "      audit entries are preview-gated and only appear for orgs with the" >&2
    echo "      preview enabled." >&2
  fi
  printf '%s' "${filtered}"
  [ -n "${filtered}" ] && echo
  exit 0
fi

echo "entries: $(printf '%s' "${entries}" | grep -c . || true)" >&2
printf '%s\n' "${entries}"
exit 0

Validation & Testing

  1. A Veto-enforcement event (AUDIT_LOG_ENTRY_KIND_ENVIRONMENT_VETO) appears in the audit log after a blocked execution (2.1) — Veto Exec entries are in preview and appear only for organizations where that preview is enabled
  2. Confirm polled ListAuditLogs entries reach your SIEM
  3. Note: audit-log retention is undocumented (“per your data retention policy”) — export continuously rather than relying on in-platform retention

Expected result: Auditable, SIEM-integrated activity trail with oversight roles assigned. (Audit logs)

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC7.2 System monitoring
NIST 800-53 AU-2 Event logging

6.2 Secure Webhooks with HMAC Verification

Profile Level: L2 (Walk)

Framework Control
CIS Controls 8.2
NIST 800-53 SC-8, AU-10

Description

Where automations are triggered by Git-provider webhooks, Ona is the receiver: you register Ona’s payload URL and the webhook secret in your Git provider, and Ona verifies the HMAC signature on every inbound payload (invalid signatures are rejected). Rotate the secret when needed (rotation invalidates the old secret immediately) and keep webhook management to organization admins — the Automations Admin role deliberately cannot manage webhooks (webhook-scoped roles are RESOURCE_ROLE_WEBHOOK_ADMIN / _VIEWER).

Rationale

Why This Matters:

  • HMAC verification ensures Ona only triggers automations on payloads genuinely from your Git provider, not spoofed calls to the payload URL
  • Immediate old-secret invalidation on rotation closes the window a leaked secret is usable
  • Restricting webhook management to org admins keeps a high-trust integration point out of broader delegated hands

Attack Prevented: Spoofed webhook payloads triggering unintended automation; stale-secret replay

ClickOps Implementation

Step 1: Verify and Restrict Webhooks

  1. Navigate to: AutomationsWebhooks+ Webhook (Automations require the Core tier or above)
  2. Register the generated payload URL and secret in your Git provider (repository or organization scope); Ona verifies the HMAC signature and matches the event to bound automations
  3. Rotate the signing secret on suspicion of exposure (WebhookService/RotateWebhookSecret, or ona webhook secret get <id> to re-read it for the provider side — the old secret invalidates immediately); keep webhook management limited to org admins and review boundWorkflowCount / lastTriggeredAt for orphaned or dormant webhooks

Time to Complete: ~30 minutes

Code Implementation

Code Pack: Terraform
hth-ona-6.02-webhook.tf View source on GitHub ↗
resource "ona_webhook" "scm_events" {
  name        = var.webhook_name
  description = var.webhook_description

  # "organization" scope: one webhook for every repository in the SCM org. Use
  # type = "repository" with repository_scopes instead to narrow it. The two
  # scope attributes are mutually exclusive.
  type         = "organization"
  scm_provider = var.webhook_scm_provider

  organization_scope = {
    host = var.webhook_organization_scope.host
    name = var.webhook_organization_scope.name
  }

  # Bumping this rotates the HMAC signing secret. The previous value stops
  # verifying immediately — there is no overlap window.
  secret_version = var.webhook_secret_version
}

# Ephemeral: retrieves the current signing secret WITHOUT writing it to plan or
# state. Ona audits this access and it requires webhook-update permission.
ephemeral "ona_webhook_secret" "scm_events" {
  webhook_id = ona_webhook.scm_events.id
}

# Consume the secret ONLY from an ephemeral context. Uncomment and point `source`
# at a module of yours that writes it to your receiver's secret store through an
# ephemeral variable or a write-only argument:
#
# module "webhook_secret_target" {
#   source         = "./modules/webhook-secret-target"
#   webhook_secret = ephemeral.ona_webhook_secret.scm_events.secret
# }
#
# It cannot be surfaced as a normal output, and it must never be echoed to logs.
Code Pack: API Script
hth-ona-6.02-audit-webhooks.sh View source on GitHub ↗
audit() {
  resolve_org
  echo "Ona 6.2 — webhook inventory and administration"
  echo "  organization: …${ORG_ID: -6} (tier ${ORG_TIER})"

  # TRAP 4: probe once so the plan boundary is named, not swallowed.
  api "WebhookService/ListWebhooks" '{"pagination":{"pageSize":1}}'
  if [ "${RPC_CODE}" = "400" ] && [ "$(rpc_err)" = "failed_precondition" ]; then
    echo "PRECONDITION: webhooks are Enterprise-only on this organization — $(rpc_msg)" >&2
    echo "  The control cannot be evaluated on this plan tier; that is a licensing boundary," >&2
    echo "  not a misconfiguration." >&2
    exit 2
  fi
  if [ "${RPC_CODE}" != "200" ]; then
    api_strict "WebhookService/ListWebhooks" '{"pagination":{"pageSize":1}}'
  fi

  paginate "WebhookService/ListWebhooks" '{}' "webhooks"
  local hooks="${PAGE_ITEMS}" total idle
  total=$(jq 'length' <<<"${hooks}")
  echo "  webhooks: ${total}"
  # TRAP 1: no secret is read. TRAP 3: coupling and liveness, side by side.
  jq -r '.[] |
    "    - id=…\(((.id // "unknown")[-6:]))" +
    " name=\(.metadata.name // "(unnamed)")" +
    " provider=\(.spec.provider // "WEBHOOK_PROVIDER_UNSPECIFIED")" +
    " type=\(.spec.type // "WEBHOOK_TYPE_UNSPECIFIED")" +
    " boundWorkflowCount=\(.boundWorkflowCount // 0)" +
    " lastTriggeredAt=\(.lastTriggeredAt // "(never)")"' <<<"${hooks}"
  echo "  (the HMAC secret is not read — GetWebhookSecret discloses it in plaintext, TRAP 1)"

  idle=$(jq '[.[] | select((.boundWorkflowCount // 0) == 0) | select((.lastTriggeredAt // "") == "")] | length' <<<"${hooks}")

  # TRAP 5: who may administer webhooks is a RESOURCE role held by a GROUP.
  paginate "GroupService/ListRoleAssignments" \
           '{"filter":{"resourceTypes":["RESOURCE_TYPE_WEBHOOK"]}}' "assignments"
  local ras="${PAGE_ITEMS}" admins
  admins=$(jq '[.[] | select((.resourceRole // "") == "RESOURCE_ROLE_WEBHOOK_ADMIN")] | length' <<<"${ras}")
  echo "  webhook role assignments: total=$(jq 'length' <<<"${ras}") WEBHOOK_ADMIN=${admins}"
  jq -r '.[] | "    - group=…\(((.groupId // "unknown")[-6:])) role=\(.resourceRole // "RESOURCE_ROLE_UNSPECIFIED") derivedFromOrgRole=\(.derivedFromOrgRole // "RESOURCE_ROLE_UNSPECIFIED")"' <<<"${ras}"

  if [ "${total}" -eq 0 ]; then
    echo "COMPLIANT: no webhooks are configured — there is no inbound endpoint to sign or to leak."
    return 0
  fi
  if [ "${idle}" -gt 0 ]; then
    echo "FINDING: ${idle} webhook(s) have no bound workflow AND have never been triggered."
    echo "  Each one is a live inbound endpoint holding a server-generated secret that nothing"
    echo "  consumes. Delete them — DeleteWebhook returns affectedWorkflowIds so you can see what"
    echo "  breaks first — or rotate the secret and bind the workflow it was meant for."
    return 1
  fi
  echo "COMPLIANT: every webhook is either bound to a workflow or has fired at least once."
  echo "  Note: the API contract names no HMAC algorithm and no signature header (TRAP 2), so"
  echo "  receiver-side verification cannot be proven from here — verify it at the receiver."
  return 0
}

Validation & Testing

  1. A payload with an invalid signature is rejected by Ona (no automation triggers)
  2. After rotation, a payload signed with the old secret is rejected

Expected result: Authenticated, admin-managed webhooks. (Webhooks)

Compliance Mappings

Framework Control ID Control Description
NIST 800-53 AU-10 Non-repudiation
NIST 800-53 SC-8 Transmission integrity

7. Compliance Quick Reference

No product-specific benchmark exists for Ona/Gitpod (verified: not in the CIS Benchmarks catalog, no DISA STIG, not in CISA SCuBA scope). The mappings below use control-family catalogs, which justify and map these controls but do not originate configuration steps. When a benchmark is eventually published, re-map against it.

NIST 800-53 Rev 5 Mapping

Control Ona Control Guide Section
IA-2 / IA-8 OIDC SSO 1.1
AC-2 SCIM provisioning + account restriction 1.2
AC-6(1) Least-privilege roles 1.3
IA-5 Token/service-account hygiene; workload identity 1.4, 4.2
CM-7 Veto policy, command deny list, MCP/SCM governance 2.12.5
SI-3 / SI-4 Runtime EDR; dotfiles mitigation 2.6, 3.5
SC-7 Port admission, browser policy, self-hosted runners 3.1, 3.2, 5.1
SC-28 Secret scoping 4.1
AU-2 / AU-6 Audit logging + SIEM 6.1

NIST AI RMF Mapping

Function Ona Control Guide Section
MANAGE-2.3 (mechanisms to supersede/deactivate AI) Veto engine, command deny list 2.1, 2.2
MAP / MEASURE (context + monitoring) Automation limits, audit logging 2.5, 6.1

SOC 2 Trust Services Criteria Mapping

Control ID Ona Control Guide Section
CC6.1 SSO, secrets, tokens 1.1, 4.1
CC6.2 Provisioning, invitations 1.2, 1.5
CC6.6 Port/network boundary, self-hosting 3.1, 5.1
CC7.2 Audit logging 6.1

Appendix A: Edition Notes

Ona gates several hardening controls to the Enterprise plan (OIDC SSO, organization secrets, longer retention options, the ability to change certain default-on policies like the in-environment web browser). Several policies are default-permissive (maximum environment lifetime off/warning-only; web browser enabled on all tiers; project visibility org-wide on non-Enterprise tiers) — treat every default as something to review, not to inherit. Confirm plan-gating and defaults in your own tenant.

Appendix B: Security Incidents (Gitpod-era)

The platform’s disclosed vulnerability history is dominated by session/origin/token-boundary bugs reachable from a single malicious link — directly motivating the port-admission (3.1), token-lifetime (1.4), and SSO (1.1) controls:

  • CVE-2023-0957 (Critical, 9.6) — Cross-Site WebSocket Hijacking from missing Origin validation led to full workspace takeover plus persistent SSH access from one clicked link. Research: Snyk Security Labs (Elliot Ward). Patched SaaS 2023-02-14.
  • CVE-2025-55750 (Medium, 6.5) — Bitbucket OAuth integration leaked valid access tokens via URL fragment through a crafted link.
  • CVE-2024-21583 (Medium) — Cookie tossing via a session cookie missing the __Host- prefix, enabling JWT manipulation from a subdomain-controlling attacker.
  • CVE-2023-32766 (Medium) and CVE-2021-35206 (Medium) — XSS/unvalidated-redirect issues.

Source: NVD keyword search “gitpod”.

Appendix C: References

Official Ona Documentation:

API, CLI, Terraform, SDK:

Hardening Baselines: none product-specific as of this writing (no CIS Benchmark, DISA STIG, or CISA SCuBA baseline for cloud development environments / coding agents).


Changelog

Date Version Maturity Changes Author
2026-08-20 0.2.1 ai-drafted · ai-validated Added ai-validated to this guide’s status set, which now reads ai-drafted + ai-validated — an AI agent exercised this guidance against a live Ona tenant and the guidance survived that contact; no human practitioner has reviewed or applied it, so the guide claims no ni- status, and an AI status never substitutes for one. What was exercised: all 22 controls walked on the live console (app.gitpod.io, a free_ona tenant) and 18 read-only api/ audit packs executed against that tenant, exit codes and sanitized output captured per pack; the OCEAN ona source observed live against the same organization. 18 controls carry a per-requirement badge naming what was run for that control. What was NOT exercised — the badges are absent on purpose: every terraform/ pack was only terraform validated inside Docker and was never applied to any organization, so no Terraform path in this guide has been proven to run; the four cli/ packs were never executed (the ona CLI was not installed on the run host); the three mutating packs (api 1.01 and 1.05, cli 2.01) were deliberately not run, so 1.1 and 1.5 rest on console observation alone; 2.5 and 6.2 are plan-gated to Core+/Enterprise on this tenant and 6.1 has no console surface at all with its API pack returning the Enterprise gate, so all three are unbadged; 2.1 came back PARTIAL because its executables section is Enterprise-gated, so it too is unbadged even though its audit pack ran. Enterprise-gated toggle behaviour throughout remains a documentation claim, not a live one. No control text, control number, or heading changed. Claude Code (Opus 5)
2026-08-19 0.2.0 ai-drafted Live validation pass (validate-hth-guide): every console path re-read off the live console (app.gitpod.io; sidebar Organization / Infrastructure / Agents / Login & Identity) — 11 controls needed path corrections (1.2, 1.5, 2.3, 2.6, 3.1–3.4, 4.1, 4.3 wrong; 2.1 partial — its executables section is plan-gated on the tenant walked); 14 doc-drift corrections (set-default takes a policy id, MCP toggle lives under Agents → Policies, WatchEvents is not the audit trail, API base app.gitpod.io, LLM-provider posture, ona idp token, webhook direction, deprecated OWNER_ONLY, preview gating for automation limits and Veto audit entries, service-account “no expiry” wording); Code Packs landed for the first time — api (read-only audit scripts, executed against a live tenant), terraform (gitpod-io/ona), cli (ona), config (Veto policy YAML, MCP toolDenyList), and Sigma rules on the audit-log schema; controls with no write surface now carry an explicit Automation verdict (2.5, 3.5); Ona added to the CLI inventory. Claude Code (Opus 5) †
2026-08-08 0.1.0 ai-drafted Initial guide — 20 controls across identity, agent guardrails (Veto/command-deny/MCP), environment & network policy, secrets, self-hosted runners, and audit logging. Authored per the create-hth-guide playbook: every control traces to fetched Ona documentation; no product benchmark exists so mappings use NIST 800-53/AI RMF/SOC 2 catalogs; the Gitpod-era CVE history grounds the rationale. Automation surfaces stated honestly (Security Policy API/CLI for ports+executables, EventService for audit) — Code Packs deferred to a follow-up. Unverified “Ona joining OpenAI” claim deliberately excluded. Claude Code (Opus 4.8) †

† Author inferred, not recorded. This row predates the Author column, so the value comes from the authoring session’s commit window (every other guide authored in that window names the same tool and model, with no dissenting entry). Undaggered rows are attributed from a sibling guide that recorded its author explicitly in the same commit, or from the row’s own text.

Contributing

Found an issue or want to improve this guide? Open an issue or PR on GitHub. Console paths should be verified against the live app.ona.com console (Ona’s own docs give inconsistent locations for the policy surface), and all code belongs in Code Packs (no inline code blocks). Follow the create-hth-guide and create-code-pack playbooks.