Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
91fd9d4
chore(gator): add gator gate skill
johntmyers Jun 3, 2026
1a21bba
chore(gator): add sandbox launcher scaffold
johntmyers Jun 3, 2026
ba80e91
chore(gator): add codex image and docs checks
johntmyers Jun 3, 2026
9825e16
chore(gator): fold approved provider policy rules
johntmyers Jun 3, 2026
567bc88
chore(gator): add deterministic reviewer runner
johntmyers Jun 4, 2026
3dd6607
chore(gator): clarify ok-to-test comments
johntmyers Jun 4, 2026
c3066ac
chore(gator): structure launcher harnesses
johntmyers Jun 4, 2026
9141c1b
chore(gator): require e2e for dependabot
johntmyers Jun 4, 2026
b875880
chore(gator): add codex refresh profile
johntmyers Jun 4, 2026
c7306cd
chore(gator): wip manifest agent launcher
johntmyers Jun 5, 2026
d810646
feat(agents): supervise watch cycles in sandbox
johntmyers Jun 5, 2026
3b111f1
fix(agents): preserve gateway refresh state
johntmyers Jun 5, 2026
1057af2
fix(gator): continue human response threads
johntmyers Jun 6, 2026
8b83535
fix(agents): keep watch supervisor retrying
johntmyers Jun 7, 2026
6846b3b
fix(agents): use refreshed Codex credential aliases
johntmyers Jun 8, 2026
34c571e
fix(gator): avoid misleading gh auth checks
johntmyers Jun 9, 2026
10bc74a
docs(agents): remove architecture build update
johntmyers Jun 9, 2026
87b2a10
fix(gator): use REST-backed GitHub writes
johntmyers Jun 9, 2026
c479d52
fix(agents): bake immutable agent payloads
johntmyers Jun 9, 2026
7c3a2eb
fix(agents): upload writable agent workspace
johntmyers Jun 9, 2026
b20fa3f
fix(agents): surface gator watch progress
elezar Jun 10, 2026
be52f1c
fix(agents): prevent codex stdin hang
elezar Jun 10, 2026
40c2314
fix(agents): align codex subagent input
elezar Jun 10, 2026
2236088
fix(agents): heartbeat during active cycles
elezar Jun 10, 2026
d97bfb9
fix(agents): clean up heartbeat sleep
elezar Jun 10, 2026
6a4d720
fix(agents): disable gh telemetry in codex harness
elezar Jun 10, 2026
b886cdf
fix(agents): reconcile closed gator PRs
elezar Jun 10, 2026
9ec28c8
fix(agents): query closed gator PR labels separately
elezar Jun 10, 2026
a89ff4d
fix(agents): tolerate rotated credential placeholders
johntmyers Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
759 changes: 759 additions & 0 deletions .agents/skills/gator-gate/SKILL.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ relay settings.

Credential placeholders in proxied HTTP requests can be resolved by the proxy
when policy allows the target endpoint. Secrets must not be logged in OCSF or
plain tracing output.
plain tracing output. The supervisor uses revision-scoped placeholders for
rotating provider credentials; provider environment keys beginning with
`v<digits>_` are reserved for that placeholder namespace.

## Connect and Logs

Expand Down
41 changes: 35 additions & 6 deletions crates/openshell-ocsf/src/format/shorthand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,13 @@ impl OcsfEvent {
(false, true) => format!(" {action}"),
(false, false) => format!(" {action}{arrow}"),
};
let message_ctx =
if detail.is_empty() && rule_ctx.is_empty() && reason_ctx.is_empty() {
message_tag(&e.base)
} else {
String::new()
};
let include_message = activity == "FAIL"
|| (detail.is_empty() && rule_ctx.is_empty() && reason_ctx.is_empty());
let message_ctx = if include_message {
message_tag(&e.base)
} else {
String::new()
};
format!("NET:{activity} {sev}{detail}{rule_ctx}{reason_ctx}{message_ctx}")
}

Expand Down Expand Up @@ -580,6 +581,34 @@ mod tests {
);
}

