Ona Hardening Guide
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.
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.comandapp.gitpod.ioare both live:https://app.ona.com/apianswers 308 tohttps://app.gitpod.io/api(andcurl -Ldrops the bearer token on that cross-host hop), the OIDC issuer (iss) ishttps://app.gitpod.io, and the API namespace remainsgitpod.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
- Identity & Access Controls
- Agent Governance & Guardrails
- Environment & Network Security
- Data & Secrets
- Deployment Architecture
- Monitoring & Detection
- 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
- 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
- Navigate to: Settings → Login & Identity → Login 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
- Add your OIDC provider (issuer URL, client ID, client secret) and assign the verified email domain(s) to it
- 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
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
# 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
- A user on the verified domain is redirected to your IdP at login
- A CEL rule denies a test principal that fails its condition
- 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
- Navigate to: Settings → Login & Identity → SCIM → Configuration → Set up SCIM
- Generate the SCIM endpoint and bearer token. The token is shown once and is unrecoverable — store it in your secrets manager immediately.
- Configure the SCIM integration in your IdP with the endpoint and token. (An SCIM configuration created through the API is stored disabled until
UpdateSCIMConfigurationsetsenabled: true.)
Step 2: Restrict Account Creation
- 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
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
# 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
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
- Remove a test user from the IdP and confirm deprovisioning in Ona
- 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
- Navigate to: Settings → Organization → Members → Groups tab (delegated roles are Enterprise-tier)
- 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_ADMINandRESOURCE_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
- Review each group’s effective permissions, remembering the highest level across a user’s groups wins; the
derivedFromOrgRolefield on a role assignment separates inherited grants from ad-hoc shares - Keep full organization admin to a small named set
Time to Complete: ~30 minutes
Code Implementation
Code Pack: Terraform
# 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
# 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
- A Runners Admin cannot change identity or policy settings
- 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
- Navigate to: Settings → Organization → Members → Service Accounts tab (Core tier and above)
- Set token validity to the shortest that works (30/60/90 days or 1 year) — never “no expiry”. The API contract itself requires
validUntilon the service account; only the token can be minted without an expiry, so auditexpiresAton every token. - 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
- In the user menu → Account → Personal access tokens → New 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 - 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
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
# 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
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
- No service account carries an indefinite token
- 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
- The domain allow-list is the organization’s
inviteDomains(API:UpdateOrganizationwithinviteDomains: {domains: []}; readable viaGetOrganization). Where your tier exposes it in the console (Settings → Organization → Members), 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
- Navigate to: Settings → Login & Identity → Login Configuration → Organization login link — this is the shareable join link; regenerate it if it may have leaked (API:
CreateOrganizationInviteissues 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
# 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
- A new address on your domain is not auto-admitted after the whitelist is removed
- 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
- Navigate to: Settings → Infrastructure → Security (Enterprise tier — on lower tiers the page shows only the security-agents section)
- 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_BLOCKin the YAML/API) - Leave the default effect at allow (
defaultEffectomitted orEFFECT_ALLOW) and block specific high-risk binaries; start in audit, then move to block once the audit trail is clean - 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-only → create policy.yaml -o yaml → set-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
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
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
# 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
# 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)
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
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
- In audit mode, confirm the intended binaries appear as
AUDIT_LOG_ENTRY_KIND_ENVIRONMENT_VETOaudit events (6.1) — Veto Exec enforcement entries are in preview and appear only for organizations where that preview is enabled - In block mode, an agent attempt to run a blocked binary fails and is logged
- 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
- Navigate to: Settings → Agents → Policies → Command deny list
- Add wildcard patterns one per line (start with destructive and network-exfil patterns) and Save
- Confirm the list applies to new agent sessions
Time to Complete: ~20 minutes
Code Implementation
Code Pack: Terraform
# 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
# 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
detection:
selection:
subjectType: 'RESOURCE_TYPE_ORGANIZATION_POLICY'
condition: selection
fields:
- createdAt
- actorId
- actorPrincipal
- subjectId
- action
- kind
Validation & Testing
- Start a new agent session and confirm a denied command is refused
- 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
- Navigate to: Settings → Agents → Policies → Agent capabilities → Model Context Protocol (MCP)
- If your organization cannot vet MCP servers individually, turn MCP off — repo
.ona/mcp-config.jsonfiles will be ignored and external MCP blocked - If MCP stays enabled, curate the org-wide integrations at Settings → Organization → Integrations (Add MCP integration), treat every repo MCP config as untrusted input, use
toolDenyListper server, and pair with the command deny list (2.2) and Veto (2.1)
Time to Complete: ~15 minutes
Code Implementation
Code Pack: Terraform
# 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
{
"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
# 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
- With MCP disabled, an agent in a repo carrying
.ona/mcp-config.jsondoes 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
- Navigate to: Settings → Agents → Policies → Agent capabilities → Source Code Management Tools (SCM) → Restrict access to
- 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
- 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) - 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
# 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
# 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
- With SCM tools disabled, an agent cannot open a PR via API
- 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
- Navigate to: Settings → Agents → Policies → Automations — Note: automation organization policy controls are in preview and available only to selected Enterprise organizations; the section is absent on other tiers
- 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
- A non-admin member is blocked from exceeding the active-automation cap
- 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
- Navigate to: Settings → Infrastructure → Security → Security agents → CrowdStrike Falcon (Enterprise tier)
- 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
# 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
- Confirm the falcon-sensor is present in a newly created environment
- 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_only → organization → everyone (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_onlykeeps 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
- Navigate to: Settings → Infrastructure → Policies → Environment access → Port sharing / Maximum port admission level (Enterprise tier; the observed default is Anyone (no login required), i.e. no cap)
- Set Maximum port admission level to the lowest that supports collaboration needs (prefer
creator_onlyororganization), or turn Port sharing off entirely (portSharingDisabled: truetakes 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
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
# 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
# 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
- A member attempting to expose a port above the cap sees “restricted by policy”
ona environment port open <port> --admission everyoneis 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
localhostservices 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
- Navigate to: Settings → Infrastructure → Policies → Environment access → Web browser (“Allow members to open the built-in browser panel. VS Code Browser is not affected.”)
- 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
# 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
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
- With the browser disabled, the agent’s browse-web skill is unavailable
- 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
- Navigate to: Settings → Infrastructure → Policies → Environment lifecycle
- 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
- Set the Auto-stop timeout ceiling (default user value is 30 min if unset; the observed org default is “No max timeout”)
- Set Archive inactive and Auto-delete archived to the shortest that meets your workflow — deletion is irreversible. API trap: on
UpdateOrganizationPoliciesa duration of0smeans no limit formaximumEnvironmentTimeout,maximumEnvironmentLifetime, anddeleteArchivedEnvironmentsAfter— zeroing these fields weakens the org;archiveEnvironmentsAftermust be a whole number of days.
Time to Complete: ~20 minutes
Code Implementation
Code Pack: Terraform
# 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
# 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
- An expired environment cannot be restarted under strict enforcement
- 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
- Navigate to: Settings → Infrastructure → Policies → Environment setup
- Enable Only admins can start from scratch (API/Terraform
disableFromScratch); consider Only admins can create projects andmembersRequireProjectsalongside it
Time to Complete: ~10 minutes
Code Implementation
Code Pack: Terraform
# 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
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
- 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 → Account → Preferences → Dotfiles 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)
- Use the Veto executable policy (2.1) to AUDIT then BLOCK high-risk binaries the bootstrap might invoke (network and package tooling)
- Use the command deny list (2.2) for destructive/exfil patterns
- 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
- A Veto-blocked binary invoked by a dotfiles script is blocked and logged
- 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
- Define secrets at user (user menu → Account → Secrets) or project scope wherever possible rather than organization scope
- For unavoidable org secrets (Settings → Infrastructure → Secrets, Enterprise tier), document that they are readable org-wide and reserve them for genuinely shared, lower-sensitivity values
- 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
# 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
detection:
selection:
kind: 'AUDIT_LOG_ENTRY_KIND_CREDENTIAL_ACCESS'
condition: selection
fields:
- createdAt
- actorId
- actorPrincipal
- subjectId
- subjectType
- action
Validation & Testing
- A project-scoped secret is not visible to environments outside that project
- 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
- In your cloud IAM, register
https://app.gitpod.ioas the OIDC issuer (that is theissclaim even for an org that only ever seesona.combranding) and constrain the trust policy using the sub claims you selected (organization_id,project_id,runner_id,environment_id,creator_email, …) - In Ona, navigate to Settings → Login & Identity → OIDC Tokens (Enterprise tier; API
UpdateOIDCConfig): choose v3 tokens and setextraSubFieldsso thesubpins a specific project/runner/environment rather than the whole org - From agents/automations, retrieve a token with
ona idp token --audience <audience>or authenticate directly withona 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
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
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
# 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
- An agent obtains short-lived cloud credentials via token exchange with no stored long-lived key
- 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
- Navigate to: Settings → Infrastructure → Runners → open the runner → Configure repository access → New provider (Ona Cloud runners ship github/gitlab/bitbucket via OAuth; private or self-hosted Git servers require Enterprise)
- Prefer OAuth over PAT where possible (
SCMIntegration.patallows PAT auth — leave it off whensupportsOauth2is true); where a PAT is used, mint it on a least-privilege service identity - Remove providers/integrations that are no longer needed to revoke their stored credentials;
CheckRepositoryAccessandListSCMOrganizationsanswer “what can this runner actually reach”
Time to Complete: ~30 minutes
Code Implementation
Code Pack: Terraform
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
# 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
- Deleting an unused integration removes its stored credentials (access no longer works)
- 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
- Navigate to: Settings → Infrastructure → Runners → Set up a new runner → AWS or GCP (Enterprise tier; Azure is waitlisted) and deploy it in your VPC
- 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. - Configure repository access on the runner (4.3); block the deprecated local-runner path with the org policy
allowLocalRunners: false(or set the system-managedRUNNER_KIND_LOCAL_CONFIGURATIONrunner’s desired phase to STOPPED)
Time to Complete: ~half day including cloud networking
Code Implementation
Code Pack: Terraform
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
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
- Confirm source code and SCM credentials remain in your VPC (never transit the management plane)
- 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
- 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
- Export via CLI (
ona audit-logs --format=json --limit=1000) or the API (POST /api/gitpod.v1.EventService/ListAuditLogs, basehttps://app.gitpod.io— or your custom dashboard domain), filtering onactorIds,actorPrincipals(USER/SERVICE_ACCOUNT/RUNNER/…),subjectTypes, andfrom/to(RFC 3339,[from, to)half-open; max 100 entries per page, 25 values per filter) - For SIEM ingestion, poll
ListAuditLogson a schedule — it is the only surface carrying actor attribution.WatchEventsstreams 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
# 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
# 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
- 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 - Confirm polled
ListAuditLogsentries reach your SIEM - 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
- Navigate to: Automations → Webhooks → + Webhook (Automations require the Core tier or above)
- 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
- Rotate the signing secret on suspicion of exposure (
WebhookService/RotateWebhookSecret, orona 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 reviewboundWorkflowCount/lastTriggeredAtfor orphaned or dormant webhooks
Time to Complete: ~30 minutes
Code Implementation
Code Pack: Terraform
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
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
- A payload with an invalid signature is rejected by Ona (no automation triggers)
- 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.1–2.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:
- Documentation home · full page index (llms.txt)
- Guardrails overview · Veto · Executable deny list · Command deny list
- Organization policies overview · Best practices
- SSO · SCIM · Audit logs · Secrets
API, CLI, Terraform, SDK:
- API reference · Organization policies API · Security Policy API · Audit-log API
- Ona CLI · CLI reference · Terraform provider
gitpod-io/ona(beta, Linux-only packages) · SDKs · MCP configuration
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.