Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
343ff7e
chore(gator): add gator gate skill
johntmyers Jun 3, 2026
98d1487
chore(gator): add sandbox launcher scaffold
johntmyers Jun 3, 2026
f5e465f
chore(gator): add codex image and docs checks
johntmyers Jun 3, 2026
3641b0a
chore(gator): fold approved provider policy rules
johntmyers Jun 3, 2026
ec1a19d
chore(gator): add deterministic reviewer runner
johntmyers Jun 4, 2026
726ac78
chore(gator): clarify ok-to-test comments
johntmyers Jun 4, 2026
ef37c1f
chore(gator): structure launcher harnesses
johntmyers Jun 4, 2026
5d5b754
chore(gator): require e2e for dependabot
johntmyers Jun 4, 2026
37867fa
chore(gator): add codex refresh profile
johntmyers Jun 4, 2026
d92acb3
chore(gator): wip manifest agent launcher
johntmyers Jun 5, 2026
a045a25
feat(agents): supervise watch cycles in sandbox
johntmyers Jun 5, 2026
b9f6b3e
fix(agents): preserve gateway refresh state
johntmyers Jun 5, 2026
09ac5d5
fix(gator): continue human response threads
johntmyers Jun 6, 2026
4a30f30
fix(agents): keep watch supervisor retrying
johntmyers Jun 7, 2026
6c3ded0
fix(agents): use refreshed Codex credential aliases
johntmyers Jun 8, 2026
8b49cc2
fix(gator): avoid misleading gh auth checks
johntmyers Jun 9, 2026
2215eb2
docs(agents): remove architecture build update
johntmyers Jun 9, 2026
0b8790a
fix(gator): use REST-backed GitHub writes
johntmyers Jun 9, 2026
293b9e2
fix(agents): bake immutable agent payloads
johntmyers Jun 9, 2026
2ca2520
fix(agents): upload writable agent workspace
johntmyers Jun 9, 2026
aee601c
fix(agents): surface gator watch progress
elezar Jun 10, 2026
eb925e0
fix(agents): prevent codex stdin hang
elezar Jun 10, 2026
14cdeff
fix(agents): align codex subagent input
elezar Jun 10, 2026
ef48e39
fix(agents): heartbeat during active cycles
elezar Jun 10, 2026
882b070
fix(agents): clean up heartbeat sleep
elezar Jun 10, 2026
acc9c9f
fix(agents): disable gh telemetry in codex harness
elezar Jun 10, 2026
3f81f6e
fix(agents): reconcile closed gator PRs
elezar Jun 10, 2026
4e2f9f9
fix(agents): query closed gator PR labels separately
elezar Jun 10, 2026
5827f36
fix(agents): tolerate rotated credential placeholders
johntmyers Jun 15, 2026
005b8b9
fix(agents): enforce gator same-sha comment guard
johntmyers Jun 22, 2026
859e290
docs(agents): scope gator trusted commentary
johntmyers Jun 22, 2026
de4a121
fix(gator): treat reviewer failures as transient
elezar Jun 26, 2026
db673cc
feat(agents): refine gator supervised workflow
johntmyers Jun 26, 2026
cc78ee2
fix(agents): stream codex prompts via stdin
johntmyers Jun 26, 2026
5c9c10b
docs(agents): clarify trusted gator responses
johntmyers Jun 26, 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
856 changes: 856 additions & 0 deletions .agents/skills/gator-gate/SKILL.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,12 @@ provider token grants mount a SPIFFE Workload API socket, the socket path must
live under a dedicated directory. Children also enter a private mount namespace
where that socket directory is hidden before privilege drop.

Credential placeholders in proxied HTTP requests can be resolved by the proxy
when policy allows the target endpoint. For GCP providers, a loopback metadata
server inside the network namespace serves placeholders to SDKs that bypass the
proxy (e.g. Go's `cloud.google.com/go/compute/metadata`). Secrets must not be
logged in OCSF or plain tracing output.
logged in OCSF or 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.

Provider profiles can also declare dynamic token grants. For matching HTTP
endpoints, the supervisor obtains a SPIFFE JWT-SVID from the local Workload API,
Expand Down
50 changes: 50 additions & 0 deletions crates/openshell-core/src/provider_credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,4 +593,54 @@ mod tests {
"suppressed key must not reappear after install_environment"
);
}

#[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(),
HashMap::new(),
);

for revision in 11..20 {
state.install_environment(
revision,
HashMap::from([("GITHUB_TOKEN".to_string(), format!("new-{revision}"))]),
HashMap::new(),
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(),
HashMap::new(),
);

for revision in 11..20 {
state.install_environment(
revision,
HashMap::from([("OTHER_TOKEN".to_string(), format!("other-{revision}"))]),
HashMap::new(),
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-core/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 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 @@ -475,6 +486,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 @@ -1050,6 +1089,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 @@ -1083,6 +1134,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
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 @@ -169,12 +169,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 @@ -595,6 +596,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 @@ -1135,6 +1135,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 @@ -1654,6 +1663,19 @@ fn is_kubernetes_service_host(host: &str) -> bool {
(is_service_name || is_cluster_local_service) && labels.iter().all(|label| !label.is_empty())
}

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 @@ -2454,7 +2476,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
- name: path_key
env_vars: [PATH_TOKEN]
Expand Down Expand Up @@ -2482,6 +2504,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(&"path_template is required for path auth"));
assert!(messages.iter().any(|message| {
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 @@ -273,7 +273,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, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement.
`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. 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
5 changes: 5 additions & 0 deletions providers/codex.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,9 @@ endpoints:
protocol: rest
access: read-write
enforcement: enforce
- host: files.openai.com
port: 443
protocol: rest
access: read-write
enforcement: enforce
binaries: [/usr/bin/codex, /usr/local/bin/codex, /usr/lib/node_modules/@openai/**]
Loading
Loading