#[test]
fn test_network_activity_shorthand_fail_shows_message_with_destination() {
let event = OcsfEvent::NetworkActivity(NetworkActivityEvent {
base: {
let mut b = base(4001, "Network Activity", 4, "Network Activity", 6, "Fail");
b.severity = crate::enums::SeverityId::Low;
b.set_message("TLS relay error: unexpected eof");
b
},
src_endpoint: None,
dst_endpoint: Some(Endpoint::from_domain("api.github.com", 443)),
proxy_endpoint: None,
actor: None,
firewall_rule: None,
connection_info: None,
action: None,
disposition: None,
observation_point_id: None,
is_src_dst_assignment_known: None,
});

let shorthand = event.format_shorthand();
assert_eq!(
shorthand,
"NET:FAIL [LOW] api.github.com:443 [msg:TLS relay error: unexpected eof]"
);
}

#[test]
fn test_network_activity_shorthand_shows_message_when_no_key_fields() {
let event = OcsfEvent::NetworkActivity(NetworkActivityEvent {
Expand Down
29 changes: 28 additions & 1 deletion crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,15 @@ pub fn validate_profile_set(
"credentials.env_vars",
"credential env var must not be empty",
));
} else if uses_reserved_placeholder_revision_namespace(env_var.trim()) {
diagnostics.push(ProfileValidationDiagnostic::error(
source,
profile_id,
"credentials.env_vars",
format!(
"credential env var '{env_var}' uses reserved OpenShell placeholder revision namespace"
),
));
} else if !env_vars.insert(env_var.trim().to_string()) {
diagnostics.push(ProfileValidationDiagnostic::error(
source,
Expand Down Expand Up @@ -1106,6 +1115,19 @@ fn endpoint_is_valid(endpoint: &EndpointProfile) -> bool {
(1..=65_535).contains(&endpoint.port)
}

fn uses_reserved_placeholder_revision_namespace(key: &str) -> bool {
let Some(suffix) = key.strip_prefix('v') else {
return false;
};
let Some((revision, key)) = suffix.split_once('_') else {
return false;
};
!revision.is_empty()
&& revision.bytes().all(|b| b.is_ascii_digit())
&& !key.is_empty()
&& key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}

static DEFAULT_PROFILES: OnceLock<Vec<ProviderTypeProfile>> = OnceLock::new();

#[must_use]
Expand Down Expand Up @@ -1456,7 +1478,7 @@ credentials:
env_vars: [BROKEN_TOKEN]
auth_style: query
- name: api_key
env_vars: [BROKEN_TOKEN, ""]
env_vars: [BROKEN_TOKEN, "", v10_GITHUB_TOKEN]
auth_style: unknown
discovery:
credentials: [api_key, missing_key]
Expand All @@ -1477,6 +1499,11 @@ binaries: ["", /usr/bin/broken]
assert!(messages.contains(&"duplicate credential name: api_key"));
assert!(messages.contains(&"duplicate credential env var 'BROKEN_TOKEN'"));
assert!(messages.contains(&"credential env var must not be empty"));
assert!(
messages.iter().any(
|message| message.contains("reserved OpenShell placeholder revision namespace")
)
);
assert!(messages.contains(&"query_param is required for query auth"));
assert!(messages.contains(&"unsupported auth_style: unknown"));
assert!(messages.contains(&"unknown discovery credential: missing_key"));
Expand Down
46 changes: 46 additions & 0 deletions crates/openshell-sandbox/src/provider_credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,50 @@ mod tests {
Some("new")
);
}

#[test]
fn stale_generation_falls_back_to_current_credential_after_retention_window() {
let state = ProviderCredentialState::from_environment(
10,
HashMap::from([("GITHUB_TOKEN".to_string(), "old".to_string())]),
HashMap::new(),
);

for revision in 11..20 {
state.install_environment(
revision,
HashMap::from([("GITHUB_TOKEN".to_string(), format!("new-{revision}"))]),
HashMap::new(),
);
}

let resolver = state.resolver().expect("resolver");
assert_eq!(
resolver.resolve_placeholder("openshell:resolve:env:v10_GITHUB_TOKEN"),
Some("new-19")
);
}

#[test]
fn stale_removed_generation_fails_closed_after_retention_window() {
let state = ProviderCredentialState::from_environment(
10,
HashMap::from([("GITHUB_TOKEN".to_string(), "old".to_string())]),
HashMap::new(),
);

for revision in 11..20 {
state.install_environment(
revision,
HashMap::from([("OTHER_TOKEN".to_string(), format!("other-{revision}"))]),
HashMap::new(),
);
}

let resolver = state.resolver().expect("retained resolver");
assert_eq!(
resolver.resolve_placeholder("openshell:resolve:env:v10_GITHUB_TOKEN"),
None
);
}
}
77 changes: 75 additions & 2 deletions crates/openshell-sandbox/src/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ impl SecretResolver {
let mut by_placeholder = HashMap::with_capacity(provider_env.len());

for (key, value) in provider_env {
if uses_reserved_revision_namespace(&key) {
tracing::warn!(
provider_env_key = %key,
"skipping provider credential env var in reserved placeholder namespace"
);
continue;
}
let placeholder = placeholder_for_env_key_for_revision(&key, revision);
let secret = SecretValue {
value,
Expand All @@ -192,7 +199,11 @@ impl SecretResolver {
}
}

(child_env, Some(Self { by_placeholder }))
if by_placeholder.is_empty() {
(child_env, None)
} else {
(child_env, Some(Self { by_placeholder }))
}
}

pub(crate) fn merge<'a>(resolvers: impl IntoIterator<Item = &'a Self>) -> Option<Self> {
Expand All @@ -215,7 +226,7 @@ impl SecretResolver {
let secret = if let Some(secret) = self.by_placeholder.get(value) {
secret
} else {
let key = alias_env_key(value)?;
let key = revisioned_placeholder_env_key(value).or_else(|| alias_env_key(value))?;
let canonical = placeholder_for_env_key(key);
self.by_placeholder.get(&canonical)?
};
Expand Down Expand Up @@ -469,6 +480,34 @@ fn alias_env_key(token: &str) -> Option<&str> {
(key_end == token.len() && key_end > key_start).then_some(&token[key_start..key_end])
}

fn revisioned_placeholder_env_key(token: &str) -> Option<&str> {
let suffix = token.strip_prefix(PLACEHOLDER_PREFIX)?;
let suffix = suffix.strip_prefix('v')?;
let underscore = suffix.find('_')?;
let (revision, key) = suffix.split_at(underscore);
if revision.is_empty() || !revision.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let key = &key[1..];
if key.is_empty() || !key.bytes().all(is_env_key_char) {
return None;
}
Some(key)
}

fn uses_reserved_revision_namespace(key: &str) -> bool {
let Some(suffix) = key.strip_prefix('v') else {
return false;
};
let Some((revision, key)) = suffix.split_once('_') else {
return false;
};
!revision.is_empty()
&& revision.bytes().all(|b| b.is_ascii_digit())
&& !key.is_empty()
&& key.bytes().all(is_env_key_char)
}

fn token_boundary_ok(text: &str, abs_start: usize, token_end: usize, token: &str) -> bool {
if token.starts_with(PLACEHOLDER_PREFIX) {
return token_end == text.len()
Expand Down Expand Up @@ -1044,6 +1083,18 @@ mod tests {
);
}

#[test]
fn provider_env_rejects_revision_namespace_keys() {
let (child_env, resolver) = SecretResolver::from_provider_env(
[("v10_GITHUB_TOKEN".to_string(), "ambiguous".to_string())]
.into_iter()
.collect(),
);

assert!(child_env.is_empty());
assert!(resolver.is_none());
}

#[test]
fn rewrites_exact_placeholder_header_values() {
let (_, resolver) = SecretResolver::from_provider_env(
Expand Down Expand Up @@ -1077,6 +1128,28 @@ mod tests {
);
}

#[test]
fn rewrites_stale_revisioned_bearer_placeholder_to_current_alias() {
let (_, resolver) = SecretResolver::from_provider_env_for_revision_with_current_aliases(
[("GITHUB_TOKEN".to_string(), "ghp-current".to_string())]
.into_iter()
.collect(),
HashMap::new(),
42,
true,
);
let resolver = resolver.expect("resolver");

assert_eq!(
rewrite_header_line_checked(
"Authorization: Bearer openshell:resolve:env:v10_GITHUB_TOKEN",
&resolver,
)
.expect("stale revision should fall back to current alias"),
"Authorization: Bearer ghp-current"
);
}

#[test]
fn rewrites_provider_shaped_alias_header_values() {
let (_, resolver) = SecretResolver::from_provider_env(
Expand Down
2 changes: 1 addition & 1 deletion docs/sandboxes/providers-v2.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ binaries:

`category` groups profiles in `openshell provider list-profiles`. Use one of the values in the category enum.

`credentials` declares the credential names, environment variables, auth metadata, and optional refresh metadata for the provider type. The current runtime still exposes configured credential keys as placeholder environment variables and resolves placeholders in outbound HTTP requests.
`credentials` declares the credential names, environment variables, auth metadata, and optional refresh metadata for the provider type. The current runtime still exposes configured credential keys as placeholder environment variables and resolves placeholders in outbound HTTP requests. Credential environment variable names must not use the reserved `v<digits>_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders.

`discovery` controls what `--from-existing` scans when
`providers_v2_enabled=true`. Each entry in `discovery.credentials` must name a
Expand Down
94 changes: 94 additions & 0 deletions openshell-agents/Dockerfile.gator

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Is the expectation that each agent will have it's own Dockerfile? If so, does it make sense to move this to openshell-agents/gator/Dockerfile instead? Alternatively, we may need to update the README.md to show an (optional?) Dockerfile.agent.

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# syntax=docker/dockerfile:1

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Gator sandbox image.
#
# This mirrors the OpenShell Community base image's core system and developer
# tooling, but keeps the initial agent surface focused on Codex + GitHub tooling
# for the gator-gate workflow.

FROM nvcr.io/nvidia/base/ubuntu:noble-20251013 AS system

ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /sandbox

# Core system dependencies copied from the community base sandbox image.
# iproute2: network namespace management (ip netns, veth pairs)
# iptables: legacy bypass detection (kept for transition)
# nftables: bypass detection; log + reject rules for direct connection diagnostics
# dnsutils: dig, nslookup
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
dnsutils \
iproute2 \
iptables \
nftables \
iputils-ping \
net-tools \
netcat-openbsd \
openssh-sftp-server \
procps \
traceroute \
&& rm -rf /var/lib/apt/lists/*

RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supervisor && \
groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox

FROM system AS devtools

# Node.js 22 + build toolchain. Keep the default apt installs aligned with the
# community base image, then add the small CLI tools gator commonly needs.
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y --no-install-recommends \
build-essential \
git \
jq \
less \
nodejs=22.22.1-1nodesource1 \
ripgrep \
vim-tiny \
nano \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g npm@11.11.0

# GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list && \
apt-get update && apt-get install -y --no-install-recommends gh && \
rm -rf /var/lib/apt/lists/*

COPY runtime/harnesses/codex/install-codex.sh /usr/local/bin/install-codex.sh
ARG CODEX_VERSION=latest
RUN chmod 755 /usr/local/bin/install-codex.sh && \
/usr/local/bin/install-codex.sh "$CODEX_VERSION"

# Provider profiles include both /usr/bin and /usr/local/bin variants for common
# tools. Create the /usr/local/bin aliases in this image so sandbox symlink
# resolution does not warn about missing alternate paths during policy reloads.
RUN ln -sf /usr/bin/gh /usr/local/bin/gh && \
ln -sf /usr/bin/git /usr/local/bin/git && \
ln -sf /usr/bin/codex /usr/local/bin/codex

FROM devtools AS final

ENV PATH="/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin"

RUN mkdir -p /etc/openshell
COPY gator/policy.yaml /etc/openshell/policy.yaml

RUN printf 'export PATH="/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin"\nexport PS1="\\u@\\h:\\w\\$ "\n' \
> /sandbox/.bashrc && \
printf '[ -f ~/.bashrc ] && . ~/.bashrc\n' > /sandbox/.profile && \
chown -R sandbox:sandbox /sandbox

USER sandbox

ENTRYPOINT ["/bin/bash"]
Loading
Loading