Skip to content

Harden PersistentAgentStore path handling and capability-filter dispatch against untrusted agent configs #12

Description

@minerva-sky

Summary

PersistentAgentStore builds filesystem paths by joining externally influenced strings, and AgentCapabilityRegistry#matches_criteria? dispatches arbitrary filter keys via send. Neither is remotely exploitable — this is defense-in-depth hardening at the two places where LLM-shaped or file-shaped input meets the filesystem and dynamic dispatch. Both are benign to disclose (local file store, no deployed service).

1. Path traversal shape in PersistentAgentStore

store honors a pre-set agent.id verbatim (lib/agentic/persistent_agent_store.rb:43) and joins it into paths:

agent_dir = File.join(@storage_path, id)          # save_to_storage
agent_path = File.join(@storage_path, id, "#{version}.json")

IDs the store generates itself are SecureRandom.uuid — fine. But id is a configurable attribute on Agent, and the self-assembly direction (WORLD.md: agents that construct themselves from task requirements) means agent configs increasingly originate from LLM output or shared/loaded files. An id like ../../../home/user/.ssh/foo escapes the store root at store time (FileUtils.mkdir_p + File.write). Read/delete paths are index-gated today, but the index itself (index.json) is trusted when read back, so a tampered index re-opens the same shape for find_agent_data/delete_from_storage — including File.delete on a joined path.

Proposed fix (small, surgical): validate id and version as single path components at the store boundary — reject anything not matching /\A[\w.-]+\z/ or containing .., raise ArgumentError. One private guard method, called from store, find_agent_data, delete_from_storage, and index load. Tests: traversal id rejected on store, tampered index entry rejected on read, UUID round-trip unaffected.

2. Unbounded dynamic dispatch in capability filtering

AgentCapabilityRegistry#matches_criteria? (lib/agentic/agent_capability_registry.rb:247) falls through for unknown criteria keys:

capability.respond_to?(key) && capability.send(key) == value

Any zero-arg public method on the capability spec is callable via a filter key. Filter hashes reach this from PersistentAgentStore#all(filter:) and discovery paths — again, surfaces likely to carry LLM-derived values as self-assembly matures. Today's specs have a benign method surface, so this is lower priority than item 1; the fix is to whitelist the queryable attributes (or capability.to_h.key?(key)-style lookup) instead of open send.

Class and level

code-refactor-sized change, proposed at L0 per the security loop's convention (findings as issues, fixes on request). Happy to open the PR for item 1 if this analysis holds up.


Origin: loop:security session 2026-08-17. bundler-audit itself came back clean on the code side; the companion lockfile-advisory PR is separate.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions