gates

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: 18 Imported by: 0

Documentation

Overview

Package gates ports src/sec_af/harness.py — the two thin wrappers SEC-AF puts between its phases and the AgentField SDK.

  • AIGateWrapper (here: AIGate) is LIVE: orchestrator.py builds one in its constructor and calls assess_reachability during scoring, phases.py calls select_strategy before the hunt fan-out, and compliance/mapping.py calls invoke directly for the compliance gate.
  • HarnessWrapper is NOT reachable from any live path — nothing constructs one, and build_ai_integration (the only place that would) has no callers. It is ported anyway, because the port is meant to be 1:1 and because its prompt-assembly helpers (PHASE_GUIDANCE, SchemaGuidance, WithPhaseGuidance, WithFileWriteHint, BuildSchemaRetryPrompt) are pure string functions worth pinning with golden tests.

The package is called `gates` rather than `harness` so it cannot be confused with the SDK's harness package, and because internal/harnessx already owns the piece of harness.py-adjacent behavior that IS on the live path (extract_harness_result).

Index

Constants

This section is empty.

Variables

View Source
var PhaseGuidance = map[string]string{
	"recon": "APPROACH: You are performing reconnaissance on a codebase to build an accurate structural map.\n" +
		"PROCESS:\n" +
		"1. Survey the codebase structure — identify key directories, entry points, and configuration\n" +
		"2. Identify the technology stack — languages, frameworks, and external services\n" +
		"3. Map security-relevant boundaries — auth layers, data inputs, API surfaces\n" +
		"4. Only after surveying, synthesize findings into the required schema\n" +
		"CONSTRAINTS:\n" +
		"- Report what IS there, not what MIGHT be there\n" +
		"- If uncertain about a detail, omit it rather than guess\n" +
		"- Prioritize breadth over depth — cover the full surface",
	"hunt": "APPROACH: You are hunting for a specific class of security vulnerability with recon context.\n" +
		"PROCESS:\n" +
		"1. Review the recon context to understand the codebase topology\n" +
		"2. Identify files and patterns relevant to your specific vulnerability class\n" +
		"3. For each candidate: read the code, trace data flow, assess exploitability\n" +
		"4. Only report findings where you have concrete code evidence\n" +
		"CONSTRAINTS:\n" +
		"- Every finding MUST cite specific file paths and line numbers you have read\n" +
		"- Do not report theoretical vulnerabilities without code evidence\n" +
		"- False negatives are better than false positives\n" +
		"- If a file is sanitized properly, do NOT report it",
	"prove": "APPROACH: You are verifying a specific candidate vulnerability for exploitability.\n" +
		"PROCESS:\n" +
		"1. Read the specific code location cited in the finding\n" +
		"2. Trace the data flow from source to sink\n" +
		"3. Check for sanitization, validation, or other mitigations on the path\n" +
		"4. If exploitable, construct a concrete exploit hypothesis\n" +
		"5. Synthesize your verdict with evidence level\n" +
		"CONSTRAINTS:\n" +
		"- You must READ the actual code — do not rely on the finding description alone\n" +
		"- INCONCLUSIVE is a valid verdict — do not force confirmation or denial\n" +
		"- Cite specific lines where sanitization exists or is missing\n" +
		"- If code has changed since the finding was generated, note the discrepancy",
}

PhaseGuidance ports harness.py PHASE_GUIDANCE verbatim — the per-phase "APPROACH / PROCESS / CONSTRAINTS" block WithPhaseGuidance prepends.

The Python literals are implicit string concatenations with explicit "\n" terminators and NO trailing newline on the last line; the Go strings below reproduce that byte for byte (verified by the golden test against the Python dict).

Functions

func BuildAIIntegration

func BuildAIIntegration(app appx.App, cfg *config.AIIntegrationConfig) (*HarnessWrapper, *AIGate, error)

BuildAIIntegration ports harness.py build_ai_integration:

resolved = config or AIIntegrationConfig.from_env()
return HarnessWrapper(app=app, config=resolved), AIGateWrapper(app=app, config=resolved)

Both wrappers share ONE resolved config instance in Python; they share one value here, with config.DefaultAIConfig() standing in for from_env. Nothing in the Python tree calls this function — orchestrator.py constructs AIGateWrapper directly — so it exists for completeness.

func BuildSchemaRetryPrompt

func BuildSchemaRetryPrompt[T any](errorDetail, cwd string) string

BuildSchemaRetryPrompt ports harness.py _build_schema_retry_prompt:

output_path = Path(cwd) / ".agentfield_output.json"
schema_json_str = json.dumps(schema.model_json_schema(), indent=2)
return (f"The JSON output at {output_path} failed validation.\n"
        f"Error: {error_detail}\n\n"
        f"Your response must conform to this JSON schema:\n"
        f"```json\n{schema_json_str}\n```\n\n"
        f"Rewrite the COMPLETE, corrected JSON to: {output_path}\n"
        f"The file must contain ONLY valid JSON matching the schema above. "
        f"No markdown fences, no extra text, no comments.")

The embedded schema JSON is byte-reproducible from the committed fixture. The fixture is written with sort_keys=True, and pydantic's own model_json_schema() dict happens to be in sorted key order at EVERY level except one: the `properties` object of the root model and of each `$defs` entry, which are in field DECLARATION order. declarationOrdered restores exactly those, using the Go struct field order reachable from T — verified against all 23 committed fixtures.

func HarnessInvoke

func HarnessInvoke[T any](ctx context.Context, w *HarnessWrapper, req InvokeRequest) (T, error)

HarnessInvoke ports HarnessWrapper.invoke.

self._cost_tracker.register_invocation()
enhanced_prompt = f"{_with_phase_guidance(prompt, phase, cwd)}\n\n{_schema_guidance(schema)}"
result = await self._run_with_retry(_operation, self.config)
self._cost_tracker.register_cost(result.cost_usd)
if result.is_error: raise AIIntegrationError(f"Harness failure{f' ({phase})' if phase else ''}: {message}")
if isinstance(parsed, schema): return parsed
...
if not parsed and not result.is_error: return await self._invoke_with_schema_retry(...)
raise AIIntegrationError(f"Harness returned invalid payload for schema {schema.__name__}")

Python parity notes:

  • The retry wrapper covers the SDK CALL only. A harness that RAN and failed comes back with is_error set and a nil Go error, so it never reaches _run_with_retry's except clause — it goes straight to the AIIntegrationError raise, transient message or not. That is Python's behavior too.
  • The phase suffix on the failure message is the empty string when phase is falsy, so an empty phase contributes nothing to the text, not " ()".
  • The `isinstance(result, schema)` third branch is Python duck-typing for an SDK that returns the model itself; the Go SDK always returns a *appx.HarnessResult, so it is unreachable and not ported.

func Invoke

func Invoke[T any](ctx context.Context, g *AIGate, user, system string) (T, error)

Invoke ports AIGateWrapper.invoke:

self._cost_tracker.register_invocation()
async def _operation():
    return await self.app.ai(system=system, user=user, schema=schema, model=self.config.ai_model)
result = await self._run_with_retry(_operation, self.config)
self._cost_tracker.register_cost(getattr(result, "cost_usd", None))
if isinstance(result, schema): return result
if isinstance(result, dict):   return schema(**result)
raise AIIntegrationError(f"AI gate returned invalid payload for schema {schema.__name__}")

It is a package-level function rather than a method because Go methods cannot carry their own type parameter, and the schema is exactly that.

Python parity notes:

  • register_invocation fires ONCE per Invoke, before the first attempt — a retried call still counts as one invocation.
  • `system=None` is spelled as the empty string here; aix.Structured omits the system message for it, matching the Python SDK, which drops a None system.
  • The two isinstance branches collapse into aix.Structured's typed return: the Go SDK hands back raw text, aix parses it into T, and a parse failure is an error — the same outcome as the trailing `raise`. The dict branch exists in Python only because the SDK may hand back an unparsed dict.
  • COST: this is a DELIBERATE, design-doc-mandated divergence. Python reads `getattr(result, "cost_usd", None)` off the PARSED PYDANTIC MODEL the SDK returns for a `schema=` call (agent_ai.py:898 `return schema(**json_data)`), and no gate schema has a cost_usd field — so AIGateWrapper.total_cost_usd is permanently 0.0 in the Python node. DESIGN.md §2/§3 specifies the Go port take the cost from the SDK response's Usage.Cost instead, which is where the real number lives. Everything else about the tracker (invocation counting, the negative/None guard, the += accumulation) is byte-for-byte Python.

func IsTransientError

func IsTransientError(errText string) bool

IsTransientError ports harness.py _is_transient_error:

lowered = error.lower()
return any(pattern in lowered for pattern in _TRANSIENT_PATTERNS)

func RunHuntAnalysis

func RunHuntAnalysis[T any](ctx context.Context, w *HarnessWrapper, prompt, cwd, projectDir string) (T, error)

RunHuntAnalysis ports HarnessWrapper.run_hunt_analysis — invoke with phase="hunt".

func RunProveAnalysis

func RunProveAnalysis[T any](ctx context.Context, w *HarnessWrapper, prompt, cwd, projectDir string) (T, error)

RunProveAnalysis ports HarnessWrapper.run_prove_analysis — invoke with phase="prove".

func RunReconAnalysis

func RunReconAnalysis[T any](ctx context.Context, w *HarnessWrapper, prompt, cwd, projectDir string) (T, error)

RunReconAnalysis ports HarnessWrapper.run_recon_analysis — invoke with phase="recon".

func SchemaGuidance

func SchemaGuidance[T any]() string

SchemaGuidance ports harness.py _schema_guidance:

field_lines = []
for field_name, field in schema.model_fields.items():
    description = (field.description or "").strip()
    if description:
        field_lines.append(f"- `{field_name}`: {description}")
if not field_lines:
    return ("Output format:\n"
            "- Return valid JSON only (no markdown fences, no extra text).\n"
            "- Follow the provided Pydantic schema exactly.")
return ("Output format:\n"
        "- Return valid JSON only (no markdown fences, no extra text).\n"
        "- Follow the provided Pydantic schema exactly.\n"
        "- Field guidance from schema descriptions:\n"
        f"{chr(10).join(field_lines)}")

The two Python inputs — the field ORDER and each field's DESCRIPTION — come from different places in Go:

  • ORDER is `schema.model_fields.items()`, pydantic declaration order. The port's cross-package contract is that a Go struct's fields are declared in the same order as its pydantic counterpart's, so reflect.Type field order IS model_fields order. (The committed JSON-Schema fixture cannot supply it: go/scripts/gen_schemas.py writes with sort_keys=True, so its `properties` object is alphabetical.)
  • DESCRIPTION is `FieldInfo.description`, which pydantic copies verbatim into `properties[<name>].description` of model_json_schema() — including for Optional and nested-model fields, where the description sits beside the anyOf/$ref rather than inside it. harnessx.SchemaFor[T] serves that document.

A field with no description contributes no line, exactly as in Python; a model with no described fields at all takes the short fallback.

func WithFileWriteHint

func WithFileWriteHint(prompt, cwd string) string

WithFileWriteHint ports harness.py _with_file_write_hint:

output_path = Path(cwd) / ".agentfield_output.json"
return (f"{prompt.rstrip()}\n"
        f"- If output is large or complex, use the file-write pattern and ensure final JSON is written to {output_path}.")

Python parity notes:

  • `str.rstrip()` with no argument strips ALL trailing whitespace, not just newlines; strings.TrimRight with unicode.IsSpace is the same set, and strings.TrimRightFunc(s, unicode.IsSpace) is what TrimSpace uses. Here strings.TrimRight over " \t\n\r\v\f" plus TrimRightFunc would be equivalent for every real prompt, so TrimRightFunc is used for exactness.
  • `Path(cwd) / name` is NOT filepath.Join. pathlib collapses redundant separators and lone "." components but deliberately keeps "..", because resolving it without touching the filesystem would be wrong in the presence of symlinks; filepath.Join runs Clean, which eats "..". So Path("./work/../work")/"f" is "work/../work/f" where Join gives "work/f". pyPathJoin below reproduces pathlib. In the live node cwd is always an absolute tempfile.mkdtemp() path where the two agree, but the divergence is real and the golden test pins it.

func WithPhaseGuidance

func WithPhaseGuidance(prompt, phase, cwd string) string

WithPhaseGuidance ports harness.py _with_phase_guidance:

normalized_phase = (phase or "").strip().lower()
phase_context = PHASE_GUIDANCE.get(normalized_phase, <default paragraph>)
constraints = _with_file_write_hint("Constraints:\n- ...\n- ...\n- ...", cwd)
return (f"Context:\n- {phase_context}\n\n"
        f"{constraints}\n\n"
        f"Task:\n{prompt.rstrip()}\n\n"
        "Output:\n"
        "- Return a single JSON object matching the requested schema.")

`phase` is `str | None` in Python; the Go signature takes a plain string because `(phase or "")` maps None and "" to the same normalized value.

Types

type AIGate

type AIGate struct {
	// App is the `.ai(...)` seam. Only AIer is required, which lets a caller
	// pass the codeaf App, an appx.Fake, or the orchestrator's budget-checking
	// proxy.
	App appx.AIer
	// Config supplies ai_model plus the retry schedule.
	Config config.AIIntegrationConfig
	// Sleep is the backoff sleeper; nil means the real clock. Tests set it to
	// record the schedule without waiting.
	Sleep Sleeper
	// contains filtered or unexported fields
}

AIGate ports harness.py AIGateWrapper — the retrying front door to `app.ai(system=..., user=..., schema=Model, model=config.ai_model)`.

Python:

class AIGateWrapper(_RetryMixin):
    def __init__(self, app, config=None):
        self.app = app
        self.config = config or AIIntegrationConfig.from_env()
        self._cost_tracker = _CostTracker()

The Go value is safe for concurrent use: the orchestrator's _assess_reachability_parallel fans AssessReachability out under a semaphore(min(5, n)), so the cost tracker is mutex-guarded and every invocation gets its own response capture.

func NewAIGate

func NewAIGate(app appx.AIer, cfg *config.AIIntegrationConfig) (*AIGate, error)

NewAIGate is `AIGateWrapper(app=app, config=config)`. A nil config takes config.DefaultAIConfig(), where Python read `AIIntegrationConfig.from_env()`.

The error return is kept from the days the environment could hold a malformed retry count; nothing reads the environment now, so it is always nil, and the signature stays so the orchestrator's construction path keeps one shape.

func (*AIGate) AssessReachability

func (g *AIGate) AssessReachability(ctx context.Context, findingSummary string) (schemas.ReachabilityGate, error)

AssessReachability ports AIGateWrapper.assess_reachability — the gate the orchestrator actually leans on (orchestrator.py:587, inside _assess_reachability_parallel).

func (*AIGate) CheckDuplicate

func (g *AIGate) CheckDuplicate(ctx context.Context, candidate, existing any) (schemas.DuplicateCheck, error)

CheckDuplicate ports AIGateWrapper.check_duplicate:

prompt = ("Decide whether candidate finding is a duplicate of existing finding. "
          "Return duplicate decision only.\n\n"
          f"Candidate: {candidate}\n"
          f"Existing: {existing}")

`candidate` and `existing` are `dict[str, Any]` in Python and land in the prompt through an f-string, i.e. `str(dict)` — which is `repr(dict)`, the `{'key': 'value'}` spelling with SINGLE quotes and `True`/`False`/`None` literals, rendered in the dict's INSERTION order.

They are typed `any` here so a caller can pass a pyfmt.Ordered built in that insertion order and get byte-identical output. A plain map[string]any also works but renders with SORTED keys (pyfmt.Repr's documented deviation), which is only a difference when the Python dict was not already in sorted order.

Python parity: check_duplicate has NO caller in the Python tree — agents/dedup.py does its semantic duplicate pass with a direct `app.ai(..., schema=DuplicateCheck)` rather than through the gate. It is ported for completeness.

func (*AIGate) ClassifySeverity

func (g *AIGate) ClassifySeverity(ctx context.Context, findingSummary string) (schemas.SeverityClassification, error)

ClassifySeverity ports AIGateWrapper.classify_severity.

The prompt is three adjacent string literals plus the summary after a blank line; it is reproduced byte-for-byte, including the trailing space after "keep rationale brief." coming from the implicit concatenation.

func (*AIGate) InvocationCount

func (g *AIGate) InvocationCount() int

InvocationCount ports the `invocation_count` property.

func (*AIGate) SelectStrategy

func (g *AIGate) SelectStrategy(ctx context.Context, reconSummary, depth string, defaultCandidates []string) (schemas.StrategySelection, error)

SelectStrategy ports AIGateWrapper.select_strategy:

prompt = ("Select SEC-AF hunt strategies from recon context. Return only selected strategies and rationale.\n"
          f"Depth profile: {depth}\n"
          f"Default candidates: {default_candidates}\n"
          f"Recon summary: {recon_summary}")

Python parity: `default_candidates` is a `list[str]` interpolated by an f-string, so it renders as a Python LIST REPR — `['injection', 'auth', 'crypto']`, single quotes and ", " separators, `[]` when empty. pyfmt.Repr produces exactly that. This is the live call site (reasoners/phases.py:262 passes `[s.value for s in default_candidates]`).

func (*AIGate) TotalCostUSD

func (g *AIGate) TotalCostUSD() float64

TotalCostUSD ports the `total_cost_usd` property.

type AIIntegrationError

type AIIntegrationError struct {
	Message string
}

AIIntegrationError ports harness.py's `class AIIntegrationError(RuntimeError)`.

func AsAIIntegrationError

func AsAIIntegrationError(err error) (*AIIntegrationError, bool)

AsAIIntegrationError reports whether err is (or wraps) an AIIntegrationError, the Go form of `except AIIntegrationError`.

func (*AIIntegrationError) Error

func (e *AIIntegrationError) Error() string

type BatchResult

type BatchResult[T any] struct {
	Value T
	Err   error
}

BatchResult is one slot of `asyncio.gather(..., return_exceptions=True)`: either the value or the exception, never both.

func HarnessInvokeBatch

func HarnessInvokeBatch[T any](ctx context.Context, w *HarnessWrapper, requests []InvokeRequest, maxConcurrent int) []BatchResult[T]

HarnessInvokeBatch ports HarnessWrapper.invoke_batch:

semaphore = asyncio.Semaphore(max_concurrent) if max_concurrent else None
tasks = [_run_request(r) for r in requests]
return await asyncio.gather(*tasks, return_exceptions=True)

Python parity notes:

  • RESULT ORDER matches REQUEST order (gather preserves it), so results are written into a pre-indexed slice rather than collected as they finish.
  • `return_exceptions=True` means one failure does NOT cancel the others; every request runs to completion and failures land in their own slot. There is no errgroup here for exactly that reason.
  • `max_concurrent=None` (spelled as a non-positive value in Go) means UNBOUNDED, which is why the semaphore is conditional.
  • Acquiring the weighted semaphore can fail only if ctx is already done; in that case the slot records the ctx error, which is the closest analogue of asyncio cancelling a queued task.

type HarnessWrapper

type HarnessWrapper struct {
	// App is the `.harness(...)` seam.
	App appx.Harnesser
	// Config supplies the retry schedule.
	Config config.AIIntegrationConfig
	// Sleep is the backoff sleeper; nil means the real clock.
	Sleep Sleeper
	// contains filtered or unexported fields
}

HarnessWrapper ports harness.py HarnessWrapper.

NOT ON THE LIVE PATH. Nothing in the Python tree constructs a HarnessWrapper: every agent module calls `app.harness(...)` directly and hands the result to extract_harness_result (ported as internal/harnessx). The class is ported because the port is 1:1 and because its prompt assembly (prompts.go) is worth pinning; the invocation machinery below is kept deliberately compact.

func NewHarnessWrapper

func NewHarnessWrapper(app appx.Harnesser, cfg *config.AIIntegrationConfig) (*HarnessWrapper, error)

NewHarnessWrapper is `HarnessWrapper(app=app, config=config)`; a nil config takes config.DefaultAIConfig(), where Python ran AIIntegrationConfig.from_env(). The error return is always nil now and is kept for the shape NewAIGate shares.

func (*HarnessWrapper) InvocationCount

func (w *HarnessWrapper) InvocationCount() int

InvocationCount ports the `invocation_count` property.

func (*HarnessWrapper) TotalCostUSD

func (w *HarnessWrapper) TotalCostUSD() float64

TotalCostUSD ports the `total_cost_usd` property.

type InvokeRequest

type InvokeRequest struct {
	Prompt     string
	Cwd        string
	ProjectDir string
	Phase      string
}

InvokeRequest is the keyword-argument set of HarnessWrapper.invoke:

invoke(*, prompt, schema, cwd, project_dir=None, model=None,
       max_turns=None, max_budget_usd=None, phase=None)

`schema` is the Go type parameter of HarnessInvoke, not a field. The zero value of each remaining field is Python's None.

`model`, `max_turns` and `max_budget_usd` are not carried: they were options of the external CLI the harness used to drive, and codeaf's harness owns its own model and turn budget, so appx.HarnessOptions has nowhere to put them.

type Sleeper

type Sleeper func(ctx context.Context, d time.Duration)

Sleeper is the injectable stand-in for `await asyncio.sleep(seconds)`.

Tests supply a recorder so the backoff schedule can be asserted without the wall-clock wait; production uses sleepReal.

Jump to

Keyboard shortcuts

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