Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,13 @@ Code Style
- Unit tests that use file paths and uris should rely on `h/file-path` and `h/file-uri` to avoid windows issues with slashes.

Secrets Management
- `src/eca/secrets/netrc.clj` - Netrc format parser (multi-line format: machine/login/password/port keywords)
- `src/eca/secrets/authinfo.clj` - Authinfo format parser (single-line format: space-separated key-value pairs)
- `src/eca/secrets.clj` - Main secrets manager for credential file operations:
- File discovery and priority order (.authinfo.gpg → .netrc.gpg → .authinfo → _authinfo → .netrc → _netrc)
- File discovery and priority order (`:netrcFile <FILE>` config → .netrc → _netrc)
- Cross-platform path construction using io/file (handles / vs \ separators automatically)
- GPG decryption with caching (5-second TTL) and timeout (30s, configurable via GPG_TIMEOUT env var)
- keyRc format parsing: [login@]machine[:port] (named after Unix "rc" config file tradition)
- Credential matching logic (exact login match when specified, first match otherwise)
- Permission validation (Unix: warns if not 0600; Windows: skipped)
- Authentication flow: config `key` → credential files `keyRc` → env var `keyEnv` → OAuth
- Security: passwords never logged; GPG decryption via clojure.java.process; cache with short TTL; subprocess timeout protection

Notes
- CI runs: bb test and bb integration-test. Ensure these pass locally before PRs.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- BREAKING ECA now only supports standard plain-text netrc as credential file reading. Drop authinfo and gpg decryption support. Users can choose to pass in their own provisioned netrc file from various secure source with `:netrcFile` in ECA config.

## 0.74.0

