From 02e617270485c97bd101121ed00a95843a68ea18 Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:52:50 -0400 Subject: [PATCH 1/2] fix(cli): stop steering the OpenAI key into .agentic.yml The missing-token error and the README told users to run `agentic config set api_token=...`. That writes plain-text YAML to $PWD/.agentic.yml, which is not gitignored, and nothing in lib/ ever reads api_token back: LegacyConfiguration takes the token from OPENAI_ACCESS_TOKEN / AGENTIC_API_TOKEN only. The advice stored a secret one `git add .` from a public repo and did not even work. - check_api_token! now names the paths that work: the env vars, Agentic.configure, or a local endpoint via AGENTIC_API_BASE_URL. - README drops the api_token example and says why the key stays out of .agentic.yml. - `config set` refuses credential-shaped keys (token, key, secret, password as whole name segments; "tokenizer" still passes) and exits non-zero without writing. - Specs: refusal writes nothing, non-secret keys still work, the error text no longer mentions .agentic.yml. The existing placeholder key `test_key` is renamed `test_option` because it trips the guard. Closes #59 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 +++ README.md | 16 ++++++--------- lib/agentic/cli.rb | 25 +++++++++++++++++++++--- spec/agentic/cli/config_commands_spec.rb | 25 +++++++++++++++++++++--- spec/agentic/cli_spec.rb | 14 +++++++++++++ 5 files changed, 67 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a314b10..a7b62c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [Unreleased] +### Security +- The CLI no longer steers the OpenAI key into `.agentic.yml`: `agentic config set` refuses credential-shaped keys (token, key, secret, password), and the missing-token error and README point at `OPENAI_ACCESS_TOKEN` / `Agentic.configure`, which are the paths that actually work. Nothing ever read `api_token` from that file, and it is not gitignored. + ### Fixed - Test suite loads on Ruby 4.0: `benchmark` is a bundled (not default) gem there, so it is now declared in the Gemfile - `EventDispatcher` slow-observer spec uses a monotonic clock and a looser bound so it no longer flakes on loaded hosts diff --git a/README.md b/README.md index e80ec88..caaaed7 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,7 @@ $ agentic config init # Initialize configuration $ agentic config init --global # Initialize global configuration $ agentic config list # List all configuration settings $ agentic config get model # Get a specific configuration value -$ agentic config set api_token=your_openai_api_key # Set your OpenAI API token -$ agentic config set model=gpt-4o-mini # Configure the default model +$ agentic config set model=gpt-4o-mini # Configure the default model # Plan Creation and Execution $ agentic plan "Generate a market research report on AI trends" # Create a plan @@ -729,20 +728,17 @@ You can configure the OpenAI API key in several ways: export OPENAI_ACCESS_TOKEN="your_api_key" ``` -2. Configuration file: - Create `.agentic.yml` in your project directory or home directory: - ```yaml - api_token: "your_api_key" - model: "gpt-4o-mini" - ``` - -3. In your Ruby code: +2. In your Ruby code: ```ruby Agentic.configure do |config| config.access_token = "your_api_key" end ``` +Keep the key out of `.agentic.yml`: that file holds non-secret settings such +as `model`, is written in plain text to your project directory, and is not read +for credentials. `agentic config set` refuses keys that look like secrets. + ### LLM Configuration You can customize the LLM behavior: diff --git a/lib/agentic/cli.rb b/lib/agentic/cli.rb index db18da2..36f3d48 100644 --- a/lib/agentic/cli.rb +++ b/lib/agentic/cli.rb @@ -468,6 +468,12 @@ def get(key) end end + # Keys that look like credentials (api_token, openai_key, client_secret, + # db_password), matched on whole name segments so "tokenizer" passes. + # .agentic.yml is plain text, is not gitignored, and nothing reads a + # token out of it, so storing one there is a leak with no payoff + CREDENTIAL_KEY = /(\A|_)(token|key|secret|password)(_|\z)/i + desc "set KEY=VALUE", "Set a configuration setting" option :global, type: :boolean, aliases: "-g", desc: "Set in global user config instead of project config" @@ -479,6 +485,19 @@ def set(key_value) exit 1 end + if key.match?(CREDENTIAL_KEY) + puts UI.box( + "Refusing to store a credential", + "'#{key}' looks like a secret, and #{CONFIG_FILE_NAME} is a plain-text file " \ + "that nothing reads credentials from.\n\n" \ + "Set OPENAI_ACCESS_TOKEN in the environment instead, or use\n" \ + "Agentic.configure { |c| c.access_token = \"...\" } in code.", + padding: [1, 2, 1, 2], + style: {border: {fg: :red}} + ) + exit 1 + end + path = options[:global] ? USER_CONFIG_PATH : PROJECT_CONFIG_PATH config = load_config(path) || {} @@ -841,9 +860,9 @@ def check_api_token! "Configuration Error", "No OpenAI API token configured.\n\n" \ "You can set it using one of these methods:\n" \ - "- Environment variable: OPENAI_ACCESS_TOKEN\n" \ - "- Configuration file: .agentic.yml\n" \ - "- Run: agentic config set api_token=your_token", + "- Environment variable: OPENAI_ACCESS_TOKEN (or AGENTIC_API_TOKEN)\n" \ + "- In code: Agentic.configure { |c| c.access_token = \"...\" }\n" \ + "- Local endpoint: AGENTIC_API_BASE_URL, which needs no token", style: {border: {fg: :red}}, padding: [1, 2, 1, 2] ) diff --git a/spec/agentic/cli/config_commands_spec.rb b/spec/agentic/cli/config_commands_spec.rb index b89c026..1f0a607 100644 --- a/spec/agentic/cli/config_commands_spec.rb +++ b/spec/agentic/cli/config_commands_spec.rb @@ -99,11 +99,11 @@ end it "sets a configuration value" do - config_commands.set("test_key=test_value") + config_commands.set("test_option=test_value") expect(output.string).to include("Configuration Updated:") - expect(output.string).to match(/Set .*test_key.* to .*test_value.*/) - expect(config_commands).to have_received(:save_config).with(config_path, {"test_key" => "test_value"}) + expect(output.string).to match(/Set .*test_option.* to .*test_value.*/) + expect(config_commands).to have_received(:save_config).with(config_path, {"test_option" => "test_value"}) end it "converts values to appropriate types" do @@ -134,6 +134,25 @@ expect(config_commands).to have_received(:save_config).with(config_path, {"test_string" => "hello"}) end + it "refuses credential-shaped keys and writes nothing" do + %w[api_token=sk-123 OPENAI_KEY=abc client_secret=x db_password=y].each do |key_value| + expect { config_commands.set(key_value) }.to raise_error(SystemExit) + end + + expect(config_commands).not_to have_received(:save_config) + expect(output.string).to include("Refusing to store a credential") + expect(output.string).to include("OPENAI_ACCESS_TOKEN") + end + + it "still sets non-secret keys, including names that merely contain a credential word" do + config_commands.set("model=gpt-4o-mini") + expect(config_commands).to have_received(:save_config).with(config_path, {"model" => "gpt-4o-mini"}) + + allow(config_commands).to receive(:load_config).and_return({}) + config_commands.set("tokenizer=tiktoken") + expect(config_commands).to have_received(:save_config).with(config_path, {"tokenizer" => "tiktoken"}) + end + it "shows an error for invalid format" do # Instead of testing with the real implementation, mock the behavior diff --git a/spec/agentic/cli_spec.rb b/spec/agentic/cli_spec.rb index 99624e9..4bd985a 100644 --- a/spec/agentic/cli_spec.rb +++ b/spec/agentic/cli_spec.rb @@ -27,6 +27,20 @@ end end + describe "#check_api_token!" do + it "names the configuration paths that work, not .agentic.yml" do + allow(Agentic).to receive(:configuration).and_return( + instance_double(Agentic::LegacyConfiguration, access_token: nil, api_base_url: nil) + ) + allow(Agentic::UI).to receive(:box) { |title, content, _| "#{title}:\n#{content}" } + + expect { described_class.new.send(:check_api_token!) }.to raise_error(Thor::Error) do |error| + expect(error.message).to include("OPENAI_ACCESS_TOKEN", "Agentic.configure") + expect(error.message).not_to include(".agentic.yml", "config set api_token") + end + end + end + describe "agent commands" do describe "agent list" do it "displays available agents" do From 2fc0c0f4e34a92d9d0310228f492db1fa83b0df2 Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:53:47 -0400 Subject: [PATCH 2/2] fix(cli): catch credential keys in any spelling, and sk- values Whole-segment matching let apiKey, api-key, apikey and accessToken through. The key check is a plain substring match now, and a value that looks like an OpenAI key is refused whatever the key is called. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- lib/agentic/cli.rb | 13 ++++++++----- spec/agentic/cli/config_commands_spec.rb | 16 ++++++++-------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b62c2..811daec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## [Unreleased] ### Security -- The CLI no longer steers the OpenAI key into `.agentic.yml`: `agentic config set` refuses credential-shaped keys (token, key, secret, password), and the missing-token error and README point at `OPENAI_ACCESS_TOKEN` / `Agentic.configure`, which are the paths that actually work. Nothing ever read `api_token` from that file, and it is not gitignored. +- The CLI no longer steers the OpenAI key into `.agentic.yml`: `agentic config set` refuses keys containing token, key, secret, passw, or credential in any spelling, and values shaped like an OpenAI key, and the missing-token error and README point at `OPENAI_ACCESS_TOKEN` / `Agentic.configure`, which are the paths that actually work. Nothing ever read `api_token` from that file, and it is not gitignored. ### Fixed - Test suite loads on Ruby 4.0: `benchmark` is a bundled (not default) gem there, so it is now declared in the Gemfile diff --git a/lib/agentic/cli.rb b/lib/agentic/cli.rb index 36f3d48..01f1112 100644 --- a/lib/agentic/cli.rb +++ b/lib/agentic/cli.rb @@ -468,11 +468,14 @@ def get(key) end end - # Keys that look like credentials (api_token, openai_key, client_secret, - # db_password), matched on whole name segments so "tokenizer" passes. + # Keys that look like credentials (api_token, apiKey, api-key, client_secret, + # db_password, credentials) and values shaped like an OpenAI key. # .agentic.yml is plain text, is not gitignored, and nothing reads a - # token out of it, so storing one there is a leak with no payoff - CREDENTIAL_KEY = /(\A|_)(token|key|secret|password)(_|\z)/i + # token out of it, so storing one there is a leak with no payoff. The + # key check is a substring match on purpose: a false positive costs a + # rename, a false negative costs a secret + CREDENTIAL_KEY = /token|key|secret|passw|credential/i + CREDENTIAL_VALUE = /\Ask-[A-Za-z0-9_-]{16,}\z/ desc "set KEY=VALUE", "Set a configuration setting" option :global, type: :boolean, aliases: "-g", @@ -485,7 +488,7 @@ def set(key_value) exit 1 end - if key.match?(CREDENTIAL_KEY) + if key.match?(CREDENTIAL_KEY) || value.match?(CREDENTIAL_VALUE) puts UI.box( "Refusing to store a credential", "'#{key}' looks like a secret, and #{CONFIG_FILE_NAME} is a plain-text file " \ diff --git a/spec/agentic/cli/config_commands_spec.rb b/spec/agentic/cli/config_commands_spec.rb index 1f0a607..3321b1e 100644 --- a/spec/agentic/cli/config_commands_spec.rb +++ b/spec/agentic/cli/config_commands_spec.rb @@ -134,9 +134,13 @@ expect(config_commands).to have_received(:save_config).with(config_path, {"test_string" => "hello"}) end - it "refuses credential-shaped keys and writes nothing" do - %w[api_token=sk-123 OPENAI_KEY=abc client_secret=x db_password=y].each do |key_value| - expect { config_commands.set(key_value) }.to raise_error(SystemExit) + it "refuses credential-shaped keys and values and writes nothing" do + %w[ + api_token=sk-123 OPENAI_KEY=abc client_secret=x db_password=y + apiKey=abc api-key=abc apikey=abc accessToken=abc credentials=abc passwd=abc + model=sk-proj-abcdefghijklmnopqrstuvwxyz + ].each do |key_value| + expect { config_commands.set(key_value) }.to raise_error(SystemExit), "expected #{key_value} to be refused" end expect(config_commands).not_to have_received(:save_config) @@ -144,13 +148,9 @@ expect(output.string).to include("OPENAI_ACCESS_TOKEN") end - it "still sets non-secret keys, including names that merely contain a credential word" do + it "still sets non-secret keys" do config_commands.set("model=gpt-4o-mini") expect(config_commands).to have_received(:save_config).with(config_path, {"model" => "gpt-4o-mini"}) - - allow(config_commands).to receive(:load_config).and_return({}) - config_commands.set("tokenizer=tiktoken") - expect(config_commands).to have_received(:save_config).with(config_path, {"tokenizer" => "tiktoken"}) end it "shows an error for invalid format" do