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.
- POST /api/auth/login: 10 attempts per minute per TCP peer IP
(X-Forwarded-For ignored), then 429 with Retry-After. The block starts
at 1 minute and doubles on each attempt while blocked and on each new
block, capped at 15 minutes. A successful login clears the IP. No
per-account lockout. In-memory, idle entries swept after 15 minutes,
at most 10,000 IPs.
- New passwords (init, change-password, /setup page) need 12 characters.
Login does not check length, so existing 8-character passwords work.
- The binary's -addr default is 127.0.0.1:8787. The Docker image passes
-addr :8787 explicitly and is unchanged.
- GET /api/health returns {"ok":true} without a valid token; with one it
still returns service and version for the web UI top bar.
The server wrote jwt.key to the executable's directory, /app in the
image, so it lived in the container's writable layer and every container
rebuild generated a new key and signed all users out.
- ARTEX_JWT_KEY_DIR selects the key directory (default: config.BaseDir(),
unchanged for non-Docker installs). docker-compose.yml sets it to
/app/keys and bind-mounts ./keys there; the Dockerfile declares the
volume. The key stays out of data/, which the file manager browses.
- loadOrCreateJWTKey creates the directory (0700). When keyDir/jwt.key is
missing it migrates dataDir/jwt.key, then BaseDir()/jwt.key (mode 0600,
old file removed), otherwise generates a new key. An existing key is
never overwritten; a short one is now a startup error.
- .gitignore and .dockerignore exclude keys/.
- README and CHANGELOG updated; the 0.5.0 known issue is removed.
The Dockerfile copied a pre-built binary from dist/<arch>/artex into the
runtime image, but dist/ is git-ignored, so a fresh `git clone` has no such
file and `docker compose up -d --build` fails with
"/dist/amd64/artex: not found".
Build the binary from source in the image instead: a Node 22 stage runs the
frontend static export, a Go 1.26 stage runs build.sh with the same flags as
the release binaries job (CGO-free cross-compile, -trimpath, pinned version),
and the runtime stage copies the compiled binary. No build-context binary is
needed, so a clean clone builds.
Drop the now-obsolete dist/ warning in install.sh.
The -race step reused the job's ARTEX_PG_DSN, so it ran against the database the plain test step had just populated. The db tests are not idempotent across runs and failed on duplicate llm_profiles names, turning main red right after #144 merged.
New accepted opts ...Option but never called them, so scrubSecrets stayed
false in the binary and ScrubSecretEnv was unreachable: the protection merged
in #136 never engaged. The unit test passed because it called the scrub
function directly instead of going through New.
Apply the options in the constructor and add a test that builds a Server
through New with WithSecretScrub and asserts the secrets are gone. Verified to
fail without the loop.
Raises the go directive from 1.26.3 to 1.26.10, the newest 1.26 patch release
(CI installs it from go.mod; release.yml's "1.26" already resolves to it; the
Dockerfile ships a prebuilt binary and has no Go image). golang.org/x/net goes
from v0.58.0 to v0.62.0, the latest; go mod tidy raises x/crypto, x/text,
x/sys, x/sync, x/mod and x/tools to the versions it requires.
go test -race runs on every package that passes under the race detector
locally (all but ./server, which needs a -race check against postgres
first). A new shell job runs shellcheck on install.sh, update.sh, start.sh and
scripts/backup.sh.