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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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>
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.
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
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).
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.
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.
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.
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.
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).
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).
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
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.
Task credentials (#3): declare test accounts at task creation, and never
lose the accounts the agent registers on its own.
- task_credentials table (unique per task+domain+username; upsert
overwrites the password in place), idempotent schema migration
- POST /api/tasks accepts credentials; GET/POST/DELETE
/api/tasks/{id}/credentials serve the UI (manual add, e.g. relaying an
OTP-verified account; delete)
- empty password = the "agent generates" mode: the entry surfaces in the
worker's graph overview with an explicit instruction to register that
account with a strong password and record it
- new tools save_task_credential / list_task_credentials for worker and
pentest; the graph overview injects the task's credentials each round
with a standing note: reuse matching entries, always record accounts
you register
- New Task dialog: optional credentials section (domain, username,
password, note) with a dedicated-test-accounts hint
- task detail: Credentials tab, passwords masked with explicit
reveal/copy, agent-recorded badge, light polling so mid-run recordings
appear
One-shot binding migration (settings-flag-guarded, same pattern as the
other seeds) grants the two tools to worker+pentest on databases seeded
before this change; fresh DBs get them via WorkerTools()/the pentest
catalog.
Closes#3
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.
Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
full text search) now use ASCII equivalents; go build passes and the
test suite matches the pre translation baseline (remaining failures
need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
matching the backend, chat mentions tests pass