afx

package
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 6 Imported by: 0

Documentation

Overview

Package afx holds the small ergonomics over the AgentField Go SDK that every reasoner handler, phase and orchestrator step in the SEC-AF port reuses:

  • Bind/ToMap — the map[string]any <-> typed struct boundary that stands in for pydantic's Model.model_validate / Model.model_dump;
  • Unwrap/AsMap — byte-exact ports of the private _unwrap/_as_dict helpers that SEC-AF applies to every `await router.call(...)` result;
  • DropNulls — the model_dump(exclude_none=True) filter;
  • WireNumbers — the int-vs-float distinction CPython's json.loads makes and Go's decoder does not (wire.go).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AsMap

func AsMap(payload any, name string) (map[string]any, error)

AsMap ports _as_dict (src/sec_af/app.py:71, src/sec_af/reasoners/phases.py:46):

def _as_dict(payload: object, name: str) -> dict[str, Any]:
    if not isinstance(payload, dict):
        raise RuntimeError(f"{name} returned non-dict payload: {type(payload).__name__}")
    return payload

The error text is reproduced exactly, including the Python type NAME of the offending payload (see PyTypeName).

A NIL map[string]any is rejected as NoneType, not accepted as `{}`. That case is not hypothetical: the SDK's `agent.Call` returned `(nil, nil)` for a SUCCEEDED execution whose result was null, and the in-process Call that replaced it (internal/secaf/audit) keeps that shape on purpose. Boxed into an `any` that is a TYPED nil, so `payload.(map[string]any)` succeeds with ok=true and m=nil. Python's `Agent.call` hands back `None` for the same execution, and `_as_dict(None, name)` raises `RuntimeError("<name> returned non-dict payload: NoneType")` — so without this guard a null `.call` result would bind to a default-seeded model (BindHuntResult(nil) yields an EMPTY HuntResult with no error) and the audit would answer 200 with zero findings where Python answers 500.

An EMPTY BUT NON-NIL map is Python's `{}` and is accepted: encoding/json produces a non-nil map for a `{}` body and leaves the map nil for `null`, so the two stay distinguishable.

func Bind

func Bind[T any](input map[string]any) (T, error)

Bind decodes a reasoner's untyped input map into a typed value T.

Handlers registered with the SDK receive input as map[string]any. Bind round-trips that map through JSON (marshal then unmarshal into T), which mirrors how Python materializes a pydantic model from the request body: field-name matching is by the json struct tags (the exact snake_case pydantic field names), and any custom UnmarshalJSON on T runs — so a T whose UnmarshalJSON seeds non-zero pydantic defaults gets those defaults for keys absent from the input (the schemas package's default-seeding pattern).

Plain encoding/json is deliberate: no json.Decoder/UseNumber. Numbers in the input map are already Go float64/int (they came from the SDK's own JSON decode, or from a Go caller), and re-marshaling then unmarshaling into the typed fields of T yields the correct concrete types without number-precision gymnastics.

Bind is NOT the whole of `Model.model_validate(dict)`. Three pydantic behaviours have no encoding/json equivalent, and internal/phases' checked binders (BindRawFinding, BindVerifiedFinding, BindReconResult, …) supply all three on top of Bind — use those for anything arriving off a `.call` boundary:

  • a MISSING required field is a ValidationError in Python; here it keeps the Go zero value (or the seeded pydantic default);
  • an explicit NULL on a non-Optional field is a ValidationError in Python; here it is a no-op for scalars and ZEROES a slice/map/pointer, which also wipes the `[]` the schemas package seeded;
  • pydantic's LAX mode parses a string-encoded number into an int/float field (`start_line="10"` -> 10); json.Unmarshal answers UnmarshalTypeError.

func DropNulls

func DropNulls(v any) any

DropNulls reproduces pydantic's model_dump(exclude_none=True) over a decoded JSON value: every mapping entry whose value is None disappears, recursively, everywhere in the tree.

Two deliberate scoping rules, both matching pydantic:

  • Only MAPPING entries are dropped. A None element inside a list survives, because exclude_none is a field-level filter, not a value filter — model_dump of `list[str | None]` containing None keeps the None.
  • An empty container is not None and survives ([] and {} are kept).

A nil Go POINTER counts as None alongside the untyped nil interface, so the function is correct whether it is handed a json.Unmarshal result (where None is always the untyped nil) or a hand-built map. A nil SLICE or MAP does NOT count: those stand for empty pydantic collections (`Field(default_factory=list)`), which model_dump emits as [] / {} rather than dropping.

The input is never mutated; fresh maps and slices are returned.

func PyTypeName

func PyTypeName(v any) string

PyTypeName renders `type(x).__name__` for a value that came off the wire as JSON, which is the only place SEC-AF's error strings expose a type name.

Go              Python
map[string]any  dict
[]any / array   list
string          str
float32/64      float
int kinds       int
bool            bool
nil             NoneType

Python parity divergence: Go's encoding/json decodes EVERY JSON number to float64, so a bare integral payload reports "float" where CPython's json.loads would have produced an int and reported "int". Unreachable in practice — the payloads _as_dict guards are reasoner result envelopes — and it only changes the text of an error raised on a malformed payload.

func ToMap

func ToMap(v any) (map[string]any, error)

ToMap is Bind's inverse: it renders a typed struct as the map[string]any shape the SDK's reasoner handlers (and Agent.Call) accept. Top-level exported fields become map entries keyed by their json tag, and the field VALUES stay typed — deliberately NOT a marshal->unmarshal round trip, which would decode nested values into plain Go maps and lose whatever their custom marshalers encode (ordered objects, the Timestamp wrapper's exact isoformat spelling). Keeping values typed lets Bind on the handler side — and the SDK's own workflow-event emitter — re-marshal them through the same custom marshalers, so ToMap -> Bind is lossless.

The reasoner input structs are flat, fully json-tagged, and carry no omitempty (every key is emitted, so Bind-side default seeding never overrides a deliberately zero field); ToMap ignores omitempty accordingly. Anonymous EXPORTED struct fields without their own json tag are flattened the way encoding/json flattens them; an embedded field of UNEXPORTED type is skipped (encoding/json would promote its exported fields, but reflect refuses to read through an unexported field, and the port has no such struct).

func Unwrap

func Unwrap(raw any, name string) (any, error)

Unwrap ports the private _unwrap helper that SEC-AF defines TWICE, verbatim and identically, in src/sec_af/app.py:59 and src/sec_af/reasoners/phases.py:34:

def _unwrap(result: object, name: str) -> object:
    if isinstance(result, dict):
        if "error" in result and isinstance(result["error"], dict):
            message = result["error"].get("message") or result["error"].get("detail") or str(result["error"])
            raise RuntimeError(f"{name} failed: {message}")
        if "output" in result:
            return result["output"]
        if "result" in result:
            return result["result"]
    return result

It is applied to the return value of every `await router.call(f"{NODE_ID}.x", ...)` before _as_dict/model_validate. Both call sites are byte-identical in THIS repo, so there is a single Go function; cloudsecurity-af carries a stricter variant (it additionally fails on an "error_message" key and on status in ("failed", "error")) which deliberately does NOT exist here — do not add it.

Parity details worth keeping in mind:

  • The error branch fires only when result["error"] is itself a dict. A string "error" value falls through to the "output"/"result" lookups.
  • `a or b or c` is PYTHON truthiness, not a nil check: an empty-string message falls through to "detail", and an empty detail falls through to str(the whole error dict).
  • The "output"/"result" lookups test key PRESENCE, not truthiness, so a present-but-null "output" unwraps to nil (and then trips AsMap).

Python parity divergence: the str(error_dict) fallback renders a Python dict repr. Python dicts iterate in insertion order; a Go map cannot, so pyfmt.Str sorts the keys to stay deterministic. A numeric value inside that dict also renders as a float ({'code': 500.0}) rather than an int, because encoding/json decodes every JSON number to float64 — see PyTypeName for the same caveat. Both only affect the text of an error raised for a malformed error envelope that carried neither a "message" nor a "detail".

func WireNumbers

func WireNumbers(v any) any

WireNumbers restores the int-vs-float distinction CPython's json.loads makes and Go's encoding/json does not.

Why it is needed

SEC-AF's Python side reads a `.call` result with `json.loads`, which turns an integer LITERAL into an `int` and anything carrying '.', 'e' or 'E' into a `float`. Go's decoder — the one inside the SDK's Call — turns EVERY JSON number into a float64, so the distinction is gone by the time a handler sees the payload.

For a value that is immediately bound into a typed struct that costs nothing: the target field's type decides the spelling. It costs something for a value that is stored UNTYPED and re-serialised later, because the writer then has only the float64 to go on and spells `2` as `2.0`. The audit result's `metadata["prove_drop_summary"]` is exactly that: app.py:205-208 copies the prove_phase payload's `drop_summary` straight into `SecurityAuditResult.metadata` (a `dict[str, object]`), and every number in it — `demoted_total`, each `by_reason` count — is an int in Python.

What it does

It re-decodes the value through encoding/json with UseNumber, leaving every numeric leaf as a json.Number that carries its literal spelling. The marshal step is what recovers the int-ness: encoding/json writes an integral float64 as "2", not "2.0", so the re-decode yields json.Number("2").

DOCUMENTED RESIDUAL, one case. A payload that spells an integral value with an explicit fraction or exponent — `{"demoted_total": 2.0}` — is a float in CPython and prints "2.0", where this round trip flattens it to "2". The distinction is unrecoverable once the SDK has decoded the body, and no producer of `drop_summary` emits that spelling: reasoners/phases.py's `_track_drop` builds the counts with `+= 1`.

On any marshal/decode failure the input is returned unchanged — a metadata entry spelled the Go way beats losing the entry.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL