Compare commits

...

14 Commits

Author SHA1 Message Date
Eric Traut
d681ed2f2b Fixed lint 2026-01-05 18:04:53 -07:00
Eric Traut
ddcb82c9b5 Code review feedback 2026-01-05 17:58:09 -07:00
Eric Traut
35567b3a0b Updated AGENTS.md to reflect code simplification rules 2026-01-05 16:02:30 -07:00
Eric Traut
73a1642fd4 Code review feedback 2026-01-05 15:57:16 -07:00
Eric Traut
ef60a312c5 Merge branch 'main' into etraut/concurrent_refresh 2026-01-05 15:56:54 -07:00
Curtis 'Fjord' Hawthorne
5f8776d34d Allow global exec flags after resume and fix CI codex build/timeout (#8440)
**Motivation**
- Bring `codex exec resume` to parity with top‑level flags so global
options (git check bypass, json, model, sandbox toggles) work after the
subcommand, including when outside a git repo.

**Description**
- Exec CLI: mark `--skip-git-repo-check`, `--json`, `--model`,
`--full-auto`, and `--dangerously-bypass-approvals-and-sandbox` as
global so they’re accepted after `resume`.
- Tests: add `exec_resume_accepts_global_flags_after_subcommand` to
verify those flags work when passed after `resume`.

**Testing**
- `just fmt`
- `cargo test -p codex-exec` (pass; ran with elevated perms to allow
network/port binds)
- Manual: exercised `codex exec resume` with global flags after the
subcommand to confirm behavior.
2026-01-05 22:12:09 +00:00
xl-openai
58a91a0b50 Use ConfigLayerStack for skills discovery. (#8497)
Use ConfigLayerStack to get all folders while loading skills.
2026-01-05 13:47:39 -08:00
Matthew Zeng
c29afc0cf3 [device-auth] Update login instruction for headless environments. (#8753)
We've seen reports that people who try to login on a remote/headless
machine will open the login link on their own machine and got errors.
Update the instructions to ask those users to use `codex login
--device-auth` instead.

<img width="1434" height="938" alt="CleanShot 2026-01-05 at 11 35 02@2x"
src="https://github.com/user-attachments/assets/2b209953-6a42-4eb0-8b55-bb0733f2e373"
/>
2026-01-05 13:46:42 -08:00
Michael Bolin
cafb07fe6e feat: add justification arg to prefix_rule() in *.rules (#8751)
Adds an optional `justification` parameter to the `prefix_rule()`
execpolicy DSL so policy authors can attach human-readable rationale to
a rule. That justification is propagated through parsing/matching and
can be surfaced to the model (or approval UI) when a command is blocked
or requires approval.

When a command is rejected (or gated behind approval) due to policy, a
generic message makes it hard for the model/user to understand what went
wrong and what to do instead. Allowing policy authors to supply a short
justification improves debuggability and helps guide the model toward
compliant alternatives.

Example:

```python
prefix_rule(
    pattern = ["git", "push"],
    decision = "forbidden",
    justification = "pushing is blocked in this repo",
)
```

If Codex tried to run `git push origin main`, now the failure would
include:

```
`git push origin main` rejected: pushing is blocked in this repo
```

whereas previously, all it was told was:

```
execpolicy forbids this command
```
2026-01-05 21:24:48 +00:00
iceweasel-oai
07f077dfb3 best effort to "hide" Sandbox users (#8492)
The elevated sandbox creates two new Windows users - CodexSandboxOffline
and CodexSandboxOnline. This is necessary, so this PR does all that it
can to "hide" those users. It uses the registry plus directory flags (on
their home directories) to get them to show up as little as possible.
2026-01-05 12:29:10 -08:00
Abrar Ahmed
7cf6f1c723 Use issuer URL in device auth prompt link (#7858)
## Summary

When using device-code login with a custom issuer
(`--experimental_issuer`), Codex correctly uses that issuer for the auth
flow — but the **terminal prompt still told users to open the default
OpenAI device URL** (`https://auth.openai.com/codex/device`). That’s
confusing and can send users to the **wrong domain** (especially for
enterprise/staging issuers). This PR updates the prompt (and related
URLs) to consistently use the configured issuer. 🎯

---

## 🔧 What changed

* 🔗 **Device auth prompt link** now uses the configured issuer (instead
of a hard-coded OpenAI URL)
* 🧭 **Redirect callback URL** is derived from the same issuer for
consistency
* 🧼 Minor cleanup: normalize the issuer base URL once and reuse it
(avoids formatting quirks like trailing `/`)

---

## 🧪 Repro + Before/After

### ▶️ Command

```bash
codex login --device-auth --experimental_issuer https://auth.example.com
```

###  Before (wrong link shown)

```text
1. Open this link in your browser and sign in to your account
   https://auth.openai.com/codex/device
```

###  After (correct link shown)

```text
1. Open this link in your browser and sign in to your account
   https://auth.example.com/codex/device
```

Full example output (same as before, but with the correct URL):

```text
Welcome to Codex [v0.72.0]
OpenAI's command-line coding agent

Follow these steps to sign in with ChatGPT using device code authorization:

1. Open this link in your browser and sign in to your account
   https://auth.example.com/codex/device

2. Enter this one-time code (expires in 15 minutes)
   BUT6-0M8K4

Device codes are a common phishing target. Never share this code.
```

---

##  Test plan

* 🟦 `codex login --device-auth` (default issuer): output remains
unchanged
* 🟩 `codex login --device-auth --experimental_issuer
https://auth.example.com`:

  * prompt link points to the issuer 
  * callback URL is derived from the same issuer 
  * no double slashes / mismatched domains 

Co-authored-by: Eric Traut <etraut@openai.com>
2026-01-05 13:09:05 -07:00
Gav Verma
57f8158608 chore: improve skills render section (#8459)
This change improves the skills render section
- Separate the skills list from usage rules with clear subheadings
- Define skill more clearly upfront
- Remove confusing trigger/discovery wording and make reference-following guidance more actionable
2026-01-05 11:55:26 -08:00
iceweasel-oai
95580f229e never let sandbox write to .codex/ or .codex/.sandbox/ (#8683)
Never treat .codex or .codex/.sandbox as a workspace root.
Handle write permissions to .codex/.sandbox in a single method so that
the sandbox setup/runner can write logs and other setup files to that
directory.
2026-01-05 11:54:21 -08:00
Eric Traut
896e9ed212 Fix "could not refresh token" error resulting from concurrent CLI instances
Idle Codex CLI instances can get stuck after another concurrently-running instance refreshes and rotates the shared ChatGPT refresh token: the idle process wakes up, gets a 401, and its in-memory refresh token is no longer valid, so refresh fails permanently.

This change makes 401 recovery resilient to concurrent token rotation by first syncing ChatGPT tokens from the configured credential store (file/keyring/auto) and retrying the request, then performing a network refresh only if needed (using the refresh token loaded from storage). It also prevents accidental cross-account/workspace switching by only adopting/refreshing when chatgpt_account_id matches the request’s auth snapshot, and adds bounded retries on transient auth.json parse errors to handle concurrent truncate+write. Added unit tests for the storage-sync outcomes.
2025-12-31 13:07:37 -07:00
33 changed files with 1658 additions and 324 deletions

View File

@@ -52,6 +52,12 @@ See `codex-rs/tui/styles.md`.
- If you need to indent wrapped lines, use the initial_indent / subsequent_indent options from RtOptions if you can, rather than writing custom logic.
- If you have a list of lines and you need to prefix them all with some prefix (optionally different on the first vs subsequent lines), use the `prefix_lines` helper from line_utils.
## Code
### Coding conventions
Keep functions relatively simple. Do not produce functions with dozens of return statements. Create helper functions if needed to keep code flow easy to understand.
## Tests
### Snapshot tests

View File

@@ -2652,19 +2652,17 @@ impl CodexMessageProcessor {
};
let skills_manager = self.conversation_manager.skills_manager();
let data = cwds
.into_iter()
.map(|cwd| {
let outcome = skills_manager.skills_for_cwd_with_options(&cwd, force_reload);
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills);
codex_app_server_protocol::SkillsListEntry {
cwd,
skills,
errors,
}
})
.collect();
let mut data = Vec::new();
for cwd in cwds {
let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await;
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills);
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills,
errors,
});
}
self.outgoing
.send_response(request_id, SkillsListResponse { data })
.await;

View File

@@ -59,3 +59,61 @@ prefix_rule(
Ok(())
}
#[test]
fn execpolicy_check_includes_justification_when_present() -> Result<(), Box<dyn std::error::Error>>
{
let codex_home = TempDir::new()?;
let policy_path = codex_home.path().join("rules").join("policy.rules");
fs::create_dir_all(
policy_path
.parent()
.expect("policy path should have a parent"),
)?;
fs::write(
&policy_path,
r#"
prefix_rule(
pattern = ["git", "push"],
decision = "forbidden",
justification = "pushing is blocked in this repo",
)
"#,
)?;
let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
.env("CODEX_HOME", codex_home.path())
.args([
"execpolicy",
"check",
"--rules",
policy_path
.to_str()
.expect("policy path should be valid UTF-8"),
"git",
"push",
"origin",
"main",
])
.output()?;
assert!(output.status.success());
let result: serde_json::Value = serde_json::from_slice(&output.stdout)?;
assert_eq!(
result,
json!({
"decision": "forbidden",
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["git", "push"],
"decision": "forbidden",
"justification": "pushing is blocked in this repo"
}
}
]
})
);
Ok(())
}

View File

@@ -100,6 +100,14 @@ impl From<RefreshTokenError> for std::io::Error {
}
impl CodexAuth {
/// Workspace/account identifier associated with this auth snapshot, derived from the cached
/// ID token claims. This is used to prevent accidentally adopting credentials for a different
/// identity when multiple Codex instances share a credential store.
pub fn chatgpt_account_id(&self) -> Option<String> {
self.get_current_token_data()
.and_then(|t| t.id_token.chatgpt_account_id)
}
pub async fn refresh_token(&self) -> Result<String, RefreshTokenError> {
tracing::info!("Refreshing token");
@@ -719,6 +727,165 @@ mod tests {
assert_eq!(auth, None);
}
#[tokio::test]
async fn sync_from_storage_applies_updated_tokens_for_matching_identity() {
let codex_home = tempdir().unwrap();
let jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_account_id: Some("acct_123".to_string()),
},
codex_home.path(),
)
.expect("seed auth");
let manager = AuthManager::new(
codex_home.path().to_path_buf(),
false,
AuthCredentialsStoreMode::File,
);
let expected = manager.auth().expect("auth should be loaded");
let updated = AuthDotJson {
openai_api_key: None,
tokens: Some(TokenData {
id_token: parse_id_token(&jwt).expect("jwt should parse"),
access_token: "new-access".to_string(),
refresh_token: "new-refresh".to_string(),
account_id: None,
}),
last_refresh: Some(Utc::now()),
};
save_auth(codex_home.path(), &updated, AuthCredentialsStoreMode::File)
.expect("write updated auth");
let sync = manager
.sync_from_storage_for_request(&expected)
.await
.expect("sync should succeed");
assert_eq!(sync, SyncFromStorageResult::Applied { changed: true });
let current = manager.auth().expect("auth should exist");
let guard = current.auth_dot_json.lock().unwrap();
let tokens = guard
.as_ref()
.and_then(|a| a.tokens.as_ref())
.expect("tokens should exist");
assert_eq!(tokens.access_token, "new-access");
assert_eq!(tokens.refresh_token, "new-refresh");
}
#[tokio::test]
async fn sync_from_storage_returns_mismatch_for_different_identity() {
let codex_home = tempdir().unwrap();
let _jwt_expected = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_account_id: Some("acct_123".to_string()),
},
codex_home.path(),
)
.expect("seed auth");
let manager = AuthManager::new(
codex_home.path().to_path_buf(),
false,
AuthCredentialsStoreMode::File,
);
let expected = manager.auth().expect("auth should be loaded");
let jwt_other = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_account_id: Some("acct_other".to_string()),
},
codex_home.path(),
)
.expect("seed other auth");
let mismatched = AuthDotJson {
openai_api_key: None,
tokens: Some(TokenData {
id_token: parse_id_token(&jwt_other).expect("jwt should parse"),
access_token: "other-access".to_string(),
refresh_token: "other-refresh".to_string(),
account_id: None,
}),
last_refresh: Some(Utc::now()),
};
save_auth(
codex_home.path(),
&mismatched,
AuthCredentialsStoreMode::File,
)
.expect("write mismatched auth");
let sync = manager
.sync_from_storage_for_request(&expected)
.await
.expect("sync should succeed");
assert_eq!(sync, SyncFromStorageResult::IdentityMismatch);
}
#[tokio::test]
async fn sync_from_storage_skips_when_identity_missing() {
let codex_home = tempdir().unwrap();
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("seed auth");
let manager = AuthManager::new(
codex_home.path().to_path_buf(),
false,
AuthCredentialsStoreMode::File,
);
let expected = manager.auth().expect("auth should be loaded");
let sync = manager
.sync_from_storage_for_request(&expected)
.await
.expect("sync should succeed");
assert_eq!(sync, SyncFromStorageResult::SkippedMissingIdentity);
}
#[tokio::test]
async fn sync_from_storage_reports_logged_out_when_storage_empty() {
let codex_home = tempdir().unwrap();
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_account_id: Some("acct_123".to_string()),
},
codex_home.path(),
)
.expect("seed auth");
let manager = AuthManager::new(
codex_home.path().to_path_buf(),
false,
AuthCredentialsStoreMode::File,
);
let expected = manager.auth().expect("auth should be loaded");
std::fs::remove_file(codex_home.path().join("auth.json")).expect("remove auth");
let sync = manager
.sync_from_storage_for_request(&expected)
.await
.expect("sync should succeed");
assert_eq!(sync, SyncFromStorageResult::LoggedOut);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
@@ -1052,14 +1219,15 @@ mod tests {
}
}
/// Central manager providing a single source of truth for auth.json derived
/// Central manager providing a single source of truth for auth storage derived
/// authentication data. It loads once (or on preference change) and then
/// hands out cloned `CodexAuth` values so the rest of the program has a
/// consistent snapshot.
///
/// External modifications to `auth.json` will NOT be observed until
/// `reload()` is called explicitly. This matches the design goal of avoiding
/// different parts of the program seeing inconsistent auth data midrun.
/// External modifications to credentials in storage will generally NOT be
/// observed until `reload()` is called explicitly. One exception is the
/// token-refresh recovery path, which may consult storage in order to adopt
/// tokens rotated by another concurrently-running Codex instance.
#[derive(Debug)]
pub struct AuthManager {
codex_home: PathBuf,
@@ -1068,6 +1236,35 @@ pub struct AuthManager {
auth_credentials_store_mode: AuthCredentialsStoreMode,
}
/// Outcome of attempting to sync the currently cached ChatGPT credentials from storage.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncFromStorageResult {
/// The request auth did not include a workspace identifier, so we cannot safely compare.
SkippedMissingIdentity,
/// No credentials were found in storage (user logged out).
LoggedOut,
/// Storage contains credentials for a different identity (workspace/account).
IdentityMismatch,
/// Storage credentials match identity and were applied to the in-memory snapshot.
Applied { changed: bool },
}
/// Per-request state used to coordinate a limited 401 recovery flow.
///
/// This is intentionally managed by `AuthManager` so callers don't need to
/// understand the recovery stages.
#[derive(Default, Debug)]
pub(crate) struct UnauthorizedRecovery {
synced_from_storage: bool,
refreshed_token: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UnauthorizedRecoveryDecision {
Retry,
GiveUp,
}
impl AuthManager {
/// Create a new manager loading the initial auth using the provided
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
@@ -1174,7 +1371,7 @@ impl AuthManager {
}
/// Attempt to refresh the current auth token (if any). On success, reload
/// the auth state from disk so other components observe refreshed token.
/// the auth state from storage so other components observe refreshed token.
/// If the token refresh fails in a permanent (nontransient) way, logs out
/// to clear invalid auth state.
pub async fn refresh_token(&self) -> Result<Option<String>, RefreshTokenError> {
@@ -1182,17 +1379,8 @@ impl AuthManager {
Some(a) => a,
None => return Ok(None),
};
match auth.refresh_token().await {
Ok(token) => {
// Reload to pick up persisted changes.
self.reload();
Ok(Some(token))
}
Err(e) => {
tracing::error!("Failed to refresh token: {}", e);
Err(e)
}
}
self.refresh_token_for_request(&auth).await
}
/// Log out by deleting the ondisk auth.json (if present). Returns Ok(true)
@@ -1209,4 +1397,233 @@ impl AuthManager {
pub fn get_auth_mode(&self) -> Option<AuthMode> {
self.auth().map(|a| a.mode)
}
pub(crate) async fn sync_from_storage_for_request(
&self,
expected: &CodexAuth,
) -> std::io::Result<SyncFromStorageResult> {
let Some(expected_account_id) = expected.chatgpt_account_id() else {
return Ok(SyncFromStorageResult::SkippedMissingIdentity);
};
let storage =
create_auth_storage(self.codex_home.clone(), self.auth_credentials_store_mode);
let Some(stored) = load_auth_dot_json_with_retries(&storage).await? else {
// Ensure the cached auth reflects the logged-out state.
self.reload();
return Ok(SyncFromStorageResult::LoggedOut);
};
// We only support ChatGPT in this sync path. If storage now points to an API key or is
// otherwise missing tokens, treat it as an identity mismatch for this request.
let Some(tokens) = stored.tokens.clone() else {
self.reload();
return Ok(SyncFromStorageResult::IdentityMismatch);
};
let Some(stored_account_id) = tokens.id_token.chatgpt_account_id.as_deref() else {
// Cannot prove identity match.
return Ok(SyncFromStorageResult::SkippedMissingIdentity);
};
if stored_account_id != expected_account_id {
// Keep cached auth in sync for subsequent requests, but do not retry the in-flight
// request under a different identity.
self.reload();
return Ok(SyncFromStorageResult::IdentityMismatch);
}
let changed = if let Some(current) = self.auth() {
if let Ok(mut guard) = current.auth_dot_json.lock() {
let current_auth = guard.clone();
*guard = Some(stored.clone());
current_auth != Some(stored)
} else {
false
}
} else {
false
};
Ok(SyncFromStorageResult::Applied { changed })
}
pub(crate) async fn refresh_token_for_request(
&self,
expected: &CodexAuth,
) -> Result<Option<String>, RefreshTokenError> {
if expected.mode != AuthMode::ChatGPT {
return Ok(None);
}
let Some(auth) = self.auth() else {
return Ok(None);
};
let expected_account_id = expected.chatgpt_account_id();
let Some(expected_account_id) = expected_account_id.as_deref() else {
// Cannot safely consult storage without a stable identity; fall back to current
// in-memory refresh behavior.
let token = auth.refresh_token().await?;
self.reload();
return Ok(Some(token));
};
let storage =
create_auth_storage(self.codex_home.clone(), self.auth_credentials_store_mode);
let Some(mut attempted_refresh_token) =
load_stored_refresh_token_if_identity_matches(&storage, expected_account_id).await?
else {
return Ok(None);
};
let mut retried = false;
loop {
let refresh_response =
try_refresh_token(attempted_refresh_token.clone(), &auth.client).await;
match refresh_response {
Ok(refresh_response) => {
let updated = update_tokens(
&storage,
refresh_response.id_token,
refresh_response.access_token,
refresh_response.refresh_token,
)
.await
.map_err(RefreshTokenError::from)?;
if let Ok(mut auth_lock) = auth.auth_dot_json.lock() {
*auth_lock = Some(updated.clone());
}
self.reload();
let access = updated
.tokens
.as_ref()
.map(|t| t.access_token.clone())
.ok_or_else(|| {
RefreshTokenError::other_with_message(
"Token data is not available after refresh.",
)
})?;
return Ok(Some(access));
}
Err(RefreshTokenError::Permanent(failed))
if !retried
&& matches!(
failed.reason,
RefreshTokenFailedReason::Expired | RefreshTokenFailedReason::Exhausted
) =>
{
// Another instance may have refreshed and rotated the refresh token while we
// were attempting our refresh. Reload and retry once if the stored refresh
// token differs and identity still matches.
let Some(stored_refresh_token) = load_stored_refresh_token_if_identity_matches(
&storage,
expected_account_id,
)
.await?
else {
return Ok(None);
};
if stored_refresh_token != attempted_refresh_token {
attempted_refresh_token = stored_refresh_token;
retried = true;
continue;
}
return Err(RefreshTokenError::Permanent(failed));
}
Err(err) => return Err(err),
}
}
}
pub(crate) async fn recover_from_unauthorized_for_request(
&self,
expected: &CodexAuth,
recovery: &mut UnauthorizedRecovery,
) -> Result<UnauthorizedRecoveryDecision, RefreshTokenError> {
if recovery.refreshed_token {
return Ok(UnauthorizedRecoveryDecision::GiveUp);
}
if expected.mode != AuthMode::ChatGPT {
return Ok(UnauthorizedRecoveryDecision::GiveUp);
}
if !recovery.synced_from_storage {
let sync = self
.sync_from_storage_for_request(expected)
.await
.map_err(RefreshTokenError::Transient)?;
recovery.synced_from_storage = true;
match sync {
SyncFromStorageResult::Applied { changed } => {
tracing::debug!(changed, "synced ChatGPT credentials from storage after 401");
Ok(UnauthorizedRecoveryDecision::Retry)
}
SyncFromStorageResult::SkippedMissingIdentity => {
Ok(UnauthorizedRecoveryDecision::Retry)
}
SyncFromStorageResult::LoggedOut | SyncFromStorageResult::IdentityMismatch => {
Ok(UnauthorizedRecoveryDecision::GiveUp)
}
}
} else {
match self.refresh_token_for_request(expected).await? {
Some(_) => {
recovery.refreshed_token = true;
Ok(UnauthorizedRecoveryDecision::Retry)
}
None => Ok(UnauthorizedRecoveryDecision::GiveUp),
}
}
}
}
async fn load_stored_refresh_token_if_identity_matches(
storage: &Arc<dyn AuthStorageBackend>,
expected_account_id: &str,
) -> Result<Option<String>, RefreshTokenError> {
let Some(stored) = load_auth_dot_json_with_retries(storage)
.await
.map_err(RefreshTokenError::Transient)?
else {
return Ok(None);
};
let Some(tokens) = stored.tokens else {
return Ok(None);
};
if tokens.id_token.chatgpt_account_id.as_deref() != Some(expected_account_id) {
return Ok(None);
}
Ok(Some(tokens.refresh_token))
}
async fn load_auth_dot_json_with_retries(
storage: &Arc<dyn AuthStorageBackend>,
) -> std::io::Result<Option<AuthDotJson>> {
// This primarily mitigates concurrent file writes where another process truncates and rewrites
// auth.json, which can cause transient JSON parse errors for readers.
const MAX_ATTEMPTS: usize = 3;
const BASE_DELAY_MS: u64 = 25;
for attempt in 0..MAX_ATTEMPTS {
match storage.load() {
Ok(value) => return Ok(value),
Err(err) if err.kind() == ErrorKind::InvalidData && attempt + 1 < MAX_ATTEMPTS => {
let delay = Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1));
tokio::time::sleep(delay).await;
continue;
}
Err(err) => return Err(err),
}
}
Ok(None)
}

View File

@@ -7,6 +7,7 @@ use sha2::Sha256;
use std::fmt::Debug;
use std::fs::File;
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::io::Read;
use std::io::Write;
#[cfg(unix)]
@@ -81,7 +82,8 @@ impl FileAuthStorage {
let mut file = File::open(auth_file)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)
.map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?;
Ok(auth_dot_json)
}

View File

@@ -17,7 +17,6 @@ use codex_api::TransportError;
use codex_api::common::Reasoning;
use codex_api::create_text_param_for_request;
use codex_api::error::ApiError;
use codex_app_server_protocol::AuthMode;
use codex_otel::otel_manager::OtelManager;
use codex_protocol::ConversationId;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -153,7 +152,7 @@ impl ModelClient {
let conversation_id = self.conversation_id.to_string();
let session_source = self.session_source.clone();
let mut refreshed = false;
let mut recovery = crate::auth::UnauthorizedRecovery::default();
loop {
let auth = auth_manager.as_ref().and_then(|m| m.auth());
let api_provider = self
@@ -179,7 +178,7 @@ impl ModelClient {
Err(ApiError::Transport(TransportError::Http { status, .. }))
if status == StatusCode::UNAUTHORIZED =>
{
handle_unauthorized(status, &mut refreshed, &auth_manager, &auth).await?;
handle_unauthorized(status, &mut recovery, &auth_manager, &auth).await?;
continue;
}
Err(err) => return Err(map_api_error(err)),
@@ -242,7 +241,7 @@ impl ModelClient {
let conversation_id = self.conversation_id.to_string();
let session_source = self.session_source.clone();
let mut refreshed = false;
let mut recovery = crate::auth::UnauthorizedRecovery::default();
loop {
let auth = auth_manager.as_ref().and_then(|m| m.auth());
let api_provider = self
@@ -276,7 +275,7 @@ impl ModelClient {
Err(ApiError::Transport(TransportError::Http { status, .. }))
if status == StatusCode::UNAUTHORIZED =>
{
handle_unauthorized(status, &mut refreshed, &auth_manager, &auth).await?;
handle_unauthorized(status, &mut recovery, &auth_manager, &auth).await?;
continue;
}
Err(err) => return Err(map_api_error(err)),
@@ -485,22 +484,20 @@ where
/// the mapped `CodexErr` is returned to the caller.
async fn handle_unauthorized(
status: StatusCode,
refreshed: &mut bool,
recovery: &mut crate::auth::UnauthorizedRecovery,
auth_manager: &Option<Arc<AuthManager>>,
auth: &Option<crate::auth::CodexAuth>,
) -> Result<()> {
if *refreshed {
return Err(map_unauthorized_status(status));
}
if let Some(manager) = auth_manager.as_ref()
&& let Some(auth) = auth.as_ref()
&& auth.mode == AuthMode::ChatGPT
{
match manager.refresh_token().await {
Ok(_) => {
*refreshed = true;
Ok(())
match manager
.recover_from_unauthorized_for_request(auth, recovery)
.await
{
Ok(crate::auth::UnauthorizedRecoveryDecision::Retry) => Ok(()),
Ok(crate::auth::UnauthorizedRecoveryDecision::GiveUp) => {
Err(map_unauthorized_status(status))
}
Err(RefreshTokenError::Permanent(failed)) => Err(CodexErr::RefreshTokenFailed(failed)),
Err(RefreshTokenError::Transient(other)) => Err(CodexErr::Io(other)),

View File

@@ -218,10 +218,11 @@ impl Codex {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
let loaded_skills = config
.features
.enabled(Feature::Skills)
.then(|| skills_manager.skills_for_cwd(&config.cwd));
let loaded_skills = if config.features.enabled(Feature::Skills) {
Some(skills_manager.skills_for_config(&config))
} else {
None
};
if let Some(outcome) = &loaded_skills {
for err in &outcome.errors {
@@ -1987,18 +1988,18 @@ mod handlers {
};
let skills = if sess.enabled(Feature::Skills) {
let skills_manager = &sess.services.skills_manager;
cwds.into_iter()
.map(|cwd| {
let outcome = skills_manager.skills_for_cwd_with_options(&cwd, force_reload);
let errors = super::errors_to_info(&outcome.errors);
let skills = super::skills_to_info(&outcome.skills);
SkillsListEntry {
cwd,
skills,
errors,
}
})
.collect()
let mut entries = Vec::new();
for cwd in cwds {
let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await;
let errors = super::errors_to_info(&outcome.errors);
let skills = super::skills_to_info(&outcome.skills);
entries.push(SkillsListEntry {
cwd,
skills,
errors,
});
}
entries
} else {
cwds.into_iter()
.map(|cwd| SkillsListEntry {
@@ -2252,11 +2253,16 @@ pub(crate) async fn run_task(
});
sess.send_event(&turn_context, event).await;
let skills_outcome = sess.enabled(Feature::Skills).then(|| {
sess.services
.skills_manager
.skills_for_cwd(&turn_context.cwd)
});
let skills_outcome = if sess.enabled(Feature::Skills) {
Some(
sess.services
.skills_manager
.skills_for_cwd(&turn_context.cwd, false)
.await,
)
} else {
None
};
let SkillInjections {
items: skill_items,

View File

@@ -28,11 +28,10 @@ use crate::features::Feature;
use crate::features::Features;
use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ExecApprovalRequirement;
use shlex::try_join as shlex_try_join;
const FORBIDDEN_REASON: &str = "execpolicy forbids this command";
const PROMPT_CONFLICT_REASON: &str =
"execpolicy requires approval for this command, but AskForApproval is set to Never";
const PROMPT_REASON: &str = "execpolicy requires approval for this command";
"approval required by policy, but AskForApproval is set to Never";
const RULES_DIR_NAME: &str = "rules";
const RULE_EXTENSION: &str = "rules";
const DEFAULT_POLICY_FILE: &str = "default.rules";
@@ -128,7 +127,7 @@ impl ExecPolicyManager {
match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
reason: FORBIDDEN_REASON.to_string(),
reason: derive_forbidden_reason(command, &evaluation),
},
Decision::Prompt => {
if matches!(approval_policy, AskForApproval::Never) {
@@ -137,7 +136,7 @@ impl ExecPolicyManager {
}
} else {
ExecApprovalRequirement::NeedsApproval {
reason: derive_prompt_reason(&evaluation),
reason: derive_prompt_reason(command, &evaluation),
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
try_derive_execpolicy_amendment_for_prompt_rules(
&evaluation.matched_rules,
@@ -299,15 +298,69 @@ fn try_derive_execpolicy_amendment_for_allow_rules(
})
}
/// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision.
fn derive_prompt_reason(evaluation: &Evaluation) -> Option<String> {
evaluation.matched_rules.iter().find_map(|rule_match| {
if is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt {
Some(PROMPT_REASON.to_string())
} else {
None
/// Only return a reason when a policy rule drove the prompt decision.
fn derive_prompt_reason(command_args: &[String], evaluation: &Evaluation) -> Option<String> {
let command = render_shlex_command(command_args);
let most_specific_prompt = evaluation
.matched_rules
.iter()
.filter_map(|rule_match| match rule_match {
RuleMatch::PrefixRuleMatch {
matched_prefix,
decision: Decision::Prompt,
justification,
..
} => Some((matched_prefix.len(), justification.as_deref())),
_ => None,
})
.max_by_key(|(matched_prefix_len, _)| *matched_prefix_len);
match most_specific_prompt {
Some((_matched_prefix_len, Some(justification))) => {
Some(format!("`{command}` requires approval: {justification}"))
}
})
Some((_matched_prefix_len, None)) => {
Some(format!("`{command}` requires approval by policy"))
}
None => None,
}
}
fn render_shlex_command(args: &[String]) -> String {
shlex_try_join(args.iter().map(String::as_str)).unwrap_or_else(|_| args.join(" "))
}
/// Derive a string explaining why the command was forbidden. If `justification`
/// is set by the user, this can contain instructions with recommended
/// alternatives, for example.
fn derive_forbidden_reason(command_args: &[String], evaluation: &Evaluation) -> String {
let command = render_shlex_command(command_args);
let most_specific_forbidden = evaluation
.matched_rules
.iter()
.filter_map(|rule_match| match rule_match {
RuleMatch::PrefixRuleMatch {
matched_prefix,
decision: Decision::Forbidden,
justification,
..
} => Some((matched_prefix, justification.as_deref())),
_ => None,
})
.max_by_key(|(matched_prefix, _)| matched_prefix.len());
match most_specific_forbidden {
Some((_matched_prefix, Some(justification))) => {
format!("`{command}` rejected: {justification}")
}
Some((matched_prefix, None)) => {
let prefix = render_shlex_command(matched_prefix);
format!("`{command}` rejected: policy forbids commands starting with `{prefix}`")
}
None => format!("`{command}` rejected: blocked by policy"),
}
}
async fn collect_policy_files(dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, ExecPolicyError> {
@@ -450,7 +503,8 @@ mod tests {
decision: Decision::Forbidden,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden
decision: Decision::Forbidden,
justification: None,
}],
},
policy.check_multiple(command.iter(), &|_| Decision::Allow)
@@ -528,7 +582,8 @@ mod tests {
decision: Decision::Forbidden,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden
decision: Decision::Forbidden,
justification: None,
}],
},
policy.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
@@ -538,7 +593,8 @@ mod tests {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["ls".to_string()],
decision: Decision::Prompt
decision: Decision::Prompt,
justification: None,
}],
},
policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow)
@@ -560,7 +616,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
let forbidden_script = vec![
"bash".to_string(),
"-lc".to_string(),
"rm -rf /tmp".to_string(),
"rm -rf /some/important/folder".to_string(),
];
let manager = ExecPolicyManager::new(policy);
@@ -577,7 +633,45 @@ prefix_rule(pattern=["rm"], decision="forbidden")
assert_eq!(
requirement,
ExecApprovalRequirement::Forbidden {
reason: FORBIDDEN_REASON.to_string()
reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string()
}
);
}
#[tokio::test]
async fn justification_is_included_in_forbidden_exec_approval_requirement() {
let policy_src = r#"
prefix_rule(
pattern=["rm"],
decision="forbidden",
justification="destructive command",
)
"#;
let mut parser = PolicyParser::new();
parser
.parse("test.rules", policy_src)
.expect("parse policy");
let policy = Arc::new(parser.build());
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(
&Features::with_defaults(),
&[
"rm".to_string(),
"-rf".to_string(),
"/some/important/folder".to_string(),
],
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
)
.await;
assert_eq!(
requirement,
ExecApprovalRequirement::Forbidden {
reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string()
}
);
}
@@ -606,7 +700,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
assert_eq!(
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: Some(PROMPT_REASON.to_string()),
reason: Some("`rm` requires approval by policy".to_string()),
proposed_execpolicy_amendment: None,
}
);
@@ -824,7 +918,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
assert_eq!(
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: Some(PROMPT_REASON.to_string()),
reason: Some("`rm` requires approval by policy".to_string()),
proposed_execpolicy_amendment: None,
}
);

View File

@@ -513,9 +513,9 @@ mod tests {
)
.unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md"));
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
let expected = format!(
"base doc\n\n## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}"
"base doc\n\n## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n### How to use skills\n{usage_rules}"
);
assert_eq!(res, expected);
}
@@ -537,9 +537,9 @@ mod tests {
dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path())
.unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md"));
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
let expected = format!(
"## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}"
"## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- linting: run clippy (file: {expected_path_str})\n### How to use skills\n{usage_rules}"
);
assert_eq!(res, expected);
}

View File

@@ -1,9 +1,10 @@
use crate::config::Config;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::config_loader::ConfigLayerStack;
use crate::skills::model::SkillError;
use crate::skills::model::SkillLoadOutcome;
use crate::skills::model::SkillMetadata;
use crate::skills::system::system_cache_root_dir;
use codex_app_server_protocol::ConfigLayerSource;
use codex_protocol::protocol::SkillScope;
use dunce::canonicalize as normalize_path;
use serde::Deserialize;
@@ -32,8 +33,6 @@ struct SkillFrontmatterMetadata {
const SKILLS_FILENAME: &str = "SKILL.md";
const SKILLS_DIR_NAME: &str = "skills";
const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex";
const ADMIN_SKILLS_ROOT: &str = "/etc/codex/skills";
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;
@@ -88,86 +87,81 @@ where
.skills
.retain(|skill| seen.insert(skill.name.clone()));
outcome
.skills
.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
outcome
}
pub(crate) fn user_skills_root(codex_home: &Path) -> SkillRoot {
SkillRoot {
path: codex_home.join(SKILLS_DIR_NAME),
scope: SkillScope::User,
}
}
pub(crate) fn system_skills_root(codex_home: &Path) -> SkillRoot {
SkillRoot {
path: system_cache_root_dir(codex_home),
scope: SkillScope::System,
}
}
pub(crate) fn admin_skills_root() -> SkillRoot {
SkillRoot {
path: PathBuf::from(ADMIN_SKILLS_ROOT),
scope: SkillScope::Admin,
}
}
pub(crate) fn repo_skills_root(cwd: &Path) -> Option<SkillRoot> {
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
let base = normalize_path(base).unwrap_or_else(|_| base.to_path_buf());
let repo_root =
resolve_root_git_project_for_trust(&base).map(|root| normalize_path(&root).unwrap_or(root));
let scope = SkillScope::Repo;
if let Some(repo_root) = repo_root.as_deref() {
for dir in base.ancestors() {
let skills_root = dir.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
if skills_root.is_dir() {
return Some(SkillRoot {
path: skills_root,
scope,
});
}
if dir == repo_root {
break;
}
fn scope_rank(scope: SkillScope) -> u8 {
// Higher-priority scopes first (matches dedupe priority order).
match scope {
SkillScope::Repo => 0,
SkillScope::User => 1,
SkillScope::System => 2,
SkillScope::Admin => 3,
}
return None;
}
let skills_root = base.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
skills_root.is_dir().then_some(SkillRoot {
path: skills_root,
scope,
})
outcome.skills.sort_by(|a, b| {
scope_rank(a.scope)
.cmp(&scope_rank(b.scope))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.path.cmp(&b.path))
});
outcome
}
pub(crate) fn skill_roots_for_cwd(codex_home: &Path, cwd: &Path) -> Vec<SkillRoot> {
fn skill_roots_from_layer_stack_inner(config_layer_stack: &ConfigLayerStack) -> Vec<SkillRoot> {
let mut roots = Vec::new();
if let Some(repo_root) = repo_skills_root(cwd) {
roots.push(repo_root);
}
for layer in config_layer_stack.layers_high_to_low() {
let Some(config_folder) = layer.config_folder() else {
continue;
};
// Load order matters: we dedupe by name, keeping the first occurrence.
// Priority order: repo, user, system, then admin.
roots.push(user_skills_root(codex_home));
roots.push(system_skills_root(codex_home));
if cfg!(unix) {
roots.push(admin_skills_root());
match &layer.name {
ConfigLayerSource::Project { .. } => {
roots.push(SkillRoot {
path: config_folder.as_path().join(SKILLS_DIR_NAME),
scope: SkillScope::Repo,
});
}
ConfigLayerSource::User { .. } => {
// `$CODEX_HOME/skills` (user-installed skills).
roots.push(SkillRoot {
path: config_folder.as_path().join(SKILLS_DIR_NAME),
scope: SkillScope::User,
});
// Embedded system skills are cached under `$CODEX_HOME/skills/.system` and are a
// special case (not a config layer).
roots.push(SkillRoot {
path: system_cache_root_dir(config_folder.as_path()),
scope: SkillScope::System,
});
}
ConfigLayerSource::System { .. } => {
// The system config layer lives under `/etc/codex/` on Unix, so treat
// `/etc/codex/skills` as admin-scoped skills.
roots.push(SkillRoot {
path: config_folder.as_path().join(SKILLS_DIR_NAME),
scope: SkillScope::Admin,
});
}
ConfigLayerSource::Mdm { .. }
| ConfigLayerSource::SessionFlags
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {}
}
}
roots
}
fn skill_roots(config: &Config) -> Vec<SkillRoot> {
skill_roots_for_cwd(&config.codex_home, &config.cwd)
skill_roots_from_layer_stack_inner(&config.config_layer_stack)
}
pub(crate) fn skill_roots_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
) -> Vec<SkillRoot> {
skill_roots_from_layer_stack_inner(config_layer_stack)
}
fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut SkillLoadOutcome) {
@@ -318,21 +312,91 @@ fn extract_frontmatter(contents: &str) -> Option<String> {
mod tests {
use super::*;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
use toml::Value as TomlValue;
const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex";
async fn make_config(codex_home: &TempDir) -> Config {
let mut config = ConfigBuilder::default()
make_config_for_cwd(codex_home, codex_home.path().to_path_buf()).await
}
async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config {
let harness_overrides = ConfigOverrides {
cwd: Some(cwd),
..Default::default()
};
ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(harness_overrides)
.build()
.await
.expect("defaults for test should always succeed");
.expect("defaults for test should always succeed")
}
config.cwd = codex_home.path().to_path_buf();
config
fn mark_as_git_repo(dir: &Path) {
// Config/project-root discovery only checks for the presence of `.git` (file or dir),
// so we can avoid shelling out to `git init` in tests.
fs::write(dir.join(".git"), "gitdir: fake\n").unwrap();
}
fn normalized(path: &Path) -> PathBuf {
normalize_path(path).unwrap_or_else(|_| path.to_path_buf())
}
#[test]
fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to_admin()
-> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let system_folder = tmp.path().join("etc/codex");
let user_folder = tmp.path().join("home/codex");
fs::create_dir_all(&system_folder)?;
fs::create_dir_all(&user_folder)?;
// The file path doesn't need to exist; it's only used to derive the config folder.
let system_file = AbsolutePathBuf::from_absolute_path(system_folder.join("config.toml"))?;
let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?;
let layers = vec![
ConfigLayerEntry::new(
ConfigLayerSource::System { file: system_file },
TomlValue::Table(toml::map::Map::new()),
),
ConfigLayerEntry::new(
ConfigLayerSource::User { file: user_file },
TomlValue::Table(toml::map::Map::new()),
),
];
let stack = ConfigLayerStack::new(layers, ConfigRequirements::default())?;
let got = skill_roots_from_layer_stack(&stack)
.into_iter()
.map(|root| (root.scope, root.path))
.collect::<Vec<_>>();
assert_eq!(
got,
vec![
(SkillScope::User, user_folder.join("skills")),
(
SkillScope::System,
user_folder.join("skills").join(".system")
),
(SkillScope::Admin, system_folder.join("skills")),
]
);
Ok(())
}
fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf {
@@ -368,7 +432,7 @@ mod tests {
#[tokio::test]
async fn loads_valid_skill() {
let codex_home = tempfile::tempdir().expect("tempdir");
write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully");
let skill_path = write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully");
let cfg = make_config(&codex_home).await;
let outcome = load_skills(&cfg);
@@ -377,15 +441,15 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
let skill = &outcome.skills[0];
assert_eq!(skill.name, "demo-skill");
assert_eq!(skill.description, "does things carefully");
assert_eq!(skill.short_description, None);
let path_str = skill.path.to_string_lossy().replace('\\', "/");
assert!(
path_str.ends_with("skills/demo/SKILL.md"),
"unexpected path {path_str}"
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "demo-skill".to_string(),
description: "does things carefully".to_string(),
short_description: None,
path: normalized(&skill_path),
scope: SkillScope::User,
}]
);
}
@@ -395,7 +459,8 @@ mod tests {
let skill_dir = codex_home.path().join("skills/demo");
fs::create_dir_all(&skill_dir).unwrap();
let contents = "---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: short summary\n---\n\n# Body\n";
fs::write(skill_dir.join(SKILLS_FILENAME), contents).unwrap();
let skill_path = skill_dir.join(SKILLS_FILENAME);
fs::write(&skill_path, contents).unwrap();
let cfg = make_config(&codex_home).await;
let outcome = load_skills(&cfg);
@@ -404,10 +469,15 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(
outcome.skills[0].short_description,
Some("short summary".to_string())
outcome.skills,
vec![SkillMetadata {
name: "demo-skill".to_string(),
description: "long description".to_string(),
short_description: Some("short summary".to_string()),
path: normalized(&skill_path),
scope: SkillScope::User,
}]
);
}
@@ -493,22 +563,14 @@ mod tests {
async fn loads_skills_from_repo_root() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
let status = Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
mark_as_git_repo(repo_dir.path());
let skills_root = repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME);
write_skill_at(&skills_root, "repo", "repo-skill", "from repo");
let mut cfg = make_config(&codex_home).await;
cfg.cwd = repo_dir.path().to_path_buf();
let repo_root = normalize_path(&skills_root).unwrap_or_else(|_| skills_root.clone());
let skill_path = write_skill_at(&skills_root, "repo", "repo-skill", "from repo");
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
let outcome = load_skills(&cfg);
assert!(
@@ -516,28 +578,28 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
let skill = &outcome.skills[0];
assert_eq!(skill.name, "repo-skill");
assert!(skill.path.starts_with(&repo_root));
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "repo-skill".to_string(),
description: "from repo".to_string(),
short_description: None,
path: normalized(&skill_path),
scope: SkillScope::Repo,
}]
);
}
#[tokio::test]
async fn loads_skills_from_nearest_codex_dir_under_repo_root() {
async fn loads_skills_from_all_codex_dirs_under_project_root() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
let status = Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
mark_as_git_repo(repo_dir.path());
let nested_dir = repo_dir.path().join("nested/inner");
fs::create_dir_all(&nested_dir).unwrap();
write_skill_at(
let root_skill_path = write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
@@ -546,7 +608,7 @@ mod tests {
"root-skill",
"from root",
);
write_skill_at(
let nested_skill_path = write_skill_at(
&repo_dir
.path()
.join("nested")
@@ -557,8 +619,7 @@ mod tests {
"from nested",
);
let mut cfg = make_config(&codex_home).await;
cfg.cwd = nested_dir;
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
let outcome = load_skills(&cfg);
assert!(
@@ -566,8 +627,25 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "nested-skill");
assert_eq!(
outcome.skills,
vec![
SkillMetadata {
name: "nested-skill".to_string(),
description: "from nested".to_string(),
short_description: None,
path: normalized(&nested_skill_path),
scope: SkillScope::Repo,
},
SkillMetadata {
name: "root-skill".to_string(),
description: "from root".to_string(),
short_description: None,
path: normalized(&root_skill_path),
scope: SkillScope::Repo,
},
]
);
}
#[tokio::test]
@@ -575,7 +653,7 @@ mod tests {
let codex_home = tempfile::tempdir().expect("tempdir");
let work_dir = tempfile::tempdir().expect("tempdir");
write_skill_at(
let skill_path = write_skill_at(
&work_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
@@ -585,8 +663,7 @@ mod tests {
"from cwd",
);
let mut cfg = make_config(&codex_home).await;
cfg.cwd = work_dir.path().to_path_buf();
let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await;
let outcome = load_skills(&cfg);
assert!(
@@ -594,25 +671,26 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "local-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "local-skill".to_string(),
description: "from cwd".to_string(),
short_description: None,
path: normalized(&skill_path),
scope: SkillScope::Repo,
}]
);
}
#[tokio::test]
async fn deduplicates_by_name_preferring_repo_over_user() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
mark_as_git_repo(repo_dir.path());
let status = Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
write_skill(&codex_home, "user", "dupe-skill", "from user");
write_skill_at(
let _user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
let repo_skill_path = write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
@@ -622,8 +700,7 @@ mod tests {
"from repo",
);
let mut cfg = make_config(&codex_home).await;
cfg.cwd = repo_dir.path().to_path_buf();
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
let outcome = load_skills(&cfg);
assert!(
@@ -631,17 +708,25 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "dupe-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from repo".to_string(),
short_description: None,
path: normalized(&repo_skill_path),
scope: SkillScope::Repo,
}]
);
}
#[tokio::test]
async fn loads_system_skills_when_present() {
let codex_home = tempfile::tempdir().expect("tempdir");
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
write_skill(&codex_home, "user", "dupe-skill", "from user");
let _system_skill_path =
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
let cfg = make_config(&codex_home).await;
let outcome = load_skills(&cfg);
@@ -650,9 +735,16 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].description, "from user");
assert_eq!(outcome.skills[0].scope, SkillScope::User);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from user".to_string(),
short_description: None,
path: normalized(&user_skill_path),
scope: SkillScope::User,
}]
);
}
#[tokio::test]
@@ -672,15 +764,9 @@ mod tests {
"from outer",
);
let status = Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.status()
.expect("git init");
assert!(status.success(), "git init failed");
mark_as_git_repo(&repo_dir);
let mut cfg = make_config(&codex_home).await;
cfg.cwd = repo_dir;
let cfg = make_config_for_cwd(&codex_home, repo_dir).await;
let outcome = load_skills(&cfg);
assert!(
@@ -695,15 +781,9 @@ mod tests {
async fn loads_skills_when_cwd_is_file_in_repo() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
mark_as_git_repo(repo_dir.path());
let status = Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
write_skill_at(
let skill_path = write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
@@ -715,8 +795,7 @@ mod tests {
let file_path = repo_dir.path().join("some-file.txt");
fs::write(&file_path, "contents").unwrap();
let mut cfg = make_config(&codex_home).await;
cfg.cwd = file_path;
let cfg = make_config_for_cwd(&codex_home, file_path).await;
let outcome = load_skills(&cfg);
assert!(
@@ -724,9 +803,16 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "repo-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "repo-skill".to_string(),
description: "from repo".to_string(),
short_description: None,
path: normalized(&skill_path),
scope: SkillScope::Repo,
}]
);
}
#[tokio::test]
@@ -746,8 +832,7 @@ mod tests {
"from outer",
);
let mut cfg = make_config(&codex_home).await;
cfg.cwd = nested_dir;
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
let outcome = load_skills(&cfg);
assert!(
@@ -763,10 +848,9 @@ mod tests {
let codex_home = tempfile::tempdir().expect("tempdir");
let work_dir = tempfile::tempdir().expect("tempdir");
write_system_skill(&codex_home, "system", "system-skill", "from system");
let skill_path = write_system_skill(&codex_home, "system", "system-skill", "from system");
let mut cfg = make_config(&codex_home).await;
cfg.cwd = work_dir.path().to_path_buf();
let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await;
let outcome = load_skills(&cfg);
assert!(
@@ -774,9 +858,16 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "system-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::System);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "system-skill".to_string(),
description: "from system".to_string(),
short_description: None,
path: normalized(&skill_path),
scope: SkillScope::System,
}]
);
}
#[tokio::test]
@@ -800,8 +891,10 @@ mod tests {
let system_dir = tempfile::tempdir().expect("tempdir");
let admin_dir = tempfile::tempdir().expect("tempdir");
write_skill_at(system_dir.path(), "system", "dupe-skill", "from system");
write_skill_at(admin_dir.path(), "admin", "dupe-skill", "from admin");
let system_skill_path =
write_skill_at(system_dir.path(), "system", "dupe-skill", "from system");
let _admin_skill_path =
write_skill_at(admin_dir.path(), "admin", "dupe-skill", "from admin");
let outcome = load_skills_from_roots([
SkillRoot {
@@ -819,9 +912,16 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "dupe-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::System);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from system".to_string(),
short_description: None,
path: normalized(&system_skill_path),
scope: SkillScope::System,
}]
);
}
#[tokio::test]
@@ -829,11 +929,11 @@ mod tests {
let codex_home = tempfile::tempdir().expect("tempdir");
let work_dir = tempfile::tempdir().expect("tempdir");
write_skill(&codex_home, "user", "dupe-skill", "from user");
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
let _system_skill_path =
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
let mut cfg = make_config(&codex_home).await;
cfg.cwd = work_dir.path().to_path_buf();
let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await;
let outcome = load_skills(&cfg);
assert!(
@@ -841,24 +941,25 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "dupe-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::User);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from user".to_string(),
short_description: None,
path: normalized(&user_skill_path),
scope: SkillScope::User,
}]
);
}
#[tokio::test]
async fn deduplicates_by_name_preferring_repo_over_system() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
mark_as_git_repo(repo_dir.path());
let status = Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
write_skill_at(
let repo_skill_path = write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
@@ -867,10 +968,10 @@ mod tests {
"dupe-skill",
"from repo",
);
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
let _system_skill_path =
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
let mut cfg = make_config(&codex_home).await;
cfg.cwd = repo_dir.path().to_path_buf();
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
let outcome = load_skills(&cfg);
assert!(
@@ -878,8 +979,66 @@ mod tests {
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "dupe-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from repo".to_string(),
short_description: None,
path: normalized(&repo_skill_path),
scope: SkillScope::Repo,
}]
);
}
#[tokio::test]
async fn deduplicates_by_name_preferring_nearest_project_codex_dir() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
mark_as_git_repo(repo_dir.path());
let nested_dir = repo_dir.path().join("nested/inner");
fs::create_dir_all(&nested_dir).unwrap();
let _root_skill_path = write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"root",
"dupe-skill",
"from root",
);
let nested_skill_path = write_skill_at(
&repo_dir
.path()
.join("nested")
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"nested",
"dupe-skill",
"from nested",
);
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
let expected_path =
normalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone());
assert_eq!(
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from nested".to_string(),
short_description: None,
path: expected_path,
scope: SkillScope::Repo,
}],
outcome.skills
);
}
}

View File

@@ -3,10 +3,17 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::RwLock;
use codex_utils_absolute_path::AbsolutePathBuf;
use toml::Value as TomlValue;
use crate::config::Config;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::skills::SkillLoadOutcome;
use crate::skills::loader::load_skills_from_roots;
use crate::skills::loader::skill_roots_for_cwd;
use crate::skills::loader::skill_roots_from_layer_stack;
use crate::skills::system::install_system_skills;
pub struct SkillsManager {
codex_home: PathBuf,
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
@@ -24,11 +31,32 @@ impl SkillsManager {
}
}
pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome {
self.skills_for_cwd_with_options(cwd, false)
/// Load skills for an already-constructed [`Config`], avoiding any additional config-layer
/// loading. This also seeds the per-cwd cache for subsequent lookups.
pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome {
let cwd = &config.cwd;
let cached = match self.cache_by_cwd.read() {
Ok(cache) => cache.get(cwd).cloned(),
Err(err) => err.into_inner().get(cwd).cloned(),
};
if let Some(outcome) = cached {
return outcome;
}
let roots = skill_roots_from_layer_stack(&config.config_layer_stack);
let outcome = load_skills_from_roots(roots);
match self.cache_by_cwd.write() {
Ok(mut cache) => {
cache.insert(cwd.to_path_buf(), outcome.clone());
}
Err(err) => {
err.into_inner().insert(cwd.to_path_buf(), outcome.clone());
}
}
outcome
}
pub fn skills_for_cwd_with_options(&self, cwd: &Path, force_reload: bool) -> SkillLoadOutcome {
pub async fn skills_for_cwd(&self, cwd: &Path, force_reload: bool) -> SkillLoadOutcome {
let cached = match self.cache_by_cwd.read() {
Ok(cache) => cache.get(cwd).cloned(),
Err(err) => err.into_inner().get(cwd).cloned(),
@@ -37,7 +65,41 @@ impl SkillsManager {
return outcome;
}
let roots = skill_roots_for_cwd(&self.codex_home, cwd);
let cwd_abs = match AbsolutePathBuf::try_from(cwd) {
Ok(cwd_abs) => cwd_abs,
Err(err) => {
return SkillLoadOutcome {
errors: vec![crate::skills::model::SkillError {
path: cwd.to_path_buf(),
message: err.to_string(),
}],
..Default::default()
};
}
};
let cli_overrides: Vec<(String, TomlValue)> = Vec::new();
let config_layer_stack = match load_config_layers_state(
&self.codex_home,
Some(cwd_abs),
&cli_overrides,
LoaderOverrides::default(),
)
.await
{
Ok(config_layer_stack) => config_layer_stack,
Err(err) => {
return SkillLoadOutcome {
errors: vec![crate::skills::model::SkillError {
path: cwd.to_path_buf(),
message: err.to_string(),
}],
..Default::default()
};
}
};
let roots = skill_roots_from_layer_stack(&config_layer_stack);
let outcome = load_skills_from_roots(roots);
match self.cache_by_cwd.write() {
Ok(mut cache) => {
@@ -50,3 +112,52 @@ impl SkillsManager {
outcome
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::TempDir;
fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) {
let skill_dir = codex_home.path().join("skills").join(dir);
fs::create_dir_all(&skill_dir).unwrap();
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
}
#[tokio::test]
async fn skills_for_config_seeds_cache_by_cwd() {
let codex_home = tempfile::tempdir().expect("tempdir");
let cwd = tempfile::tempdir().expect("tempdir");
let cfg = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
})
.build()
.await
.expect("defaults for test should always succeed");
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf());
write_user_skill(&codex_home, "a", "skill-a", "from a");
let outcome1 = skills_manager.skills_for_config(&cfg);
assert!(
outcome1.skills.iter().any(|s| s.name == "skill-a"),
"expected skill-a to be discovered"
);
// Write a new skill after the first call; the second call should hit the cache and not
// reflect the new file.
write_user_skill(&codex_home, "b", "skill-b", "from b");
let outcome2 = skills_manager.skills_for_config(&cfg);
assert_eq!(outcome2.errors, outcome1.errors);
assert_eq!(outcome2.skills, outcome1.skills);
}
}

View File

@@ -7,7 +7,8 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
lines.push("## Skills".to_string());
lines.push("These skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.".to_string());
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
lines.push("### Available skills".to_string());
for skill in skills {
let path_str = skill.path.to_string_lossy().replace('\\', "/");
@@ -16,22 +17,22 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
lines.push(format!("- {name}: {description} (file: {path_str})"));
}
lines.push("### How to use skills".to_string());
lines.push(
r###"- Discovery: Available skills are listed in project docs and may also appear in a runtime "## Skills" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
4) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
- Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
.to_string(),

View File

@@ -97,7 +97,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
assert!(
end.aggregated_output
.contains("execpolicy forbids this command"),
.contains("policy forbids commands starting with `echo`"),
"unexpected output: {}",
end.aggregated_output
);

View File

@@ -11,11 +11,17 @@ pub struct Cli {
pub command: Option<Command>,
/// Optional image(s) to attach to the initial prompt.
#[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
#[arg(
long = "image",
short = 'i',
value_name = "FILE",
value_delimiter = ',',
num_args = 1..
)]
pub images: Vec<PathBuf>,
/// Model the agent should use.
#[arg(long, short = 'm')]
#[arg(long, short = 'm', global = true)]
pub model: Option<String>,
/// Use open-source provider.
@@ -37,7 +43,7 @@ pub struct Cli {
pub config_profile: Option<String>,
/// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write).
#[arg(long = "full-auto", default_value_t = false)]
#[arg(long = "full-auto", default_value_t = false, global = true)]
pub full_auto: bool,
/// Skip all confirmation prompts and execute commands without sandboxing.
@@ -46,6 +52,7 @@ pub struct Cli {
long = "dangerously-bypass-approvals-and-sandbox",
alias = "yolo",
default_value_t = false,
global = true,
conflicts_with = "full_auto"
)]
pub dangerously_bypass_approvals_and_sandbox: bool,
@@ -55,7 +62,7 @@ pub struct Cli {
pub cwd: Option<PathBuf>,
/// Allow running Codex outside a Git repository.
#[arg(long = "skip-git-repo-check", default_value_t = false)]
#[arg(long = "skip-git-repo-check", global = true, default_value_t = false)]
pub skip_git_repo_check: bool,
/// Additional directories that should be writable alongside the primary workspace.
@@ -74,7 +81,12 @@ pub struct Cli {
pub color: Color,
/// Print events to stdout as JSONL.
#[arg(long = "json", alias = "experimental-json", default_value_t = false)]
#[arg(
long = "json",
alias = "experimental-json",
default_value_t = false,
global = true
)]
pub json: bool,
/// Specifies file where the last message from the agent should be written.
@@ -107,6 +119,16 @@ pub struct ResumeArgs {
#[arg(long = "last", default_value_t = false)]
pub last: bool,
/// Optional image(s) to attach to the prompt sent after resuming.
#[arg(
long = "image",
short = 'i',
value_name = "FILE",
value_delimiter = ',',
num_args = 1
)]
pub images: Vec<PathBuf>,
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
pub prompt: Option<String>,

View File

@@ -335,6 +335,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
let prompt_text = resolve_prompt(prompt_arg);
let mut items: Vec<UserInput> = imgs
.into_iter()
.chain(args.images.into_iter())
.map(|path| UserInput::LocalImage { path })
.collect();
items.push(UserInput::Text {

View File

@@ -1,6 +1,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Context;
use core_test_support::test_codex_exec::test_codex_exec;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::path::Path;
use std::string::ToString;
@@ -69,6 +70,39 @@ fn extract_conversation_id(path: &std::path::Path) -> String {
.to_string()
}
fn last_user_image_count(path: &std::path::Path) -> usize {
let content = std::fs::read_to_string(path).unwrap_or_default();
let mut last_count = 0;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let Ok(item): Result<Value, _> = serde_json::from_str(line) else {
continue;
};
if item.get("type").and_then(|t| t.as_str()) != Some("response_item") {
continue;
}
let Some(payload) = item.get("payload") else {
continue;
};
if payload.get("type").and_then(|t| t.as_str()) != Some("message") {
continue;
}
if payload.get("role").and_then(|r| r.as_str()) != Some("user") {
continue;
}
let Some(content_items) = payload.get("content").and_then(|v| v.as_array()) else {
continue;
};
last_count = content_items
.iter()
.filter(|entry| entry.get("type").and_then(|t| t.as_str()) == Some("input_image"))
.count();
}
last_count
}
#[test]
fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
let test = test_codex_exec();
@@ -177,6 +211,41 @@ fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<(
Ok(())
}
#[test]
fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<()> {
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
// Seed a session.
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
.arg("echo seed-resume-session")
.assert()
.success();
// Resume while passing global flags after the subcommand to ensure clap accepts them.
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("resume")
.arg("--last")
.arg("--json")
.arg("--model")
.arg("gpt-5.2-codex")
.arg("--config")
.arg("reasoning_level=xhigh")
.arg("--dangerously-bypass-approvals-and-sandbox")
.arg("--skip-git-repo-check")
.arg("echo resume-with-global-flags-after-subcommand")
.assert()
.success();
Ok(())
}
#[test]
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let test = test_codex_exec();
@@ -309,3 +378,64 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
assert!(content.contains(&marker2));
Ok(())
}
#[test]
fn exec_resume_accepts_images_after_subcommand() -> anyhow::Result<()> {
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
let marker = format!("resume-image-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt)
.assert()
.success();
let image_path = test.cwd_path().join("resume_image.png");
let image_path_2 = test.cwd_path().join("resume_image_2.png");
let image_bytes: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00,
0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
std::fs::write(&image_path, image_bytes)?;
std::fs::write(&image_path_2, image_bytes)?;
let marker2 = format!("resume-image-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg("resume")
.arg("--last")
.arg("--image")
.arg(&image_path)
.arg("--image")
.arg(&image_path_2)
.arg(&prompt2)
.assert()
.success();
let sessions_dir = test.home_path().join("sessions");
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
.expect("no session file found after resume with images");
let image_count = last_user_image_count(&resumed_path);
assert_eq!(
image_count, 2,
"resume prompt should include both attached images"
);
Ok(())
}

View File

@@ -1,52 +1,65 @@
# codex-execpolicy
## Overview
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, match?, not_match?)`.
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, justification?, match?, not_match?)`.
- This release covers the prefix-rule subset of the execpolicy language; a richer language will follow.
- Tokens are matched in order; any `pattern` element may be a list to denote alternatives. `decision` defaults to `allow`; valid values: `allow`, `prompt`, `forbidden`.
- `justification` is an optional human-readable rationale for why a rule exists. It can be provided for any `decision` and may be surfaced in different contexts (for example, in approval prompts or rejection messages). When `decision = "forbidden"` is used, include a recommended alternative in the `justification`, when appropriate (e.g., ``"Use `jj` instead of `git`."``).
- `match` / `not_match` supply example invocations that are validated at load time (think of them as unit tests); examples can be token arrays or strings (strings are tokenized with `shlex`).
- The CLI always prints the JSON serialization of the evaluation result.
- The legacy rule matcher lives in `codex-execpolicy-legacy`.
## Policy shapes
- Prefix rules use Starlark syntax:
```starlark
prefix_rule(
pattern = ["cmd", ["alt1", "alt2"]], # ordered tokens; list entries denote alternatives
decision = "prompt", # allow | prompt | forbidden; defaults to allow
justification = "explain why this rule exists",
match = [["cmd", "alt1"], "cmd alt2"], # examples that must match this rule
not_match = [["cmd", "oops"], "cmd alt3"], # examples that must not match this rule
)
```
## CLI
- From the Codex CLI, run `codex execpolicy check` subcommand with one or more policy files (for example `src/default.rules`) to check a command:
```bash
codex execpolicy check --rules path/to/policy.rules git status
```
- Pass multiple `--rules` flags to merge rules, evaluated in the order provided, and use `--pretty` for formatted JSON.
- You can also run the standalone dev binary directly during development:
```bash
cargo run -p codex-execpolicy -- check --rules path/to/policy.rules git status
```
- Example outcomes:
- Match: `{"matchedRules":[{...}],"decision":"allow"}`
- No match: `{"matchedRules":[]}`
## Response shape
```json
{
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["<token>", "..."],
"decision": "allow|prompt|forbidden"
"decision": "allow|prompt|forbidden",
"justification": "..."
}
}
],
"decision": "allow|prompt|forbidden"
}
```
- When no rules match, `matchedRules` is an empty array and `decision` is omitted.
- `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched.
- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`).

