167 Commits
Author SHA1 Message Date
BaaRai 101c92d516 Coverage ledger: review fixes (one-line capped fields, framed gap lines, per-run end_reason)
Normalise and length-check ledger fields in db.RecordCoverage, frame the planner gap lines in the data envelope, cap the wrap-up (25) and report (200) gap lists, cap current rows at 2000 per task. Replace the activity budget marker with tasks.end_reason, written with the guarded terminal status and cleared on revive. Generic 500 message in taskCoverage; early return on an empty ledger in the planner block.
2026-11-12 20:48:34 +01:00
BaaRai 6f3a59e205 Coverage ledger: record examined, ruled-out and untested risk classes
Workers record per (surface, risk_area) outcomes with the new record_coverage
tool (reported, no_issue_found, ruled_out, not_applicable, needs_follow_up).
Gaps are needs_follow_up entries, entries missing their required evidence or
reason, and goal vulnclass values with no entry (unrecorded_risk_class), so an
absent finding never reads as a clean target. Completeness marks timeout,
budget wrap-up, pause, abort and running tasks as incomplete with a caveat.

Surfaced in the planner context (one line per round, full gap list on the
timeout/budget wrap-up round), GET /api/tasks/{id}/coverage, the coverage card
and the Markdown report. Follows coverage_enabled; no new env var.
2026-11-12 20:16:23 +01:00
BaaRai 4cde1a889f Budgets: revive clears the settle state, document config, allow update_finding_report in wrap-up
- resetSettledRun clears settling and the budget ledger; a done/failed task
  revived to running now plans and claims again (was gated by the old wrap-up).
  resetTimeoutRevival calls it. Test: TestResetSettledRunRestartsBudget.
- docker-compose.yml passes the four ARTEX_*_BUDGET_* variables through.
- .env.example and README document the variables, the wrap-up, the in-memory
  ledger and the practical floor.
