Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.178
1.0.179
24 changes: 23 additions & 1 deletion lib/optimal_system_agent/orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -743,8 +743,22 @@ defmodule OptimalSystemAgent.Orchestrator do
end

defp start_linger(pid, subagent_id, worktree_info, config, result, ttl) do
# The linger owner must be the process that OWNS the Registry entry, because
# Registry drops a key when its registering process dies — so the entry
# cannot be registered by this (short-lived join) process. But the key must
# be observable BEFORE we report `:lingering` up the stack: otherwise a fast
# follow-up resume runs `lingering?/1` against a not-yet-registered key,
# misses it, falls to run_fresh_subagent, and that path terminates this very
# resident (the "Cleaning up stale subagent" branch) and replays it under a
# new pid. That is the #179 flake — load-dependent because the register
# normally lands within microseconds of the spawn starting. Fix: the owner
# registers, then hands back a token; we wait for it (bounded, since linger
# is a pure optimization and a completion path must never block on it).
caller = self()

spawn(fn ->
Registry.register(OptimalSystemAgent.SessionRegistry, linger_key(subagent_id), pid)
send(caller, {:linger_registered, subagent_id})
ref = Process.monitor(pid)

receive do
Expand All @@ -769,7 +783,15 @@ defmodule OptimalSystemAgent.Orchestrator do
end
end)

:ok
# Block only until the linger key is visible (typically microseconds). The
# bound guarantees a completion path is never stuck if the owner dies before
# registering — a missed linger just means the next resume replays, which is
# the pre-linger behavior, never a hang.
receive do
{:linger_registered, ^subagent_id} -> :ok
after
1_000 -> :ok
end
end

# True only for an id whose linger owner is registered AND whose resident Loop
Expand Down
77 changes: 28 additions & 49 deletions lib/optimal_system_agent/security/attack_orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,9 @@ defmodule OptimalSystemAgent.Security.AttackOrchestrator do
use GenServer

alias OptimalSystemAgent.Security.{
ShadowGraph,
AttackTree,
ClassQueue,
AnomalyQueue,
ThreatIntel,
WeaponCatalog,
ExploitGenerator,
LiveExploitRunner,
AttackChainReasoner,
AttackPrioritizer
Expand Down Expand Up @@ -141,8 +137,8 @@ defmodule OptimalSystemAgent.Security.AttackOrchestrator do
@doc """
Execute the full attack sequence.

Traverses ShadowGraph for paths, feeds ClassQueue with prioritized candidates,
runs exploits via LiveExploitRunner, and collects results into completed/failed.
Feeds ClassQueue with prioritized candidates and runs exploits via
LiveExploitRunner, then extends the weapon set with chain-derived hops.
"""
@spec execute_sequence(state()) :: {:results, state()} | {:blocked, String.t()}
def execute_sequence(%{phase: :complete} = state) do
Expand All @@ -157,57 +153,40 @@ defmodule OptimalSystemAgent.Security.AttackOrchestrator do
end

def execute_sequence(state) do
# Step 1: Use ShadowGraph to find attack paths from current recon data
graph_paths = ShadowGraph.attack_paths(state.session_id) || []

# Step 2: Feed AttackTree with prioritized classes (basics first)
class_priorities = AttackTree.next_classes(state.session_id, max: length(graph_paths))

# Step 3: Classify all findings as weapons
# Step 1: Classify all findings as weapons
weaponized = WeaponCatalog.classify_batch(state.findings)

# Step 4: Prioritize targets by exploitability × impact
# Step 2: Prioritize targets by exploitability × impact. Each entry wraps
# the weapon in a ranked struct (%{weapon: ..., rank_score: ..., ...}).
prioritized = AttackPrioritizer.rank(weaponized)

# Step 5: Feed into ClassQueue in priority order
Enum.each(prioritized, fn weapon ->
entry = %{
class: weapon.class,
# Step 3: Feed into ClassQueue in priority order, keyed by vuln class.
Enum.each(prioritized, fn %{weapon: weapon} ->
ClassQueue.put(state.session_id, weapon.domain, %{
target: weapon.target,
confidence: weapon.score,
evidence: weapon.evidence
}

ClassQueue.enqueue(state.session_id, entry)
note: "score=#{weapon.score}"
})
end)

# Step 6: Deploy exploits via LiveExploitRunner
results =
Enum.map(prioritized, fn weapon ->
case LiveExploitRunner.deploy(weapon) do
{:ok, result}
when is_map(result) and is_map_key(result, :confirmed) and
:erlang.map_get(:confirmed, result) == true ->
%{state.completed | end: [weapon.target]}
{:depleted, result}

{:ok, result} ->
# Not confirmed yet — feed into AnomalyQueue for follow-one-hop
AnomalyQueue.record(state.session_id, %{
target: weapon.target,
class: weapon.class,
anomaly_type: :unconfirmed_exploit,
evidence: result
})

{:potential, result}

:error ->
{:failed, weapon.target}
end
end)
# Step 4: Deploy exploits via LiveExploitRunner (fail-closed without RoE).
Enum.each(prioritized, fn %{weapon: weapon} ->
case LiveExploitRunner.deploy(weapon) do
{:ok, %{confirmed: true}} ->
:ok

{:ok, _result} ->
# Not confirmed yet — feed into AnomalyQueue for follow-one-hop.
AnomalyQueue.record(state.session_id, %{
target: weapon.target,
note: "unconfirmed exploit for #{weapon.domain}"
})

{:error, _reason} ->
:ok
end
end)

# Step 7: Check for post-exploitation opportunities via ChainReasoner
# Step 5: Check for post-exploitation opportunities via ChainReasoner
chains = AttackChainReasoner.find_chains(state.session_id)

new_weapons =
Expand Down
58 changes: 36 additions & 22 deletions lib/optimal_system_agent/tools/file_state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ defmodule OptimalSystemAgent.Tools.FileState do
@spec record_read(term(), String.t(), keyword()) :: :ok
def record_read(session_id, path, opts \\ []) do
ensure_table()
cpath = canonical(path)
key = {skey(session_id), cpath}
cpath = fs_path(path)
key = {skey(session_id), key_path(cpath)}
range = Keyword.get(opts, :range, :whole)
delivered = Keyword.get(opts, :bytes, 0)
lines = Keyword.get(opts, :lines, [])
Expand Down Expand Up @@ -220,8 +220,8 @@ defmodule OptimalSystemAgent.Tools.FileState do
@spec record_write(term(), String.t()) :: :ok
def record_write(session_id, path) do
ensure_table()
cpath = canonical(path)
key = {skey(session_id), cpath}
cpath = fs_path(path)
key = {skey(session_id), key_path(cpath)}

case stat(cpath) do
{:ok, mtime, size} ->
Expand Down Expand Up @@ -263,8 +263,8 @@ defmodule OptimalSystemAgent.Tools.FileState do
def held_lines(session_id, path) do
if enforce?(session_id) do
ensure_table()
cpath = canonical(path)
key = {skey(session_id), cpath}
cpath = fs_path(path)
key = {skey(session_id), key_path(cpath)}

case safe_lookup(key) do
[{^key, %{mtime: rmtime, size: rsize, hash: rhash, epoch: repoch} = entry}] ->
Expand Down Expand Up @@ -321,8 +321,8 @@ defmodule OptimalSystemAgent.Tools.FileState do
def read_status(session_id, path, range \\ :whole) do
if enforce?(session_id) do
ensure_table()
cpath = canonical(path)
key = {skey(session_id), cpath}
cpath = fs_path(path)
key = {skey(session_id), key_path(cpath)}

case safe_lookup(key) do
[{^key, %{mtime: rmtime, size: rsize, hash: rhash, epoch: repoch, ranges: ranges}}]
Expand Down Expand Up @@ -424,8 +424,8 @@ defmodule OptimalSystemAgent.Tools.FileState do
def check_read(session_id, path) do
if enforce?(session_id) do
ensure_table()
cpath = canonical(path)
key = {skey(session_id), cpath}
cpath = fs_path(path)
key = {skey(session_id), key_path(cpath)}

case safe_lookup(key) do
[{^key, %{mtime: rmtime, size: rsize}}] ->
Expand Down Expand Up @@ -461,7 +461,7 @@ defmodule OptimalSystemAgent.Tools.FileState do
@spec read?(term(), String.t()) :: boolean()
def read?(session_id, path) do
ensure_table()
key = {skey(session_id), canonical(path)}
key = {skey(session_id), key_path(fs_path(path))}
match?([{^key, _}], safe_lookup(key))
end

Expand Down Expand Up @@ -498,17 +498,31 @@ defmodule OptimalSystemAgent.Tools.FileState do
defp skey(session_id) when is_binary(session_id), do: session_id
defp skey(session_id), do: session_id

# Canonical absolute path: expand, then resolve the full symlink chain so the
# key agrees across file_read (resolves symlinks), file_write and
# multi_file_edit (expand only) — resolution here makes them converge. The
# key is also Unicode NFC-normalised: macOS stores names in NFD while callers
# commonly type NFC, and a rescue read under one form must satisfy
# check_read/2 for the other — otherwise the caller reads successfully and is
# then told it never read the file.
defp canonical(path) do
path
|> OptimalSystemAgent.Agent.Safety.PathCanon.canonicalize()
|> :unicode.characters_to_nfc_binary()
# The real filesystem path: expand, then resolve the full symlink chain so it
# agrees across file_read (resolves symlinks), file_write and multi_file_edit
# (expand only) — resolution here makes them converge. Every filesystem
# operation in this module (`stat/1`, `content_hash/2`) uses THIS path, not
# the ledger key: a name's bytes are preserved verbatim by Linux filesystems,
# so a read rescued to the on-disk NFD form must be stat'd under those same
# bytes. NFC-normalising first would yield a path that does not exist on Linux
# and the read would be silently dropped (#212).
defp fs_path(path) do
OptimalSystemAgent.Agent.Safety.PathCanon.canonicalize(path)
end

# The ledger key derived from an already-resolved `fs_path/1`: Unicode
# NFC-normalised so a read recorded under one normalisation form (e.g. the NFD
# name file_read rescued to on disk) is found by a lookup under the other (e.g.
# the NFC name the model typed). macOS (APFS) collapses the two at the FS
# layer; Linux preserves the bytes, so the ledger must normalise them itself —
# otherwise the caller reads successfully and is then told it never read the
# file. Falls back to the raw path if the bytes are not valid UTF-8 (a name is
# arbitrary bytes), which at worst declines to unify the two forms.
defp key_path(fspath) do
case :unicode.characters_to_nfc_binary(fspath) do
bin when is_binary(bin) -> bin
_ -> fspath
end
end

defp stat(path) do
Expand Down
2 changes: 1 addition & 1 deletion priv/rust/tui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion priv/rust/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name = "osa-tui"
# `config::version_source_tests` fails the build if they diverge. This literal is
# only ever a last-resort fallback: build.rs prefers $OSA_VERSION (release CI),
# then the VERSION file.
version = "1.0.178"
version = "1.0.179"
edition = "2021"

[[bin]]
Expand Down
Loading
Loading