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.
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.
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.
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.
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.
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.
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.
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.
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.
A completed retest with a reproduced or fixed verdict is an independent proof,
so it sets verification_status to verified with a note naming the retest, its
verdict and the start of its summary. The upgrade applies only from unverified,
not_assessed or blocked: a verified finding keeps its POE level and note.
Inconclusive, failed and stopped retests never change verification_status; their
verdict stays in the retest history. The human status is untouched by this path.
The existing fixed path (status set to Fixed with notifications) is unchanged.
Tests cover reproduced, fixed (status stays Fixed), inconclusive on a verified
finding, failed and stopped, and the upgrade from not_assessed and blocked.
Show the automated verification outcome next to the human triage status:
a badge on the findings list and detail page ("Verified L3", "Unverified",
"Blocked", "Not assessed", "False positive"), a verification-state filter
next to the status filter (persisted with the other list preferences), and
the verification note on the detail page when the state is not verified.
API: the findings list, groups, asset tree and export accept a
verification_status filter. Without it nothing changes.
Not included: a verified count in the statistics.
The quote x7 is now rejected as too short before the store is read, so the
test never reached the not_assessed path. Use a quote long enough to pass
the minimum; the store removal is unchanged.
A two-character quote such as "x7" can exist in any body and proves
nothing. Normalized quotes under 8 runes are now ignored; if none remain
the finding is unverified with the note "evidence quote too short".
Check order is unchanged: binding, POE, quote presence, then body.
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).
Revert the four UntrustedOutputBoundary: true lines. The bump to Norma
v0.4.6 stays; the boundary is available there but remains disabled until
an isolated smoke run is done (no throwaway database, cheap profile or
test key yet).
Bump the Norma replace to v0.4.6, which adds the opt-in
UntrustedOutputBoundary option: tool results are framed as data between
nonce-tagged markers, and the output cap leaves room for the framing.
Enable the option in the four agentcore.Options literals that set
ToolOutputDir: chat, mainagent, planner and worker.
Since e233c02 the judge writer prefixes its reason with "[model]" while
interceptSource, the approvalDecisionSource SQL fallback and the archive
restore checked "[Model]", so model-decided rows were inserted with
decision_source='unknown' and missed by the decision_source=model filter.
- db: add ModelReasonPrefix ("[model]") and isModelReason, which also
accepts the pre-e233c02 "[模型]" prefix; readers compare
case-insensitively (lower(reason) LIKE in SQL).
- intercept: the writer builds its reason through JudgeReason, using the
shared constant.
- schema.sql: idempotent backfill of 'unknown' rows without a rule whose
reason carries either prefix.
- tests: un-skip TestInterceptFilterHTTP (it failed only because of
this), make the db filter fixture use the constant, and add a
Postgres-free test pinning the writer output to interceptSource.
Fixes#156
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.
The build stage of the Dockerfile runs `COPY . .`, so anything left in the
checkout root is sent to the builder and copied into the build stage. Exclude
the local secrets and backups that the install and backup scripts create
there: .env, config.json, jwt.key and backups/. keys/ was already excluded.
The Docker Compose (manual) section of the README still told users to build
the single binary before `docker compose up -d --build`. Since the
multi-stage Dockerfile builds the frontend and the Go binary itself, that
step is no longer needed; remove it.
- README: ARTEX_TRUSTED_PROXIES for nginx and ARTEX on the host
(127.0.0.1) and for nginx on the host with ARTEX in Docker (the bridge
gateway, found with docker network inspect).
- CI shell job: run shellcheck on reset-password.sh too.
- ARTEX_TRUSTED_PROXIES (comma-separated CIDRs, empty by default). When
the TCP peer is listed, the login limit reads X-Forwarded-For from the
right and keys on the first hop that is not a trusted proxy; otherwise
the header is ignored. Without it every client behind a reverse proxy
shares one bucket and an attacker can block the admin. Passed through
by docker-compose.yml and listed in .env.example.
- README: nginx example sends X-Forwarded-For; document the variable;
the binary's default listen address is 127.0.0.1:8787.
- reset-password.sh refuses passwords under 12 characters, counted in
characters like the server.