- llmrec: the usage sink runs even without a PostgreSQL recorder; comment fixed.
- BudgetWrapupBlocks allows update_finding_report.
2026-11-12 18:48:41 +01:00
BaaRai 16ddfc7779 Hierarchical run budgets: min(child, parent) with forced wrap-up (off by default)
Per-level token/turn budgets: task -> intent (worker run) -> planner round
(counted inside the task). A child's effective budget is
min(own budget, parent's remaining above the wrap-up reserve). When the task
can no longer afford one worker, workers stop claiming intents and the task
settles through one forced wrap-up planner round (the existing settle
sequence, budget words instead of the timeout words).

Config: ARTEX_TASK_BUDGET_TOKENS, ARTEX_TASK_BUDGET_TURNS,
ARTEX_INTENT_BUDGET_TOKENS, ARTEX_INTENT_BUDGET_TURNS. 0 or unset = unlimited;
with all four unset nothing changes.
2026-11-12 17:27:28 +01:00
BaaRai d116517c64 Egress: return large unshared responses without an error, namespace the transport cache per client, pace WebFetch only 2026-11-12 16:44:08 +01:00
BaaRai 3b5b2c3b88 WebFetch: coalesce only, never cache; pin the gate-off redirect test to the off profile 2026-11-12 16:33:14 +01:00
BaaRai 0b59f4670b Egress governor: noise profiles (off by default), per-host rate limit, dedupe and short cache
ARTEX_NOISE_PROFILE = off | quiet | balanced | aggressive drives one shared
governor used by WebFetch, the http custom tools and the enrich probe: token
bucket, bounded jitter and concurrency per host, a global cap, backoff on
429/503 honouring Retry-After with bounded retries, per-task coalescing and
a short cache of identical GETs (never POST/PUT/PATCH/DELETE), and a
per-host noise log line each minute. Off (the default) is a strict
passthrough.

Removes the fixed artex-enrich/1.0 User-Agent; ARTEX_TARGET_USER_AGENT sets
one under a profile (empty = unchanged).
2026-11-12 16:30:56 +01:00
BaaRai e640c1b0fd Tool RAG: pin the keep set (harness, Skill, deferred) in a test 2026-11-12 15:58:34 +01:00
BaaRai f3a20ef2c6 Tool RAG: lexical projection of the bound tool catalog (off by default)
When ARTEX_TOOL_RAG is enabled, AugmentTools keeps the core tools plus the
top-k (ARTEX_TOOL_RAG_TOPK, default 12) BM25-ranked bound tools for the
worker intent / planner goal. Runs after ToolResolve, so it only removes;
falls back to the full list on empty query, catalog under 20, or scorer error.
2026-11-12 15:42:31 +01:00
BaaRai 8c3bae41f6 Untrusted boundary ON by default (opt-out via ARTEX_UNTRUSTED_BOUNDARY=0)
ARTEX_UNTRUSTED_BOUNDARY unset or empty now enables the boundary.
0, false, off and no (case-insensitive, trimmed) disable it; any other
value keeps it on. Docs state the default, the opt-out and the measured
cost (about 154 bytes per tool result plus one system note).
2026-11-12 14:46:44 +01:00
BaaRai 0f5d93fbee Add ingest_bbot tool (bbot JSONL, typed scope split)
ingest_bbot reads a bbot output file by path and ingests it through
the typed parser from #199 (parseBbotJSONL), one line at a time:

- Lines are read with a fixed cap (bbotMaxLineBytes = 64 KiB, newline
  included). A longer line is skipped and counted, never buffered whole.
- scope_distance == 0 events that are assets (assetItems) go through
  the existing insert_assets handler; no asset SQL is written here.
- Every other event is recorded as a fact with source
  third_party_intel. The summary is built from typed fields only; the
  free text is stored in detail as the inert Go-quoted string.
- Intel never reaches insert_assets or task_scope.

The tool is in WorkerTools, so the existing BuiltinToolSeeds/SeedTool
loop inserts its row (a new key, inserted on fresh and existing DBs).
It is listed in the target gate's noTargetTools: its input is a local
path, not a network target (TestDomainToolsDeclareNoTarget).
2026-11-12 12:35:06 +01:00
BaaRai aefc8c68ec agent: typed parser for bbot JSONL output (F10b)
Parse bbot output.json reading only the typed fields (type,
scope_distance, host, url, port, module, module_sequence), validated.
The free text (data, or data_json with the FINDING description) is kept
only as one Go-quoted inert string, never used for scope or an action.
Unknown keys are ignored. Events at scope_distance 0 are the only ones
mapped to insert_assets items, from their typed host; any other distance
becomes a third_party_intel item that is never auto-scoped.
2026-11-12 12:15:37 +01:00
BaaRai 3528287d61 enrich: route the HTTP probe through the pinned dialer (F13)
doHTTP now connects through intercept.PinnedDialer under the target scope of the request's context, so the probe uses the same pinned-address dial as the target gate. ProbeSite takes the context of the call that asks for the probe. A probe without a scope is refused; with the gate on, the probe bypasses the recording proxy so the dialer decides on the target itself. The path stays dormant: nothing calls ProbeSite on main.
2026-11-12 11:53:33 +01:00
BaaRai cd8a830b53 docs(coverage): say "declared domain or IP root" for auto-scope roots (F8)
declaredScopeFilter treats only root_domain, subdomain, ip and cidr rows as
roots. A declared company, icp or keyword row does not make an auto row
count, but the add_task_scope description said "a goal target or scope
added here or manually". The description and the code comments now say
"declared domain or IP root" and name the kinds that act as roots.
2026-11-12 09:38:52 +01:00
BaaRai 37fab18809 fix(coverage): count auto-scope only under a declared root (F8)
Auto task_scope rows (source='auto') come from insert_assets and intent
asset_ids, i.e. from target-derived content. They now count toward the
coverage denominator and list_untested_assets only when they sit under a
root the same task declared (a non-auto row: goal target registered via
add_task_scope, or manual scope): the same or a deeper domain of a declared
root_domain/subdomain, or a net inside a declared ip/cidr.

The direct-source coverage view also stops counting an asset from an intent
anchor alone; fact anchors still count, so tested evidence is unchanged.
Without this, insert_assets put a planted host back into the backlog
through its intent anchor.

task_scope is described as "coverage scope, not enforced" in the code
comments, the add_task_scope description, the goal-decomposer tail and the
task overview UI, instead of "authorization boundary".

Audit F8, 0.7.0.
2026-11-12 07:37:58 +01:00
BaaRai 975f63434b agent: frame agent-derived fields in a per-message nonce envelope (#124, F3)
The planner and worker user messages now carry worker output, finding
details, recent_facts, finding_list, cold_digests, related_tasks and hints
not written by the operator inside a code-owned data envelope whose nonce
comes from crypto/rand (16 bytes, hex) once per message. Embedded marker
stems are neutralised with a fixed-string replacement. The task goal, goal
nodes, constraints and the operator's own hints stay outside, labelled
operator. Overview fact summaries are capped at factSummaryMax (160 runes),
the cap list_facts already uses.
2026-11-12 01:30:17 +01:00
BaaRai 62c9b4a178 agent: fact provenance (declared source plus server-set origin)
record_fact takes a source field from a closed enum (target_response,
tool_output, third_party_intel, operator_input, inference). An empty value
is stored as inference; any other value is refused with the allowed list.
The source is stored in the fact payload.

The writer of a node is already recorded by the server in the origin
column. compactNode now surfaces origin and source, so recent_facts in the
overview, list_facts, expand_digest members and related-task facts carry
them. The cold-digest compressor input carries source and origin next to
confidence; the digest body is unchanged model text.

prove_goal refuses a fact whose source is target_response, in addition to
the existing confidence=observed rule. The goal stays open; findings are
still accepted.

The worker, pentest and planner default prompts ask for the source, and the
planner treats target_response facts as non-authoritative for goals.

Refs #124 (F4).
2026-11-12 00:37:44 +01:00
BaaRai 8c9c1fc0ab feat(findings): optional counterevidence and confidence_rationale
Add two optional free-text fields to findings so a reporter can record
what was tested and did not work (counterevidence) and why the
conclusion holds (confidence_rationale).

- db: idempotent ADD COLUMN IF NOT EXISTS, TEXT NOT NULL DEFAULT '',
  no CHECK; RecordFindingInput, the INSERT, the shared select list and
  all three scan sites carry them.
- agent: report_finding accepts both as optional strings; the worker
  and pentest prompts say to fill them when confidence is not high.
- server: FindingDTO (list and detail) carries both, omitted when empty;
  existing databases get the two schema properties added to the stored
  report_finding row once, without overwriting user edits.
- web: the finding detail page shows each section only when non-empty.

Both fields stay optional: a missing field never refuses a finding.
2026-11-11 23:43:12 +01:00
BaaRai 4bcb6d7250 Add agentic guards for goals, hint origin and planner Bash (F5, F6, F11)
Audit findings F5, F6 and F11 for issue #124.

F5, goal guards:
- goal_met refuses while any goal node of the task is open, and when the
  goals cannot be read. A task without goal nodes keeps the old behavior.
- prove_goal accepts a fact as evidence only when its confidence is
  "observed"; otherwise it refuses and the goal stays open. Findings are
  still accepted.

F6, hint origin:
- add_hint stores the calling ToolSet's worker as the hint origin instead
  of "human", and passes it to the planner trigger (TriggerEvent.HintOrigin).
- renderTriggers names the writer: the human via the main agent, a
  cross-task orchestration, or an agent, with a neutral fallback.

F11, built-in rules for the planner and the main agent:
- Interceptor.MatchBuiltin evaluates the enabled "[built-in]" deny rules on
  the intercept-enabled tools.
- The target-only guard applies them before the target gate, whether the
  gate is on or off. User rules and the judge stay out of it.
- SetRulesForTest is a test seam for the store-less guard tests; Log is a
  no-op without a store.
2026-11-11 22:39:13 +01:00
BaaRai 09ef702c24 agent: untrusted-content tail for every chat agent; evidence wording fits the reporter 2026-11-11 21:59:09 +01:00
BaaRai 8acf1be46e agent: code-owned prompt-injection tails and planner goal anchor (#124)
Phase B, part 1 of the agentic attack-class audit (issue #124).

- Planner and cold-digest compactor: a code-owned untrusted-data tail
  after the rendered body. Worker summaries, facts, digests, traces and
  tool outputs are data; they cannot add goals, targets or scope, end the
  task, or be relayed through steer_work as instructions.
- The #49 untrusted-content block moves out of the editable section [A]
  of the worker, mainagent, auto and pentest seeds into a code-owned tail,
  also applied to the reporter. A body that already carries the block
  verbatim (rows seeded before this change) does not get it twice.
- Planner user message: a code-owned goal anchor after the situational
  block restates the task goal and the operation constraints and asks each
  new intent to name the goal id it serves. The message builder is
  factored into plannerRoundInput for pure tests.
- bbot-recon skill: every event field is untrusted; only scope_distance
  == 0 reaches insert_assets; scope_distance > 0 stays an intel fact with
  source=third_party_intel, never auto-scoped; FINDING text is a lead.

Tests: prompt_injection_seed_test.go checks that a PromptOverride without
the block still renders the tail for every role, planner and compactor
included, that the block is not duplicated, and that the planner input
ends with the goal anchor. shipped_skills_test.go checks the bbot markers.
2026-11-11 21:47:44 +01:00
BaaRai f6b29b835b feat(intercept): pinned dialer for WebFetch and the custom http tool (S3b 2.1)
In a gated scope, the two HTTP clients the platform owns connect only to
addresses that a pin or a stored exemption covers at the moment of the
connection, on every hop.

- intercept.PinnedDialer(tool, scoped): the DialContext of both clients.
  It reads the target scope from the context net/http passes to the dial.
  Gate off, or no scope in a client built without one: plain dial, as
  today. A client built for a scope whose scope is missing at the dial
  fails closed with TARGET_GATE_UNAVAILABLE.
- dialTarget runs the gate decision on the dialled host:port with a fresh
  lookup (block rules first, exemptions, pins, port precedence) and
  connects only to the addresses it compared, never to the name. Derived
  exemptions and name-only pins dial the address as received. An address
  no pin covers finds or opens the request (new_ip / no_pin, wait 0) and
  fails at once with TARGET_APPROVAL_PENDING. Redirect hops are dialled
  against the hop's own (host, port).
- WebFetch (withWebFetch, now given the run context): DialContext set on
  every agent kind; DisableDirectRetry when the run's scope is gated.
- Custom http tool: a direct call in a gated scope gets a DefaultTransport
  clone with the pinned dialer, no environment proxy and keep-alive off.
  Proxied calls are unchanged (the recording proxy is step 2.2).
- Name-only: request reason says the platform does not bind the
  connection; the first-use pin_match row records the effective state.
- Derived exemptions: a proxy URL without a port is listed with the port
  net/http dials (1080 for socks5) (OPEN-9).
- go.mod: Norma v0.4.8 (DialContext, DisableDirectRetry).
2026-11-11 19:19:55 +01:00
BaaRai 1c3a5f4ffa feat(intercept): check every redirect hop against the target gate (S3b 1.9)
WebFetch and the custom http tool followed redirects with net/http's
default policy, so a pinned host could send the client to a target nobody
approved. RedirectCheck is now the CheckRedirect policy of both clients.

- Gate off, or no scope (operator test endpoint): only the 10-hop limit,
  same error text; nothing is parsed, resolved, read or written.
- Gate on: each hop runs through the call's decision code (URL rules on
  the hop URL, block rules before any lookup, exemptions, resolution,
  pins). A hop no pin covers is not followed and never waits: its request
  row is found or created and the call fails with TARGET_APPROVAL_PENDING.
  http -> https asks again for port 443 unless a (host, 0) pin exists. An
  unreadable hop is blocked with TARGET_GATE_UNAVAILABLE.
- Rows raised by a hop record redirect_from in the audit targets.
- Norma v0.4.7 has WebFetchConfig.CheckRedirect but no agentcore option,
  so the four agent kinds build WebFetch themselves (same proxy, CA and
  position in the tool list) instead of EnableWebFetch. Norma carries the
  policy to the direct retry client.
2026-11-11 17:07:35 +01:00
BaaRai 78f92f44fc feat(intercept): target extraction, resolver, exemptions and the gate in the hook (S3b 1.5-1.7)
The approval gate for asset targets now decides. It is opt-in per task and
per conversation (target_gate, default off). With the flag off nothing is
extracted, resolved, read or written, and every branch of the hook behaves as
before. No UI.

Extraction and normalisation (1.5)
- intercept.NormalizeTarget: lower case, trailing dot, userinfo, IPv6
  brackets, IPv4-mapped IPv6, decimal / hex / octal / short IPv4, percent-
  encoded host, punycode; port explicit, else the scheme default, else 0.
- intercept.ExtractTargets: WebFetch url exactly; Bash, shell_open, Monitor
  (command) and shell_send (text) with the command-string rules (URL with a
  scheme, IP, IP:port, host:port, CIDR; a bare name is not a target); every
  string of the input for MCP tools, custom tools and any tool the gate does
  not know. ExecuteExtraTool is unwrapped to the inner tool. The platform's
  domain tools, the file tools and web_search have no target.
- The fields are decoded as the tools decode them (key case, last duplicate),
  and a URL authority that reads differently quoted and unquoted yields both
  readings.

Resolver, exemptions, gate decision (1.6)
- Resolver interface, system implementation (A and AAAA), 3 s per lookup,
  positive answers cached 10 s per host with collapsed lookups, failures and
  empty answers never cached, always fresh when a request is raised.
- Exemptions are exact host + port. Derived from the configuration (LLM
  gateways, search backend in use, platform proxies): computed on every call,
  never resolved, never voided. Stored (settings key
  target_gate_exempt_hosts, seeded once with the package sources): resolved,
  and void when any address is private, loopback, link-local, unspecified,
  unique-local, multicast or a metadata address. An entry in both origins
  counts as derived. A stored IP literal that can never be exempt does not
  apply.
- GET / PUT /api/target-gate/exemptions and GET .../history. A change is
  validated (ValidateExemptionHost on the normalised host) and appended to
  target_gate_exemption_log in the transaction that stores it.
- Interceptor.CheckTargets: asset block rules (global and of the task) on the
  name and its addresses, exemptions, resolution, pin lookup. A (host, 0) pin
  covers every port; a (host, 443) pin covers 443 only and never a target
  without a declared port; two pins are never merged. At most 5 unpinned
  targets per call. Codes: TARGET_RULE_BLOCKED, TARGET_RESOLVE_FAILED,
  TARGET_TOO_MANY, TARGET_GATE_UNAVAILABLE; none opens a request.
- With the upstream egress proxy set every pin is name-only at call time,
  whatever name_only_effective says in its row.
- An approval never pins zero addresses: the pin writer refuses a request
  that displays none (db.ErrTargetPinEmpty, 422 on the decide endpoint, the
  row stays pending) and RequestTargets does not open one.
- Every block, and the first use per process of each exemption and of each
  pin version, is a decided row (decided_by system, decision_source target).

The gate in the hook (1.7)
- guard.applyIntercept: a deny by a rule or by the judge blocks first, before
  any target request exists; then CheckTargets; an allow is logged as before
  and does not satisfy the gate; an ask and an unpinned target share one
  request, and the classic ask is raised only when it was not merged. The
  call is checked again after the decisions, at most three passes.
- The gate applies to every tool of a gated session, enabled for rules or
  not, and to the target-only guard of the planner and the main agent, which
  still records blocks only.
- Target blocks have their own model-facing text, built from the code and the
  reason of the result.

Not in this change: rendered targets of custom tools (1.8) and redirect
targets (1.9). Subprocess paths stay advisory; the TestBypass_* tests document
what the gate does not see.
2026-11-11 00:34:55 +01:00
BaaRai 1f4cb65ca6 feat(intercept): target-gate schema, opt-in flag and approval requests (S3b 1.2-1.4)
Storage, flag and request path of the approval gate for asset targets. The
gate itself is not wired into the tool hook yet (steps 1.5 to 1.7): with this
change nothing is extracted, resolved or blocked, whatever the flag says.

Schema and store (1.2)
- tasks.target_gate, conversations.target_gate (default false).
- intercept_pending.target_key and a partial unique index: one open request
  per scope (task or conversation) and host:port.
- target_pins (no foreign keys: a pin outlives its task, its conversation and
  its request row) and target_gate_exemption_log. All DDL is idempotent; no
  CHECK constraints; the cascade on intercept_pending.conversation_id stays.
- db/target_pins.go: pin lookup with port precedence, list, revoke,
  find-or-create of a request, and ResolveTargetRequest, which settles the
  row, supersedes the old pin and writes the new version in one transaction.
- Deleting a task or an archive stub revokes its pins and settles its open
  target requests in the delete transaction. DeleteConversation and
  DeleteConversations revoke the pins of the removed conversations in the
  same transaction as the delete.

Opt-in flag (1.3)
- Per task through the create and metadata-patch paths, per conversation
  through create and patch; exposed in the task DTO. A retest conversation
  takes the flag of the finding's task, never its pins. A spawned task starts
  with the gate off.
- The task flag is atomic and read on every call through TargetScope.Enabled.
  TargetScope now carries the Interceptor that owns the gate.

Request, wait, decide (1.4)
- pendingManager keeps a list of waiters per request; resolve wakes all.
- RequestTargets: find-or-create per target, one card per request, wait on
  channel, poll, context and wait bound. A plain request is never settled by
  a call that leaves. A request merged with an ask is bound to its creating
  call and a timeout never allows it.
- Decide records the approver (subject of the session token) and the remote
  address for every human decision, classic asks included. On a target
  request "allowed" pins the displayed addresses; that is the only writer of
  pins.
- A request raised while the global egress proxy is set says the approval is
  name-only and its pin is stored with name_only_effective.
- Routes: GET/POST .../target-pins and .../target-pins/{pid}/revoke under
  /api/tasks/{id} and /api/conversations/{id}; decision_source=target filter.
- ValidateExemptionHost: an exemption written as an IP literal may be private
  unicast, never loopback, link-local or metadata, unspecified or multicast.
2026-11-10 23:05:47 +01:00
BaaRai 135b083b9a feat(guard): planner and main-agent hooks, target-gate scope in every session (S3b 1.1)
Step 1.1 of the S3b phase 1 plan. Wiring only: the approval gate for asset
targets is not enforced yet and no behaviour changes.

- Planner.Plan and MainAgent.Chat take a hooks parameter, like Worker.Execute.
  Their sessions had no hooks, so nothing saw their tool calls before execution.
- guard.NewTargetOnly: the guard for those two agent kinds. It runs the
  target-gate seam only: no intercept rule, no fallback judge, and no entry in
  the audit ring (Guard.record is untouched and not called for these calls).
- Task.TargetGuard, built beside Task.Guard.
- intercept.WithTargetScope / TargetScopeFromContext: task id or conversation
  id, an Enabled func and a wait bound, set at the five session entry points
  (planner round, final planner round with wait bound 0, main-agent turn,
  worker intent, conversation turn).

The seam allows every call and reads and writes nothing. No task or
conversation has an opt-in flag yet, so Enabled is nil (off) everywhere.
2026-11-10 22:14:24 +01:00
BaaRai c0e713d32f feat(engine): intent claim heartbeat, TTL and fencing token (S3c)
An intent claim was atomic but had no heartbeat, no expiry and no owner
token: startup reset every running intent to open unconditionally, so a
second instance on the same database stole live claims, and the worker
that lost its intent could still write the result.

- schema: last_heartbeat_at and unclaim_token on exploration_nodes
  (idempotent ADD COLUMN IF NOT EXISTS, nullable, no CHECK, no backfill).
- db: every claim (open->running and the paused->running message path)
  gets a fresh crypto/rand token and starts its lease in the same UPDATE.
  HeartbeatIntentClaim renews it for that token only.
- TTL: db.IntentClaimTTL = 90s, heartbeat every 15s
  (db.IntentClaimHeartbeatInterval), both on the database clock.
- takeover: ResetRunningIntents now reopens only claims whose heartbeat
  is older than the TTL, or NULL (rows claimed before the columns
  existed). The engine runs it per task every 15s as well as at startup.
- fencing: a heartbeat or result write whose token is not the current
  one returns db.ErrIntentClaimLost and writes nothing. All intent state
  writes in runIntent go through the fenced SettleIntentClaim /
  SettleIntentClaimBlocked; a rejected heartbeat cancels the stale run
  with the new work_claim_lost cause.

Behaviour change: after a restart, intents that were in flight are
resumed once their claim expires (at most 105s) instead of immediately.

Not in this change: fencing of the facts, findings and activity a worker
writes during its run; they are not guarded by the token.
2026-11-10 17:45:11 +01:00
BaaRai 00f5d8c103 Merge pull request #130 from kiserufetch/fix/list-task-credentials-empty-required
fix(tools): list_task_credentials emits invalid "required": [""] and breaks workers on strict LLM providers
2026-11-10 10:43:29 +01:00
BaaRai a44af3ca2b fix(tools): harden obj against blank required names and heal seeded list_task_credentials rows
The previous commit stops list_task_credentials from building its schema
with obj(map[string]any{}, ""). Two gaps remained:

- obj still emitted whatever it was given, so the same typo on another tool
  would again produce "required": [""]. obj now drops names that are blank
  after strings.TrimSpace and omits the "required" key when nothing is left.

- Tool schemas are cached in the tools table and SeedTool is insert-only, so
  the code fix only reached fresh installs. Existing databases kept the
  malformed row and strict providers kept rejecting every worker run.
  healListTaskCredentialsSchema runs at startup next to the other tool
  seeds and removes the "required" key from that one row, only when it is
  exactly [""]:

    UPDATE tools SET schema = schema - 'required', updated_at=now()
    WHERE key='list_task_credentials' AND schema->'required' = '[""]'::jsonb

  It is idempotent (a healed row no longer matches), needs no settings flag,
  and leaves the description, bindings, enabled flag and every other tool
  untouched. tool_schema_refresh_v7 is not bumped.

Tests: TestObjDropsBlankRequired and
TestListTaskCredentialsSchemaHasNoBlankRequired (agent),
TestHealListTaskCredentialsSchema (server, Postgres).
2026-11-10 09:46:49 +01:00
BaaRai c73e2872a3 feat: enable the untrusted-output boundary with ARTEX_UNTRUSTED_BOUNDARY
Norma v0.4.6 frames tool output between nonce-tagged markers, so the model
reads it as data. ARTEX passes that option from the environment: 1, true or
on enable it (case and surrounding spaces ignored); unset or anything else
keeps it off, which is the default. Nothing changes unless the variable is set.

The option is wired into the four agentcore.Options builders that set
ToolOutputDir: worker, planner, chat and the main agent. .env.example
documents the variable, commented out, with a note to test it on a local
target before enabling it in production.

Not included: docker-compose.yml does not pass the variable to the artex
service, so it has no effect under compose until it is listed there.
2026-11-09 23:42:49 +01:00
BaaRai f938ba00d9 feat: verified findings backend (S1)
Add a machine-checked verification state to findings, separate from the
human disposition (status). A finding is verified only when a proof or
verification traffic body is bound, the agent declares POE L3 or higher,
and every evidence quote appears verbatim, after whitespace
normalization, in one of those response bodies.

- schema: poe_level, verification_status, verification_note on findings
  (idempotent ADD COLUMN IF NOT EXISTS, no CHECK, no backfill).
- db: SetFindingVerification validates status and POE 0-4; the three
  fields are scanned with the finding rows and exposed in the DTO.
- update_finding_report: optional poe_level and evidence_quotes. The
  report is always saved; verification only decides the status. Read
  failures give not_assessed, never unverified.
- report agent guidance: declare POE and exact quotes from the bound body.

Not in this change: UI badges and filters, the blocked and false_positive
transitions, and the stored default report prompt for existing databases
(the guidance is appended at run time).
2026-11-09 20:21:27 +01:00
BaaRai 4acdf3bb26 fix(llm): validate custom User-Agent in the connection-test path
testLLM set cfg.UserAgent from the request (or the stored-profile
fallback) with no validation, while pgSaveProfile runs
agent.ValidateUserAgent and returns 400 on a bad value. A UA with a
control character could therefore reach the wire from the test button
though it is rejected on save. Call ValidateUserAgent on the final value
(after the stored fallback) and return 400 with the same message style,
mirroring pgSaveProfile; the fallback itself is unchanged.

ValidateUserAgent rejects every control character (r < 0x20 or r ==
0x7f), TAB included, so the header value stays a single printable token.
Extend the agent tests to cover NUL, US, DEL and TAB, and that a
printable slash-token value with spaces survives trimming unchanged. Add
a Postgres-free server test driving testLLM through an httptest endpoint:
a control-character UA is a 400 before any request leaves, a printable UA
reaches the wire verbatim.
2026-11-08 18:15:33 +01:00
BaaRai 13dde2da15 feat(llm): optional custom User-Agent for LLM requests
Add an optional per-profile User-Agent that overrides the header on every
outbound LLM request. When empty (the default), the existing behaviour is
unchanged and the default User-Agent is left untouched.

The value is trimmed and validated: control characters / newlines (header
injection) and values longer than 256 bytes are rejected. Wired through the
profile config struct, the Postgres llm_profiles schema, the profile API/DTO,
the connection-test path, and the LLM profile form, following the existing
session-header field. Addresses part of #154; the fixed-full-URL mode is not
included.
2026-11-08 17:11:43 +01:00
GRAB Racem a6e3fd0a2e fix(agent): match ARTEX_LLM_PROVIDER case-insensitively
FromEnv compared the raw value, so "Ollama" or "OpenAI" matched no case and
fell through to the anthropic default. With ARTEX_LLM_PROVIDER=Ollama and an
ANTHROPIC_API_KEY set, the Anthropic key was sent to the Ollama base URL.
2026-11-03 23:20:21 +01:00
BaaRai 1c35c9cf68 fix(ollama): normalize the base URL on the environment path too
The saved-profile path normalized /v1 but FromEnv did not, so
ARTEX_LLM_BASE_URL=http://host:11434 sent chat to /chat/completions and got a
404 on a LAN Ollama server. Share one helper between both entry points, and add
an env-path test with a bare host and with a full endpoint URL.

Also soften the keyless comment: a local Ollama ignores the bearer token, but a
gateway in front of it can require one, which belongs in a saved profile.
2026-11-02 17:03:57 +01:00
kiserufetchandClaude Opus 4.8 a66c716c0a fix(tools): drop empty required entry from list_task_credentials schema
list_task_credentials takes no parameters but built its JSON Schema with
obj(map[string]any{}, ""), emitting {"type":"object","required":[""],
"properties":{}}. The required array contains an empty string, which strict
function-call validators reject. Observed with DeepSeek through an
OpenAI-compatible gateway (format "openai"):

  openai: status 400 invalid_tool_spec -
  "function.parameters.required must be an array of non-empty strings"

Because the tool is in the worker toolset, every worker run fails before the
first tool call (terminal state model_error); the planner and main agent are
unaffected since they don't load it. Anthropic/OpenAI accept the malformed
schema silently, so it only surfaces on stricter providers.

Pass no required args so obj omits the required key entirely:
{"type":"object","properties":{}}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-02 09:05:03 +03:00
BaaRai 978f308a9e fix(ollama): keyless env config and /v1 base URL normalization
Two review fixes on top of the Ollama provider:

FromEnv no longer inherits OPENAI_API_KEY for the ollama format. Ollama
ignores the bearer token, so inheriting the cloud key could only ship it to
the local or LAN endpoint, contradicting the comment in NewProvider and the
documented behavior.

ConfigFrom now normalizes the base URL to the /v1 root, so the natural
Ollama URL (host:11434 without /v1) works for chat as well. Previously the
provider appended /chat/completions to it and the request landed on a path
Ollama does not serve, while pgListModels already appended /v1.

Tests: bare-host normalization assertion, the /v1/chat/completions path
assertion, and a new test that the env config carries no inherited key.
2026-10-25 19:18:25 +01:00
Glauber Portella 80a1ee96f6 ollama llm 2026-10-23 18:12:38 -03:00
BaaRai b237fbaa32 feat(engine): ouroboros takes - goal ambiguity gate, failover escalation, stagnation surfacing
Three orchestration mechanisms from the Q00/ouroboros evaluation (#99):

- Goal ambiguity gate at task creation: a goal must name a target (URL,
  domain, IPv4, localhost:port or absolute path) and reach a length floor,
  else the API returns 400 before any token is spent. Deliberately
  target-less goals opt out with the literal marker [targetless]. Guards
  against the live failure where a goal naming no target sent every worker
  to an unrelated asset.

- Consecutive-failure escalation on the task LLM chain: quota exhaustion
  already advances the chain; now 3 consecutive non-quota call failures on
  the same chain profile advance it too, with the true reason recorded.
  Escalation-only by design (no auto-downgrade: mid-task model swaps risk
  behavior drift; the operator keeps the manual chain reset).

- Stagnation surfacing in the planner overview: finished intents are
  fingerprinted (case-folded, digits collapsed); a direction dispatched 3+
  times with zero findings since its first attempt is flagged in a
  self-describing stagnation_warning payload that instructs the planner to
  diversify instead of grinding. Absent when nothing stagnates.

Closes #99
2026-10-21 14:46:56 +02:00
BaaRai a8f3c2c328 feat(rsi): evidence-gated lesson library in shadow mode
Extracts candidate lessons from terminal tasks (confirmed, negative,
retest caution), gates them on cue + action + evidence, and makes
extraction idempotent through a per-source evidence key.

ARTEX_RSI_LESSONS selects off / shadow / inject. Shadow logs what would
have been shown; inject appends a code-owned, data-not-instructions block
after the planner and worker system prompts. No prompt mutation and no
adoption: status stays candidate.

Credit: the Library admission rule comes from harness2 (Google Research,
Apache-2.0, arXiv 2610.14224); the gain taxonomy from awesome-rsi
(lobehub).
2026-10-21 14:25:35 +02:00
BaaRai aaf966872a feat: each test package runs on its own database
agent, db, server and evidence serialized on one shared test database via an advisory lock, while evidence, llmrec and traffic-style packages could still run against it in parallel; under full-suite load that window produced a real SQLSTATE 40P01 deadlock in the blackboard direct-source test. TestMain in every PG-using package now rewrites ARTEX_PG_DSN to a per-package database (created automatically by ensureDatabase), so packages neither contend nor need to serialize; the advisory lock stays as an in-package safety net. llmrec gains the TestMain it lacked, which also gives its requires-isolated-DSN tests a truly isolated database inside ./... runs.
2026-10-12 18:43:29 +02:00
BaaRai 93300a6097 Merge pull request #65 from BaaRai/feat/deadline-contract
feat: deadline contract in the executor seeds
2026-10-12 08:02:08 +02:00
BaaRai 2629140b93 feat: deadline contract in the executor seeds
Workers learn their budget is finite only when the wrap-up interrupt
arrives, which is too late: commands started minutes before exhaustion
die mid-flight, and their results are the ones lost. The pentest agent
has a wrap-up priority rule but nothing that prevents launching
long-running work that cannot finish before the task deadline.

Changes:
- worker seed: budget discipline block. Before starting any command or
  probe, check it finishes well within the remaining step and wall-clock
  budget; never start work whose timeout can outlive what remains, take
  the smaller variant that lands; when the budget is nearly spent or the
  wrap-up signal arrives, land everything already collected (facts
  first, then findings).
- pentest seed: deadline contract bullet. Long commands must fit the
  task's remaining time; as the deadline approaches, shift from
  discovery to consolidation.
- agent/deadline_contract_seed_test.go guards the markers.

Pattern adapted from bug-bounty-hunting-prompts (MIT), whose deadline
contract reads: never start a command whose timeout can extend beyond
the hard deadline.
2026-10-12 07:30:27 +02:00
BaaRai 02265524f2 feat: duplicate discipline before report_finding and in the reporter
Nothing today makes an agent check for an existing finding before
registering a new one, and the reporter has no duplicate or collapsing
rules. Duplicates burn reviewer trust on submission (programs read them
as noise or padding) and they inflate the task's apparent progress.

Changes:
- worker and pentest seeds: before report_finding, check
  list_findings / list_task_findings for an existing finding with the
  same root cause, endpoint, and parameter; the same defect again means
  extending that finding's evidence, not registering a duplicate. A
  genuinely distinct entry point, parameter, or privilege level remains
  its own finding (the adversarial gate's anti-merge rule still holds:
  the dedup check collapses only true duplicates).
- reporter seed: an explicit duplicate pass over list_task_findings in
  the work steps, plus two discipline rules (no duplicate reports for an
  already-reported defect; one finding per report, additional locations
  of the same root cause listed inside it, never silently merged or spun
  off).
- agent/finding_dedup_seed_test.go guards the markers.
2026-10-12 07:29:59 +02:00
BaaRai 4b815d5ee0 feat: ask_operator environmental handoff discipline
When a worker hits an environmental failure (sandboxed or filtered
network path, missing binary or key, TLS or proxy interception, missing
runtime permission), nothing today tells it how to stop: transcripts
show turn after turn of retries with different flags, equivalent tools,
or fallback transports, all burning budget against a wall the agent
cannot move.

Changes:

- the ask_operator tool description now covers environmental blocks
  explicitly: do not retry or work around; include the exact single
  command the operator should run in their own terminal as one
  copy-pastable line, plus what to paste back, then wait. The question
  parameter description gains the same guidance.
- worker and pentest seeds gain an "Operator handoff on environmental
  blocks" block: stop after the first environmental failure, no
  flag-hopping, no bypass hunting, one command per ask_operator request,
  never batch.
- tests: the tool description must keep the handoff markers
  (TestAskOperatorEnvHandoffGuidance) and the executor seeds must keep
  the block (TestPromptSeedsCarryOperatorHandoff).

Pattern adapted from bug-bounty-hunting-prompts (MIT), whose operator
handoff rule produced exactly this single-command format.
2026-10-12 06:45:21 +02:00
BaaRai b17f885935 Merge pull request #53 from BaaRai/feat/adversarial-verification-gate
feat: adversarial verification gate before confirmed findings
2026-10-12 06:31:28 +02:00
BaaRai bb81979bf6 feat: adversarial verification gate before confirmed findings
The worker and pentest seeds already tell the agent to self-check
conclusions, but the check has no concrete axes and no rules for what
an elimination requires. In practice candidates get dropped on vibes
("probably validated downstream") or merged into one narrative finding,
and there is pressure to produce at least one finding per intent.

Changes, all in the default prompt seeds plus a guard test:

- worker and pentest seeds gain an "Adversarial gate before
  report_finding" block: actively try to disprove every candidate along
  five named axes (reachability, preconditions, downstream controls,
  scope, simpler explanation); eliminations need cited evidence at the
  same bar as confirmations; distinct entry points, parameters, or
  privilege levels are separate findings (anti-merge); zero confirmed
  findings is a valid outcome and precise negatives are real work.
- the retester seed now argues the opposite case before every verdict
  along the same five axes and must cite the specific requests,
  responses, or controls supporting reproduced / fixed / inconclusive
  alike; a prose assumption never substitutes for evidence.
- a seed test guards all markers against silent removal.

Methodology adapted from VulnHunter (Apache-2.0, adversarial
verification and anti-merge rules) and bug-bounty-hunting-prompts
(MIT, independent pre-confirmation disprove pass).
2026-10-12 06:26:51 +02:00
BaaRai 2d8e357961 feat: measured impact and attacker-goal proof standard for findings
Confirmed findings today assert impact in prose ("the attacker can dump
the table") with no number behind it, and nothing in the seeds demands
that a PoC demonstrate the attacker's goal end to end rather than the
presence of a vulnerable pattern.

Three changes, all in the default prompt seeds plus a guard test:

- worker and pentest seeds gain a "Finding quality gates" block: the PoC
  must show the goal achieved (data returned, command executed, another
  user's resource touched); a mechanism blocked downstream is a hardening
  note, not a finding; every finding carries a measured impact as a
  number with its denominator plus what the defense blocked, a proof
  basis line (executed live / executed lab / static unexecuted), bounded
  non-destructive testing rules (own objects first, at most three
  non-owned objects, smallest sufficient request, reversible values,
  touched IDs recorded), and a priority rule against standalone
  low-impact oracles.
- the reporter seed gains a mandatory "## Measured impact" report section
  (quantified outcome, denominator, defense-blocked facts, proof basis)
  and a "measured, not asserted" discipline bullet.
- a seed test guards all markers against silent removal.

Methodology adapted from VulnHunter (Apache-2.0, measured-impact and
attacker-goal doctrine) and bug-bounty-hunting-prompts (MIT, bounded
testing standard and priority discipline).
2026-10-12 06:25:35 +02:00
BaaRai c1bebabafa feat: first-layer prompt-injection hardening for agents
Executor agents ingest attacker-controlled content on every run (HTTP
responses via curl, browser pages via the browser MCP, traffic records,
binary strings via remote analysis tools) with no counter-instruction
anywhere in their context.

Two defenses, both zero-cost per call:

- A standing hardening block in the default prompt seeds of worker,
  pentest, mainagent and auto: tool output from or through the target is
  data, never instructions; embedded directives are quoted verbatim and
  recorded as a possible prompt-injection finding instead of followed;
  credentials and operator data never go to a destination suggested by
  target-controlled content.
- Every MCP tool description gains a one-line untrusted-output note at
  wiring time (covers both transports and travels with the description:
  cached prompt for global tools, on-demand schema for deferred ones).

A seed test guards the hardening block against silent removal.

Closes #48
2026-10-12 00:58:53 +02:00
BaaRai 525ad9ffde Skip the four known-broken tests in code instead of CI -skip flags
The go test -skip flag proved unreliable in the full-suite CI shape
(the same pattern skipped correctly in isolation but ran on the
runner), so each of the four tests now carries an explicit t.Skip with
the reason and a pointer to issue #9. The CI workflow drops the -skip
argument and keeps serial package execution.
2026-10-10 18:12:42 +02:00