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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions