100 Commits
Author SHA1 Message Date
BaaRai 558b685886 Merge pull request #207 from BaaRai/feat/coverage-ledger
Coverage ledger: record examined, ruled-out and untested risk classes
2026-11-12 22:00:29 +01:00
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 afa6f4faab Merge pull request #206 from BaaRai/feat/hierarchical-budgets
Hierarchical budgets: min(child, parent) with forced wrap-up (off by default)
2026-11-12 19:53:52 +01:00
BaaRai d234e341fc Budget docs: wrap-up is typical, not deterministic; [budget] logged per planner round only 2026-11-12 19:18:07 +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 4f2c6ef789 Merge pull request #205 from BaaRai/feat/egress-governor
Egress governor: noise profiles (off by default), per-host pacing, dedupe and target UA
2026-11-12 17:00:40 +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 6a5adf73fe Docs: WebFetch results are coalesced, not cached 2026-11-12 16:33:22 +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 b789dd2eae Merge pull request #204 from BaaRai/feat/tool-rag-lexical
Tool RAG: lexical projection of the bound tool catalog (off by default)
2026-11-12 16:16:22 +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 34ece5937a Merge pull request #203 from BaaRai/feat/untrusted-boundary-default-on
Untrusted boundary ON by default (opt-out via ARTEX_UNTRUSTED_BOUNDARY=0)
2026-11-12 14:53:27 +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 6109917957 Merge pull request #202 from BaaRai/feat/update-banner-opt-in
Opt-in update banner, off by default
2026-11-12 13:38:14 +01:00
BaaRai e5202b1c34 Merge pull request #201 from BaaRai/feat/release-body-from-changelog
release: build release body from CHANGELOG section
2026-11-12 13:38:10 +01:00
BaaRai e59db80173 Opt-in update banner, off by default
ARTEX_UPDATE_CHECK=1 lets the backend check the public GitHub releases API
for the latest tag at most once per 24 h (in-memory cache; failures are not
retried in the window) and report it in GET /api/health when it is newer
than the build version. The web UI shows a dismissible strip linking to the
Releases page. Unset (the default), no update request is made.
2026-11-12 12:54:25 +01:00
BaaRai 1e3c858b4e release: build release body from CHANGELOG section
The release job now checks out the repo and extracts the CHANGELOG.md
section matching the pushed tag (leading v stripped) with
scripts/extract-changelog-section.sh, and passes it as body_path. If the
section is missing it logs a warning and falls back to a generic body
naming the tag and pointing at CHANGELOG.md.

scripts/test-extract-changelog-section.sh covers the found and missing
cases against a fake CHANGELOG in a temp dir; the CI shell job runs it
and shellchecks both scripts.
2026-11-12 12:43:10 +01:00
BaaRai f90589c03c Merge pull request #200 from BaaRai/feat/ingest-bbot
Add ingest_bbot tool (bbot JSONL, typed scope split)
2026-11-12 12:41:19 +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 840bc53023 Merge pull request #199 from BaaRai/feat/bbot-typed-ingest
agent: typed parser for bbot JSONL output (F10b)
2026-11-12 12:27:03 +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 8a937ab306 Merge pull request #198 from BaaRai/fix/enrich-http-gate
enrich: route the HTTP probe through the pinned dialer (F13)
2026-11-12 12:11:29 +01:00
BaaRai 64dabdfbaf Merge pull request #197 from BaaRai/feat/ask-operator-target-tag
feat(ask_operator): tag requests that name hosts outside the goal (F12)
2026-11-12 12:11:25 +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 a7e91c81e6 fix(ask_operator): seed the goal targets with bare goal domains (F12)
The goal gate accepts a goal that names its target as a bare domain (goalDomainRE), but the in-goal set was built only with commandTargets, which does not read bare hosts. A question citing the operator's own target as a URL was then tagged target-derived. goalTargets adds the goalDomainRE matches, widened to the whole dotted name, lowercased, port and path dropped. The question-side parser is unchanged.
2026-11-12 11:51:38 +01:00
BaaRai 6dbcd33552 feat(ask_operator): tag requests that name hosts outside the goal (F12)
An ask_operator question that names a host or URL not among the task's
goal targets is tagged "contains target-derived text": the hosts go into
the transcript activity's Detail payload as target_derived_hosts, and the
transcript shows the tag on the request card when the field is set.

Hosts are read with intercept.ExtractTargets (the target gate's
command-string rules) from both the question and the task goal, and
compared without the port. The text the model reads, the pause, and the
operator's decision are unchanged: the tag is a visible hint only.

Audit F12, 0.7.0.
2026-11-12 11:12:22 +01:00
BaaRai 2c1a49058e Merge pull request #196 from BaaRai/fix/auto-scope-hygiene
fix(coverage): count auto-scope only under a declared root (F8)
2026-11-12 11:03:10 +01:00
BaaRai 88306a0567 fix(tools): migrate the add_task_scope description on existing databases (F8)
add_task_scope is seeded through BuiltinToolSeeds, SeedTool inserts only
once, and ToolResolve serves the stored row, so existing installs kept the
old "this is the task's authorization boundary" text.

seedTaskScopeDescription runs from seedOrchestrationTools and updates the
description only:

  UPDATE tools SET description=$new, updated_at=now()
  WHERE key='add_task_scope' AND system AND description=$old

$old is the description as seeded at 0dc8bdf, kept verbatim; $new is the
code description from BuiltinToolSeeds. It is idempotent, a no-op when the
row is absent, and leaves an operator-edited description alone. It does not
use RefreshToolDefaults, which overwrites without condition.

TestTaskScopeDescriptionUpgrade (server, PG) covers the replacement, the
preserved custom text, an unchanged updated_at on a second run, and the
description ToolResolve serves to the planner.
2026-11-12 09:38:52 +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 0dc8bdf179 Merge pull request #195 from BaaRai/feat/derived-field-envelope
agent: frame agent-derived fields in a per-message nonce envelope (#124, F3)
2026-11-12 07:26:56 +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 ff75bfaf1f Merge pull request #194 from BaaRai/feat/fact-provenance
agent: fact provenance (declared source plus server-set origin)
2026-11-12 01:22:00 +01:00
BaaRai bc33db2735 server: add the record_fact source enum to existing databases
SeedTool is first-insert only and ToolResolve serves the stored tools row,
so on an existing database the record_fact schema never gained the source
property: the model never saw the enum, every fact was stored as inference,
and prove_goal's target_response refusal could not fire.

seedFactSourceSchema copies properties.source from the record_fact seed into
the stored schema, only when the stored schema lacks it (same pattern as
seedFindingEvidenceSchema). Operator edits to the description and to other
properties are preserved, and a second run changes nothing. It runs from
seedOrchestrationTools next to the other schema seeders.

The PG test edits the stored row (no source, custom description and
parameter text), runs seedOrchestrationTools twice, and checks the added
enum, the preserved operator text, the unchanged row on the second run,
and that ToolResolve serves the source property to the worker.

Refs #124 (F4).
2026-11-12 01:09:30 +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 a13c68cb70 Merge pull request #191 from BaaRai/chore/release-0.6.0
release: v0.6.0
2026-11-12 00:17:29 +01:00
BaaRai 9fddb83d2d docs(changelog): date 0.6.0 as 2026-11-12 2026-11-12 00:06:57 +01:00
BaaRai 6f42db90bc docs(changelog): add #192 and #193 to 0.6.0 2026-11-11 23:52:27 +01:00
BaaRai 9355a373f1 Merge pull request #193 from BaaRai/feat/finding-evidence-schema
feat(findings): optional counterevidence and confidence_rationale
2026-11-11 23:51:40 +01:00
BaaRai f92938836d Merge pull request #192 from BaaRai/fix/block-reason-bound
guard: bound the length of block reasons sent to the model
2026-11-11 23:51:36 +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 1f297d75f2 guard: bound the length of block reasons sent to the model
A deny reason comes from a rule message, the fallback judge or the
target gate, and nothing bounds its length. systemBlockMessage and
TargetBlockMessage now cap the reason at 4000 runes (rune-safe, with a
" …(truncated)" marker) and keep the platform framing and the guidance
text intact. Interceptor.Log still stores the raw reason.
2026-11-11 23:33:53 +01:00
BaaRai 80c192d8ac release: v0.6.0 2026-11-11 22:58:09 +01:00
BaaRai a1f3256cf6 Merge pull request #190 from BaaRai/feat/agentic-guards
Agentic guards: goal_met/prove_goal, hint origin, built-in rules for planner Bash (#124 F5, F6, F11)
2026-11-11 22:53:54 +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 2945f58b01 Merge pull request #189 from BaaRai/feat/agentic-prompt-hardening
agent: code-owned prompt-injection tails and planner goal anchor (#124, part 1)
2026-11-11 22:11:40 +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 271f3b088a Merge pull request #188 from BaaRai/chore/docker-verified-tools
chore(docker): verify tool downloads and pin build inputs
2026-11-11 21:04:43 +01:00
BaaRai ac003ede28 chore(docker): verify tool downloads and pin build inputs
Stage the recon binaries in a build-platform "tools" stage and verify each
release archive against the checksums file of its own GitHub release (exact
asset name, sha256), failing the build when the entry is missing or does not
match: nuclei v3.11.1, httpx v1.12.0, subfinder v2.17.0, katana v1.8.0,
ffuf v2.3.0, dalfox v3.2.4. sqlmap is cloned at tag 1.9.9 and checked
against commit cceda39208b5aad478de2c3f731df4239d9881f6 before .git is
removed. The runtime stage copies /out/ and /opt/sqlmap from that stage.

Install Node v22.23.4 from the official nodejs.org tarball verified against
SHASUMS256.txt instead of piping the NodeSource setup script into bash.

Pin @playwright/mcp@0.0.83, @playwright/cli@0.1.22 and playwright@1.64.1
instead of @latest.

amd64 and arm64 are mapped explicitly; any other architecture fails the
build. dalfox --version no longer ignores failures.

Carries over the Dockerfile part of #127 by stevenAthompson.
2026-11-11 20:38:33 +01:00
BaaRai 31e56a16fc Merge pull request #187 from BaaRai/fix/customtool-proxy-scheme
Fix doubled scheme in the custom http tool's recording proxy URL
2026-11-11 20:27:16 +01:00
BaaRai bbc4f6269a Fix doubled scheme in the custom http tool's recording proxy URL
ProxyAddr returns a URL that already carries a scheme (the recording
proxy and the validated global proxy), but httpProxyTransport wrote
"http://" before it, so use_recording_proxy produced http://http://...
proxyURLString now adds http:// only to a bare host:port, for both the
operator proxy and ProxyAddr. httpToolTransport falls back to a fresh
http.Transport when http.DefaultTransport is not an *http.Transport.
2026-11-11 20:20:16 +01:00
BaaRai e38e48e80e Merge pull request #186 from BaaRai/feat/target-gate-pinned-dialer
feat(intercept): pinned dialer for WebFetch and the custom http tool (S3b 2.1)
2026-11-11 19:54:37 +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 08830365c4 Merge pull request #185 from BaaRai/feat/target-gate-redirect-hops
feat(intercept): check every redirect hop against the target gate (S3b 1.9)
2026-11-11 17:15:06 +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 1e616c9286 Merge pull request #184 from BaaRai/chore/bump-norma-v0.4.7
chore: bump Norma to v0.4.7
2026-11-11 16:43:00 +01:00
BaaRai 46e81f5f67 chore: bump norma to v0.4.7
Point the Norma replace at v0.4.7, which brings the thinking replay fix
(#4) and the WebFetch CheckRedirect hook (#3). go.sum updated by
go mod tidy. No ARTEX code changes.
2026-11-11 16:33:04 +01:00
BaaRai c5e12541bd Merge pull request #183 from BaaRai/fix/target-gate-declared-first
fix(intercept): match block rules on the declared targets before any lookup
2026-11-11 16:05:15 +01:00
BaaRai 743e6b0d15 Merge pull request #182 from BaaRai/feat/target-gate-inherit-spawn
feat(intercept): spawned tasks and triggered conversations inherit the target gate
2026-11-11 15:58:15 +01:00
BaaRai ca15063db7 fix(intercept): match block rules on the declared targets before any lookup
The target gate resolved each name through the cache before it matched the
asset block rules, so a call to a name that a name rule blocks still cost a
DNS lookup, and that query reached the name servers of the blocked domain.
URL rules (exact_url, fuzzy_url) were not evaluated at dispatch at all: the
gate passed no URL to the matcher, so they only applied to asset and intent
writes.

targetCheck.run now decides in this order, for a gated call:

1. URL rules, once per decision, on the URLs the call declares, as written.
2. Every block rule on the declared form of every target: the name as
   written, the IP literal, the range.
3. Only then resolution (names a derived exemption covers are still never
   resolved), and the block rules again on the resolved addresses.
4. Exemptions, pins and requests, unchanged.

A hit in 1 or 2 blocks with TARGET_RULE_BLOCKED and the same decided row as
any block, costs no lookup for any target of the call, and opens no request.

- intercept.extractDeclared returns the targets (ExtractTargets is now a thin
  wrapper, unchanged for its callers) and the URLs that name a target: the
  WebFetch url, and every URL token the command-string rules read in Bash,
  shell_open, Monitor, shell_send and the string leaves of other tools,
  including ExecuteExtraTool's inner call.
- The custom http tool's rendered URL and the URLs of a rendered command go to
  the same URL rules (gateRendered takes the URLs).
- Known limit, stated in the comments next to the matcher (db.matchOne) and
  at the dispatch match: URL rules match the declared string. exact_url misses
  any other spelling (case, percent-encoding, trailing slash, added query,
  /./), fuzzy_url misses an encoded substring, and a command can build its URL
  at run time. For a host, a domain, IP or CIDR rule is the one to write.

Unchanged: a tool-rule or judge deny still blocks before the gate is reached
(no extraction, no lookup, no request); asset block rules still win over an
allow and an ask; the gate off path does nothing; scripts stay advisory.

Tests:
- intercept: TestBlockRuleOnDeclaredNameNeedsNoLookup (exact_domain and
  fuzzy_domain; zero lookups, also for an unblocked name earlier in the same
  call), TestExtractDeclaredURLs, TestURLBlockRuleAtDispatch (WebFetch, Bash,
  MCP, ExecuteExtraTool, a pinned and an exempt host, a disabled rule, the
  rendered URL and command; zero lookups), TestExactURLRuleMatchesOnlyAsWritten
  (documents the limit).
- guard: TestBlockRuleWinsOverAllowAndAsk (name rule and URL rule, each over a
  rule allow, judge allow, rule ask and judge ask, real interceptor: blocked,
  no allow row, nobody asked, zero lookups). TestDenyRuleWinsBeforeTargetRequest
  now also covers a judge deny and asserts zero lookups with the real gate.
- server: TestBypass_CustomScriptToolTargetNotGated documents that a script
  tool's target, written in its stored code, is not visible to the gate: in a
  gated task the call is allowed and reaches the target with no lookup, no
  request and no row.
2026-11-11 15:47:58 +01:00
BaaRai f07e84ac7a feat(intercept): spawned tasks and triggered conversations inherit the target gate
spawn_task now reads the target-gate scope of the calling session from the
tool context. When the caller is gated, the child task is created with the
gate on (D23) and with a copy of the calling task's block rules (D24). Pins
and allow rules are never copied, parent_ref is not consulted, and spawn_task
gets no new parameter. A gate-off caller, or a call with no scope, creates the
child exactly as before.

A conversation started by a trigger of a gated task is created with the gate
on (D25). A merged run is gated if any of its fires came from a gated task.
Pins are never copied. Fires of a gate-off task and fires with no task are
unchanged.

TestSpawnedTaskStartsWithGateOff becomes TestSpawnedTaskInheritsGateFromCaller.
New: TestSpawnTaskSeesCallerScopeThroughHarness and
TestTriggeredConversationInheritsGate.
2026-11-11 15:47:21 +01:00
BaaRai a77851db7d Merge pull request #180 from BaaRai/feat/target-gate-custom-tools
feat(intercept): gate the rendered targets of custom tools (S3b 1.8)
2026-11-11 15:32:57 +01:00
BaaRai c0b741bcf6 feat(intercept): gate the rendered targets of custom tools (S3b 1.8)
The hook reads the parameters of a custom tool call, not the URL or the
command they render, and not a target written in the template itself. The
custom command and http tools now run the target gate inside the tool, on what
they are about to run, before anything is sent or started. Gate off (the
default) is unchanged: with no scope or the flag off nothing is parsed,
resolved, read or written.

- intercept.GateRenderedURL: the target is read as net/http dials it (host of
  the parsed URL, explicit port, else the scheme default), then goes through
  the same decision as at the hook (asset block rules, exemptions, resolution,
  pin lookup) and the same request-and-wait, at most three passes. A URL whose
  target cannot be read is blocked with TARGET_GATE_UNAVAILABLE and logged as
  a decided row; no request, no lookup.
- intercept.GateRenderedCommand: the rendered command is read with the
  command-string rules of Bash, and so is the rendering without the shell
  quoting of the parameters (a quoted parameter inside a host name hides the
  name from the rules). A command that names no target passes, as in Bash.
- A gated scope without the interceptor that owns the gate blocks
  (TARGET_GATE_UNAVAILABLE), it never lets the call through.
- runCommandTool and runHTTPTool take the tool key, which names the request
  rows. The gate runs before the tool's own timeout starts.
- guard.TargetBlockMessage (was targetBlockMessage) is exported so the tools
  return the same model-facing text as the hook.
- Unchanged: the script tool (its target is in stored code; advisory, D9), the
  custom-tool test endpoint (no scope: the operator's own action, D14), the
  hook order (a rule or judge deny blocks first; the tool then never runs, so
  its rendered target is never read), the evaluation of asset rules.

Tests: TestRendered* in intercept (no database); in server, against
PostgreSQL: TestCustomHTTPRenderedTargetGated,
TestCustomCommandRenderedTargetGated, TestCustomToolTestEndpointUngated,
TestTargetGateEndToEnd (a scripted worker's WebFetch waits, a click pins, the
next call passes, revoke, it asks again), TestDenyRuleWinsBeforeRenderedTarget
and TestGateOffTaskIsUnchanged.

Not in this change: redirect targets of WebFetch and of the custom http tool
(1.9).
2026-11-11 15:04:42 +01:00
BaaRai ec3db7fdec Merge pull request #179 from BaaRai/feat/target-gate-extract-resolve-gate
feat(intercept): target extraction, resolver, exemptions and the gate in the hook (S3b 1.5-1.7)
2026-11-11 14:42:45 +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 923c4a813a Merge pull request #178 from BaaRai/feat/target-gate-schema-request
feat(intercept): target-gate schema, opt-in flag and approval requests (S3b 1.2-1.4)
2026-11-10 23:35: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 ce9f4d0107 Merge pull request #177 from BaaRai/feat/target-gate-hooks-scope
feat(guard): planner and main-agent hooks, target-gate scope in every session (S3b 1.1)
2026-11-10 22:36:27 +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 7ccae15e3f Merge pull request #176 from BaaRai/feat/claim-heartbeat-fencing
feat(engine): intent claim heartbeat, TTL and fencing token (S3c)
2026-11-10 19:13:54 +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 b51a0bf74b Merge pull request #175 from BaaRai/chore/server-tests-skip-without-dsn
server: skip Postgres-backed tests when no database is configured
2026-11-10 17:06:23 +01:00
BaaRai b847867e5c Merge pull request #174 from BaaRai/fix/feishu-digest-overflow
notify: pack Feishu digests against the serialized request body
2026-11-10 17:06:19 +01:00
BaaRai 9b7276d65a Merge pull request #173 from BaaRai/feat/notify-limits-ui
web: add the notification render limits to the notify settings page
2026-11-10 17:06:15 +01:00
BaaRai 542582e3bc notify: pack Feishu digests against the serialized request body
A Feishu digest sends every item as its own card element. The packing
counted the text of each line only, not the element around it (about 52
bytes of JSON per item, plus whatever the encoding escapes) nor the rest
of the body beyond a fixed 320-byte reserve. A digest of 500 short lines
therefore left as a single request of 41846 bytes, while Feishu rejects
a request body over 20 KB as a whole.

feishuCard now adds whole items while the request body, measured as it
is serialized, stays within feishuMaxCardBytes: each item element by its
JSON encoding, the card around the items (header, intro, platform link)
by encoding it for the number of items kept, and what Send adds around
the card (msg_type, timestamp, sign) as feishuEnvelopeBytes, which the
new test pins to the bytes on the wire.

Unchanged: the first item of a digest is always kept and its text is
truncated to the same budget, single pushes are built as before, and
the other channels still pack with packItemCount.
2026-11-10 16:28:30 +01:00
BaaRai a422a071f6 server: skip Postgres-backed tests when no database is configured
Without ARTEX_PG_DSN and without a config file, go test ./server/ failed
44 tests with "no database config found" instead of skipping them. The
tests go through NewManager and t.Fatal on its error: 35 through the
shared fixture trafficEvidenceServer (directly, or through
verificationFixture and newNotifyFixture), 2 through ingestServer, and 7
call NewManager themselves. The 7 already carry a skip on m.pg == nil,
but it sits after the t.Fatal and is never reached.

Add skipWithoutDSN, which calls db.DSN() and skips with the message the
db package and assembly_test.go already use ("no database config (%v) —
skipping"), and call it before NewManager at those 9 sites.

Nothing changes when a database is configured: db.DSN() succeeds, the
helper returns, and a NewManager error (for example an unreachable
Postgres) still fails the test.
2026-11-10 16:28:26 +01:00
BaaRai 0cc22cb794 web: add the notification render limits to the notify settings page
PR #172 made the summary length and the number of assets listed in an IM
message configurable, but only through the API. The Notifications page now
shows both next to the other global settings:

  Summary length (characters)   notify_summary_max_runes   0-4000
  Assets listed per finding     notify_assets_max_shown    0-50

The values are read from GET /api/settings (they are not part of
/notify/meta) and saved with the existing "Save global settings" PUT.
Each field carries a help text stating that 0 means no truncation.

A value that is not a whole number within the bounds marks the input
invalid and blocks the save with a toast. The check runs before the
request because the backend applies the fields of one PUT in order, so a
400 on a limit would leave the URL and digest interval already saved. An
empty field is not submitted and keeps the stored value.
2026-11-10 16:24:07 +01:00
BaaRai 3b6eb2fd2c Merge pull request #172 from BaaRai/feat/notify-configurable-limits
notify: make the summary and asset limits configurable
2026-11-10 16:16:30 +01:00
BaaRai 2ab1046746 Merge pull request #171 from BaaRai/fix/companies-overflow-count
db: report hidden asset count in malformed-ip warning suffix
2026-11-10 16:16:24 +01:00
BaaRai 809436e111 notify: make the summary and asset limits configurable
The IM renderers cut the summary of a single push at 120 characters and
listed at most 3 assets, as fixed constants. Both are now global settings
in the settings table, read by the delivery engine when it renders:

  notify_summary_max_runes   0-4000, default 120
  notify_assets_max_shown    0-50,   default 3

0 means no truncation and an unset key keeps 120 / 3. PUT /api/settings
answers 400 for a value outside the bounds. The limits travel on
notify.Message, so the notify package still does not touch the database.

With no truncation, nothing bounded a single item in a Feishu card, and
Feishu rejects an oversized request as a whole. feishuCard now truncates
an item's text as the final backstop, the role TruncateBytes plays in
markdownBody for DingTalk and WeCom. feishuMaxCardBytes goes from 24000
to 16000: Feishu custom bots cap the request body at 20 KB, which the old
value exceeded.

Refs #168.
2026-11-10 16:05:31 +01:00
BaaRai c84f124cb4 db: report hidden asset count in malformed-ip warning suffix
malformedIPAssetWarning appended "and N more" with N = total, the number
of offending assets, instead of the number left out of the sample. With 8
bad rows and 5 named it read "... and 8 more"; it now reads "... and 3
more". Same bug as the one fixed in notify/render.go.

Move the wording into malformedIPAssetWarningText so it can be tested
without Postgres, and add a test for the 8-of-5 and 5-of-5 cases.
2026-11-10 15:49:41 +01:00
BaaRai cf0b0c1002 Merge pull request #170 from BaaRai/fix/notify-overflow-count
notify: report hidden asset count in overflow suffix
2026-11-10 15:44:46 +01:00
BaaRai b17a0ea796 notify: report hidden asset count in overflow suffix
assetLine appended "and N more" with N = len(assets), the total, instead
of the number of assets left out. With 5 assets and a limit of 2 it read
"a, b and 5 more"; it now reads "a, b and 3 more".

Fix the test that pinned the old value and add an 8-assets/limit-3 case.
2026-11-10 15:07:37 +01:00
BaaRai 59c3702a5e Merge pull request #169 from BaaRai/fix/verification-gzip-body
fix(verification): decode gzip/deflate bodies before the S1 quote check
2026-11-10 14:31:37 +01:00
BaaRai 05df40eecb test(verification): lock that an empty bound body never satisfies a quote
A response captured with Content-Encoding: gzip and no body (HEAD, 304) is
decoded to an empty body without error. Pin that behaviour, and pin that an
empty body, compressed or not, matches no non-empty quote through
readBoundResponse and quoteInBodies, so it can never lead to a verified
finding.
2026-11-10 12:52:56 +01:00
BaaRai f4f0df128a fix(verification): decode gzip/deflate response bodies before the S1 quote check
Compressed HTML responses are stored as captured, so the S1 receipt check saw them as binary and returned not_assessed. readBoundResponse now undoes the Content-Encoding declared in the snapshot's response head before the text/binary detection and the quote search.

decodeEvidenceBody handles identity and a single layer of gzip or deflate (zlib-wrapped or bare), bounded by verificationReadCap on the decoded side. Any other coding, stacked codings, or a stream that fails to decode is an error, which the caller reports as not_assessed (evidence body unreadable). The stored evidence bytes are unchanged.
2026-11-10 12:33:40 +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 81eab87d16 Merge pull request #166 from BaaRai/feat/untrusted-boundary-toggle
feat: enable the untrusted-output boundary with ARTEX_UNTRUSTED_BOUNDARY
2026-11-09 23:51:52 +01:00
BaaRai 06453409ea chore: pass ARTEX_UNTRUSTED_BOUNDARY to the artex service
Without this line the toggle set in .env never reached the container under
docker compose. The default is empty, which keeps the boundary off.
2026-11-09 23:44:38 +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 a83f494d03 Merge pull request #165 from BaaRai/feat/intercept-decided-by
feat: record which actor decided an intercept approval
2026-11-09 23:28:17 +01:00
BaaRai 52ea3f65c6 fix: rename the model filter option to AI-decided
The option filters decided_by = model, which includes denials the model judge
made, so "AI-approved" was inaccurate. Approved-by-AI is the combination with
status allowed.
2026-11-09 23:17:08 +01:00
BaaRai 2218480eba feat: show and filter the decided_by actor in the approval records
The approval list and detail return decided_by exactly as stored, and the
decided_by filter matches that same value. Rows decided before the column
existed show "Legacy" and match no decided_by filter; their decision_source
stays in its own column and nothing is rewritten.

UI: a "Decided by" column (Human, Rule, Model, System) and a matching filter
beside the decision-source filter, including "AI-approved only" for model.
Types and the API query carry decided_by the same way decision_source does.
2026-11-09 22:35:09 +01:00
BaaRai 8f50b81792 feat: record which actor decided an intercept approval
Add intercept_pending.decided_by (human | rule | model | system; empty while
pending or for rows decided before the column existed). Approval semantics are
unchanged: only the actor is recorded.

- rule-matched and model-judged rows written by CreateDecidedIntercept record
  rule or model;
- ResolveIntercept takes the actor explicitly and rejects any other value
  before touching the database;
- Decide (human allow/deny) records human; timeouts and work-cancelled
  denials record system. Work-cancelled is a system refusal, not a human one.
- decided_by is exposed on the pending and approval records and filterable
  with decided_by=human|rule|model|system (anything else is a 400).
- Legacy rows show decision_source as their actor when decided_by is empty.
  The filter uses the stored column only, so decided_by=model cannot match
  older human decisions or timeouts.
- Archive restore fills decided_by for archives that predate the column, and
  marks a restored pending row as system.
2026-11-09 22:17:56 +01:00