From b34ed2ab831d9b11d77ce493371112fd7d67eccb Mon Sep 17 00:00:00 2001 From: oai-ragona <144164704+oai-ragona@users.noreply.github.com> Date: Thu, 24 Apr 2025 15:33:45 -0700 Subject: [PATCH 1/6] [codex-rs] More fine-grained sandbox flag support on Linux (#632) ##### What/Why This PR makes it so that in Linux we actually respect the different types of `--sandbox` flag, such that users can apply network and filesystem restrictions in combination (currently the only supported behavior), or just pick one or the other. We should add similar support for OSX in a future PR. ##### Testing From Linux devbox, updated tests to use more specific flags: ``` test linux::tests_linux::sandbox_blocks_ping ... ok test linux::tests_linux::sandbox_blocks_getent ... ok test linux::tests_linux::test_root_read ... ok test linux::tests_linux::test_dev_null_write ... ok test linux::tests_linux::sandbox_blocks_dev_tcp_redirection ... ok test linux::tests_linux::sandbox_blocks_ssh ... ok test linux::tests_linux::test_writable_root ... ok test linux::tests_linux::sandbox_blocks_curl ... ok test linux::tests_linux::sandbox_blocks_wget ... ok test linux::tests_linux::sandbox_blocks_nc ... ok test linux::tests_linux::test_root_write - should panic ... ok ``` ##### Todo - [ ] Add negative tests (e.g. confirm you can hit the network if you configure filesystem only restrictions) --- codex-rs/core/src/codex.rs | 4 +++ codex-rs/core/src/exec.rs | 11 ++++-- codex-rs/core/src/linux.rs | 63 ++++++++++++++++++++--------------- codex-rs/core/src/protocol.rs | 24 +++++++++++++ 4 files changed, 73 insertions(+), 29 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 74e466789e..e57d3bbf07 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -868,6 +868,7 @@ async fn handle_function_call( sandbox_type, &roots_snapshot, sess.ctrl_c.clone(), + sess.sandbox_policy, ) .await; @@ -952,11 +953,14 @@ async fn handle_function_call( let retry_roots = { sess.writable_roots.lock().unwrap().clone() }; + // This is an escalated retry; the policy will not be + // examined and the sandbox has been set to `None`. let retry_output_result = process_exec_tool_call( params.clone(), SandboxType::None, &retry_roots, sess.ctrl_c.clone(), + sess.sandbox_policy, ) .await; diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 1a92c8adc3..fe6bad548e 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -15,8 +15,10 @@ use tokio::sync::Notify; use crate::error::CodexErr; use crate::error::Result; use crate::error::SandboxErr; +use crate::protocol::SandboxPolicy; /// Maximum we keep for each stream (100 KiB). +/// TODO(ragona) this should be reduced const MAX_STREAM_OUTPUT: usize = 100 * 1024; const DEFAULT_TIMEOUT_MS: u64 = 10_000; @@ -55,8 +57,9 @@ async fn exec_linux( params: ExecParams, writable_roots: &[PathBuf], ctrl_c: Arc, + sandbox_policy: SandboxPolicy, ) -> Result { - crate::linux::exec_linux(params, writable_roots, ctrl_c).await + crate::linux::exec_linux(params, writable_roots, ctrl_c, sandbox_policy).await } #[cfg(not(target_os = "linux"))] @@ -64,6 +67,7 @@ async fn exec_linux( _params: ExecParams, _writable_roots: &[PathBuf], _ctrl_c: Arc, + _sandbox_policy: SandboxPolicy, ) -> Result { Err(CodexErr::Io(io::Error::new( io::ErrorKind::InvalidInput, @@ -76,6 +80,7 @@ pub async fn process_exec_tool_call( sandbox_type: SandboxType, writable_roots: &[PathBuf], ctrl_c: Arc, + sandbox_policy: SandboxPolicy, ) -> Result { let start = Instant::now(); @@ -98,7 +103,9 @@ pub async fn process_exec_tool_call( ) .await } - SandboxType::LinuxSeccomp => exec_linux(params, writable_roots, ctrl_c).await, + SandboxType::LinuxSeccomp => { + exec_linux(params, writable_roots, ctrl_c, sandbox_policy).await + } }; let duration = start.elapsed(); match raw_output_result { diff --git a/codex-rs/core/src/linux.rs b/codex-rs/core/src/linux.rs index f2dd9e6b96..61711c46bb 100644 --- a/codex-rs/core/src/linux.rs +++ b/codex-rs/core/src/linux.rs @@ -9,6 +9,7 @@ use crate::error::SandboxErr; use crate::exec::exec; use crate::exec::ExecParams; use crate::exec::RawExecToolCallOutput; +use crate::protocol::SandboxPolicy; use landlock::Access; use landlock::AccessFs; @@ -33,6 +34,7 @@ pub async fn exec_linux( params: ExecParams, writable_roots: &[PathBuf], ctrl_c: Arc, + sandbox_policy: SandboxPolicy, ) -> Result { // Allow READ on / // Allow WRITE on /dev/null @@ -47,34 +49,12 @@ pub async fn exec_linux( .expect("Failed to create runtime"); rt.block_on(async { - let abi = ABI::V5; - let access_rw = AccessFs::from_all(abi); - let access_ro = AccessFs::from_read(abi); - - let mut ruleset = Ruleset::default() - .set_compatibility(CompatLevel::BestEffort) - .handle_access(access_rw)? - .create()? - .add_rules(landlock::path_beneath_rules(&["/"], access_ro))? - .add_rules(landlock::path_beneath_rules(&["/dev/null"], access_rw))? - .set_no_new_privs(true); - - if !writable_roots_copy.is_empty() { - ruleset = ruleset.add_rules(landlock::path_beneath_rules( - &writable_roots_copy, - access_rw, - ))?; + if sandbox_policy.is_network_restricted() { + install_network_seccomp_filter_on_current_thread()?; } - let status = ruleset.restrict_self()?; - - // TODO(wpt): Probably wanna expand this more generically and not warn every time. - if status.ruleset == landlock::RulesetStatus::NotEnforced { - return Err(CodexErr::Sandbox(SandboxErr::LandlockRestrict)); - } - - if let Err(e) = install_network_seccomp_filter() { - return Err(CodexErr::Sandbox(e)); + if sandbox_policy.is_file_write_restricted() { + install_filesystem_landlock_rules_on_current_thread(writable_roots_copy)?; } exec(params, ctrl_c_copy).await @@ -92,7 +72,33 @@ pub async fn exec_linux( } } -fn install_network_seccomp_filter() -> std::result::Result<(), SandboxErr> { +fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec) -> Result<()> { + let abi = ABI::V5; + let access_rw = AccessFs::from_all(abi); + let access_ro = AccessFs::from_read(abi); + + let mut ruleset = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(access_rw)? + .create()? + .add_rules(landlock::path_beneath_rules(&["/"], access_ro))? + .add_rules(landlock::path_beneath_rules(&["/dev/null"], access_rw))? + .set_no_new_privs(true); + + if !writable_roots.is_empty() { + ruleset = ruleset.add_rules(landlock::path_beneath_rules(&writable_roots, access_rw))?; + } + + let status = ruleset.restrict_self()?; + + if status.ruleset == landlock::RulesetStatus::NotEnforced { + return Err(CodexErr::Sandbox(SandboxErr::LandlockRestrict)); + } + + Ok(()) +} + +fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> { // Build rule map. let mut rules: BTreeMap> = BTreeMap::new(); @@ -156,6 +162,7 @@ mod tests_linux { use crate::exec::process_exec_tool_call; use crate::exec::ExecParams; use crate::exec::SandboxType; + use crate::protocol::SandboxPolicy; use std::sync::Arc; use tempfile::NamedTempFile; use tokio::sync::Notify; @@ -172,6 +179,7 @@ mod tests_linux { SandboxType::LinuxSeccomp, writable_roots, Arc::new(Notify::new()), + SandboxPolicy::NetworkAndFileWriteRestricted, ) .await .unwrap(); @@ -238,6 +246,7 @@ mod tests_linux { SandboxType::LinuxSeccomp, &[], Arc::new(Notify::new()), + SandboxPolicy::NetworkRestricted, ) .await; diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index d1975ae847..42c8478e6b 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -100,6 +100,30 @@ pub enum SandboxPolicy { DangerousNoRestrictions, } +impl SandboxPolicy { + pub fn is_dangerous(&self) -> bool { + match self { + SandboxPolicy::NetworkRestricted => false, + SandboxPolicy::FileWriteRestricted => false, + SandboxPolicy::NetworkAndFileWriteRestricted => false, + SandboxPolicy::DangerousNoRestrictions => true, + } + } + + pub fn is_network_restricted(&self) -> bool { + matches!( + self, + SandboxPolicy::NetworkRestricted | SandboxPolicy::NetworkAndFileWriteRestricted + ) + } + + pub fn is_file_write_restricted(&self) -> bool { + matches!( + self, + SandboxPolicy::FileWriteRestricted | SandboxPolicy::NetworkAndFileWriteRestricted + ) + } +} /// User input #[non_exhaustive] #[derive(Debug, Clone, Deserialize, Serialize)] From bb2d411043cf48129998809643e44bd7e728d242 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 24 Apr 2025 16:38:57 -0700 Subject: [PATCH 2/6] fix: update scripts/build_container.sh to use pnpm instead of npm (#631) I suspect this is why some contributors kept accidentally including a new `codex-cli/package-lock.json` in their PRs. Note the `Dockerfile` still uses `npm` instead of `pnpm`, but that appears to be fine. (Probably nicer to globally install as few things as possible in the image.) --- codex-cli/scripts/build_container.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-cli/scripts/build_container.sh b/codex-cli/scripts/build_container.sh index fd4c8f5a86..d4d29f6b34 100755 --- a/codex-cli/scripts/build_container.sh +++ b/codex-cli/scripts/build_container.sh @@ -8,9 +8,9 @@ pushd "$SCRIPT_DIR/.." >> /dev/null || { echo "Error: Failed to change directory to $SCRIPT_DIR/.." exit 1 } -npm install -npm run build +pnpm install +pnpm run build rm -rf ./dist/openai-codex-*.tgz -npm pack --pack-destination ./dist +pnpm pack --pack-destination ./dist mv ./dist/openai-codex-*.tgz ./dist/codex.tgz docker build -t codex -f "./Dockerfile" . From 36a5a02d5ce01bf258e0ae23c172d748df3975d6 Mon Sep 17 00:00:00 2001 From: sooraj Date: Fri, 25 Apr 2025 05:26:00 +0530 Subject: [PATCH 3/6] feat: display error on selection of invalid model (#594) Up-to-date of #78 Fixes #32 addressed requested changes @tibo-openai :) made sense to me though, previous rationale with passing the state up was assuming there could be a future need to have a shared state with all available models being available to the parent --- .../src/components/chat/terminal-chat.tsx | 17 ++- codex-cli/src/components/model-overlay.tsx | 9 +- .../terminal-chat-model-selection.test.tsx | 130 ++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 codex-cli/tests/terminal-chat-model-selection.test.tsx diff --git a/codex-cli/src/components/chat/terminal-chat.tsx b/codex-cli/src/components/chat/terminal-chat.tsx index e3638ac3f1..7f59c0b3c4 100644 --- a/codex-cli/src/components/chat/terminal-chat.tsx +++ b/codex-cli/src/components/chat/terminal-chat.tsx @@ -31,6 +31,7 @@ import DiffOverlay from "../diff-overlay.js"; import HelpOverlay from "../help-overlay.js"; import HistoryOverlay from "../history-overlay.js"; import ModelOverlay from "../model-overlay.js"; +import chalk from "chalk"; import { Box, Text } from "ink"; import { spawn } from "node:child_process"; import OpenAI from "openai"; @@ -575,7 +576,7 @@ export default function TerminalChat({ providers={config.providers} currentProvider={provider} hasLastResponse={Boolean(lastResponseId)} - onSelect={(newModel) => { + onSelect={(allModels, newModel) => { log( "TerminalChat: interruptAgent invoked – calling agent.cancel()", ); @@ -585,6 +586,20 @@ export default function TerminalChat({ agent?.cancel(); setLoading(false); + if (!allModels?.includes(newModel)) { + // eslint-disable-next-line no-console + console.error( + chalk.bold.red( + `Model "${chalk.yellow( + newModel, + )}" is not available for provider "${chalk.yellow( + provider, + )}".`, + ), + ); + return; + } + setModel(newModel); setLastResponseId((prev) => prev && newModel !== model ? null : prev, diff --git a/codex-cli/src/components/model-overlay.tsx b/codex-cli/src/components/model-overlay.tsx index c9dde0e6b4..28b2575a71 100644 --- a/codex-cli/src/components/model-overlay.tsx +++ b/codex-cli/src/components/model-overlay.tsx @@ -19,7 +19,7 @@ type Props = { currentProvider?: string; hasLastResponse: boolean; providers?: Record; - onSelect: (model: string) => void; + onSelect: (allModels: Array, model: string) => void; onSelectProvider?: (provider: string) => void; onExit: () => void; }; @@ -153,7 +153,12 @@ export default function ModelOverlay({ } initialItems={items} currentValue={currentModel} - onSelect={onSelect} + onSelect={() => + onSelect( + items?.map((m) => m.value), + currentModel, + ) + } onExit={onExit} /> ); diff --git a/codex-cli/tests/terminal-chat-model-selection.test.tsx b/codex-cli/tests/terminal-chat-model-selection.test.tsx new file mode 100644 index 0000000000..4e2bd5e999 --- /dev/null +++ b/codex-cli/tests/terminal-chat-model-selection.test.tsx @@ -0,0 +1,130 @@ +/* eslint-disable no-console */ +import { renderTui } from "./ui-test-helpers.js"; +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import chalk from "chalk"; +import ModelOverlay from "src/components/model-overlay.js"; + +// Mock the necessary dependencies +vi.mock("../src/utils/logger/log.js", () => ({ + log: vi.fn(), +})); + +vi.mock("chalk", () => ({ + default: { + bold: { + red: vi.fn((msg) => `[bold-red]${msg}[/bold-red]`), + }, + yellow: vi.fn((msg) => `[yellow]${msg}[/yellow]`), + }, +})); + +describe("Model Selection Error Handling", () => { + // Create a console.error spy with proper typing + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleErrorSpy.mockRestore(); + }); + + it("should display error with chalk formatting when selecting unavailable model", () => { + // Setup + const allModels = ["gpt-4", "gpt-3.5-turbo"]; + const currentModel = "gpt-4"; + const unavailableModel = "gpt-invalid"; + const currentProvider = "openai"; + + renderTui( + { + if (!models?.includes(newModel)) { + console.error( + chalk.bold.red( + `Model "${chalk.yellow( + newModel, + )}" is not available for provider "${chalk.yellow( + currentProvider, + )}".`, + ), + ); + return; + } + }} + onSelectProvider={() => {}} + onExit={() => {}} + />, + ); + + const onSelectHandler = vi.fn((models, newModel) => { + if (!models?.includes(newModel)) { + console.error( + chalk.bold.red( + `Model "${chalk.yellow( + newModel, + )}" is not available for provider "${chalk.yellow( + currentProvider, + )}".`, + ), + ); + return; + } + }); + + onSelectHandler(allModels, unavailableModel); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(chalk.bold.red).toHaveBeenCalled(); + expect(chalk.yellow).toHaveBeenCalledWith(unavailableModel); + expect(chalk.yellow).toHaveBeenCalledWith(currentProvider); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + `[bold-red]Model "[yellow]${unavailableModel}[/yellow]" is not available for provider "[yellow]${currentProvider}[/yellow]".[/bold-red]`, + ); + }); + + it("should not proceed with model change when model is unavailable", () => { + const mockSetModel = vi.fn(); + const mockSetLastResponseId = vi.fn(); + const mockSaveConfig = vi.fn(); + const mockSetItems = vi.fn(); + const mockSetOverlayMode = vi.fn(); + + const onSelectHandler = vi.fn((allModels, newModel) => { + if (!allModels?.includes(newModel)) { + console.error( + chalk.bold.red( + `Model "${chalk.yellow( + newModel, + )}" is not available for provider "${chalk.yellow("openai")}".`, + ), + ); + return; + } + + mockSetModel(newModel); + mockSetLastResponseId(null); + mockSaveConfig({}); + mockSetItems((prev: Array) => [...prev, {}]); + mockSetOverlayMode("none"); + }); + + onSelectHandler(["gpt-4", "gpt-3.5-turbo"], "gpt-invalid"); + + expect(mockSetModel).not.toHaveBeenCalled(); + expect(mockSetLastResponseId).not.toHaveBeenCalled(); + expect(mockSaveConfig).not.toHaveBeenCalled(); + expect(mockSetItems).not.toHaveBeenCalled(); + expect(mockSetOverlayMode).not.toHaveBeenCalled(); + + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); From 5e40d9d2211737f46136610497bcd9a8271009e0 Mon Sep 17 00:00:00 2001 From: nvp159 Date: Fri, 25 Apr 2025 05:30:14 +0530 Subject: [PATCH 4/6] feat(bug-report): print bug report URL in terminal instead of opening browser (#510) (#528) Solves #510 This PR changes the `/bug` command to print the URL into the terminal (so it works in headless sessions) instead of trying to open a browser. --------- Co-authored-by: Thibault Sottiaux --- .../src/components/chat/terminal-chat-input.tsx | 17 +++-------------- codex-cli/src/components/help-overlay.tsx | 3 ++- codex-cli/src/utils/slash-commands.ts | 5 ++++- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index aad2e6b3c3..070f08f0ca 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -470,15 +470,8 @@ export default function TerminalChatInput({ setInput(""); try { - // Dynamically import dependencies to avoid unnecessary bundle size - const [{ default: open }, os] = await Promise.all([ - import("open"), - import("node:os"), - ]); - - // Lazy import CLI_VERSION to avoid circular deps + const os = await import("node:os"); const { CLI_VERSION } = await import("../../utils/session.js"); - const { buildBugReportUrl } = await import( "../../utils/bug-report.js" ); @@ -492,10 +485,6 @@ export default function TerminalChatInput({ .join(" | "), }); - // Open the URL in the user's default browser - await open(url, { wait: false }); - - // Inform the user in the chat history setItems((prev) => [ ...prev, { @@ -505,13 +494,13 @@ export default function TerminalChatInput({ content: [ { type: "input_text", - text: "📋 Opened browser to file a bug report. Please include any context that might help us fix the issue!", + text: `🔗 Bug report URL: ${url}`, }, ], }, ]); } catch (error) { - // If anything went wrong, notify the user + // If anything went wrong, notify the user. setItems((prev) => [ ...prev, { diff --git a/codex-cli/src/components/help-overlay.tsx b/codex-cli/src/components/help-overlay.tsx index 6eeffb9efb..d302f7551e 100644 --- a/codex-cli/src/components/help-overlay.tsx +++ b/codex-cli/src/components/help-overlay.tsx @@ -53,7 +53,8 @@ export default function HelpOverlay({ /clearhistory – clear command history - /bug – file a bug report with session log + /bug – generate a prefilled GitHub issue URL + with session log /diff – view working tree git diff diff --git a/codex-cli/src/utils/slash-commands.ts b/codex-cli/src/utils/slash-commands.ts index b276c49135..4ccc3a9fc5 100644 --- a/codex-cli/src/utils/slash-commands.ts +++ b/codex-cli/src/utils/slash-commands.ts @@ -23,7 +23,10 @@ export const SLASH_COMMANDS: Array = [ { command: "/help", description: "Show list of commands" }, { command: "/model", description: "Open model selection panel" }, { command: "/approval", description: "Open approval mode selection panel" }, - { command: "/bug", description: "Generate a prefilled GitHub bug report" }, + { + command: "/bug", + description: "Generate a prefilled GitHub issue URL with session log", + }, { command: "/diff", description: From 58f0e5ab747a12d799ec4030ed077cce8ee84095 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 24 Apr 2025 17:14:47 -0700 Subject: [PATCH 5/6] feat: introduce codex_execpolicy crate for defining "safe" commands (#634) As described in detail in `codex-rs/execpolicy/README.md` introduced in this PR, `execpolicy` is a tool that lets you define a set of _patterns_ used to match [`execv(3)`](https://linux.die.net/man/3/execv) invocations. When a pattern is matched, `execpolicy` returns the parsed version in a structured form that is amenable to static analysis. The primary use case is to define patterns match commands that should be auto-approved by a tool such as Codex. This supports a richer pattern matching mechanism that the sort of prefix-matching we have done to date, e.g.: https://github.com/openai/codex/blob/5e40d9d2211737f46136610497bcd9a8271009e0/codex-cli/src/approvals.ts#L333-L354 Note we are still playing with the API and the `system_path` option in particular still needs some work. --- codex-rs/Cargo.lock | 1063 ++++++++++++++++- codex-rs/Cargo.toml | 1 + codex-rs/execpolicy/Cargo.toml | 28 + codex-rs/execpolicy/README.md | 180 +++ codex-rs/execpolicy/build.rs | 3 + codex-rs/execpolicy/src/arg_matcher.rs | 118 ++ codex-rs/execpolicy/src/arg_resolver.rs | 194 +++ codex-rs/execpolicy/src/arg_type.rs | 87 ++ codex-rs/execpolicy/src/default.policy | 202 ++++ codex-rs/execpolicy/src/error.rs | 96 ++ codex-rs/execpolicy/src/exec_call.rs | 28 + codex-rs/execpolicy/src/execv_checker.rs | 263 ++++ codex-rs/execpolicy/src/lib.rs | 45 + codex-rs/execpolicy/src/main.rs | 166 +++ codex-rs/execpolicy/src/opt.rs | 77 ++ codex-rs/execpolicy/src/policy.rs | 103 ++ codex-rs/execpolicy/src/policy_parser.rs | 222 ++++ codex-rs/execpolicy/src/program.rs | 247 ++++ codex-rs/execpolicy/src/sed_command.rs | 17 + codex-rs/execpolicy/src/valid_exec.rs | 95 ++ codex-rs/execpolicy/tests/bad.rs | 9 + codex-rs/execpolicy/tests/cp.rs | 85 ++ codex-rs/execpolicy/tests/good.rs | 9 + codex-rs/execpolicy/tests/head.rs | 132 ++ codex-rs/execpolicy/tests/literal.rs | 50 + codex-rs/execpolicy/tests/ls.rs | 166 +++ .../execpolicy/tests/parse_sed_command.rs | 23 + codex-rs/execpolicy/tests/pwd.rs | 85 ++ codex-rs/execpolicy/tests/sed.rs | 83 ++ 29 files changed, 3830 insertions(+), 47 deletions(-) create mode 100644 codex-rs/execpolicy/Cargo.toml create mode 100644 codex-rs/execpolicy/README.md create mode 100644 codex-rs/execpolicy/build.rs create mode 100644 codex-rs/execpolicy/src/arg_matcher.rs create mode 100644 codex-rs/execpolicy/src/arg_resolver.rs create mode 100644 codex-rs/execpolicy/src/arg_type.rs create mode 100644 codex-rs/execpolicy/src/default.policy create mode 100644 codex-rs/execpolicy/src/error.rs create mode 100644 codex-rs/execpolicy/src/exec_call.rs create mode 100644 codex-rs/execpolicy/src/execv_checker.rs create mode 100644 codex-rs/execpolicy/src/lib.rs create mode 100644 codex-rs/execpolicy/src/main.rs create mode 100644 codex-rs/execpolicy/src/opt.rs create mode 100644 codex-rs/execpolicy/src/policy.rs create mode 100644 codex-rs/execpolicy/src/policy_parser.rs create mode 100644 codex-rs/execpolicy/src/program.rs create mode 100644 codex-rs/execpolicy/src/sed_command.rs create mode 100644 codex-rs/execpolicy/src/valid_exec.rs create mode 100644 codex-rs/execpolicy/tests/bad.rs create mode 100644 codex-rs/execpolicy/tests/cp.rs create mode 100644 codex-rs/execpolicy/tests/good.rs create mode 100644 codex-rs/execpolicy/tests/head.rs create mode 100644 codex-rs/execpolicy/tests/literal.rs create mode 100644 codex-rs/execpolicy/tests/ls.rs create mode 100644 codex-rs/execpolicy/tests/parse_sed_command.rs create mode 100644 codex-rs/execpolicy/tests/pwd.rs create mode 100644 codex-rs/execpolicy/tests/sed.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f9f5860861..1f91c0072b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -17,6 +27,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy 0.7.35", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -26,6 +48,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocative" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fac2ce611db8b8cee9b2aa886ca03c924e9da5e5295d0dbd0526e5d0b0710f7" +dependencies = [ + "allocative_derive", + "bumpalo", + "ctor", + "hashbrown 0.14.5", + "num-bigint", +] + +[[package]] +name = "allocative_derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe233a377643e0fc1a56421d7c90acdec45c291b30345eb9f08e8d0ddce5a4ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -47,6 +93,15 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" +dependencies = [ + "unicode-width 0.1.14", +] + [[package]] name = "ansi-to-tui" version = "7.0.0" @@ -128,6 +183,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -174,7 +238,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -222,6 +286,33 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.0" @@ -262,6 +353,12 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -298,6 +395,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "chrono" version = "0.4.40" @@ -308,6 +411,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -331,7 +435,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", "terminal_size", ] @@ -344,7 +448,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -353,6 +457,21 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "clipboard-win" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmp_any" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" + [[package]] name = "codex-ansi-escape" version = "0.1.0" @@ -445,6 +564,26 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "codex-execpolicy" +version = "0.1.0" +dependencies = [ + "allocative", + "anyhow", + "clap", + "derive_more", + "env_logger", + "log", + "multimap", + "path-absolutize", + "regex", + "serde", + "serde_json", + "serde_with", + "starlark", + "tempfile", +] + [[package]] name = "codex-interactive" version = "0.1.0" @@ -551,6 +690,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -588,7 +736,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.9.0", "crossterm_winapi", "mio", "parking_lot", @@ -607,6 +755,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" + +[[package]] +name = "ctor" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "darling" version = "0.20.11" @@ -627,8 +791,8 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", - "syn", + "strsim 0.11.1", + "syn 2.0.100", ] [[package]] @@ -639,7 +803,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -660,6 +824,17 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "debugserver-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf6834a70ed14e8e4e41882df27190bea150f1f6ecf461f1033f8739cd8af4a" +dependencies = [ + "schemafy", + "serde", + "serde_json", +] + [[package]] name = "deranged" version = "0.4.0" @@ -667,6 +842,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", + "serde", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.100", + "unicode-xid", ] [[package]] @@ -701,6 +910,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -713,6 +932,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "display_container" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a110a75c96bedec8e65823dea00a1d710288b7a369d95fd8a0f5127639466fa" +dependencies = [ + "either", + "indenter", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -721,7 +961,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -730,12 +970,41 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dupe" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed2bc011db9c93fbc2b6cdb341a53737a55bafb46dbb74cf6764fc33a2fbf9c" +dependencies = [ + "dupe_derive", +] + +[[package]] +name = "dupe_derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "ena" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +dependencies = [ + "log", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -745,6 +1014,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enumflags2" version = "0.7.11" @@ -762,7 +1037,7 @@ checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -771,12 +1046,44 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbfd0e7fc632dec5e6c9396a27bc9f9975b4e039720e1fd3e34021d3ce28c415" +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + [[package]] name = "errno" version = "0.3.11" @@ -787,6 +1094,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "error-code" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" + [[package]] name = "event-listener" version = "5.4.0" @@ -846,6 +1159,23 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.0.5", + "windows-sys 0.59.0", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "float-cmp" version = "0.10.0" @@ -956,7 +1286,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -989,6 +1319,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1041,13 +1380,29 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.9.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.2" @@ -1071,6 +1426,27 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "http" version = "1.3.1" @@ -1330,7 +1706,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -1366,6 +1742,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.9.0" @@ -1373,7 +1760,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", + "serde", ] [[package]] @@ -1392,7 +1780,16 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.100", +] + +[[package]] +name = "inventory" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83" +dependencies = [ + "rustversion", ] [[package]] @@ -1401,12 +1798,32 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi 0.5.0", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1422,6 +1839,30 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1432,6 +1873,37 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lalrpop" +version = "0.19.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a1cbf952127589f2851ab2046af368fd20645491bb4b376f04b7f94d7a9837b" +dependencies = [ + "ascii-canvas", + "bit-set", + "diff", + "ena", + "is-terminal", + "itertools 0.10.5", + "lalrpop-util", + "petgraph", + "regex", + "regex-syntax 0.6.29", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", +] + +[[package]] +name = "lalrpop-util" +version = "0.19.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3c48237b9604c5a4702de6b824e02006c3214327564636aef27c1028a8fa0ed" +dependencies = [ + "regex", +] + [[package]] name = "landlock" version = "0.4.1" @@ -1461,7 +1933,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags", + "bitflags 2.9.0", "libc", ] @@ -1499,15 +1971,57 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "logos" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf8b031682c67a8e3d5446840f9573eb7fe26efe7ec8d195c9ac4c0647c502f1" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d849148dbaf9661a6151d1ca82b13bb4c4c128146a88d05253b38d4e2f496c" +dependencies = [ + "beef", + "fnv", + "proc-macro2", + "quote", + "regex-syntax 0.6.29", + "syn 1.0.109", +] + [[package]] name = "lru" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.2", ] +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "matchers" version = "0.1.0" @@ -1523,6 +2037,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1566,6 +2089,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "multimap" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +dependencies = [ + "serde", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1583,6 +2115,33 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1620,12 +2179,31 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1641,7 +2219,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", ] @@ -1666,7 +2244,7 @@ version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ - "bitflags", + "bitflags 2.9.0", "cfg-if", "foreign-types", "libc", @@ -1683,7 +2261,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -1784,12 +2362,49 @@ dependencies = [ "nom_locate", ] +[[package]] +name = "path-absolutize" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +dependencies = [ + "path-dedot", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.9.0", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1808,6 +2423,21 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1820,9 +2450,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy", + "zerocopy 0.8.24", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "predicates" version = "3.1.3" @@ -1897,6 +2533,16 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.9.1" @@ -1932,13 +2578,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.9.0", "cassowary", "compact_str", "crossterm", "indoc", "instability", - "itertools", + "itertools 0.13.0", "lru", "paste", "strum", @@ -1959,7 +2605,7 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" dependencies = [ - "bitflags", + "bitflags 2.9.0", ] [[package]] @@ -1973,6 +2619,17 @@ dependencies = [ "rust-argon2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.0" @@ -1984,6 +2641,26 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "regex" version = "1.11.1" @@ -2112,7 +2789,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.9.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2125,7 +2802,7 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" dependencies = [ - "bitflags", + "bitflags 2.9.0", "errno", "libc", "linux-raw-sys 0.9.4", @@ -2177,6 +2854,28 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.1.14", + "utf8parse", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.20" @@ -2192,6 +2891,48 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemafy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aea5ba40287dae331f2c48b64dbc8138541f5e97ee8793caa7948c1f31d86d5" +dependencies = [ + "Inflector", + "schemafy_core", + "schemafy_lib", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "syn 1.0.109", +] + +[[package]] +name = "schemafy_core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41781ae092f4fd52c9287efb74456aea0d3b90032d2ecad272bd14dbbcb0511b" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "schemafy_lib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e953db32579999ca98c451d80801b6f6a7ecba6127196c5387ec0774c528befa" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "schemafy_core", + "serde", + "serde_derive", + "serde_json", + "syn 1.0.109", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2213,7 +2954,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.9.0", "core-foundation", "core-foundation-sys", "libc", @@ -2247,7 +2988,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -2256,13 +2997,24 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ - "indexmap", + "indexmap 2.9.0", "itoa", "memchr", "ryu", "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -2284,6 +3036,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2341,6 +3123,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.9" @@ -2372,6 +3160,96 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "starlark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f53849859f05d9db705b221bd92eede93877fd426c1b4a3c3061403a5912a8f" +dependencies = [ + "allocative", + "anyhow", + "bumpalo", + "cmp_any", + "debugserver-types", + "derivative", + "derive_more", + "display_container", + "dupe", + "either", + "erased-serde", + "hashbrown 0.14.5", + "inventory", + "itertools 0.13.0", + "maplit", + "memoffset", + "num-bigint", + "num-traits", + "once_cell", + "paste", + "ref-cast", + "regex", + "rustyline", + "serde", + "serde_json", + "starlark_derive", + "starlark_map", + "starlark_syntax", + "static_assertions", + "strsim 0.10.0", + "textwrap", + "thiserror 1.0.69", +] + +[[package]] +name = "starlark_derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe58bc6c8b7980a1fe4c9f8f48200c3212db42ebfe21ae6a0336385ab53f082a" +dependencies = [ + "dupe", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "starlark_map" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92659970f120df0cc1c0bb220b33587b7a9a90e80d4eecc5c5af5debb950173d" +dependencies = [ + "allocative", + "dupe", + "equivalent", + "fxhash", + "hashbrown 0.14.5", + "serde", +] + +[[package]] +name = "starlark_syntax" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe53b3690d776aafd7cb6b9fed62d94f83280e3b87d88e3719cc0024638461b3" +dependencies = [ + "allocative", + "annotate-snippets", + "anyhow", + "derivative", + "derive_more", + "dupe", + "lalrpop", + "lalrpop-util", + "logos", + "lsp-types", + "memchr", + "num-bigint", + "num-traits", + "once_cell", + "starlark_map", + "thiserror 1.0.69", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -2384,6 +3262,24 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -2409,7 +3305,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.100", ] [[package]] @@ -2418,6 +3314,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.100" @@ -2446,7 +3353,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -2455,7 +3362,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.9.0", "core-foundation", "system-configuration-sys", ] @@ -2483,6 +3390,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "terminal_size" version = "0.4.2" @@ -2499,6 +3417,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width 0.1.14", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2525,7 +3452,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -2536,7 +3463,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -2580,6 +3507,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -2615,7 +3551,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -2678,7 +3614,7 @@ version = "0.22.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ - "indexmap", + "indexmap 2.9.0", "serde", "serde_spanned", "toml_datetime", @@ -2744,7 +3680,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -2877,7 +3813,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -2894,6 +3830,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2909,6 +3851,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2941,6 +3884,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -3002,7 +3951,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.100", "wasm-bindgen-shared", ] @@ -3037,7 +3986,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3117,7 +4066,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -3128,7 +4077,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -3360,7 +4309,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.0", ] [[package]] @@ -3401,17 +4350,37 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive 0.7.35", +] + [[package]] name = "zerocopy" version = "0.8.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" dependencies = [ - "zerocopy-derive", + "zerocopy-derive 0.8.24", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", ] [[package]] @@ -3422,7 +4391,7 @@ checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -3442,7 +4411,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", "synstructure", ] @@ -3471,5 +4440,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f3f66eb2d7..69c4e8a8a0 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -6,6 +6,7 @@ members = [ "cli", "core", "exec", + "execpolicy", "interactive", "repl", "tui", diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml new file mode 100644 index 0000000000..6d8fd5ac05 --- /dev/null +++ b/codex-rs/execpolicy/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "codex-execpolicy" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "codex-execpolicy" +path = "src/main.rs" + +[lib] +name = "codex_execpolicy" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +starlark = "0.13.0" +allocative = "0.3.3" +clap = { version = "4", features = ["derive"] } +derive_more = { version = "1", features = ["display"] } +env_logger = "0.11.5" +log = "0.4" +multimap = "0.10.0" +path-absolutize = "3.1.1" +regex = "1.11.1" +serde = { version = "1.0.194", features = ["derive"] } +serde_json = "1.0.110" +serde_with = { version = "3", features = ["macros"] } +tempfile = "3.13.0" diff --git a/codex-rs/execpolicy/README.md b/codex-rs/execpolicy/README.md new file mode 100644 index 0000000000..ca95829440 --- /dev/null +++ b/codex-rs/execpolicy/README.md @@ -0,0 +1,180 @@ +# codex_execpolicy + +The goal of this library is to classify a proposed [`execv(3)`](https://linux.die.net/man/3/execv) command into one of the following states: + +- `safe` The command is safe to run (\*). +- `match` The command matched a rule in the policy, but the caller should decide whether it is safe to run based on the files it will write. +- `forbidden` The command is not allowed to be run. +- `unverified` The safety cannot be determined: make the user decide. + +(\*) Whether an `execv(3)` call should be considered "safe" often requires additional context beyond the arguments to `execv()` itself. For example, if you trust an autonomous software agent to write files in your source tree, then deciding whether `/bin/cp foo bar` is "safe" depends on `getcwd(3)` for the calling process as well as the `realpath` of `foo` and `bar` when resolved against `getcwd()`. +To that end, rather than returning a boolean, the validator returns a structured result that the client is expected to use to determine the "safety" of the proposed `execv()` call. + +For example, to check the command `ls -l foo`, the checker would be invoked as follows: + +```shell +cargo run -- check ls -l foo | jq +``` + +It will exit with `0` and print the following to stdout: + +```json +{ + "result": "safe", + "match": { + "program": "ls", + "flags": [ + { + "name": "-l" + } + ], + "opts": [], + "args": [ + { + "index": 1, + "type": "ReadableFile", + "value": "foo" + } + ], + "system_path": ["/bin/ls", "/usr/bin/ls"] + } +} +``` + +Of note: + +- `foo` is tagged as a `ReadableFile`, so the caller should resolve `foo` relative to `getcwd()` and `realpath` it (as it may be a symlink) to determine whether `foo` is safe to read. +- While the specified executable is `ls`, `"system_path"` offers `/bin/ls` and `/usr/bin/ls` as viable alternatives to avoid using whatever `ls` happens to appear first on the user's `$PATH`. If either exists on the host, it is recommended to use it as the first argument to `execv(3)` instead of `ls`. + +Further, "safety" in this system is not a guarantee that the command will execute successfully. As an example, `cat /Users/mbolin/code/codex/README.md` may be considered "safe" if the system has decided the agent is allowed to read anything under `/Users/mbolin/code/codex`, but it will fail at runtime if `README.md` does not exist. (Though this is "safe" in that the agent did not read any files that it was not authorized to read.) + +## Policy + +Currently, the default policy is defined in [`default.policy`](./src/default.policy) within the crate. + +The system uses [Starlark](https://bazel.build/rules/language) as the file format because, unlike something like JSON or YAML, it supports "macros" without compromising on safety or reproducibility. (Under the hood, we use [`starlark-rust`](https://github.com/facebook/starlark-rust) as the specific Starlark implementation.) + +This policy contains "rules" such as: + +```python +define_program( + program="cp", + options=[ + flag("-r"), + flag("-R"), + flag("--recursive"), + ], + args=[ARG_RFILES, ARG_WFILE], + system_path=["/bin/cp", "/usr/bin/cp"], + should_match=[ + ["foo", "bar"], + ], + should_not_match=[ + ["foo"], + ], +) +``` + +This rule means that: + +- `cp` can be used with any of the following flags (where "flag" means "an option that does not take an argument"): `-r`, `-R`, `--recursive`. +- The initial `ARG_RFILES` passed to `args` means that it expects one or more arguments that correspond to "readable files" +- The final `ARG_WFILE` passed to `args` means that it expects exactly one argument that corresponds to a "writeable file." +- As a means of a lightweight way of including a unit test alongside the definition, the `should_match` list is a list of examples of `execv(3)` args that should match the rule and `should_not_match` is a list of examples that should not match. These examples are verified when the `.policy` file is loaded. + +Note that the language of the `.policy` file is still evolving, as we have to continue to expand it so it is sufficiently expressive to accept all commands we want to consider "safe" without allowing unsafe commands to pass through. + +The integrity of `default.policy` is verified [via unit tests](./tests). + +Further, the CLI supports a `--policy` option to specify a custom `.policy` file for ad-hoc testing. + +## Output Type: `match` + +Going back to the `cp` example, because the rule matches an `ARG_WFILE`, it will return `match` instead of `safe`: + +```shell +cargo run -- check cp src1 src2 dest | jq +``` + +If the caller wants to consider allowing this command, it should parse the JSON to pick out the `WriteableFile` arguments and decide whether they are safe to write: + +```json +{ + "result": "match", + "match": { + "program": "cp", + "flags": [], + "opts": [], + "args": [ + { + "index": 0, + "type": "ReadableFile", + "value": "src1" + }, + { + "index": 1, + "type": "ReadableFile", + "value": "src2" + }, + { + "index": 2, + "type": "WriteableFile", + "value": "dest" + } + ], + "system_path": ["/bin/cp", "/usr/bin/cp"] + } +} +``` + +Note the exit code is still `0` for a `match` unless the `--require-safe` flag is specified, in which case the exit code is `12`. + +## Output Type: `forbidden` + +It is also possible to define a rule that, if it matches a command, should flag it as _forbidden_. For example, we do not want agents to be able to run `applied deploy` _ever_, so we define the following rule: + +```python +define_program( + program="applied", + args=["deploy"], + forbidden="Infrastructure Risk: command contains 'applied deploy'", + should_match=[ + ["deploy"], + ], + should_not_match=[ + ["lint"], + ], +) +``` + +Note that for a rule to be forbidden, the `forbidden` keyword arg must be specified as the reason the command is forbidden. This will be included in the output: + +```shell +cargo run -- check applied deploy | jq +``` + +```json +{ + "result": "forbidden", + "reason": "Infrastructure Risk: command contains 'applied deploy'", + "cause": { + "Exec": { + "exec": { + "program": "applied", + "flags": [], + "opts": [], + "args": [ + { + "index": 0, + "type": { + "Literal": "deploy" + }, + "value": "deploy" + } + ], + "system_path": [] + } + } + } +} +``` diff --git a/codex-rs/execpolicy/build.rs b/codex-rs/execpolicy/build.rs new file mode 100644 index 0000000000..eda4846853 --- /dev/null +++ b/codex-rs/execpolicy/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=src/default.policy"); +} diff --git a/codex-rs/execpolicy/src/arg_matcher.rs b/codex-rs/execpolicy/src/arg_matcher.rs new file mode 100644 index 0000000000..12d91b4465 --- /dev/null +++ b/codex-rs/execpolicy/src/arg_matcher.rs @@ -0,0 +1,118 @@ +#![allow(clippy::needless_lifetimes)] + +use crate::arg_type::ArgType; +use crate::starlark::values::ValueLike; +use allocative::Allocative; +use derive_more::derive::Display; +use starlark::any::ProvidesStaticType; +use starlark::values::starlark_value; +use starlark::values::string::StarlarkStr; +use starlark::values::AllocValue; +use starlark::values::Heap; +use starlark::values::NoSerialize; +use starlark::values::StarlarkValue; +use starlark::values::UnpackValue; +use starlark::values::Value; + +/// Patterns that lists of arguments should be compared against. +#[derive(Clone, Debug, Display, Eq, PartialEq, NoSerialize, ProvidesStaticType, Allocative)] +#[display("{}", self)] +pub enum ArgMatcher { + /// Literal string value. + Literal(String), + + /// We cannot say what type of value this should match, but it is *not* a file path. + OpaqueNonFile, + + /// Required readable file. + ReadableFile, + + /// Required writeable file. + WriteableFile, + + /// Non-empty list of readable files. + ReadableFiles, + + /// Non-empty list of readable files, or empty list, implying readable cwd. + ReadableFilesOrCwd, + + /// Positive integer, like one that is required for `head -n`. + PositiveInteger, + + /// Bespoke matcher for safe sed commands. + SedCommand, + + /// Matches an arbitrary number of arguments without attributing any + /// particular meaning to them. Caller is responsible for interpreting them. + UnverifiedVarargs, +} + +impl ArgMatcher { + pub fn cardinality(&self) -> ArgMatcherCardinality { + match self { + ArgMatcher::Literal(_) + | ArgMatcher::OpaqueNonFile + | ArgMatcher::ReadableFile + | ArgMatcher::WriteableFile + | ArgMatcher::PositiveInteger + | ArgMatcher::SedCommand => ArgMatcherCardinality::One, + ArgMatcher::ReadableFiles => ArgMatcherCardinality::AtLeastOne, + ArgMatcher::ReadableFilesOrCwd | ArgMatcher::UnverifiedVarargs => { + ArgMatcherCardinality::ZeroOrMore + } + } + } + + pub fn arg_type(&self) -> ArgType { + match self { + ArgMatcher::Literal(value) => ArgType::Literal(value.clone()), + ArgMatcher::OpaqueNonFile => ArgType::OpaqueNonFile, + ArgMatcher::ReadableFile => ArgType::ReadableFile, + ArgMatcher::WriteableFile => ArgType::WriteableFile, + ArgMatcher::ReadableFiles => ArgType::ReadableFile, + ArgMatcher::ReadableFilesOrCwd => ArgType::ReadableFile, + ArgMatcher::PositiveInteger => ArgType::PositiveInteger, + ArgMatcher::SedCommand => ArgType::SedCommand, + ArgMatcher::UnverifiedVarargs => ArgType::Unknown, + } + } +} + +pub enum ArgMatcherCardinality { + One, + AtLeastOne, + ZeroOrMore, +} + +impl ArgMatcherCardinality { + pub fn is_exact(&self) -> Option { + match self { + ArgMatcherCardinality::One => Some(1), + ArgMatcherCardinality::AtLeastOne => None, + ArgMatcherCardinality::ZeroOrMore => None, + } + } +} + +impl<'v> AllocValue<'v> for ArgMatcher { + fn alloc_value(self, heap: &'v Heap) -> Value<'v> { + heap.alloc_simple(self) + } +} + +#[starlark_value(type = "ArgMatcher")] +impl<'v> StarlarkValue<'v> for ArgMatcher { + type Canonical = ArgMatcher; +} + +impl<'v> UnpackValue<'v> for ArgMatcher { + type Error = starlark::Error; + + fn unpack_value_impl(value: Value<'v>) -> starlark::Result> { + if let Some(str) = value.downcast_ref::() { + Ok(Some(ArgMatcher::Literal(str.as_str().to_string()))) + } else { + Ok(value.downcast_ref::().cloned()) + } + } +} diff --git a/codex-rs/execpolicy/src/arg_resolver.rs b/codex-rs/execpolicy/src/arg_resolver.rs new file mode 100644 index 0000000000..d1138a8ffc --- /dev/null +++ b/codex-rs/execpolicy/src/arg_resolver.rs @@ -0,0 +1,194 @@ +use serde::Serialize; + +use crate::arg_matcher::ArgMatcher; +use crate::arg_matcher::ArgMatcherCardinality; +use crate::error::Error; +use crate::error::Result; +use crate::valid_exec::MatchedArg; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct PositionalArg { + pub index: usize, + pub value: String, +} + +pub fn resolve_observed_args_with_patterns( + program: &str, + args: Vec, + arg_patterns: &Vec, +) -> Result> { + // Naive matching implementation. Among `arg_patterns`, there is allowed to + // be at most one vararg pattern. Assuming `arg_patterns` is non-empty, we + // end up with either: + // + // - all `arg_patterns` in `prefix_patterns` + // - `arg_patterns` split across `prefix_patterns` (which could be empty), + // one `vararg_pattern`, and `suffix_patterns` (which could also empty). + // + // From there, we start by matching everything in `prefix_patterns`. + // Then we calculate how many positional args should be matched by + // `suffix_patterns` and use that to determine how many args are left to + // be matched by `vararg_pattern` (which could be zero). + // + // After assocating positional args with `vararg_pattern`, we match the + // `suffix_patterns` with the remaining args. + let ParitionedArgs { + num_prefix_args, + num_suffix_args, + prefix_patterns, + suffix_patterns, + vararg_pattern, + } = partition_args(program, arg_patterns)?; + + let mut matched_args = Vec::::new(); + + let prefix = get_range_checked(&args, 0..num_prefix_args)?; + let mut prefix_arg_index = 0; + for pattern in prefix_patterns { + let n = pattern.cardinality().is_exact().unwrap(); + for positional_arg in &prefix[prefix_arg_index..prefix_arg_index + n] { + let matched_arg = MatchedArg::new( + positional_arg.index, + pattern.arg_type(), + &positional_arg.value.clone(), + )?; + matched_args.push(matched_arg); + } + prefix_arg_index += n; + } + + if num_suffix_args > args.len() { + return Err(Error::NotEnoughArgs { + program: program.to_string(), + args, + arg_patterns: arg_patterns.clone(), + }); + } + + let initial_suffix_args_index = args.len() - num_suffix_args; + if prefix_arg_index > initial_suffix_args_index { + return Err(Error::PrefixOverlapsSuffix {}); + } + + if let Some(pattern) = vararg_pattern { + let vararg = get_range_checked(&args, prefix_arg_index..initial_suffix_args_index)?; + match pattern.cardinality() { + ArgMatcherCardinality::One => { + return Err(Error::InternalInvariantViolation { + message: "vararg pattern should not have cardinality of one".to_string(), + }); + } + ArgMatcherCardinality::AtLeastOne => { + if vararg.is_empty() { + return Err(Error::VarargMatcherDidNotMatchAnything { + program: program.to_string(), + matcher: pattern, + }); + } else { + for positional_arg in vararg { + let matched_arg = MatchedArg::new( + positional_arg.index, + pattern.arg_type(), + &positional_arg.value.clone(), + )?; + matched_args.push(matched_arg); + } + } + } + ArgMatcherCardinality::ZeroOrMore => { + for positional_arg in vararg { + let matched_arg = MatchedArg::new( + positional_arg.index, + pattern.arg_type(), + &positional_arg.value.clone(), + )?; + matched_args.push(matched_arg); + } + } + } + } + + let suffix = get_range_checked(&args, initial_suffix_args_index..args.len())?; + let mut suffix_arg_index = 0; + for pattern in suffix_patterns { + let n = pattern.cardinality().is_exact().unwrap(); + for positional_arg in &suffix[suffix_arg_index..suffix_arg_index + n] { + let matched_arg = MatchedArg::new( + positional_arg.index, + pattern.arg_type(), + &positional_arg.value.clone(), + )?; + matched_args.push(matched_arg); + } + suffix_arg_index += n; + } + + if matched_args.len() < args.len() { + let extra_args = get_range_checked(&args, matched_args.len()..args.len())?; + Err(Error::UnexpectedArguments { + program: program.to_string(), + args: extra_args.to_vec(), + }) + } else { + Ok(matched_args) + } +} + +#[derive(Default)] +struct ParitionedArgs { + num_prefix_args: usize, + num_suffix_args: usize, + prefix_patterns: Vec, + suffix_patterns: Vec, + vararg_pattern: Option, +} + +fn partition_args(program: &str, arg_patterns: &Vec) -> Result { + let mut in_prefix = true; + let mut partitioned_args = ParitionedArgs::default(); + + for pattern in arg_patterns { + match pattern.cardinality().is_exact() { + Some(n) => { + if in_prefix { + partitioned_args.prefix_patterns.push(pattern.clone()); + partitioned_args.num_prefix_args += n; + } else { + partitioned_args.suffix_patterns.push(pattern.clone()); + partitioned_args.num_suffix_args += n; + } + } + None => match partitioned_args.vararg_pattern { + None => { + partitioned_args.vararg_pattern = Some(pattern.clone()); + in_prefix = false; + } + Some(existing_pattern) => { + return Err(Error::MultipleVarargPatterns { + program: program.to_string(), + first: existing_pattern, + second: pattern.clone(), + }); + } + }, + } + } + + Ok(partitioned_args) +} + +fn get_range_checked(vec: &[T], range: std::ops::Range) -> Result<&[T]> { + if range.start > range.end { + Err(Error::RangeStartExceedsEnd { + start: range.start, + end: range.end, + }) + } else if range.end > vec.len() { + Err(Error::RangeEndOutOfBounds { + end: range.end, + len: vec.len(), + }) + } else { + Ok(&vec[range]) + } +} diff --git a/codex-rs/execpolicy/src/arg_type.rs b/codex-rs/execpolicy/src/arg_type.rs new file mode 100644 index 0000000000..11be0277ec --- /dev/null +++ b/codex-rs/execpolicy/src/arg_type.rs @@ -0,0 +1,87 @@ +#![allow(clippy::needless_lifetimes)] + +use crate::error::Error; +use crate::error::Result; +use crate::sed_command::parse_sed_command; +use allocative::Allocative; +use derive_more::derive::Display; +use serde::Serialize; +use starlark::any::ProvidesStaticType; +use starlark::values::starlark_value; +use starlark::values::StarlarkValue; + +#[derive(Debug, Clone, Display, Eq, PartialEq, ProvidesStaticType, Allocative, Serialize)] +#[display("{}", self)] +pub enum ArgType { + Literal(String), + /// We cannot say what this argument represents, but it is *not* a file path. + OpaqueNonFile, + /// A file (or directory) that can be expected to be read as part of this command. + ReadableFile, + /// A file (or directory) that can be expected to be written as part of this command. + WriteableFile, + /// Positive integer, like one that is required for `head -n`. + PositiveInteger, + /// Bespoke arg type for a safe sed command. + SedCommand, + /// Type is unknown: it may or may not be a file. + Unknown, +} + +impl ArgType { + pub fn validate(&self, value: &str) -> Result<()> { + match self { + ArgType::Literal(literal_value) => { + if value != *literal_value { + Err(Error::LiteralValueDidNotMatch { + expected: literal_value.clone(), + actual: value.to_string(), + }) + } else { + Ok(()) + } + } + ArgType::ReadableFile => { + if value.is_empty() { + Err(Error::EmptyFileName {}) + } else { + Ok(()) + } + } + ArgType::WriteableFile => { + if value.is_empty() { + Err(Error::EmptyFileName {}) + } else { + Ok(()) + } + } + ArgType::OpaqueNonFile | ArgType::Unknown => Ok(()), + ArgType::PositiveInteger => match value.parse::() { + Ok(0) => Err(Error::InvalidPositiveInteger { + value: value.to_string(), + }), + Ok(_) => Ok(()), + Err(_) => Err(Error::InvalidPositiveInteger { + value: value.to_string(), + }), + }, + ArgType::SedCommand => parse_sed_command(value), + } + } + + pub fn might_write_file(&self) -> bool { + match self { + ArgType::WriteableFile | ArgType::Unknown => true, + ArgType::Literal(_) + | ArgType::OpaqueNonFile + | ArgType::PositiveInteger + | ArgType::ReadableFile + | ArgType::SedCommand => false, + } + } +} + +#[starlark_value(type = "ArgType")] +impl<'v> StarlarkValue<'v> for ArgType { + type Canonical = ArgType; +} diff --git a/codex-rs/execpolicy/src/default.policy b/codex-rs/execpolicy/src/default.policy new file mode 100644 index 0000000000..bd27a0bb30 --- /dev/null +++ b/codex-rs/execpolicy/src/default.policy @@ -0,0 +1,202 @@ +""" +define_program() supports the following arguments: +- program: the name of the program +- system_path: list of absolute paths on the system where program can likely be found +- option_bundling (PLANNED): whether to allow bundling of options (e.g. `-al` for `-a -l`) +- combine_format (PLANNED): whether to allow `--option=value` (as opposed to `--option value`) +- options: the command-line flags/options: use flag() and opt() to define these +- args: the rules for what arguments are allowed that are not "options" +- should_match: list of command-line invocations that should be matched by the rule +- should_not_match: list of command-line invocations that should not be matched by the rule +""" + +define_program( + program="ls", + system_path=["/bin/ls", "/usr/bin/ls"], + options=[ + flag("-1"), + flag("-a"), + flag("-l"), + ], + args=[ARG_RFILES_OR_CWD], +) + +define_program( + program="cat", + options=[ + flag("-b"), + flag("-n"), + flag("-t"), + ], + system_path=["/bin/cat", "/usr/bin/cat"], + args=[ARG_RFILES], + should_match=[ + ["file.txt"], + ["-n", "file.txt"], + ["-b", "file.txt"], + ], + should_not_match=[ + # While cat without args is valid, it will read from stdin, which + # does not seem appropriate for our current use case. + [], + # Let's not auto-approve advisory locking. + ["-l", "file.txt"], + ] +) + +define_program( + program="cp", + options=[ + flag("-r"), + flag("-R"), + flag("--recursive"), + ], + args=[ARG_RFILES, ARG_WFILE], + system_path=["/bin/cp", "/usr/bin/cp"], + should_match=[ + ["foo", "bar"], + ], + should_not_match=[ + ["foo"], + ], +) + +define_program( + program="head", + system_path=["/bin/head", "/usr/bin/head"], + options=[ + opt("-c", ARG_POS_INT), + opt("-n", ARG_POS_INT), + ], + args=[ARG_RFILES], +) + +printenv_system_path = ["/usr/bin/printenv"] + +# Print all environment variables. +define_program( + program="printenv", + args=[], + system_path=printenv_system_path, + # This variant of `printenv` only allows zero args. + should_match=[[]], + should_not_match=[["PATH"]], +) + +# Print a specific environment variable. +define_program( + program="printenv", + args=[ARG_OPAQUE_VALUE], + system_path=printenv_system_path, + # This variant of `printenv` only allows exactly one arg. + should_match=[["PATH"]], + should_not_match=[[], ["PATH", "HOME"]], +) + +# Note that `pwd` is generally implemented as a shell built-in. It does not +# accept any arguments. +define_program( + program="pwd", + options=[ + flag("-L"), + flag("-P"), + ], + args=[], +) + +define_program( + program="rg", + options=[ + opt("-A", ARG_POS_INT), + opt("-B", ARG_POS_INT), + opt("-C", ARG_POS_INT), + opt("-d", ARG_POS_INT), + opt("--max-depth", ARG_POS_INT), + opt("-g", ARG_OPAQUE_VALUE), + opt("--glob", ARG_OPAQUE_VALUE), + opt("-m", ARG_POS_INT), + opt("--max-count", ARG_POS_INT), + + flag("-n"), + flag("-i"), + flag("-l"), + flag("--files"), + flag("--files-with-matches"), + flag("--files-without-match"), + ], + args=[ARG_OPAQUE_VALUE, ARG_RFILES_OR_CWD], + should_match=[ + ["-n", "init"], + ["-n", "init", "."], + ["-i", "-n", "init", "src"], + ["--files", "--max-depth", "2", "."], + ], + should_not_match=[ + ["-m", "-n", "init"], + ["--glob", "src"], + ], + # TODO(mbolin): Perhaps we need a way to indicate that we expect `rg` to be + # bundled with the host environment and we should be using that verison. + system_path=[], +) + +# Unfortunately, `sed` is difficult to secure because GNU sed supports an `e` +# flag where `s/pattern/replacement/e` would run `replacement` as a shell +# command every time `pattern` is matched. For example, try the following on +# Ubuntu (which uses GNU sed, unlike macOS): +# +# ```shell +# $ yes | head -n 4 > /tmp/yes.txt +# $ sed 's/y/echo hi/e' /tmp/yes.txt +# hi +# hi +# hi +# hi +# ``` +# +# As you can see, `echo hi` got executed four times. In order to support some +# basic sed functionality, we implement a bespoke `ARG_SED_COMMAND` that matches +# only "known safe" sed commands. +common_sed_flags = [ + # We deliberately do not support -i or -f. + flag("-n"), + flag("-u"), +] +sed_system_path = ["/usr/bin/sed"] + +# When -e is not specified, the first argument must be a valid sed command. +define_program( + program="sed", + options=common_sed_flags, + args=[ARG_SED_COMMAND, ARG_RFILES], + system_path=sed_system_path, +) + +# When -e is required, all arguments are assumed to be readable files. +define_program( + program="sed", + options=common_sed_flags + [ + opt("-e", ARG_SED_COMMAND, required=True), + ], + args=[ARG_RFILES], + system_path=sed_system_path, +) + +define_program( + program="which", + options=[ + flag("-a"), + flag("-s"), + ], + # Surprisingly, `which` takes more than one argument. + args=[ARG_RFILES], + should_match=[ + ["python3"], + ["-a", "python3"], + ["-a", "python3", "cargo"], + ], + should_not_match=[ + [], + ], + system_path=["/bin/which", "/usr/bin/which"], +) diff --git a/codex-rs/execpolicy/src/error.rs b/codex-rs/execpolicy/src/error.rs new file mode 100644 index 0000000000..ff781f43a5 --- /dev/null +++ b/codex-rs/execpolicy/src/error.rs @@ -0,0 +1,96 @@ +use std::path::PathBuf; + +use serde::Serialize; + +use crate::arg_matcher::ArgMatcher; +use crate::arg_resolver::PositionalArg; +use serde_with::serde_as; +use serde_with::DisplayFromStr; + +pub type Result = std::result::Result; + +#[serde_as] +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "type")] +pub enum Error { + NoSpecForProgram { + program: String, + }, + OptionMissingValue { + program: String, + option: String, + }, + OptionFollowedByOptionInsteadOfValue { + program: String, + option: String, + value: String, + }, + UnknownOption { + program: String, + option: String, + }, + UnexpectedArguments { + program: String, + args: Vec, + }, + DoubleDashNotSupportedYet { + program: String, + }, + MultipleVarargPatterns { + program: String, + first: ArgMatcher, + second: ArgMatcher, + }, + RangeStartExceedsEnd { + start: usize, + end: usize, + }, + RangeEndOutOfBounds { + end: usize, + len: usize, + }, + PrefixOverlapsSuffix {}, + NotEnoughArgs { + program: String, + args: Vec, + arg_patterns: Vec, + }, + InternalInvariantViolation { + message: String, + }, + VarargMatcherDidNotMatchAnything { + program: String, + matcher: ArgMatcher, + }, + EmptyFileName {}, + LiteralValueDidNotMatch { + expected: String, + actual: String, + }, + InvalidPositiveInteger { + value: String, + }, + MissingRequiredOptions { + program: String, + options: Vec, + }, + SedCommandNotProvablySafe { + command: String, + }, + ReadablePathNotInReadableFolders { + file: PathBuf, + folders: Vec, + }, + WriteablePathNotInWriteableFolders { + file: PathBuf, + folders: Vec, + }, + CannotCheckRelativePath { + file: PathBuf, + }, + CannotCanonicalizePath { + file: String, + #[serde_as(as = "DisplayFromStr")] + error: std::io::ErrorKind, + }, +} diff --git a/codex-rs/execpolicy/src/exec_call.rs b/codex-rs/execpolicy/src/exec_call.rs new file mode 100644 index 0000000000..e9753eccf3 --- /dev/null +++ b/codex-rs/execpolicy/src/exec_call.rs @@ -0,0 +1,28 @@ +use std::fmt::Display; + +use serde::Serialize; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct ExecCall { + pub program: String, + pub args: Vec, +} + +impl ExecCall { + pub fn new(program: &str, args: &[&str]) -> Self { + Self { + program: program.to_string(), + args: args.iter().map(|&s| s.into()).collect(), + } + } +} + +impl Display for ExecCall { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.program)?; + for arg in &self.args { + write!(f, " {}", arg)?; + } + Ok(()) + } +} diff --git a/codex-rs/execpolicy/src/execv_checker.rs b/codex-rs/execpolicy/src/execv_checker.rs new file mode 100644 index 0000000000..787fbce122 --- /dev/null +++ b/codex-rs/execpolicy/src/execv_checker.rs @@ -0,0 +1,263 @@ +use std::ffi::OsString; +use std::path::Path; +use std::path::PathBuf; + +use crate::ArgType; +use crate::Error::CannotCanonicalizePath; +use crate::Error::CannotCheckRelativePath; +use crate::Error::ReadablePathNotInReadableFolders; +use crate::Error::WriteablePathNotInWriteableFolders; +use crate::ExecCall; +use crate::MatchedExec; +use crate::Policy; +use crate::Result; +use crate::ValidExec; +use path_absolutize::*; +use std::os::unix::fs::PermissionsExt; + +macro_rules! check_file_in_folders { + ($file:expr, $folders:expr, $error:ident) => { + if !$folders.iter().any(|folder| $file.starts_with(folder)) { + return Err($error { + file: $file.clone(), + folders: $folders.to_vec(), + }); + } + }; +} + +pub struct ExecvChecker { + execv_policy: Policy, +} + +impl ExecvChecker { + pub fn new(execv_policy: Policy) -> Self { + Self { execv_policy } + } + + pub fn r#match(&self, exec_call: &ExecCall) -> Result { + self.execv_policy.check(exec_call) + } + + /// The caller is responsible for ensuring readable_folders and + /// writeable_folders are in canonical form. + pub fn check( + &self, + valid_exec: ValidExec, + cwd: &Option, + readable_folders: &[PathBuf], + writeable_folders: &[PathBuf], + ) -> Result { + for (arg_type, value) in valid_exec + .args + .into_iter() + .map(|arg| (arg.r#type, arg.value)) + .chain( + valid_exec + .opts + .into_iter() + .map(|opt| (opt.r#type, opt.value)), + ) + { + match arg_type { + ArgType::ReadableFile => { + let readable_file = ensure_absolute_path(&value, cwd)?; + check_file_in_folders!( + readable_file, + readable_folders, + ReadablePathNotInReadableFolders + ); + } + ArgType::WriteableFile => { + let writeable_file = ensure_absolute_path(&value, cwd)?; + check_file_in_folders!( + writeable_file, + writeable_folders, + WriteablePathNotInWriteableFolders + ); + } + ArgType::OpaqueNonFile + | ArgType::Unknown + | ArgType::PositiveInteger + | ArgType::SedCommand + | ArgType::Literal(_) => { + continue; + } + } + } + + let mut program = valid_exec.program.to_string(); + for system_path in valid_exec.system_path { + if is_executable_file(&system_path) { + program = system_path.to_string(); + break; + } + } + + Ok(program) + } +} + +fn ensure_absolute_path(path: &str, cwd: &Option) -> Result { + let file = PathBuf::from(path); + let result = if file.is_relative() { + match cwd { + Some(cwd) => file.absolutize_from(cwd), + None => return Err(CannotCheckRelativePath { file }), + } + } else { + file.absolutize() + }; + result + .map(|path| path.into_owned()) + .map_err(|error| CannotCanonicalizePath { + file: path.to_string(), + error: error.kind(), + }) +} + +fn is_executable_file(path: &str) -> bool { + let file_path = Path::new(path); + + if let Ok(metadata) = std::fs::metadata(file_path) { + let permissions = metadata.permissions(); + // Check if the file is executable (by checking the executable bit for the owner) + return metadata.is_file() && (permissions.mode() & 0o111 != 0); + } + + false +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::MatchedArg; + use crate::PolicyParser; + + fn setup(fake_cp: &Path) -> ExecvChecker { + let source = format!( + r#" +define_program( +program="cp", +args=[ARG_RFILE, ARG_WFILE], +system_path=[{fake_cp:?}] +) +"# + ); + let parser = PolicyParser::new("#test", &source); + let policy = parser.parse().unwrap(); + ExecvChecker::new(policy) + } + + #[test] + fn test_check_valid_input_files() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + + // Create an executable file that can be used with the system_path arg. + let fake_cp = temp_dir.path().join("cp"); + let fake_cp_file = std::fs::File::create(&fake_cp).unwrap(); + let mut permissions = fake_cp_file.metadata().unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&fake_cp, permissions).unwrap(); + + // Create root_path and reference to files under the root. + let root_path = temp_dir.path().to_path_buf(); + let source_path = root_path.join("source"); + let dest_path = root_path.join("dest"); + + let cp = fake_cp.to_str().unwrap().to_string(); + let root = root_path.to_str().unwrap().to_string(); + let source = source_path.to_str().unwrap().to_string(); + let dest = dest_path.to_str().unwrap().to_string(); + + let cwd = Some(root_path.clone().into()); + + let checker = setup(&fake_cp); + let exec_call = ExecCall { + program: "cp".into(), + args: vec![source.clone(), dest.clone()], + }; + let valid_exec = match checker.r#match(&exec_call)? { + MatchedExec::Match { exec } => exec, + unexpected => panic!("Expected a safe exec but got {unexpected:?}"), + }; + + // No readable or writeable folders specified. + assert_eq!( + checker.check(valid_exec.clone(), &cwd, &[], &[]), + Err(ReadablePathNotInReadableFolders { + file: source_path.clone(), + folders: vec![] + }), + ); + + // Only readable folders specified. + assert_eq!( + checker.check(valid_exec.clone(), &cwd, &[root_path.clone()], &[]), + Err(WriteablePathNotInWriteableFolders { + file: dest_path.clone(), + folders: vec![] + }), + ); + + // Both readable and writeable folders specified. + assert_eq!( + checker.check( + valid_exec.clone(), + &cwd, + &[root_path.clone()], + &[root_path.clone()] + ), + Ok(cp.clone()), + ); + + // Args are the readable and writeable folders, not files within the + // folders. + let exec_call_folders_as_args = ExecCall { + program: "cp".into(), + args: vec![root.clone(), root.clone()], + }; + let valid_exec_call_folders_as_args = match checker.r#match(&exec_call_folders_as_args)? { + MatchedExec::Match { exec } => exec, + _ => panic!("Expected a safe exec"), + }; + assert_eq!( + checker.check( + valid_exec_call_folders_as_args, + &cwd, + &[root_path.clone()], + &[root_path.clone()] + ), + Ok(cp.clone()), + ); + + // Specify a parent of a readable folder as input. + let exec_with_parent_of_readable_folder = ValidExec { + program: "cp".into(), + args: vec![ + MatchedArg::new( + 0, + ArgType::ReadableFile, + root_path.parent().unwrap().to_str().unwrap(), + )?, + MatchedArg::new(1, ArgType::WriteableFile, &dest)?, + ], + ..Default::default() + }; + assert_eq!( + checker.check( + exec_with_parent_of_readable_folder, + &cwd, + &[root_path.clone()], + &[dest_path.clone()] + ), + Err(ReadablePathNotInReadableFolders { + file: root_path.parent().unwrap().to_path_buf(), + folders: vec![root_path.clone()] + }), + ); + Ok(()) + } +} diff --git a/codex-rs/execpolicy/src/lib.rs b/codex-rs/execpolicy/src/lib.rs new file mode 100644 index 0000000000..6f12225981 --- /dev/null +++ b/codex-rs/execpolicy/src/lib.rs @@ -0,0 +1,45 @@ +#![allow(clippy::type_complexity)] +#![allow(clippy::too_many_arguments)] +#[macro_use] +extern crate starlark; + +mod arg_matcher; +mod arg_resolver; +mod arg_type; +mod error; +mod exec_call; +mod execv_checker; +mod opt; +mod policy; +mod policy_parser; +mod program; +mod sed_command; +mod valid_exec; + +pub use arg_matcher::ArgMatcher; +pub use arg_resolver::PositionalArg; +pub use arg_type::ArgType; +pub use error::Error; +pub use error::Result; +pub use exec_call::ExecCall; +pub use execv_checker::ExecvChecker; +pub use opt::Opt; +pub use policy::Policy; +pub use policy_parser::PolicyParser; +pub use program::Forbidden; +pub use program::MatchedExec; +pub use program::NegativeExamplePassedCheck; +pub use program::PositiveExampleFailedCheck; +pub use program::ProgramSpec; +pub use sed_command::parse_sed_command; +pub use valid_exec::MatchedArg; +pub use valid_exec::MatchedFlag; +pub use valid_exec::MatchedOpt; +pub use valid_exec::ValidExec; + +const DEFAULT_POLICY: &str = include_str!("default.policy"); + +pub fn get_default_policy() -> starlark::Result { + let parser = PolicyParser::new("#default", DEFAULT_POLICY); + parser.parse() +} diff --git a/codex-rs/execpolicy/src/main.rs b/codex-rs/execpolicy/src/main.rs new file mode 100644 index 0000000000..d8cb034d2a --- /dev/null +++ b/codex-rs/execpolicy/src/main.rs @@ -0,0 +1,166 @@ +use anyhow::Result; +use clap::Parser; +use clap::Subcommand; +use codex_execpolicy::get_default_policy; +use codex_execpolicy::ExecCall; +use codex_execpolicy::MatchedExec; +use codex_execpolicy::Policy; +use codex_execpolicy::PolicyParser; +use codex_execpolicy::ValidExec; +use serde::de; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use std::str::FromStr; + +const MATCHED_BUT_WRITES_FILES_EXIT_CODE: i32 = 12; +const MIGHT_BE_SAFE_EXIT_CODE: i32 = 13; +const FORBIDDEN_EXIT_CODE: i32 = 14; + +#[derive(Parser, Deserialize, Debug)] +#[command(version, about, long_about = None)] +pub struct Args { + /// If the command fails the policy, exit with 13, but print parseable JSON + /// to stdout. + #[clap(long)] + pub require_safe: bool, + + /// Path to the policy file. + #[clap(long, short = 'p')] + pub policy: Option, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Clone, Debug, Deserialize, Subcommand)] +pub enum Command { + /// Checks the command as if the arguments were the inputs to execv(3). + Check { + #[arg(trailing_var_arg = true)] + command: Vec, + }, + + /// Checks the command encoded as a JSON object. + #[clap(name = "check-json")] + CheckJson { + /// JSON object with "program" (str) and "args" (list[str]) fields. + #[serde(deserialize_with = "deserialize_from_json")] + exec: ExecArg, + }, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ExecArg { + pub program: String, + + #[serde(default)] + pub args: Vec, +} + +fn main() -> Result<()> { + env_logger::init(); + + let args = Args::parse(); + let policy = match args.policy { + Some(policy) => { + let policy_source = policy.to_string_lossy().to_string(); + let unparsed_policy = std::fs::read_to_string(policy)?; + let parser = PolicyParser::new(&policy_source, &unparsed_policy); + parser.parse() + } + None => get_default_policy(), + }; + let policy = policy.map_err(|err| err.into_anyhow())?; + + let exec = match args.command { + Command::Check { command } => match command.split_first() { + Some((first, rest)) => ExecArg { + program: first.to_string(), + args: rest.iter().map(|s| s.to_string()).collect(), + }, + None => { + eprintln!("no command provided"); + std::process::exit(1); + } + }, + Command::CheckJson { exec } => exec, + }; + + let (output, exit_code) = check_command(&policy, exec, args.require_safe); + let json = serde_json::to_string(&output)?; + println!("{}", json); + std::process::exit(exit_code); +} + +fn check_command( + policy: &Policy, + ExecArg { program, args }: ExecArg, + check: bool, +) -> (Output, i32) { + let exec_call = ExecCall { program, args }; + match policy.check(&exec_call) { + Ok(MatchedExec::Match { exec }) => { + if exec.might_write_files() { + let exit_code = if check { + MATCHED_BUT_WRITES_FILES_EXIT_CODE + } else { + 0 + }; + (Output::Match { r#match: exec }, exit_code) + } else { + (Output::Safe { r#match: exec }, 0) + } + } + Ok(MatchedExec::Forbidden { reason, cause }) => { + let exit_code = if check { FORBIDDEN_EXIT_CODE } else { 0 }; + (Output::Forbidden { reason, cause }, exit_code) + } + Err(err) => { + let exit_code = if check { MIGHT_BE_SAFE_EXIT_CODE } else { 0 }; + (Output::Unverified { error: err }, exit_code) + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "result")] +pub enum Output { + /// The command is verified as safe. + #[serde(rename = "safe")] + Safe { r#match: ValidExec }, + + /// The command has matched a rule in the policy, but the caller should + /// decide whether it is "safe" given the files it wants to write. + #[serde(rename = "match")] + Match { r#match: ValidExec }, + + /// The user is forbidden from running the command. + #[serde(rename = "forbidden")] + Forbidden { + reason: String, + cause: codex_execpolicy::Forbidden, + }, + + /// The safety of the command could not be verified. + #[serde(rename = "unverified")] + Unverified { error: codex_execpolicy::Error }, +} + +fn deserialize_from_json<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + let decoded = serde_json::from_str(&s) + .map_err(|e| serde::de::Error::custom(format!("JSON parse error: {e}")))?; + Ok(decoded) +} + +impl FromStr for ExecArg { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| e.into()) + } +} diff --git a/codex-rs/execpolicy/src/opt.rs b/codex-rs/execpolicy/src/opt.rs new file mode 100644 index 0000000000..4a58037462 --- /dev/null +++ b/codex-rs/execpolicy/src/opt.rs @@ -0,0 +1,77 @@ +#![allow(clippy::needless_lifetimes)] + +use crate::starlark::values::ValueLike; +use crate::ArgType; +use allocative::Allocative; +use derive_more::derive::Display; +use starlark::any::ProvidesStaticType; +use starlark::values::starlark_value; +use starlark::values::AllocValue; +use starlark::values::Heap; +use starlark::values::NoSerialize; +use starlark::values::StarlarkValue; +use starlark::values::UnpackValue; +use starlark::values::Value; + +/// Command line option that takes a value. +#[derive(Clone, Debug, Display, PartialEq, Eq, ProvidesStaticType, NoSerialize, Allocative)] +#[display("opt({})", opt)] +pub struct Opt { + /// The option as typed on the command line, e.g., `-h` or `--help`. If + /// it can be used in the `--name=value` format, then this should be + /// `--name` (though this is subject to change). + pub opt: String, + pub meta: OptMeta, + pub required: bool, +} + +/// When defining an Opt, use as specific an OptMeta as possible. +#[derive(Clone, Debug, Display, PartialEq, Eq, ProvidesStaticType, NoSerialize, Allocative)] +#[display("{}", self)] +pub enum OptMeta { + /// Option does not take a value. + Flag, + + /// Option takes a single value matching the specified type. + Value(ArgType), +} + +impl Opt { + pub fn new(opt: String, meta: OptMeta, required: bool) -> Self { + Self { + opt, + meta, + required, + } + } + + pub fn name(&self) -> &str { + &self.opt + } +} + +#[starlark_value(type = "Opt")] +impl<'v> StarlarkValue<'v> for Opt { + type Canonical = Opt; +} + +impl<'v> UnpackValue<'v> for Opt { + type Error = starlark::Error; + + fn unpack_value_impl(value: Value<'v>) -> starlark::Result> { + // TODO(mbolin): It fels like this should be doable without cloning? + // Cannot simply consume the value? + Ok(value.downcast_ref::().cloned()) + } +} + +impl<'v> AllocValue<'v> for Opt { + fn alloc_value(self, heap: &'v Heap) -> Value<'v> { + heap.alloc_simple(self) + } +} + +#[starlark_value(type = "OptMeta")] +impl<'v> StarlarkValue<'v> for OptMeta { + type Canonical = OptMeta; +} diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs new file mode 100644 index 0000000000..5ce7d7b917 --- /dev/null +++ b/codex-rs/execpolicy/src/policy.rs @@ -0,0 +1,103 @@ +use multimap::MultiMap; +use regex::Error as RegexError; +use regex::Regex; + +use crate::error::Error; +use crate::error::Result; +use crate::policy_parser::ForbiddenProgramRegex; +use crate::program::PositiveExampleFailedCheck; +use crate::ExecCall; +use crate::Forbidden; +use crate::MatchedExec; +use crate::NegativeExamplePassedCheck; +use crate::ProgramSpec; + +pub struct Policy { + programs: MultiMap, + forbidden_program_regexes: Vec, + forbidden_substrings_pattern: Option, +} + +impl Policy { + pub fn new( + programs: MultiMap, + forbidden_program_regexes: Vec, + forbidden_substrings: Vec, + ) -> std::result::Result { + let forbidden_substrings_pattern = if forbidden_substrings.is_empty() { + None + } else { + let escaped_substrings = forbidden_substrings + .iter() + .map(|s| regex::escape(s)) + .collect::>() + .join("|"); + Some(Regex::new(&format!("({escaped_substrings})"))?) + }; + Ok(Self { + programs, + forbidden_program_regexes, + forbidden_substrings_pattern, + }) + } + + pub fn check(&self, exec_call: &ExecCall) -> Result { + let ExecCall { program, args } = &exec_call; + for ForbiddenProgramRegex { regex, reason } in &self.forbidden_program_regexes { + if regex.is_match(program) { + return Ok(MatchedExec::Forbidden { + cause: Forbidden::Program { + program: program.clone(), + exec_call: exec_call.clone(), + }, + reason: reason.clone(), + }); + } + } + + for arg in args { + if let Some(regex) = &self.forbidden_substrings_pattern { + if regex.is_match(arg) { + return Ok(MatchedExec::Forbidden { + cause: Forbidden::Arg { + arg: arg.clone(), + exec_call: exec_call.clone(), + }, + reason: format!("arg `{}` contains forbidden substring", arg), + }); + } + } + } + + let mut last_err = Err(Error::NoSpecForProgram { + program: program.clone(), + }); + if let Some(spec_list) = self.programs.get_vec(program) { + for spec in spec_list { + match spec.check(exec_call) { + Ok(matched_exec) => return Ok(matched_exec), + Err(err) => { + last_err = Err(err); + } + } + } + } + last_err + } + + pub fn check_each_good_list_individually(&self) -> Vec { + let mut violations = Vec::new(); + for (_program, spec) in self.programs.flat_iter() { + violations.extend(spec.verify_should_match_list()); + } + violations + } + + pub fn check_each_bad_list_individually(&self) -> Vec { + let mut violations = Vec::new(); + for (_program, spec) in self.programs.flat_iter() { + violations.extend(spec.verify_should_not_match_list()); + } + violations + } +} diff --git a/codex-rs/execpolicy/src/policy_parser.rs b/codex-rs/execpolicy/src/policy_parser.rs new file mode 100644 index 0000000000..caf4efd10d --- /dev/null +++ b/codex-rs/execpolicy/src/policy_parser.rs @@ -0,0 +1,222 @@ +#![allow(clippy::needless_lifetimes)] + +use crate::arg_matcher::ArgMatcher; +use crate::opt::OptMeta; +use crate::Opt; +use crate::Policy; +use crate::ProgramSpec; +use log::info; +use multimap::MultiMap; +use regex::Regex; +use starlark::any::ProvidesStaticType; +use starlark::environment::GlobalsBuilder; +use starlark::environment::LibraryExtension; +use starlark::environment::Module; +use starlark::eval::Evaluator; +use starlark::syntax::AstModule; +use starlark::syntax::Dialect; +use starlark::values::list::UnpackList; +use starlark::values::none::NoneType; +use starlark::values::Heap; +use std::cell::RefCell; +use std::collections::HashMap; + +pub struct PolicyParser { + policy_source: String, + unparsed_policy: String, +} + +impl PolicyParser { + pub fn new(policy_source: &str, unparsed_policy: &str) -> Self { + Self { + policy_source: policy_source.to_string(), + unparsed_policy: unparsed_policy.to_string(), + } + } + + pub fn parse(&self) -> starlark::Result { + let mut dialect = Dialect::Extended.clone(); + dialect.enable_f_strings = true; + let ast = AstModule::parse(&self.policy_source, self.unparsed_policy.clone(), &dialect)?; + let globals = GlobalsBuilder::extended_by(&[LibraryExtension::Typing]) + .with(policy_builtins) + .build(); + let module = Module::new(); + + let heap = Heap::new(); + + module.set("ARG_OPAQUE_VALUE", heap.alloc(ArgMatcher::OpaqueNonFile)); + module.set("ARG_RFILE", heap.alloc(ArgMatcher::ReadableFile)); + module.set("ARG_WFILE", heap.alloc(ArgMatcher::WriteableFile)); + module.set("ARG_RFILES", heap.alloc(ArgMatcher::ReadableFiles)); + module.set( + "ARG_RFILES_OR_CWD", + heap.alloc(ArgMatcher::ReadableFilesOrCwd), + ); + module.set("ARG_POS_INT", heap.alloc(ArgMatcher::PositiveInteger)); + module.set("ARG_SED_COMMAND", heap.alloc(ArgMatcher::SedCommand)); + module.set( + "ARG_UNVERIFIED_VARARGS", + heap.alloc(ArgMatcher::UnverifiedVarargs), + ); + + let policy_builder = PolicyBuilder::new(); + { + let mut eval = Evaluator::new(&module); + eval.extra = Some(&policy_builder); + eval.eval_module(ast, &globals)?; + } + let policy = policy_builder.build(); + policy.map_err(|e| starlark::Error::new_kind(starlark::ErrorKind::Other(e.into()))) + } +} + +#[derive(Debug)] +pub struct ForbiddenProgramRegex { + pub regex: regex::Regex, + pub reason: String, +} + +#[derive(Debug, ProvidesStaticType)] +struct PolicyBuilder { + programs: RefCell>, + forbidden_program_regexes: RefCell>, + forbidden_substrings: RefCell>, +} + +impl PolicyBuilder { + fn new() -> Self { + Self { + programs: RefCell::new(MultiMap::new()), + forbidden_program_regexes: RefCell::new(Vec::new()), + forbidden_substrings: RefCell::new(Vec::new()), + } + } + + fn build(self) -> Result { + let programs = self.programs.into_inner(); + let forbidden_program_regexes = self.forbidden_program_regexes.into_inner(); + let forbidden_substrings = self.forbidden_substrings.into_inner(); + Policy::new(programs, forbidden_program_regexes, forbidden_substrings) + } + + fn add_program_spec(&self, program_spec: ProgramSpec) { + info!("adding program spec: {:?}", program_spec); + let name = program_spec.program.clone(); + let mut programs = self.programs.borrow_mut(); + programs.insert(name.clone(), program_spec); + } + + fn add_forbidden_substrings(&self, substrings: &[String]) { + let mut forbidden_substrings = self.forbidden_substrings.borrow_mut(); + forbidden_substrings.extend_from_slice(substrings); + } + + fn add_forbidden_program_regex(&self, regex: Regex, reason: String) { + let mut forbidden_program_regexes = self.forbidden_program_regexes.borrow_mut(); + forbidden_program_regexes.push(ForbiddenProgramRegex { regex, reason }); + } +} + +#[starlark_module] +fn policy_builtins(builder: &mut GlobalsBuilder) { + fn define_program<'v>( + program: String, + system_path: Option>, + option_bundling: Option, + combined_format: Option, + options: Option>, + args: Option>, + forbidden: Option, + should_match: Option>>, + should_not_match: Option>>, + eval: &mut Evaluator, + ) -> anyhow::Result { + let option_bundling = option_bundling.unwrap_or(false); + let system_path = system_path.map_or_else(Vec::new, |v| v.items.to_vec()); + let combined_format = combined_format.unwrap_or(false); + let options = options.map_or_else(Vec::new, |v| v.items.to_vec()); + let args = args.map_or_else(Vec::new, |v| v.items.to_vec()); + + let mut allowed_options = HashMap::::new(); + for opt in options { + let name = opt.name().to_string(); + if allowed_options + .insert(opt.name().to_string(), opt) + .is_some() + { + return Err(anyhow::format_err!("duplicate flag: {name}")); + } + } + + let program_spec = ProgramSpec::new( + program, + system_path, + option_bundling, + combined_format, + allowed_options, + args, + forbidden, + should_match + .map_or_else(Vec::new, |v| v.items.to_vec()) + .into_iter() + .map(|v| v.items.to_vec()) + .collect(), + should_not_match + .map_or_else(Vec::new, |v| v.items.to_vec()) + .into_iter() + .map(|v| v.items.to_vec()) + .collect(), + ); + let policy_builder = eval + .extra + .as_ref() + .unwrap() + .downcast_ref::() + .unwrap(); + policy_builder.add_program_spec(program_spec); + Ok(NoneType) + } + + fn forbid_substrings( + strings: UnpackList, + eval: &mut Evaluator, + ) -> anyhow::Result { + let policy_builder = eval + .extra + .as_ref() + .unwrap() + .downcast_ref::() + .unwrap(); + policy_builder.add_forbidden_substrings(&strings.items.to_vec()); + Ok(NoneType) + } + + fn forbid_program_regex( + regex: String, + reason: String, + eval: &mut Evaluator, + ) -> anyhow::Result { + let policy_builder = eval + .extra + .as_ref() + .unwrap() + .downcast_ref::() + .unwrap(); + let compiled_regex = regex::Regex::new(®ex)?; + policy_builder.add_forbidden_program_regex(compiled_regex, reason); + Ok(NoneType) + } + + fn opt(name: String, r#type: ArgMatcher, required: Option) -> anyhow::Result { + Ok(Opt::new( + name, + OptMeta::Value(r#type.arg_type()), + required.unwrap_or(false), + )) + } + + fn flag(name: String) -> anyhow::Result { + Ok(Opt::new(name, OptMeta::Flag, false)) + } +} diff --git a/codex-rs/execpolicy/src/program.rs b/codex-rs/execpolicy/src/program.rs new file mode 100644 index 0000000000..6984f5cb3c --- /dev/null +++ b/codex-rs/execpolicy/src/program.rs @@ -0,0 +1,247 @@ +use serde::Serialize; +use std::collections::HashMap; +use std::collections::HashSet; + +use crate::arg_matcher::ArgMatcher; +use crate::arg_resolver::resolve_observed_args_with_patterns; +use crate::arg_resolver::PositionalArg; +use crate::error::Error; +use crate::error::Result; +use crate::opt::Opt; +use crate::opt::OptMeta; +use crate::valid_exec::MatchedFlag; +use crate::valid_exec::MatchedOpt; +use crate::valid_exec::ValidExec; +use crate::ArgType; +use crate::ExecCall; + +#[derive(Debug)] +pub struct ProgramSpec { + pub program: String, + pub system_path: Vec, + pub option_bundling: bool, + pub combined_format: bool, + pub allowed_options: HashMap, + pub arg_patterns: Vec, + forbidden: Option, + required_options: HashSet, + should_match: Vec>, + should_not_match: Vec>, +} + +impl ProgramSpec { + pub fn new( + program: String, + system_path: Vec, + option_bundling: bool, + combined_format: bool, + allowed_options: HashMap, + arg_patterns: Vec, + forbidden: Option, + should_match: Vec>, + should_not_match: Vec>, + ) -> Self { + let required_options = allowed_options + .iter() + .filter_map(|(name, opt)| { + if opt.required { + Some(name.clone()) + } else { + None + } + }) + .collect(); + Self { + program, + system_path, + option_bundling, + combined_format, + allowed_options, + arg_patterns, + forbidden, + required_options, + should_match, + should_not_match, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub enum MatchedExec { + Match { exec: ValidExec }, + Forbidden { cause: Forbidden, reason: String }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub enum Forbidden { + Program { + program: String, + exec_call: ExecCall, + }, + Arg { + arg: String, + exec_call: ExecCall, + }, + Exec { + exec: ValidExec, + }, +} + +impl ProgramSpec { + // TODO(mbolin): The idea is that there should be a set of rules defined for + // a program and the args should be checked against the rules to determine + // if the program should be allowed to run. + pub fn check(&self, exec_call: &ExecCall) -> Result { + let mut expecting_option_value: Option<(String, ArgType)> = None; + let mut args = Vec::::new(); + let mut matched_flags = Vec::::new(); + let mut matched_opts = Vec::::new(); + + for (index, arg) in exec_call.args.iter().enumerate() { + if let Some(expected) = expecting_option_value { + // If we are expecting an option value, then the next argument + // should be the value for the option. + // This had better not be another option! + let (name, arg_type) = expected; + if arg.starts_with("-") { + return Err(Error::OptionFollowedByOptionInsteadOfValue { + program: self.program.clone(), + option: name, + value: arg.clone(), + }); + } + + matched_opts.push(MatchedOpt::new(&name, arg, arg_type)?); + expecting_option_value = None; + } else if arg == "--" { + return Err(Error::DoubleDashNotSupportedYet { + program: self.program.clone(), + }); + } else if arg.starts_with("-") { + match self.allowed_options.get(arg) { + Some(opt) => { + match &opt.meta { + OptMeta::Flag => { + matched_flags.push(MatchedFlag { name: arg.clone() }); + // A flag does not expect an argument: continue. + continue; + } + OptMeta::Value(arg_type) => { + expecting_option_value = Some((arg.clone(), arg_type.clone())); + continue; + } + } + } + None => { + // It could be an --option=value style flag... + } + } + + return Err(Error::UnknownOption { + program: self.program.clone(), + option: arg.clone(), + }); + } else { + args.push(PositionalArg { + index, + value: arg.clone(), + }); + } + } + + if let Some(expected) = expecting_option_value { + let (name, _arg_type) = expected; + return Err(Error::OptionMissingValue { + program: self.program.clone(), + option: name, + }); + } + + let matched_args = + resolve_observed_args_with_patterns(&self.program, args, &self.arg_patterns)?; + + // Verify all required options are present. + let matched_opt_names: HashSet = matched_opts + .iter() + .map(|opt| opt.name().to_string()) + .collect(); + if !matched_opt_names.is_superset(&self.required_options) { + let mut options = self + .required_options + .difference(&matched_opt_names) + .map(|s| s.to_string()) + .collect::>(); + options.sort(); + return Err(Error::MissingRequiredOptions { + program: self.program.clone(), + options, + }); + } + + let exec = ValidExec { + program: self.program.clone(), + flags: matched_flags, + opts: matched_opts, + args: matched_args, + system_path: self.system_path.clone(), + }; + match &self.forbidden { + Some(reason) => Ok(MatchedExec::Forbidden { + cause: Forbidden::Exec { exec }, + reason: reason.clone(), + }), + None => Ok(MatchedExec::Match { exec }), + } + } + + pub fn verify_should_match_list(&self) -> Vec { + let mut violations = Vec::new(); + for good in &self.should_match { + let exec_call = ExecCall { + program: self.program.clone(), + args: good.clone(), + }; + match self.check(&exec_call) { + Ok(_) => {} + Err(error) => { + violations.push(PositiveExampleFailedCheck { + program: self.program.clone(), + args: good.clone(), + error, + }); + } + } + } + violations + } + + pub fn verify_should_not_match_list(&self) -> Vec { + let mut violations = Vec::new(); + for bad in &self.should_not_match { + let exec_call = ExecCall { + program: self.program.clone(), + args: bad.clone(), + }; + if self.check(&exec_call).is_ok() { + violations.push(NegativeExamplePassedCheck { + program: self.program.clone(), + args: bad.clone(), + }); + } + } + violations + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct PositiveExampleFailedCheck { + pub program: String, + pub args: Vec, + pub error: Error, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct NegativeExamplePassedCheck { + pub program: String, + pub args: Vec, +} diff --git a/codex-rs/execpolicy/src/sed_command.rs b/codex-rs/execpolicy/src/sed_command.rs new file mode 100644 index 0000000000..64494ddf00 --- /dev/null +++ b/codex-rs/execpolicy/src/sed_command.rs @@ -0,0 +1,17 @@ +use crate::error::Error; +use crate::error::Result; + +pub fn parse_sed_command(sed_command: &str) -> Result<()> { + // For now, we parse only commands like `122,202p`. + if let Some(stripped) = sed_command.strip_suffix("p") { + if let Some((first, rest)) = stripped.split_once(",") { + if first.parse::().is_ok() && rest.parse::().is_ok() { + return Ok(()); + } + } + } + + Err(Error::SedCommandNotProvablySafe { + command: sed_command.to_string(), + }) +} diff --git a/codex-rs/execpolicy/src/valid_exec.rs b/codex-rs/execpolicy/src/valid_exec.rs new file mode 100644 index 0000000000..0cc3b239ca --- /dev/null +++ b/codex-rs/execpolicy/src/valid_exec.rs @@ -0,0 +1,95 @@ +use crate::arg_type::ArgType; +use crate::error::Result; +use serde::Serialize; + +/// exec() invocation that has been accepted by a `Policy`. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +pub struct ValidExec { + pub program: String, + pub flags: Vec, + pub opts: Vec, + pub args: Vec, + + /// If non-empty, a prioritized list of paths to try instead of `program`. + /// For example, `/bin/ls` is harder to compromise than whatever `ls` + /// happens to be in the user's `$PATH`, so `/bin/ls` would be included for + /// `ls`. The caller is free to disregard this list and use `program`. + pub system_path: Vec, +} + +impl ValidExec { + pub fn new(program: &str, args: Vec, system_path: &[&str]) -> Self { + Self { + program: program.to_string(), + flags: vec![], + opts: vec![], + args, + system_path: system_path.iter().map(|&s| s.to_string()).collect(), + } + } + + /// Whether a possible side effect of running this command includes writing + /// a file. + pub fn might_write_files(&self) -> bool { + self.opts.iter().any(|opt| opt.r#type.might_write_file()) + || self.args.iter().any(|opt| opt.r#type.might_write_file()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct MatchedArg { + pub index: usize, + pub r#type: ArgType, + pub value: String, +} + +impl MatchedArg { + pub fn new(index: usize, r#type: ArgType, value: &str) -> Result { + r#type.validate(value)?; + Ok(Self { + index, + r#type, + value: value.to_string(), + }) + } +} + +/// A match for an option declared with opt() in a .policy file. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct MatchedOpt { + /// Name of the option that was matched. + pub name: String, + /// Value supplied for the option. + pub value: String, + /// Type of the value supplied for the option. + pub r#type: ArgType, +} + +impl MatchedOpt { + pub fn new(name: &str, value: &str, r#type: ArgType) -> Result { + r#type.validate(value)?; + Ok(Self { + name: name.to_string(), + value: value.to_string(), + r#type, + }) + } + + pub fn name(&self) -> &str { + &self.name + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct MatchedFlag { + /// Name of the flag that was matched. + pub name: String, +} + +impl MatchedFlag { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + } + } +} diff --git a/codex-rs/execpolicy/tests/bad.rs b/codex-rs/execpolicy/tests/bad.rs new file mode 100644 index 0000000000..91f8b52ba4 --- /dev/null +++ b/codex-rs/execpolicy/tests/bad.rs @@ -0,0 +1,9 @@ +use codex_execpolicy::get_default_policy; +use codex_execpolicy::NegativeExamplePassedCheck; + +#[test] +fn verify_everything_in_bad_list_is_rejected() { + let policy = get_default_policy().expect("failed to load default policy"); + let violations = policy.check_each_bad_list_individually(); + assert_eq!(Vec::::new(), violations); +} diff --git a/codex-rs/execpolicy/tests/cp.rs b/codex-rs/execpolicy/tests/cp.rs new file mode 100644 index 0000000000..8981ac7a34 --- /dev/null +++ b/codex-rs/execpolicy/tests/cp.rs @@ -0,0 +1,85 @@ +extern crate codex_execpolicy; + +use codex_execpolicy::get_default_policy; +use codex_execpolicy::ArgMatcher; +use codex_execpolicy::ArgType; +use codex_execpolicy::Error; +use codex_execpolicy::ExecCall; +use codex_execpolicy::MatchedArg; +use codex_execpolicy::MatchedExec; +use codex_execpolicy::Policy; +use codex_execpolicy::Result; +use codex_execpolicy::ValidExec; + +fn setup() -> Policy { + get_default_policy().expect("failed to load default policy") +} + +#[test] +fn test_cp_no_args() { + let policy = setup(); + let cp = ExecCall::new("cp", &[]); + assert_eq!( + Err(Error::NotEnoughArgs { + program: "cp".to_string(), + args: vec![], + arg_patterns: vec![ArgMatcher::ReadableFiles, ArgMatcher::WriteableFile] + }), + policy.check(&cp) + ) +} + +#[test] +fn test_cp_one_arg() { + let policy = setup(); + let cp = ExecCall::new("cp", &["foo/bar"]); + + assert_eq!( + Err(Error::VarargMatcherDidNotMatchAnything { + program: "cp".to_string(), + matcher: ArgMatcher::ReadableFiles, + }), + policy.check(&cp) + ); +} + +#[test] +fn test_cp_one_file() -> Result<()> { + let policy = setup(); + let cp = ExecCall::new("cp", &["foo/bar", "../baz"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec::new( + "cp", + vec![ + MatchedArg::new(0, ArgType::ReadableFile, "foo/bar")?, + MatchedArg::new(1, ArgType::WriteableFile, "../baz")?, + ], + &["/bin/cp", "/usr/bin/cp"] + ) + }), + policy.check(&cp) + ); + Ok(()) +} + +#[test] +fn test_cp_multiple_files() -> Result<()> { + let policy = setup(); + let cp = ExecCall::new("cp", &["foo", "bar", "baz"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec::new( + "cp", + vec![ + MatchedArg::new(0, ArgType::ReadableFile, "foo")?, + MatchedArg::new(1, ArgType::ReadableFile, "bar")?, + MatchedArg::new(2, ArgType::WriteableFile, "baz")?, + ], + &["/bin/cp", "/usr/bin/cp"] + ) + }), + policy.check(&cp) + ); + Ok(()) +} diff --git a/codex-rs/execpolicy/tests/good.rs b/codex-rs/execpolicy/tests/good.rs new file mode 100644 index 0000000000..18a002850c --- /dev/null +++ b/codex-rs/execpolicy/tests/good.rs @@ -0,0 +1,9 @@ +use codex_execpolicy::get_default_policy; +use codex_execpolicy::PositiveExampleFailedCheck; + +#[test] +fn verify_everything_in_good_list_is_allowed() { + let policy = get_default_policy().expect("failed to load default policy"); + let violations = policy.check_each_good_list_individually(); + assert_eq!(Vec::::new(), violations); +} diff --git a/codex-rs/execpolicy/tests/head.rs b/codex-rs/execpolicy/tests/head.rs new file mode 100644 index 0000000000..196de081f6 --- /dev/null +++ b/codex-rs/execpolicy/tests/head.rs @@ -0,0 +1,132 @@ +use codex_execpolicy::get_default_policy; +use codex_execpolicy::ArgMatcher; +use codex_execpolicy::ArgType; +use codex_execpolicy::Error; +use codex_execpolicy::ExecCall; +use codex_execpolicy::MatchedArg; +use codex_execpolicy::MatchedExec; +use codex_execpolicy::MatchedOpt; +use codex_execpolicy::Policy; +use codex_execpolicy::Result; +use codex_execpolicy::ValidExec; + +extern crate codex_execpolicy; + +fn setup() -> Policy { + get_default_policy().expect("failed to load default policy") +} + +#[test] +fn test_head_no_args() { + let policy = setup(); + let head = ExecCall::new("head", &[]); + // It is actually valid to call `head` without arguments: it will read from + // stdin instead of from a file. Though recall that a command rejected by + // the policy is not "unsafe:" it just means that this library cannot + // *guarantee* that the command is safe. + // + // If we start verifying individual components of a shell command, such as: + // `find . -name | head -n 10`, then it might be important to allow the + // no-arg case. + assert_eq!( + Err(Error::VarargMatcherDidNotMatchAnything { + program: "head".to_string(), + matcher: ArgMatcher::ReadableFiles, + }), + policy.check(&head) + ) +} + +#[test] +fn test_head_one_file_no_flags() -> Result<()> { + let policy = setup(); + let head = ExecCall::new("head", &["src/extension.ts"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec::new( + "head", + vec![MatchedArg::new( + 0, + ArgType::ReadableFile, + "src/extension.ts" + )?], + &["/bin/head", "/usr/bin/head"] + ) + }), + policy.check(&head) + ); + Ok(()) +} + +#[test] +fn test_head_one_flag_one_file() -> Result<()> { + let policy = setup(); + let head = ExecCall::new("head", &["-n", "100", "src/extension.ts"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec { + program: "head".to_string(), + flags: vec![], + opts: vec![MatchedOpt::new("-n", "100", ArgType::PositiveInteger).unwrap()], + args: vec![MatchedArg::new( + 2, + ArgType::ReadableFile, + "src/extension.ts" + )?], + system_path: vec!["/bin/head".to_string(), "/usr/bin/head".to_string()], + } + }), + policy.check(&head) + ); + Ok(()) +} + +#[test] +fn test_head_invalid_n_as_0() { + let policy = setup(); + let head = ExecCall::new("head", &["-n", "0", "src/extension.ts"]); + assert_eq!( + Err(Error::InvalidPositiveInteger { + value: "0".to_string(), + }), + policy.check(&head) + ) +} + +#[test] +fn test_head_invalid_n_as_nonint_float() { + let policy = setup(); + let head = ExecCall::new("head", &["-n", "1.5", "src/extension.ts"]); + assert_eq!( + Err(Error::InvalidPositiveInteger { + value: "1.5".to_string(), + }), + policy.check(&head) + ) +} + +#[test] +fn test_head_invalid_n_as_float() { + let policy = setup(); + let head = ExecCall::new("head", &["-n", "1.0", "src/extension.ts"]); + assert_eq!( + Err(Error::InvalidPositiveInteger { + value: "1.0".to_string(), + }), + policy.check(&head) + ) +} + +#[test] +fn test_head_invalid_n_as_negative_int() { + let policy = setup(); + let head = ExecCall::new("head", &["-n", "-1", "src/extension.ts"]); + assert_eq!( + Err(Error::OptionFollowedByOptionInsteadOfValue { + program: "head".to_string(), + option: "-n".to_string(), + value: "-1".to_string(), + }), + policy.check(&head) + ) +} diff --git a/codex-rs/execpolicy/tests/literal.rs b/codex-rs/execpolicy/tests/literal.rs new file mode 100644 index 0000000000..d849371e3b --- /dev/null +++ b/codex-rs/execpolicy/tests/literal.rs @@ -0,0 +1,50 @@ +use codex_execpolicy::ArgType; +use codex_execpolicy::Error; +use codex_execpolicy::ExecCall; +use codex_execpolicy::MatchedArg; +use codex_execpolicy::MatchedExec; +use codex_execpolicy::PolicyParser; +use codex_execpolicy::Result; +use codex_execpolicy::ValidExec; + +extern crate codex_execpolicy; + +#[test] +fn test_invalid_subcommand() -> Result<()> { + let unparsed_policy = r#" +define_program( + program="fake_executable", + args=["subcommand", "sub-subcommand"], +) +"#; + let parser = PolicyParser::new("test_invalid_subcommand", unparsed_policy); + let policy = parser.parse().expect("failed to parse policy"); + let valid_call = ExecCall::new("fake_executable", &["subcommand", "sub-subcommand"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec::new( + "fake_executable", + vec![ + MatchedArg::new(0, ArgType::Literal("subcommand".to_string()), "subcommand")?, + MatchedArg::new( + 1, + ArgType::Literal("sub-subcommand".to_string()), + "sub-subcommand" + )?, + ], + &[] + ) + }), + policy.check(&valid_call) + ); + + let invalid_call = ExecCall::new("fake_executable", &["subcommand", "not-a-real-subcommand"]); + assert_eq!( + Err(Error::LiteralValueDidNotMatch { + expected: "sub-subcommand".to_string(), + actual: "not-a-real-subcommand".to_string() + }), + policy.check(&invalid_call) + ); + Ok(()) +} diff --git a/codex-rs/execpolicy/tests/ls.rs b/codex-rs/execpolicy/tests/ls.rs new file mode 100644 index 0000000000..f7e78f22f3 --- /dev/null +++ b/codex-rs/execpolicy/tests/ls.rs @@ -0,0 +1,166 @@ +extern crate codex_execpolicy; + +use codex_execpolicy::get_default_policy; +use codex_execpolicy::ArgType; +use codex_execpolicy::Error; +use codex_execpolicy::ExecCall; +use codex_execpolicy::MatchedArg; +use codex_execpolicy::MatchedExec; +use codex_execpolicy::MatchedFlag; +use codex_execpolicy::Policy; +use codex_execpolicy::Result; +use codex_execpolicy::ValidExec; + +fn setup() -> Policy { + get_default_policy().expect("failed to load default policy") +} + +#[test] +fn test_ls_no_args() { + let policy = setup(); + let ls = ExecCall::new("ls", &[]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec::new("ls", vec![], &["/bin/ls", "/usr/bin/ls"]) + }), + policy.check(&ls) + ); +} + +#[test] +fn test_ls_dash_a_dash_l() { + let policy = setup(); + let args = &["-a", "-l"]; + let ls_a_l = ExecCall::new("ls", args); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec { + program: "ls".into(), + flags: vec![MatchedFlag::new("-a"), MatchedFlag::new("-l")], + system_path: ["/bin/ls".into(), "/usr/bin/ls".into()].into(), + ..Default::default() + } + }), + policy.check(&ls_a_l) + ); +} + +#[test] +fn test_ls_dash_z() { + let policy = setup(); + + // -z is currently an invalid option for ls, but it has so many options, + // perhaps it will get added at some point... + let ls_z = ExecCall::new("ls", &["-z"]); + assert_eq!( + Err(Error::UnknownOption { + program: "ls".into(), + option: "-z".into() + }), + policy.check(&ls_z) + ); +} + +#[test] +fn test_ls_dash_al() { + let policy = setup(); + + // This currently fails, but it should pass once option_bundling=True is implemented. + let ls_al = ExecCall::new("ls", &["-al"]); + assert_eq!( + Err(Error::UnknownOption { + program: "ls".into(), + option: "-al".into() + }), + policy.check(&ls_al) + ); +} + +#[test] +fn test_ls_one_file_arg() -> Result<()> { + let policy = setup(); + + let ls_one_file_arg = ExecCall::new("ls", &["foo"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec::new( + "ls", + vec![MatchedArg::new(0, ArgType::ReadableFile, "foo")?], + &["/bin/ls", "/usr/bin/ls"] + ) + }), + policy.check(&ls_one_file_arg) + ); + Ok(()) +} + +#[test] +fn test_ls_multiple_file_args() -> Result<()> { + let policy = setup(); + + let ls_multiple_file_args = ExecCall::new("ls", &["foo", "bar", "baz"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec::new( + "ls", + vec![ + MatchedArg::new(0, ArgType::ReadableFile, "foo")?, + MatchedArg::new(1, ArgType::ReadableFile, "bar")?, + MatchedArg::new(2, ArgType::ReadableFile, "baz")?, + ], + &["/bin/ls", "/usr/bin/ls"] + ) + }), + policy.check(&ls_multiple_file_args) + ); + Ok(()) +} + +#[test] +fn test_ls_multiple_flags_and_file_args() -> Result<()> { + let policy = setup(); + + let ls_multiple_flags_and_file_args = ExecCall::new("ls", &["-l", "-a", "foo", "bar", "baz"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec { + program: "ls".into(), + flags: vec![MatchedFlag::new("-l"), MatchedFlag::new("-a")], + args: vec![ + MatchedArg::new(2, ArgType::ReadableFile, "foo")?, + MatchedArg::new(3, ArgType::ReadableFile, "bar")?, + MatchedArg::new(4, ArgType::ReadableFile, "baz")?, + ], + system_path: ["/bin/ls".into(), "/usr/bin/ls".into()].into(), + ..Default::default() + } + }), + policy.check(&ls_multiple_flags_and_file_args) + ); + Ok(()) +} + +#[test] +fn test_flags_after_file_args() -> Result<()> { + let policy = setup(); + + // TODO(mbolin): While this is "safe" in that it will not do anything bad + // to the user's machine, it will fail because apparently `ls` does not + // allow flags after file arguments (as some commands do). We should + // extend define_program() to make this part of the configuration so that + // this command is disallowed. + let ls_flags_after_file_args = ExecCall::new("ls", &["foo", "-l"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec { + program: "ls".into(), + flags: vec![MatchedFlag::new("-l")], + args: vec![MatchedArg::new(0, ArgType::ReadableFile, "foo")?], + system_path: ["/bin/ls".into(), "/usr/bin/ls".into()].into(), + ..Default::default() + } + }), + policy.check(&ls_flags_after_file_args) + ); + Ok(()) +} diff --git a/codex-rs/execpolicy/tests/parse_sed_command.rs b/codex-rs/execpolicy/tests/parse_sed_command.rs new file mode 100644 index 0000000000..6d03b626ef --- /dev/null +++ b/codex-rs/execpolicy/tests/parse_sed_command.rs @@ -0,0 +1,23 @@ +use codex_execpolicy::parse_sed_command; +use codex_execpolicy::Error; + +#[test] +fn parses_simple_print_command() { + assert_eq!(parse_sed_command("122,202p"), Ok(())); +} + +#[test] +fn rejects_malformed_print_command() { + assert_eq!( + parse_sed_command("122,202"), + Err(Error::SedCommandNotProvablySafe { + command: "122,202".to_string(), + }) + ); + assert_eq!( + parse_sed_command("122202"), + Err(Error::SedCommandNotProvablySafe { + command: "122202".to_string(), + }) + ); +} diff --git a/codex-rs/execpolicy/tests/pwd.rs b/codex-rs/execpolicy/tests/pwd.rs new file mode 100644 index 0000000000..4e29e4cbc1 --- /dev/null +++ b/codex-rs/execpolicy/tests/pwd.rs @@ -0,0 +1,85 @@ +extern crate codex_execpolicy; + +use std::vec; + +use codex_execpolicy::get_default_policy; +use codex_execpolicy::Error; +use codex_execpolicy::ExecCall; +use codex_execpolicy::MatchedExec; +use codex_execpolicy::MatchedFlag; +use codex_execpolicy::Policy; +use codex_execpolicy::PositionalArg; +use codex_execpolicy::ValidExec; + +fn setup() -> Policy { + get_default_policy().expect("failed to load default policy") +} + +#[test] +fn test_pwd_no_args() { + let policy = setup(); + let pwd = ExecCall::new("pwd", &[]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec { + program: "pwd".into(), + ..Default::default() + } + }), + policy.check(&pwd) + ); +} + +#[test] +fn test_pwd_capital_l() { + let policy = setup(); + let pwd = ExecCall::new("pwd", &["-L"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec { + program: "pwd".into(), + flags: vec![MatchedFlag::new("-L")], + ..Default::default() + } + }), + policy.check(&pwd) + ); +} + +#[test] +fn test_pwd_capital_p() { + let policy = setup(); + let pwd = ExecCall::new("pwd", &["-P"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec { + program: "pwd".into(), + flags: vec![MatchedFlag::new("-P")], + ..Default::default() + } + }), + policy.check(&pwd) + ); +} + +#[test] +fn test_pwd_extra_args() { + let policy = setup(); + let pwd = ExecCall::new("pwd", &["foo", "bar"]); + assert_eq!( + Err(Error::UnexpectedArguments { + program: "pwd".to_string(), + args: vec![ + PositionalArg { + index: 0, + value: "foo".to_string() + }, + PositionalArg { + index: 1, + value: "bar".to_string() + }, + ], + }), + policy.check(&pwd) + ); +} diff --git a/codex-rs/execpolicy/tests/sed.rs b/codex-rs/execpolicy/tests/sed.rs new file mode 100644 index 0000000000..cc26bf1eb4 --- /dev/null +++ b/codex-rs/execpolicy/tests/sed.rs @@ -0,0 +1,83 @@ +extern crate codex_execpolicy; + +use codex_execpolicy::get_default_policy; +use codex_execpolicy::ArgType; +use codex_execpolicy::Error; +use codex_execpolicy::ExecCall; +use codex_execpolicy::MatchedArg; +use codex_execpolicy::MatchedExec; +use codex_execpolicy::MatchedFlag; +use codex_execpolicy::MatchedOpt; +use codex_execpolicy::Policy; +use codex_execpolicy::Result; +use codex_execpolicy::ValidExec; + +fn setup() -> Policy { + get_default_policy().expect("failed to load default policy") +} + +#[test] +fn test_sed_print_specific_lines() -> Result<()> { + let policy = setup(); + let sed = ExecCall::new("sed", &["-n", "122,202p", "hello.txt"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec { + program: "sed".to_string(), + flags: vec![MatchedFlag::new("-n")], + args: vec![ + MatchedArg::new(1, ArgType::SedCommand, "122,202p")?, + MatchedArg::new(2, ArgType::ReadableFile, "hello.txt")?, + ], + system_path: vec!["/usr/bin/sed".to_string()], + ..Default::default() + } + }), + policy.check(&sed) + ); + Ok(()) +} + +#[test] +fn test_sed_print_specific_lines_with_e_flag() -> Result<()> { + let policy = setup(); + let sed = ExecCall::new("sed", &["-n", "-e", "122,202p", "hello.txt"]); + assert_eq!( + Ok(MatchedExec::Match { + exec: ValidExec { + program: "sed".to_string(), + flags: vec![MatchedFlag::new("-n")], + opts: vec![MatchedOpt::new("-e", "122,202p", ArgType::SedCommand).unwrap()], + args: vec![MatchedArg::new(3, ArgType::ReadableFile, "hello.txt")?], + system_path: vec!["/usr/bin/sed".to_string()], + } + }), + policy.check(&sed) + ); + Ok(()) +} + +#[test] +fn test_sed_reject_dangerous_command() { + let policy = setup(); + let sed = ExecCall::new("sed", &["-e", "s/y/echo hi/e", "hello.txt"]); + assert_eq!( + Err(Error::SedCommandNotProvablySafe { + command: "s/y/echo hi/e".to_string(), + }), + policy.check(&sed) + ); +} + +#[test] +fn test_sed_verify_e_or_pattern_is_required() { + let policy = setup(); + let sed = ExecCall::new("sed", &["122,202p"]); + assert_eq!( + Err(Error::MissingRequiredOptions { + program: "sed".to_string(), + options: vec!["-e".to_string()], + }), + policy.check(&sed) + ); +} From d2cb604b7dd9ce0a921f4283462324a5591f453f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 24 Apr 2025 17:46:10 -0700 Subject: [PATCH 6/6] fix: close stdin when running an exec tool call --- codex-rs/core/src/exec.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index fe6bad548e..ae83dc84e7 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -206,9 +206,17 @@ pub async fn exec( if let Some(dir) = &workdir { cmd.current_dir(dir); } - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - cmd.kill_on_drop(true); - cmd.spawn()? + + // Do not create a file descriptor for stdin because otherwise some + // commands may hang forever waiting for input. For example, ripgrep has + // a heuristic where it may try to read from stdin as explained here: + // https://github.com/BurntSushi/ripgrep/blob/e2362d4d5185d02fa857bf381e7bd52e66fafc73/crates/core/flags/hiargs.rs#L1101-L1103 + cmd.stdin(Stdio::null()); + + cmd.stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()? }; let stdout_handle = tokio::spawn(read_capped(