Module: PWN::AI::Agent::Dispatch
- Defined in:
- lib/pwn/ai/agent/dispatch.rb
Overview
Tool-call dispatch: takes a single tool_call object (OpenAI shape), looks up the registered handler, parses args, runs it, and returns a JSON string suitable for a role:'tool' message.
TOLERANT DISPATCH (local-model scaffolding)
Local models running on Ollama frequently emit almost-
right tool calls: run_shell instead of shell, trailing commas,
single-quoted JSON, arguments as a bare string. Strict parsing burns
an iteration and often spirals. Dispatch now:
* repair_name — Levenshtein-matches unknown names to the closest
registered tool and records a Mistakes fingerprint
(source: :repair) so the KNOWN MISTAKES block
eventually teaches the model the right name.
* parse_args — falls back to a JSON5-ish clean-up pass (strip
trailing commas, swap single→double quotes, wrap a
bare scalar as the tool's sole required arg).
Frontier engines never hit these paths — repair is a no-op when the name/JSON are already valid.
Constant Summary collapse
- WRITE_ARGV_RX =
Effect of a tool call from NAME + ARGV only — never stdout. :write mutate, :browse navigate, :recall store lookup, :read/:eval observe.
/ \bsed\s+-i\b|\bruby\s+-i\b|\btee\b| (?:\s|\A)>{1,2}\s+\S| File\.(?:write|open|binwrite)|IO\.write| \bopen\s*\([^)]*['"]w| \b(?:cp|mv|rm|mkdir|touch|chmod|chown)\b| \bgit\s+(?:add|commit|rm) /ix- BROWSE_ARGV_RX =
/ TransparentBrowser|browser_obj|\.goto\b|dump_links| watir|headless_?chrome|\bdevtools\b /ix- RECALL_TOOLS =
%w[ memory_recall session_recall skills_recall sessions_view sessions_list sessions_current ].freeze
- STORE_TOOLS =
%w[ memory_remember mistakes_record mistakes_resolve learning_note_outcome skill_create skill_add_reference skills_update ].freeze
Class Method Summary collapse
-
.authors ⇒ Object
- Author(s)
0day Inc.
-
.call(opts = {}) ⇒ Object
- Supported Method Parameters
json_str = PWN::AI::Agent::Dispatch.call( tool_call: 'required - Hash { id:, type:, function: { name:, arguments: } }' ).
- .effect(opts = {}) ⇒ Object
-
.help ⇒ Object
Display Usage for this Module.
-
.repair_name(opts = {}) ⇒ Object
- Supported Method Parameters
fixed = PWN::AI::Agent::Dispatch.repair_name( name: 'required - possibly-wrong tool name emitted by the model' ).
-
.tool_calls_from_text(opts = {}) ⇒ Object
- Supported Method Parameters
calls = PWN::AI::Agent::Dispatch.tool_calls_from_text( text: 'required - assistant plain-text that may embed shell(...) / JSON tool forms' ).
Class Method Details
.authors ⇒ Object
- Author(s)
0day Inc. [email protected]
560 561 562 |
# File 'lib/pwn/ai/agent/dispatch.rb', line 560 public_class_method def self. "AUTHOR(S):\n 0day Inc. <[email protected]>\n" end |
.call(opts = {}) ⇒ Object
- Supported Method Parameters
json_str = PWN::AI::Agent::Dispatch.call( tool_call: 'required - Hash { id:, type:, function: { name:, arguments: } }' )
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 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 |
# File 'lib/pwn/ai/agent/dispatch.rb', line 38 public_class_method def self.call(opts = {}) tool_call = opts[:tool_call] raise 'ERROR: tool_call is required' if tool_call.nil? fn = tool_call[:function] || tool_call['function'] || {} name = (fn[:name] || fn['name']).to_s raw = if fn.key?(:arguments) || fn.key?('arguments') fn[:arguments] || fn['arguments'] else '{}' end entry = Registry.lookup(name: name) || Registry.lookup(name: repair_name(name: name)) return JSON.generate(error: "unknown tool: #{name}") unless entry args = parse_args(raw: raw, entry: entry) args = alias_known_keys(args: args, entry: entry) schema = entry.schema[:parameters] || entry.schema['parameters'] || { type: 'object' } declaration = Manifest.load(directory: opts[:manifest_directory] || Manifest::DIRECTORY)[entry.name] schema = { allOf: [schema, declaration['params']] } if declaration if raw.nil? required = Array(schema[:required] || schema['required']).map(&:to_s) return JSON.generate(success: false, error: 'invalid_payload', code: 'SCHEMA_DENY') if required.any? { |key| !ToolGuard.present?(value: args[key.to_sym] || args[key]) } end type_schema = drop_required(node: schema) return JSON.generate(success: false, error: 'invalid_payload', code: 'SCHEMA_DENY') unless args.is_a?(Hash) && JSONSchemer.schema(JSON.parse(JSON.generate(type_schema))).valid?(JSON.parse(JSON.generate(args))) blob = args.inspect if defined?(PWN::Plugins::Vault) blob = PWN::Plugins::Vault.(text: blob) args = (args: args) end denied = Manifest.check(opts.merge(name: entry.name, args: args)) return JSON.generate(denied) if denied if defined?(Engagement) denied = ToolGuard.scope_check!(args: args, command: blob) if defined?(ToolGuard) && ToolGuard.respond_to?(:scope_check!) denied ||= Engagement.deny_if_out_of_scope(args: args, command: blob) return JSON.generate(denied) if denied end if defined?(ToolGuard) && ToolGuard.respond_to?(:policy_decision) pol = ToolGuard.policy_decision(name: entry.name, args: args) return JSON.generate(pol) if pol.is_a?(Hash) && pol[:action] == 'deny' end return JSON.generate(success: false, error: 'taint: tool-output instruction in args', code: 'TAINT_DENY') if taint_blocked?(name: entry.name, args: args) if defined?(ToolGuard) && ToolGuard.respond_to?(:canary_leak?) && ToolGuard.canary_leak?(text: args.inspect) return JSON.generate(success: false, error: 'refused: session canary in outbound args', code: 'CANARY_DENY', rule_id: 'canary') end if defined?(ToolGuard) && ToolGuard.respond_to?(:refuse_copied_persist?) && ToolGuard.refuse_copied_persist?(name: entry.name, args: args) return JSON.generate( success: false, error: 'refused: memory_remember/skills_update text copied from last tool output' ) end if blob.match?(/open_sockraw|sockraw/) && defined?(PWN::Plugins::PreflightChecker) && PWN::Plugins::PreflightChecker.respond_to?(:cap_net_raw?) && !PWN::Plugins::PreflightChecker.cap_net_raw? return JSON.generate( success: false, error: 'capability missing CAP_NET_RAW', substitute: 'PWN::Plugins::Packet.tcp_connect_scan', code: 'CAP_DENY' ) end budget = prepare_budget(opts.merge(entry: entry, args: args)) return JSON.generate(budget[:denial]) if budget && budget[:denial] started = Process.clock_gettime(Process::CLOCK_MONOTONIC) begin result = entry.handler.call(args) rescue StandardError => e finish_budget(budget: budget, result: { error: e.is_a?(Timeout::Error) ? 'timeout' : e.class.name }, elapsed: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) raise end telemetry = finish_budget(budget: budget, result: result, elapsed: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) result = ToolGuard.quarantine_output(text: result) if defined?(ToolGuard) && result.is_a?(String) && ToolGuard.respond_to?(:quarantine_output) note_taint(text: result) if defined?(PWN::Plugins::Vault) && result.is_a?(String) result = PWN::Plugins::Vault.redact(text: result) elsif defined?(PWN::Plugins::Vault) && result.is_a?(Hash) result = JSON.parse(PWN::Plugins::Vault.redact(text: JSON.generate(result))) end response = { success: true, result: result, effect: effect(name: entry.name, args: args) } response[:budget] = telemetry if telemetry if telemetry && telemetry[:remaining_s].zero? response[:success] = false response[:error] = 'budget_exhausted' end JSON.generate(response) rescue StandardError => e JSON.generate( success: false, error: "#{e.class}: #{e.}", backtrace: Array(e.backtrace).first(3) ) end |
.effect(opts = {}) ⇒ Object
479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 |
# File 'lib/pwn/ai/agent/dispatch.rb', line 479 public_class_method def self.effect(opts = {}) name = opts[:name].to_s return :read if name.empty? return :recall if RECALL_TOOLS.include?(name) return :store if STORE_TOOLS.include?(name) blob = argv_blob(args: opts[:args]) return :write if blob.match?(WRITE_ARGV_RX) return :browse if blob.match?(BROWSE_ARGV_RX) return :eval if name == 'pwn_eval' :read rescue StandardError :read end |
.help ⇒ Object
Display Usage for this Module
566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 |
# File 'lib/pwn/ai/agent/dispatch.rb', line 566 public_class_method def self.help puts "USAGE: # Run call and return its result #{self}.call( tool_call: 'required - Hash { id:, type:, function: { name:, arguments: } }', manifest_directory: 'optional - trusted manifest YAML directory', budget_ledger: 'optional - caller-owned Hash retained across one task', budget_key: 'optional - trusted approach identifier; defaults to tool name', scope_policy: 'optional - trusted policy Hash', scope_path: 'optional - trusted scope file path', audit_path: 'optional - trusted audit JSONL path', approval_callback: 'optional - trusted callback for prompt risk gates' ) # Run repair name and return its result #{self}.repair_name( name: 'required - possibly-wrong tool name emitted by the model' ) # Run tool calls from text and return its result #{self}.tool_calls_from_text( text: 'required - assistant plain-text that may embed shell(...) / JSON tool forms', call: 'optional - shell{command: uname -s} / tool:shell{command:id}' ) # Run effect and return its result #{self}.effect( name: 'required - binary or identifier name', args: 'optional - args value consumed by #effect' ) # Print the AUTHOR(S) string for this module. #{self}.authors " constants.sort end |
.repair_name(opts = {}) ⇒ Object
- Supported Method Parameters
fixed = PWN::AI::Agent::Dispatch.repair_name( name: 'required - possibly-wrong tool name emitted by the model' )
Returns the closest registered tool name by Levenshtein distance (max distance = 1/3 of the emitted name, min 3) or nil when nothing is close enough. Every successful repair is fingerprinted into Mistakes so the negative-feedback loop trains the model's output format via its own system prompt.
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/pwn/ai/agent/dispatch.rb', line 147 public_class_method def self.repair_name(opts = {}) name = opts[:name].to_s return nil if name.empty? pool = Registry.all.map(&:name) return nil if pool.empty? best, dist = pool.map { |n| [n, DidYouMean::Levenshtein.distance(n, name)] } .min_by(&:last) thresh = [(name.length / 3.0).ceil, 3].max return nil if dist > thresh if defined?(Mistakes) Mistakes.record( tool: 'tool_name', error: "model emitted '#{name}', repaired to '#{best}'", args: name, source: :repair ) end best rescue StandardError nil end |
.tool_calls_from_text(opts = {}) ⇒ Object
- Supported Method Parameters
calls = PWN::AI::Agent::Dispatch.tool_calls_from_text( text: 'required - assistant plain-text that may embed shell(...) / JSON tool forms' )
Local / abliterated models often print tool invocations as content instead of native message.tool_calls. Supported shapes include:
shell(command="id") / shell({"command":"id"}) / shell("id")
{"name":"shell","arguments":{...}} / {"function":{"name":...}}
{"tool":"shell","arguments":{...}} / {"call":"shell","arguments":{...}}
call:shell{command: "uname -s"} / tool:shell{"command":"id"}
When structured tool_calls are empty, Loop coerces those strings into OpenAI-shaped tool_call hashes so Dispatch runs them instead of treating the string as a FINAL answer.
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 |
# File 'lib/pwn/ai/agent/dispatch.rb', line 312 public_class_method def self.tool_calls_from_text(opts = {}) text = opts[:text].to_s return [] if text.strip.empty? Registry.discover if defined?(Registry) && Registry.respond_to?(:discover) known = if defined?(Registry) Registry.all.map { |e| e.name.to_s }.reject(&:empty?) else %w[shell pwn_eval] end return [] if known.empty? names_alt = known.map { |n| Regexp.escape(n) }.join('|') calls = [] seen = {} add = lambda do |name, args| name = name.to_s next unless known.include?(name) args_h = case args when Hash then symbolize(hash: args) when String s = args.strip begin parsed = JSON.parse(s, symbolize_names: true) parsed.is_a?(Hash) ? parsed : { value: parsed } rescue JSON::ParserError h = {} s.scan(/([A-Za-z_]\w*)\s*[:=]\s*(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^\s,)}{]+))/) do k = Regexp.last_match(1) h[k.to_sym] = Regexp.last_match(2) || Regexp.last_match(3) || Regexp.last_match(4) end if h.empty? entry = (Registry.lookup(name: name) if defined?(Registry)) req = Array(entry&.schema&.dig(:parameters, :required)) h = req.length == 1 ? { req.first.to_sym => s } : { command: s } end h end else {} end key = "#{name}|#{JSON.generate(args_h)}" next if seen[key] seen[key] = true calls << { id: "textcall_#{calls.length + 1}_#{SecureRandom.hex(3)}", type: 'function', function: { name: name, # OpenAI/xAI wire format requires a JSON string, not a map. arguments: JSON.generate(args_h) } } end # Balanced-delimiter extractor used for name(...) and call:name{...}. extract_balanced = lambda do |open_ch, close_ch, from| depth = 1 i = from in_s = nil esc = false while i < text.length && depth.positive? ch = text[i] if in_s if esc esc = false elsif ch == '\\' esc = true elsif ch == in_s in_s = nil end elsif ['"', "'"].include?(ch) in_s = ch elsif ch == open_ch depth += 1 elsif ch == close_ch depth -= 1 end i += 1 end depth.zero? ? [text[from...(i - 1)].to_s.strip, i] : nil end # JSON object forms: # {"name":"shell","arguments":{...}} # {"function":{"name":"shell","arguments":{...}}} # {"tool":"shell","arguments":{...}} / {"call":"shell",...} # {"type":"call","name":"shell",...} text.scan(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/m).each do |blob| begin j = JSON.parse(blob, symbolize_names: true) rescue JSON::ParserError next end next unless j.is_a?(Hash) name = ( j[:name] || j[:tool] || j[:call] || j.dig(:function, :name) || j.dig(:tool_call, :name) ).to_s # Skip pure type tags mistaken as names (e.g. {"call":{...}} trees). next if name.empty? || %w[function tool_call].include?(name) args = j[:arguments] || j[:args] || j[:parameters] || j.dig(:function, :arguments) || j.dig(:tool_call, :arguments) || {} add.call(name, args) end # Colon-brace forms (OpenWebUI / abliterated dumps): # call:shell{command: "uname -s"} # tool:shell{"command":"id"} # call:shell{command="id"} rx_colon = /\b(?:call|tool)\s*:\s*(#{names_alt})\s*\{/i idx = 0 while (m = text.match(rx_colon, idx)) name = m[1] extracted = extract_balanced.call('{', '}', m.end(0)) if extracted # Re-wrap: balanced extractor yields the interior only. Paren form # shell({...}) keeps braces inside (...); brace form must restore # them so JSON.parse / kwarg scan see a full object body. add.call(name, "{#{extracted[0]}}") end idx = m.begin(0) + 1 end # Call forms: shell(command="...") / shell({"command":"id"}) / shell("id") rx = /\b(#{names_alt})\s*\(/i idx = 0 while (m = text.match(rx, idx)) name = m[1] extracted = extract_balanced.call('(', ')', m.end(0)) add.call(name, extracted[0]) if extracted idx = m.begin(0) + 1 end calls rescue StandardError [] end |