View File

@@ -4,6 +4,7 @@
prefix_rule(
pattern = ["git", "reset", "--hard"],
decision = "forbidden",
justification = "destructive operation",
match = [
["git", "reset", "--hard"],
],

View File

@@ -11,6 +11,8 @@ pub enum Error {
InvalidPattern(String),
#[error("invalid example: {0}")]
InvalidExample(String),
#[error("invalid rule: {0}")]
InvalidRule(String),
#[error(
"expected every example to match at least one rule. rules: {rules:?}; unmatched examples: \
{examples:?}"

View File

@@ -212,6 +212,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
decision: Option<&'v str>,
r#match: Option<UnpackList<Value<'v>>>,
not_match: Option<UnpackList<Value<'v>>>,
justification: Option<&'v str>,
eval: &mut Evaluator<'v, '_, '_>,
) -> anyhow::Result<NoneType> {
let decision = match decision {
@@ -219,6 +220,14 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
None => Decision::Allow,
};
let justification = match justification {
Some(raw) if raw.trim().is_empty() => {
return Err(Error::InvalidRule("justification cannot be empty".to_string()).into());
}
Some(raw) => Some(raw.to_string()),
None => None,
};
let pattern_tokens = parse_pattern(pattern)?;
let matches: Vec<Vec<String>> =
@@ -246,6 +255,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
rest: rest.clone(),
},
decision,
justification: justification.clone(),
}) as RuleRef
})
.collect();

View File

@@ -46,6 +46,7 @@ impl Policy {
.into(),
},
decision,
justification: None,
});
self.rules_by_program.insert(first_token.clone(), rule);

View File

@@ -63,6 +63,12 @@ pub enum RuleMatch {
#[serde(rename = "matchedPrefix")]
matched_prefix: Vec<String>,
decision: Decision,
/// Optional rationale for why this rule exists.
///
/// This can be supplied for any decision and may be surfaced in different contexts
/// (e.g., prompt reasons or rejection messages).
#[serde(skip_serializing_if = "Option::is_none")]
justification: Option<String>,
},
HeuristicsRuleMatch {
command: Vec<String>,
@@ -83,6 +89,7 @@ impl RuleMatch {
pub struct PrefixRule {
pub pattern: PrefixPattern,
pub decision: Decision,
pub justification: Option<String>,
}
pub trait Rule: Any + Debug + Send + Sync {
@@ -104,6 +111,7 @@ impl Rule for PrefixRule {
.map(|matched_prefix| RuleMatch::PrefixRuleMatch {
matched_prefix,
decision: self.decision,
justification: self.justification.clone(),
})
}
}

View File

@@ -64,6 +64,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
decision: Decision::Allow,
justification: None,
}],
},
evaluation
@@ -71,6 +72,84 @@ prefix_rule(
Ok(())
}
#[test]
fn justification_is_attached_to_forbidden_matches() -> Result<()> {
let policy_src = r#"
prefix_rule(
pattern = ["rm"],
decision = "forbidden",
justification = "destructive command",
)
"#;
let mut parser = PolicyParser::new();
parser.parse("test.rules", policy_src)?;
let policy = parser.build();
let evaluation = policy.check(
&tokens(&["rm", "-rf", "/some/important/folder"]),
&allow_all,
);
assert_eq!(
Evaluation {
decision: Decision::Forbidden,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["rm"]),
decision: Decision::Forbidden,
justification: Some("destructive command".to_string()),
}],
},
evaluation
);
Ok(())
}
#[test]
fn justification_can_be_used_with_allow_decision() -> Result<()> {
let policy_src = r#"
prefix_rule(
pattern = ["ls"],
decision = "allow",
justification = "safe and commonly used",
)
"#;
let mut parser = PolicyParser::new();
parser.parse("test.rules", policy_src)?;
let policy = parser.build();
let evaluation = policy.check(&tokens(&["ls", "-l"]), &prompt_all);
assert_eq!(
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["ls"]),
decision: Decision::Allow,
justification: Some("safe and commonly used".to_string()),
}],
},
evaluation
);
Ok(())
}
#[test]
fn justification_cannot_be_empty() {
let policy_src = r#"
prefix_rule(
pattern = ["ls"],
decision = "prompt",
justification = " ",
)
"#;
let mut parser = PolicyParser::new();
let err = parser
.parse("test.rules", policy_src)
.expect_err("expected parse error");
assert!(
err.to_string()
.contains("invalid rule: justification cannot be empty")
);
}
#[test]
fn add_prefix_rule_extends_policy() -> Result<()> {
let mut policy = Policy::empty();
@@ -84,17 +163,19 @@ fn add_prefix_rule_extends_policy() -> Result<()> {
rest: vec![PatternToken::Single(String::from("-l"))].into(),
},
decision: Decision::Prompt,
justification: None,
})],
rules
);
let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]), &allow_all);
let evaluation = policy.check(&tokens(&["ls", "-l", "/some/important/folder"]), &allow_all);
assert_eq!(
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["ls", "-l"]),
decision: Decision::Prompt,
justification: None,
}],
},
evaluation
@@ -142,6 +223,7 @@ prefix_rule(
rest: Vec::<PatternToken>::new().into(),
},
decision: Decision::Prompt,
justification: None,
}),
RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
@@ -149,6 +231,7 @@ prefix_rule(
rest: vec![PatternToken::Single("commit".to_string())].into(),
},
decision: Decision::Forbidden,
justification: None,
}),
],
git_rules
@@ -161,6 +244,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
justification: None,
}],
},
status_eval
@@ -174,10 +258,12 @@ prefix_rule(
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
justification: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "commit"]),
decision: Decision::Forbidden,
justification: None,
},
],
},
@@ -211,6 +297,7 @@ prefix_rule(
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
},
decision: Decision::Allow,
justification: None,
})],
bash_rules
);
@@ -221,6 +308,7 @@ prefix_rule(
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
},
decision: Decision::Allow,
justification: None,
})],
sh_rules
);
@@ -232,6 +320,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["bash", "-c"]),
decision: Decision::Allow,
justification: None,
}],
},
bash_eval
@@ -244,6 +333,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["sh", "-l"]),
decision: Decision::Allow,
justification: None,
}],
},
sh_eval
@@ -277,6 +367,7 @@ prefix_rule(
.into(),
},
decision: Decision::Allow,
justification: None,
})],
rules
);
@@ -288,6 +379,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]),
decision: Decision::Allow,
justification: None,
}],
},
npm_i
@@ -303,6 +395,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["npm", "install", "--no-save"]),
decision: Decision::Allow,
justification: None,
}],
},
npm_install
@@ -332,6 +425,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
decision: Decision::Allow,
justification: None,
}],
},
match_eval
@@ -378,10 +472,12 @@ prefix_rule(
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
justification: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "commit"]),
decision: Decision::Forbidden,
justification: None,
},
],
},
@@ -419,14 +515,17 @@ prefix_rule(
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
justification: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
justification: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "commit"]),
decision: Decision::Forbidden,
justification: None,
},
],
},

View File

@@ -137,26 +137,27 @@ async fn poll_for_token(
}
}
fn print_device_code_prompt(code: &str) {
fn print_device_code_prompt(code: &str, issuer_base_url: &str) {
println!(
"\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\
\nFollow these steps to sign in with ChatGPT using device code authorization:\n\
\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\
\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}{issuer_base_url}/codex/device{ANSI_RESET}\n\
\n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\
\n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n",
version = env!("CARGO_PKG_VERSION"),
code = code
code = code,
issuer_base_url = issuer_base_url
);
}
/// Full device code login flow.
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
let client = reqwest::Client::new();
let base_url = opts.issuer.trim_end_matches('/');
let api_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/'));
let issuer_base_url = opts.issuer.trim_end_matches('/');
let api_base_url = format!("{issuer_base_url}/api/accounts");
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
print_device_code_prompt(&uc.user_code);
print_device_code_prompt(&uc.user_code, issuer_base_url);
let code_resp = poll_for_token(
&client,
@@ -171,10 +172,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
code_verifier: code_resp.code_verifier,
code_challenge: code_resp.code_challenge,
};
let redirect_uri = format!("{base_url}/deviceauth/callback");
let redirect_uri = format!("{issuer_base_url}/deviceauth/callback");
let tokens = crate::server::exchange_code_for_tokens(
base_url,
issuer_base_url,
&opts.client_id,
&redirect_uri,
&pkce,

View File

@@ -279,6 +279,12 @@ impl AuthModeWidget {
lines.push("".into());
lines.push(Line::from(state.auth_url.as_str().cyan().underlined()));
lines.push("".into());
lines.push(Line::from(vec![
" On a remote or headless machine? Use ".into(),
"codex login --device-auth".cyan(),
" instead".into(),
]));
lines.push("".into());
}
lines.push(" Press Esc to cancel".dim().into());

View File

@@ -279,6 +279,12 @@ impl AuthModeWidget {
lines.push("".into());
lines.push(Line::from(state.auth_url.as_str().cyan().underlined()));
lines.push("".into());
lines.push(Line::from(vec![
" On a remote or headless machine? Use ".into(),
"codex login --device-auth".cyan(),
" instead".into(),
]));
lines.push("".into());
}
lines.push(" Press Esc to cancel".dim().into());

View File

@@ -30,7 +30,7 @@ const SKIP_DIR_SUFFIXES: &[&str] = &[
"/programdata",
];
fn normalize_path_key(p: &Path) -> String {
pub(crate) fn normalize_path_key(p: &Path) -> String {
let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
n.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
}

View File

@@ -8,6 +8,7 @@ use codex_windows_sandbox::create_process_as_user;
use codex_windows_sandbox::create_readonly_token_with_cap_from;
use codex_windows_sandbox::create_workspace_write_token_with_cap_from;
use codex_windows_sandbox::get_current_token_for_restriction;
use codex_windows_sandbox::hide_current_user_profile_dir;
use codex_windows_sandbox::log_note;
use codex_windows_sandbox::parse_policy;
use codex_windows_sandbox::to_wide;
@@ -91,6 +92,7 @@ pub fn main() -> Result<()> {
}
let req: RunnerRequest = serde_json::from_str(&input).context("parse runner request json")?;
let log_dir = Some(req.codex_home.as_path());
hide_current_user_profile_dir(req.codex_home.as_path());
log_note(
&format!(
"runner start cwd={} cmd={:?} real_codex_home={}",

View File

@@ -0,0 +1,160 @@
#![cfg(target_os = "windows")]
use crate::logging::log_note;
use crate::winutil::format_last_error;
use crate::winutil::to_wide;
use anyhow::anyhow;
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use windows_sys::Win32::Foundation::GetLastError;
use windows_sys::Win32::Storage::FileSystem::GetFileAttributesW;
use windows_sys::Win32::Storage::FileSystem::SetFileAttributesW;
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_HIDDEN;
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_SYSTEM;
use windows_sys::Win32::Storage::FileSystem::INVALID_FILE_ATTRIBUTES;
use windows_sys::Win32::System::Registry::RegCloseKey;
use windows_sys::Win32::System::Registry::RegCreateKeyExW;
use windows_sys::Win32::System::Registry::RegSetValueExW;
use windows_sys::Win32::System::Registry::HKEY;
use windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE;
use windows_sys::Win32::System::Registry::KEY_WRITE;
use windows_sys::Win32::System::Registry::REG_DWORD;
use windows_sys::Win32::System::Registry::REG_OPTION_NON_VOLATILE;
const USERLIST_KEY_PATH: &str =
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList";
pub fn hide_newly_created_users(usernames: &[String], log_base: &Path) {
if usernames.is_empty() {
return;
}
if let Err(err) = hide_users_in_winlogon(usernames, log_base) {
log_note(
&format!("hide users: failed to update Winlogon UserList: {err}"),
Some(log_base),
);
}
}
/// Best-effort: hides the current sandbox user's profile directory once it exists.
///
/// Windows only creates profile directories when that user first logs in.
/// This intentionally runs in the command-runner (as the sandbox user) because
/// command running is what causes us to log in as a particular sandbox user.
pub fn hide_current_user_profile_dir(log_base: &Path) {
let Some(profile) = std::env::var_os("USERPROFILE") else {
return;
};
let profile_dir = PathBuf::from(profile);
if !profile_dir.exists() {
return;
}
match hide_directory(&profile_dir) {
Ok(true) => {
// Log only when we actually change attributes, so this stays one-time per profile dir.
log_note(
&format!(
"hide users: profile dir hidden for current user ({})",
profile_dir.display()
),
Some(log_base),
);
}
Ok(false) => {}
Err(err) => {
log_note(
&format!(
"hide users: failed to hide current user profile dir ({}): {err}",
profile_dir.display()
),
Some(log_base),
);
}
}
}
fn hide_users_in_winlogon(usernames: &[String], log_base: &Path) -> anyhow::Result<()> {
let key = create_userlist_key()?;
for username in usernames {
let name_w = to_wide(OsStr::new(username));
let value: u32 = 0;
let status = unsafe {
RegSetValueExW(
key,
name_w.as_ptr(),
0,
REG_DWORD,
&value as *const u32 as *const u8,
std::mem::size_of_val(&value) as u32,
)
};
if status != 0 {
log_note(
&format!(
"hide users: failed to set UserList value for {username}: {status} ({error})",
error = format_last_error(status as i32)
),
Some(log_base),
);
}
}
unsafe {
RegCloseKey(key);
}
Ok(())
}
fn create_userlist_key() -> anyhow::Result<HKEY> {
let key_path = to_wide(USERLIST_KEY_PATH);
let mut key: HKEY = 0;
let status = unsafe {
RegCreateKeyExW(
HKEY_LOCAL_MACHINE,
key_path.as_ptr(),
0,
std::ptr::null_mut(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
std::ptr::null_mut(),
&mut key,
std::ptr::null_mut(),
)
};
if status != 0 {
return Err(anyhow!(
"RegCreateKeyExW failed: {status} ({error})",
error = format_last_error(status as i32)
));
}
Ok(key)
}
/// Sets HIDDEN|SYSTEM on `path` if needed, returning whether it changed anything.
fn hide_directory(path: &Path) -> anyhow::Result<bool> {
let wide = to_wide(path);
let attrs = unsafe { GetFileAttributesW(wide.as_ptr()) };
if attrs == INVALID_FILE_ATTRIBUTES {
let err = unsafe { GetLastError() } as i32;
return Err(anyhow!(
"GetFileAttributesW failed for {}: {err} ({error})",
path.display(),
error = format_last_error(err)
));
}
let new_attrs = attrs | FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM;
if new_attrs == attrs {
return Ok(false);
}
let ok = unsafe { SetFileAttributesW(wide.as_ptr(), new_attrs) };
if ok == 0 {
let err = unsafe { GetLastError() } as i32;
return Err(anyhow!(
"SetFileAttributesW failed for {}: {err} ({error})",
path.display(),
error = format_last_error(err)
));
}
Ok(true)
}

View File

@@ -119,9 +119,10 @@ pub fn require_logon_sandbox_creds(
) -> Result<SandboxCreds> {
let sandbox_dir = crate::setup::sandbox_dir(codex_home);
let needed_read = gather_read_roots(command_cwd, policy);
let mut needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map);
// Ensure the sandbox directory itself is writable by sandbox users.
needed_write.push(sandbox_dir.clone());
let needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map);
// NOTE: Do not add CODEX_HOME/.sandbox to `needed_write`; it must remain non-writable by the
// restricted capability token. The setup helper's `lock_sandbox_dir` is responsible for
// granting the sandbox group access to this directory without granting the capability SID.
let mut setup_reason: Option<String> = None;
let mut _existing_marker: Option<SetupMarker> = None;

View File

@@ -5,7 +5,8 @@ macro_rules! windows_modules {
}
windows_modules!(
acl, allow, audit, cap, dpapi, env, identity, logging, policy, process, token, winutil
acl, allow, audit, cap, dpapi, env, hide_users, identity, logging, policy, process, token,
winutil
);
#[cfg(target_os = "windows")]
@@ -38,6 +39,10 @@ pub use dpapi::unprotect as dpapi_unprotect;
#[cfg(target_os = "windows")]
pub use elevated_impl::run_windows_sandbox_capture as run_windows_sandbox_capture_elevated;
#[cfg(target_os = "windows")]
pub use hide_users::hide_current_user_profile_dir;
#[cfg(target_os = "windows")]
pub use hide_users::hide_newly_created_users;
#[cfg(target_os = "windows")]
pub use identity::require_logon_sandbox_creds;
#[cfg(target_os = "windows")]
pub use logging::log_note;

View File

@@ -9,6 +9,7 @@ use base64::Engine;
use codex_windows_sandbox::convert_string_sid_to_sid;
use codex_windows_sandbox::ensure_allow_mask_aces_with_inheritance;
use codex_windows_sandbox::ensure_allow_write_aces;
use codex_windows_sandbox::hide_newly_created_users;
use codex_windows_sandbox::load_or_create_cap_sids;
use codex_windows_sandbox::log_note;
use codex_windows_sandbox::path_mask_allows;
@@ -448,6 +449,11 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
&payload.online_username,
log,
)?;
let users = vec![
payload.offline_username.clone(),
payload.online_username.clone(),
];
hide_newly_created_users(&users, sbx_dir);
}
let offline_sid = resolve_sid(&payload.offline_username)?;
let offline_sid_str = string_from_sid_bytes(&offline_sid).map_err(anyhow::Error::msg)?;

View File

@@ -408,15 +408,12 @@ fn build_payload_roots(
read_roots_override: Option<Vec<PathBuf>>,
write_roots_override: Option<Vec<PathBuf>>,
) -> (Vec<PathBuf>, Vec<PathBuf>) {
let sbx_dir = sandbox_dir(codex_home);
let mut write_roots = if let Some(roots) = write_roots_override {
let write_roots = if let Some(roots) = write_roots_override {
canonical_existing(&roots)
} else {
gather_write_roots(policy, policy_cwd, command_cwd, env_map)
};
if !write_roots.contains(&sbx_dir) {
write_roots.push(sbx_dir.clone());
}
let write_roots = filter_sensitive_write_roots(write_roots, codex_home);
let mut read_roots = if let Some(roots) = read_roots_override {
canonical_existing(&roots)
} else {
@@ -426,3 +423,17 @@ fn build_payload_roots(
read_roots.retain(|root| !write_root_set.contains(root));
(read_roots, write_roots)
}
fn filter_sensitive_write_roots(mut roots: Vec<PathBuf>, codex_home: &Path) -> Vec<PathBuf> {
// Never grant capability write access to CODEX_HOME or anything under CODEX_HOME/.sandbox.
// These locations contain sandbox control/state and must remain tamper-resistant.
let codex_home_key = crate::audit::normalize_path_key(codex_home);
let sbx_dir_key = crate::audit::normalize_path_key(&sandbox_dir(codex_home));
let sbx_dir_prefix = format!("{}/", sbx_dir_key.trim_end_matches('/'));
roots.retain(|root| {
let key = crate::audit::normalize_path_key(root);
key != codex_home_key && key != sbx_dir_key && !key.starts_with(&sbx_dir_prefix)
});
roots
}