You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of an ActiveAgent + actionagent dashboard functional review (multi-agent, adversarially verified). Severity: 🟠 Major.
The engine is the reference copy and still leaks: showtimeline and export return raw action.value/metadata, bypassing the password/PII redaction that /actions applies. And index/recent scope via owned() on user_id/account_id columns that nothing on the record-creation path ever populates, so the multi-tenant recordings list is always empty even though individual recordings open fine.
Findings
Engine show timeline and export return raw action.value/metadata, bypassing the redaction /actions applies (same bug as app; reference copy also leaks)
Where:actionagent/app/controllers/action_agent/api/session_recordings_controller.rb:314 · severity: major · kind: security · repo: activeagent
What breaks: The engine is the reference implementation and has the identical redaction gap. SessionRecording#timeline (engine model line 116-129) emits value: action.value unredacted; recording_detail exposes it as timeline: (controller line 314) and build_cassette (line 349) exposes it in export. Meanwhile /actions uses RecordingAction#as_json_for_api which redacts passwords/PII (recording_action.rb:59 redacted_value). So on the engine too, /:id (show) and /:id/export return cleartext secrets that /:id/actions redacts. Because the engine is the copy other installs are extracted from, the fix belongs here as well.
Evidence: LIVE PROOF (engine dummy, no auth): recorded value 'hunter2secret' into input[name=password] on recording 1, then curl http://localhost:3001/activeagents/api/session_recordings/1 returned timeline {"type":"type","selector":"input[name=password]","value":"hunter2secret"} while curl .../1/actions returned "value":"[REDACTED]" for the same action. Export: curl -X POST .../1/export returned the cassette with "value":"hunter2secret". Files: engine timeline model actionagent/app/models/action_agent/session_recording.rb:123; as_json_for_api actionagent/app/models/action_agent/recording_action.rb:52-65; recording_detail timeline controller:314; build_cassette value controller:349.
Suggested fix: In SessionRecording#timeline and build_cassette, serialize each action through the same redaction path as /actions: either call action.as_json_for_api (adjusting keys the timeline/cassette consumers expect), or make RecordingAction#redacted_value and #safe_metadata public and use value: action.send(:redacted_value) -> value: action.redacted_value, metadata: action.safe_metadata in both places (note both methods are currently private, so the fix must change their visibility or go through as_json_for_api). Apply identically in the app copy: activeagents/app/models/session_recording.rb:120 (timeline) and activeagents/app/controllers/api/session_recordings_controller.rb:375 (cassette).
Note: The platform app carries the same defect at app/models/session_recording.rb:120 (no drift — both copies broken). Per the engine-canonical policy this is tracked here; PR Scaling generation #102 (mount engine) removes the app copy.
Engine index/recent scope via owned() on user_id/account_id columns that nothing ever populates — multi-tenant recordings list is always empty though individual recordings open fine
Where:actionagent/app/controllers/action_agent/api/session_recordings_controller.rb:22 · severity: major · kind: bug · repo: activeagent
What breaks: index (controller line 22, owned(SessionRecording).or(name: lander_demo)) and recent (line 60, owned(SessionRecording)) filter by the owner column that Ownable maps to user_id or account_id when a host configures user_class/account_class. But nothing in the engine ever writes user_id/account_id onto a SessionRecording: start! (model 35-43), start_user_session! (46-59), the middleware's find_or_create_recording, SessionRecordable#start_session_recording! (concern 31-39) and handoff's create! (controller 230-241) all omit the owner. So in any multi-tenant mount, owned() matches zero rows and the list shows only lander_demo — even for the account that created the recordings. This directly contradicts the controller's own comment on set_recording (lines 267-270): it deliberately avoided owned() there ('nothing in the engine writes user_id/account_id ... an ownership scope would hide it from the person who made it') and used can_manage_recording? instead, but index/recent were left on owned(). Net effect: a user can open a recording by direct id (show works via can_manage_recording?/sandbox ownership) but it never appears in their list. Single-user installs (owner_association nil -> owned() = all) are unaffected, which is why the dummy works. The app copy sidesteps this with UserSessionClaimer populating metadata.account_id at signup plus metadata-based index scoping; the engine's rewrite to Ownable columns dropped the population half.
Evidence: Code read end-to-end: owned() (base_controller.rb:32-40) scopes to where(user_id:/account_id:) when owner_association is :user/:account; owner_association resolves non-nil whenever ActionAgent.user_class/account_class is set (ownable.rb:46-48, action_agent.rb multi_tenant?). No creation path sets those columns (session_recording.rb start!/start_user_session!, session_recordable.rb:31-39, controller handoff:230-241). set_recording (controller:271-288) intentionally uses can_manage_recording? not owned(), with the comment at 267-270 stating the column is never written. LIVE: dummy is single-user (no user_class/account_class configured), so owned()=all and index returned the recording (total:1) — masking the multi-tenant break.
Suggested fix: Two-part fix in the engine. (a) Write the owner at creation: thread an owner through SessionRecording.start!/start_user_session! and SessionRecordingService/SessionRecordable/middleware (recording.owner = ... — Ownable#owner= at ownable.rb:81-84 already no-ops on single-user installs), and set it in the handoff continuation create! (controller:230-241, which today stuffs user_id into metadata instead of the column). (b) Until/unless backfill exists, make index (line 22) and recent (line 60) match set_recording's reachability: in addition to owned(SessionRecording), include recordings whose sandbox_session belongs to current_owner (join sandbox_sessions on its owner column), mirroring can_manage_recording? (lines 278-288). Note recent should also be checked for whether lander_demo inclusion is wanted there. If anonymous-until-signup semantics are desired, port the app's UserSessionClaimer claim step instead, writing the real Ownable column rather than metadata.
Verification
Each finding above was produced by a dedicated per-feature review agent, then confirmed by an independent adversarial verifier (all rated high-confidence; zero rejected in this set). File:line citations are against the current main/HEAD of each repo; many were reproduced live against a booted dashboard.
The engine is the reference copy and still leaks:
showtimelineandexportreturn rawaction.value/metadata, bypassing the password/PII redaction that/actionsapplies. Andindex/recentscope viaowned()onuser_id/account_idcolumns that nothing on the record-creation path ever populates, so the multi-tenant recordings list is always empty even though individual recordings open fine.Findings
Engine show
timelineandexportreturn raw action.value/metadata, bypassing the redaction /actions applies (same bug as app; reference copy also leaks)actionagent/app/controllers/action_agent/api/session_recordings_controller.rb:314· severity: major · kind: security · repo:activeagentvalue: action.valueunredacted; recording_detail exposes it astimeline:(controller line 314) and build_cassette (line 349) exposes it in export. Meanwhile /actions uses RecordingAction#as_json_for_api which redacts passwords/PII (recording_action.rb:59 redacted_value). So on the engine too, /:id (show) and /:id/export return cleartext secrets that /:id/actions redacts. Because the engine is the copy other installs are extracted from, the fix belongs here as well.curl http://localhost:3001/activeagents/api/session_recordings/1returned timeline{"type":"type","selector":"input[name=password]","value":"hunter2secret"}whilecurl .../1/actionsreturned"value":"[REDACTED]"for the same action. Export:curl -X POST .../1/exportreturned the cassette with"value":"hunter2secret". Files: engine timeline model actionagent/app/models/action_agent/session_recording.rb:123; as_json_for_api actionagent/app/models/action_agent/recording_action.rb:52-65; recording_detail timeline controller:314; build_cassette value controller:349.value: action.send(:redacted_value)->value: action.redacted_value,metadata: action.safe_metadatain both places (note both methods are currently private, so the fix must change their visibility or go through as_json_for_api). Apply identically in the app copy: activeagents/app/models/session_recording.rb:120 (timeline) and activeagents/app/controllers/api/session_recordings_controller.rb:375 (cassette).app/models/session_recording.rb:120(no drift — both copies broken). Per the engine-canonical policy this is tracked here; PR Scaling generation #102 (mount engine) removes the app copy.Engine index/recent scope via owned() on user_id/account_id columns that nothing ever populates — multi-tenant recordings list is always empty though individual recordings open fine
actionagent/app/controllers/action_agent/api/session_recordings_controller.rb:22· severity: major · kind: bug · repo:activeagentowned(SessionRecording).or(name: lander_demo)) and recent (line 60,owned(SessionRecording)) filter by the owner column that Ownable maps to user_id or account_id when a host configures user_class/account_class. But nothing in the engine ever writes user_id/account_id onto a SessionRecording: start! (model 35-43), start_user_session! (46-59), the middleware's find_or_create_recording, SessionRecordable#start_session_recording! (concern 31-39) and handoff's create! (controller 230-241) all omit the owner. So in any multi-tenant mount, owned() matches zero rows and the list shows only lander_demo — even for the account that created the recordings. This directly contradicts the controller's own comment on set_recording (lines 267-270): it deliberately avoided owned() there ('nothing in the engine writes user_id/account_id ... an ownership scope would hide it from the person who made it') and used can_manage_recording? instead, but index/recent were left on owned(). Net effect: a user can open a recording by direct id (show works via can_manage_recording?/sandbox ownership) but it never appears in their list. Single-user installs (owner_association nil -> owned() = all) are unaffected, which is why the dummy works. The app copy sidesteps this with UserSessionClaimer populating metadata.account_id at signup plus metadata-based index scoping; the engine's rewrite to Ownable columns dropped the population half.Verification
Each finding above was produced by a dedicated per-feature review agent, then confirmed by an independent adversarial verifier (all rated high-confidence; zero rejected in this set). File:line citations are against the current
main/HEAD of each repo; many were reproduced live against a booted dashboard.