feat(tasks): per-task test-account credentials

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
This commit is contained in:
BaaRai
2026-10-09 14:28:08 +02:00
parent b28829e36d
commit c177ab9d86
15 changed files with 779 additions and 3 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ func builtinToolsByAgent() map[string][]actool.CoreTool {
// pentest (solo pentest agent) binds by default: list assets / insert assets /
// report finding / list findings / list companies.
// New databases are seeded from here; old databases are migrated by seedPentestDefaultBindings.
"pentest": {ts.listAssets(), ts.insertAssets(), ts.addFinding(), ts.listFindings(), ts.listCompanies()},
"pentest": {ts.listAssets(), ts.insertAssets(), ts.addFinding(), ts.listFindings(), ts.listCompanies(), ts.saveTaskCredential(), ts.listTaskCredentials()},
}
}
+8
View File
@@ -564,6 +564,14 @@ func (t *ToolSet) graphOverviewData() map[string]any {
}
}
}
// Task credentials (#3): test accounts for this target — user-declared (e.g. a
// program alias) plus accounts recorded by earlier runs. Surfaced every round so
// authenticated testing reuses them instead of re-registering, and so accounts
// this run registers get saved back (see save_task_credential).
if creds := t.taskCredentialOverview(); creds != nil {
out["task_credentials"] = creds
out["task_credentials_note"] = "test accounts for this task; use the matching entry for authenticated testing instead of registering a new account; entries with an empty password mean you must register that account first (strong unique password) and record it via save_task_credential; ALWAYS record accounts you register via save_task_credential"
}
return out
}
+118
View File
@@ -0,0 +1,118 @@
package agent
import (
"context"
"encoding/json"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
// Task credential tools (#3): a per-task record of test accounts. User-declared
// accounts (e.g. a program-provided alias) are injected into the worker's graph
// overview; accounts the agent registers itself MUST be recorded via
// save_task_credential so they are not forgotten when the session context rolls
// over. Handlers guard nil stores internally, mirroring the asset tools.
func (t *ToolSet) saveTaskCredential() actool.CoreTool {
return writeTool(
"save_task_credential",
"Record (or update) a test account for this task's target. ALWAYS call this immediately after registering an account on the target, with the exact username and password you chose, so the account is not lost. Also use it to update a known account whose password changed. Same (domain, username) is overwritten in place, not duplicated.\n"+
"Returns: {id, domain, username, source}.",
obj(map[string]any{
"domain": str("domain or host the account belongs to (e.g. target.example.com)"),
"username": str("login name / email used"),
"password": str("the exact password that was set — required"),
"note": str("optional context, e.g. which registration flow created it"),
}, "domain", "username", "password"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Domain string `json:"domain"`
Username string `json:"username"`
Password string `json:"password"`
Note string `json:"note"`
}
if err := json.Unmarshal(in, &a); err != nil {
return actool.Errorf("invalid input: " + err.Error()), nil
}
if t.ts == nil {
return actool.Errorf("save_task_credential is not enabled: task store is not initialized"), nil
}
taskID := t.taskID
if taskID <= 0 {
var err error
taskID, err = t.ts.TaskID()
if err != nil || taskID <= 0 {
return actool.Errorf("save_task_credential is only available inside a task"), nil
}
t.taskID = taskID
}
c, err := t.ts.UpsertTaskCredential(taskID, db.TaskCredentialInput{
Domain: a.Domain, Username: a.Username, Password: a.Password,
Source: "agent", Note: a.Note,
})
if err != nil {
return actool.Errorf("save_task_credential failed: " + err.Error()), nil
}
return jsonResult(map[string]any{
"id": c.ID, "domain": c.Domain, "username": c.Username, "source": c.Source,
})
})
}
func (t *ToolSet) listTaskCredentials() actool.CoreTool {
return writeTool(
"list_task_credentials",
"List this task's test accounts: user-declared accounts (e.g. program-provided aliases; a user-declared entry with an empty password means: register an account on that domain with a strong password, then record it via save_task_credential) plus accounts recorded by earlier runs. Check this BEFORE registering a new account, and use matching entries for authenticated testing.",
obj(map[string]any{}, ""),
func(_ context.Context, _ json.RawMessage) (actool.Result, error) {
if t.ts == nil {
return actool.Errorf("list_task_credentials is not enabled: task store is not initialized"), nil
}
taskID := t.taskID
if taskID <= 0 {
var err error
taskID, err = t.ts.TaskID()
if err != nil || taskID <= 0 {
return actool.Errorf("list_task_credentials is only available inside a task"), nil
}
t.taskID = taskID
}
list, err := t.ts.TaskCredentials(taskID)
if err != nil {
return actool.Errorf("list_task_credentials failed: " + err.Error()), nil
}
return jsonResult(map[string]any{"credentials": list})
})
}
// taskCredentialOverview renders the credentials block injected into the worker's
// graph overview. Empty user-side passwords surface as an explicit instruction
// (the agent generates and records one) rather than a half-entry.
func (t *ToolSet) taskCredentialOverview() []map[string]any {
if t.ts == nil || t.taskID <= 0 {
return nil
}
list, err := t.ts.TaskCredentials(t.taskID)
if err != nil || len(list) == 0 {
return nil
}
out := make([]map[string]any, 0, len(list))
for _, c := range list {
m := map[string]any{
"domain": c.Domain,
"username": c.Username,
"source": c.Source,
}
if c.Note != "" {
m["note"] = c.Note
}
if c.Password == "" {
m["instruction"] = "no password yet: register an account with this username on this domain (choose a strong unique password), then record it via save_task_credential"
} else {
m["password"] = c.Password
}
out = append(out, m)
}
return out
}
+3
View File
@@ -663,6 +663,9 @@ func (t *ToolSet) WorkerTools() []actool.CoreTool {
// add_company_scope is not given to workers: defining a company's asset scope is
// the duty of planning/main-control/Auto; a worker only executes exploration.
t.insertAssets(), t.listAssets(),
// Task credentials (#3): reuse user-declared test accounts, record
// self-registered ones so they are not forgotten.
t.saveTaskCredential(), t.listTaskCredentials(),
// Cross-work review: a worker can also reuse other works' observations to avoid
// duplicate work.
// search_all_worker_traces: no need to know an intent_id first — fish out matching
+17
View File
@@ -1292,6 +1292,23 @@ CREATE TABLE IF NOT EXISTS task_intercept_rules (
);
CREATE INDEX IF NOT EXISTS idx_task_intercept_task ON task_intercept_rules(task_id);
-- Backfills old databases (an earlier revision created this table without the action column): add the column (with IF NOT EXISTS).
-- Per-task test accounts (#3): user-declared (e.g. a program-provided alias
-- account) or agent-registered and recorded so they are not forgotten. Passwords
-- are local-only, same trust domain as captured traffic evidence; the UI masks
-- them by default.
CREATE TABLE IF NOT EXISTS task_credentials (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'user' CHECK (source IN ('user','agent')),
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (task_id, domain, username)
);
ALTER TABLE task_intercept_rules ADD COLUMN IF NOT EXISTS action TEXT NOT NULL DEFAULT 'block';
DROP TRIGGER IF EXISTS trg_task_intercept_rules_upd ON task_intercept_rules;
CREATE TRIGGER trg_task_intercept_rules_upd BEFORE UPDATE ON task_intercept_rules
+125
View File
@@ -0,0 +1,125 @@
package db
import (
"errors"
"strings"
"time"
)
// TaskCredential is a per-task test account (#3): either declared by the user at
// task creation (source=user, e.g. a program-provided alias account) or registered
// by the agent itself and recorded so it is not forgotten (source=agent).
//
// Passwords are stored in the local Postgres, plaintext — the same trust domain as
// captured traffic evidence, which already contains credentials by nature. The UI
// masks them by default; anything injected into agent context transits to the LLM
// provider, so only dedicated test accounts belong here.
type TaskCredential struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
Domain string `json:"domain"`
Username string `json:"username"`
Password string `json:"password"`
Source string `json:"source"` // "user" | "agent"
Note string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskCredentialInput validates and normalizes one upsert. Empty username or
// domain is rejected; an empty password is allowed only for source=user (the
// "agent generates and records it later" mode from #3).
type TaskCredentialInput struct {
Domain string
Username string
Password string
Source string
Note string
}
var ErrTaskCredentialInvalid = errors.New("credential domain and username are required")
func (in TaskCredentialInput) normalize() (TaskCredentialInput, error) {
in.Domain = strings.TrimSpace(in.Domain)
in.Username = strings.TrimSpace(in.Username)
in.Password = strings.TrimSpace(in.Password)
in.Note = strings.TrimSpace(in.Note)
if in.Source != "agent" {
in.Source = "user"
}
if in.Domain == "" || in.Username == "" {
return in, ErrTaskCredentialInvalid
}
if in.Password == "" && in.Source == "agent" {
return in, errors.New("agent-recorded credentials need the password that was set")
}
return in, nil
}
// UpsertTaskCredential inserts or updates the (task, domain, username) entry: an
// agent re-registering the same account overwrites the password in place instead
// of piling up a second row.
func (d *DB) UpsertTaskCredential(taskID int64, in TaskCredentialInput) (TaskCredential, error) {
in, err := in.normalize()
if err != nil {
return TaskCredential{}, err
}
var c TaskCredential
err = d.QueryRow(`
INSERT INTO task_credentials (task_id, domain, username, password, source, note)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (task_id, domain, username) DO UPDATE
SET password = EXCLUDED.password,
source = EXCLUDED.source,
note = CASE WHEN EXCLUDED.note = '' THEN task_credentials.note ELSE EXCLUDED.note END,
updated_at = now()
RETURNING id, task_id, domain, username, password, source, note, created_at, updated_at`,
taskID, in.Domain, in.Username, in.Password, in.Source, in.Note).
Scan(&c.ID, &c.TaskID, &c.Domain, &c.Username, &c.Password, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt)
return c, err
}
// TaskCredentials lists a task's credentials, oldest first (the user-declared
// ones keep their position ahead of agent-discovered ones).
func (d *DB) TaskCredentials(taskID int64) ([]TaskCredential, error) {
rows, err := d.Query(`
SELECT id, task_id, domain, username, password, source, note, created_at, updated_at
FROM task_credentials WHERE task_id = $1 ORDER BY id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TaskCredential
for rows.Next() {
var c TaskCredential
if err := rows.Scan(&c.ID, &c.TaskID, &c.Domain, &c.Username, &c.Password, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// DeleteTaskCredential removes one entry; taskID scopes the delete so a stale
// request cannot cross tasks.
func (d *DB) DeleteTaskCredential(taskID, id int64) error {
tag, err := d.Exec(`DELETE FROM task_credentials WHERE id = $1 AND task_id = $2`, id, taskID)
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n == 0 {
return errors.New("credential not found")
}
return nil
}
// Thin wrappers on ExplorationStore: the ToolSet already holds the task's
// exploration store, so credential tools need no extra wiring.
func (s *ExplorationStore) UpsertTaskCredential(taskID int64, in TaskCredentialInput) (TaskCredential, error) {
return s.db.UpsertTaskCredential(taskID, in)
}
func (s *ExplorationStore) TaskCredentials(taskID int64) ([]TaskCredential, error) {
return s.db.TaskCredentials(taskID)
}
+21 -2
View File
@@ -491,8 +491,9 @@ func (s *Server) seedOrchestrationTools() {
s.seedPlannerDefaultBindings()
s.seedPlannerListAssetsBinding()
s.seedCompanyScopeRebind()
s.seedWorkerReadToolsUnbind() // list_facts/list_companies/list_worker_traces unbound from worker's defaults (one-time)
s.seedWorkerReadbackRebind() // repairs an old migration's wrong removal: re-binds search_all_worker_traces/get_worker_trace/node_detail back onto worker (one-time)
s.seedWorkerReadToolsUnbind() // list_facts/list_companies/list_worker_traces unbound from worker's defaults (one-time)
s.seedWorkerReadbackRebind() // repairs an old migration's wrong removal: re-binds search_all_worker_traces/get_worker_trace/node_detail back onto worker (one-time)
s.seedTaskCredentialBindings() // save/list_task_credential onto worker + pentest (one-time, #3)
s.seedAutoReportFindingBinding()
s.unbindGoalMetDefault()
s.reseedGoalsPrompt() // goals prompt gains the "extract operation constraints" step → old DBs append a new default version (one-time)
@@ -882,6 +883,24 @@ func (s *Server) seedWorkerReadToolsUnbind() {
// they had been added to WorkerTools(), so any DB that ran that migration lost them.
// Fresh DBs already have them via WorkerTools() and this is a harmless no-op there.
// One-shot + flag-guarded so a user who later deliberately unbinds them isn't overridden.
// seedTaskCredentialBindings binds the task-credential tools onto worker and
// pentest ONCE (guarded by a settings flag), so existing DBs seeded before #3
// still get them — fresh DBs already have them via WorkerTools()/the pentest
// catalog seed. A user who deliberately unbinds them is not overridden.
func (s *Server) seedTaskCredentialBindings() {
const flag = "task_credential_bindings_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
for _, agentKey := range []string{"worker", "pentest"} {
if err := s.m.pg.AddAgentToToolBinding(agentKey, []string{"save_task_credential", "list_task_credentials"}); err != nil {
log.Printf("[%s] task-credential default binding failed: %v", agentKey, err)
return // flag not set; retried on next startup
}
}
_ = s.m.pg.SetSetting(flag, "true")
}
func (s *Server) seedWorkerReadbackRebind() {
const flag = "worker_readback_rebind_v2" // v2: adds node_detail
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
+27
View File
@@ -693,6 +693,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch)
mux.HandleFunc("GET /api/task-archives", s.listTaskArchives)
mux.HandleFunc("GET /api/task-archives/{id}", s.getTaskArchive)
mux.HandleFunc("GET /api/tasks/{id}/credentials", s.listTaskCredentials)
mux.HandleFunc("POST /api/tasks/{id}/credentials", s.addTaskCredential)
mux.HandleFunc("DELETE /api/tasks/{id}/credentials/{cid}", s.deleteTaskCredential)
mux.HandleFunc("POST /api/tasks/{id}/archive", s.queueTaskArchive)
mux.HandleFunc("POST /api/tasks/archive/batch", s.queueTaskArchivesBatch)
mux.HandleFunc("POST /api/task-archives/{id}/restore", s.queueTaskArchiveRestore)
@@ -1457,6 +1460,16 @@ type createTaskReq struct {
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // asset coverage feature; omitted/null = default on (true). false = disables coverage calculation/display/auto-accumulated scope + hides add_task_scope/list_untested_assets. company links are unaffected.
// InterceptRules: task-level asset interception/allow rules (entered at creation, stored in task_intercept_rules, never the global table).
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
// Credentials: test accounts declared at creation (#3). An empty password means
// "agent generates": the agent registers that account and records it back.
Credentials []taskCredentialReq `json:"credentials,omitempty"`
}
type taskCredentialReq struct {
Domain string `json:"domain"`
Username string `json:"username"`
Password string `json:"password"`
Note string `json:"note"`
}
func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
@@ -1528,6 +1541,20 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("[task] created task #%s «%s» goal: %s", t.ID, req.Description, req.Goal)
// Task credentials (#3): rows land after the task row commits; a bad entry
// fails the whole request so the UI never shows a half-configured task.
if len(req.Credentials) > 0 {
taskNum, _ := strconv.ParseInt(t.ID, 10, 64)
for _, c := range req.Credentials {
if _, err := s.m.PG().UpsertTaskCredential(taskNum, db.TaskCredentialInput{
Domain: c.Domain, Username: c.Username, Password: c.Password,
Source: "user", Note: c.Note,
}); err != nil {
writeErr(w, 400, "invalid credential for "+c.Domain+": "+err.Error())
return
}
}
}
// Shared post-create flow (seed + seed intent + background goal decomposition + engine.Run), reusing the same launchTask as spawn_task.
// launchTask is async internally and never blocks the UI — goal decomposition visibly proceeds in the background.
s.launchTask(t, req.Description+" "+req.Goal, req.SeedFirstIntent != nil && *req.SeedFirstIntent)
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"github.com/Autumn-27/artex/db"
)
// Task credential HTTP handlers (#3). The agent-side write path is the
// save_task_credential tool; these routes serve the UI (task detail tab) and the
// creation dialog.
// taskCredentialDTO masks nothing — the admin is the credential owner — but the
// frontend renders passwords masked with an explicit reveal.
func taskCredentialDTO(c db.TaskCredential) map[string]any {
return map[string]any{
"id": c.ID, "task_id": c.TaskID, "domain": c.Domain, "username": c.Username,
"password": c.Password, "source": c.Source, "note": c.Note,
"created_at": c.CreatedAt, "updated_at": c.UpdatedAt,
}
}
func (s *Server) taskCredentialTaskID(w http.ResponseWriter, r *http.Request) (int64, bool) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return 0, false
}
id, err := strconv.ParseInt(t.ID, 10, 64)
if err != nil || id <= 0 {
writeErr(w, 500, "task id is not numeric")
return 0, false
}
return id, true
}
func (s *Server) listTaskCredentials(w http.ResponseWriter, r *http.Request) {
id, ok := s.taskCredentialTaskID(w, r)
if !ok {
return
}
list, err := s.m.PG().TaskCredentials(id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
out := make([]map[string]any, 0, len(list))
for _, c := range list {
out = append(out, taskCredentialDTO(c))
}
writeJSON(w, 200, out)
}
func (s *Server) addTaskCredential(w http.ResponseWriter, r *http.Request) {
id, ok := s.taskCredentialTaskID(w, r)
if !ok {
return
}
var req taskCredentialReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "bad json: "+err.Error())
return
}
// source=user: manual adds from the UI (e.g. relaying an OTP-verified account).
// An empty password stays allowed here — the "agent generates" mode.
c, err := s.m.PG().UpsertTaskCredential(id, db.TaskCredentialInput{
Domain: req.Domain, Username: req.Username, Password: req.Password,
Source: "user", Note: req.Note,
})
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 201, taskCredentialDTO(c))
}
func (s *Server) deleteTaskCredential(w http.ResponseWriter, r *http.Request) {
id, ok := s.taskCredentialTaskID(w, r)
if !ok {
return
}
cid, err := strconv.ParseInt(r.PathValue("cid"), 10, 64)
if err != nil || cid <= 0 {
writeErr(w, 400, "invalid credential id")
return
}
if err := s.m.PG().DeleteTaskCredential(id, cid); err != nil {
writeErr(w, 404, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
+112
View File
@@ -0,0 +1,112 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/Autumn-27/artex/db"
)
// Task credentials (#3): declared at creation, listed/upserted/deleted via the
// API, and recorded by the agent path (ExplorationStore upsert, source=agent).
func TestTaskCredentialsFlow(t *testing.T) {
s, _, request := trafficEvidenceServer(t)
// Create a task carrying two credentials: one complete, one "agent generates".
var created struct {
ID string `json:"id"`
}
resp := request("POST", "/api/tasks", `{
"description": "credential flow test",
"goal": "verify task credentials",
"credentials": [
{"domain": "target.example.com", "username": "alias@test", "password": "s3cret!", "note": "program alias account"},
{"domain": "target.example.com", "username": "", "password": ""}
]
}`)
if resp.Code != http.StatusBadRequest {
t.Fatalf("an entry without a username must be rejected, got HTTP %d: %s", resp.Code, resp.Body.String())
}
resp = request("POST", "/api/tasks", `{
"description": "credential flow test",
"goal": "verify task credentials",
"credentials": [
{"domain": "target.example.com", "username": "alias@test", "password": "s3cret!", "note": "program alias account"},
{"domain": "app.target.example.com", "username": "agent-account", "password": ""}
]
}`)
if resp.Code != http.StatusCreated {
t.Fatalf("create with credentials failed, HTTP %d: %s", resp.Code, resp.Body.String())
}
if err := json.Unmarshal(resp.Body.Bytes(), &created); err != nil || created.ID == "" {
t.Fatalf("bad create response: %s", resp.Body.String())
}
taskNum := json.Number(created.ID)
id, err := taskNum.Int64()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.m.PG().Exec(`DELETE FROM tasks WHERE id=$1`, id) })
// List: both rows, user source, order stable.
resp = request("GET", fmt.Sprintf("/api/tasks/%s/credentials", created.ID), "")
if resp.Code != http.StatusOK {
t.Fatalf("list failed, HTTP %d: %s", resp.Code, resp.Body.String())
}
var list []db.TaskCredential
if err := json.Unmarshal(resp.Body.Bytes(), &list); err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("want 2 credentials, got %d: %s", len(list), resp.Body.String())
}
if list[0].Domain != "target.example.com" || list[0].Username != "alias@test" || list[0].Password != "s3cret!" || list[0].Note != "program alias account" {
t.Fatalf("declared credential mismatch: %+v", list[0])
}
if list[1].Password != "" {
t.Fatalf("agent-generates entry must keep an empty password until recorded, got %q", list[1].Password)
}
// The agent path records the generated password onto the empty entry.
if _, err := s.m.PG().UpsertTaskCredential(id, db.TaskCredentialInput{
Domain: "app.target.example.com", Username: "agent-account", Password: "generated-strong-pass",
Source: "agent",
}); err != nil {
t.Fatal(err)
}
list, err = s.m.PG().TaskCredentials(id)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 || list[1].Password != "generated-strong-pass" || list[1].Source != "agent" {
t.Fatalf("agent record must update in place, got %+v", list[1])
}
// Manual add + delete through the API.
resp = request("POST", fmt.Sprintf("/api/tasks/%s/credentials", created.ID),
`{"domain":"other.example.com","username":"relay","password":"otp-verified"}`)
if resp.Code != http.StatusCreated {
t.Fatalf("manual add failed, HTTP %d: %s", resp.Code, resp.Body.String())
}
var added db.TaskCredential
if err := json.Unmarshal(resp.Body.Bytes(), &added); err != nil {
t.Fatal(err)
}
resp = request("DELETE", fmt.Sprintf("/api/tasks/%s/credentials/%d", created.ID, added.ID), "")
if resp.Code != http.StatusOK {
t.Fatalf("delete failed, HTTP %d: %s", resp.Code, resp.Body.String())
}
list, _ = s.m.PG().TaskCredentials(id)
if len(list) != 2 {
t.Fatalf("after delete want 2 credentials, got %d", len(list))
}
// A delete from another task must not cross tasks.
resp = request("DELETE", fmt.Sprintf("/api/tasks/%s/credentials/999999", created.ID), "")
if resp.Code != http.StatusNotFound {
t.Fatalf("foreign credential delete must 404, got HTTP %d", resp.Code)
}
}
@@ -0,0 +1,162 @@
"use client";
import * as React from "react";
import { CopyIcon, EyeIcon, EyeOffIcon, PlusIcon, TrashIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/api";
import type { TaskCredential } from "@/lib/types";
// Credentials tab (#3): the task's test accounts — user-declared (program alias
// accounts) and agent-recorded (accounts the workers registered themselves).
// Passwords render masked; reveal and copy are explicit.
export function CredentialsTab({ taskId }: { taskId: string }) {
const [items, setItems] = React.useState<TaskCredential[] | null>(null);
const [error, setError] = React.useState("");
const [revealed, setRevealed] = React.useState<Set<number>>(new Set());
const [domain, setDomain] = React.useState("");
const [username, setUsername] = React.useState("");
const [password, setPassword] = React.useState("");
const [note, setNote] = React.useState("");
const [adding, setAdding] = React.useState(false);
const load = React.useCallback(() => {
api
.taskCredentials(taskId)
.then((r) => {
setItems(r);
setError("");
})
.catch((e) => setError((e as Error).message));
}, [taskId]);
React.useEffect(() => {
load();
// Agent runs can record credentials mid-task; a light poll keeps the tab current.
const timer = setInterval(load, 15000);
return () => clearInterval(timer);
}, [load]);
async function add() {
if (!domain.trim() || !username.trim()) return;
setAdding(true);
try {
await api.addTaskCredential(taskId, {
domain: domain.trim(),
username: username.trim(),
password, // empty = "agent generates and records it"
note: note.trim(),
});
setDomain("");
setUsername("");
setPassword("");
setNote("");
load();
} catch (e) {
setError((e as Error).message);
} finally {
setAdding(false);
}
}
async function remove(id: number) {
try {
await api.deleteTaskCredential(taskId, id);
load();
} catch (e) {
setError((e as Error).message);
}
}
function toggle(id: number) {
setRevealed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<Card>
<CardHeader>
<CardTitle>Test accounts</CardTitle>
<CardDescription>
Test accounts for this task. Entries with an empty password mean the agent registers that account and records it. Agent-recorded accounts appear here automatically.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{error ? <p className="text-destructive text-sm">{error}</p> : null}
{items === null ? (
<Skeleton className="h-24 w-full" />
) : items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>No test accounts</EmptyTitle>
<EmptyDescription>
Add one below, or declare them when creating the task — the agent reuses them for authenticated testing.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="flex flex-col divide-y">
{items.map((c) => (
<div key={c.id} className="flex flex-wrap items-center gap-2 py-2.5">
<code className="text-sm font-medium">{c.domain}</code>
<span className="text-muted-foreground text-sm">{c.username}</span>
<Badge variant={c.source === "agent" ? "secondary" : "outline"}>
{c.source === "agent" ? "agent-recorded" : "declared"}
</Badge>
<div className="ml-auto flex items-center gap-1">
<code className="max-w-40 truncate rounded bg-muted px-2 py-0.5 text-sm">
{c.password === "" ? "— agent generates —" : revealed.has(c.id) ? c.password : "•".repeat(Math.min(c.password.length, 12))}
</code>
{c.password !== "" ? (
<>
<Button size="icon" variant="ghost" className="size-7" aria-label={revealed.has(c.id) ? "Hide password" : "Reveal password"} onClick={() => toggle(c.id)}>
{revealed.has(c.id) ? <EyeOffIcon /> : <EyeIcon />}
</Button>
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Copy password"
onClick={() => navigator.clipboard?.writeText(c.password).catch(() => {})}
>
<CopyIcon />
</Button>
</>
) : null}
<Button size="icon" variant="ghost" className="size-7" aria-label="Delete credential" onClick={() => remove(c.id)}>
<TrashIcon />
</Button>
</div>
{c.note ? <p className="text-muted-foreground w-full text-xs">{c.note}</p> : null}
</div>
))}
</div>
)}
<div className="grid gap-2 sm:grid-cols-[1fr_1fr_1fr_auto]">
<Input placeholder="domain (e.g. target.example.com)" value={domain} onChange={(e) => setDomain(e.target.value)} />
<Input placeholder="username" value={username} onChange={(e) => setUsername(e.target.value)} />
<Input
placeholder="password (empty = agent generates)"
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button variant="outline" disabled={adding || !domain.trim() || !username.trim()} onClick={add}>
<PlusIcon /> Add
</Button>
<Input className="sm:col-span-4" placeholder="note (optional, e.g. program alias account)" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</CardContent>
</Card>
);
}
@@ -42,6 +42,7 @@ import type { LLMProfile, Task } from "@/lib/types";
import { AssetsTab } from "./_tabs/assets-tab";
import { BroadcastTab } from "./_tabs/broadcast-tab";
import { CoverageGraphTab } from "./_tabs/coverage-graph-tab";
import { CredentialsTab } from "./_tabs/credentials-tab";
import { FindingsTab } from "./_tabs/findings-tab";
import { GraphTab } from "./_tabs/graph-tab";
import { InterceptTab } from "./_tabs/intercept-tab";
@@ -58,6 +59,7 @@ const TABS = [
{ value: "findings", label: "Findings" },
{ value: "retests", label: "Retests" },
{ value: "assets", label: "Test assets" },
{ value: "credentials", label: "Credentials" },
{ value: "coverage", label: "Asset coverage" },
{ value: "intercept", label: "Interception approval" },
{ value: "report", label: "Report" },
@@ -432,6 +434,9 @@ function TaskDetailInner() {
<TabsContent value="findings" className="mt-0">
<FindingsTab taskId={id} />
</TabsContent>
<TabsContent value="credentials" className="mt-0">
<CredentialsTab taskId={task.id} />
</TabsContent>
<TabsContent value="retests" className="mt-0">
<RetestsTab key={id} taskId={id} />
</TabsContent>
@@ -33,6 +33,7 @@ import {
PinOffIcon,
PlayIcon,
PlusIcon,
TrashIcon,
SaveIcon,
SearchIcon,
SlidersHorizontalIcon,
@@ -123,6 +124,7 @@ import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.
import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference";
import type {
AssetInterceptRuleInput,
TaskCredentialInput,
ChatAttachment,
Company,
DeleteTaskOptions,
@@ -3088,6 +3090,7 @@ function CreateTaskSheet({
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // dispatch a seed intent at creation so the worker starts without waiting for the first planner round; off by default, standard plan-then-execute
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // asset coverage feature; on by default. Off = no coverage computed/displayed, no scope accumulation, scope-related tools hidden (company links unaffected)
const [interceptRules, setInterceptRules] = React.useState<AssetInterceptRuleInput[]>([]); // task-level asset intercept rules (this task only, never entering the global table)
const [credentials, setCredentials] = React.useState<TaskCredentialInput[]>([]); // test accounts declared at creation (#3); an empty password = the agent registers that account and records it
// Method 1, file upload: files are staged under drafts/<draftId>/uploads/ before the task is created, and their absolute paths are appended to the description.
const [uploading, setUploading] = React.useState(false);
const [uploadCount, setUploadCount] = React.useState(0);
@@ -3157,6 +3160,9 @@ function CreateTaskSheet({
interceptRules: interceptRules
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
.filter((r) => r.pattern !== ""),
credentials: credentials
.map((c) => ({ domain: c.domain.trim(), username: c.username.trim(), password: c.password, note: c.note?.trim() ?? "" }))
.filter((c) => c.domain !== "" && c.username !== ""),
});
toast.success("Task created");
setName("");
@@ -3402,6 +3408,59 @@ function CreateTaskSheet({
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Test accounts (optional)</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCredentials((prev) => [...prev, { domain: "", username: "", password: "", note: "" }])}
>
<PlusIcon /> Add account
</Button>
</div>
<p className="text-muted-foreground text-xs">
Test accounts reused by the agent for authenticated testing. Leave the password empty to have the agent register that
account itself and record the password it chose. Use dedicated test accounts only — anything declared here is sent to
the configured LLM provider during the task.
</p>
{credentials.map((c, i) => (
<div key={i} className="grid gap-2 sm:grid-cols-[1fr_1fr_1fr_auto]">
<Input
placeholder="domain (e.g. target.example.com)"
value={c.domain}
onChange={(e) => setCredentials((prev) => prev.map((x, j) => (j === i ? { ...x, domain: e.target.value } : x)))}
/>
<Input
placeholder="username"
value={c.username}
onChange={(e) => setCredentials((prev) => prev.map((x, j) => (j === i ? { ...x, username: e.target.value } : x)))}
/>
<Input
placeholder="password (empty = agent generates)"
value={c.password}
onChange={(e) => setCredentials((prev) => prev.map((x, j) => (j === i ? { ...x, password: e.target.value } : x)))}
/>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Remove account"
onClick={() => setCredentials((prev) => prev.filter((_, j) => j !== i))}
>
<TrashIcon />
</Button>
<Input
className="sm:col-span-4"
placeholder="note (optional, e.g. program alias account)"
value={c.note ?? ""}
onChange={(e) => setCredentials((prev) => prev.map((x, j) => (j === i ? { ...x, note: e.target.value } : x)))}
/>
</div>
))}
</div>
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
<SheetClose asChild>
<Button variant="outline">Cancel</Button>
+8
View File
@@ -16,6 +16,8 @@ import type {
Asset,
AssetInterceptRule,
AssetInterceptRuleInput,
TaskCredential,
TaskCredentialInput,
Audit,
BatchCategoryItem,
BatchControlItem,
@@ -260,6 +262,7 @@ export const api = {
planHeartbeatSeconds?: number;
coverageEnabled?: boolean;
interceptRules?: AssetInterceptRuleInput[];
credentials?: TaskCredentialInput[];
}) =>
post<Task>("/tasks", {
name: input.name ?? "",
@@ -274,12 +277,17 @@ export const api = {
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = the backend normalizes to the default 600 (10min)
coverage_enabled: input.coverageEnabled ?? true, // on by default; false disables the asset coverage feature
intercept_rules: input.interceptRules ?? [], // task-level asset interception rules
credentials: input.credentials ?? [], // test accounts declared at creation; an empty password = the agent generates and records it (#3)
}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch<Task>(`/tasks/${id}`, input),
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
taskCredentials: (id: string) => get<TaskCredential[]>(`/tasks/${id}/credentials`).then((r) => arr(r)),
addTaskCredential: (id: string, input: TaskCredentialInput) =>
post<TaskCredential>(`/tasks/${id}/credentials`, input),
deleteTaskCredential: (id: string, cid: number) => del(`/tasks/${id}/credentials/${cid}`),
renameTaskCategory: (id: number, name: string) => patch<TaskCategory>(`/task-categories/${id}`, { name }),
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
updateTaskCategory: (taskId: string, categoryId?: number) =>
+19
View File
@@ -1293,6 +1293,25 @@ export interface AssetInterceptRuleInput {
enabled: boolean;
}
// Task credentials (#3): per-task test accounts. An empty password on a user-declared
// entry means "agent generates": the agent registers that account and records it back.
export type TaskCredentialInput = {
domain: string;
username: string;
password: string;
note?: string;
};
export type TaskCredential = TaskCredentialInput & {
id: number;
task_id: number;
source: "user" | "agent";
note: string;
created_at: string;
updated_at: string;
};
export interface AssetInterceptRule {
id: number;
enabled: boolean;