- Improved `eca_edit_file` to automatically handle whitespace and indentation differences in single-occurrence edits.
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
systemPromptFile?: string;
};
otlp?: {[key: string]: string};
netrcFile?: string;
}
```

Expand All @@ -530,6 +531,7 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
"ollama": {"url": "http://localhost:11434"}
},
"defaultModel": null, // let ECA decides the default model.
"netrcFile": null, // search ~/.netrc or ~/_netrc when null.
"hooks": {},
"rules" : [],
"commands" : [],
Expand Down
15 changes: 12 additions & 3 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ Only set this when your provider uses a different path or expects query paramete

### Credential File Authentication

Use `keyRc` in your provider config to read credentials from `~/.authinfo(.gpg)` or `~/.netrc(.gpg)` without storing keys directly in config or env vars.
ECA also supports standard plain-text .netrc file format for reading credentials.

Use `keyRc` in your provider config to read credentials from `~/.netrc` without storing keys directly in config or env vars.

Example:

Expand All @@ -163,13 +165,20 @@ Example:

keyRc lookup specification format: `[login@]machine[:port]` (e.g., `api.openai.com`, `[email protected]`, `api.custom.com:8443`).

ECA by default search .netrc file stored in user's home directory. You can also provide the path to the actual file to use with `:netrcFile` in ECA config.

Tip for those wish to store their credentials encrypted with tools like gpg or age:

```bash
# via secure tempororay file
gpg --batch -q -d ./netrc.gpg > /tmp/netrc.$$ && chmod 600 /tmp/netrc.$$ && ECA_CONFIG='{"netrcFile": "/tmp/netrc.$$"}' eca server && shred -u /tmp/netrc.$$
```

Further reading on credential file formats:
- [Emacs authinfo documentation](https://www.gnu.org/software/emacs/manual/html_node/auth/Help-for-users.html)
- [Curl Netrc documentation](https://everything.curl.dev/usingcurl/netrc)
- [GNU Inetutils .netrc documentation](https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html)

Notes:
- Preferred files are GPG-encrypted (`~/.authinfo.gpg` / `~/.netrc.gpg`); plaintext variants are supported.
- Authentication priority (short): `key` > `keyRc files` > `keyEnv` > OAuth.
- All providers with API key auth can use credential files.

Expand Down
1 change: 1 addition & 0 deletions src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
:maxEntriesPerDir 50}}
:completion {:model "openai/gpt-4.1"
:systemPromptFile "prompts/inline_completion.md"}
:netrcFile nil
:env "prod"})


Expand Down
6 changes: 3 additions & 3 deletions src/eca/features/commands.clj
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@

(defn ^:private doctor-msg [db config]
(let [model (llm-api/default-model db config)
cred-check (secrets/check-credential-files)
cred-check (secrets/check-credential-files (:netrcFile config))
existing-files (filter :exists (:files cred-check))]
(multi-str (str "ECA version: " (config/eca-version))
""
Expand Down Expand Up @@ -173,7 +173,7 @@
(System/getenv)))
""
(if (seq existing-files)
(str "Credential files (GPG available: " (:gpg-available cred-check) "):"
(str "Credential files:"
(reduce
(fn [s file-info]
(str s "\n " (:path file-info) ":"
Expand All @@ -189,7 +189,7 @@
(str "\n " (:suggestion file-info)))))
""
existing-files))
(str "Credential files: None found (GPG available: " (:gpg-available cred-check) ")")))))
"Credential files: None found"))))

(defn handle-command! [command args {:keys [chat-id db* config messenger full-model instructions user-messages metrics]}]
(let [db @db*
Expand Down
2 changes: 1 addition & 1 deletion src/eca/llm_util.clj
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
(when-let [key (:api-key provider-auth)]
[:auth/oauth key])
(when-let [key (some-> (get-in config [:providers (name provider) :keyRc])
(secrets/get-credential))]
(secrets/get-credential (:netrcFile config)))]
[:auth/token key])
(when-let [key (some-> (get-in config [:providers (name provider) :keyEnv])
config/get-env)]
Expand Down
3 changes: 2 additions & 1 deletion src/eca/main.clj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
[borkdude.dynaload]
[clojure.string :as string]
[eca.config :as config]
[eca.secrets :as secrets]
[eca.logger :as logger]
[eca.server :as server]
[eca.proxy :as proxy]))
Expand Down Expand Up @@ -42,7 +43,7 @@
(def log-levels #{"error" "warn" "info" "debug"})

(def cli-spec
{:order [:help :version :verbose :config-file]
{:order [:help :version :verbose :config-file :log-level]
:spec {:help {:alias :h
:desc "Print the available commands and its options"}
:version {:desc "Print eca version"}
Expand Down
181 changes: 41 additions & 140 deletions src/eca/secrets.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
(:require
[br.dev.zz.parc :as parc]
[clojure.java.io :as io]
[clojure.java.process :as process]
[clojure.string :as string]
[eca.logger :as logger]
[eca.shared :as shared])
Expand All @@ -15,9 +14,6 @@

(def ^:private logger-tag "[secrets]")

(defn ^:private netrc-path? [path]
(string/includes? (string/lower-case (.getName (io/file path))) "netrc"))

(defn ^:private normalize-port
[port]
(cond
Expand All @@ -28,15 +24,15 @@

(defn credential-file-paths
"Returns ordered list of credential file paths to check."
[]
(let [home (System/getProperty "user.home")
files [".authinfo" "_authinfo" ".netrc" "_netrc"]]
(mapcat (fn [filename]
(let [path (str (io/file home filename))]
[(str path ".gpg") path]))
files)))

(defn ^:private validate-permissions [^File file]
[netrc-file]
(if (some? netrc-file)
[netrc-file]
(let [home (System/getProperty "user.home")
files [".netrc" "_netrc"]]
(->> files
(mapv #(str (io/file home %)))))))

(defn ^:private validate-permissions [^File file & {:keys [warn?] :or {warn? false}}]
(try
;; On Unix systems, check if file is readable by others
(when-not (string/includes? (System/getProperty "os.name") "Windows")
Expand All @@ -45,98 +41,34 @@
(if (or (contains? perms PosixFilePermission/GROUP_READ)
(contains? perms PosixFilePermission/OTHERS_READ))
(do
(logger/warn logger-tag "Credential file has insecure permissions:"
(.getPath file)
"- should be 0600 (readable only by owner)")
(when warn?
(logger/warn logger-tag "Credential file has insecure permissions:"
(.getPath file)
"- should be 0600 (readable only by owner)"))
false)
true)))
true
(catch Exception _e
;; If permission check fails (e.g., on Windows), just proceed
true)))

(defn gpg-available?
"Checks if gpg command is available on system."
[]
(try (string? (process/exec "gpg" "--version"))
(catch Exception _e
false)))

;; Cache for GPG decryption results (5-second TTL)
(def ^:private gpg-cache (atom {}))

(defn ^:private gpg-cache-key [^File file]
(str (.getPath file) ":" (.lastModified file)))

(defn ^:private get-cached-gpg [cache-key]
(when-let [{:keys [content timestamp]} (@gpg-cache cache-key)]
(when (< (- (System/currentTimeMillis) timestamp) 5000) ; 5-second TTL
(logger/debug logger-tag "GPG cache hit for" cache-key)
content)))

(defn ^:private cache-gpg-result! [cache-key content]
(swap! gpg-cache assoc cache-key {:content content
:timestamp (System/currentTimeMillis)})
content)

(defn decrypt-gpg
"Decrypts a .gpg file using gpg command. Returns decrypted content or nil on failure.
Timeout: 30 seconds (configurable via GPG_TIMEOUT env var)."
[file-path]
(try
(let [file (io/file file-path)
cache-key (gpg-cache-key file)]
;; Check cache first
(or (get-cached-gpg cache-key)
;; Not in cache, decrypt using clojure.java.process directly
(let [timeout-seconds (try
(Long/parseLong (System/getenv "GPG_TIMEOUT"))
(catch Exception _e 30)) ; default 30 seconds
timeout-ms (* timeout-seconds 1000)
proc (process/start {:out :pipe :err :pipe}
"gpg" "--quiet" "--batch" "--decrypt" file-path)
exit (deref (process/exit-ref proc) timeout-ms ::timeout)]
(if (= exit ::timeout)
(do
(.destroy proc)
(logger/warn logger-tag "GPG decryption timed out after" timeout-seconds "seconds for" file-path)
nil)
(let [out (slurp (process/stdout proc))
err (slurp (process/stderr proc))]
(if (zero? exit)
(do
(logger/debug logger-tag "GPG decryption successful for" file-path)
(cache-gpg-result! cache-key out))
(do
(logger/warn logger-tag "GPG decryption failed for" file-path
"- exit code:" exit)
(when-not (string/blank? err)
(logger/debug logger-tag "GPG error:" err))
nil)))))))
(catch Exception e
(logger/warn logger-tag "GPG command failed for" file-path ":" (.getMessage e))
nil)))

(defn ^:private parse-credentials [path content]
(defn ^:private parse-credentials [content]
(let [entries (parc/->netrc (StringReader. (or content "")))]
(cond->> entries
(netrc-path? path)
(map (fn [entry]
(cond-> entry
(contains? entry :port)
(update :port normalize-port)))))))
(map (fn [entry]
(cond-> entry
(contains? entry :port)
(update :port normalize-port)))
entries)))

(defn ^:private load-credentials-from-file* [^String file-path]
(try
(logger/debug logger-tag "Checking credential file:" file-path)
(let [file (io/file file-path)]
(when (and (.exists file) (.isFile file) (.canRead file))
(let [content (if (string/ends-with? file-path ".gpg")
(when (gpg-available?) (decrypt-gpg file-path))
(do (validate-permissions file)
(slurp file)))]
(when (and (.exists file) (.canRead file))
(validate-permissions file :warn? true)
(let [content (slurp file)]
(when (seq content)
(when-let [credentials (seq (parse-credentials file-path content))]
(when-let [credentials (seq (parse-credentials content))]
(logger/debug logger-tag "Loaded" (count credentials) "credentials from" file-path)
(vec credentials))))))
(catch Exception e
Expand All @@ -146,9 +78,9 @@
(def ^:private load-credentials-from-file
(shared/memoize-by-file-last-modified load-credentials-from-file*))

(defn ^:private load-all-credentials []
(defn ^:private load-all-credentials [netrc-file]
(vec (mapcat #(or (load-credentials-from-file %) [])
(credential-file-paths))))
(credential-file-paths netrc-file))))

(defn parse-key-rc
"Parses keyRc value in format [login@]machine[:port].
Expand Down Expand Up @@ -179,70 +111,40 @@
"Retrieves password for keyRc specifier from credential files.
Format: [login@]machine[:port]
Returns password string or nil if not found."
[key-rc]
(when-let [spec (parse-key-rc key-rc)]
(when-let [credentials (load-all-credentials)]
(when-let [matched (first (filter #(match-credential % spec) credentials))]
(logger/debug logger-tag "Found credential for" key-rc)
(:password matched)))))
([key-rc]
(get-credential key-rc nil))
([key-rc netrc-file]
(when-let [spec (parse-key-rc key-rc)]
(when-let [credentials (load-all-credentials netrc-file)]
(when-let [matched (first (filter #(match-credential % spec) credentials))]
(logger/debug logger-tag "Found credential for" key-rc)
(:password matched))))))

(defn check-credential-files
"Performs diagnostic checks on credential files for /doctor command.
Returns a map with:
- :gpg-available - boolean indicating if GPG is available
- :files - vector of file check results, each containing:
- :path - file path
- :exists - boolean
- :readable - boolean (if exists)
- :permissions-secure - boolean (Unix only, if exists)
- :is-gpg - boolean
- :credentials-count - number of valid credentials (if readable)
- :parse-error - error message (if parse fails)
- :suggestion - optional security suggestion"
[]
(let [gpg-avail? (gpg-available?)
file-paths (credential-file-paths)
- :parse-error - error message (if parse fails)"
[netrc-file]
(let [file-paths (credential-file-paths netrc-file)
file-checks (for [path file-paths]
(let [file (io/file path)
exists (.exists file)
is-file (and exists (.isFile file))
readable (and is-file (.canRead file))
is-gpg (or (string/ends-with? path ".authinfo.gpg")
(string/ends-with? path ".netrc.gpg"))
is-plaintext (and (not is-gpg)
(or (string/ends-with? path ".authinfo")
(string/ends-with? path "_authinfo")
(string/ends-with? path ".netrc")
(string/ends-with? path "_netrc")))]
readable (.canRead file)]
(cond-> {:path path
:exists exists
:is-gpg is-gpg}
:exists exists}
;; Check readability
exists (assoc :readable readable)

;; Check permissions (Unix only, plaintext only)
(and is-plaintext readable)
readable
(assoc :permissions-secure
(try
(when-not (string/includes? (System/getProperty "os.name") "Windows")
(let [file-path (.toPath file)
perms (Files/getPosixFilePermissions
file-path
(into-array LinkOption []))]
(not (or (contains? perms PosixFilePermission/GROUP_READ)
(contains? perms PosixFilePermission/OTHERS_READ)))))
(catch Exception _e
true))) ; On Windows or permission check failure, consider secure

;; Add GPG suggestion for plaintext files
(and is-plaintext readable)
(assoc :suggestion
(str "Consider encrypting with GPG: "
"gpg --output "
(if (string/ends-with? path ".netrc")
"~/.netrc.gpg"
"~/.authinfo.gpg")
" --symmetric " path))
(validate-permissions file))

;; Try to parse and count credentials
readable
Expand All @@ -252,5 +154,4 @@
{:credentials-count (count credentials)})
(catch Exception e
{:parse-error (.getMessage e)}))))))]
{:gpg-available gpg-avail?
:files (vec file-checks)}))
{:files (vec file-checks)}))
Loading
Loading