Class: Aidp::Security::WorkLoopAdapter

Inherits:
Object
  • Object
show all
Defined in:
lib/aidp/security/work_loop_adapter.rb

Overview

Adapts the Rule of Two security framework to the WorkLoopRunner Tracks trifecta state per work unit and enforces policy before agent calls

Integration points:

  • Work unit start/end lifecycle
  • Untrusted input detection (issues, PRs, external data)
  • Egress detection (git operations, API calls)
  • Private data detection (registered secrets)

Usage:

adapter = WorkLoopAdapter.new(project_dir: Dir.pwd)
adapter.begin_work_unit(work_unit_id: "unit_123", context: context)
adapter.check_agent_call_allowed!(operation: :git_push)
adapter.end_work_unit

Constant Summary collapse

MCP_CONSERVATIVE_FLAGS =
%i[untrusted_input private_data egress].freeze
UNTRUSTED_SOURCES =

Sources of untrusted input that trigger the untrusted_input flag

%w[
  github_issue
  github_pr
  github_comment
  external_url
  user_provided_url
  webhook_payload
].freeze
EGRESS_OPERATIONS =

Operations that constitute egress (external communication)

%w[
  git_push
  git_fetch
  api_call
  http_request
  webhook_send
  email_send
  file_upload
  pr_comment
  issue_comment
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(project_dir:, config: nil, enforcer: nil, secrets_proxy: nil, provider_info_class: nil) ⇒ WorkLoopAdapter

Returns a new instance of WorkLoopAdapter.



46
47
48
49
50
51
52
53
54
55
# File 'lib/aidp/security/work_loop_adapter.rb', line 46

def initialize(project_dir:, config: nil, enforcer: nil, secrets_proxy: nil, provider_info_class: nil)
  @project_dir = project_dir
  @config = config || load_security_config
  @enforcer = enforcer || Aidp::Security.enforcer
  @secrets_proxy = secrets_proxy || Aidp::Security.secrets_proxy
  @provider_info_class = provider_info_class || Aidp::Harness::ProviderInfo
  @mcp_risk_profile = load_mcp_risk_profile
  @current_work_unit_id = nil
  @current_state = nil
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



20
21
22
# File 'lib/aidp/security/work_loop_adapter.rb', line 20

def config
  @config
end

#current_stateObject (readonly)

Returns the value of attribute current_state.



20
21
22
# File 'lib/aidp/security/work_loop_adapter.rb', line 20

def current_state
  @current_state
end

#current_work_unit_idObject (readonly)

Returns the value of attribute current_work_unit_id.



20
21
22
# File 'lib/aidp/security/work_loop_adapter.rb', line 20

def current_work_unit_id
  @current_work_unit_id
end

#mcp_risk_profileObject (readonly)

Returns the value of attribute mcp_risk_profile.



20
21
22
# File 'lib/aidp/security/work_loop_adapter.rb', line 20

def mcp_risk_profile
  @mcp_risk_profile
end

#project_dirObject (readonly)

Returns the value of attribute project_dir.



20
21
22
# File 'lib/aidp/security/work_loop_adapter.rb', line 20

def project_dir
  @project_dir
end

Instance Method Details

#apply_mcp_tool_risk!(tool_name) ⇒ Object

Apply deterministic MCP tool risk flags generated during configuration. This uses the stored profile and does not perform any AI calls at runtime.



139
140
141
142
143
144
145
146
# File 'lib/aidp/security/work_loop_adapter.rb', line 139

def apply_mcp_tool_risk!(tool_name)
  return @current_state unless enabled? && @current_state

  tool_profile = @mcp_risk_profile.tool(tool_name)
  return apply_unclassified_mcp_tool_risk!(tool_name) unless tool_profile

  enable_mcp_tool_flags(tool_name, tool_profile[:flags])
end

#apply_provider_mcp_tool_risk!(provider_name, force_refresh: false) ⇒ Object

Apply deterministic MCP tool risk flags for all MCP servers configured on the selected provider. This reads only persisted provider metadata so the work loop never re-introspects provider CLIs at runtime.



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/aidp/security/work_loop_adapter.rb', line 151

def apply_provider_mcp_tool_risk!(provider_name, force_refresh: false)
  return @current_state unless enabled? && @current_state

  if force_refresh
    Aidp.log_debug("security.adapter", "ignored_provider_mcp_refresh_request",
      provider: provider_name)
  end

  provider_mcp_server_names(provider_name).each do |tool_name|
    apply_mcp_tool_risk!(tool_name)
  end

  @current_state
rescue => e
  Aidp.log_warn("security.adapter", "mcp_provider_lookup_failed",
    provider: provider_name,
    error: e.message)
  apply_unknown_mcp_risk!(provider_name)
end

#begin_work_unit(work_unit_id:, context: {}) ⇒ TrifectaState

Begin tracking a work unit

Parameters:

  • work_unit_id (String)

    Unique identifier for the work unit

  • context (Hash) (defaults to: {})

    Work context containing source information

Returns:



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/aidp/security/work_loop_adapter.rb', line 67

def begin_work_unit(work_unit_id:, context: {})
  return nil unless enabled?

  @current_work_unit_id = work_unit_id
  @current_state = @enforcer.begin_work_unit(work_unit_id: work_unit_id)

  # Analyze context for untrusted input
  detect_and_enable_untrusted_input(context)

  Aidp.log_debug("security.adapter", "work_unit_started",
    work_unit_id: work_unit_id,
    initial_state: @current_state.to_h)

  @current_state
end

#check_agent_call_allowed!(operation:, requires_credentials: false) ⇒ TrifectaState

Check if an agent call would be allowed and enable egress flag

Parameters:

  • operation (String, Symbol)

    The type of operation (e.g., :git_push)

  • requires_credentials (Boolean) (defaults to: false)

    Whether operation needs credentials

Returns:

Raises:



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/aidp/security/work_loop_adapter.rb', line 103

def check_agent_call_allowed!(operation:, requires_credentials: false)
  return @current_state unless enabled? && @current_state

  operation_str = operation.to_s

  # Check if this operation constitutes egress
  if egress_operation?(operation_str)
    begin
      @current_state.enable(:egress, source: "agent_operation:#{operation_str}")
    rescue PolicyViolation => e
      Aidp.log_warn("security.adapter", "egress_blocked",
        operation: operation_str,
        reason: e.message,
        current_state: @current_state.to_h)
      raise
    end
  end

  # If operation requires credentials, check if we can enable private_data
  if requires_credentials
    begin
      @current_state.enable(:private_data, source: "credential_access:#{operation_str}")
    rescue PolicyViolation => e
      Aidp.log_warn("security.adapter", "credential_access_blocked",
        operation: operation_str,
        reason: e.message,
        current_state: @current_state.to_h)
      raise
    end
  end

  @current_state
end

#enabled?Boolean

Check if security enforcement is enabled

Returns:

  • (Boolean)


58
59
60
61
# File 'lib/aidp/security/work_loop_adapter.rb', line 58

def enabled?
  rule_of_two_config = @config[:rule_of_two] || {}
  rule_of_two_config.fetch(:enabled, true)
end

#end_work_unitHash

End tracking for current work unit

Returns:

  • (Hash)

    Final state summary



85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/aidp/security/work_loop_adapter.rb', line 85

def end_work_unit
  return nil unless enabled? && @current_work_unit_id

  summary = @enforcer.end_work_unit(@current_work_unit_id)
  @current_work_unit_id = nil
  @current_state = nil

  Aidp.log_debug("security.adapter", "work_unit_ended",
    summary: summary)

  summary
end

#request_credential(secret_name:, scope: nil) ⇒ Hash

Request credentials through the secrets proxy This enables the private_data flag and returns a short-lived token

Parameters:

  • secret_name (String)

    The registered secret name

  • scope (String) (defaults to: nil)

    The intended use of this credential

Returns:

  • (Hash)

    Token details from the secrets proxy

Raises:



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/aidp/security/work_loop_adapter.rb', line 177

def request_credential(secret_name:, scope: nil)
  unless enabled?
    # If security is disabled, return direct access (legacy mode)
    env_var = @secrets_proxy.registry.env_var_for(secret_name)
    return {token: ENV[env_var], direct_access: true} if env_var

    raise UnregisteredSecretError.new(secret_name: secret_name)
  end

  # Check if enabling private_data would violate Rule of Two
  if @current_state&.would_create_trifecta?(:private_data)
    raise PolicyViolation.new(
      flag: :private_data,
      source: "credential_request:#{secret_name}",
      current_state: @current_state.to_h,
      message: "Cannot access credentials for '#{secret_name}' - would create lethal trifecta"
    )
  end

  # Enable private_data flag
  @current_state&.enable(:private_data, source: "secrets_proxy:#{secret_name}")

  # Request token from proxy
  @secrets_proxy.request_token(secret_name: secret_name, scope: scope)
end

#sanitized_environmentHash

Get a sanitized environment for agent execution Strips all registered secrets from the environment

Returns:

  • (Hash)

    Sanitized environment hash



206
207
208
# File 'lib/aidp/security/work_loop_adapter.rb', line 206

def sanitized_environment
  @secrets_proxy.sanitized_environment
end

#statusObject

Get current security status for display



240
241
242
243
244
245
246
247
248
249
# File 'lib/aidp/security/work_loop_adapter.rb', line 240

def status
  return {enabled: false} unless enabled?

  {
    enabled: true,
    active_work_unit: @current_work_unit_id,
    state: @current_state&.to_h,
    status_string: @current_state&.status_string || "No active work unit"
  }
end

#with_sanitized_environment { ... } ⇒ Object

Execute a block with sanitized environment

Yields:

  • The block to execute

Returns:

  • (Object)

    The result of the block



213
214
215
# File 'lib/aidp/security/work_loop_adapter.rb', line 213

def with_sanitized_environment(&block)
  @secrets_proxy.with_sanitized_environment(&block)
end

#would_allow?(flag) ⇒ Hash

Check if current state would allow enabling a flag

Parameters:

  • flag (Symbol)

    :untrusted_input, :private_data, or :egress

Returns:

  • (Hash)

    { allowed: boolean, reason: string }



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/aidp/security/work_loop_adapter.rb', line 220

def would_allow?(flag)
  return {allowed: true, reason: "Security disabled"} unless enabled?
  return {allowed: true, reason: "No active work unit"} unless @current_state

  if @current_state.would_create_trifecta?(flag)
    {
      allowed: false,
      reason: "Would create lethal trifecta",
      current_state: @current_state.to_h
    }
  else
    {
      allowed: true,
      reason: "Operation allowed",
      enabled_count: @current_state.enabled_count
    }
  end
end