diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..dfdb8b77
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.sh text eol=lf
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..22529caa
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,35 @@
+version: 2
+updates:
+ - package-ecosystem: gomod
+ directory: "/"
+ schedule:
+ interval: weekly
+ day: monday
+ time: "04:00"
+ timezone: Asia/Shanghai
+ open-pull-requests-limit: 10
+ labels:
+ - dependencies
+ - go
+ commit-message:
+ prefix: deps
+ groups:
+ golang-x:
+ patterns:
+ - "golang.org/x/*"
+ chainreactors:
+ patterns:
+ - "github.com/chainreactors/*"
+
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
+ day: monday
+ time: "04:30"
+ timezone: Asia/Shanghai
+ labels:
+ - dependencies
+ - github-actions
+ commit-message:
+ prefix: deps
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a8fcb36f..7049e80e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,30 +17,244 @@ concurrency:
cancel-in-progress: true
jobs:
- build:
+ # ── Fast gates (independent, no deps) ──────────────────────────
+
+ lint:
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Run golangci-lint
+ uses: golangci/golangci-lint-action@v9.2.1
+ with:
+ version: v2.12.2
+ args: --timeout=5m
+
+ tidy:
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Check go mod tidy
+ run: |
+ cp go.mod go.mod.orig
+ cp go.sum go.sum.orig
+ go mod tidy
+ if ! diff -q go.mod go.mod.orig >/dev/null 2>&1; then
+ echo "::error::go.mod is not tidy. Run 'go mod tidy' and commit the result."
+ diff go.mod.orig go.mod || true
+ exit 1
+ fi
+ if ! diff -q go.sum go.sum.orig >/dev/null 2>&1; then
+ echo "::error::go.sum is not tidy. Run 'go mod tidy' and commit the result."
+ diff go.sum.orig go.sum | head -30 || true
+ exit 1
+ fi
+
+ # ── Unit tests (depends on tidy) ──────────────────────────────
+
+ test:
+ runs-on: ubuntu-22.04
+ needs: tidy
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Generate embedded resources
+ run: go generate ./core/resources/...
+
+ - name: Run unit tests with coverage
+ run: |
+ go test -race -count=1 -timeout 5m \
+ -coverprofile=coverage.out \
+ -covermode=atomic \
+ ./...
+
+ - name: Display coverage summary
+ if: always()
+ run: |
+ if [ -f coverage.out ]; then
+ echo "### Total coverage"
+ go tool cover -func=coverage.out | tail -1
+ echo ""
+ echo "### Per-package coverage (top 20)"
+ go tool cover -func=coverage.out | grep -E '^[a-z]' | sort -t$'\t' -k3 -rn | head -20
+ fi
+
+ - name: Upload coverage artifact
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report
+ path: coverage.out
+ retention-days: 14
+
+ # ── Proxy & TMux tool tests (depends on tidy) ─────────────────
+
+ tool-tests:
+ runs-on: ubuntu-22.04
+ needs: tidy
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Run proxy tool tests
+ run: |
+ go test -race -count=1 -timeout 5m -v \
+ ./pkg/tools/proxy/
+
+ - name: Run tmux command tests
+ run: |
+ go test -race -count=1 -timeout 5m -v \
+ -run 'Tmux|BashProxy' \
+ ./pkg/commands/
+
+ - name: Run PTY interactive session tests
+ run: |
+ go test -race -count=1 -timeout 5m -v \
+ -run 'MultiRound|SendCtrlC' \
+ ./pkg/agent/tmux/
+
+ - name: Run agent tmux integration tests
+ run: |
+ go test -race -count=1 -timeout 5m -v \
+ -run 'AgentTmux' \
+ ./pkg/agent/
+
+ # ── Generated templates tests (depends on tidy) ───────────────
+
+ generated-test:
runs-on: ubuntu-22.04
+ needs: tidy
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- - name: Install upx
- run: sudo apt install upx -y
- continue-on-error: true
+ - name: Run go generate for templates
+ run: go generate ./core/resources/...
- - name: Compile release targets
- uses: goreleaser/goreleaser-action@v6
+ - name: Run resources tests
+ run: |
+ go test -race -count=1 -timeout 5m \
+ ./core/resources/...
+
+ # ── E2E tests (depends on test) ───────────────────────────────
+
+ e2e:
+ runs-on: ubuntu-22.04
+ needs: test
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
with:
- distribution: goreleaser
- version: '~> v2'
- args: build --snapshot --clean
- env:
- GOPATH: "/home/runner/go"
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Run e2e tests
+ run: |
+ go test -race -count=1 -timeout 10m \
+ -tags e2e \
+ -v \
+ ./pkg/e2e/
+
+ # ── Build (depends on test, 3 parallel profiles) ──────────────
+
+ build:
+ runs-on: ubuntu-22.04
+ needs: test
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - id: standard
+ main: ./cmd/aiscan
+ tags: "forceposix emptytemplates noembed osusergo netgo"
+ generate: true
+ - id: full
+ main: ./cmd/aiscan
+ tags: "forceposix emptytemplates noembed osusergo netgo full sqlite"
+ generate: true
+ - id: agent
+ main: ./cmd/agent
+ tags: "forceposix emptytemplates noembed osusergo netgo"
+ generate: false
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Generate embedded resources
+ if: matrix.generate
+ run: go generate ./core/resources
+
+ - name: Build all platforms (${{ matrix.id }})
+ run: |
+ for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
+ IFS='/' read -r goos goarch <<< "$target"
+ echo " compile ${goos}/${goarch}"
+ CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
+ go build -trimpath -tags "${{ matrix.tags }}" -ldflags "-s -w" \
+ -buildvcs=false -o "dist/${{ matrix.id }}_${goos}_${goarch}" ${{ matrix.main }}
+ done
+ ls -lh dist/
diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml
index 06a61be9..36a46547 100644
--- a/.github/workflows/go-release.yml
+++ b/.github/workflows/go-release.yml
@@ -23,19 +23,27 @@ concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
cancel-in-progress: false
+# ---------------------------------------------------------------------------
+# Three parallel build jobs (standard / full / agent) → one release job
+# ---------------------------------------------------------------------------
+
jobs:
- goreleaser:
+
+ # ── Resolve the tag once, share with all jobs ───────────────────
+ prepare:
runs-on: ubuntu-22.04
+ outputs:
+ tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.target || github.ref }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- submodules: recursive
- - name: Prepare release tag
+ - name: Resolve release tag
+ id: tag
shell: bash
run: |
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
@@ -50,7 +58,6 @@ jobs:
echo "Invalid release tag: ${TAG}" >&2
exit 1
fi
-
if [[ "${TAG}" == *nightly* ]]; then
echo "Nightly tags must be released by nightly.yml: ${TAG}" >&2
exit 1
@@ -59,13 +66,12 @@ jobs:
git fetch --force --tags origin
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
- git checkout --force "refs/tags/${TAG}"
+ echo "Tag ${TAG} exists"
else
if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then
echo "Tag ${TAG} was expected to exist for a tag push event" >&2
exit 1
fi
-
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout --force "${TARGET}"
@@ -73,25 +79,183 @@ jobs:
git push origin "refs/tags/${TAG}"
fi
- echo "RELEASE_TAG=${TAG}" >> "${GITHUB_ENV}"
- echo "GORELEASER_CURRENT_TAG=${TAG}" >> "${GITHUB_ENV}"
+ echo "tag=${TAG}" >> "${GITHUB_OUTPUT}"
+
+ # ── Parallel build matrix ───────────────────────────────────────
+ build:
+ needs: prepare
+ runs-on: ubuntu-22.04
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - id: aiscan
+ profile: standard
+ main: ./cmd/aiscan
+ binary: aiscan
+ tags: "forceposix emptytemplates noembed osusergo netgo"
+ generate: true
+ - id: aiscan-full
+ profile: full
+ main: ./cmd/aiscan
+ binary: aiscan-full
+ tags: "forceposix emptytemplates noembed osusergo netgo full sqlite"
+ generate: true
+ - id: aiscan-agent
+ profile: agent
+ main: ./cmd/agent
+ binary: aiscan-agent
+ tags: "forceposix emptytemplates noembed osusergo netgo"
+ generate: false
+
+ env:
+ GORELEASER_CURRENT_TAG: ${{ needs.prepare.outputs.tag }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ ref: refs/tags/${{ needs.prepare.outputs.tag }}
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+ submodules: recursive
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
+ - name: Generate embedded resources
+ if: matrix.generate
+ run: go generate ./core/resources
+
+ - name: Warm build cache
+ run: CGO_ENABLED=0 go build -tags "${{ matrix.tags }}" ${{ matrix.main }}/...
+
- name: Install upx
run: sudo apt install upx -y
continue-on-error: true
- - name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v6
+ - name: Cross-compile
+ shell: bash
+ run: |
+ set -euo pipefail
+ TARGETS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64"
+ TAGS="${{ matrix.tags }}"
+ BINARY="${{ matrix.binary }}"
+ MAIN="${{ matrix.main }}"
+ OUTDIR="dist/build"
+ mkdir -p "${OUTDIR}"
+
+ for target in $TARGETS; do
+ IFS='/' read -r goos goarch <<< "$target"
+ suffix=""; [[ "$goos" == "windows" ]] && suffix=".exe"
+ out="${OUTDIR}/${BINARY}_${goos}_${goarch}${suffix}"
+ echo " compiling ${goos}/${goarch} → ${out}"
+ CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
+ go build -trimpath -tags "$TAGS" -ldflags "-s -w" -buildvcs=false \
+ -o "$out" "$MAIN"
+ done
+
+ echo "=== Binaries ==="
+ ls -lh "${OUTDIR}/"
+
+ - name: Compress with UPX
+ continue-on-error: true
+ shell: bash
+ run: |
+ for f in dist/build/*_linux_amd64 dist/build/*_windows_amd64.exe; do
+ [ -f "$f" ] || continue
+ echo "upx: $f"
+ upx --best --lzma "$f" || true
+ done
+
+ - name: Package archives
+ shell: bash
+ run: |
+ set -euo pipefail
+ BINARY="${{ matrix.binary }}"
+ ARCHDIR="dist/archives"
+ mkdir -p "${ARCHDIR}"
+
+ for f in dist/build/${BINARY}_*; do
+ [ -f "$f" ] || continue
+ base=$(basename "$f")
+ # strip .exe for archive name
+ archive_name="${base%.exe}"
+ zipfile="${ARCHDIR}/${archive_name}.zip"
+ echo " zip: ${zipfile}"
+
+ # rename binary inside zip to just the binary name (+ .exe if windows)
+ inner_name="${BINARY}"
+ [[ "$base" == *.exe ]] && inner_name="${BINARY}.exe"
+
+ tmpdir=$(mktemp -d)
+ cp "$f" "${tmpdir}/${inner_name}"
+ cp README.md "${tmpdir}/" 2>/dev/null || true
+ if [[ "$BINARY" != "aiscan-agent" ]] && [ -d docs ]; then
+ cp -r docs "${tmpdir}/" 2>/dev/null || true
+ fi
+ (cd "$tmpdir" && zip -r - .) > "$zipfile"
+ rm -rf "$tmpdir"
+ done
+
+ echo "=== Archives ==="
+ ls -lh "${ARCHDIR}/"
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v7
+ with:
+ name: release-${{ matrix.profile }}
+ path: dist/archives/*.zip
+ retention-days: 1
+
+ # ── Publish release ─────────────────────────────────────────────
+ release:
+ needs: [prepare, build]
+ runs-on: ubuntu-22.04
+ env:
+ TAG: ${{ needs.prepare.outputs.tag }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
with:
- distribution: goreleaser
- version: '~> v2'
- args: release --clean
+ ref: refs/tags/${{ needs.prepare.outputs.tag }}
+ fetch-depth: 0
+
+ - name: Download all artifacts
+ uses: actions/download-artifact@v8
+ with:
+ path: dist/release
+ merge-multiple: true
+
+ - name: Generate checksums
+ run: |
+ cd dist/release
+ sha256sum *.zip > aiscan_checksums.txt
+ cat aiscan_checksums.txt
+
+ - name: Generate changelog
+ id: changelog
+ run: |
+ prev_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed -n '2p' || echo "")
+ if [ -n "$prev_tag" ]; then
+ git log --pretty=format:"- %s" "${prev_tag}..${TAG}" --no-merges | grep -v "^- ${TAG}$" | grep -v "^- docs" > /tmp/changelog.md || true
+ else
+ git log --pretty=format:"- %s" -20 --no-merges > /tmp/changelog.md || true
+ fi
+ echo "path=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
+
+ - name: Create or update release
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GOPATH: "/home/runner/go"
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # Delete existing release if any (replace mode)
+ gh release delete "${TAG}" --yes 2>/dev/null || true
+
+ gh release create "${TAG}" \
+ --title "${TAG}" \
+ --notes-file "${{ steps.changelog.outputs.path }}" \
+ --draft \
+ dist/release/*
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 8a6f3733..fb216f59 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -1,9 +1,8 @@
name: nightly
on:
- push:
- branches:
- - master
+ schedule:
+ - cron: '0 16 * * *' # UTC 16:00 = CST 00:00
workflow_dispatch:
permissions:
@@ -14,7 +13,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -28,7 +27,18 @@ jobs:
echo "GORELEASER_CURRENT_TAG=v0.0.0-nightly.$DATE" >> $GITHUB_ENV
echo "RELEASE_NAME=Nightly $DATE" >> $GITHUB_ENV
- - name: Create or update nightly tag
+ - name: Delete old nightly releases
+ run: |
+ gh release list --limit 50 --json tagName,isPrerelease \
+ | jq -r '.[] | select(.tagName | startswith("v0.0.0-nightly")) | .tagName' \
+ | while read tag; do
+ echo "Deleting release $tag"
+ gh release delete "$tag" --yes --cleanup-tag || true
+ done
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create nightly tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -36,7 +46,7 @@ jobs:
git push --force origin "$TAG"
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
@@ -46,7 +56,7 @@ jobs:
continue-on-error: true
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v6
+ uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: '~> v2'
@@ -54,3 +64,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GOPATH: "/home/runner/go"
+
+ - name: Publish release (remove draft)
+ run: gh release edit "$TAG" --draft=false --prerelease
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 7a20bbc0..ce09a83c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,8 +15,16 @@
# vendor/
.idea/
bin/
+dist/
+.tmp/
*.stat
*.db
*.db-*
-spray
-/pkg/scanner/resources/template.go
+*.sock.lock
+aiscan
+/spray
+out/
+scan_results.jsonl
+pw_driver_bin
+node_modules/
+community.yaml
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 00000000..64d5b419
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,84 @@
+version: "2"
+run:
+ modules-download-mode: readonly
+linters:
+ default: none
+ enable:
+ - bodyclose
+ - durationcheck
+ - errcheck
+ - gosec
+ - govet
+ - ineffassign
+ - misspell
+ - nilerr
+ - staticcheck
+ - unused
+ settings:
+ errcheck:
+ check-type-assertions: true
+ check-blank: false
+ exclude-functions:
+ - io.Close
+ - (*os/exec.Cmd).Process.Kill
+ - (*os.File).Close
+ - (io.Closer).Close
+ gosec:
+ excludes:
+ - G104
+ - G304
+ - G204
+ - G301
+ - G302
+ - G306
+ - G703
+ confidence: medium
+ govet:
+ disable:
+ - fieldalignment
+ - shadow
+ enable-all: true
+ misspell:
+ locale: US
+ staticcheck:
+ checks:
+ - all
+ - -QF1001
+ - -QF1012
+ - -ST1000
+ - -ST1003
+ - -ST1005
+ - -ST1016
+ exclusions:
+ generated: lax
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+ rules:
+ - linters:
+ - errcheck
+ - gosec
+ path: _test\.go
+ - linters:
+ - errcheck
+ path: pkg/command/bash\.go
+ text: Error return value of.*Kill|Signal|Close
+ paths:
+ - template\.go$
+ - vendor
+ - templates
+ - third_party$
+ - builtin$
+ - examples$
+issues:
+ max-issues-per-linter: 0
+ max-same-issues: 0
+formatters:
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/.goreleaser-community.yml b/.goreleaser-community.yml
new file mode 100644
index 00000000..eb990beb
--- /dev/null
+++ b/.goreleaser-community.yml
@@ -0,0 +1,125 @@
+version: 2
+
+project_name: aiscan-community
+
+before:
+ hooks: []
+
+builds:
+ - id: aiscan
+ main: ./cmd/aiscan
+ binary: "aiscan"
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - darwin
+ - windows
+ goarch:
+ - amd64
+ - arm64
+ ignore:
+ - goos: windows
+ goarch: arm64
+ flags:
+ - -trimpath
+ tags:
+ - forceposix
+ - emptytemplates
+ - noembed
+ - osusergo
+ - netgo
+ ldflags:
+ - >-
+ -s -w
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultProvider=deepseek'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultAPIKey={{ .Env.COMMUNITY_LLM_KEY }}'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultModel=deepseek-chat'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultCyberhubURL={{ .Env.COMMUNITY_CYBERHUB_URL }}'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultCyberhubKey={{ .Env.COMMUNITY_CYBERHUB_KEY }}'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultCyberhubMode=merge'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultVerify=auto'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultTavilyKeys={{ .Env.COMMUNITY_TAVILY_KEYS }}'
+ asmflags:
+ - all=-trimpath={{.Env.GOPATH}}
+ gcflags:
+ - all=-trimpath={{.Env.GOPATH}}
+
+ - id: aiscan-agent
+ main: ./cmd/agent
+ binary: "aiscan-agent"
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - darwin
+ - windows
+ goarch:
+ - amd64
+ - arm64
+ ignore:
+ - goos: windows
+ goarch: arm64
+ flags:
+ - -trimpath
+ tags:
+ - forceposix
+ - emptytemplates
+ - noembed
+ - osusergo
+ - netgo
+ ldflags:
+ - >-
+ -s -w
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultProvider=deepseek'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultAPIKey={{ .Env.COMMUNITY_LLM_KEY }}'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultModel=deepseek-chat'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultCyberhubURL={{ .Env.COMMUNITY_CYBERHUB_URL }}'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultCyberhubKey={{ .Env.COMMUNITY_CYBERHUB_KEY }}'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultCyberhubMode=merge'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultVerify=auto'
+ -X 'github.com/chainreactors/aiscan/core/config.DefaultTavilyKeys={{ .Env.COMMUNITY_TAVILY_KEYS }}'
+ asmflags:
+ - all=-trimpath={{.Env.GOPATH}}
+ gcflags:
+ - all=-trimpath={{.Env.GOPATH}}
+
+upx:
+ - enabled: true
+ goos: [linux, windows]
+ goarch:
+ - amd64
+
+archives:
+ - id: aiscan
+ builds: [aiscan]
+ name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
+ formats:
+ - zip
+ files:
+ - none*
+
+ - id: aiscan-agent
+ builds: [aiscan-agent]
+ name_template: "{{ .ProjectName }}-agent_{{ .Os }}_{{ .Arch }}"
+ formats:
+ - zip
+ files:
+ - none*
+
+checksum:
+ name_template: "{{ .ProjectName }}_checksums.txt"
+
+snapshot:
+ version_template: "{{ incpatch .Version }}-community"
+
+changelog:
+ sort: desc
+ filters:
+ exclude:
+ - '^MERGE'
+ - "{{ .Tag }}"
+ - "^docs"
+
+release:
+ disable: true
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 44717da3..01ddfef3 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -4,7 +4,7 @@ project_name: aiscan
before:
hooks:
- - go generate ./pkg/scanner/resources
+ - go generate ./core/resources
builds:
- id: aiscan
@@ -37,6 +37,68 @@ builds:
gcflags:
- all=-trimpath={{.Env.GOPATH}}
+ - id: aiscan-full
+ main: ./cmd/aiscan
+ binary: "{{ .ProjectName }}-full"
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - darwin
+ - windows
+ goarch:
+ - amd64
+ - arm64
+ ignore:
+ - goos: windows
+ goarch: arm64
+ flags:
+ - -trimpath
+ tags:
+ - forceposix
+ - emptytemplates
+ - noembed
+ - osusergo
+ - netgo
+ - full
+ - sqlite
+ ldflags:
+ - -s -w
+ asmflags:
+ - all=-trimpath={{.Env.GOPATH}}
+ gcflags:
+ - all=-trimpath={{.Env.GOPATH}}
+
+ - id: aiscan-agent
+ main: ./cmd/agent
+ binary: "{{ .ProjectName }}-agent"
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - darwin
+ - windows
+ goarch:
+ - amd64
+ - arm64
+ ignore:
+ - goos: windows
+ goarch: arm64
+ flags:
+ - -trimpath
+ tags:
+ - forceposix
+ - emptytemplates
+ - noembed
+ - osusergo
+ - netgo
+ ldflags:
+ - -s -w
+ asmflags:
+ - all=-trimpath={{.Env.GOPATH}}
+ gcflags:
+ - all=-trimpath={{.Env.GOPATH}}
+
upx:
- enabled: true
goos: [linux, windows]
@@ -44,9 +106,31 @@ upx:
- amd64
archives:
- - name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
+ - id: aiscan
+ builds: [aiscan]
+ name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
+ formats:
+ - zip
+ files:
+ - src: README.md
+ - src: docs/*
+
+ - id: aiscan-full
+ builds: [aiscan-full]
+ name_template: "{{ .ProjectName }}-full_{{ .Os }}_{{ .Arch }}"
+ formats:
+ - zip
+ files:
+ - src: README.md
+ - src: docs/*
+
+ - id: aiscan-agent
+ builds: [aiscan-agent]
+ name_template: "{{ .ProjectName }}-agent_{{ .Os }}_{{ .Arch }}"
formats:
- - binary
+ - zip
+ files:
+ - src: README.md
checksum:
name_template: "{{ .ProjectName }}_checksums.txt"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..fe6b9036
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,662 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
+
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..2c56cafc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,238 @@
+
+
+
aiscan
+ Agentic Security Scanner — AI-driven reconnaissance meets deterministic scanning
+ Preview — APIs and features may change between releases
+
+
+
+
+
+
+
+
+
+
+
+ 中文文档
+
+
+---
+
+**aiscan** combines LLM agents with traditional security scanning engines. Three modes: **Scan** (deterministic pipeline, optional AI assist), **Agent** (natural-language autonomous assessment), **IOA** (multi-agent distributed collaboration).
+
+> **Use only on explicitly authorized targets. Unauthorized use is illegal.**
+
+## Quick Start
+
+```bash
+# No LLM needed — one-line scan
+aiscan scan -i 192.168.1.0/24
+
+# With LLM — one-line agent
+aiscan agent --base-url "https://api.deepseek.com" --api-key "sk-..." --model deepseek-chat \
+ -p "scan targets and check for high-risk vulnerabilities" -i 192.168.1.0/24
+```
+
+## Install
+
+### Download Binary
+
+From [GitHub Releases](https://github.com/chainreactors/aiscan/releases/latest):
+
+| Edition | Description |
+| --- | --- |
+| **aiscan** | Standard — scan/agent/gogo/spray/zombie/neutron/arsenal |
+| **aiscan-full** | Full — adds playwright browser, passive recon, katana crawler |
+| **aiscan-agent** | Lightweight agent runtime, ideal for remote worker deployment |
+
+| OS | Arch | Standard | Full | Agent |
+| --- | --- | --- | --- | --- |
+| Linux | amd64 / arm64 | `aiscan_linux_amd64` | `aiscan-full_linux_amd64` | `aiscan-agent_linux_amd64` |
+| macOS | Intel / Apple Silicon | `aiscan_darwin_amd64` | `aiscan-full_darwin_arm64` | `aiscan-agent_darwin_arm64` |
+| Windows | amd64 | `aiscan_windows_amd64.exe` | `aiscan-full_windows_amd64.exe` | `aiscan-agent_windows_amd64.exe` |
+
+```bash
+# Linux
+curl -L -o aiscan https://github.com/chainreactors/aiscan/releases/latest/download/aiscan_linux_amd64
+chmod +x aiscan && sudo mv aiscan /usr/local/bin/
+
+# macOS
+curl -L -o aiscan https://github.com/chainreactors/aiscan/releases/latest/download/aiscan_darwin_arm64
+chmod +x aiscan && sudo mv aiscan /usr/local/bin/
+
+# Windows (PowerShell)
+Invoke-WebRequest "https://github.com/chainreactors/aiscan/releases/latest/download/aiscan_windows_amd64.exe" -OutFile aiscan.exe
+```
+
+### Build from Source
+
+```bash
+git clone https://github.com/chainreactors/aiscan.git && cd aiscan
+
+go build -o aiscan ./cmd/aiscan # standard
+go build -tags full -o aiscan-full ./cmd/aiscan # full (playwright/katana/passive)
+```
+
+---
+
+## Features
+
+### Design
+
+- **Single binary, zero dependencies** — statically-linked, drop-in deployment
+- **Minimal agent core** — composable ~160-line loop; tools, retries, evaluation are plugged in, not hardcoded
+- **Plugin architecture** — adding a new tool is one file; heavy dependencies (playwright, katana) are compile-time optional
+- **Embedded skills** — each tool carries its own usage docs and tactical guidance, loaded by the agent on demand
+- **Scan + Agent unified** — the same engines drive both the deterministic pipeline and the autonomous agent
+
+### Scan — Deterministic Pipeline
+
+- Multi-stage auto-chaining: port discovery → web probing → weak credentials → POC detection — no LLM required
+- Optional AI-driven result verification, public CVE correlation, and dynamic testing
+- Quick mode for fast exposure mapping, full mode for deep crawl and extended coverage
+
+### Agent — Autonomous Security Assessment
+
+- Natural language tasks — the agent plans, scans, analyzes, and reports autonomously
+- Goal evaluation — an independent evaluator judges task completion and drives automatic retry
+- Interactive REPL with direct command execution
+- Multi-provider fallback for resilience
+
+### [IOA](https://github.com/chainreactors/ioa) — Multi-Agent Collaboration
+
+- Shared message spaces for distributed agent coordination
+- Worker mode for persistent task listening
+- Built-in IOA server with token authentication
+- See: [Design](https://github.com/chainreactors/ioa/blob/main/docs/design.md) | [CLI](https://github.com/chainreactors/ioa/blob/main/docs/cli.md) | [Extension](https://github.com/chainreactors/ioa/blob/main/docs/extension.md)
+
+### Built-in Toolset
+
+**Scanners**
+- [gogo](https://github.com/chainreactors/gogo) — port, service, and banner discovery
+- [spray](https://github.com/chainreactors/spray) — web probing, fingerprinting, path fuzzing
+- [zombie](https://github.com/chainreactors/zombie) — credential testing
+- [neutron](https://github.com/chainreactors/neutron) — template-based POC execution
+- [cyberhub](https://github.com/chainreactors/fingers) — fingerprint and POC association query
+
+**Browser & Recon** (full edition)
+- playwright — headless Chromium sessions, screenshots, network capture
+- katana — web crawler with standard/headless/hybrid engines
+- passive — cyberspace search (FOFA, Hunter, Shodan)
+
+**Utilities**
+- tmux — background task sessions with incremental output delivery
+- arsenal — security tool package manager ([crtm](https://github.com/chainreactors/crtm)), one-command install
+- proxy — multi-protocol proxy chain (trojan/vless/anytls/hy2/ss)
+- web_search / fetch — CVE search and URL fetching
+
+---
+
+## Usage
+
+### Scan Mode
+
+```bash
+aiscan scan -i 192.168.1.0/24 # quick scan
+aiscan scan -i 192.168.1.0/24 --mode full # full scan
+aiscan scan -i http://target.example --verify=high --sniper # AI-enhanced
+aiscan scan -i http://target.example --mode full --deep --report # full + deep + report
+```
+
+### Agent Mode
+
+```bash
+# One-shot task
+aiscan agent -p "scan and find web vulnerabilities" -i 192.168.1.0/24
+
+# With goal evaluation
+aiscan agent -p "full scan" -i http://target.example -e "find all open ports with service fingerprints"
+
+# Interactive REPL
+aiscan agent
+```
+
+### IOA Mode
+
+```bash
+# Start IOA server
+aiscan ioa serve --ioa-url http://0.0.0.0:8765
+
+# Start IOA worker
+aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \
+ -p "scan assigned targets and report findings"
+```
+
+### LLM Configuration
+
+```bash
+# Environment variable
+export OPENAI_API_KEY="sk-..."
+
+# CLI arguments
+aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key sk-... --model deepseek-chat
+```
+
+Config file `~/.config/aiscan/config.yaml`:
+
+```yaml
+llm:
+ provider: openai
+ api_key: sk-...
+ model: gpt-4o
+```
+
+---
+
+## Documentation
+
+| Doc | Description |
+| --- | --- |
+| [Scan Mode](docs/scan.md) | Pipeline, AI enhancements, output formats |
+| [Agent Mode](docs/agent.md) | Toolset, Goal Evaluation, REPL |
+| [IOA](docs/ioa.md) | Multi-agent architecture, Space/Node/Message model |
+| [Reference](docs/reference.md) | Configuration, providers, flags, scanner usage, FAQ |
+| [Changelog](docs/changelog.md) | Version history |
+
+## Contributing
+
+1. Fork this repository
+2. Create a feature branch (`git checkout -b feature/xxx`)
+3. Commit your changes (`git commit -m 'feat: add xxx'`)
+4. Push to the branch (`git push origin feature/xxx`)
+5. Create a Pull Request
+
+## Disclaimer
+
+1. This tool is intended for **authorized security testing and research purposes only**. If you need to test its capabilities, please set up your own lab environment.
+2. Before using this tool for any scanning, you must ensure compliance with local laws and regulations and obtain **sufficient authorization. Do not scan unauthorized targets.**
+3. If you engage in any illegal activity while using this tool, you shall bear all consequences yourself. We assume no legal or joint liability.
+4. Before installing and using this tool, please **carefully read and fully understand all terms**. Limitation and disclaimer clauses may be highlighted for your attention.
+5. Unless you have fully read, understood, and accepted all terms of this agreement, please do not install or use this tool. Your use or any other express or implied acceptance constitutes your agreement to be bound by these terms.
+
+## License
+
+This project is licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0)](LICENSE).
+
+## Links
+
+- [chainreactors](https://github.com/chainreactors) — Organization
+- [IOA](https://github.com/chainreactors/ioa) — Internet of Agents
+- [gogo](https://github.com/chainreactors/gogo) — Port & service discovery
+- [spray](https://github.com/chainreactors/spray) — Web probing & fingerprinting
+- [zombie](https://github.com/chainreactors/zombie) — Credential testing
+- [neutron](https://github.com/chainreactors/neutron) — Template-based POC engine
+- [fingers](https://github.com/chainreactors/fingers) — Fingerprint rule engine
+- [sdk](https://github.com/chainreactors/sdk) — Scanner SDK (gogo/spray/zombie core)
+- [proxyclient](https://github.com/chainreactors/proxyclient) — Multi-protocol proxy client
+- [crtm](https://github.com/chainreactors/crtm) — Security tool package registry
+- [utils](https://github.com/chainreactors/utils) — Shared utilities & PTY manager
+- [parsers](https://github.com/chainreactors/parsers) — Protocol & data parsers
+
+---
+
+
+
+
+
+
diff --git a/README_CN.md b/README_CN.md
new file mode 100644
index 00000000..d63dc171
--- /dev/null
+++ b/README_CN.md
@@ -0,0 +1,240 @@
+
+
+
aiscan
+ Agentic Security Scanner — AI 驱动的侦察与确定性扫描融合
+ Preview — 本项目处于早期预览阶段,API 和功能可能随版本变更
+
+
+
+
+
+
+
+
+
+
+
+ English
+
+
+---
+
+**aiscan** 融合 LLM agent 与传统安全扫描引擎。三种模式:**Scan**(确定性流水线扫描,AI 可选辅助)、**Agent**(自然语言驱动的自主安全评估)、**IOA**(多 agent 分布式协作)。
+
+> **请只在明确授权的目标上使用,未经授权的使用属于违法行为。**
+
+## 快速开始
+
+```bash
+# 无需 LLM,一行启动扫描
+aiscan scan -i 192.168.1.0/24
+
+# 有 LLM,一行启动 agent
+aiscan agent --base-url "https://api.deepseek.com" --api-key "sk-..." --model deepseek-chat \
+ -p "扫描目标并检查高风险漏洞" -i 192.168.1.0/24
+```
+
+## 安装
+
+### 下载二进制
+
+从 [GitHub Releases](https://github.com/chainreactors/aiscan/releases/latest) 下载:
+
+| 版本 | 说明 |
+| --- | --- |
+| **aiscan** | 标准版 — scan/agent/gogo/spray/zombie/neutron/arsenal |
+| **aiscan-full** | 完整版 — 额外包含 playwright 浏览器、passive recon、katana 爬虫 |
+| **aiscan-agent** | 轻量 agent 版 — 仅 agent 运行时,适合部署为远程 worker |
+
+| 系统 | 架构 | 标准版 | 完整版 | Agent 版 |
+| --- | --- | --- | --- | --- |
+| Linux | amd64 / arm64 | `aiscan_linux_amd64` | `aiscan-full_linux_amd64` | `aiscan-agent_linux_amd64` |
+| macOS | Intel / Apple Silicon | `aiscan_darwin_amd64` | `aiscan-full_darwin_arm64` | `aiscan-agent_darwin_arm64` |
+| Windows | amd64 | `aiscan_windows_amd64.exe` | `aiscan-full_windows_amd64.exe` | `aiscan-agent_windows_amd64.exe` |
+
+```bash
+# Linux
+curl -L -o aiscan https://github.com/chainreactors/aiscan/releases/latest/download/aiscan_linux_amd64
+chmod +x aiscan && sudo mv aiscan /usr/local/bin/
+
+# macOS
+curl -L -o aiscan https://github.com/chainreactors/aiscan/releases/latest/download/aiscan_darwin_arm64
+chmod +x aiscan && sudo mv aiscan /usr/local/bin/
+
+# Windows (PowerShell)
+Invoke-WebRequest "https://github.com/chainreactors/aiscan/releases/latest/download/aiscan_windows_amd64.exe" -OutFile aiscan.exe
+```
+
+### 从源码构建
+
+```bash
+git clone https://github.com/chainreactors/aiscan.git && cd aiscan
+
+go build -o aiscan ./cmd/aiscan # 标准版
+go build -tags full -o aiscan-full ./cmd/aiscan # 完整版(含 playwright/katana/passive)
+```
+
+---
+
+## Features
+
+### 设计理念
+
+- **单文件、零依赖** — 静态链接,开箱即用
+- **极简 agent 内核** — 可组合的 ~160 行循环;工具、重试、评估均为插拔式,非硬编码
+- **插件式架构** — 新增工具只需一个文件;重依赖(playwright、katana)编译期可选
+- **内嵌 Skill** — 每个工具自带用法文档和战术指导,agent 按需加载
+- **Scan + Agent 统一** — 同一套引擎驱动确定性流水线和自主 agent
+
+### Scan — 确定性扫描流水线
+
+- 多阶段自动串联:端口发现 → Web 探测 → 弱口令检测 → POC 检测,无需 LLM
+- 可选 AI 驱动的结果验证、公开漏洞关联和动态测试
+- quick 模式快速暴露面发现,full 模式深度爬取和扩展覆盖
+
+### Agent — 自主安全评估
+
+- 自然语言描述任务,agent 自主规划、扫描、分析、输出结论
+- Goal Evaluation — 独立评估器判定任务完成度,自动驱动重试
+- 交互式 REPL,支持直接执行命令
+- 多 provider 容错降级
+
+### [IOA](https://github.com/chainreactors/ioa) — 多 Agent 协作
+
+- 共享消息空间实现分布式 agent 协调
+- Worker 模式持续监听任务
+- 内置 IOA server,支持 token 认证
+- 参阅:[设计理念](https://github.com/chainreactors/ioa/blob/main/docs/design_zh.md) | [CLI 文档](https://github.com/chainreactors/ioa/blob/main/docs/cli_zh.md) | [扩展开发](https://github.com/chainreactors/ioa/blob/main/docs/extension_zh.md)
+
+### 内置工具集
+
+**扫描器**
+- [gogo](https://github.com/chainreactors/gogo) — 端口、服务、banner 发现
+- [spray](https://github.com/chainreactors/spray) — Web 探测、指纹识别、路径 fuzz
+- [zombie](https://github.com/chainreactors/zombie) — 弱口令检测
+- [neutron](https://github.com/chainreactors/neutron) — 模板化 POC 执行
+- [cyberhub](https://github.com/chainreactors/fingers) — 指纹和 POC 关联查询
+
+**浏览器 & 侦察**(完整版)
+- playwright — headless Chromium 会话、截图、网络捕获
+- katana — Web 爬虫,支持 standard/headless/hybrid 引擎
+- passive — 网络空间搜索(FOFA、Hunter、Shodan)
+
+**辅助工具**
+- tmux — 后台任务会话,增量输出自动推送
+- arsenal — 安全工具包管理器([crtm](https://github.com/chainreactors/crtm)),一键安装
+- proxy — 多协议代理链(trojan/vless/anytls/hy2/ss)
+- web_search / fetch — CVE 搜索和 URL 抓取
+
+---
+
+## 使用示例
+
+### Scan 模式
+
+```bash
+aiscan scan -i 192.168.1.0/24 # 快速扫描
+aiscan scan -i 192.168.1.0/24 --mode full # 完整扫描
+aiscan scan -i http://target.example --verify=high --sniper # AI 增强
+aiscan scan -i http://target.example --mode full --deep --report # 完整 + 深度 + 报告
+```
+
+### Agent 模式
+
+```bash
+# 一次性任务
+aiscan agent -p "扫描目标,发现所有 Web 服务并检查高风险漏洞" -i 192.168.1.0/24
+
+# 带 Goal Evaluation
+aiscan agent -p "全面扫描目标" -i http://target.example -e "发现所有开放端口并输出服务指纹"
+
+# 交互式 REPL
+aiscan agent
+```
+
+### IOA 模式
+
+```bash
+# 启动 IOA Server
+aiscan ioa serve --ioa-url http://0.0.0.0:8765
+
+# 启动 IOA worker
+aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \
+ -p "scan assigned targets and report findings"
+```
+
+### LLM 配置
+
+```bash
+# 环境变量
+export OPENAI_API_KEY="sk-..."
+
+# CLI 参数
+aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key sk-... --model deepseek-chat
+```
+
+配置文件 `~/.config/aiscan/config.yaml`:
+
+```yaml
+llm:
+ provider: openai
+ api_key: sk-...
+ model: gpt-4o
+```
+
+---
+
+## 文档
+
+| 文档 | 说明 |
+| --- | --- |
+| [Scan 模式详解](docs/scan.md) | 扫描流水线、AI 增强、输出格式 |
+| [Agent 模式详解](docs/agent.md) | Agent 工具集、Goal Evaluation、REPL |
+| [IOA 协作](docs/ioa.md) | 多 Agent 协作架构、Space/Node/Message 模型 |
+| [参考手册](docs/reference.md) | 配置、LLM Provider、全局参数、扫描器用法、FAQ |
+| [Changelog](docs/changelog.md) | 版本变更记录 |
+
+## 贡献
+
+欢迎提交 Issue 和 Pull Request。
+
+1. Fork 本仓库
+2. 创建功能分支 (`git checkout -b feature/xxx`)
+3. 提交更改 (`git commit -m 'feat: add xxx'`)
+4. 推送分支 (`git push origin feature/xxx`)
+5. 创建 Pull Request
+
+## 免责声明
+
+1. 本工具仅面向**合法授权**的企业安全建设行为及个人学习用途,如您需要测试本工具的可用性,请自行搭建靶机环境。
+2. 在使用本工具进行检测时,您应确保该行为符合当地的法律法规,并且已经取得了足够的授权。**请勿对非授权目标进行扫描。**
+3. 如您在使用本工具的过程中存在任何非法行为,您需自行承担相应后果,我们将不承担任何法律及连带责任。
+4. 在安装并使用本工具前,请您**务必审慎阅读、充分理解各条款内容**,限制、免责条款或者其他涉及您重大权益的条款可能会以加粗、加下划线等形式提示您重点注意。
+5. 除非您已充分阅读、完全理解并接受本协议所有条款,否则,请您不要安装并使用本工具。您的使用行为或者您以其他任何明示或者默示方式表示接受本协议的,即视为您已阅读并同意本协议的约束。
+
+## 许可证
+
+本项目使用 [GNU Affero General Public License v3.0 (AGPL-3.0)](LICENSE) 许可。
+
+## 链接
+
+- [chainreactors](https://github.com/chainreactors) — 组织
+- [IOA](https://github.com/chainreactors/ioa) — Internet of Agents 多 agent 协作协议
+- [gogo](https://github.com/chainreactors/gogo) — 端口和服务发现
+- [spray](https://github.com/chainreactors/spray) — Web 探测和指纹识别
+- [zombie](https://github.com/chainreactors/zombie) — 弱口令检测
+- [neutron](https://github.com/chainreactors/neutron) — 模板化 POC 引擎
+- [fingers](https://github.com/chainreactors/fingers) — 指纹规则引擎
+- [sdk](https://github.com/chainreactors/sdk) — 扫描器 SDK(gogo/spray/zombie 核心)
+- [proxyclient](https://github.com/chainreactors/proxyclient) — 多协议代理客户端
+- [crtm](https://github.com/chainreactors/crtm) — 安全工具包注册中心
+- [utils](https://github.com/chainreactors/utils) — 共享工具库 & PTY 管理器
+- [parsers](https://github.com/chainreactors/parsers) — 协议和数据解析器
+
+---
+
+
+
+
+
+
diff --git a/assets/logo.svg b/assets/logo.svg
new file mode 100644
index 00000000..ffea9b7a
--- /dev/null
+++ b/assets/logo.svg
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build.sh b/build.sh
new file mode 100755
index 00000000..b1960e58
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,330 @@
+#!/bin/bash
+
+# aiscan 构建脚本
+# 用法:
+# ./build.sh # 读取 config.yaml,编译多平台可执行文件
+# ./build.sh -g # 仅打印生成的 ldflags,不编译
+# ./build.sh -o linux/amd64 # 快速编译单一平台
+# ./build.sh -o "linux/amd64 darwin/arm64" # 编译指定平台
+# ./build.sh --config prod.yaml # 使用指定配置文件
+# ./build.sh --llm-model deepseek-chat # CLI 覆盖配置文件中的值
+# ./build.sh --embed # 嵌入扫描资源(不加 emptytemplates/noembed tag)
+# ./build.sh --ioa # 同时编译 ioa server 二进制
+
+set -euo pipefail
+
+# ─── 变量 ───────────────────────────────────────────────────────
+
+CONFIG_FILE="config.yaml"
+OSARCH=""
+EXTRA_TAGS=""
+OUTPUT_DIR="dist"
+GENERATE_ONLY=false
+EMBED_RESOURCES=false
+BUILD_IOA=false
+QUICK_TARGET=""
+PROFILE="mini"
+AISCAN_BIN="aiscan"
+
+# CLI 覆盖(优先级高于 config.yaml)
+OPT_PROVIDER=""
+OPT_BASE_URL=""
+OPT_API_KEY=""
+OPT_MODEL=""
+OPT_PROXY=""
+OPT_CYBERHUB_URL=""
+OPT_CYBERHUB_KEY=""
+OPT_CYBERHUB_MODE=""
+OPT_IOA_URL=""
+OPT_IOA_NODE_NAME=""
+OPT_IOA_SPACE=""
+OPT_VERIFY=""
+OPT_VERIFY_TIMEOUT=""
+OPT_TAVILY_KEYS=""
+
+MODULE="github.com/chainreactors/aiscan/core/config"
+
+DEFAULT_OSARCH="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64"
+
+# ─── YAML 解析 ──────────────────────────────────────────────────
+
+yaml_val() {
+ local file="$1" section="$2" key="$3"
+ [ -f "$file" ] || return 0
+ sed -n "/^${section}:/,/^[a-zA-Z_]/{
+ /^ ${key}:/{
+ s/^ ${key}:[[:space:]]*//
+ s/[[:space:]]#.*$//
+ s/\r$//
+ s/^\"//; s/\"$//
+ s/^'//; s/'$//
+ /^$/d
+ p
+ }
+ }" "$file" 2>/dev/null | head -1
+}
+
+# ─── 参数解析 ────────────────────────────────────────────────────
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --config|-c) CONFIG_FILE="$2"; shift 2 ;;
+ -o) OSARCH="$2"; shift 2 ;;
+ --tags) EXTRA_TAGS="$2"; shift 2 ;;
+ --output) OUTPUT_DIR="$2"; shift 2 ;;
+ -g|--ldflags) GENERATE_ONLY=true; shift ;;
+ --embed) EMBED_RESOURCES=true; shift ;;
+ --ioa) BUILD_IOA=true; shift ;;
+ --profile) PROFILE="$2"; shift 2 ;;
+ --llm-provider) OPT_PROVIDER="$2"; shift 2 ;;
+ --llm-base-url) OPT_BASE_URL="$2"; shift 2 ;;
+ --llm-api-key) OPT_API_KEY="$2"; shift 2 ;;
+ --llm-model) OPT_MODEL="$2"; shift 2 ;;
+ --llm-proxy) OPT_PROXY="$2"; shift 2 ;;
+ --cyberhub-url) OPT_CYBERHUB_URL="$2"; shift 2 ;;
+ --cyberhub-key) OPT_CYBERHUB_KEY="$2"; shift 2 ;;
+ --cyberhub-mode) OPT_CYBERHUB_MODE="$2"; shift 2 ;;
+ --ioa-url) OPT_IOA_URL="$2"; shift 2 ;;
+ --ioa-node-name) OPT_IOA_NODE_NAME="$2"; shift 2 ;;
+ --space) OPT_IOA_SPACE="$2"; shift 2 ;;
+ --verify) OPT_VERIFY="$2"; shift 2 ;;
+ --verify-timeout) OPT_VERIFY_TIMEOUT="$2"; shift 2 ;;
+ --tavily-keys) OPT_TAVILY_KEYS="$2"; shift 2 ;;
+ -h|--help)
+ cat <<'HELP'
+aiscan 构建脚本
+
+用法: ./build.sh [选项]
+
+配置:
+ --config, -c FILE 配置文件路径 (默认: config.yaml)
+ -g, --ldflags 仅打印生成的 ldflags,不编译
+
+构建:
+ -o OSARCH 目标平台,空格分隔 (默认: linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64)
+ --tags TAGS 额外 build tags,逗号分隔
+ --output DIR 输出目录 (默认: dist)
+ --embed 嵌入扫描资源(不加 emptytemplates/noembed tag)
+ --ioa 同时编译 ioa server 二进制
+ --profile PROFILE 构建配置: agent (~28MB), mini (默认, ~77MB), full (~123MB)
+
+LLM 覆盖(优先级高于 config.yaml):
+ --llm-provider NAME
+ --llm-base-url URL
+ --llm-api-key KEY
+ --llm-model NAME
+ --llm-proxy URL
+
+Cyberhub 覆盖:
+ --cyberhub-url URL
+ --cyberhub-key KEY
+ --cyberhub-mode MODE
+
+IOA 覆盖:
+ --ioa-url URL
+ --ioa-node-name NAME
+ --space NAME
+
+Web Search:
+ --tavily-keys KEYS Comma-separated Tavily API keys (rotation)
+
+扫描覆盖:
+ --verify MODE auto, off, low, medium, high, critical
+ --verify-timeout SEC
+
+示例:
+ ./build.sh # 读取 config.yaml 编译全平台
+ ./build.sh -o linux/amd64 # 快速编译单平台
+ ./build.sh --config prod.yaml -o linux/amd64 # 使用生产配置编译
+ ./build.sh --cyberhub-url http://10.0.0.1:9000 --cyberhub-key mykey
+ ./build.sh --llm-provider deepseek --llm-model deepseek-chat
+ ./build.sh --embed # 嵌入资源的完整构建
+ ./build.sh -g # 打印 ldflags(用于自定义构建命令)
+ ./build.sh --ioa -o linux/amd64 # 同时编译 ioa server
+ ./build.sh --profile agent -o linux/amd64 # agent 构建 (仅 agent REPL + bash, 无内置扫描器)
+ ./build.sh --profile full -o linux/amd64 # full 构建 (全部扫描器 + browser + recon + ioa)
+HELP
+ exit 0
+ ;;
+ *)
+ echo "未知选项: $1" >&2
+ echo "使用 -h 查看帮助" >&2
+ exit 1
+ ;;
+ esac
+done
+
+# ─── 读取配置 ────────────────────────────────────────────────────
+
+resolve() {
+ # resolve CLI_VALUE CONFIG_VALUE → 非空的那个(CLI 优先)
+ if [ -n "$1" ]; then echo "$1"; elif [ -n "$2" ]; then echo "$2"; fi
+}
+
+CFG_PROVIDER=$(resolve "$OPT_PROVIDER" "$(yaml_val "$CONFIG_FILE" llm provider)")
+CFG_BASE_URL=$(resolve "$OPT_BASE_URL" "$(yaml_val "$CONFIG_FILE" llm base_url)")
+CFG_API_KEY=$(resolve "$OPT_API_KEY" "$(yaml_val "$CONFIG_FILE" llm api_key)")
+CFG_MODEL=$(resolve "$OPT_MODEL" "$(yaml_val "$CONFIG_FILE" llm model)")
+CFG_SCANNER_PROXY=$(resolve "$OPT_PROXY" "$(yaml_val "$CONFIG_FILE" cyberhub proxy)")
+
+CFG_CYBERHUB_URL=$(resolve "$OPT_CYBERHUB_URL" "$(yaml_val "$CONFIG_FILE" cyberhub url)")
+CFG_CYBERHUB_KEY=$(resolve "$OPT_CYBERHUB_KEY" "$(yaml_val "$CONFIG_FILE" cyberhub key)")
+CFG_CYBERHUB_MODE=$(resolve "$OPT_CYBERHUB_MODE" "$(yaml_val "$CONFIG_FILE" cyberhub mode)")
+
+CFG_IOA_URL=$(resolve "$OPT_IOA_URL" "$(yaml_val "$CONFIG_FILE" ioa url)")
+CFG_IOA_NODE_NAME=$(resolve "$OPT_IOA_NODE_NAME" "$(yaml_val "$CONFIG_FILE" ioa node_name)")
+CFG_IOA_SPACE=$(resolve "$OPT_IOA_SPACE" "$(yaml_val "$CONFIG_FILE" ioa space)")
+
+CFG_VERIFY=$(resolve "$OPT_VERIFY" "$(yaml_val "$CONFIG_FILE" scan verify)")
+CFG_VERIFY_TIMEOUT=$(resolve "$OPT_VERIFY_TIMEOUT" "$(yaml_val "$CONFIG_FILE" scan verify_timeout)")
+
+CFG_TAVILY_KEYS=$(resolve "$OPT_TAVILY_KEYS" "$(yaml_val "$CONFIG_FILE" websearch tavily_keys)")
+
+# build 段仅从 config.yaml 读取(不做 CLI 覆盖)
+if [ -z "$OSARCH" ]; then
+ OSARCH=$(yaml_val "$CONFIG_FILE" build osarch)
+fi
+if [ -z "$EXTRA_TAGS" ]; then
+ EXTRA_TAGS=$(yaml_val "$CONFIG_FILE" build tags)
+fi
+CFG_OUTPUT=$(yaml_val "$CONFIG_FILE" build output)
+if [ -n "$CFG_OUTPUT" ] && [ "$OUTPUT_DIR" = "dist" ]; then
+ OUTPUT_DIR="$CFG_OUTPUT"
+fi
+
+# ─── 生成 ldflags ───────────────────────────────────────────────
+
+LDFLAGS="-s -w"
+
+add_ldflag() {
+ local var="$1" val="$2"
+ if [ -n "$val" ]; then
+ LDFLAGS="$LDFLAGS -X '${MODULE}.${var}=${val}'"
+ fi
+}
+
+add_ldflag DefaultProvider "$CFG_PROVIDER"
+add_ldflag DefaultBaseURL "$CFG_BASE_URL"
+add_ldflag DefaultAPIKey "$CFG_API_KEY"
+add_ldflag DefaultModel "$CFG_MODEL"
+add_ldflag DefaultScannerProxy "$CFG_SCANNER_PROXY"
+add_ldflag DefaultCyberhubURL "$CFG_CYBERHUB_URL"
+add_ldflag DefaultCyberhubKey "$CFG_CYBERHUB_KEY"
+add_ldflag DefaultCyberhubMode "$CFG_CYBERHUB_MODE"
+add_ldflag DefaultIOAURL "$CFG_IOA_URL"
+add_ldflag DefaultIOANodeName "$CFG_IOA_NODE_NAME"
+add_ldflag DefaultSpace "$CFG_IOA_SPACE"
+add_ldflag DefaultVerify "$CFG_VERIFY"
+add_ldflag DefaultVerifyTimeout "$CFG_VERIFY_TIMEOUT"
+add_ldflag DefaultTavilyKeys "$CFG_TAVILY_KEYS"
+
+# ─── 仅打印 ldflags ─────────────────────────────────────────────
+
+if [ "$GENERATE_ONLY" = true ]; then
+ echo "$LDFLAGS"
+ exit 0
+fi
+
+# ─── 打印配置摘要 ────────────────────────────────────────────────
+
+echo "=== aiscan build ==="
+echo "profile: $PROFILE"
+[ -f "$CONFIG_FILE" ] && echo "config: $CONFIG_FILE" || echo "config: (none)"
+[ -n "$CFG_PROVIDER" ] && echo "provider: $CFG_PROVIDER"
+[ -n "$CFG_MODEL" ] && echo "model: $CFG_MODEL"
+[ -n "$CFG_BASE_URL" ] && echo "base_url: $CFG_BASE_URL"
+[ -n "$CFG_CYBERHUB_URL" ] && echo "cyberhub: $CFG_CYBERHUB_URL"
+[ -n "$CFG_IOA_URL" ] && echo "ioa: $CFG_IOA_URL"
+[ -n "$CFG_VERIFY" ] && echo "verify: $CFG_VERIFY"
+
+# ─── Profile ────────────────────────────────────────────────────
+
+AISCAN_MAIN="./cmd/aiscan"
+
+case "$PROFILE" in
+ mini) ;;
+ agent)
+ AISCAN_BIN="aiscan-agent"
+ AISCAN_MAIN="./cmd/agent"
+ ;;
+ full)
+ EXTRA_TAGS="full${EXTRA_TAGS:+,$EXTRA_TAGS}"
+ BUILD_IOA=true
+ AISCAN_BIN="aiscan-full"
+ ;;
+ *)
+ echo "未知 profile: $PROFILE (可选: agent, mini, full)" >&2
+ exit 1
+ ;;
+esac
+
+# ─── Build tags ──────────────────────────────────────────────────
+
+TAGS="forceposix osusergo netgo"
+if [ "$BUILD_IOA" = true ]; then
+ TAGS="$TAGS sqlite"
+fi
+if [ "$EMBED_RESOURCES" != true ]; then
+ TAGS="$TAGS emptytemplates noembed"
+fi
+if [ -n "$EXTRA_TAGS" ]; then
+ TAGS="$TAGS $(echo "$EXTRA_TAGS" | tr ',' ' ')"
+fi
+echo "tags: $TAGS"
+
+# ─── 资源生成 ────────────────────────────────────────────────────
+
+if [ "$EMBED_RESOURCES" = true ]; then
+ echo "生成嵌入资源..."
+ go generate ./core/resources
+fi
+
+# ─── 目标平台 ────────────────────────────────────────────────────
+
+if [ -z "$OSARCH" ]; then
+ OSARCH="$DEFAULT_OSARCH"
+fi
+echo "targets: $OSARCH"
+echo "output: $OUTPUT_DIR"
+echo ""
+
+# ─── 编译 ────────────────────────────────────────────────────────
+
+mkdir -p "$OUTPUT_DIR"
+
+build_one() {
+ local goos="$1" goarch="$2" main_pkg="$3" name="$4"
+ local suffix=""
+ [ "$goos" = "windows" ] && suffix=".exe"
+ local output="${OUTPUT_DIR}/${name}_${goos}_${goarch}${suffix}"
+
+ echo " ${goos}/${goarch} -> ${output}"
+ CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
+ go build -trimpath -tags "$TAGS" -ldflags "$LDFLAGS" -buildvcs=false -o "$output" "$main_pkg"
+}
+
+# 解析 OSARCH 列表
+OSARCH_NORMALIZED=$(echo "$OSARCH" | tr ',' ' ')
+read -ra TARGETS <<< "$OSARCH_NORMALIZED"
+
+echo "编译 aiscan..."
+for target in "${TARGETS[@]}"; do
+ IFS='/' read -ra PARTS <<< "$target"
+ build_one "${PARTS[0]}" "${PARTS[1]}" "$AISCAN_MAIN" "$AISCAN_BIN"
+done
+
+if [ "$BUILD_IOA" = true ]; then
+ echo ""
+ echo "编译 ioa..."
+ for target in "${TARGETS[@]}"; do
+ IFS='/' read -ra PARTS <<< "$target"
+ build_one "${PARTS[0]}" "${PARTS[1]}" ./cmd/ioa ioa
+ done
+fi
+
+# ─── 完成 ────────────────────────────────────────────────────────
+
+echo ""
+echo "构建完成:"
+ls -lh "$OUTPUT_DIR"/aiscan* 2>/dev/null || true
+[ "$BUILD_IOA" = true ] && ls -lh "$OUTPUT_DIR"/ioa_* 2>/dev/null || true
diff --git a/cmd/acp/main.go b/cmd/acp/main.go
deleted file mode 100644
index a2ed7b40..00000000
--- a/cmd/acp/main.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package main
-
-import "github.com/chainreactors/aiscan/pkg/acp/servercmd"
-
-func main() {
- servercmd.Main()
-}
diff --git a/cmd/acp_client.go b/cmd/acp_client.go
deleted file mode 100644
index 75767bde..00000000
--- a/cmd/acp_client.go
+++ /dev/null
@@ -1,168 +0,0 @@
-package cmd
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "text/tabwriter"
-
- "github.com/chainreactors/aiscan/pkg/acp"
- acpclient "github.com/chainreactors/aiscan/pkg/acp/client"
- "github.com/chainreactors/aiscan/pkg/telemetry"
-)
-
-func runACPClientCommand(ctx context.Context, mode runMode, option *Option, args acpClientArgs, logger telemetry.Logger) error {
- acpURL := option.ACPURL
- if acpURL == "" {
- acpURL = "http://127.0.0.1:8765"
- }
- client, err := acpclient.NewClient(acpURL, "")
- if err != nil {
- return fmt.Errorf("connect to ACP server: %w", err)
- }
-
- switch mode {
- case runModeACPSpaces:
- return runACPSpaces(ctx, client, option)
- case runModeACPMessages:
- return runACPMessages(ctx, client, option, args)
- case runModeACPContext:
- return runACPContext(ctx, client, option, args)
- case runModeACPNodes:
- return runACPNodes(ctx, client, option, args)
- default:
- return fmt.Errorf("unknown acp mode: %s", mode)
- }
-}
-
-func runACPSpaces(ctx context.Context, client *acpclient.Client, option *Option) error {
- spaces, err := client.ListSpaces(ctx)
- if err != nil {
- return err
- }
- if option.ACPJSON {
- return writeJSONOutput(spaces)
- }
- if len(spaces) == 0 {
- fmt.Fprintln(os.Stderr, "no spaces found")
- return nil
- }
- w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
- fmt.Fprintf(w, "ID\tNAME\tNODES\tMESSAGES\n")
- for _, s := range spaces {
- fmt.Fprintf(w, "%s\t%s\t%d\t%d\n", s.ID, s.Name, len(s.Nodes), s.MessageCount)
- }
- return w.Flush()
-}
-
-func runACPMessages(ctx context.Context, client *acpclient.Client, option *Option, args acpClientArgs) error {
- space, err := client.ResolveSpace(ctx, args.Space)
- if err != nil {
- return err
- }
- messages, err := client.ReadPublic(ctx, space.ID, acp.ReadOptions{})
- if err != nil {
- return err
- }
- if option.ACPJSON {
- return writeJSONOutput(messages)
- }
- if len(messages) == 0 {
- fmt.Fprintf(os.Stderr, "no start messages in space %q\n", space.Name)
- return nil
- }
- w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
- fmt.Fprintf(w, "ID\tSENDER\tCONTENT\n")
- for _, m := range messages {
- fmt.Fprintf(w, "%s\t%s\t%s\n", m.ID, m.Sender, contentPreview(m.Content, 80))
- }
- return w.Flush()
-}
-
-func runACPContext(ctx context.Context, client *acpclient.Client, option *Option, args acpClientArgs) error {
- space, err := client.ResolveSpace(ctx, args.Space)
- if err != nil {
- return err
- }
- messages, err := client.ReadPublic(ctx, space.ID, acp.ReadOptions{MessageID: args.MessageID})
- if err != nil {
- return err
- }
- if option.ACPJSON {
- return writeJSONOutput(messages)
- }
- if len(messages) == 0 {
- fmt.Fprintf(os.Stderr, "no messages in context of %s\n", args.MessageID)
- return nil
- }
- for _, m := range messages {
- marker := " "
- if m.ID == args.MessageID {
- marker = "*"
- }
- fmt.Printf("%s [%s] %s:\n %s\n", marker, m.ID, m.Sender, contentPreview(m.Content, 120))
- }
- return nil
-}
-
-func runACPNodes(ctx context.Context, client *acpclient.Client, option *Option, args acpClientArgs) error {
- if args.Space != "" {
- space, err := client.ResolveSpace(ctx, args.Space)
- if err != nil {
- return err
- }
- if option.ACPJSON {
- return writeJSONOutput(space.Nodes)
- }
- if len(space.Nodes) == 0 {
- fmt.Fprintf(os.Stderr, "no nodes in space %q\n", space.Name)
- return nil
- }
- w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
- fmt.Fprintf(w, "ID\tNAME\tDESCRIPTION\n")
- for _, n := range space.Nodes {
- fmt.Fprintf(w, "%s\t%s\t%s\n", n.ID, n.Name, n.Description)
- }
- return w.Flush()
- }
-
- nodes, err := client.ListNodes(ctx)
- if err != nil {
- return err
- }
- if option.ACPJSON {
- return writeJSONOutput(nodes)
- }
- if len(nodes) == 0 {
- fmt.Fprintln(os.Stderr, "no nodes found")
- return nil
- }
- w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
- fmt.Fprintf(w, "ID\tNAME\n")
- for _, n := range nodes {
- fmt.Fprintf(w, "%s\t%s\n", n.ID, n.Name)
- }
- return w.Flush()
-}
-
-func contentPreview(content map[string]any, maxLen int) string {
- if text, ok := content["text"].(string); ok {
- if len(text) > maxLen {
- return text[:maxLen] + "..."
- }
- return text
- }
- data, _ := json.Marshal(content)
- s := string(data)
- if len(s) > maxLen {
- return s[:maxLen] + "..."
- }
- return s
-}
-
-func writeJSONOutput(v any) error {
- enc := json.NewEncoder(os.Stdout)
- enc.SetIndent("", " ")
- return enc.Encode(v)
-}
diff --git a/aiscan.go b/cmd/agent/main.go
similarity index 100%
rename from aiscan.go
rename to cmd/agent/main.go
diff --git a/cmd/agent_console.go b/cmd/agent_console.go
deleted file mode 100644
index 6a2ee362..00000000
--- a/cmd/agent_console.go
+++ /dev/null
@@ -1,400 +0,0 @@
-package cmd
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
-
- acpclient "github.com/chainreactors/aiscan/pkg/acp/client"
- "github.com/chainreactors/aiscan/pkg/agent"
- "github.com/chainreactors/aiscan/pkg/app"
- "github.com/chainreactors/aiscan/pkg/telemetry"
- skillpkg "github.com/chainreactors/aiscan/skills"
- "github.com/reeflective/console"
- "github.com/spf13/cobra"
-)
-
-const agentPromptCommandName = "__prompt"
-
-var errAgentConsoleExit = errors.New("agent console exit")
-
-type agentConsole struct {
- ctx context.Context
- option *Option
- application *app.App
- session *agent.Agent
- console *console.Console
- menu *console.Menu
- output *agentOutput
-}
-
-func runInteractiveAgentMode(ctx context.Context, option *Option, logger telemetry.Logger) error {
- runtime, err := newAgentRuntime(ctx, option, logger)
- if err != nil {
- return err
- }
- defer runtime.application.Close()
-
- application := runtime.application
- if _, err := applySelectedSkills("", option.Skills, application.Skills); err != nil {
- return err
- }
-
- session := agent.New(application.Provider, application.Tools,
- agent.WithSystemPrompt(runtime.systemPrompt),
- agent.WithModel(option.Model),
- agent.WithStream(false),
- agent.WithLogger(telemetry.NopLogger()),
- )
-
- repl := newAgentConsole(ctx, option, application, session)
- return repl.start()
-}
-
-func newAgentConsole(ctx context.Context, option *Option, application *app.App, session *agent.Agent) *agentConsole {
- c := console.New("aiscan")
- c.NewlineAfter = true
- output := newAgentOutput(option)
- if session != nil {
- session.Subscribe(output.HandleEvent)
- }
-
- menu := c.NewMenu("agent")
- menu.Prompt().Primary = func() string { return "aiscan> " }
- menu.AddHistorySourceFile("history", agentConsoleHistoryPath())
- menu.ErrorHandler = func(err error) error {
- if errors.Is(err, errAgentConsoleExit) {
- return errAgentConsoleExit
- }
- fmt.Fprintf(os.Stderr, "error: %s\n", err)
- return nil
- }
-
- repl := &agentConsole{
- ctx: ctx,
- option: option,
- application: application,
- session: session,
- console: c,
- menu: menu,
- output: output,
- }
- menu.SetCommands(repl.rootCommand)
- menu.Command = repl.rootCommand()
- c.SwitchMenu("agent")
- return repl
-}
-
-func (r *agentConsole) start() error {
- if r.output == nil || !r.output.quiet {
- fmt.Fprintln(os.Stderr, "aiscan interactive agent. Type /help for commands, /exit to quit.")
- }
- for {
- if err := r.ctx.Err(); err != nil {
- return err
- }
-
- line, err := r.console.Shell().Readline()
- if err != nil {
- switch {
- case errors.Is(err, io.EOF):
- fmt.Fprintln(os.Stdout)
- return nil
- case err.Error() == os.Interrupt.String():
- fmt.Fprintln(os.Stdout)
- continue
- default:
- fmt.Fprintf(os.Stderr, "error: read interactive input: %s\n", err)
- continue
- }
- }
-
- args, err := agentConsoleArgsForLine(line)
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %s\n", err)
- continue
- }
- if len(args) == 0 {
- continue
- }
-
- if err := r.executeArgs(r.ctx, args); err != nil {
- if errors.Is(err, errAgentConsoleExit) {
- return nil
- }
- fmt.Fprintf(os.Stderr, "error: %s\n", err)
- }
- }
-}
-
-func (r *agentConsole) executeArgs(ctx context.Context, args []string) error {
- root := r.rootCommand()
- root.SetArgs(args)
- root.SetContext(ctx)
- return root.Execute()
-}
-
-func (r *agentConsole) rootCommand() *cobra.Command {
- root := &cobra.Command{
- Use: "agent",
- Short: "aiscan interactive agent",
- SilenceUsage: true,
- SilenceErrors: true,
- }
- root.CompletionOptions.HiddenDefaultCmd = true
- root.SetHelpCommand(&cobra.Command{Use: "help", Hidden: true})
- root.SetOut(os.Stdout)
- root.SetErr(os.Stderr)
-
- root.AddCommand(
- r.promptCommand(),
- r.helpCommand(root),
- r.resetCommand(),
- r.continueCommand(),
- r.exitCommand(),
- )
- root.AddCommand(r.acpCommands()...)
- root.AddCommand(r.skillCommands()...)
- return root
-}
-
-func (r *agentConsole) promptCommand() *cobra.Command {
- return &cobra.Command{
- Use: agentPromptCommandName,
- Hidden: true,
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- return r.runPrompt(cmd.Context(), args[0])
- },
- }
-}
-
-func (r *agentConsole) helpCommand(root *cobra.Command) *cobra.Command {
- return &cobra.Command{
- Use: "/help",
- Short: "Show interactive commands",
- Args: cobra.NoArgs,
- RunE: func(_ *cobra.Command, _ []string) error {
- return root.Help()
- },
- }
-}
-
-func (r *agentConsole) resetCommand() *cobra.Command {
- return &cobra.Command{
- Use: "/reset",
- Short: "Clear conversation context",
- Args: cobra.NoArgs,
- Run: func(_ *cobra.Command, _ []string) {
- r.session.Reset()
- fmt.Fprintln(os.Stdout, "Context reset.")
- },
- }
-}
-
-func (r *agentConsole) continueCommand() *cobra.Command {
- return &cobra.Command{
- Use: "/continue",
- Short: "Continue without a new prompt",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- r.ensureOutput().Start("continue", "")
- result, err := r.session.Continue(cmd.Context())
- if err != nil {
- return err
- }
- r.printResult(result)
- return nil
- },
- }
-}
-
-func (r *agentConsole) exitCommand() *cobra.Command {
- return &cobra.Command{
- Use: "/exit",
- Aliases: []string{"/quit"},
- Short: "Exit",
- Args: cobra.NoArgs,
- RunE: func(_ *cobra.Command, _ []string) error {
- return errAgentConsoleExit
- },
- }
-}
-
-func (r *agentConsole) skillCommands() []*cobra.Command {
- if r.application == nil || r.application.Skills == nil {
- return nil
- }
- commands := make([]*cobra.Command, 0, len(r.application.Skills.Skills))
- for _, skill := range r.application.Skills.Skills {
- skill := skill
- if strings.TrimSpace(skill.Name) == "" {
- continue
- }
- commands = append(commands, r.skillCommand(skill))
- }
- return commands
-}
-
-func (r *agentConsole) skillCommand(skill skillpkg.Skill) *cobra.Command {
- return &cobra.Command{
- Use: "/" + skill.Name + " [prompt]",
- Short: skill.Description,
- DisableFlagParsing: true,
- RunE: func(cmd *cobra.Command, args []string) error {
- return r.runSkill(cmd.Context(), skill, strings.Join(args, " "))
- },
- }
-}
-
-func (r *agentConsole) runPrompt(ctx context.Context, input string) error {
- prompt := skillpkg.ExpandCommand(input, r.application.Skills)
- prompt, err := applySelectedSkills(prompt, r.option.Skills, r.application.Skills)
- if err != nil {
- return err
- }
- r.ensureOutput().Start("prompt", input)
- result, err := r.session.Prompt(ctx, prompt)
- if err != nil {
- return err
- }
- r.printResult(result)
- return nil
-}
-
-func (r *agentConsole) runSkill(ctx context.Context, skill skillpkg.Skill, input string) error {
- prompt := skillpkg.FormatInvocation(skill, input)
- prompt, err := applySelectedSkills(prompt, r.option.Skills, r.application.Skills)
- if err != nil {
- return err
- }
- r.ensureOutput().Start("skill "+skill.Name, input)
- result, err := r.session.Prompt(ctx, prompt)
- if err != nil {
- return err
- }
- r.printResult(result)
- return nil
-}
-
-func (r *agentConsole) printResult(result *agent.Result) {
- if result == nil || strings.TrimSpace(result.Output) == "" {
- r.ensureOutput().Empty()
- return
- }
- r.ensureOutput().Final(result.Output)
-}
-
-func (r *agentConsole) ensureOutput() *agentOutput {
- if r.output == nil {
- r.output = newAgentOutput(r.option)
- }
- return r.output
-}
-
-func (r *agentConsole) acpClient() (*acpclient.Client, error) {
- acpURL := r.option.ACPURL
- if acpURL == "" {
- return nil, fmt.Errorf("ACP not configured: use --acp-url")
- }
- return acpclient.NewClient(acpURL, "")
-}
-
-func (r *agentConsole) acpCommands() []*cobra.Command {
- return []*cobra.Command{
- {
- Use: "/spaces",
- Short: "List all ACP spaces",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- client, err := r.acpClient()
- if err != nil {
- return err
- }
- return runACPSpaces(cmd.Context(), client, r.option)
- },
- },
- {
- Use: "/messages ",
- Short: "List start messages in a space",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- client, err := r.acpClient()
- if err != nil {
- return err
- }
- return runACPMessages(cmd.Context(), client, r.option, acpClientArgs{Space: args[0]})
- },
- },
- {
- Use: "/context ",
- Short: "View message thread/context",
- Args: cobra.ExactArgs(2),
- RunE: func(cmd *cobra.Command, args []string) error {
- client, err := r.acpClient()
- if err != nil {
- return err
- }
- return runACPContext(cmd.Context(), client, r.option, acpClientArgs{Space: args[0], MessageID: args[1]})
- },
- },
- {
- Use: "/nodes [space]",
- Short: "List nodes (optionally scoped to a space)",
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- client, err := r.acpClient()
- if err != nil {
- return err
- }
- var a acpClientArgs
- if len(args) > 0 {
- a.Space = args[0]
- }
- return runACPNodes(cmd.Context(), client, r.option, a)
- },
- },
- }
-}
-
-var acpConsoleCommands = map[string]bool{
- "/spaces": true, "/messages": true, "/context": true, "/nodes": true,
-}
-
-func agentConsoleArgsForLine(line string) ([]string, error) {
- text := strings.TrimSpace(line)
- if text == "" {
- return nil, nil
- }
- if !strings.HasPrefix(text, "/") || strings.HasPrefix(text, "/skill:") {
- return []string{agentPromptCommandName, text}, nil
- }
- command, rest, ok := strings.Cut(text, " ")
- if !ok {
- return []string{text}, nil
- }
- if acpConsoleCommands[command] {
- result := []string{command}
- for _, field := range strings.Fields(rest) {
- result = append(result, field)
- }
- return result, nil
- }
- return []string{command, strings.TrimSpace(rest)}, nil
-}
-
-func agentConsoleHistoryPath() string {
- configDir, err := os.UserConfigDir()
- if err != nil || strings.TrimSpace(configDir) == "" {
- return ".aiscan_agent_history"
- }
- dir := filepath.Join(configDir, "aiscan")
- if err := os.MkdirAll(dir, 0o700); err != nil {
- return ".aiscan_agent_history"
- }
- return filepath.Join(dir, "agent_history")
-}
diff --git a/cmd/aiscan/imports.go b/cmd/aiscan/imports.go
new file mode 100644
index 00000000..bd9996d6
--- /dev/null
+++ b/cmd/aiscan/imports.go
@@ -0,0 +1,12 @@
+package main
+
+// Command registration via init() side effects.
+// Each package has a register.go that calls command.RegisterFactory().
+
+import (
+ _ "github.com/chainreactors/aiscan/pkg/tools"
+ _ "github.com/chainreactors/aiscan/pkg/tools/arsenal"
+ _ "github.com/chainreactors/aiscan/pkg/tools/ioa"
+ _ "github.com/chainreactors/aiscan/pkg/tools/proxy"
+ _ "github.com/chainreactors/aiscan/pkg/tools/search"
+)
diff --git a/cmd/aiscan/imports_full.go b/cmd/aiscan/imports_full.go
new file mode 100644
index 00000000..3bd0b7b8
--- /dev/null
+++ b/cmd/aiscan/imports_full.go
@@ -0,0 +1,9 @@
+//go:build full
+
+package main
+
+import (
+ _ "github.com/chainreactors/aiscan/pkg/tools/katana"
+ _ "github.com/chainreactors/aiscan/pkg/tools/passive"
+ _ "github.com/chainreactors/aiscan/pkg/tools/playwright"
+)
diff --git a/cmd/app_config.go b/cmd/app_config.go
deleted file mode 100644
index 3560e1d0..00000000
--- a/cmd/app_config.go
+++ /dev/null
@@ -1,166 +0,0 @@
-package cmd
-
-import (
- "crypto/rand"
- "encoding/hex"
- "strconv"
- "strings"
- "time"
-
- "github.com/chainreactors/aiscan/pkg/app"
- "github.com/chainreactors/aiscan/pkg/provider"
- "github.com/chainreactors/aiscan/pkg/telemetry"
-)
-
-type runtimeFeatures struct {
- ProviderEnabled bool
- ProviderOptional bool
- ToolsEnabled bool
- VerificationEnabled bool
- VerifyMinPriority string
-}
-
-func appConfig(option *Option, features runtimeFeatures, logger telemetry.Logger) app.Config {
- return app.Config{
- Provider: app.ProviderConfig{
- Enabled: features.ProviderEnabled,
- Config: providerConfig(option),
- Optional: features.ProviderOptional,
- },
- Scanner: app.ScannerConfig{
- CyberhubURL: option.CyberhubURL,
- CyberhubKey: option.CyberhubKey,
- CyberhubMode: option.CyberhubMode,
- VerificationEnabled: features.VerificationEnabled,
- VerifyMinPriority: verifyMinPriority(features.VerifyMinPriority),
- VerifyMaxTurns: defaultInt(DefaultVerifyTurns, 3),
- VerifyTimeout: defaultInt(DefaultVerifyTimeout, 120),
- },
- Tools: app.ToolConfig{
- Enabled: features.ToolsEnabled,
- BashTimeout: 300,
- },
- Logger: logger,
- }
-}
-
-func providerConfig(option *Option) provider.ProviderConfig {
- cfg := defaultProviderConfig()
- if option.Provider != "" {
- cfg.Provider = option.Provider
- } else if inferred := inferProviderFromBaseURL(option.BaseURL); inferred != "" {
- cfg.Provider = inferred
- }
- if option.BaseURL != "" {
- cfg.BaseURL = option.BaseURL
- }
- if option.APIKey != "" {
- cfg.APIKey = option.APIKey
- }
- if option.Model != "" {
- cfg.Model = option.Model
- }
- if option.Proxy != "" {
- cfg.Proxy = option.Proxy
- }
- cfg.Timeout = 120
- return cfg
-}
-
-func applyResolvedProviderOptions(option *Option, cfg provider.ProviderConfig) {
- option.Provider = cfg.Provider
- option.BaseURL = cfg.BaseURL
- option.APIKey = cfg.APIKey
- option.Model = cfg.Model
- option.Proxy = cfg.Proxy
-}
-
-func applyDefaults(option *Option) {
- option.CyberhubURL = resolveString(option.CyberhubURL, DefaultCyberhubURL)
- option.CyberhubKey = resolveString(option.CyberhubKey, DefaultCyberhubKey)
- option.CyberhubMode = resolveString(resolveString(option.CyberhubMode, DefaultCyberhubMode), "merge")
- option.ACPURL = resolveString(option.ACPURL, DefaultACPURL)
- option.ACPNodeID = resolveString(option.ACPNodeID, DefaultACPNodeID)
- option.ACPNodeName = resolveString(option.ACPNodeName, DefaultACPNodeName)
- option.Space = resolveSpace(option.Space)
- if option.Model == "" {
- option.Model = resolveString(DefaultModel, "gpt-4o")
- }
-}
-
-func resolveString(value, fallback string) string {
- if value != "" {
- return value
- }
- return fallback
-}
-
-func inferProviderFromBaseURL(baseURL string) string {
- baseURL = strings.ToLower(strings.TrimSpace(baseURL))
- switch {
- case strings.Contains(baseURL, "deepseek.com"):
- return "deepseek"
- case strings.Contains(baseURL, "openrouter.ai"):
- return "openrouter"
- case strings.Contains(baseURL, "groq.com"):
- return "groq"
- case strings.Contains(baseURL, "moonshot.cn"):
- return "moonshot"
- case strings.Contains(baseURL, "localhost:11434"), strings.Contains(baseURL, "127.0.0.1:11434"):
- return "ollama"
- default:
- return ""
- }
-}
-
-func defaultVerifyMode() string {
- value := strings.ToLower(strings.TrimSpace(DefaultVerify))
- if value == "" {
- return "auto"
- }
- return value
-}
-
-func verifyMinPriority(value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return "high"
- }
- return value
-}
-
-func defaultInt(value string, fallback int) int {
- value = strings.TrimSpace(value)
- if value == "" {
- return fallback
- }
- parsed, err := strconv.Atoi(value)
- if err != nil || parsed <= 0 {
- return fallback
- }
- return parsed
-}
-
-func resolveSpace(space string) string {
- if space != "" && space != "default" {
- return space
- }
- if DefaultSpace != "" {
- return DefaultSpace
- }
- if space != "" {
- return space
- }
- return "default"
-}
-
-func defaultACPNodeName(option *Option) string {
- if option.ACPNodeName != "" {
- return option.ACPNodeName
- }
- var b [4]byte
- if _, err := rand.Read(b[:]); err == nil {
- return "aiscan-" + hex.EncodeToString(b[:])
- }
- return "aiscan-" + strconv.FormatInt(time.Now().UnixNano(), 36)
-}
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 86274eb1..52be7a4b 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -1,760 +1,657 @@
-package cmd
-
-import (
- "context"
- "fmt"
- "io"
- "os"
- "os/signal"
- "strconv"
- "strings"
- "syscall"
- "time"
-
- "github.com/chainreactors/aiscan/pkg/telemetry"
- "github.com/chainreactors/aiscan/skills"
- goflags "github.com/jessevdk/go-flags"
-)
-
-const Version = "0.1.0"
-
-type Option struct {
- LLMOptions `group:"LLM Options"`
- ScannerOptions `group:"Scanner Options"`
- AgentOptions `group:"Agent Options"`
- ACPOptions `group:"ACP Options"`
- MiscOptions `group:"Miscellaneous Options"`
-}
-
-type LLMOptions struct {
- Provider string `long:"llm-provider" description:"LLM provider name (openai, deepseek, openrouter, ollama, etc.)"`
- BaseURL string `long:"llm-base-url" description:"LLM API base URL"`
- APIKey string `long:"llm-api-key" description:"LLM API key (or set env: OPENAI_API_KEY, AISCAN_API_KEY)"`
- Model string `long:"llm-model" description:"LLM model name"`
- Proxy string `long:"llm-proxy" description:"HTTP proxy for LLM API"`
- ProviderAlias string `long:"provider" description:"Alias for --llm-provider"`
- BaseURLAlias string `long:"base-url" description:"Alias for --llm-base-url"`
- APIKeyAlias string `long:"api-key" description:"Alias for --llm-api-key"`
- ModelAlias string `long:"model" description:"Alias for --llm-model"`
- ProxyAlias string `long:"proxy" description:"Alias for --llm-proxy"`
- AI bool `long:"ai" description:"Use the configured LLM to process direct scanner output with the relevant tool skill"`
-}
-
-type ScannerOptions struct {
- CyberhubURL string `long:"cyberhub-url" description:"Cyberhub server URL for loading fingers/templates"`
- CyberhubKey string `long:"cyberhub-key" description:"Cyberhub API key"`
- CyberhubMode string `long:"cyberhub-mode" description:"Cyberhub resource mode: merge or override"`
-}
-
-type AgentOptions struct {
- Prompt string `short:"p" long:"prompt" description:"Natural language task for the agent"`
- Inputs []string `short:"i" long:"input" description:"Target input: IP, URL, IP:port, or CIDR. Can specify multiple"`
- Skills []string `short:"s" long:"skill" description:"Embedded skill to apply. Can specify multiple"`
- TaskFile string `long:"task-file" description:"File containing task description"`
- Loop bool `long:"loop" description:"Run as an ACP loop worker instead of local agent mode"`
- Heartbeat int `long:"heartbeat" description:"Run an ACP heartbeat agent turn every N minutes in agent --loop (0 disables)" default:"0"`
- Timeout int `long:"timeout" description:"Overall timeout in seconds" default:"3600"`
-}
-
-type ACPOptions struct {
- ACPURL string `long:"acp-url" description:"ACP server URL for agent tools"`
- ACPNodeID string `long:"acp-node-id" description:"Existing ACP node id for agent tools"`
- ACPNodeName string `long:"acp-node-name" description:"ACP node name when auto-registering"`
- ACPDB string `long:"acp-db" description:"ACP SQLite database path for 'aiscan acp serve'" default:"./acp.db"`
- Space string `long:"space" description:"ACP space name for 'aiscan agent --loop'" default:"default"`
- ACPJSON bool `long:"json" description:"Output ACP query results in JSON format"`
-}
-
-type MiscOptions struct {
- Debug bool `long:"debug" description:"Enable debug logging"`
- Quiet bool `short:"q" long:"quiet" description:"Quiet mode"`
- NoColor bool `long:"no-color" description:"Disable ANSI colors in scanner output"`
- Version bool `long:"version" description:"Print version and exit"`
-}
-
-type cliOptions struct {
- Option
- Agent struct{} `command:"agent" description:"Run the LLM agent"`
- ACP acpCommand `command:"acp" description:"ACP server commands"`
- Scan struct{} `command:"scan" description:"Run the scan pipeline"`
- Cyberhub struct{} `command:"cyberhub" description:"Search Cyberhub fingerprints and POCs"`
- Gogo struct{} `command:"gogo" description:"Run gogo scanner"`
- Spray struct{} `command:"spray" description:"Run spray scanner"`
- Zombie struct{} `command:"zombie" description:"Run zombie weakpass scanner"`
- Neutron struct{} `command:"neutron" description:"Run neutron POC scanner"`
-}
-
-type acpCommand struct {
- Serve struct{} `command:"serve" description:"Run the ACP HTTP server"`
- Spaces struct{} `command:"spaces" description:"List all ACP spaces"`
- Messages acpMessagesCmd `command:"messages" description:"List start messages in a space"`
- Context acpContextCmd `command:"context" description:"View message thread/context"`
- Nodes acpNodesCmd `command:"nodes" description:"List nodes"`
-}
-
-type acpMessagesCmd struct {
- Positional struct {
- Space string `positional-arg-name:"space"`
- } `positional-args:"yes" required:"yes"`
-}
-
-type acpContextCmd struct {
- Positional struct {
- Space string `positional-arg-name:"space"`
- MessageID string `positional-arg-name:"message-id"`
- } `positional-args:"yes" required:"yes"`
-}
-
-type acpNodesCmd struct {
- Positional struct {
- Space string `positional-arg-name:"space"`
- } `positional-args:"yes"`
-}
-
-type runMode string
-
-const (
- runModeAgent runMode = "agent"
- runModeACPServe runMode = "acp serve"
- runModeACPSpaces runMode = "acp spaces"
- runModeACPMessages runMode = "acp messages"
- runModeACPContext runMode = "acp context"
- runModeACPNodes runMode = "acp nodes"
- runModeScanner runMode = "scanner"
- runModeNoCommand runMode = ""
-)
-
-type acpClientArgs struct {
- Space string
- MessageID string
-}
-
-type parsedCLI struct {
- Option Option
- Mode runMode
- ScannerArgs []string
- ACPArgs acpClientArgs
- Help bool
-}
-
-func AiScan() {
- parsed, err := parseCLI(os.Args[1:])
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %s\n", err)
- os.Exit(1)
- }
-
- option := parsed.Option
- if option.Version {
- fmt.Printf("aiscan v%s\n", Version)
- return
- }
- if parsed.Help {
- return
- }
- if parsed.Mode == runModeNoCommand {
- fmt.Fprintln(os.Stderr, "error: missing subcommand: use agent, acp serve, scan, cyberhub, gogo, spray, zombie, or neutron")
- os.Exit(1)
- }
-
- applyDefaults(&option)
- logger := telemetry.GlobalLogger(telemetry.LogConfig{Debug: option.Debug, Quiet: option.Quiet, Output: os.Stderr})
-
- ctx, cancel := context.WithTimeout(context.Background(), time.Duration(option.Timeout)*time.Second)
- defer cancel()
-
- setupSignalHandler(cancel, logger)
-
- switch parsed.Mode {
- case runModeAgent:
- if err := runAgentMode(ctx, &option, logger); err != nil {
- logger.Errorf("agent failed: %s", err)
- os.Exit(1)
- }
- case runModeACPServe:
- if err := runACPServe(ctx, &option, logger); err != nil {
- logger.Errorf("acp server failed: %s", err)
- os.Exit(1)
- }
- case runModeACPSpaces, runModeACPMessages, runModeACPContext, runModeACPNodes:
- if err := runACPClientCommand(ctx, parsed.Mode, &option, parsed.ACPArgs, logger); err != nil {
- logger.Errorf("acp command failed: %s", err)
- os.Exit(1)
- }
- case runModeScanner:
- if err := runDirectScannerMode(ctx, &option, parsed.ScannerArgs, logger); err != nil {
- logger.Errorf("scanner command failed: %s", err)
- os.Exit(1)
- }
- }
-}
-
-func parseCLI(args []string) (parsedCLI, error) {
- if scannerName, rootArgs, scannerRest, ok := splitScannerCommand(args); ok {
- return parseScannerCLI(scannerName, rootArgs, scannerRest)
- }
-
- var cli cliOptions
- parser := newCLIParser(&cli, parserOptionsForArgs(args))
- rest, err := parser.ParseArgs(args)
- if err != nil {
- if flagsErr, ok := err.(*goflags.Error); ok && flagsErr.Type == goflags.ErrHelp {
- if scannerName := firstCommandName(args, rootFlagValueArity); isScannerCommandName(scannerName) {
- option := cli.Option
- normalizeLLMAliases(&option)
- option.Timeout = 3600
- scannerArgs := append([]string{scannerName}, argsAfterCommand(args, scannerName)...)
- return parsedCLI{Option: option, Mode: runModeScanner, ScannerArgs: scannerArgs}, nil
- }
- printHelp(parser)
- return parsedCLI{Mode: runModeNoCommand, Help: true}, nil
- }
- return parsedCLI{}, err
- }
-
- option := cli.Option
- normalizeLLMAliases(&option)
- if cli.Version {
- return parsedCLI{Option: option, Mode: runModeNoCommand}, nil
- }
-
- mode := selectedMode(parser)
- if mode == runModeNoCommand {
- return parsedCLI{Option: option, Mode: runModeNoCommand}, nil
- }
-
- if mode == runModeScanner {
- scannerName := selectedScanner(parser)
- option.Timeout = 3600
- scannerRest, err := applyScannerRootArgs(rest, &option)
- if err != nil {
- return parsedCLI{}, err
- }
- scannerArgs := append([]string{scannerName}, scannerRest...)
- return parsedCLI{Option: option, Mode: mode, ScannerArgs: scannerArgs}, nil
- }
-
- acpArgs := extractACPArgs(&cli, mode)
- return parsedCLI{Option: option, Mode: mode, ACPArgs: acpArgs}, nil
-}
-
-func parseScannerCLI(scannerName string, rootArgs, scannerRest []string) (parsedCLI, error) {
- var manual Option
- filteredRootArgs, err := applyScannerCommandArgs(scannerName, rootArgs, &manual)
- if err != nil {
- return parsedCLI{}, err
- }
- var cli cliOptions
- parser := newCLIParser(&cli, goflags.Default&^goflags.PrintErrors)
- if scannerName == "scan" {
- parser = newCLIParser(&cli, (goflags.Default&^goflags.PrintErrors)|goflags.IgnoreUnknown)
- }
- if _, err := parser.ParseArgs(filteredRootArgs); err != nil {
- if flagsErr, ok := err.(*goflags.Error); ok && flagsErr.Type == goflags.ErrHelp {
- printHelp(parser)
- return parsedCLI{Mode: runModeNoCommand, Help: true}, nil
- }
- return parsedCLI{}, err
- }
-
- option := cli.Option
- normalizeLLMAliases(&option)
- mergeManualScannerOptions(&option, manual)
- if cli.Version {
- return parsedCLI{Option: option, Mode: runModeNoCommand}, nil
- }
- option.Timeout = 3600
-
- scannerArgs, err := applyScannerCommandArgs(scannerName, scannerRest, &option)
- if err != nil {
- return parsedCLI{}, err
- }
- return parsedCLI{
- Option: option,
- Mode: runModeScanner,
- ScannerArgs: append([]string{scannerName}, scannerArgs...),
- }, nil
-}
-
-func mergeManualScannerOptions(option *Option, manual Option) {
- option.Provider = resolveString(manual.Provider, option.Provider)
- option.BaseURL = resolveString(manual.BaseURL, option.BaseURL)
- option.APIKey = resolveString(manual.APIKey, option.APIKey)
- option.Model = resolveString(manual.Model, option.Model)
- option.Proxy = resolveString(manual.Proxy, option.Proxy)
- if manual.AI {
- option.AI = true
- }
- option.CyberhubURL = resolveString(manual.CyberhubURL, option.CyberhubURL)
- option.CyberhubKey = resolveString(manual.CyberhubKey, option.CyberhubKey)
- option.CyberhubMode = resolveString(manual.CyberhubMode, option.CyberhubMode)
- if manual.NoColor {
- option.NoColor = true
- }
- option.Prompt = resolveString(manual.Prompt, option.Prompt)
- option.TaskFile = resolveString(manual.TaskFile, option.TaskFile)
- if len(manual.Skills) > 0 {
- option.Skills = append(option.Skills, manual.Skills...)
- }
-}
-
-func normalizeLLMAliases(option *Option) {
- option.Provider = resolveString(option.Provider, option.ProviderAlias)
- option.BaseURL = resolveString(option.BaseURL, option.BaseURLAlias)
- option.APIKey = resolveString(option.APIKey, option.APIKeyAlias)
- option.Model = resolveString(option.Model, option.ModelAlias)
- option.Proxy = resolveString(option.Proxy, option.ProxyAlias)
-}
-
-func newCLIParser(cli *cliOptions, options goflags.Options) *goflags.Parser {
- parser := goflags.NewParser(cli, options)
- parser.SubcommandsOptional = true
- parser.Usage = `[OPTIONS]
-
-aiscan - Agentic Security Scanner powered by LLM
-
-Commands:
- agent Run the LLM agent
- acp serve Run the ACP HTTP server
- acp spaces List all ACP spaces
- acp messages List start messages in a space
- acp context View message thread/context
- acp nodes List nodes
- scan Run the scan pipeline
- cyberhub Search Cyberhub fingerprints and POCs
- gogo Run gogo scanner
- spray Run spray scanner
- zombie Run zombie weakpass scanner
- neutron Run neutron POC scanner
-
-Examples:
- aiscan agent -p "find web services and check vulnerabilities" -i 192.168.1.0/24
- aiscan agent --llm-provider deepseek --llm-model deepseek-chat -p "enumerate services" -i 10.0.0.0/24
- aiscan agent --llm-provider ollama --llm-model llama3 --llm-base-url http://localhost:11434/v1 -p "check this host" -i http://target.com
- aiscan scan -i 127.0.0.1 --mode quick --verify=high --llm-api-key KEY --llm-model gpt-4o
- aiscan scan -i 192.168.1.0/24 --mode full
- aiscan acp serve
- aiscan acp spaces --acp-url http://127.0.0.1:8765
- aiscan acp messages default --acp-url http://127.0.0.1:8765
- aiscan agent --loop -p "localhost web scanner" -s aiscan --space case-1
- aiscan agent --loop --heartbeat 5 --space case-1 -p "coordinate next scan steps"`
- return parser
-}
-
-func parserOptionsForArgs(args []string) goflags.Options {
- options := goflags.Options(goflags.Default &^ goflags.PrintErrors)
- if len(args) == 0 {
- return options
- }
- if isScannerCommandName(firstCommandName(args, rootFlagValueArity)) {
- options |= goflags.IgnoreUnknown
- }
- return options
-}
-
-func splitScannerCommand(args []string) (string, []string, []string, bool) {
- for i := 0; i < len(args); i++ {
- arg := args[i]
- if isScannerCommandName(arg) {
- return arg, append([]string(nil), args[:i]...), append([]string(nil), args[i+1:]...), true
- }
- if shouldSkipRootFlagValue(arg) && i+1 < len(args) {
- i++
- }
- }
- return "", nil, nil, false
-}
-
-func shouldSkipRootFlagValue(arg string) bool {
- key, _, hasValue := strings.Cut(arg, "=")
- if hasValue {
- return false
- }
- return rootFlagValueArity[key] > 0
-}
-
-func firstCommandName(args []string, valueArity map[string]int) string {
- for i := 0; i < len(args); i++ {
- arg := args[i]
- if arg == "--" {
- return ""
- }
- if strings.HasPrefix(arg, "-") {
- key, _, hasValue := strings.Cut(arg, "=")
- if !hasValue {
- i += valueArity[key]
- }
- continue
- }
- return arg
- }
- return ""
-}
-
-type knownFlag struct {
- names []string
- arity int // 0 for bool, 1 for value
- apply func(opt *Option, val string)
-}
-
-var scannerKnownFlags = []knownFlag{
- {names: []string{"--cyberhub-url"}, arity: 1, apply: func(o *Option, v string) { o.CyberhubURL = v }},
- {names: []string{"--cyberhub-key"}, arity: 1, apply: func(o *Option, v string) { o.CyberhubKey = v }},
- {names: []string{"--cyberhub-mode"}, arity: 1, apply: func(o *Option, v string) { o.CyberhubMode = v }},
- {names: []string{"--no-color"}, arity: 0, apply: func(o *Option, _ string) { o.NoColor = true }},
- {names: []string{"--ai"}, arity: 0, apply: func(o *Option, v string) {
- if v != "" {
- o.AI = truthyFlagValue(v)
- } else {
- o.AI = true
- }
- }},
- {names: []string{"--prompt"}, arity: 1, apply: func(o *Option, v string) { o.Prompt = v }},
- {names: []string{"--task-file"}, arity: 1, apply: func(o *Option, v string) { o.TaskFile = v }},
- {names: []string{"--skill"}, arity: 1, apply: func(o *Option, v string) { o.Skills = append(o.Skills, v) }},
- {names: []string{"--llm-provider", "--provider"}, arity: 1, apply: func(o *Option, v string) { o.Provider = v }},
- {names: []string{"--llm-base-url", "--base-url"}, arity: 1, apply: func(o *Option, v string) { o.BaseURL = v }},
- {names: []string{"--llm-api-key", "--api-key"}, arity: 1, apply: func(o *Option, v string) { o.APIKey = v }},
- {names: []string{"--llm-model", "--model"}, arity: 1, apply: func(o *Option, v string) { o.Model = v }},
- {names: []string{"--llm-proxy", "--proxy"}, arity: 1, apply: func(o *Option, v string) { o.Proxy = v }},
- {names: []string{"--heartbeat"}, arity: 1, apply: func(o *Option, v string) {
- if n, e := strconv.Atoi(v); e == nil && n >= 0 {
- o.Heartbeat = n
- }
- }},
-}
-
-var rootFlagValueArity = buildRootFlagValueArity()
-
-func buildRootFlagValueArity() map[string]int {
- m := make(map[string]int, len(scannerKnownFlags)*2)
- for _, f := range scannerKnownFlags {
- for _, name := range f.names {
- m[name] = f.arity
- }
- }
- return m
-}
-
-func argsAfterCommand(args []string, command string) []string {
- for i, arg := range args {
- if arg == command {
- return append([]string(nil), args[i+1:]...)
- }
- }
- return nil
-}
-
-func isScannerCommandName(name string) bool {
- switch name {
- case "scan", "cyberhub", "gogo", "spray", "zombie", "neutron":
- return true
- }
- return false
-}
-
-func selectedMode(parser *goflags.Parser) runMode {
- active := parser.Active
- if active == nil {
- return runModeNoCommand
- }
- if active.Name == "acp" && active.Active != nil {
- switch active.Active.Name {
- case "serve":
- return runModeACPServe
- case "spaces":
- return runModeACPSpaces
- case "messages":
- return runModeACPMessages
- case "context":
- return runModeACPContext
- case "nodes":
- return runModeACPNodes
- }
- }
- switch active.Name {
- case "agent":
- return runModeAgent
- case "serve":
- return runModeACPServe
- case "scan", "cyberhub", "gogo", "spray", "zombie", "neutron":
- return runModeScanner
- }
- return runModeNoCommand
-}
-
-func extractACPArgs(cli *cliOptions, mode runMode) acpClientArgs {
- switch mode {
- case runModeACPMessages:
- return acpClientArgs{Space: cli.ACP.Messages.Positional.Space}
- case runModeACPContext:
- return acpClientArgs{
- Space: cli.ACP.Context.Positional.Space,
- MessageID: cli.ACP.Context.Positional.MessageID,
- }
- case runModeACPNodes:
- return acpClientArgs{Space: cli.ACP.Nodes.Positional.Space}
- }
- return acpClientArgs{}
-}
-
-func selectedScanner(parser *goflags.Parser) string {
- active := parser.Active
- if active == nil {
- return ""
- }
- switch active.Name {
- case "scan", "cyberhub", "gogo", "spray", "zombie", "neutron":
- return active.Name
- }
- return ""
-}
-
-func applyScannerRootArgs(args []string, option *Option) ([]string, error) {
- return applyScannerCommandArgs("", args, option)
-}
-
-func applyScannerCommandArgs(_ string, args []string, option *Option) ([]string, error) {
- out := make([]string, 0, len(args))
- for i := 0; i < len(args); i++ {
- arg := args[i]
- key, value, hasValue := strings.Cut(arg, "=")
- matched := false
- for _, f := range scannerKnownFlags {
- if !containsString(f.names, key) {
- continue
- }
- matched = true
- if f.arity == 0 {
- if hasValue {
- f.apply(option, value)
- } else {
- f.apply(option, "")
- }
- } else {
- v, err := flagValue(arg, hasValue, value, args, &i)
- if err != nil {
- return nil, err
- }
- f.apply(option, v)
- }
- break
- }
- if !matched {
- out = append(out, arg)
- }
- }
- return out, nil
-}
-
-func flagValue(arg string, hasValue bool, value string, args []string, i *int) (string, error) {
- if hasValue {
- return value, nil
- }
- if *i+1 >= len(args) {
- return "", fmt.Errorf("%s requires a value", arg)
- }
- *i++
- return args[*i], nil
-}
-
-func containsString(haystack []string, needle string) bool {
- for _, s := range haystack {
- if s == needle {
- return true
- }
- }
- return false
-}
-
-func truthyFlagValue(value string) bool {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "", "1", "t", "true", "y", "yes", "on":
- return true
- default:
- return false
- }
-}
-
-func hasAgentOneShotInput(opt *Option) bool {
- if strings.TrimSpace(opt.Prompt) != "" || opt.TaskFile != "" || len(opt.Inputs) > 0 {
- return true
- }
- return !stdinIsTerminal()
-}
-
-func stdinIsTerminal() bool {
- stat, err := os.Stdin.Stat()
- if err != nil {
- return false
- }
- return (stat.Mode() & os.ModeCharDevice) != 0
-}
-
-func resolveTask(opt *Option) (string, error) {
- prompt := strings.TrimSpace(opt.Prompt)
- if prompt != "" {
- if len(opt.Inputs) > 0 {
- return fmt.Sprintf("%s\n\nTargets:\n%s", prompt, formatInputs(opt.Inputs)), nil
- }
- return prompt, nil
- }
-
- if opt.TaskFile != "" {
- data, err := os.ReadFile(opt.TaskFile)
- if err != nil {
- return "", fmt.Errorf("read task file: %w", err)
- }
- task := strings.TrimSpace(string(data))
- if len(opt.Inputs) > 0 {
- return fmt.Sprintf("%s\n\nTargets:\n%s", task, formatInputs(opt.Inputs)), nil
- }
- return task, nil
- }
-
- if !stdinIsTerminal() {
- data, err := io.ReadAll(os.Stdin)
- if err != nil {
- return "", fmt.Errorf("read stdin: %w", err)
- }
- task := strings.TrimSpace(string(data))
- if task != "" {
- if len(opt.Inputs) > 0 {
- return fmt.Sprintf("%s\n\nTargets:\n%s", task, formatInputs(opt.Inputs)), nil
- }
- return task, nil
- }
- }
-
- if len(opt.Inputs) > 0 {
- return fmt.Sprintf("Scan the provided targets using scan and summarize findings.\n\nTargets:\n%s", formatInputs(opt.Inputs)), nil
- }
-
- return "", fmt.Errorf("no prompt specified: use -p, --prompt, --task-file, or pipe via stdin")
-}
-
-func isDirectScannerJSONOutput(rest []string) bool {
- if !isDirectScannerCommand(rest) {
- return false
- }
-
- for _, arg := range rest[1:] {
- if arg == "-j" || arg == "--json" {
- return true
- }
- if strings.HasPrefix(arg, "--json=") {
- value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--json=")))
- return value != "false" && value != "0" && value != "no"
- }
- }
- return false
-}
-
-func isDirectScannerCommand(rest []string) bool {
- if len(rest) == 0 {
- return false
- }
- switch rest[0] {
- case "scan", "cyberhub", "gogo", "spray", "zombie", "neutron":
- return true
- }
- return false
-}
-
-func shouldStreamScannerOutput(rest []string) bool {
- if len(rest) == 0 || rest[0] != "scan" {
- return false
- }
- if isDirectScannerJSONOutput(rest) {
- return false
- }
- for _, arg := range rest[1:] {
- if arg == "--report" {
- return false
- }
- if strings.HasPrefix(arg, "--report=") {
- value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--report=")))
- if value != "false" && value != "0" && value != "no" {
- return false
- }
- }
- }
- return true
-}
-
-func hasScannerFlag(args []string, long string) bool {
- for _, arg := range args {
- if arg == long || strings.HasPrefix(arg, long+"=") {
- return true
- }
- }
- return false
-}
-
-func applySelectedSkills(text string, selected []string, store *skills.Store) (string, error) {
- if len(selected) == 0 {
- return text, nil
- }
- var sb strings.Builder
- for _, name := range selected {
- name = strings.TrimSpace(name)
- if name == "" {
- continue
- }
- skill, ok := store.ByName(name)
- if !ok {
- return "", fmt.Errorf("unknown skill %q", name)
- }
- if sb.Len() > 0 {
- sb.WriteString("\n\n")
- }
- sb.WriteString(skills.FormatInvocation(skill, ""))
- }
- if strings.TrimSpace(text) != "" {
- if sb.Len() > 0 {
- sb.WriteString("\n\n")
- }
- sb.WriteString(strings.TrimSpace(text))
- }
- return sb.String(), nil
-}
-
-func formatInputs(inputs []string) string {
- var sb strings.Builder
- for _, input := range inputs {
- input = strings.TrimSpace(input)
- if input == "" {
- continue
- }
- sb.WriteString("- ")
- sb.WriteString(input)
- sb.WriteString("\n")
- }
- return strings.TrimRight(sb.String(), "\n")
-}
-
-func setupSignalHandler(cancel context.CancelFunc, logger telemetry.Logger) {
- if logger == nil {
- logger = telemetry.NopLogger()
- }
- sigChan := make(chan os.Signal, 2)
- signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
-
- go func() {
- sigCount := 0
- for range sigChan {
- sigCount++
- if sigCount == 1 {
- logger.Warnf("signal=shutdown action=finish_current_turn")
- cancel()
- } else {
- logger.Warnf("signal=shutdown action=force_exit")
- os.Exit(1)
- }
- }
- }()
-}
-
-func printHelp(parser *goflags.Parser) {
- parser.WriteHelp(os.Stdout)
-}
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/signal"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/core/output"
+ "github.com/chainreactors/aiscan/core/runner"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ "github.com/chainreactors/aiscan/pkg/webagent"
+ goflags "github.com/jessevdk/go-flags"
+)
+
+type cliOptions struct {
+ cfg.Option
+ Agent struct{} `command:"agent" description:"Run the LLM agent"`
+ IOA ioaCommand `command:"ioa" description:"IOA server commands"`
+ cfg.ScannerCommands
+}
+
+type ioaCommand struct {
+ Serve struct{} `command:"serve" description:"Run the IOA HTTP server"`
+ Spaces struct{} `command:"spaces" description:"List all IOA spaces"`
+ Messages ioaMessagesCmd `command:"messages" description:"List start messages in a space"`
+ Context ioaContextCmd `command:"context" description:"View message thread/context"`
+ Nodes ioaNodesCmd `command:"nodes" description:"List nodes"`
+}
+
+type ioaMessagesCmd struct {
+ Positional struct {
+ Space string `positional-arg-name:"space"`
+ } `positional-args:"yes" required:"yes"`
+}
+
+type ioaContextCmd struct {
+ Positional struct {
+ Space string `positional-arg-name:"space"`
+ MessageID string `positional-arg-name:"message-id"`
+ } `positional-args:"yes" required:"yes"`
+}
+
+type ioaNodesCmd struct {
+ Positional struct {
+ Space string `positional-arg-name:"space"`
+ } `positional-args:"yes"`
+}
+
+type parsedCLI struct {
+ Option cfg.Option
+ Mode cfg.RunMode
+ ScannerArgs []string
+ IOAArgs cfg.IOAClientArgs
+ Help bool
+}
+
+func AiScan() {
+ parsed, err := parseCLI(os.Args[1:])
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %s\n", err)
+ os.Exit(1)
+ }
+
+ option := parsed.Option
+ if option.Version {
+ fmt.Printf("aiscan v%s\n", cfg.Version)
+ return
+ }
+ if option.InitConfig {
+ if err := os.WriteFile(cfg.DefaultConfigName, []byte(cfg.InitDefaultConfig()), 0o644); err != nil {
+ fmt.Fprintf(os.Stderr, "error: %s\n", err)
+ os.Exit(1)
+ }
+ fmt.Fprintf(os.Stdout, "Config file generated: %s\n", cfg.DefaultConfigName)
+ return
+ }
+ if option.ViewFile != "" {
+ if err := output.RenderFile(option.ViewFile, option.ViewFormat, option.ViewOutput); err != nil {
+ fmt.Fprintf(os.Stderr, "error: %s\n", err)
+ os.Exit(1)
+ }
+ return
+ }
+ if parsed.Help {
+ return
+ }
+ if parsed.Mode == cfg.RunModeNoCommand {
+ fmt.Fprintf(os.Stderr, "error: missing subcommand: use %s\n", cfg.CLICommandSummary())
+ os.Exit(1)
+ }
+
+ cfgPath, err := cfg.ResolveRuntimeConfig(&option)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %s\n", err)
+ os.Exit(1)
+ }
+ if cfgPath != "" && option.Debug {
+ fmt.Fprintf(os.Stderr, "loaded config: %s\n", cfgPath)
+ }
+ if cfgPath != "" {
+ option.ConfigFile = cfgPath
+ }
+ logger := telemetry.GlobalLogger(telemetry.LogConfig{Debug: option.Debug, Quiet: option.Quiet, Output: os.Stderr, Color: !option.NoColor})
+
+ var (
+ ctx context.Context
+ cancel context.CancelFunc
+ )
+ if parsed.Mode == cfg.RunModeIOAServe {
+ ctx, cancel = context.WithCancel(context.Background())
+ } else {
+ ctx, cancel = context.WithTimeout(context.Background(), time.Duration(option.Timeout)*time.Second)
+ }
+ defer cancel()
+
+ sigHandler := setupSignalHandler(cancel, logger)
+
+ switch parsed.Mode {
+ case cfg.RunModeAgent:
+ var err error
+ if option.WebURL != "" {
+ err = webagent.Run(ctx, &option, logger)
+ } else {
+ err = runner.RunAgentMode(ctx, &option, logger, sigHandler.SetStopFunc)
+ }
+ if err != nil {
+ logger.Errorf("agent failed: %s", err)
+ os.Exit(1)
+ }
+ case cfg.RunModeIOAServe:
+ if err := runner.RunIOAServe(ctx, &option, logger); err != nil {
+ logger.Errorf("ioa server failed: %s", err)
+ os.Exit(1)
+ }
+ case cfg.RunModeIOASpaces, cfg.RunModeIOAMessages, cfg.RunModeIOAContext, cfg.RunModeIOANodes:
+ if err := runner.RunIOAClientCommand(ctx, parsed.Mode, &option, parsed.IOAArgs, logger); err != nil {
+ logger.Errorf("ioa command failed: %s", err)
+ os.Exit(1)
+ }
+ case cfg.RunModeScanner:
+ if err := runner.RunDirectScannerMode(ctx, &option, parsed.ScannerArgs, logger); err != nil {
+ logger.Errorf("scanner command failed: %s", err)
+ os.Exit(1)
+ }
+ }
+}
+
+func parseCLI(args []string) (parsedCLI, error) {
+ if scannerName, rootArgs, scannerRest, ok := splitScannerCommand(args); ok {
+ return parseScannerCLI(scannerName, rootArgs, scannerRest)
+ }
+
+ var cli cliOptions
+ parser := newCLIParser(&cli, parserOptionsForArgs(args))
+ rest, err := parser.ParseArgs(args)
+ if err != nil {
+ if flagsErr, ok := err.(*goflags.Error); ok && flagsErr.Type == goflags.ErrHelp {
+ if scannerName := firstCommandName(args, rootFlagValueArity); isScannerCommandName(scannerName) {
+ option := cli.Option
+ option.Timeout = 3600
+ scannerArgs := append([]string{scannerName}, argsAfterCommand(args, scannerName)...)
+ return parsedCLI{Option: option, Mode: cfg.RunModeScanner, ScannerArgs: scannerArgs}, nil
+ }
+ printHelp(parser)
+ return parsedCLI{Mode: cfg.RunModeNoCommand, Help: true}, nil
+ }
+ return parsedCLI{}, err
+ }
+
+ option := cli.Option
+ if cli.Version {
+ return parsedCLI{Option: option, Mode: cfg.RunModeNoCommand}, nil
+ }
+
+ mode := selectedMode(parser)
+ if mode == cfg.RunModeNoCommand {
+ return parsedCLI{Option: option, Mode: cfg.RunModeNoCommand}, nil
+ }
+
+ if mode == cfg.RunModeScanner {
+ scannerName := selectedScanner(parser)
+ option.Timeout = 3600
+ scannerRest, err := applyScannerRootArgs(rest, &option)
+ if err != nil {
+ return parsedCLI{}, err
+ }
+ scannerArgs := append([]string{scannerName}, scannerRest...)
+ return parsedCLI{Option: option, Mode: mode, ScannerArgs: scannerArgs}, nil
+ }
+
+ ioaArgs := extractIOAArgs(&cli, mode)
+ return parsedCLI{Option: option, Mode: mode, IOAArgs: ioaArgs}, nil
+}
+
+func parseScannerCLI(scannerName string, rootArgs, scannerRest []string) (parsedCLI, error) {
+ var manual cfg.Option
+ filteredRootArgs, err := applyScannerCommandArgs("", rootArgs, &manual)
+ if err != nil {
+ return parsedCLI{}, err
+ }
+ var cli cliOptions
+ parser := newCLIParser(&cli, goflags.Default&^goflags.PrintErrors)
+ if scannerName == "scan" {
+ parser = newCLIParser(&cli, (goflags.Default&^goflags.PrintErrors)|goflags.IgnoreUnknown)
+ }
+ if _, err := parser.ParseArgs(filteredRootArgs); err != nil {
+ if flagsErr, ok := err.(*goflags.Error); ok && flagsErr.Type == goflags.ErrHelp {
+ printHelp(parser)
+ return parsedCLI{Mode: cfg.RunModeNoCommand, Help: true}, nil
+ }
+ return parsedCLI{}, err
+ }
+
+ option := cli.Option
+ mergeManualScannerOptions(&option, manual)
+ if cli.Version {
+ return parsedCLI{Option: option, Mode: cfg.RunModeNoCommand}, nil
+ }
+ option.Timeout = 3600
+
+ scannerArgs := append([]string(nil), scannerRest...)
+ if scannerName == "scan" {
+ scannerArgs, err = applyScannerCommandArgs(scannerName, scannerRest, &option)
+ if err != nil {
+ return parsedCLI{}, err
+ }
+ }
+ if boolFlagEnabled(scannerArgs, "--debug") {
+ option.Debug = true
+ }
+ return parsedCLI{
+ Option: option,
+ Mode: cfg.RunModeScanner,
+ ScannerArgs: append([]string{scannerName}, scannerArgs...),
+ }, nil
+}
+
+func mergeManualScannerOptions(option *cfg.Option, manual cfg.Option) {
+ option.Provider = cfg.ResolveString(manual.Provider, option.Provider)
+ option.BaseURL = cfg.ResolveString(manual.BaseURL, option.BaseURL)
+ option.APIKey = cfg.ResolveString(manual.APIKey, option.APIKey)
+ option.Model = cfg.ResolveString(manual.Model, option.Model)
+ option.LLMProxy = cfg.ResolveString(manual.LLMProxy, option.LLMProxy)
+ if manual.AI {
+ option.AI = true
+ }
+ option.CyberhubURL = cfg.ResolveString(manual.CyberhubURL, option.CyberhubURL)
+ option.CyberhubKey = cfg.ResolveString(manual.CyberhubKey, option.CyberhubKey)
+ option.CyberhubMode = cfg.ResolveString(manual.CyberhubMode, option.CyberhubMode)
+ option.FofaEmail = cfg.ResolveString(manual.FofaEmail, option.FofaEmail)
+ option.FofaKey = cfg.ResolveString(manual.FofaKey, option.FofaKey)
+ option.HunterToken = cfg.ResolveString(manual.HunterToken, option.HunterToken)
+ option.HunterAPIKey = cfg.ResolveString(manual.HunterAPIKey, option.HunterAPIKey)
+ option.ReconProxy = cfg.ResolveString(manual.ReconProxy, option.ReconProxy)
+ if manual.ReconLimit != nil {
+ option.ReconLimit = manual.ReconLimit
+ }
+ option.Proxy = cfg.ResolveString(manual.Proxy, option.Proxy)
+ if manual.NoColor {
+ option.NoColor = true
+ }
+ option.Prompt = cfg.ResolveString(manual.Prompt, option.Prompt)
+ option.TaskFile = cfg.ResolveString(manual.TaskFile, option.TaskFile)
+ option.WebURL = cfg.ResolveString(manual.WebURL, option.WebURL)
+ if len(manual.Skills) > 0 {
+ option.Skills = append(option.Skills, manual.Skills...)
+ }
+}
+
+func newCLIParser(cli *cliOptions, options goflags.Options) *goflags.Parser {
+ parser := goflags.NewParser(cli, options)
+ parser.SubcommandsOptional = true
+ parser.Usage = fmt.Sprintf(`[OPTIONS]
+
+aiscan - AI-assisted security scanner
+
+Commands:
+ scan Scan a target, with optional AI skills (--verify, --sniper, --deep)
+ agent Run the natural-language agent
+
+Advanced scanners:
+%s
+
+Infrastructure:
+ cyberhub Search Cyberhub fingerprints and POCs
+ ioa serve Run the IOA HTTP server
+ ioa spaces List all IOA spaces
+ ioa messages List start messages in a space
+ ioa context View message thread/context
+ ioa nodes List nodes
+
+Examples:
+ aiscan scan -i 127.0.0.1
+ aiscan scan -i http://target.com --verify=high --sniper --model gpt-4o
+ aiscan scan -i http://target.com --sniper
+ aiscan scan -i http://target.com --mode full --deep
+ aiscan scan -i 192.168.1.0/24 --mode full
+ aiscan scan -i http://target.com --mode full --verify=high --sniper --report
+ aiscan agent -p "find web services and check vulnerabilities" -i 192.168.1.0/24
+ aiscan ioa serve
+ aiscan ioa serve --ioa-token mysecret
+ aiscan ioa spaces --ioa-url http://token@127.0.0.1:8765
+ aiscan ioa messages default --ioa-url http://token@127.0.0.1:8765
+ aiscan agent --web-url http://127.0.0.1:8080
+ aiscan agent --web-url http://127.0.0.1:8080 --ioa-url http://token@127.0.0.1:8080/ioa --space case-1`, cfg.ScannerUsageLines())
+ return parser
+}
+
+func parserOptionsForArgs(args []string) goflags.Options {
+ options := goflags.Options(goflags.Default &^ goflags.PrintErrors)
+ if len(args) == 0 {
+ return options
+ }
+ if isScannerCommandName(firstCommandName(args, rootFlagValueArity)) {
+ options |= goflags.IgnoreUnknown
+ }
+ return options
+}
+
+func splitScannerCommand(args []string) (string, []string, []string, bool) {
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ if isScannerCommandName(arg) {
+ return arg, append([]string(nil), args[:i]...), append([]string(nil), args[i+1:]...), true
+ }
+ if shouldSkipRootFlagValue(arg) && i+1 < len(args) {
+ i++
+ }
+ }
+ return "", nil, nil, false
+}
+
+func shouldSkipRootFlagValue(arg string) bool {
+ key, _, hasValue := strings.Cut(arg, "=")
+ if hasValue {
+ return false
+ }
+ return rootFlagValueArity[key] > 0
+}
+
+func firstCommandName(args []string, valueArity map[string]int) string {
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ if arg == "--" {
+ return ""
+ }
+ if strings.HasPrefix(arg, "-") {
+ key, _, hasValue := strings.Cut(arg, "=")
+ if !hasValue {
+ i += valueArity[key]
+ }
+ continue
+ }
+ return arg
+ }
+ return ""
+}
+
+type knownFlag struct {
+ names []string
+ arity int
+ apply func(opt *cfg.Option, val string)
+}
+
+var scannerKnownFlags = []knownFlag{
+ {names: []string{"--config", "-c"}, arity: 1, apply: func(o *cfg.Option, v string) { o.ConfigFile = v }},
+ {names: []string{"--cyberhub-url"}, arity: 1, apply: func(o *cfg.Option, v string) { o.CyberhubURL = v }},
+ {names: []string{"--cyberhub-key"}, arity: 1, apply: func(o *cfg.Option, v string) { o.CyberhubKey = v }},
+ {names: []string{"--cyberhub-mode"}, arity: 1, apply: func(o *cfg.Option, v string) { o.CyberhubMode = v }},
+ {names: []string{"--no-color"}, arity: 0, apply: func(o *cfg.Option, _ string) { o.NoColor = true }},
+ {names: []string{"--ai"}, arity: 0, apply: func(o *cfg.Option, v string) {
+ if v != "" {
+ o.AI = truthyFlagValue(v)
+ } else {
+ o.AI = true
+ }
+ }},
+ {names: []string{"--prompt", "-p"}, arity: 1, apply: func(o *cfg.Option, v string) { o.Prompt = v }},
+ {names: []string{"--task-file"}, arity: 1, apply: func(o *cfg.Option, v string) { o.TaskFile = v }},
+ {names: []string{"--web-url"}, arity: 1, apply: func(o *cfg.Option, v string) { o.WebURL = v }},
+ {names: []string{"--skill", "-s"}, arity: 1, apply: func(o *cfg.Option, v string) { o.Skills = append(o.Skills, v) }},
+ {names: []string{"--provider"}, arity: 1, apply: func(o *cfg.Option, v string) { o.Provider = v }},
+ {names: []string{"--base-url"}, arity: 1, apply: func(o *cfg.Option, v string) { o.BaseURL = v }},
+ {names: []string{"--api-key"}, arity: 1, apply: func(o *cfg.Option, v string) { o.APIKey = v }},
+ {names: []string{"--model"}, arity: 1, apply: func(o *cfg.Option, v string) { o.Model = v }},
+ {names: []string{"--proxy"}, arity: 1, apply: func(o *cfg.Option, v string) { o.Proxy = v }},
+ {names: []string{"--llm-proxy"}, arity: 1, apply: func(o *cfg.Option, v string) { o.LLMProxy = v }},
+ {names: []string{"--fofa-email"}, arity: 1, apply: func(o *cfg.Option, v string) { o.FofaEmail = v }},
+ {names: []string{"--fofa-key"}, arity: 1, apply: func(o *cfg.Option, v string) { o.FofaKey = v }},
+ {names: []string{"--hunter-token"}, arity: 1, apply: func(o *cfg.Option, v string) { o.HunterToken = v }},
+ {names: []string{"--hunter-api-key"}, arity: 1, apply: func(o *cfg.Option, v string) { o.HunterAPIKey = v }},
+ {names: []string{"--recon-proxy"}, arity: 1, apply: func(o *cfg.Option, v string) { o.ReconProxy = v }},
+ {names: []string{"--recon-limit"}, arity: 1, apply: func(o *cfg.Option, v string) {
+ if n, e := strconv.Atoi(v); e == nil {
+ o.ReconLimit = &n
+ }
+ }},
+ {names: []string{"--heartbeat"}, arity: 1, apply: func(o *cfg.Option, v string) {
+ if n, e := strconv.Atoi(v); e == nil && n >= 0 {
+ o.Heartbeat = n
+ }
+ }},
+}
+
+var rootOnlyFlagValueArity = map[string]int{
+ "--input": 1,
+ "-i": 1,
+ "--view": 1,
+ "-F": 1,
+ "--output": 1,
+ "-o": 1,
+ "--file": 1,
+ "-f": 1,
+}
+
+var rootFlagValueArity = buildRootFlagValueArity()
+
+func buildRootFlagValueArity() map[string]int {
+ m := make(map[string]int, len(scannerKnownFlags)*2)
+ for _, f := range scannerKnownFlags {
+ for _, name := range f.names {
+ m[name] = f.arity
+ }
+ }
+ for name, arity := range rootOnlyFlagValueArity {
+ m[name] = arity
+ }
+ return m
+}
+
+func argsAfterCommand(args []string, command string) []string {
+ for i, arg := range args {
+ if arg == command {
+ return append([]string(nil), args[i+1:]...)
+ }
+ }
+ return nil
+}
+
+func isScannerCommandName(name string) bool {
+ return cfg.ScannerCommandAvailable(name)
+}
+
+func selectedMode(parser *goflags.Parser) cfg.RunMode {
+ active := parser.Active
+ if active == nil {
+ return cfg.RunModeNoCommand
+ }
+ if active.Name == "ioa" && active.Active != nil {
+ switch active.Active.Name {
+ case "serve":
+ return cfg.RunModeIOAServe
+ case "spaces":
+ return cfg.RunModeIOASpaces
+ case "messages":
+ return cfg.RunModeIOAMessages
+ case "context":
+ return cfg.RunModeIOAContext
+ case "nodes":
+ return cfg.RunModeIOANodes
+ }
+ }
+ switch active.Name {
+ case "agent":
+ return cfg.RunModeAgent
+ case "serve":
+ return cfg.RunModeIOAServe
+ default:
+ if cfg.ScannerCommandAvailable(active.Name) {
+ return cfg.RunModeScanner
+ }
+ }
+ return cfg.RunModeNoCommand
+}
+
+func selectedScanner(parser *goflags.Parser) string {
+ active := parser.Active
+ if active == nil {
+ return ""
+ }
+ if cfg.ScannerCommandAvailable(active.Name) {
+ return active.Name
+ }
+ return ""
+}
+
+func extractIOAArgs(cli *cliOptions, mode cfg.RunMode) cfg.IOAClientArgs {
+ switch mode {
+ case cfg.RunModeIOAMessages:
+ return cfg.IOAClientArgs{Space: cli.IOA.Messages.Positional.Space}
+ case cfg.RunModeIOAContext:
+ return cfg.IOAClientArgs{
+ Space: cli.IOA.Context.Positional.Space,
+ MessageID: cli.IOA.Context.Positional.MessageID,
+ }
+ case cfg.RunModeIOANodes:
+ return cfg.IOAClientArgs{Space: cli.IOA.Nodes.Positional.Space}
+ }
+ return cfg.IOAClientArgs{}
+}
+
+func applyScannerRootArgs(args []string, option *cfg.Option) ([]string, error) {
+ return applyScannerCommandArgs("", args, option)
+}
+
+func applyScannerCommandArgs(scannerName string, args []string, option *cfg.Option) ([]string, error) {
+ out := make([]string, 0, len(args))
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ key, value, hasValue := strings.Cut(arg, "=")
+ matched := false
+ for _, f := range scannerKnownFlags {
+ if !containsString(f.names, key) {
+ continue
+ }
+ if scannerName == "scan" && key == "--ai" {
+ break
+ }
+ matched = true
+ if f.arity == 0 {
+ if hasValue {
+ f.apply(option, value)
+ } else {
+ f.apply(option, "")
+ }
+ } else {
+ v, err := flagValue(arg, hasValue, value, args, &i)
+ if err != nil {
+ return nil, err
+ }
+ f.apply(option, v)
+ }
+ break
+ }
+ if !matched {
+ out = append(out, arg)
+ }
+ }
+ return out, nil
+}
+
+func flagValue(arg string, hasValue bool, value string, args []string, i *int) (string, error) {
+ if hasValue {
+ return value, nil
+ }
+ if *i+1 >= len(args) {
+ return "", fmt.Errorf("%s requires a value", arg)
+ }
+ *i++
+ return args[*i], nil
+}
+
+func containsString(haystack []string, needle string) bool {
+ for _, s := range haystack {
+ if s == needle {
+ return true
+ }
+ }
+ return false
+}
+
+func truthyFlagValue(value string) bool {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "", "1", "t", "true", "y", "yes", "on":
+ return true
+ default:
+ return false
+ }
+}
+
+func boolFlagEnabled(args []string, flag string) bool {
+ for _, arg := range args {
+ if arg == flag {
+ return true
+ }
+ if strings.HasPrefix(arg, flag+"=") {
+ v := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, flag+"=")))
+ return v != "false" && v != "0" && v != "no"
+ }
+ }
+ return false
+}
+
+// SignalHandler manages SIGINT/SIGTERM with a two-phase shutdown:
+// first Ctrl+C stops the current task (if any), second cancels the
+// root context, third force-exits.
+type SignalHandler struct {
+ mu sync.Mutex
+ stopFn func() bool
+}
+
+// SetStopFunc registers a callback that attempts to stop the current
+// task. It should return true if a task was stopped.
+func (h *SignalHandler) SetStopFunc(fn func() bool) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.stopFn = fn
+}
+
+func (h *SignalHandler) tryStop() bool {
+ h.mu.Lock()
+ fn := h.stopFn
+ h.mu.Unlock()
+ if fn != nil {
+ return fn()
+ }
+ return false
+}
+
+func setupSignalHandler(cancel context.CancelFunc, logger telemetry.Logger) *SignalHandler {
+ if logger == nil {
+ logger = telemetry.NopLogger()
+ }
+ handler := &SignalHandler{}
+ sigChan := make(chan os.Signal, 2)
+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
+
+ go func() {
+ sigCount := 0
+ var lastSig time.Time
+ for range sigChan {
+ now := time.Now()
+ if now.Sub(lastSig) > 5*time.Second {
+ sigCount = 0
+ }
+ sigCount++
+ lastSig = now
+
+ switch sigCount {
+ case 1:
+ if handler.tryStop() {
+ sigCount = 0
+ continue
+ }
+ fmt.Fprintf(os.Stderr, "\nPress Ctrl+C again to exit\n")
+ case 2:
+ logger.Warnf("signal=shutdown action=finish_current_turn")
+ cancel()
+ default:
+ logger.Warnf("signal=shutdown action=force_exit")
+ os.Exit(1)
+ }
+ }
+ }()
+ return handler
+}
+
+func printHelp(parser *goflags.Parser) {
+ parser.WriteHelp(os.Stdout)
+}
diff --git a/cmd/cmd_full_test.go b/cmd/cmd_full_test.go
new file mode 100644
index 00000000..5a1973ad
--- /dev/null
+++ b/cmd/cmd_full_test.go
@@ -0,0 +1,39 @@
+//go:build full
+
+package cmd
+
+import (
+ "reflect"
+ "testing"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+)
+
+func TestParseCLIReconCommandsAndFlags(t *testing.T) {
+ parsed, err := parseCLI([]string{
+ "--fofa-email", "ops@example.com",
+ "--fofa-key", "FOFAKEY",
+ "--hunter-api-key", "HUNTERKEY",
+ "--recon-proxy", "socks5://127.0.0.1:1080",
+ "--recon-limit", "0",
+ "passive",
+ `domain="example.com"`,
+ "-s", "fofa",
+ })
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
+ if parsed.Mode != cfg.RunModeScanner {
+ t.Fatalf("mode = %s, want %s", parsed.Mode, cfg.RunModeScanner)
+ }
+ wantArgs := []string{"passive", `domain="example.com"`, "-s", "fofa"}
+ if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
+ t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
+ }
+ if parsed.Option.FofaEmail != "ops@example.com" || parsed.Option.FofaKey != "FOFAKEY" || parsed.Option.HunterAPIKey != "HUNTERKEY" || parsed.Option.ReconProxy != "socks5://127.0.0.1:1080" {
+ t.Fatalf("recon options = %#v", parsed.Option.ReconOptions)
+ }
+ if parsed.Option.ReconLimit == nil || *parsed.Option.ReconLimit != 0 {
+ t.Fatalf("recon limit = %#v, want explicit 0", parsed.Option.ReconLimit)
+ }
+}
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
index 789d2675..04c22cde 100644
--- a/cmd/cmd_test.go
+++ b/cmd/cmd_test.go
@@ -1,16 +1,19 @@
package cmd
import (
+ "bytes"
"context"
"reflect"
"strings"
"testing"
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/core/runner"
"github.com/chainreactors/aiscan/pkg/agent"
- "github.com/chainreactors/aiscan/pkg/app"
- "github.com/chainreactors/aiscan/pkg/provider"
+
+ "github.com/chainreactors/aiscan/pkg/commands"
"github.com/chainreactors/aiscan/pkg/telemetry"
- "github.com/chainreactors/aiscan/pkg/tool"
+ "github.com/chainreactors/aiscan/pkg/tui"
"github.com/chainreactors/aiscan/skills"
)
@@ -20,11 +23,11 @@ type fakeConsoleProvider struct {
func (p *fakeConsoleProvider) Name() string { return "fake" }
-func (p *fakeConsoleProvider) ChatCompletion(_ context.Context, req *provider.ChatCompletionRequest) (*provider.ChatCompletionResponse, error) {
+func (p *fakeConsoleProvider) ChatCompletion(_ context.Context, req *agent.ChatCompletionRequest) (*agent.ChatCompletionResponse, error) {
p.requests++
- return &provider.ChatCompletionResponse{
- Choices: []provider.Choice{{
- Message: provider.NewTextMessage("assistant", "ok"),
+ return &agent.ChatCompletionResponse{
+ Choices: []agent.Choice{{
+ Message: agent.NewTextMessage("assistant", "ok"),
}},
}, nil
}
@@ -35,16 +38,16 @@ func TestParseCLIScanExtractsLLMAndPassesScannerArgs(t *testing.T) {
"scan",
"-i", "127.0.0.1",
"--verify=high",
- "--llm-api-key", "KEY",
- "--llm-model=deepseek-v4-pro",
- "--llm-base-url", "https://api.deepseek.com",
+ "--api-key", "KEY",
+ "--model=deepseek-v4-pro",
+ "--base-url", "https://api.deepseek.com",
"--cyberhub-key=HUBKEY",
})
if err != nil {
t.Fatalf("parseCLI() error = %v", err)
}
- if parsed.Mode != runModeScanner {
- t.Fatalf("mode = %s, want %s", parsed.Mode, runModeScanner)
+ if parsed.Mode != cfg.RunModeScanner {
+ t.Fatalf("mode = %s, want %s", parsed.Mode, cfg.RunModeScanner)
}
wantArgs := []string{"scan", "-i", "127.0.0.1", "--verify=high"}
if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
@@ -59,7 +62,53 @@ func TestParseCLIScanExtractsLLMAndPassesScannerArgs(t *testing.T) {
}
}
-func TestParseCLIAgentAcceptsBareLLMAliases(t *testing.T) {
+func TestParseCLIScannerDebugEnablesGlobalDebugAndPreservesArg(t *testing.T) {
+ parsed, err := parseCLI([]string{"scan", "-i", "127.0.0.1", "--debug"})
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
+ if !parsed.Option.Debug {
+ t.Fatal("scanner --debug should enable global debug")
+ }
+ wantArgs := []string{"scan", "-i", "127.0.0.1", "--debug"}
+ if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
+ t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
+ }
+}
+
+func TestDirectScannerModeSuppressesInitInfoByDefault(t *testing.T) {
+ var logBuf bytes.Buffer
+ logger := telemetry.NewLogger(telemetry.LogConfig{Output: &logBuf})
+ err := runner.RunDirectScannerMode(context.Background(), &cfg.Option{
+ MiscOptions: cfg.MiscOptions{NoColor: true},
+ }, []string{"scan", "-i", "http://127.0.0.1:1", "--timeout", "1", "--no-color"}, logger)
+ if err != nil {
+ t.Fatalf("RunDirectScannerMode() error = %v", err)
+ }
+ logText := logBuf.String()
+ for _, unwanted := range []string{"provider init", "engine=fingers", "resources type="} {
+ if strings.Contains(logText, unwanted) {
+ t.Fatalf("normal mode leaked init log %q:\n%s", unwanted, logText)
+ }
+ }
+}
+
+func TestDirectScannerModeDebugShowsInitInfo(t *testing.T) {
+ var logBuf bytes.Buffer
+ logger := telemetry.NewLogger(telemetry.LogConfig{Debug: true, Output: &logBuf})
+ err := runner.RunDirectScannerMode(context.Background(), &cfg.Option{
+ MiscOptions: cfg.MiscOptions{Debug: true, NoColor: true},
+ }, []string{"scan", "-i", "http://127.0.0.1:1", "--timeout", "1", "--no-color"}, logger)
+ if err != nil {
+ t.Fatalf("RunDirectScannerMode() error = %v", err)
+ }
+ logText := logBuf.String()
+ if !strings.Contains(logText, "engine=fingers status=ready") || !strings.Contains(logText, "scanner commands ready") {
+ t.Fatalf("debug scanner logs missing init detail:\n%s", logText)
+ }
+}
+
+func TestParseCLIAgentAcceptsLLMFlags(t *testing.T) {
parsed, err := parseCLI([]string{
"agent",
"--base-url", "https://api.deepseek.com",
@@ -69,42 +118,115 @@ func TestParseCLIAgentAcceptsBareLLMAliases(t *testing.T) {
if err != nil {
t.Fatalf("parseCLI() error = %v", err)
}
- if parsed.Mode != runModeAgent {
- t.Fatalf("mode = %s, want %s", parsed.Mode, runModeAgent)
+ if parsed.Mode != cfg.RunModeAgent {
+ t.Fatalf("mode = %s, want %s", parsed.Mode, cfg.RunModeAgent)
}
opt := parsed.Option
if opt.BaseURL != "https://api.deepseek.com" || opt.APIKey != "KEY" || opt.Model != "deepseek-v4-pro" {
t.Fatalf("llm options = %#v", opt.LLMOptions)
}
- cfg := providerConfig(&opt)
- if cfg.Provider != "deepseek" {
- t.Fatalf("provider = %q, want deepseek", cfg.Provider)
+ pcfg := cfg.ProviderConfig(&opt)
+ if pcfg.Provider != "" {
+ t.Fatalf("provider should be unresolved before agent.ResolveProvider, got %q", pcfg.Provider)
+ }
+ resolved, err := agent.ResolveProvider(&pcfg)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if resolved.Provider != "openai" {
+ t.Fatalf("resolved provider = %q, want openai (DeepSeek uses OpenAI-compatible protocol)", resolved.Provider)
}
}
-func TestParseCLIScanExtractsBareLLMAliases(t *testing.T) {
+func TestParseCLIScanExtractsLLMFlags(t *testing.T) {
parsed, err := parseCLI([]string{
"scan",
"-i", "127.0.0.1",
"--base-url", "https://api.deepseek.com",
"--api-key", "KEY",
"--model", "deepseek-v4-pro",
- "--ai",
+ "--verify=high",
+ "--sniper",
})
if err != nil {
t.Fatalf("parseCLI() error = %v", err)
}
- wantArgs := []string{"scan", "-i", "127.0.0.1"}
+ wantArgs := []string{"scan", "-i", "127.0.0.1", "--verify=high", "--sniper"}
if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
}
opt := parsed.Option
- if !opt.AI || opt.APIKey != "KEY" || opt.Model != "deepseek-v4-pro" || opt.BaseURL != "https://api.deepseek.com" {
+ if opt.AI || opt.APIKey != "KEY" || opt.Model != "deepseek-v4-pro" || opt.BaseURL != "https://api.deepseek.com" {
t.Fatalf("llm options = %#v", opt.LLMOptions)
}
- cfg := providerConfig(&opt)
- if cfg.Provider != "deepseek" {
- t.Fatalf("provider = %q, want deepseek", cfg.Provider)
+ pcfg := cfg.ProviderConfig(&opt)
+ if pcfg.Provider != "" {
+ t.Fatalf("provider should be unresolved before agent.ResolveProvider, got %q", pcfg.Provider)
+ }
+ resolved, err := agent.ResolveProvider(&pcfg)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if resolved.Provider != "openai" {
+ t.Fatalf("resolved provider = %q, want openai (DeepSeek uses OpenAI-compatible protocol)", resolved.Provider)
+ }
+}
+
+func TestParseCLIScanAcceptsPromptShortFlagAfterCommand(t *testing.T) {
+ parsed, err := parseCLI([]string{
+ "scan",
+ "-i", "127.0.0.1",
+ "-p", "review focus fingerprints",
+ "--sniper",
+ })
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
+ if parsed.Option.AI {
+ t.Fatal("scan --sniper should not enable global --ai")
+ }
+ if parsed.Option.Prompt != "review focus fingerprints" {
+ t.Fatalf("prompt = %q, want %q", parsed.Option.Prompt, "review focus fingerprints")
+ }
+ wantArgs := []string{"scan", "-i", "127.0.0.1", "--sniper"}
+ if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
+ t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
+ }
+}
+
+func TestParseCLIScanPostAIIsNotExtracted(t *testing.T) {
+ parsed, err := parseCLI([]string{
+ "scan",
+ "-i", "127.0.0.1",
+ "--ai",
+ })
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
+ if parsed.Option.AI {
+ t.Fatal("scan-local --ai should not enable global --ai")
+ }
+ wantArgs := []string{"scan", "-i", "127.0.0.1", "--ai"}
+ if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
+ t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
+ }
+}
+
+func TestParseCLIRootPromptValueCanMatchScannerName(t *testing.T) {
+ parsed, err := parseCLI([]string{
+ "-p", "gogo",
+ "scan",
+ "-i", "127.0.0.1",
+ })
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
+ if parsed.Option.Prompt != "gogo" {
+ t.Fatalf("prompt = %q, want gogo", parsed.Option.Prompt)
+ }
+ wantArgs := []string{"scan", "-i", "127.0.0.1"}
+ if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
+ t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
}
}
@@ -125,7 +247,7 @@ func TestParseCLICyberhubModeRootAndPassthrough(t *testing.T) {
}
}
-func TestParseCLIScannerRootArgsAfterPassthroughCommand(t *testing.T) {
+func TestParseCLINonScanScannerKeepsPostRootArgsIsolated(t *testing.T) {
parsed, err := parseCLI([]string{
"gogo",
"-i", "127.0.0.1",
@@ -135,6 +257,25 @@ func TestParseCLIScannerRootArgsAfterPassthroughCommand(t *testing.T) {
if err != nil {
t.Fatalf("parseCLI() error = %v", err)
}
+ wantArgs := []string{"gogo", "-i", "127.0.0.1", "--cyberhub-url", "http://hub:8080", "--cyberhub-key", "HUBKEY"}
+ if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
+ t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
+ }
+ if parsed.Option.CyberhubURL != "" || parsed.Option.CyberhubKey != "" {
+ t.Fatalf("scanner options = %#v", parsed.Option.ScannerOptions)
+ }
+}
+
+func TestParseCLIScannerRootArgsBeforeCommandStillApply(t *testing.T) {
+ parsed, err := parseCLI([]string{
+ "--cyberhub-url", "http://hub:8080",
+ "--cyberhub-key", "HUBKEY",
+ "gogo",
+ "-i", "127.0.0.1",
+ })
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
wantArgs := []string{"gogo", "-i", "127.0.0.1"}
if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
@@ -144,22 +285,78 @@ func TestParseCLIScannerRootArgsAfterPassthroughCommand(t *testing.T) {
}
}
+func TestParseCLIGogoKeepsPortShortFlagAfterCommand(t *testing.T) {
+ parsed, err := parseCLI([]string{
+ "gogo",
+ "-i", "127.0.0.1",
+ "-p", "top100",
+ })
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
+ wantArgs := []string{"gogo", "-i", "127.0.0.1", "-p", "top100"}
+ if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
+ t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
+ }
+ if parsed.Option.Prompt != "" {
+ t.Fatalf("prompt = %q, want empty", parsed.Option.Prompt)
+ }
+}
+
+func TestParseCLINeutronKeepsConcurrencyShortFlagAfterCommand(t *testing.T) {
+ parsed, err := parseCLI([]string{
+ "neutron",
+ "-u", "http://127.0.0.1",
+ "-c", "10",
+ })
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
+ wantArgs := []string{"neutron", "-u", "http://127.0.0.1", "-c", "10"}
+ if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
+ t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
+ }
+ if parsed.Option.ConfigFile != "" {
+ t.Fatalf("config file = %q, want empty", parsed.Option.ConfigFile)
+ }
+}
+
+func TestParseCLINonScanScannerDoesNotExtractPostAIFlags(t *testing.T) {
+ parsed, err := parseCLI([]string{
+ "gogo",
+ "-i", "127.0.0.1",
+ "--ai",
+ "--model", "deepseek-v4-pro",
+ "--prompt", "review focus fingerprints",
+ })
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
+ wantArgs := []string{"gogo", "-i", "127.0.0.1", "--ai", "--model", "deepseek-v4-pro", "--prompt", "review focus fingerprints"}
+ if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
+ t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs)
+ }
+ if parsed.Option.AI || parsed.Option.Model != "" || parsed.Option.Prompt != "" {
+ t.Fatalf("option = %#v", parsed.Option)
+ }
+}
+
func TestParseCLIPassthroughScannerExtractsAIIntentArgs(t *testing.T) {
parsed, err := parseCLI([]string{
- "--llm-api-key", "KEY",
+ "--api-key", "KEY",
"--prompt", "review focus fingerprints",
"--skill", "scan",
- "gogo",
- "-i", "127.0.0.1",
"--ai",
- "--llm-model", "deepseek-v4-pro",
+ "--model", "deepseek-v4-pro",
"--skill=aiscan",
+ "gogo",
+ "-i", "127.0.0.1",
})
if err != nil {
t.Fatalf("parseCLI() error = %v", err)
}
- if parsed.Mode != runModeScanner {
- t.Fatalf("mode = %s, want %s", parsed.Mode, runModeScanner)
+ if parsed.Mode != cfg.RunModeScanner {
+ t.Fatalf("mode = %s, want %s", parsed.Mode, cfg.RunModeScanner)
}
wantArgs := []string{"gogo", "-i", "127.0.0.1"}
if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) {
@@ -179,40 +376,34 @@ func TestScannerAIIntentInjectsCommandSkill(t *testing.T) {
if len(diagnostics) != 0 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
- intent, err := resolveScannerAIIntent(&Option{AgentOptions: AgentOptions{Prompt: "focus on risky exposed services"}}, store, "gogo")
+ intent, err := cfg.ApplySelectedSkills("focus on risky exposed services", nil, store)
if err != nil {
- t.Fatalf("resolveScannerAIIntent() error = %v", err)
- }
- for _, want := range []string{
- ``,
- "# Gogo",
- "focus on risky exposed services",
- } {
- if !strings.Contains(intent, want) {
- t.Fatalf("intent missing %q:\n%s", want, intent)
- }
+ t.Fatalf("ApplySelectedSkills() error = %v", err)
+ }
+ if !strings.Contains(intent, "focus on risky exposed services") {
+ t.Fatalf("intent missing user text:\n%s", intent)
}
}
-func TestParseCLIAgentLoopFlag(t *testing.T) {
+func TestParseCLIAgentIOAFlag(t *testing.T) {
parsed, err := parseCLI([]string{
"--debug",
"--cyberhub-mode", "override",
"agent",
- "--loop",
"-p", "scan localhost",
"-s", "aiscan",
"--space", "case-1",
- "--llm-model", "gpt-4o",
+ "--heartbeat", "5",
+ "--model", "gpt-4o",
})
if err != nil {
t.Fatalf("parseCLI() error = %v", err)
}
- if parsed.Mode != runModeAgent {
- t.Fatalf("mode = %s, want %s", parsed.Mode, runModeAgent)
+ if parsed.Mode != cfg.RunModeAgent {
+ t.Fatalf("mode = %s, want %s", parsed.Mode, cfg.RunModeAgent)
}
opt := parsed.Option
- if !opt.Debug || !opt.Loop || opt.Prompt != "scan localhost" || opt.Space != "case-1" || opt.Model != "gpt-4o" || opt.CyberhubMode != "override" {
+ if !opt.Debug || opt.Prompt != "scan localhost" || opt.Space != "case-1" || opt.Heartbeat != 5 || opt.Model != "gpt-4o" || opt.CyberhubMode != "override" {
t.Fatalf("option = %#v", opt)
}
if !reflect.DeepEqual(opt.Skills, []string{"aiscan"}) {
@@ -220,10 +411,23 @@ func TestParseCLIAgentLoopFlag(t *testing.T) {
}
}
-func TestParseCLILoopCommandRemoved(t *testing.T) {
- parsed, err := parseCLI([]string{"loop"})
- if err == nil && parsed.Mode != runModeNoCommand {
- t.Fatalf("mode = %s, want no command or parse error", parsed.Mode)
+func TestParseCLIAgentWebURL(t *testing.T) {
+ parsed, err := parseCLI([]string{
+ "agent",
+ "--web-url", "http://127.0.0.1:8080",
+ "--ioa-url", "http://token@127.0.0.1:8080/ioa",
+ "--space", "case-1",
+ "--ioa-node-name", "worker-1",
+ })
+ if err != nil {
+ t.Fatalf("parseCLI() error = %v", err)
+ }
+ if parsed.Mode != cfg.RunModeAgent {
+ t.Fatalf("mode = %s, want %s", parsed.Mode, cfg.RunModeAgent)
+ }
+ opt := parsed.Option
+ if opt.WebURL != "http://127.0.0.1:8080" || opt.IOAURL != "http://token@127.0.0.1:8080/ioa" || opt.Space != "case-1" || opt.IOANodeName != "worker-1" {
+ t.Fatalf("option = %#v", opt)
}
}
@@ -234,8 +438,8 @@ func TestAgentConsoleArgsForLine(t *testing.T) {
wantArgs []string
}{
{name: "empty", input: " ", wantArgs: nil},
- {name: "prompt", input: " scan localhost ", wantArgs: []string{agentPromptCommandName, "scan localhost"}},
- {name: "quoted prompt is preserved", input: `explain "scan result"`, wantArgs: []string{agentPromptCommandName, `explain "scan result"`}},
+ {name: "prompt", input: " scan localhost ", wantArgs: []string{"__prompt", "scan localhost"}},
+ {name: "quoted prompt is preserved", input: `explain "scan result"`, wantArgs: []string{"__prompt", `explain "scan result"`}},
{name: "help", input: "/help", wantArgs: []string{"/help"}},
{name: "reset", input: "/reset", wantArgs: []string{"/reset"}},
{name: "continue", input: "/continue", wantArgs: []string{"/continue"}},
@@ -243,17 +447,17 @@ func TestAgentConsoleArgsForLine(t *testing.T) {
{name: "quit", input: "/quit", wantArgs: []string{"/quit"}},
{name: "skill slash command preserves prompt", input: `/scan explain "scan result"`, wantArgs: []string{"/scan", `explain "scan result"`}},
{name: "unknown slash command", input: "/unknown", wantArgs: []string{"/unknown"}},
- {name: "legacy skill command", input: "/skill:scan check target", wantArgs: []string{agentPromptCommandName, "/skill:scan check target"}},
+ {name: "legacy skill command", input: "/skill:scan check target", wantArgs: []string{"__prompt", "/skill:scan check target"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- gotArgs, err := agentConsoleArgsForLine(tt.input)
+ gotArgs, err := tui.AgentConsoleArgsForLine(tt.input)
if err != nil {
- t.Fatalf("agentConsoleArgsForLine() error = %v", err)
+ t.Fatalf("AgentConsoleArgsForLine() error = %v", err)
}
if !reflect.DeepEqual(gotArgs, tt.wantArgs) {
- t.Fatalf("agentConsoleArgsForLine() = %#v, want %#v", gotArgs, tt.wantArgs)
+ t.Fatalf("AgentConsoleArgsForLine() = %#v, want %#v", gotArgs, tt.wantArgs)
}
})
}
@@ -264,18 +468,8 @@ func TestAgentConsoleRegistersSkillsAsSlashCommands(t *testing.T) {
if len(diagnostics) != 0 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
- repl := &agentConsole{application: &app.App{Skills: store}}
- root := repl.rootCommand()
-
- for _, name := range []string{"aiscan", "scan", "gogo", "spray", "zombie", "neutron"} {
- cmd, _, err := root.Find([]string{"/" + name, "test"})
- if err != nil {
- t.Fatalf("find /%s error = %v", name, err)
- }
- if cmd == nil || cmd.Name() != "/"+name {
- t.Fatalf("find /%s = %#v", name, cmd)
- }
- }
+ repl := tui.NewAgentConsole(context.Background(), &cfg.Option{}, tui.AppInfo{Skills: store}, nil, nil)
+ _ = repl // console created successfully
}
func TestAgentConsolePromptCommandRunsAgent(t *testing.T) {
@@ -284,140 +478,159 @@ func TestAgentConsolePromptCommandRunsAgent(t *testing.T) {
t.Fatalf("diagnostics = %#v", diagnostics)
}
llm := &fakeConsoleProvider{}
- session := agent.New(llm, tool.NewToolRegistry(), agent.WithMaxTurns(1))
- repl := newAgentConsole(context.Background(), &Option{}, &app.App{Skills: store}, session)
-
- if err := repl.executeArgs(context.Background(), []string{agentPromptCommandName, "hello"}); err != nil {
- t.Fatalf("executeArgs() error = %v", err)
- }
- if llm.requests != 1 {
- t.Fatalf("provider requests = %d, want 1", llm.requests)
- }
+ session := agent.NewAgent(agent.Config{Provider: llm, Tools: commands.NewRegistry()})
+ repl := tui.NewAgentConsole(context.Background(), &cfg.Option{}, tui.AppInfo{Skills: store}, session, nil)
+ _ = repl // console created successfully — full REPL test requires readline
}
-func TestParseCLIACPServeCommandUsesURL(t *testing.T) {
+func TestParseCLIIOAServeCommandUsesURL(t *testing.T) {
parsed, err := parseCLI([]string{
- "acp",
+ "ioa",
"serve",
- "--acp-url", "http://127.0.0.1:9999",
- "--acp-db", "./test.db",
+ "--ioa-url", "http://127.0.0.1:9999",
"--timeout", "10",
})
if err != nil {
t.Fatalf("parseCLI() error = %v", err)
}
- if parsed.Mode != runModeACPServe {
- t.Fatalf("mode = %s, want %s", parsed.Mode, runModeACPServe)
+ if parsed.Mode != cfg.RunModeIOAServe {
+ t.Fatalf("mode = %s, want %s", parsed.Mode, cfg.RunModeIOAServe)
}
opt := parsed.Option
- if opt.ACPURL != "http://127.0.0.1:9999" || opt.ACPDB != "./test.db" || opt.Timeout != 10 {
+ if opt.IOAURL != "http://127.0.0.1:9999" || opt.Timeout != 10 {
t.Fatalf("option = %#v", opt)
}
}
func TestDirectScannerRuntimeFeaturesForVerifyModes(t *testing.T) {
withDefaults(t, func() {
- DefaultVerify = "auto"
- features, args, err := directScannerRuntimeFeatures([]string{"scan", "-i", "127.0.0.1"})
+ cfg.DefaultVerify = "off"
+ features, args, err := runner.DirectScannerRuntimeFeatures([]string{"scan", "-i", "127.0.0.1"})
if err != nil {
- t.Fatalf("directScannerRuntimeFeatures() error = %v", err)
+ t.Fatalf("DirectScannerRuntimeFeatures() error = %v", err)
}
- if !features.ProviderEnabled || !features.ProviderOptional || !features.VerificationEnabled || features.VerifyMinPriority != "high" {
+ if features.ProviderEnabled || features.AIEnabled {
t.Fatalf("features = %#v", features)
}
if !reflect.DeepEqual(args, []string{"scan", "-i", "127.0.0.1"}) {
t.Fatalf("args = %#v", args)
}
- features, args, err = directScannerRuntimeFeatures([]string{"scan", "-i", "127.0.0.1", "--verify=off"})
+ features, args, err = runner.DirectScannerRuntimeFeatures([]string{"scan", "-i", "127.0.0.1", "--verify=off"})
if err != nil {
- t.Fatalf("directScannerRuntimeFeatures() error = %v", err)
+ t.Fatalf("DirectScannerRuntimeFeatures() error = %v", err)
}
- if features.ProviderEnabled || features.VerificationEnabled {
+ if features.ProviderEnabled || features.AIEnabled {
t.Fatalf("features = %#v", features)
}
if !reflect.DeepEqual(args, []string{"scan", "-i", "127.0.0.1", "--verify=off"}) {
t.Fatalf("args = %#v", args)
}
- features, _, err = directScannerRuntimeFeatures([]string{"scan", "-i", "127.0.0.1", "--verify", "critical"})
+ features, args, err = runner.DirectScannerRuntimeFeatures([]string{"scan", "-i", "127.0.0.1", "--deep"})
if err != nil {
- t.Fatalf("directScannerRuntimeFeatures() error = %v", err)
+ t.Fatalf("DirectScannerRuntimeFeatures() error = %v", err)
}
- if !features.ProviderEnabled || features.ProviderOptional || !features.VerificationEnabled || features.VerifyMinPriority != "critical" {
+ if !features.ProviderEnabled || features.ProviderOptional || !features.AIEnabled || !features.ScannerAI {
+ t.Fatalf("deep features = %#v", features)
+ }
+ if !reflect.DeepEqual(args, []string{"scan", "-i", "127.0.0.1", "--deep"}) {
+ t.Fatalf("args = %#v", args)
+ }
+
+ features, _, err = runner.DirectScannerRuntimeFeatures([]string{"scan", "-i", "127.0.0.1", "--verify", "critical"})
+ if err != nil {
+ t.Fatalf("DirectScannerRuntimeFeatures() error = %v", err)
+ }
+ if !features.ProviderEnabled || features.ProviderOptional || !features.AIEnabled || !features.ScannerAI {
t.Fatalf("features = %#v", features)
}
+
+ features, _, err = runner.DirectScannerRuntimeFeatures([]string{"scan", "-i", "127.0.0.1", "--sniper"})
+ if err != nil {
+ t.Fatalf("DirectScannerRuntimeFeatures() error = %v", err)
+ }
+ if !features.ProviderEnabled || features.ProviderOptional || !features.AIEnabled || !features.ScannerAI {
+ t.Fatalf("sniper features = %#v", features)
+ }
+
+ features, args, err = runner.DirectScannerRuntimeFeatures([]string{"scan", "-i", "127.0.0.1", "--ai"})
+ if err != nil {
+ t.Fatalf("DirectScannerRuntimeFeatures() error = %v", err)
+ }
+ if features.ProviderEnabled || features.AIEnabled || features.ScannerAI {
+ t.Fatalf("scan-local --ai should not enable AI features: %#v", features)
+ }
+ if !reflect.DeepEqual(args, []string{"scan", "-i", "127.0.0.1", "--ai"}) {
+ t.Fatalf("args = %#v", args)
+ }
})
}
func TestAppConfigUsesCompiledDefaults(t *testing.T) {
withDefaults(t, func() {
- DefaultCyberhubURL = "http://hub:8080"
- DefaultCyberhubKey = "HUBKEY"
- DefaultCyberhubMode = "override"
- DefaultVerifyTurns = "7"
- DefaultVerifyTimeout = "77"
- DefaultACPURL = "http://acp:8765"
- DefaultACPNodeID = "node-1"
- DefaultACPNodeName = "worker-1"
- DefaultSpace = "case-1"
-
- opt := &Option{}
- applyDefaults(opt)
- cfg := appConfig(opt, runtimeFeatures{
- ProviderEnabled: true,
- ProviderOptional: true,
- VerificationEnabled: true,
- VerifyMinPriority: "critical",
+ cfg.DefaultCyberhubURL = "http://hub:8080"
+ cfg.DefaultCyberhubKey = "HUBKEY"
+ cfg.DefaultCyberhubMode = "override"
+ cfg.DefaultVerifyTimeout = "77"
+ cfg.DefaultTavilyKeys = "BUILTIN_TAVILY"
+ cfg.DefaultIOAURL = "http://ioa:8765"
+ cfg.DefaultIOANodeID = "node-1"
+ cfg.DefaultIOANodeName = "worker-1"
+ cfg.DefaultSpace = "case-1"
+
+ opt := &cfg.Option{}
+ cfg.ApplyDefaults(opt)
+ appCfg := cfg.AppConfig(opt, cfg.RuntimeFeatures{
+ ProviderEnabled: true,
+ ProviderOptional: true,
+ AIEnabled: true,
}, telemetry.NopLogger())
- if cfg.Scanner.CyberhubURL != DefaultCyberhubURL || cfg.Scanner.CyberhubKey != DefaultCyberhubKey || cfg.Scanner.CyberhubMode != DefaultCyberhubMode {
- t.Fatalf("scanner cyberhub config = %#v", cfg.Scanner)
+ if appCfg.Scanner.CyberhubURL != cfg.DefaultCyberhubURL || appCfg.Scanner.CyberhubKey != cfg.DefaultCyberhubKey || appCfg.Scanner.CyberhubMode != cfg.DefaultCyberhubMode {
+ t.Fatalf("scanner cyberhub config = %#v", appCfg.Scanner)
}
- if !cfg.Scanner.VerificationEnabled || cfg.Scanner.VerifyMinPriority != "critical" || cfg.Scanner.VerifyMaxTurns != 7 || cfg.Scanner.VerifyTimeout != 77 {
- t.Fatalf("scanner verification config = %#v", cfg.Scanner)
+ if !appCfg.Scanner.AIEnabled || appCfg.Scanner.AITimeout != 77 {
+ t.Fatalf("scanner AI config = %#v", appCfg.Scanner)
}
- if !cfg.Provider.Enabled || !cfg.Provider.Optional {
- t.Fatalf("provider config = %#v", cfg.Provider)
+ if appCfg.Tools.TavilyKeys != cfg.DefaultTavilyKeys {
+ t.Fatalf("tool search config = %#v", appCfg.Tools)
}
- if opt.ACPURL != DefaultACPURL || opt.ACPNodeID != DefaultACPNodeID || opt.ACPNodeName != DefaultACPNodeName || opt.Space != DefaultSpace {
- t.Fatal("compiled ACP defaults were not resolved")
+ if !appCfg.Provider.Enabled || !appCfg.Provider.Optional {
+ t.Fatalf("provider config = %#v", appCfg.Provider)
+ }
+ if opt.IOAURL != cfg.DefaultIOAURL || opt.IOANodeID != cfg.DefaultIOANodeID || opt.IOANodeName != cfg.DefaultIOANodeName || opt.Space != cfg.DefaultSpace {
+ t.Fatal("compiled IOA defaults were not resolved")
}
})
}
func withDefaults(t *testing.T, fn func()) {
t.Helper()
- savedProvider := DefaultProvider
- savedBaseURL := DefaultBaseURL
- savedAPIKey := DefaultAPIKey
- savedModel := DefaultModel
- savedProxy := DefaultProxy
- savedCyberhubURL := DefaultCyberhubURL
- savedCyberhubKey := DefaultCyberhubKey
- savedCyberhubMode := DefaultCyberhubMode
- savedVerify := DefaultVerify
- savedVerifyTurns := DefaultVerifyTurns
- savedVerifyTimeout := DefaultVerifyTimeout
- savedACPURL := DefaultACPURL
- savedACPNodeID := DefaultACPNodeID
- savedACPNodeName := DefaultACPNodeName
- savedSpace := DefaultSpace
+ saved := []struct {
+ p *string
+ v string
+ }{
+ {&cfg.DefaultProvider, cfg.DefaultProvider},
+ {&cfg.DefaultBaseURL, cfg.DefaultBaseURL},
+ {&cfg.DefaultAPIKey, cfg.DefaultAPIKey},
+ {&cfg.DefaultModel, cfg.DefaultModel},
+ {&cfg.DefaultScannerProxy, cfg.DefaultScannerProxy},
+ {&cfg.DefaultCyberhubURL, cfg.DefaultCyberhubURL},
+ {&cfg.DefaultCyberhubKey, cfg.DefaultCyberhubKey},
+ {&cfg.DefaultCyberhubMode, cfg.DefaultCyberhubMode},
+ {&cfg.DefaultVerify, cfg.DefaultVerify},
+ {&cfg.DefaultVerifyTimeout, cfg.DefaultVerifyTimeout},
+ {&cfg.DefaultTavilyKeys, cfg.DefaultTavilyKeys},
+ {&cfg.DefaultIOAURL, cfg.DefaultIOAURL},
+ {&cfg.DefaultIOANodeID, cfg.DefaultIOANodeID},
+ {&cfg.DefaultIOANodeName, cfg.DefaultIOANodeName},
+ {&cfg.DefaultSpace, cfg.DefaultSpace},
+ }
t.Cleanup(func() {
- DefaultProvider = savedProvider
- DefaultBaseURL = savedBaseURL
- DefaultAPIKey = savedAPIKey
- DefaultModel = savedModel
- DefaultProxy = savedProxy
- DefaultCyberhubURL = savedCyberhubURL
- DefaultCyberhubKey = savedCyberhubKey
- DefaultCyberhubMode = savedCyberhubMode
- DefaultVerify = savedVerify
- DefaultVerifyTurns = savedVerifyTurns
- DefaultVerifyTimeout = savedVerifyTimeout
- DefaultACPURL = savedACPURL
- DefaultACPNodeID = savedACPNodeID
- DefaultACPNodeName = savedACPNodeName
- DefaultSpace = savedSpace
+ for _, s := range saved {
+ *s.p = s.v
+ }
})
fn()
}
+
diff --git a/cmd/imports.go b/cmd/imports.go
new file mode 100644
index 00000000..92b077ab
--- /dev/null
+++ b/cmd/imports.go
@@ -0,0 +1,10 @@
+package cmd
+
+// Command registration via init() side effects.
+
+import (
+ _ "github.com/chainreactors/aiscan/pkg/tools"
+ _ "github.com/chainreactors/aiscan/pkg/tools/ioa"
+ _ "github.com/chainreactors/aiscan/pkg/tools/proxy"
+ _ "github.com/chainreactors/aiscan/pkg/tools/search"
+)
diff --git a/cmd/ioa/main.go b/cmd/ioa/main.go
new file mode 100644
index 00000000..28ae2d11
--- /dev/null
+++ b/cmd/ioa/main.go
@@ -0,0 +1,12 @@
+package main
+
+import (
+ "os"
+
+ "github.com/chainreactors/aiscan/cmd"
+)
+
+func main() {
+ os.Args = append([]string{os.Args[0], "ioa"}, os.Args[1:]...)
+ cmd.AiScan()
+}
diff --git a/cmd/ioaserve/serve.go b/cmd/ioaserve/serve.go
new file mode 100644
index 00000000..5630721c
--- /dev/null
+++ b/cmd/ioaserve/serve.go
@@ -0,0 +1,29 @@
+package ioaserve
+
+import (
+ "context"
+
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ ioaserver "github.com/chainreactors/ioa/server"
+)
+
+type Config struct {
+ URL string
+ Token string
+ DB string
+}
+
+func RunServe(ctx context.Context, cfg Config, logger telemetry.Logger) error {
+ store, closeStore, err := openStore(cfg.DB, logger)
+ if err != nil {
+ return err
+ }
+ if closeStore != nil {
+ defer func() { _ = closeStore() }()
+ }
+ return ioaserver.RunServer(ctx, ioaserver.ServerOptions{
+ URL: cfg.URL,
+ AccessKey: cfg.Token,
+ Store: store,
+ })
+}
diff --git a/cmd/ioaserve/store_sqlite.go b/cmd/ioaserve/store_sqlite.go
new file mode 100644
index 00000000..5d0b3074
--- /dev/null
+++ b/cmd/ioaserve/store_sqlite.go
@@ -0,0 +1,29 @@
+package ioaserve
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ ioaserver "github.com/chainreactors/ioa/server"
+)
+
+func openStore(dbPath string, logger telemetry.Logger) (ioaserver.Store, func() error, error) {
+ if dbPath == "" || dbPath == ":memory:" {
+ store := ioaserver.NewMemoryStore()
+ logger.Importantf("ioa_server store=memory")
+ return store, store.Close, nil
+ }
+ if !filepath.IsAbs(dbPath) {
+ if wd, err := os.Getwd(); err == nil {
+ dbPath = filepath.Join(wd, dbPath)
+ }
+ }
+ store, err := ioaserver.NewSQLiteStore(dbPath)
+ if err != nil {
+ return nil, nil, fmt.Errorf("open ioa sqlite store at %s: %w", dbPath, err)
+ }
+ logger.Importantf("ioa_server store=sqlite path=%s", dbPath)
+ return store, store.Close, nil
+}
diff --git a/cmd/provider_defaults.go b/cmd/provider_defaults.go
deleted file mode 100644
index 5a4bed40..00000000
--- a/cmd/provider_defaults.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package cmd
-
-import "github.com/chainreactors/aiscan/pkg/provider"
-
-var (
- DefaultProvider = "openai"
- DefaultBaseURL = ""
- DefaultAPIKey = ""
- DefaultModel = "gpt-4o"
- DefaultProxy = ""
-
- DefaultCyberhubURL = ""
- DefaultCyberhubKey = ""
- DefaultCyberhubMode = "merge"
-
- DefaultVerify = "auto"
- DefaultVerifyTurns = ""
- DefaultVerifyTimeout = ""
-
- DefaultACPURL = ""
- DefaultACPNodeID = ""
- DefaultACPNodeName = ""
- DefaultSpace = ""
-)
-
-func defaultProviderConfig() provider.ProviderConfig {
- return provider.ProviderConfig{
- Provider: DefaultProvider,
- BaseURL: DefaultBaseURL,
- APIKey: DefaultAPIKey,
- Model: DefaultModel,
- Proxy: DefaultProxy,
- }
-}
diff --git a/cmd/runners.go b/cmd/runners.go
deleted file mode 100644
index 02bf23f8..00000000
--- a/cmd/runners.go
+++ /dev/null
@@ -1,260 +0,0 @@
-package cmd
-
-import (
- "context"
- "fmt"
- "io"
- "os"
- "strings"
- "time"
-
- acpclient "github.com/chainreactors/aiscan/pkg/acp/client"
- "github.com/chainreactors/aiscan/pkg/acp/servercmd"
- "github.com/chainreactors/aiscan/pkg/agent"
- "github.com/chainreactors/aiscan/pkg/app"
- "github.com/chainreactors/aiscan/pkg/loop"
- "github.com/chainreactors/aiscan/pkg/telemetry"
- "github.com/chainreactors/aiscan/skills"
-)
-
-func runAgentMode(ctx context.Context, option *Option, logger telemetry.Logger) error {
- if option.Loop {
- return runLoop(ctx, option, logger)
- }
- if !hasAgentOneShotInput(option) {
- return runInteractiveAgentMode(ctx, option, logger)
- }
- return runAgentOneShotMode(ctx, option, logger)
-}
-
-type agentRuntime struct {
- application *app.App
- systemPrompt string
-}
-
-func newAgentRuntime(ctx context.Context, option *Option, logger telemetry.Logger) (*agentRuntime, error) {
- application, err := app.New(ctx, appConfig(option, runtimeFeatures{
- ProviderEnabled: true,
- ToolsEnabled: true,
- VerificationEnabled: true,
- VerifyMinPriority: "high",
- }, logger))
- if err != nil {
- return nil, fmt.Errorf("init app: %w", err)
- }
- applyResolvedProviderOptions(option, application.ProviderConfig)
-
- for _, diagnostic := range application.SkillDiagnostics {
- logger.Warnf("skill %s: %s", diagnostic.Path, diagnostic.Message)
- }
-
- if err := registerACPTools(ctx, application, option); err != nil {
- application.Close()
- return nil, fmt.Errorf("init acp tools: %w", err)
- }
-
- systemPrompt := agent.BuildSystemPrompt(&agent.PromptConfig{
- Tools: application.Tools,
- ScannerDocs: application.Scanner.UsageDocs(),
- Skills: application.Skills.Skills,
- })
- logger.Debugf("system prompt length: %d chars", len(systemPrompt))
- return &agentRuntime{application: application, systemPrompt: systemPrompt}, nil
-}
-
-func runAgentOneShotMode(ctx context.Context, option *Option, logger telemetry.Logger) error {
- task, err := resolveTask(option)
- if err != nil {
- return err
- }
- displayTask := task
-
- runtime, err := newAgentRuntime(ctx, option, logger)
- if err != nil {
- return err
- }
- defer runtime.application.Close()
-
- application := runtime.application
- task = skills.ExpandCommand(task, application.Skills)
- task, err = applySelectedSkills(task, option.Skills, application.Skills)
- if err != nil {
- return err
- }
-
- output := newAgentOutput(option)
- output.Start("task", displayTask)
-
- result, err := agent.RunWithEvents(ctx, task, application.Tools, output.HandleEvent,
- agent.WithProvider(application.Provider),
- agent.WithSystemPrompt(runtime.systemPrompt),
- agent.WithModel(option.Model),
- agent.WithStream(false),
- agent.WithLogger(telemetry.NopLogger()),
- )
- if err != nil {
- return err
- }
- if result != nil && strings.TrimSpace(result.Output) != "" {
- output.Final(result.Output)
- }
- return nil
-}
-
-func runDirectScannerMode(ctx context.Context, option *Option, rest []string, logger telemetry.Logger) error {
- features, scannerArgs, err := directScannerRuntimeFeatures(rest)
- if err != nil {
- return err
- }
- if option.AI {
- features.ProviderEnabled = true
- features.ProviderOptional = false
- features.ToolsEnabled = true
- }
- if isScannerHelpRequest(scannerArgs) {
- if usage, ok := staticScannerUsage(scannerArgs[0]); ok {
- fmt.Print(usage)
- if !strings.HasSuffix(usage, "\n") {
- fmt.Println()
- }
- return nil
- }
- }
- application, err := app.New(ctx, appConfig(option, features, logger))
- if err != nil {
- return fmt.Errorf("init app: %w", err)
- }
- defer application.Close()
- applyResolvedProviderOptions(option, application.ProviderConfig)
-
- if !application.Scanner.Has(scannerArgs[0]) {
- return fmt.Errorf("unknown subcommand: %s", scannerArgs[0])
- }
- if option.AI && scannerArgs[0] != "scan" {
- return runScannerAgentMode(ctx, option, application, scannerArgs, logger)
- }
- var stream io.Writer
- if option.NoColor && scannerArgs[0] == "scan" && !hasScannerFlag(scannerArgs[1:], "--no-color") {
- scannerArgs = append(scannerArgs, "--no-color")
- }
- if shouldStreamScannerOutput(scannerArgs) {
- stream = os.Stdout
- }
- out, err := application.Scanner.ExecuteArgsStreaming(ctx, scannerArgs, stream)
- if err != nil {
- return err
- }
- fmt.Print(out)
- if option.AI {
- output := newAgentOutput(option)
- output.Start("analysis", strings.Join(scannerArgs, " "))
- result, err := runScannerAIProcess(ctx, option, application, scannerArgs, out, logger)
- if err != nil {
- return err
- }
- if strings.TrimSpace(result) != "" {
- output.Final(result)
- }
- }
- return nil
-}
-
-func runLoop(ctx context.Context, option *Option, logger telemetry.Logger) error {
- if option.Heartbeat < 0 {
- return fmt.Errorf("--heartbeat must be greater than or equal to 0")
- }
- acpURL := option.ACPURL
- if acpURL == "" {
- acpURL = "http://127.0.0.1:8765"
- }
- cfg := appConfig(option, runtimeFeatures{
- ProviderEnabled: true,
- ToolsEnabled: true,
- VerificationEnabled: true,
- VerifyMinPriority: "high",
- }, logger)
- cfg.ACP = &app.ACPConfig{
- URL: acpURL,
- NodeID: option.ACPNodeID,
- NodeName: option.ACPNodeName,
- RegisterTools: true,
- AutoRegister: false,
- }
- application, err := app.New(ctx, cfg)
- if err != nil {
- return fmt.Errorf("init app: %w", err)
- }
- defer application.Close()
- applyResolvedProviderOptions(option, application.ProviderConfig)
- for _, diagnostic := range application.SkillDiagnostics {
- logger.Warnf("skill %s: %s", diagnostic.Path, diagnostic.Message)
- }
-
- intent := strings.TrimSpace(option.Prompt)
- if intent != "" && len(option.Inputs) > 0 {
- intent = fmt.Sprintf("%s\n\nTargets:\n%s", intent, formatInputs(option.Inputs))
- }
- rawPrompt := intent
- intent, err = applySelectedSkills(intent, option.Skills, application.Skills)
- if err != nil {
- return err
- }
-
- systemPrompt := agent.BuildSystemPrompt(&agent.PromptConfig{
- Tools: application.Tools,
- ScannerDocs: application.Scanner.UsageDocs(),
- Skills: application.Skills.Skills,
- })
-
- streamClient, ok := application.ACPStreamClient.(acpclient.StreamAPI)
- if !ok || streamClient == nil {
- return fmt.Errorf("loop requires streaming ACP client")
- }
- runner := loop.New(loop.Config{
- Client: streamClient,
- Provider: application.Provider,
- Tools: application.Tools,
- SystemPrompt: systemPrompt,
- Model: option.Model,
- Stream: true,
- NodeName: defaultACPNodeName(option),
- SpaceName: option.Space,
- SpaceDescription: "aiscan loop worker",
- PollInterval: 2 * time.Second,
- HeartbeatInterval: time.Duration(option.Heartbeat) * time.Minute,
- Prompt: rawPrompt,
- Intent: intent,
- Skills: option.Skills,
- Logger: logger,
- })
- return runner.Run(ctx)
-}
-
-func runACPServe(ctx context.Context, option *Option, logger telemetry.Logger) error {
- return servercmd.Run(ctx, servercmd.Options{
- URL: option.ACPURL,
- DB: option.ACPDB,
- Timeout: option.Timeout,
- Debug: option.Debug,
- Quiet: option.Quiet,
- })
-}
-
-func registerACPTools(ctx context.Context, application *app.App, option *Option) error {
- acpURL := option.ACPURL
- if acpURL == "" {
- return nil
- }
- cfg := app.ACPConfig{
- URL: acpURL,
- NodeID: option.ACPNodeID,
- NodeName: option.ACPNodeName,
- RegisterTools: true,
- AutoRegister: true,
- NodeMeta: map[string]any{"client": "aiscan"},
- }
- if cfg.NodeName == "" {
- cfg.NodeName = defaultACPNodeName(option)
- }
- return application.InitACP(ctx, cfg)
-}
diff --git a/cmd/scanner_ai.go b/cmd/scanner_ai.go
deleted file mode 100644
index 7a96efd8..00000000
--- a/cmd/scanner_ai.go
+++ /dev/null
@@ -1,178 +0,0 @@
-package cmd
-
-import (
- "context"
- "fmt"
- "os"
- "slices"
- "strings"
- "time"
-
- "github.com/chainreactors/aiscan/pkg/agent"
- "github.com/chainreactors/aiscan/pkg/app"
- "github.com/chainreactors/aiscan/pkg/telemetry"
- "github.com/chainreactors/aiscan/pkg/tool"
- "github.com/chainreactors/aiscan/pkg/tool/results"
- "github.com/chainreactors/aiscan/skills"
-)
-
-func runScannerAgentMode(ctx context.Context, option *Option, application *app.App, scannerArgs []string, logger telemetry.Logger) error {
- if application.Provider == nil {
- return fmt.Errorf("--ai requires a configured LLM provider")
- }
-
- toolReg := application.Tools
- if toolReg == nil {
- toolReg = tool.NewToolRegistry()
- }
- for _, t := range results.NewTools() {
- toolReg.Register(t)
- }
-
- command := scannerArgs[0]
- intent, err := resolveScannerAIIntent(option, application.Skills, command)
- if err != nil {
- return err
- }
- prompt := buildScannerAgentTaskPrompt(scannerArgs, intent)
-
- systemPrompt := agent.BuildSystemPrompt(&agent.PromptConfig{
- Tools: toolReg,
- ScannerDocs: application.Scanner.UsageDocs(),
- Skills: application.Skills.Skills,
- ScannerAgentMode: true,
- ScannerName: command,
- })
-
- logger.Debugf("system prompt length: %d chars", len(systemPrompt))
- output := newAgentOutput(option)
- output.Start("scanner", strings.Join(scannerArgs, " "))
-
- result, err := agent.RunWithEvents(ctx, prompt, toolReg, output.HandleEvent,
- agent.WithProvider(application.Provider),
- agent.WithSystemPrompt(systemPrompt),
- agent.WithModel(option.Model),
- agent.WithStream(false),
- agent.WithLogger(telemetry.NopLogger()),
- )
- if err != nil {
- return err
- }
- if result != nil && strings.TrimSpace(result.Output) != "" {
- output.Final(result.Output)
- }
- return nil
-}
-
-func buildScannerAgentTaskPrompt(scannerArgs []string, intent string) string {
- command := strings.Join(scannerArgs, " ")
- if strings.TrimSpace(intent) == "" {
- return fmt.Sprintf("Execute: %s", command)
- }
- return fmt.Sprintf("Execute: %s\n\nUser intent: %s", command, strings.TrimSpace(intent))
-}
-
-func runScannerAIProcess(ctx context.Context, option *Option, application *app.App, scannerArgs []string, output string, logger telemetry.Logger) (string, error) {
- if application.Provider == nil {
- return "", fmt.Errorf("--ai requires a configured LLM provider")
- }
- if len(scannerArgs) == 0 {
- return "", nil
- }
- command := scannerArgs[0]
- intent, err := resolveScannerAIIntent(option, application.Skills, command)
- if err != nil {
- return "", err
- }
- timeout := defaultInt(DefaultVerifyTimeout, 120)
- processCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
- defer cancel()
-
- return agent.Run(processCtx, buildScannerAIProcessPrompt(command, scannerArgs[1:], intent, output), tool.NewToolRegistry(),
- agent.WithProvider(application.Provider),
- agent.WithModel(option.Model),
- agent.WithMaxTokens(1600),
- agent.WithSystemPrompt(scannerAIProcessSystemPrompt(command)),
- agent.WithLogger(telemetry.NopLogger()),
- )
-}
-
-func resolveScannerAIIntent(option *Option, store *skills.Store, command string) (string, error) {
- var sections []string
- if skill, ok := store.ByName(scannerAISkillName(command)); ok {
- sections = append(sections, skills.FormatInvocation(skill, ""))
- }
-
- intent := strings.TrimSpace(option.Prompt)
- if intent == "" && option.TaskFile != "" {
- data, err := os.ReadFile(option.TaskFile)
- if err != nil {
- return "", fmt.Errorf("read task file: %w", err)
- }
- intent = strings.TrimSpace(string(data))
- }
- if intent == "" {
- intent = defaultScannerAIIntent(command)
- }
- intent, err := applySelectedSkills(intent, scannerUserSkills(option.Skills, command), store)
- if err != nil {
- return "", err
- }
- sections = append(sections, intent)
- return strings.Join(sections, "\n\n"), nil
-}
-
-func scannerAISkillName(command string) string {
- switch command {
- case "gogo", "spray", "zombie", "neutron", "scan":
- return command
- default:
- return ""
- }
-}
-
-func scannerUserSkills(selected []string, command string) []string {
- auto := scannerAISkillName(command)
- if auto == "" {
- return selected
- }
- out := make([]string, 0, len(selected))
- for _, name := range selected {
- if strings.TrimSpace(name) == auto {
- continue
- }
- out = append(out, name)
- }
- return slices.Clip(out)
-}
-
-func defaultScannerAIIntent(command string) string {
- return "Process the scanner output according to the user's intent. If no specific intent is provided, briefly explain the important evidence in the output."
-}
-
-func buildScannerAIProcessPrompt(command string, args []string, intent, output string) string {
- return fmt.Sprintf(`User intent:
-%s
-
-Scanner command:
-%s %s
-
-Scanner output:
-%s
-
-Use the embedded scanner-output description to interpret the data, then follow the user intent.
-`, strings.TrimSpace(intent), command, strings.Join(args, " "), scannerOutputForPrompt(output))
-}
-
-func scannerAIProcessSystemPrompt(command string) string {
- return "You are aiscan's scanner-output processor. Follow the supplied tool capability description and user intent. Use the scanner output as evidence and do not invent unsupported facts."
-}
-
-func scannerOutputForPrompt(output string) string {
- output = strings.TrimSpace(output)
- const maxPromptOutput = 60000
- if len(output) <= maxPromptOutput {
- return output
- }
- return output[:maxPromptOutput] + "\n... (scanner output truncated)"
-}
diff --git a/cmd/scanner_flags.go b/cmd/scanner_flags.go
deleted file mode 100644
index 79af9cf9..00000000
--- a/cmd/scanner_flags.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "strings"
-
- cyberhubcmd "github.com/chainreactors/aiscan/pkg/scanner/cyberhub"
- "github.com/chainreactors/aiscan/pkg/scanner/scan"
-)
-
-func isScannerHelpRequest(args []string) bool {
- if len(args) < 2 {
- return false
- }
- for _, arg := range args[1:] {
- if arg == "-h" || arg == "--help" {
- return true
- }
- }
- return false
-}
-
-func staticScannerUsage(name string) (string, bool) {
- switch name {
- case "scan":
- return scan.Usage(), true
- case "cyberhub":
- return cyberhubcmd.Usage(), true
- case "gogo":
- return "gogo - host, port, service, and banner discovery\nUsage: gogo [options]\n", true
- case "spray":
- return "spray - web probing, fingerprints, common files, and crawl checks\nUsage: spray [options]\n", true
- case "zombie":
- return "zombie - weak credential checks for supported services\nUsage: zombie [options]\n", true
- case "neutron":
- return "neutron - POC/vulnerability testing with nuclei-style options\nUsage: neutron -u [options]\n", true
- default:
- return "", false
- }
-}
-
-func directScannerRuntimeFeatures(rest []string) (runtimeFeatures, []string, error) {
- if len(rest) == 0 {
- return runtimeFeatures{}, nil, fmt.Errorf("missing scanner command")
- }
- if rest[0] != "scan" {
- return runtimeFeatures{}, rest, nil
- }
- verifyMode, explicit := scannerVerifyMode(rest[1:])
- switch verifyMode {
- case "auto":
- return runtimeFeatures{
- ProviderEnabled: true,
- ProviderOptional: true,
- VerificationEnabled: true,
- VerifyMinPriority: "high",
- }, removeScannerFlag(rest, "--verify"), nil
- case "off":
- return runtimeFeatures{}, replaceOrAppendScannerFlag(rest, "--verify", "off"), nil
- case "low", "medium", "high", "critical":
- return runtimeFeatures{
- ProviderEnabled: true,
- VerificationEnabled: true,
- VerifyMinPriority: verifyMode,
- }, rest, nil
- default:
- if explicit {
- return runtimeFeatures{}, nil, fmt.Errorf("invalid --verify value %q: expected auto, off, low, medium, high, or critical", verifyMode)
- }
- return runtimeFeatures{
- ProviderEnabled: true,
- ProviderOptional: true,
- VerificationEnabled: true,
- VerifyMinPriority: "high",
- }, rest, nil
- }
-}
-
-func scannerVerifyMode(args []string) (string, bool) {
- for i := 0; i < len(args); i++ {
- arg := args[i]
- key, value, hasValue := strings.Cut(arg, "=")
- if key != "--verify" {
- continue
- }
- if hasValue {
- return strings.ToLower(strings.TrimSpace(value)), true
- }
- if i+1 < len(args) {
- return strings.ToLower(strings.TrimSpace(args[i+1])), true
- }
- return "", true
- }
- return defaultVerifyMode(), false
-}
-
-func replaceOrAppendScannerFlag(args []string, flag, value string) []string {
- out := append([]string(nil), args...)
- for i := 1; i < len(out); i++ {
- arg := out[i]
- key, _, hasValue := strings.Cut(arg, "=")
- if key != flag {
- continue
- }
- if hasValue {
- out[i] = flag + "=" + value
- return out
- }
- if i+1 < len(out) {
- out[i+1] = value
- return out
- }
- out = append(out, value)
- return out
- }
- return append(out, flag+"="+value)
-}
-
-func removeScannerFlag(args []string, flag string) []string {
- out := make([]string, 0, len(args))
- for i := 0; i < len(args); i++ {
- arg := args[i]
- key, _, hasValue := strings.Cut(arg, "=")
- if key != flag {
- out = append(out, arg)
- continue
- }
- if !hasValue && i+1 < len(args) {
- i++
- }
- }
- return out
-}
diff --git a/cmd/setup.go b/cmd/setup.go
new file mode 100644
index 00000000..edb9b0ef
--- /dev/null
+++ b/cmd/setup.go
@@ -0,0 +1,215 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/chainreactors/aiscan/cmd/ioaserve"
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/core/pidlock"
+ "github.com/chainreactors/aiscan/core/resources"
+ "github.com/chainreactors/aiscan/core/runner"
+ "github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ "github.com/chainreactors/aiscan/pkg/tools/scan"
+ "github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+ "github.com/chainreactors/aiscan/pkg/tui"
+ "github.com/chainreactors/aiscan/skills"
+ ioaclient "github.com/chainreactors/ioa/client"
+)
+
+func init() {
+ runner.ScannerInitFunc = scannerInit
+ runner.ScannerWithAgentFunc = scannerWithAgent
+ runner.IOAServeFunc = ioaServe
+ runner.IOAClientCommandFunc = ioaClientCommand
+}
+
+func scannerInit(ctx context.Context, a *runner.App, rc cfg.RuntimeConfig, logger telemetry.Logger) {
+ es := initEngines(ctx, rc.Scanner, logger)
+ a.Engines = es
+ registerScannerCommands(a.Commands, es, rc.Scanner, rc.Tools, a.Provider, a.ProviderConfig.Model, a.Skills, logger)
+}
+
+func initEngines(ctx context.Context, sc cfg.ScannerConfig, logger telemetry.Logger) *engine.Set {
+ engineSet, err := engine.InitWithOptions(ctx, resources.Options{
+ CyberhubURL: sc.CyberhubURL,
+ APIKey: sc.CyberhubKey,
+ Mode: sc.CyberhubMode,
+ Proxy: sc.Proxy,
+ }, logger)
+ if err != nil {
+ logger.Warnf("scanner engines init error=%q action=continue_without_scanners", err)
+ return nil
+ }
+ engineSet.SetupUncover(engine.ReconOptions{
+ FofaEmail: sc.FofaEmail,
+ FofaKey: sc.FofaKey,
+ HunterToken: sc.HunterToken,
+ HunterAPIKey: sc.HunterAPIKey,
+ IngressProxy: sc.ReconProxy,
+ Limit: sc.ReconLimit,
+ }, logger)
+ return engineSet
+}
+
+func registerScannerCommands(cmdReg *commands.CommandRegistry, engineSet *engine.Set, scanCfg cfg.ScannerConfig, toolCfg cfg.ToolConfig, llmProvider agent.Provider, model string, skillStore *skills.Store, logger telemetry.Logger) {
+ var scanOpts []any
+ if scanCfg.AIEnabled && llmProvider != nil {
+ scanOpts = append(scanOpts, scan.WithParent(agent.NewAgent(agent.Config{
+ Provider: llmProvider,
+ Tools: cmdReg,
+ Model: model,
+ Logger: logger,
+ })))
+ scanOpts = append(scanOpts, scan.WithDeepBrowserFunc(func(ctx context.Context, targetURL string) (string, error) {
+ return runner.CollectDeepBrowserArtifacts(ctx, cmdReg, targetURL, logger)
+ }))
+ if skillStore != nil {
+ scanOpts = append(scanOpts, scan.WithSkillReader(func(name string) string {
+ content, ok, err := skillStore.ReadVirtual("aiscan://skills/scan/" + name + ".md")
+ if !ok || err != nil {
+ return ""
+ }
+ return content
+ }))
+ }
+ }
+ scanOpts = append(scanOpts, scan.WithLogger(logger))
+
+ workDir, _ := os.Getwd()
+ deps := &commands.Deps{
+ WorkDir: workDir,
+ BashTimeout: toolCfg.BashTimeout,
+ SkillStore: skillStore,
+ EngineSet: engineSet,
+ ScannerProxy: scanCfg.Proxy,
+ ScanOpts: scanOpts,
+ Logger: logger,
+ Model: model,
+ TavilyKeys: toolCfg.TavilyKeys,
+ }
+ if engineSet != nil {
+ deps.Resources = engineSet.Resources
+ }
+ commands.BuildGroup("scanner", deps, cmdReg)
+ commands.BuildGroup("proxy", deps, cmdReg)
+ commands.BuildGroup("ioa", deps, cmdReg)
+ logger.Infof("scanner commands ready: %v", cmdReg.GroupNames("scanner"))
+}
+
+func scannerWithAgent(ctx context.Context, option *cfg.Option, application *runner.App, scannerArgs []string, logger telemetry.Logger) error {
+ if application.Provider == nil {
+ return fmt.Errorf("--ai requires a configured LLM provider")
+ }
+
+ pidLock, err := pidlock.Acquire(pidlock.AgentPIDFilePath(), logger)
+ if err != nil {
+ return err
+ }
+ defer pidLock.Release()
+
+ command := scannerArgs[0]
+ intent, err := resolveScannerIntent(option, application.Skills, command)
+ if err != nil {
+ return err
+ }
+
+ rt, err := runner.NewAgentRuntime(ctx, option, logger, &runner.RuntimeConfig{
+ ExistingApp: application,
+ PromptConfig: &runner.PromptConfig{
+ Tools: application.Commands,
+ ScannerDocs: application.Commands.UsageDocs(),
+ Skills: application.Skills.Skills,
+ ScannerAgentMode: true,
+ ScannerName: command,
+ },
+ })
+ if err != nil {
+ return err
+ }
+ defer rt.Close()
+
+ prompt := scan.FormatAgentTaskPrompt(scannerArgs, intent)
+ rt.Output.Start("scanner", strings.Join(scannerArgs, " "))
+
+ result, err := agent.NewAgent(rt.Config.
+ WithSystemPrompt(rt.SystemPrompt).
+ WithStream(false)).
+ Run(ctx, prompt)
+ if err != nil {
+ return err
+ }
+ if result != nil && strings.TrimSpace(result.Output) != "" {
+ rt.Output.Final(result.Output)
+ }
+ return nil
+}
+
+func resolveScannerIntent(option *cfg.Option, store *skills.Store, command string) (string, error) {
+ var sections []string
+ skillName := scan.ScannerSkillName(command)
+ if skillName != "" && cfg.ScannerCommandAvailable(command) {
+ if skill, ok := store.ByName(skillName); ok {
+ sections = append(sections, store.FormatInvocation(skill, ""))
+ }
+ }
+
+ intent := strings.TrimSpace(option.Prompt)
+ if intent == "" && option.TaskFile != "" {
+ data, err := os.ReadFile(option.TaskFile)
+ if err != nil {
+ return "", fmt.Errorf("read task file: %w", err)
+ }
+ intent = strings.TrimSpace(string(data))
+ }
+ if intent == "" {
+ intent = "Process the scanner output according to the user's intent. If no specific intent is provided, briefly explain the important evidence in the output."
+ }
+ intent, err := cfg.ApplySelectedSkills(intent, scan.FilterAutoSkill(option.Skills, command), store)
+ if err != nil {
+ return "", err
+ }
+ sections = append(sections, intent)
+ return strings.Join(sections, "\n\n"), nil
+}
+
+func ioaServe(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error {
+ return ioaserve.RunServe(ctx, ioaserve.Config{
+ URL: option.IOAURL,
+ Token: option.IOAToken,
+ DB: "",
+ }, logger)
+}
+
+func ioaClientCommand(ctx context.Context, mode cfg.RunMode, option *cfg.Option, args cfg.IOAClientArgs, logger telemetry.Logger) error {
+ ioaURL := option.IOAURL
+ if ioaURL == "" {
+ ioaURL = "http://127.0.0.1:8765"
+ }
+ client, err := ioaclient.NewClient(ioaURL, "")
+ if err != nil {
+ return fmt.Errorf("connect to IOA server: %w", err)
+ }
+ if client.AccessKey() != "" {
+ if err := client.EnsureRegistered(ctx, "aiscan-cli", "", nil); err != nil {
+ return fmt.Errorf("IOA auth register: %w", err)
+ }
+ }
+
+ switch mode {
+ case cfg.RunModeIOASpaces:
+ return tui.RunIOASpaces(ctx, client, option, os.Stdout, os.Stderr)
+ case cfg.RunModeIOAMessages:
+ return tui.RunIOAMessages(ctx, client, option, args, os.Stdout, os.Stderr)
+ case cfg.RunModeIOAContext:
+ return tui.RunIOAContext(ctx, client, option, args, os.Stdout, os.Stderr)
+ case cfg.RunModeIOANodes:
+ return tui.RunIOANodes(ctx, client, option, args, os.Stdout, os.Stderr)
+ default:
+ return fmt.Errorf("unknown ioa mode: %s", mode)
+ }
+}
diff --git a/cmd/web/imports.go b/cmd/web/imports.go
new file mode 100644
index 00000000..c1f29889
--- /dev/null
+++ b/cmd/web/imports.go
@@ -0,0 +1,10 @@
+package main
+
+// Command registration via init() side effects for the web entrypoint.
+
+import (
+ _ "github.com/chainreactors/aiscan/pkg/tools"
+ _ "github.com/chainreactors/aiscan/pkg/tools/ioa"
+ _ "github.com/chainreactors/aiscan/pkg/tools/proxy"
+ _ "github.com/chainreactors/aiscan/pkg/tools/search"
+)
diff --git a/cmd/web/imports_full.go b/cmd/web/imports_full.go
new file mode 100644
index 00000000..2406b405
--- /dev/null
+++ b/cmd/web/imports_full.go
@@ -0,0 +1,5 @@
+//go:build full
+
+package main
+
+import _ "github.com/chainreactors/aiscan/pkg/tools/playwright"
diff --git a/cmd/web/main.go b/cmd/web/main.go
new file mode 100644
index 00000000..ad6a102e
--- /dev/null
+++ b/cmd/web/main.go
@@ -0,0 +1,314 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "os/signal"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/core/runner"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ "github.com/chainreactors/aiscan/pkg/web"
+ webstatic "github.com/chainreactors/aiscan/web"
+ "github.com/chainreactors/ioa/protocols"
+ ioaserver "github.com/chainreactors/ioa/server"
+ "gopkg.in/yaml.v3"
+)
+
+func main() {
+ addr := flag.String("addr", "127.0.0.1:8080", "HTTP listen address")
+ dbPath := flag.String("db", "aiscan-web.db", "SQLite database path")
+ configFile := flag.String("config", "", "Path to aiscan config.yaml")
+ debug := flag.Bool("debug", false, "Enable debug logging")
+ maxScans := flag.Int("max-scans", 3, "Maximum concurrent scans")
+ scanTimeout := flag.Int("scan-timeout", 600, "Maximum scan runtime in seconds")
+ ioaToken := flag.String("ioa-token", "", "IOA access key (auto-generated if empty)")
+ flag.Parse()
+
+ logger := telemetry.GlobalLogger(telemetry.LogConfig{
+ Debug: *debug,
+ Output: os.Stderr,
+ Color: true,
+ })
+
+ store, err := web.NewSQLiteStore(*dbPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: open database: %s\n", err)
+ os.Exit(1)
+ }
+ defer store.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ application, err := initApp(ctx, *configFile, logger)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: init aiscan: %s\n", err)
+ os.Exit(1)
+ }
+
+ if application.Provider != nil {
+ logger.Infof("LLM provider ready, AI features enabled")
+ } else {
+ logger.Warnf("no LLM provider configured, AI features disabled (set api_key in config.yaml or env)")
+ }
+
+ service := web.NewService(web.ServiceConfig{
+ Store: store,
+ App: application,
+ ConfigStore: &llmConfigFileStore{explicit: *configFile},
+ AppFactory: func(ctx context.Context) (*runner.App, error) { return initApp(ctx, *configFile, logger) },
+ MaxConcurrent: *maxScans,
+ ScanTimeout: time.Duration(*scanTimeout) * time.Second,
+ })
+ defer service.Close()
+
+ var pool *web.AgentPool
+ if *debug {
+ pool = web.NewAgentPool(service.Hub(), "*")
+ } else {
+ pool = web.NewAgentPool(service.Hub())
+ }
+ service.SetAgentPool(pool)
+
+ staticSub, err := fs.Sub(webstatic.FS, "static")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: load static assets: %s\n", err)
+ os.Exit(1)
+ }
+
+ // Embedded IOA server
+ accessKey := *ioaToken
+ if accessKey == "" {
+ accessKey = protocols.NewToken()
+ }
+ ioaSvc := ioaserver.NewService(ioaserver.NewMemoryStore(), accessKey)
+ ioaHandler := ioaserver.AuthMiddleware(ioaSvc)(ioaserver.NewHandler(ioaSvc))
+
+ handler := web.NewHandler(service, pool, ioaHandler, newSPAFileServer(staticSub))
+
+ srv := &http.Server{
+ Addr: *addr,
+ Handler: handler,
+ }
+
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ go func() {
+ <-sigCh
+ logger.Infof("shutting down...")
+ cancel()
+ shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer shutCancel()
+ _ = srv.Shutdown(shutCtx)
+ }()
+
+ logger.Infof("aiscan web server listening on http://%s", *addr)
+ logger.Infof("IOA server embedded at http://%s/ioa (token=%s)", *addr, accessKey)
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ fmt.Fprintf(os.Stderr, "error: %s\n", err)
+ os.Exit(1)
+ }
+}
+
+func newSPAFileServer(fsys fs.FS) http.HandlerFunc {
+ fileServer := http.FileServer(http.FS(fsys))
+ return func(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
+ if name != "" {
+ if f, err := fsys.Open(name); err == nil {
+ f.Close()
+ fileServer.ServeHTTP(w, r)
+ return
+ }
+ }
+ r = r.Clone(r.Context())
+ r.URL.Path = "/"
+ fileServer.ServeHTTP(w, r)
+ }
+}
+
+type yamlConfig struct {
+ LLM struct {
+ Provider string `yaml:"provider" config:"provider"`
+ BaseURL string `yaml:"base_url" config:"base_url"`
+ APIKey string `yaml:"api_key" config:"api_key"`
+ Model string `yaml:"model" config:"model"`
+ Proxy string `yaml:"proxy" config:"proxy"`
+ } `yaml:"llm" config:"llm"`
+ Cyberhub struct {
+ URL string `yaml:"url" config:"url"`
+ Key string `yaml:"key" config:"key"`
+ Mode string `yaml:"mode" config:"mode"`
+ Proxy string `yaml:"proxy" config:"proxy"`
+ } `yaml:"cyberhub" config:"cyberhub"`
+ Scan struct {
+ Verify string `yaml:"verify" config:"verify"`
+ VerifyTimeout int `yaml:"verify_timeout" config:"verify_timeout"`
+ } `yaml:"scan" config:"scan"`
+ Search struct {
+ TavilyKeys string `yaml:"tavily_keys" config:"tavily_keys"`
+ } `yaml:"search" config:"search"`
+}
+
+type llmConfigFileStore struct {
+ explicit string
+ mu sync.Mutex
+}
+
+func (s *llmConfigFileStore) GetLLMConfig(ctx context.Context) (web.LLMConfig, error) {
+ if err := ctx.Err(); err != nil {
+ return web.LLMConfig{}, err
+ }
+ path, loaded := s.resolvePath()
+ cfg := yamlConfig{}
+ if loaded {
+ cfg = loadYAMLConfig(path)
+ }
+ return web.LLMConfig{
+ ConfigPath: path,
+ ConfigLoaded: loaded,
+ Provider: cfg.LLM.Provider,
+ BaseURL: cfg.LLM.BaseURL,
+ APIKeyConfigured: strings.TrimSpace(cfg.LLM.APIKey) != "",
+ Model: cfg.LLM.Model,
+ Proxy: cfg.LLM.Proxy,
+ }, nil
+}
+
+func (s *llmConfigFileStore) SaveLLMConfig(ctx context.Context, cfg web.LLMConfig) (web.LLMConfig, error) {
+ if err := ctx.Err(); err != nil {
+ return web.LLMConfig{}, err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ path, loaded := s.resolvePath()
+ var data []byte
+ if loaded {
+ current, err := os.ReadFile(path)
+ if err != nil {
+ return web.LLMConfig{}, err
+ }
+ data = current
+ }
+
+ current := yamlConfig{}
+ if len(data) > 0 {
+ _ = yaml.Unmarshal(data, ¤t)
+ }
+ apiKey := strings.TrimSpace(cfg.APIKey)
+ if apiKey == "" {
+ apiKey = current.LLM.APIKey
+ }
+
+ current.LLM.Provider = strings.TrimSpace(cfg.Provider)
+ current.LLM.BaseURL = strings.TrimSpace(cfg.BaseURL)
+ current.LLM.APIKey = apiKey
+ current.LLM.Model = strings.TrimSpace(cfg.Model)
+ current.LLM.Proxy = strings.TrimSpace(cfg.Proxy)
+ next, _ := yaml.Marshal(¤t)
+ if dir := filepath.Dir(path); dir != "." && dir != "" {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return web.LLMConfig{}, err
+ }
+ }
+ if err := os.WriteFile(path, next, 0600); err != nil {
+ return web.LLMConfig{}, err
+ }
+ saved := loadYAMLConfig(path)
+ return web.LLMConfig{
+ ConfigPath: path,
+ ConfigLoaded: true,
+ Provider: saved.LLM.Provider,
+ BaseURL: saved.LLM.BaseURL,
+ APIKeyConfigured: strings.TrimSpace(saved.LLM.APIKey) != "",
+ Model: saved.LLM.Model,
+ Proxy: saved.LLM.Proxy,
+ }, nil
+}
+
+func (s *llmConfigFileStore) resolvePath() (string, bool) {
+ path := findConfigFile(s.explicit)
+ if path != "" {
+ return path, true
+ }
+ if s.explicit != "" {
+ return s.explicit, false
+ }
+ return "config.yaml", false
+}
+
+
+func findConfigFile(explicit string) string {
+ if explicit != "" {
+ return explicit
+ }
+ if _, err := os.Stat("config.yaml"); err == nil {
+ return "config.yaml"
+ }
+ if dir, err := os.UserConfigDir(); err == nil {
+ p := filepath.Join(dir, "aiscan", "config.yaml")
+ if _, err := os.Stat(p); err == nil {
+ return p
+ }
+ }
+ return ""
+}
+
+func loadYAMLConfig(path string) yamlConfig {
+ var c yamlConfig
+ if path == "" {
+ return c
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return c
+ }
+ _ = yaml.Unmarshal(data, &c)
+ return c
+}
+
+func initApp(ctx context.Context, configFile string, logger telemetry.Logger) (*runner.App, error) {
+ option := cfg.Option{}
+ if configFile != "" {
+ option.ConfigFile = configFile
+ }
+ cfgPath, err := cfg.ResolveRuntimeConfig(&option)
+ if err != nil {
+ return nil, err
+ }
+ if cfgPath != "" {
+ logger.Infof("loaded config: %s", cfgPath)
+ }
+
+ appCfg := cfg.AppConfig(&option, cfg.RuntimeFeatures{
+ ProviderEnabled: true,
+ ProviderOptional: true,
+ ToolsEnabled: true,
+ AIEnabled: true,
+ }, logger)
+ appCfg.Scanner.EnableAllAISkills = false
+ appCfg.Scanner.VerifyMode = "off"
+
+ app, err := runner.NewApp(ctx, appCfg)
+ if err != nil {
+ return nil, err
+ }
+ if err := app.WaitEngines(ctx); err != nil {
+ app.Close()
+ return nil, err
+ }
+ return app, nil
+}
+
diff --git a/cmd/web/main_test.go b/cmd/web/main_test.go
new file mode 100644
index 00000000..ead9634d
--- /dev/null
+++ b/cmd/web/main_test.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/chainreactors/aiscan/pkg/web"
+)
+
+func TestLLMConfigFileStoreReadsAndWritesConfig(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if err := os.WriteFile(path, []byte(`llm:
+ provider: ""
+ base_url: ""
+ api_key: ""
+ model: ""
+ proxy: ""
+cyberhub:
+ mode: "merge"
+`), 0600); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+
+ store := &llmConfigFileStore{explicit: path}
+ cfg, err := store.GetLLMConfig(context.Background())
+ if err != nil {
+ t.Fatalf("GetLLMConfig() error = %v", err)
+ }
+ if !cfg.ConfigLoaded || cfg.Provider != "" || cfg.APIKeyConfigured {
+ t.Fatalf("initial config = %#v", cfg)
+ }
+
+ saved, err := store.SaveLLMConfig(context.Background(), web.LLMConfig{
+ Provider: "ollama",
+ BaseURL: "http://127.0.0.1:11434/v1",
+ Model: "qwen2.5",
+ })
+ if err != nil {
+ t.Fatalf("SaveLLMConfig() error = %v", err)
+ }
+ if saved.Provider != "ollama" || saved.Model != "qwen2.5" || saved.APIKeyConfigured {
+ t.Fatalf("saved config = %#v", saved)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read config: %v", err)
+ }
+ content := string(data)
+ for _, want := range []string{
+ `provider: ollama`,
+ `base_url: http://127.0.0.1:11434/v1`,
+ `model: qwen2.5`,
+ `cyberhub:`,
+ } {
+ if !strings.Contains(content, want) {
+ t.Fatalf("config missing %q:\n%s", want, content)
+ }
+ }
+}
diff --git a/cmd/web/setup.go b/cmd/web/setup.go
new file mode 100644
index 00000000..09e6c8d0
--- /dev/null
+++ b/cmd/web/setup.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+ "context"
+ "os"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/core/resources"
+ "github.com/chainreactors/aiscan/core/runner"
+ "github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ "github.com/chainreactors/aiscan/pkg/tools/scan"
+ "github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+ "github.com/chainreactors/aiscan/skills"
+)
+
+func init() {
+ runner.ScannerInitFunc = scannerInit
+}
+
+func scannerInit(ctx context.Context, app *runner.App, rc cfg.RuntimeConfig, logger telemetry.Logger) {
+ engineSet := initEngines(ctx, rc.Scanner, logger)
+ app.Engines = engineSet
+ registerScannerCommands(app.Commands, engineSet, rc.Scanner, rc.Tools, app.Provider, app.ProviderConfig.Model, app.Skills, logger)
+}
+
+func initEngines(ctx context.Context, sc cfg.ScannerConfig, logger telemetry.Logger) *engine.Set {
+ engineSet, err := engine.InitWithOptions(ctx, resources.Options{
+ CyberhubURL: sc.CyberhubURL,
+ APIKey: sc.CyberhubKey,
+ Mode: sc.CyberhubMode,
+ Proxy: sc.Proxy,
+ }, logger)
+ if err != nil {
+ logger.Warnf("scanner engines init error=%q action=continue_without_scanners", err)
+ return nil
+ }
+ engineSet.SetupUncover(engine.ReconOptions{
+ FofaEmail: sc.FofaEmail,
+ FofaKey: sc.FofaKey,
+ HunterToken: sc.HunterToken,
+ HunterAPIKey: sc.HunterAPIKey,
+ IngressProxy: sc.ReconProxy,
+ Limit: sc.ReconLimit,
+ }, logger)
+ return engineSet
+}
+
+func registerScannerCommands(cmdReg *commands.CommandRegistry, engineSet *engine.Set, scanCfg cfg.ScannerConfig, toolCfg cfg.ToolConfig, llmProvider agent.Provider, model string, skillStore *skills.Store, logger telemetry.Logger) {
+ var scanOpts []any
+ if scanCfg.AIEnabled && llmProvider != nil {
+ scanOpts = append(scanOpts, scan.WithParent(agent.NewAgent(agent.Config{
+ Provider: llmProvider,
+ Tools: cmdReg,
+ Model: model,
+ Logger: logger,
+ })))
+ scanOpts = append(scanOpts, scan.WithDeepBrowserFunc(func(ctx context.Context, targetURL string) (string, error) {
+ return runner.CollectDeepBrowserArtifacts(ctx, cmdReg, targetURL, logger)
+ }))
+ if skillStore != nil {
+ scanOpts = append(scanOpts, scan.WithSkillReader(func(name string) string {
+ content, ok, err := skillStore.ReadVirtual("aiscan://skills/scan/" + name + ".md")
+ if !ok || err != nil {
+ return ""
+ }
+ return content
+ }))
+ }
+ }
+ scanOpts = append(scanOpts, scan.WithLogger(logger))
+
+ workDir, _ := os.Getwd()
+ deps := &commands.Deps{
+ WorkDir: workDir,
+ BashTimeout: toolCfg.BashTimeout,
+ SkillStore: skillStore,
+ EngineSet: engineSet,
+ ScannerProxy: scanCfg.Proxy,
+ ScanOpts: scanOpts,
+ Logger: logger,
+ Model: model,
+ TavilyKeys: toolCfg.TavilyKeys,
+ }
+ if engineSet != nil {
+ deps.Resources = engineSet.Resources
+ }
+ commands.BuildGroup("scanner", deps, cmdReg)
+ commands.BuildGroup("proxy", deps, cmdReg)
+ commands.BuildGroup("ioa", deps, cmdReg)
+ logger.Infof("scanner commands ready: %v", cmdReg.GroupNames("scanner"))
+}
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 00000000..6ffdfd81
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,75 @@
+# aiscan 配置文件
+#
+# 编译时: build.sh 读取此文件,通过 ldflags 将配置固化到二进制
+# 运行时: aiscan 自动加载 ./config.yaml 或 ~/.config/aiscan/config.yaml
+# 优先级: CLI 参数 > 环境变量 > 配置文件(-c 或默认路径)> 编译时固化值
+#
+# 仅填写需要的字段,留空或删除的字段不会覆盖其他来源的值
+
+# LLM Provider 配置
+llm:
+ # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic
+ # 环境变量: AISCAN_PROVIDER / AISCAN_LLM_PROVIDER
+ provider: ""
+ # API base URL(留空使用 provider 默认值)
+ # 环境变量: AISCAN_BASE_URL / AISCAN_BASEURL / AISCAN_LLM_BASE_URL / AISCAN_LLM_BASEURL
+ # OpenAI/Codex 风格: OPENAI_BASE_URL / OPENAI_BASEURL
+ # Claude Code 风格: ANTHROPIC_BASE_URL / ANTHROPIC_BASEURL
+ base_url: ""
+ # API key(建议使用环境变量而非写入文件)
+ # 环境变量: AISCAN_API_KEY / Provider 对应 API key 变量(如 OPENAI_API_KEY)
+ api_key: ""
+ # 模型名称
+ # 环境变量: AISCAN_MODEL / AISCAN_LLM_MODEL
+ # OpenAI/Codex 风格: OPENAI_MODEL
+ # Claude Code 风格: ANTHROPIC_MODEL
+ model: ""
+ # 访问 LLM API 的 HTTP proxy
+ # 环境变量: AISCAN_LLM_PROXY
+ proxy: ""
+
+# Cyberhub 资源服务
+cyberhub:
+ url: ""
+ key: ""
+ # merge 或 override
+ mode: ""
+
+# IOA 协作
+ioa:
+ url: ""
+ db: ""
+ node_name: ""
+ space: ""
+
+# 扫描默认值
+scan:
+ # auto, off, low, medium, high, critical
+ verify: ""
+ # 单次验证超时秒数(0 表示不覆盖)
+ verify_timeout: 0
+
+# 搜索
+search:
+ # Tavily API keys(逗号分隔,留空则 fallback 到 DuckDuckGo)
+ tavily_keys: ""
+ # search tavily 请求代理(如 http://127.0.0.1:7890 或 socks5://...)
+ proxy: ""
+
+# 被动信息收集
+recon:
+ fofa_email: ""
+ fofa_key: ""
+ hunter_api_key: ""
+
+# 通用选项
+misc:
+ debug: false
+ quiet: false
+ no_color: false
+
+# 以下仅 build.sh 使用
+build:
+ osarch: ""
+ tags: ""
+ output: dist
diff --git a/core/config/app_config.go b/core/config/app_config.go
new file mode 100644
index 00000000..b5195856
--- /dev/null
+++ b/core/config/app_config.go
@@ -0,0 +1,71 @@
+package config
+
+import (
+ "strings"
+
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+)
+
+type RuntimeFeatures struct {
+ ProviderEnabled bool
+ ProviderOptional bool
+ ToolsEnabled bool
+ AIEnabled bool
+ ScannerAI bool
+ Warning string
+}
+
+func AppConfig(option *Option, features RuntimeFeatures, logger telemetry.Logger) RuntimeConfig {
+ return RuntimeConfig{
+ Provider: RuntimeProviderConfig{
+ Enabled: features.ProviderEnabled,
+ Config: ProviderConfig(option),
+ Fallbacks: FallbackProviderConfigs(option),
+ Optional: features.ProviderOptional,
+ },
+ Scanner: ScannerConfig{
+ CyberhubURL: option.CyberhubURL,
+ CyberhubKey: option.CyberhubKey,
+ CyberhubMode: option.CyberhubMode,
+ AIEnabled: features.AIEnabled,
+ EnableAllAISkills: option.AI,
+ AITimeout: DefaultInt(DefaultVerifyTimeout, 120),
+ VerifyMode: DefaultVerify,
+ Proxy: option.Proxy,
+ FofaEmail: option.FofaEmail,
+ FofaKey: option.FofaKey,
+ HunterToken: option.HunterToken,
+ HunterAPIKey: option.HunterAPIKey,
+ ReconProxy: option.ReconProxy,
+ ReconLimit: intOptionValue(option.ReconLimit),
+ },
+ Tools: ToolConfig{
+ Enabled: features.ToolsEnabled,
+ BashTimeout: 300,
+ TavilyKeys: DefaultTavilyKeys,
+ },
+ Logger: logger,
+ CLISkillPaths: skillPathsFromOptions(option),
+ }
+}
+
+func skillPathsFromOptions(option *Option) []string {
+ var paths []string
+ for _, s := range option.Skills {
+ if looksLikePath(s) {
+ paths = append(paths, s)
+ }
+ }
+ return paths
+}
+
+func looksLikePath(s string) bool {
+ return strings.ContainsAny(s, `/\`) || strings.HasPrefix(s, ".")
+}
+
+func intOptionValue(p *int) int {
+ if p != nil {
+ return *p
+ }
+ return 0
+}
diff --git a/core/config/config_test.go b/core/config/config_test.go
new file mode 100644
index 00000000..12a82662
--- /dev/null
+++ b/core/config/config_test.go
@@ -0,0 +1,624 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+)
+
+func writeTestConfig(t *testing.T, dir, content string) string {
+ t.Helper()
+ path := filepath.Join(dir, "config.yaml")
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestMergeOptionOnlyFillsEmpty(t *testing.T) {
+ dst := Option{}
+ dst.Provider = "cli-provider"
+ dst.Model = ""
+
+ src := Option{}
+ src.Provider = "config-provider"
+ src.Model = "config-model"
+ src.CyberhubURL = "http://config-hub:9000"
+
+ mergeOption(&dst, &src)
+
+ if dst.Provider != "cli-provider" {
+ t.Errorf("Provider: got %q, want %q (CLI should win)", dst.Provider, "cli-provider")
+ }
+ if dst.Model != "config-model" {
+ t.Errorf("Model: got %q, want %q (config should fill empty)", dst.Model, "config-model")
+ }
+ if dst.CyberhubURL != "http://config-hub:9000" {
+ t.Errorf("CyberhubURL: got %q, want %q", dst.CyberhubURL, "http://config-hub:9000")
+ }
+}
+
+func TestMergeOptionSpaceDefault(t *testing.T) {
+ dst := Option{}
+ dst.Space = "default"
+
+ src := Option{}
+ src.Space = "production"
+
+ mergeOption(&dst, &src)
+
+ if dst.Space != "production" {
+ t.Errorf("Space: got %q, want %q (config should override go-flags default)", dst.Space, "production")
+ }
+}
+
+func TestMergeOptionSpaceExplicitCLI(t *testing.T) {
+ dst := Option{}
+ dst.Space = "cli-space"
+
+ src := Option{}
+ src.Space = "config-space"
+
+ mergeOption(&dst, &src)
+
+ if dst.Space != "cli-space" {
+ t.Errorf("Space: got %q, want %q (CLI should win)", dst.Space, "cli-space")
+ }
+}
+
+func TestLoadConfig(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: deepseek
+ model: deepseek-chat
+ base_url: https://api.deepseek.com/v1
+cyberhub:
+ url: http://hub:9000
+ key: testkey
+ mode: override
+agent:
+ web_url: http://web:8080
+ioa:
+ url: http://ioa:8765
+ space: case-1
+`)
+
+ var opt Option
+ err := LoadConfig(filepath.Join(dir, "config.yaml"), &opt)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ checks := []struct{ field, got, want string }{
+ {"Provider", opt.Provider, "deepseek"},
+ {"Model", opt.Model, "deepseek-chat"},
+ {"BaseURL", opt.BaseURL, "https://api.deepseek.com/v1"},
+ {"CyberhubURL", opt.CyberhubURL, "http://hub:9000"},
+ {"CyberhubKey", opt.CyberhubKey, "testkey"},
+ {"CyberhubMode", opt.CyberhubMode, "override"},
+ {"WebURL", opt.WebURL, "http://web:8080"},
+ {"IOAURL", opt.IOAURL, "http://ioa:8765"},
+ {"Space", opt.Space, "case-1"},
+ }
+ for _, c := range checks {
+ if c.got != c.want {
+ t.Errorf("%s: got %q, want %q", c.field, c.got, c.want)
+ }
+ }
+}
+
+func TestLoadConfigReconNumericZeroIsExplicit(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+recon:
+ limit: 0
+`)
+
+ var opt Option
+ if err := LoadConfig(filepath.Join(dir, "config.yaml"), &opt); err != nil {
+ t.Fatal(err)
+ }
+ if opt.ReconLimit == nil || *opt.ReconLimit != 0 {
+ t.Fatalf("ReconLimit = %#v, want explicit 0", opt.ReconLimit)
+ }
+}
+
+func TestMergeOptionReconExplicitZeroWins(t *testing.T) {
+ zeroInt := 0
+ cfgLimit := 10
+ dst := Option{ReconOptions: ReconOptions{
+ ReconLimit: &zeroInt,
+ }}
+ src := Option{ReconOptions: ReconOptions{
+ ReconLimit: &cfgLimit,
+ }}
+
+ mergeOption(&dst, &src)
+
+ if *dst.ReconLimit != 0 {
+ t.Fatalf("explicit zero was overwritten: %#v", dst.ReconOptions)
+ }
+}
+
+func TestLoadConfigEmptyFieldsAreZero(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: ""
+ model: ""
+cyberhub:
+ url: ""
+`)
+
+ var opt Option
+ if err := LoadConfig(filepath.Join(dir, "config.yaml"), &opt); err != nil {
+ t.Fatal(err)
+ }
+ if opt.Provider != "" {
+ t.Errorf("Provider should be empty, got %q", opt.Provider)
+ }
+ if opt.Model != "" {
+ t.Errorf("Model should be empty, got %q", opt.Model)
+ }
+ if opt.CyberhubURL != "" {
+ t.Errorf("CyberhubURL should be empty, got %q", opt.CyberhubURL)
+ }
+}
+
+func TestPriorityCLIOverConfig(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: config-provider
+ model: config-model
+ api_key: config-key
+cyberhub:
+ url: http://config-hub:9000
+`)
+
+ option := Option{}
+ option.Provider = "cli-provider"
+ option.APIKey = "cli-key"
+
+ var loaded Option
+ if err := LoadConfig(filepath.Join(dir, "config.yaml"), &loaded); err != nil {
+ t.Fatal(err)
+ }
+ mergeOption(&option, &loaded)
+
+ if option.Provider != "cli-provider" {
+ t.Errorf("Provider: got %q, want %q (CLI wins)", option.Provider, "cli-provider")
+ }
+ if option.APIKey != "cli-key" {
+ t.Errorf("APIKey: got %q, want %q (CLI wins)", option.APIKey, "cli-key")
+ }
+ if option.Model != "config-model" {
+ t.Errorf("Model: got %q, want %q (config fills empty)", option.Model, "config-model")
+ }
+ if option.CyberhubURL != "http://config-hub:9000" {
+ t.Errorf("CyberhubURL: got %q, want %q (config fills empty)", option.CyberhubURL, "http://config-hub:9000")
+ }
+}
+
+func TestPriorityCustomConfigSameAsMerge(t *testing.T) {
+ dir := t.TempDir()
+ customPath := writeTestConfig(t, dir, `
+llm:
+ provider: custom-provider
+ model: custom-model
+`)
+
+ option := Option{}
+ option.Provider = "cli-provider"
+ option.ConfigFile = customPath
+
+ var loaded Option
+ if err := LoadConfig(customPath, &loaded); err != nil {
+ t.Fatal(err)
+ }
+ mergeOption(&option, &loaded)
+
+ if option.Provider != "cli-provider" {
+ t.Errorf("Provider: got %q, want %q (CLI > -c)", option.Provider, "cli-provider")
+ }
+ if option.Model != "custom-model" {
+ t.Errorf("Model: got %q, want %q (-c fills empty)", option.Model, "custom-model")
+ }
+}
+
+func TestPriorityConfigOverBuild(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: config-provider
+`)
+
+ withDefaults(t, func() {
+ DefaultProvider = "build-provider"
+
+ option := Option{}
+ var loaded Option
+ if err := LoadConfig(filepath.Join(dir, "config.yaml"), &loaded); err != nil {
+ t.Fatal(err)
+ }
+ mergeOption(&option, &loaded)
+
+ if option.Provider != "config-provider" {
+ t.Errorf("Provider: got %q, want %q (config fills empty)", option.Provider, "config-provider")
+ }
+
+ ApplyDefaults(&option)
+ if option.Provider != "config-provider" {
+ t.Errorf("Provider after ApplyDefaults: got %q, want %q (config > build)", option.Provider, "config-provider")
+ }
+ })
+}
+
+func TestPriorityBuildFillsRemaining(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: ""
+`)
+
+ withDefaults(t, func() {
+ DefaultModel = "build-model"
+
+ option := Option{}
+ var loaded Option
+ if err := LoadConfig(filepath.Join(dir, "config.yaml"), &loaded); err != nil {
+ t.Fatal(err)
+ }
+ mergeOption(&option, &loaded)
+
+ if option.Model != "" {
+ t.Errorf("Model before ApplyDefaults: got %q, want empty", option.Model)
+ }
+
+ ApplyDefaults(&option)
+ if option.Model != "build-model" {
+ t.Errorf("Model after ApplyDefaults: got %q, want %q (build fills remaining)", option.Model, "build-model")
+ }
+ })
+}
+
+func TestLoadConfigSearchOptions(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+search:
+ tavily_keys: "K1,K2"
+`)
+
+ withDefaults(t, func() {
+ if err := loadRuntimeDefaults(filepath.Join(dir, "config.yaml")); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := AppConfig(&Option{}, RuntimeFeatures{ToolsEnabled: true}, telemetry.NopLogger())
+ if cfg.Tools.TavilyKeys != "K1,K2" {
+ t.Fatalf("tool config = %#v", cfg.Tools)
+ }
+ })
+}
+
+func TestLoadScanDefaults(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+scan:
+ verify: critical
+ verify_timeout: 90
+`)
+
+ withDefaults(t, func() {
+ if err := loadRuntimeDefaults(filepath.Join(dir, "config.yaml")); err != nil {
+ t.Fatal(err)
+ }
+
+ if DefaultVerify != "critical" {
+ t.Errorf("DefaultVerify: got %q, want %q", DefaultVerify, "critical")
+ }
+ if DefaultVerifyTimeout != "90" {
+ t.Errorf("DefaultVerifyTimeout: got %q, want %q", DefaultVerifyTimeout, "90")
+ }
+ })
+}
+
+func TestLoadAndApplyConfigDefaultFile(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: found-provider
+`)
+
+ origDir, _ := os.Getwd()
+ os.Chdir(dir)
+ defer os.Chdir(origDir)
+
+ option := Option{}
+ path, err := LoadAndApplyConfig(&option)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if path == "" {
+ t.Fatal("expected config.yaml to be found")
+ }
+ if option.Provider != "found-provider" {
+ t.Errorf("Provider: got %q, want %q", option.Provider, "found-provider")
+ }
+}
+
+func TestLoadAndApplyConfigCustomFile(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: default-provider
+ model: default-model
+`)
+ customDir := t.TempDir()
+ customPath := writeTestConfig(t, customDir, `
+llm:
+ provider: custom-provider
+`)
+
+ origDir, _ := os.Getwd()
+ os.Chdir(dir)
+ defer os.Chdir(origDir)
+
+ option := Option{}
+ option.ConfigFile = customPath
+ path, err := LoadAndApplyConfig(&option)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if path != customPath {
+ t.Errorf("path: got %q, want %q", path, customPath)
+ }
+ if option.Provider != "custom-provider" {
+ t.Errorf("Provider: got %q, want %q (-c wins over default)", option.Provider, "custom-provider")
+ }
+ if option.Model != "" {
+ t.Errorf("Model: got %q, want empty (-c replaces default config, not merges)", option.Model)
+ }
+}
+
+func TestLoadAndApplyConfigRejectsMalformedFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ if err := os.WriteFile(path, []byte("llm:\n provider: [\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ option := Option{}
+ option.ConfigFile = path
+ gotPath, err := LoadAndApplyConfig(&option)
+ if err == nil {
+ t.Fatal("expected malformed config to return an error")
+ }
+ if gotPath != path {
+ t.Errorf("path: got %q, want %q", gotPath, path)
+ }
+ if option.Provider != "" {
+ t.Errorf("Provider: got %q, want empty after failed config load", option.Provider)
+ }
+}
+
+func TestLoadAndApplyConfigRejectsMissingExplicitFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "missing.yaml")
+ option := Option{}
+ option.ConfigFile = path
+
+ gotPath, err := LoadAndApplyConfig(&option)
+ if err == nil {
+ t.Fatal("expected missing explicit config to return an error")
+ }
+ if gotPath != "" {
+ t.Errorf("path: got %q, want empty", gotPath)
+ }
+}
+
+func TestInitDefaultConfig(t *testing.T) {
+ content := InitDefaultConfig()
+ if len(content) < 100 {
+ t.Error("generated config too short")
+ }
+
+ var opt Option
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ os.WriteFile(path, []byte(content), 0o644)
+ if err := LoadConfig(path, &opt); err != nil {
+ t.Errorf("generated config should be parseable: %v", err)
+ }
+}
+
+func TestFullPriorityChain(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: config-provider
+ model: config-model
+ api_key: config-key
+cyberhub:
+ url: http://config-hub:9000
+ proxy: config-proxy
+`)
+
+ withDefaults(t, func() {
+ DefaultProvider = "build-provider"
+ DefaultModel = "build-model"
+ DefaultScannerProxy = "build-proxy"
+
+ origDir, _ := os.Getwd()
+ os.Chdir(dir)
+ defer os.Chdir(origDir)
+
+ option := Option{}
+ option.Provider = "cli-provider"
+
+ if _, err := LoadAndApplyConfig(&option); err != nil {
+ t.Fatal(err)
+ }
+ ApplyDefaults(&option)
+
+ checks := []struct{ field, got, want, reason string }{
+ {"Provider", option.Provider, "cli-provider", "CLI > config > build"},
+ {"Model", option.Model, "config-model", "config > build (CLI empty)"},
+ {"APIKey", option.APIKey, "config-key", "config fills empty"},
+ {"Proxy", option.Proxy, "config-proxy", "config > build"},
+ {"CyberhubURL", option.CyberhubURL, "http://config-hub:9000", "config fills empty"},
+ }
+ for _, c := range checks {
+ if c.got != c.want {
+ t.Errorf("%s: got %q, want %q (%s)", c.field, c.got, c.want, c.reason)
+ }
+ }
+ })
+}
+
+func TestResolveRuntimeConfigEnvOverridesConfig(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: deepseek
+ base_url: https://config.example/v1
+ api_key: config-key
+ model: config-model
+ proxy: http://config-proxy:7890
+cyberhub:
+ url: http://config-hub:9000
+`)
+ t.Setenv("AISCAN_MODEL", "env-model")
+ t.Setenv("AISCAN_BASE_URL", "https://env.example/v1")
+ t.Setenv("AISCAN_API_KEY", "env-key")
+ t.Setenv("AISCAN_LLM_PROXY", "http://env-proxy:7890")
+ t.Setenv("CYBERHUB_URL", "http://env-hub:9000")
+
+ withDefaults(t, func() {
+ origDir, _ := os.Getwd()
+ os.Chdir(dir)
+ defer os.Chdir(origDir)
+
+ option := Option{}
+ if _, err := ResolveRuntimeConfig(&option); err != nil {
+ t.Fatal(err)
+ }
+
+ checks := []struct{ field, got, want string }{
+ {"Provider", option.Provider, "deepseek"},
+ {"BaseURL", option.BaseURL, "https://env.example/v1"},
+ {"APIKey", option.APIKey, "env-key"},
+ {"Model", option.Model, "env-model"},
+ {"LLMProxy", option.LLMProxy, "http://env-proxy:7890"},
+ {"CyberhubURL", option.CyberhubURL, "http://env-hub:9000"},
+ }
+ for _, c := range checks {
+ if c.got != c.want {
+ t.Errorf("%s: got %q, want %q", c.field, c.got, c.want)
+ }
+ }
+ })
+}
+
+func TestResolveRuntimeConfigCLIWinsOverEnv(t *testing.T) {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ model: config-model
+`)
+ t.Setenv("AISCAN_MODEL", "env-model")
+ t.Setenv("AISCAN_BASE_URL", "https://env.example/v1")
+ t.Setenv("AISCAN_API_KEY", "env-key")
+
+ withDefaults(t, func() {
+ origDir, _ := os.Getwd()
+ os.Chdir(dir)
+ defer os.Chdir(origDir)
+
+ option := Option{}
+ option.Model = "cli-model"
+ option.BaseURL = "https://cli.example/v1"
+ option.APIKey = "cli-key"
+ if _, err := ResolveRuntimeConfig(&option); err != nil {
+ t.Fatal(err)
+ }
+ if option.Model != "cli-model" || option.BaseURL != "https://cli.example/v1" || option.APIKey != "cli-key" {
+ t.Fatalf("CLI values were overridden: %#v", option.LLMOptions)
+ }
+ })
+}
+
+func TestResolveRuntimeConfigSupportsOpenAIEnvAliases(t *testing.T) {
+ t.Setenv("OPENAI_BASE_URL", "https://openai-proxy.example/v1")
+ t.Setenv("OPENAI_MODEL", "gpt-env")
+ t.Setenv("OPENAI_API_KEY", "openai-key")
+
+ withDefaults(t, func() {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: ""
+`)
+ origDir, _ := os.Getwd()
+ os.Chdir(dir)
+ defer os.Chdir(origDir)
+
+ option := Option{}
+ if _, err := ResolveRuntimeConfig(&option); err != nil {
+ t.Fatal(err)
+ }
+ if option.Provider != "openai" || option.BaseURL != "https://openai-proxy.example/v1" || option.Model != "gpt-env" || option.APIKey != "openai-key" {
+ t.Fatalf("OpenAI env aliases not applied: %#v", option.LLMOptions)
+ }
+ })
+}
+
+func TestResolveRuntimeConfigSupportsAnthropicEnvAliases(t *testing.T) {
+ t.Setenv("ANTHROPIC_BASE_URL", "https://anthropic-proxy.example/v1")
+ t.Setenv("ANTHROPIC_MODEL", "claude-env")
+ t.Setenv("ANTHROPIC_API_KEY", "anthropic-key")
+
+ withDefaults(t, func() {
+ dir := t.TempDir()
+ writeTestConfig(t, dir, `
+llm:
+ provider: ""
+`)
+ origDir, _ := os.Getwd()
+ os.Chdir(dir)
+ defer os.Chdir(origDir)
+
+ option := Option{}
+ if _, err := ResolveRuntimeConfig(&option); err != nil {
+ t.Fatal(err)
+ }
+ if option.Provider != "anthropic" || option.BaseURL != "https://anthropic-proxy.example/v1" || option.Model != "claude-env" || option.APIKey != "anthropic-key" {
+ t.Fatalf("Anthropic env aliases not applied: %#v", option.LLMOptions)
+ }
+ })
+}
+
+func withDefaults(t *testing.T, fn func()) {
+ t.Helper()
+ saved := []*string{
+ &DefaultProvider, &DefaultBaseURL, &DefaultAPIKey, &DefaultModel,
+ &DefaultScannerProxy, &DefaultCyberhubURL, &DefaultCyberhubKey,
+ &DefaultCyberhubMode, &DefaultVerify, &DefaultVerifyTimeout,
+ &DefaultTavilyKeys, &DefaultIOAURL, &DefaultIOANodeID,
+ &DefaultIOANodeName, &DefaultSpace,
+ }
+ originals := make([]string, len(saved))
+ for i, p := range saved {
+ originals[i] = *p
+ }
+ t.Cleanup(func() {
+ for i, p := range saved {
+ *p = originals[i]
+ }
+ })
+ fn()
+}
diff --git a/core/config/env.go b/core/config/env.go
new file mode 100644
index 00000000..e6fb24a2
--- /dev/null
+++ b/core/config/env.go
@@ -0,0 +1,187 @@
+package config
+
+import (
+ "os"
+ "strings"
+
+ "github.com/chainreactors/aiscan/pkg/agent"
+)
+
+type envLookup func(string) (string, bool)
+
+func ResolveRuntimeConfig(option *Option) (string, error) {
+ explicit := *option
+ configPath, err := LoadAndApplyConfig(option)
+ if err != nil {
+ return configPath, err
+ }
+ applyEnvironment(option, explicit, os.LookupEnv)
+ ApplyDefaults(option)
+ return configPath, nil
+}
+
+func applyEnvironment(option *Option, explicit Option, lookup envLookup) {
+ applyLLMEnvironment(option, explicit, lookup)
+ applyScannerEnvironment(option, explicit, lookup)
+ applyReconEnvironment(option, explicit, lookup)
+}
+
+func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup) {
+ providerExplicit := strings.TrimSpace(explicit.Provider) != ""
+ if v := firstEnv(lookup, "AISCAN_PROVIDER", "AISCAN_LLM_PROVIDER"); v != "" && !providerExplicit {
+ option.Provider = v
+ }
+
+ selectedProvider := selectedEnvProvider(option, lookup)
+ if option.Provider == "" && selectedProvider != "" && !providerExplicit {
+ option.Provider = selectedProvider
+ }
+
+ if strings.TrimSpace(explicit.BaseURL) == "" {
+ if v := firstEnv(lookup, "AISCAN_BASE_URL", "AISCAN_BASEURL", "AISCAN_LLM_BASE_URL", "AISCAN_LLM_BASEURL"); v != "" {
+ option.BaseURL = v
+ } else if v := providerBaseURLEnv(selectedProvider, lookup); v != "" {
+ option.BaseURL = v
+ }
+ }
+
+ if strings.TrimSpace(explicit.Model) == "" {
+ if v := firstEnv(lookup, "AISCAN_MODEL", "AISCAN_LLM_MODEL"); v != "" {
+ option.Model = v
+ } else if v := providerModelEnv(selectedProvider, lookup); v != "" {
+ option.Model = v
+ }
+ }
+
+ if strings.TrimSpace(explicit.APIKey) == "" {
+ if v := providerAPIKeyEnv(selectedProvider, lookup); v != "" {
+ option.APIKey = v
+ } else if v := firstEnv(lookup, "AISCAN_API_KEY", "AISCAN_LLM_API_KEY"); v != "" {
+ option.APIKey = v
+ }
+ }
+
+ if strings.TrimSpace(explicit.LLMProxy) == "" {
+ if v := firstEnv(lookup, "AISCAN_LLM_PROXY"); v != "" {
+ option.LLMProxy = v
+ }
+ }
+}
+
+func applyScannerEnvironment(option *Option, explicit Option, lookup envLookup) {
+ if strings.TrimSpace(explicit.CyberhubURL) == "" {
+ if v := firstEnv(lookup, "CYBERHUB_URL", "AISCAN_CYBERHUB_URL"); v != "" {
+ option.CyberhubURL = v
+ }
+ }
+ if strings.TrimSpace(explicit.CyberhubKey) == "" {
+ if v := firstEnv(lookup, "CYBERHUB_KEY", "AISCAN_CYBERHUB_KEY"); v != "" {
+ option.CyberhubKey = v
+ }
+ }
+ if strings.TrimSpace(explicit.CyberhubMode) == "" {
+ if v := firstEnv(lookup, "CYBERHUB_MODE", "AISCAN_CYBERHUB_MODE"); v != "" {
+ option.CyberhubMode = v
+ }
+ }
+ if strings.TrimSpace(explicit.Proxy) == "" {
+ if v := firstEnv(lookup, "AISCAN_PROXY", "AISCAN_SCANNER_PROXY"); v != "" {
+ option.Proxy = v
+ }
+ }
+}
+
+func applyReconEnvironment(option *Option, explicit Option, lookup envLookup) {
+ if strings.TrimSpace(explicit.FofaEmail) == "" {
+ if v := firstEnv(lookup, "FOFA_EMAIL"); v != "" {
+ option.FofaEmail = v
+ }
+ }
+ if strings.TrimSpace(explicit.FofaKey) == "" {
+ if v := firstEnv(lookup, "FOFA_KEY"); v != "" {
+ option.FofaKey = v
+ }
+ }
+ if strings.TrimSpace(explicit.HunterToken) == "" {
+ if v := firstEnv(lookup, "HUNTER_TOKEN"); v != "" {
+ option.HunterToken = v
+ }
+ }
+ if strings.TrimSpace(explicit.HunterAPIKey) == "" {
+ if v := firstEnv(lookup, "HUNTER_API_KEY"); v != "" {
+ option.HunterAPIKey = v
+ }
+ }
+ if strings.TrimSpace(explicit.ReconProxy) == "" {
+ if v := firstEnv(lookup, "RECON_PROXY"); v != "" {
+ option.ReconProxy = v
+ }
+ }
+}
+
+func selectedEnvProvider(option *Option, lookup envLookup) string {
+ if v := strings.ToLower(strings.TrimSpace(option.Provider)); v != "" {
+ return v
+ }
+ if option.BaseURL != "" {
+ return agent.InferProviderFromBaseURL(option.BaseURL)
+ }
+ if firstEnv(lookup, "ANTHROPIC_API_KEY") != "" {
+ return "anthropic"
+ }
+ if firstEnv(lookup, "OPENAI_API_KEY") != "" {
+ return "openai"
+ }
+ return ""
+}
+
+func providerBaseURLEnv(providerName string, lookup envLookup) string {
+ providerName = strings.ToLower(strings.TrimSpace(providerName))
+ if providerName == "" {
+ return ""
+ }
+ if providerName == "openai" {
+ if v := firstEnv(lookup, "OPENAI_BASE_URL", "OPENAI_BASEURL", "OPENAI_API_BASE_URL", "OPENAI_API_BASE"); v != "" {
+ return v
+ }
+ }
+ return firstEnv(lookup, providerEnvName(providerName, "BASE_URL"), providerEnvName(providerName, "BASEURL"))
+}
+
+func providerModelEnv(providerName string, lookup envLookup) string {
+ providerName = strings.ToLower(strings.TrimSpace(providerName))
+ if providerName == "" {
+ return ""
+ }
+ return firstEnv(lookup, providerEnvName(providerName, "MODEL"))
+}
+
+func providerAPIKeyEnv(providerName string, lookup envLookup) string {
+ providerName = strings.ToLower(strings.TrimSpace(providerName))
+ switch providerName {
+ case "anthropic":
+ return firstEnv(lookup, "ANTHROPIC_API_KEY")
+ default:
+ return firstEnv(lookup, "OPENAI_API_KEY")
+ }
+}
+
+func providerEnvName(providerName, suffix string) string {
+ providerName = strings.ToUpper(strings.TrimSpace(providerName))
+ providerName = strings.ReplaceAll(providerName, "-", "_")
+ return providerName + "_" + suffix
+}
+
+func firstEnv(lookup envLookup, names ...string) string {
+ for _, name := range names {
+ value, ok := lookup(name)
+ if !ok {
+ continue
+ }
+ value = strings.TrimSpace(value)
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
diff --git a/core/config/loader.go b/core/config/loader.go
new file mode 100644
index 00000000..0e53e42a
--- /dev/null
+++ b/core/config/loader.go
@@ -0,0 +1,238 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+
+ gkcfg "github.com/gookit/config/v2"
+ yamldrv "github.com/gookit/config/v2/yaml"
+)
+
+const DefaultConfigName = "config.yaml"
+
+func init() {
+ gkcfg.WithOptions(func(opt *gkcfg.Options) {
+ opt.DecoderConfig.TagName = "config"
+ opt.ParseDefault = true
+ })
+ gkcfg.AddDriver(yamldrv.Driver)
+}
+
+func newConfigLoader() *gkcfg.Config {
+ c := gkcfg.New("aiscan")
+ c.WithOptions(func(opt *gkcfg.Options) {
+ opt.DecoderConfig.TagName = "config"
+ })
+ c.AddDriver(yamldrv.Driver)
+ return c
+}
+
+func LoadConfig(filename string, v interface{}) error {
+ c := newConfigLoader()
+ if err := c.LoadFiles(filename); err != nil {
+ return err
+ }
+ if err := c.Decode(v); err != nil {
+ return err
+ }
+ applyExplicitReconNumericOptions(c, v)
+ return nil
+}
+
+func applyExplicitReconNumericOptions(c *gkcfg.Config, v interface{}) {
+ opt, ok := v.(*Option)
+ if !ok || opt == nil {
+ return
+ }
+ if c.Exists("recon.limit") {
+ v := c.Int("recon.limit")
+ opt.ReconLimit = &v
+ }
+}
+
+func findDefaultConfigFile() string {
+ if _, err := os.Stat(DefaultConfigName); err == nil {
+ return DefaultConfigName
+ }
+ if dir, err := os.UserConfigDir(); err == nil {
+ p := filepath.Join(dir, "aiscan", DefaultConfigName)
+ if _, err := os.Stat(p); err == nil {
+ return p
+ }
+ }
+ return ""
+}
+
+func LoadAndApplyConfig(option *Option) (string, error) {
+ configPath := option.ConfigFile
+ if configPath == "" {
+ configPath = findDefaultConfigFile()
+ }
+ if configPath == "" {
+ return "", nil
+ }
+ if _, err := os.Stat(configPath); err != nil {
+ if option.ConfigFile == "" && os.IsNotExist(err) {
+ return "", nil
+ }
+ return "", fmt.Errorf("config file %s: %w", configPath, err)
+ }
+
+ var loaded Option
+ if err := LoadConfig(configPath, &loaded); err != nil {
+ return configPath, fmt.Errorf("load config %s: %w", configPath, err)
+ }
+ mergeOption(option, &loaded)
+ if err := loadRuntimeDefaults(configPath); err != nil {
+ return configPath, fmt.Errorf("load runtime defaults %s: %w", configPath, err)
+ }
+ return configPath, nil
+}
+
+func loadRuntimeDefaults(filename string) error {
+ c := newConfigLoader()
+ if err := c.LoadFiles(filename); err != nil {
+ return err
+ }
+ if v := c.String("scan.verify"); v != "" {
+ DefaultVerify = v
+ }
+ if v := c.Int("scan.verify_timeout"); v > 0 {
+ DefaultVerifyTimeout = strconv.Itoa(v)
+ }
+ if v := c.String("search.tavily_keys"); v != "" {
+ DefaultTavilyKeys = v
+ }
+ return nil
+}
+
+func mergeOption(dst, src *Option) {
+ dst.Provider = ResolveString(dst.Provider, src.Provider)
+ dst.BaseURL = ResolveString(dst.BaseURL, src.BaseURL)
+ dst.APIKey = ResolveString(dst.APIKey, src.APIKey)
+ dst.Model = ResolveString(dst.Model, src.Model)
+ dst.LLMProxy = ResolveString(dst.LLMProxy, src.LLMProxy)
+ dst.CyberhubURL = ResolveString(dst.CyberhubURL, src.CyberhubURL)
+ dst.CyberhubKey = ResolveString(dst.CyberhubKey, src.CyberhubKey)
+ dst.CyberhubMode = ResolveString(dst.CyberhubMode, src.CyberhubMode)
+ dst.FofaEmail = ResolveString(dst.FofaEmail, src.FofaEmail)
+ dst.FofaKey = ResolveString(dst.FofaKey, src.FofaKey)
+ dst.HunterToken = ResolveString(dst.HunterToken, src.HunterToken)
+ dst.HunterAPIKey = ResolveString(dst.HunterAPIKey, src.HunterAPIKey)
+ dst.ReconProxy = ResolveString(dst.ReconProxy, src.ReconProxy)
+ if dst.ReconLimit == nil && src.ReconLimit != nil {
+ dst.ReconLimit = src.ReconLimit
+ }
+ dst.Proxy = ResolveString(dst.Proxy, src.Proxy)
+ dst.WebURL = ResolveString(dst.WebURL, src.WebURL)
+ dst.IOAURL = ResolveString(dst.IOAURL, src.IOAURL)
+ dst.IOAToken = ResolveString(dst.IOAToken, src.IOAToken)
+ dst.IOANodeName = ResolveString(dst.IOANodeName, src.IOANodeName)
+ if (dst.Space == "" || dst.Space == "default") && src.Space != "" {
+ dst.Space = src.Space
+ }
+}
+
+func InitDefaultConfig() string {
+ return defaultConfigTemplate
+}
+
+const defaultConfigTemplate = `# aiscan 配置文件
+#
+# 编译时: build.sh 读取此文件,通过 ldflags 将配置固化到二进制
+# 运行时: aiscan 自动加载 ./config.yaml 或 ~/.config/aiscan/config.yaml
+# 优先级: CLI 参数 > 环境变量 > 配置文件(-c 或默认路径)> 编译时固化值
+#
+# 仅填写需要的字段,留空或删除的字段不会覆盖其他来源的值
+
+# LLM Provider 配置
+llm:
+ # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic
+ # 环境变量: AISCAN_PROVIDER / AISCAN_LLM_PROVIDER
+ provider: ""
+ # API base URL(留空使用 provider 默认值)
+ # 环境变量: AISCAN_BASE_URL / AISCAN_BASEURL / AISCAN_LLM_BASE_URL / AISCAN_LLM_BASEURL
+ # OpenAI/Codex 风格: OPENAI_BASE_URL / OPENAI_BASEURL
+ # Claude Code 风格: ANTHROPIC_BASE_URL / ANTHROPIC_BASEURL
+ base_url: ""
+ # API key(建议使用环境变量而非写入文件)
+ # 环境变量: AISCAN_API_KEY / Provider 对应 API key 变量(如 OPENAI_API_KEY)
+ api_key: ""
+ # 模型名称
+ # 环境变量: AISCAN_MODEL / AISCAN_LLM_MODEL
+ # OpenAI/Codex 风格: OPENAI_MODEL
+ # Claude Code 风格: ANTHROPIC_MODEL
+ model: ""
+ # LLM API 代理
+ # 环境变量: AISCAN_LLM_PROXY
+ proxy: ""
+ # 备用 provider 列表(按优先级排序,主 provider 不可用时自动切换)
+ # providers:
+ # - provider: openai
+ # model: gpt-4o
+ # api_key: ""
+ # - provider: ollama
+ # model: llama3.1
+ # base_url: http://localhost:11434/v1
+
+# Cyberhub 资源服务
+cyberhub:
+ url: ""
+ key: ""
+ # merge 或 override
+ mode: ""
+ # 扫描器代理,支持以下格式:
+ # socks5://127.0.0.1:1080
+ # trojan://password@server:443?sni=example.com
+ # clash://?url=&strategy=adaptive
+ proxy: ""
+
+# 搜索
+search:
+ # Tavily API keys(逗号分隔,留空则 fallback 到 DuckDuckGo)
+ tavily_keys: ""
+
+# Agent 远程连接
+agent:
+ # 直接连接 aiscan web,提供远程 REPL / PTY
+ web_url: ""
+
+# IOA 协作
+ioa:
+ url: ""
+ db: ""
+ node_name: ""
+ space: ""
+
+# 资产测绘 (通过 uncover SDK)
+# FOFA 凭证从此处或环境变量 FOFA_EMAIL / FOFA_KEY 读取
+# 额外 source (Shodan/Censys/...) 通过环境变量或 ~/.uncover-config/provider-config.yaml 配置
+recon:
+ fofa_email: ""
+ fofa_key: ""
+ hunter_token: "" # 极少用; Hunter web 端 token
+ hunter_api_key: "" # 华顺信安后台 API 管理生成的 64 位 hex key
+ proxy: "" # 出站代理 (Hunter 屏蔽境外 IP, 中国 VPS 走 socks5://host:1080)
+ limit: 0 # 单次查询最多返回多少 asset, 0 = 不限
+
+# 扫描默认值
+scan:
+ # auto, off, low, medium, high, critical
+ verify: ""
+ # 单次验证超时秒数(0 表示不覆盖)
+ verify_timeout: 0
+
+# 通用选项
+misc:
+ debug: false
+ quiet: false
+ no_color: false
+
+# 以下仅 build.sh 使用
+build:
+ osarch: ""
+ tags: ""
+ output: dist
+`
diff --git a/core/config/option_defaults.go b/core/config/option_defaults.go
new file mode 100644
index 00000000..a817c1f2
--- /dev/null
+++ b/core/config/option_defaults.go
@@ -0,0 +1,47 @@
+package config
+
+import (
+ "strconv"
+ "strings"
+)
+
+func ResolveString(value, fallback string) string {
+ if value != "" {
+ return value
+ }
+ return fallback
+}
+
+func DefaultInt(value string, fallback int) int {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return fallback
+ }
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed <= 0 {
+ return fallback
+ }
+ return parsed
+}
+
+func resolveSpace(space string) string {
+ if space != "" && space != "default" {
+ return space
+ }
+ return ResolveString(DefaultSpace, "default")
+}
+
+func ApplyDefaults(option *Option) {
+ option.CyberhubURL = ResolveString(option.CyberhubURL, DefaultCyberhubURL)
+ option.CyberhubKey = ResolveString(option.CyberhubKey, DefaultCyberhubKey)
+ mode := ResolveString(option.CyberhubMode, DefaultCyberhubMode)
+ option.CyberhubMode = ResolveString(mode, "merge")
+ option.Proxy = ResolveString(option.Proxy, DefaultScannerProxy)
+ option.IOAURL = ResolveString(option.IOAURL, DefaultIOAURL)
+ option.IOANodeID = ResolveString(option.IOANodeID, DefaultIOANodeID)
+ option.IOANodeName = ResolveString(option.IOANodeName, DefaultIOANodeName)
+ option.Space = resolveSpace(option.Space)
+ if option.Model == "" {
+ option.Model = DefaultModel
+ }
+}
diff --git a/core/config/options.go b/core/config/options.go
new file mode 100644
index 00000000..6fb82acb
--- /dev/null
+++ b/core/config/options.go
@@ -0,0 +1,215 @@
+package config
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "github.com/chainreactors/aiscan/skills"
+)
+
+const Version = "0.1.0"
+
+type Option struct {
+ LLMOptions `group:"LLM Options" config:"llm"`
+ ScannerOptions `group:"Scanner Options" config:"cyberhub"`
+ AgentOptions `group:"Agent Options" config:"agent"`
+ IOAOptions `group:"IOA Options" config:"ioa"`
+ ReconOptions `group:"Recon Options" config:"recon"`
+ MiscOptions `group:"Miscellaneous Options" config:"misc"`
+ ScanConfig ScanConfigOptions `no-flag:"true" config:"scan"`
+}
+
+type ScanConfigOptions struct {
+ Verify string `config:"verify"`
+ VerifyTimeout int `config:"verify_timeout"`
+}
+
+type LLMOptions struct {
+ Provider string `long:"provider" config:"provider" description:"LLM provider type: openai (default, OpenAI-compatible) or anthropic"`
+ BaseURL string `long:"base-url" config:"base_url" description:"LLM API base URL"`
+ APIKey string `long:"api-key" config:"api_key" description:"LLM API key (or set env: OPENAI_API_KEY, AISCAN_API_KEY)"`
+ Model string `long:"model" config:"model" description:"LLM model name"`
+ LLMProxy string `long:"llm-proxy" config:"proxy" description:"Proxy for LLM API requests"`
+ Providers []LLMProviderEntry `no-flag:"true" config:"providers" yaml:"providers"`
+ AI bool `long:"ai" description:"Analyze direct scanner output with an LLM"`
+}
+
+type LLMProviderEntry struct {
+ Provider string `config:"provider" yaml:"provider"`
+ BaseURL string `config:"base_url" yaml:"base_url"`
+ APIKey string `config:"api_key" yaml:"api_key"`
+ Model string `config:"model" yaml:"model"`
+ Proxy string `config:"proxy" yaml:"proxy"`
+ Timeout int `config:"timeout" yaml:"timeout"`
+}
+
+type ScannerOptions struct {
+ CyberhubURL string `long:"cyberhub-url" config:"url" description:"Cyberhub server URL for loading fingers/templates"`
+ CyberhubKey string `long:"cyberhub-key" config:"key" description:"Cyberhub API key"`
+ CyberhubMode string `long:"cyberhub-mode" config:"mode" description:"Cyberhub resource mode: merge or override"`
+ Proxy string `long:"proxy" config:"proxy" description:"Proxy for scanner tools. Supports socks5://, trojan://, vless://, clash:// (subscription with load balancing)"`
+}
+
+type AgentOptions struct {
+ Prompt string `short:"p" long:"prompt" description:"Natural language task for the agent"`
+ Inputs []string `short:"i" long:"input" description:"Target input: IP, URL, IP:port, or CIDR. Can specify multiple"`
+ Skills []string `short:"s" long:"skill" description:"Skill to apply (name or file path). Can specify multiple"`
+ TaskFile string `long:"task-file" description:"File containing task description"`
+ Heartbeat int `long:"heartbeat" description:"Heartbeat interval in minutes: periodically wake the agent to review context (0 disables)" default:"0"`
+ Timeout int `long:"timeout" config:"timeout" description:"Overall timeout in seconds" default:"3600"`
+ EvalCriteria string `short:"e" long:"eval" config:"eval_criteria" description:"Goal evaluation criteria — an independent LLM evaluates whether the task was achieved"`
+ EvalModel string `long:"eval-model" config:"eval_model" description:"Model for goal evaluation (defaults to main model)"`
+ EvalMaxRetries int `long:"eval-retries" config:"eval_retries" description:"Max goal evaluation retry rounds" default:"3"`
+ WebURL string `long:"web-url" config:"web_url" description:"AIScan web server URL for remote REPL and PTY access"`
+}
+
+type IOAOptions struct {
+ IOAURL string `long:"ioa-url" config:"url" description:"IOA server URL (supports http://token@host:port for auth)"`
+ IOAToken string `long:"ioa-token" config:"token" description:"IOA server access key (for 'ioa serve'; auto-generated if empty)"`
+ IOANodeID string `long:"ioa-node-id" description:"Existing IOA node id for agent tools"`
+ IOANodeName string `long:"ioa-node-name" config:"node_name" description:"IOA node name when auto-registering"`
+ Space string `long:"space" config:"space" description:"IOA space name" default:"default"`
+ IOAJSON bool `long:"json" description:"Output IOA query results in JSON format"`
+}
+
+type MiscOptions struct {
+ ConfigFile string `short:"c" long:"config" description:"Path to config file (default: ./config.yaml, ~/.config/aiscan/config.yaml)"`
+ InitConfig bool `long:"init" description:"Generate default config.yaml and exit"`
+ ViewFile string `short:"F" long:"view" description:"View a scan record JSONL file"`
+ ViewFormat string `short:"o" long:"output" description:"Output format for -F: terminal (default), markdown" default:"terminal"`
+ ViewOutput string `short:"f" long:"file" description:"Write -F output to file instead of stdout"`
+ Debug bool `long:"debug" config:"debug" description:"Enable debug logging"`
+ Verbose []bool `short:"v" long:"verbose" description:"Increase verbosity (-v tools, -vv thinking)"`
+ Quiet bool `short:"q" long:"quiet" config:"quiet" description:"Quiet mode — only show final result"`
+ NoColor bool `long:"no-color" config:"no_color" description:"Disable ANSI colors in scanner output"`
+ Version bool `long:"version" description:"Print version and exit"`
+}
+
+type RunMode string
+
+const (
+ RunModeAgent RunMode = "agent"
+ RunModeIOAServe RunMode = "ioa serve"
+ RunModeIOASpaces RunMode = "ioa spaces"
+ RunModeIOAMessages RunMode = "ioa messages"
+ RunModeIOAContext RunMode = "ioa context"
+ RunModeIOANodes RunMode = "ioa nodes"
+ RunModeScanner RunMode = "scanner"
+ RunModeNoCommand RunMode = ""
+)
+
+type IOAClientArgs struct {
+ Space string
+ MessageID string
+}
+
+func HasAgentOneShotInput(opt *Option) bool {
+ if strings.TrimSpace(opt.Prompt) != "" || opt.TaskFile != "" || len(opt.Inputs) > 0 {
+ return true
+ }
+ return !StdinIsTerminal()
+}
+
+func StdinIsTerminal() bool {
+ stat, err := os.Stdin.Stat()
+ if err != nil {
+ return false
+ }
+ return (stat.Mode() & os.ModeCharDevice) != 0
+}
+
+func ResolveTask(opt *Option) (string, error) {
+ prompt := strings.TrimSpace(opt.Prompt)
+ if prompt != "" {
+ if len(opt.Inputs) > 0 {
+ return fmt.Sprintf("%s\n\nTargets:\n%s", prompt, FormatInputs(opt.Inputs)), nil
+ }
+ return prompt, nil
+ }
+
+ if opt.TaskFile != "" {
+ data, err := os.ReadFile(opt.TaskFile)
+ if err != nil {
+ return "", fmt.Errorf("read task file: %w", err)
+ }
+ task := strings.TrimSpace(string(data))
+ if len(opt.Inputs) > 0 {
+ return fmt.Sprintf("%s\n\nTargets:\n%s", task, FormatInputs(opt.Inputs)), nil
+ }
+ return task, nil
+ }
+
+ if !StdinIsTerminal() {
+ data, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ return "", fmt.Errorf("read stdin: %w", err)
+ }
+ task := strings.TrimSpace(string(data))
+ if task != "" {
+ if len(opt.Inputs) > 0 {
+ return fmt.Sprintf("%s\n\nTargets:\n%s", task, FormatInputs(opt.Inputs)), nil
+ }
+ return task, nil
+ }
+ }
+
+ if len(opt.Inputs) > 0 {
+ return fmt.Sprintf("Scan the provided targets using scan and summarize results.\n\nTargets:\n%s", FormatInputs(opt.Inputs)), nil
+ }
+
+ return "", fmt.Errorf("no prompt specified: use -p, --prompt, --task-file, or pipe via stdin")
+}
+
+func FormatInputs(inputs []string) string {
+ var sb strings.Builder
+ for _, input := range inputs {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ continue
+ }
+ sb.WriteString("- ")
+ sb.WriteString(input)
+ sb.WriteString("\n")
+ }
+ return strings.TrimRight(sb.String(), "\n")
+}
+
+func ApplySelectedSkills(text string, selected []string, store *skills.Store) (string, error) {
+ if len(selected) == 0 {
+ return text, nil
+ }
+ var sb strings.Builder
+ for _, name := range selected {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ if skill, ok := store.ByName(name); ok {
+ if sb.Len() > 0 {
+ sb.WriteString("\n\n")
+ }
+ sb.WriteString(store.FormatInvocation(skill, ""))
+ continue
+ }
+ body := skills.ReadFile("skills/" + name + ".md")
+ if body == "" {
+ body = skills.ReadFile(name)
+ }
+ if body == "" {
+ return "", fmt.Errorf("unknown skill %q", name)
+ }
+ if sb.Len() > 0 {
+ sb.WriteString("\n\n")
+ }
+ sb.WriteString(body)
+ }
+ if strings.TrimSpace(text) != "" {
+ if sb.Len() > 0 {
+ sb.WriteString("\n\n")
+ }
+ sb.WriteString(strings.TrimSpace(text))
+ }
+ return sb.String(), nil
+}
diff --git a/core/config/provider.go b/core/config/provider.go
new file mode 100644
index 00000000..414be2ae
--- /dev/null
+++ b/core/config/provider.go
@@ -0,0 +1,85 @@
+package config
+
+import "github.com/chainreactors/aiscan/pkg/agent"
+
+var (
+ DefaultProvider = "openai"
+ DefaultBaseURL = ""
+ DefaultAPIKey = ""
+ DefaultModel = ""
+
+ DefaultScannerProxy = ""
+
+ DefaultCyberhubURL = ""
+ DefaultCyberhubKey = ""
+ DefaultCyberhubMode = "merge"
+
+ DefaultVerify = "auto"
+ DefaultVerifyTimeout = ""
+
+ DefaultIOAURL = ""
+ DefaultIOANodeID = ""
+ DefaultIOANodeName = ""
+ DefaultSpace = ""
+
+ DefaultTavilyKeys = ""
+)
+
+func defaultProviderConfig() agent.ProviderConfig {
+ return agent.ProviderConfig{
+ Provider: DefaultProvider,
+ BaseURL: DefaultBaseURL,
+ APIKey: DefaultAPIKey,
+ Model: DefaultModel,
+ }
+}
+
+func ProviderConfig(option *Option) agent.ProviderConfig {
+ cfg := defaultProviderConfig()
+ if option.Provider != "" {
+ cfg.Provider = option.Provider
+ }
+ if option.BaseURL != "" {
+ cfg.BaseURL = option.BaseURL
+ if option.Provider == "" {
+ cfg.Provider = ""
+ }
+ }
+ if option.APIKey != "" {
+ cfg.APIKey = option.APIKey
+ }
+ if option.Model != "" {
+ cfg.Model = option.Model
+ }
+ if option.LLMProxy != "" {
+ cfg.Proxy = option.LLMProxy
+ }
+ cfg.Timeout = 120
+ return cfg
+}
+
+func FallbackProviderConfigs(option *Option) []agent.ProviderConfig {
+ var configs []agent.ProviderConfig
+ for _, entry := range option.Providers {
+ cfg := agent.ProviderConfig{
+ Provider: entry.Provider,
+ BaseURL: entry.BaseURL,
+ APIKey: entry.APIKey,
+ Model: entry.Model,
+ Proxy: entry.Proxy,
+ Timeout: entry.Timeout,
+ }
+ if cfg.Timeout <= 0 {
+ cfg.Timeout = 120
+ }
+ configs = append(configs, cfg)
+ }
+ return configs
+}
+
+func ApplyResolvedProviderOptions(option *Option, cfg agent.ProviderConfig) {
+ option.Provider = cfg.Provider
+ option.BaseURL = cfg.BaseURL
+ option.APIKey = cfg.APIKey
+ option.Model = cfg.Model
+}
diff --git a/core/config/recon_options.go b/core/config/recon_options.go
new file mode 100644
index 00000000..cca785ea
--- /dev/null
+++ b/core/config/recon_options.go
@@ -0,0 +1,12 @@
+//go:build full
+
+package config
+
+type ReconOptions struct {
+ FofaEmail string `long:"fofa-email" config:"fofa_email" description:"FOFA account email for passive recon (or set env FOFA_EMAIL)"`
+ FofaKey string `long:"fofa-key" config:"fofa_key" description:"FOFA API key for passive recon (or set env FOFA_KEY)"`
+ HunterToken string `long:"hunter-token" config:"hunter_token" description:"Hunter web token (rarely needed; prefer hunter-api-key)"`
+ HunterAPIKey string `long:"hunter-api-key" config:"hunter_api_key" description:"Hunter API key (64-hex from console) (or env HUNTER_API_KEY)"`
+ ReconProxy string `long:"recon-proxy" config:"proxy" description:"Outbound proxy for passive recon (socks5://host:port for hunter via mainland)"`
+ ReconLimit *int `long:"recon-limit" config:"limit" description:"Per-query asset limit for passive recon (0 = unlimited)"`
+}
diff --git a/core/config/recon_options_stub.go b/core/config/recon_options_stub.go
new file mode 100644
index 00000000..518f6a17
--- /dev/null
+++ b/core/config/recon_options_stub.go
@@ -0,0 +1,12 @@
+//go:build !full
+
+package config
+
+type ReconOptions struct {
+ FofaEmail string `long:"fofa-email" config:"fofa_email" hidden:"true"`
+ FofaKey string `long:"fofa-key" config:"fofa_key" hidden:"true"`
+ HunterToken string `long:"hunter-token" config:"hunter_token" hidden:"true"`
+ HunterAPIKey string `long:"hunter-api-key" config:"hunter_api_key" hidden:"true"`
+ ReconProxy string `long:"recon-proxy" config:"proxy" hidden:"true"`
+ ReconLimit *int `long:"recon-limit" config:"limit" hidden:"true"`
+}
diff --git a/core/config/runtime.go b/core/config/runtime.go
new file mode 100644
index 00000000..04b83253
--- /dev/null
+++ b/core/config/runtime.go
@@ -0,0 +1,55 @@
+package config
+
+import (
+ "github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+)
+
+type RuntimeConfig struct {
+ Provider RuntimeProviderConfig
+ Scanner ScannerConfig
+ Tools ToolConfig
+ IOA *IOAConfig
+ Logger telemetry.Logger
+ CLISkillPaths []string
+}
+
+type RuntimeProviderConfig struct {
+ Enabled bool
+ Config agent.ProviderConfig
+ Fallbacks []agent.ProviderConfig
+ Optional bool
+}
+
+type ScannerConfig struct {
+ CyberhubURL string
+ CyberhubKey string
+ CyberhubMode string
+ AIEnabled bool
+ EnableAllAISkills bool
+ AITimeout int
+ VerifyMode string
+ Proxy string
+ FofaEmail string
+ FofaKey string
+ HunterToken string
+ HunterAPIKey string
+ ReconProxy string
+ ReconLimit int
+}
+
+type ToolConfig struct {
+ Enabled bool
+ BashTimeout int
+ TavilyKeys string
+}
+
+type IOAConfig struct {
+ URL string
+ NodeID string
+ NodeName string
+ Space string
+ RegisterTools bool
+ AutoRegister bool
+ NodeMeta map[string]any
+}
diff --git a/core/config/scanner.go b/core/config/scanner.go
new file mode 100644
index 00000000..98f4a97d
--- /dev/null
+++ b/core/config/scanner.go
@@ -0,0 +1,124 @@
+package config
+
+import (
+ "strings"
+)
+
+var ExtraCommands = map[string]bool{}
+
+var ExtraUsageEntries []string
+
+var ExtraSummaryEntries []string
+
+var ExtraScannerUsage = map[string]func() string{}
+
+// ScanUsageFunc is set by the scan package init in non-mini builds.
+var ScanUsageFunc func() string
+
+// ScannerEnabled reports whether built-in scanner commands are available.
+// Defaults to true; cmd/agent sets it to false.
+var ScannerEnabled = true
+
+type ScannerCommands struct {
+ Scan struct{} `command:"scan" description:"Run the scan pipeline"`
+ Gogo struct{} `command:"gogo" description:"Run gogo scanner"`
+ Spray struct{} `command:"spray" description:"Run spray scanner"`
+ Katana struct{} `command:"katana" description:"Run katana web crawler"`
+ Zombie struct{} `command:"zombie" description:"Run zombie weakpass scanner"`
+ Neutron struct{} `command:"neutron" description:"Run neutron POC scanner"`
+ Passive struct{} `command:"passive" description:"Run passive cyberspace recon"`
+}
+
+func ScannerCommandAvailable(name string) bool {
+ if !ScannerEnabled {
+ return ExtraCommands[name]
+ }
+ switch name {
+ case "scan", "gogo", "spray", "zombie", "neutron":
+ return true
+ default:
+ return ExtraCommands[name]
+ }
+}
+
+func ScannerUsageLines() string {
+ if !ScannerEnabled {
+ if len(ExtraUsageEntries) == 0 {
+ return ""
+ }
+ return strings.Join(ExtraUsageEntries, "\n")
+ }
+ base := ` gogo Run gogo directly
+ spray Run spray directly
+ zombie Run zombie directly
+ neutron Run neutron directly`
+ if len(ExtraUsageEntries) == 0 {
+ return base
+ }
+ return base + "\n" + strings.Join(ExtraUsageEntries, "\n")
+}
+
+func CLICommandSummary() string {
+ if !ScannerEnabled {
+ base := "agent, ioa serve"
+ if len(ExtraSummaryEntries) == 0 {
+ return base
+ }
+ return base + ", " + strings.Join(ExtraSummaryEntries, ", ")
+ }
+ base := "agent, ioa serve, scan, gogo, spray, zombie, neutron"
+ if len(ExtraSummaryEntries) == 0 {
+ return base
+ }
+ return base + ", " + strings.Join(ExtraSummaryEntries, ", ")
+}
+
+func IsScannerHelpRequest(args []string) bool {
+ if len(args) < 2 {
+ return false
+ }
+ for _, arg := range args[1:] {
+ if arg == "-h" || arg == "--help" {
+ return true
+ }
+ }
+ return false
+}
+
+func StaticScannerUsage(name string) (string, bool) {
+ switch name {
+ case "scan":
+ if ScanUsageFunc != nil {
+ return ScanUsageFunc(), true
+ }
+ if !ScannerEnabled {
+ return "", false
+ }
+ return "scan - AI-assisted security scan pipeline\nUsage: scan [options]\n", true
+ case "gogo":
+ if !ScannerEnabled {
+ return "", false
+ }
+ return "gogo - host, port, service, and banner discovery\nUsage: gogo [options]\n", true
+ case "spray":
+ if !ScannerEnabled {
+ return "", false
+ }
+ return "spray - web probing, fingerprints, common files, and crawl checks\nUsage: spray [options]\n", true
+ case "zombie":
+ if !ScannerEnabled {
+ return "", false
+ }
+ return "zombie - weak credential checks for supported services\nUsage: zombie [options]\n", true
+ case "neutron":
+ if !ScannerEnabled {
+ return "", false
+ }
+ return "neutron - POC/vulnerability testing with nuclei-style options\nUsage: neutron -u [options]\n", true
+ default:
+ if fn, ok := ExtraScannerUsage[name]; ok {
+ return fn(), true
+ }
+ return "", false
+ }
+}
diff --git a/core/config/scanner_katana.go b/core/config/scanner_katana.go
new file mode 100644
index 00000000..ee35992d
--- /dev/null
+++ b/core/config/scanner_katana.go
@@ -0,0 +1,12 @@
+//go:build full
+
+package config
+
+import katanacmd "github.com/chainreactors/aiscan/pkg/tools/katana"
+
+func init() {
+ ExtraCommands["katana"] = true
+ ExtraUsageEntries = append(ExtraUsageEntries, " katana Run katana web crawler")
+ ExtraSummaryEntries = append(ExtraSummaryEntries, "katana")
+ ExtraScannerUsage["katana"] = func() string { return katanacmd.New().Usage() }
+}
diff --git a/core/config/scanner_passive.go b/core/config/scanner_passive.go
new file mode 100644
index 00000000..5730a4f5
--- /dev/null
+++ b/core/config/scanner_passive.go
@@ -0,0 +1,12 @@
+//go:build full
+
+package config
+
+import passivecmd "github.com/chainreactors/aiscan/pkg/tools/passive"
+
+func init() {
+ ExtraCommands["passive"] = true
+ ExtraUsageEntries = append(ExtraUsageEntries, " passive Run passive cyberspace recon")
+ ExtraSummaryEntries = append(ExtraSummaryEntries, "passive")
+ ExtraScannerUsage["passive"] = func() string { return passivecmd.New(nil).Usage() }
+}
diff --git a/core/eventbus/eventbus.go b/core/eventbus/eventbus.go
new file mode 100644
index 00000000..c7ed6a22
--- /dev/null
+++ b/core/eventbus/eventbus.go
@@ -0,0 +1,51 @@
+package eventbus
+
+import "sync"
+
+type entry[T any] struct {
+ id int
+ handler func(T)
+}
+
+type Bus[T any] struct {
+ mu sync.RWMutex
+ subs []entry[T]
+ next int
+}
+
+func New[T any]() *Bus[T] {
+ return &Bus[T]{}
+}
+
+func (b *Bus[T]) Subscribe(handler func(T)) func() {
+ b.mu.Lock()
+ id := b.next
+ b.next++
+ b.subs = append(b.subs, entry[T]{id: id, handler: handler})
+ b.mu.Unlock()
+ return func() { b.unsubscribe(id) }
+}
+
+func (b *Bus[T]) unsubscribe(id int) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ for i, s := range b.subs {
+ if s.id == id {
+ b.subs = append(b.subs[:i], b.subs[i+1:]...)
+ return
+ }
+ }
+}
+
+func (b *Bus[T]) Emit(event T) {
+ b.mu.RLock()
+ snapshot := make([]func(T), len(b.subs))
+ for i, s := range b.subs {
+ snapshot[i] = s.handler
+ }
+ b.mu.RUnlock()
+
+ for _, h := range snapshot {
+ h(event)
+ }
+}
diff --git a/core/eventbus/eventbus_test.go b/core/eventbus/eventbus_test.go
new file mode 100644
index 00000000..f2f8e867
--- /dev/null
+++ b/core/eventbus/eventbus_test.go
@@ -0,0 +1,89 @@
+package eventbus
+
+import (
+ "sync"
+ "sync/atomic"
+ "testing"
+)
+
+func TestSubscribeAndEmit(t *testing.T) {
+ bus := New[string]()
+ var got []string
+ bus.Subscribe(func(s string) { got = append(got, s) })
+ bus.Emit("hello")
+ bus.Emit("world")
+ if len(got) != 2 || got[0] != "hello" || got[1] != "world" {
+ t.Fatalf("expected [hello world], got %v", got)
+ }
+}
+
+func TestMultipleSubscribers(t *testing.T) {
+ bus := New[int]()
+ var a, b int
+ bus.Subscribe(func(v int) { a += v })
+ bus.Subscribe(func(v int) { b += v * 10 })
+ bus.Emit(3)
+ if a != 3 {
+ t.Fatalf("a: expected 3, got %d", a)
+ }
+ if b != 30 {
+ t.Fatalf("b: expected 30, got %d", b)
+ }
+}
+
+func TestUnsubscribe(t *testing.T) {
+ bus := New[int]()
+ var count int
+ unsub := bus.Subscribe(func(int) { count++ })
+ bus.Emit(1)
+ unsub()
+ bus.Emit(2)
+ if count != 1 {
+ t.Fatalf("expected 1 call after unsubscribe, got %d", count)
+ }
+}
+
+func TestUnsubscribeMiddle(t *testing.T) {
+ bus := New[int]()
+ var a, b, c int
+ bus.Subscribe(func(int) { a++ })
+ unsub := bus.Subscribe(func(int) { b++ })
+ bus.Subscribe(func(int) { c++ })
+ bus.Emit(1)
+ unsub()
+ bus.Emit(2)
+ if a != 2 || b != 1 || c != 2 {
+ t.Fatalf("expected a=2 b=1 c=2, got a=%d b=%d c=%d", a, b, c)
+ }
+}
+
+func TestEmitNoSubscribers(t *testing.T) {
+ bus := New[string]()
+ bus.Emit("noop")
+}
+
+func TestConcurrentEmit(t *testing.T) {
+ bus := New[int]()
+ var total atomic.Int64
+ bus.Subscribe(func(v int) { total.Add(int64(v)) })
+
+ var wg sync.WaitGroup
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ bus.Emit(1)
+ }()
+ }
+ wg.Wait()
+ if total.Load() != 100 {
+ t.Fatalf("expected 100, got %d", total.Load())
+ }
+}
+
+func TestDoubleUnsubscribe(t *testing.T) {
+ bus := New[int]()
+ unsub := bus.Subscribe(func(int) {})
+ unsub()
+ unsub()
+}
diff --git a/core/harness/agent_test.go b/core/harness/agent_test.go
new file mode 100644
index 00000000..b369b876
--- /dev/null
+++ b/core/harness/agent_test.go
@@ -0,0 +1,158 @@
+//go:build e2e
+
+package harness
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestAgentSimplePrompt(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "simple-prompt",
+ Prompt: "What is 2+2? Reply with just the number.",
+ OutputContains: []string{"4"},
+ MaxTurns: 2,
+ JudgeCriteria: "The agent must reply with the number 4. No tool calls needed. The answer must be mathematically correct.",
+ }.Run(t, h)
+}
+
+func TestAgentEmptyReply(t *testing.T) {
+ h := New(t)
+ r := h.Agent("Reply with the word 'pong' and nothing else.")
+ Verify(t, r).OK().Done()
+ if !strings.Contains(strings.ToLower(r.Output()), "pong") {
+ t.Fatalf("expected 'pong', got: %s", r.Output())
+ }
+}
+
+func TestAgentBashTool(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "bash-echo",
+ Prompt: "Run 'echo hello_e2e' in a shell and tell me the exact output.",
+ Steps: Steps(
+ Tool("bash").ArgContains("echo hello_e2e").ResultHas("hello_e2e").NoError(),
+ ),
+ OutputContains: []string{"hello_e2e"},
+ NoErrors: true,
+ MaxTurns: 3,
+ JudgeCriteria: "The agent must: (1) call the bash tool with a command containing 'echo hello_e2e', " +
+ "(2) the bash result must contain 'hello_e2e', " +
+ "(3) the final output must report 'hello_e2e' as the result.",
+ }.Run(t, h)
+}
+
+func TestAgentReadTool(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "read-file",
+ Prompt: "Read /etc/hostname and reply with only its contents.",
+ Steps: Steps(
+ Tool("read").ArgContains("hostname").NoError(),
+ ),
+ NoErrors: true,
+ MaxTurns: 3,
+ JudgeCriteria: "The agent must use the read tool to read /etc/hostname, and the final output must contain the hostname value " +
+ "(not just say 'I read it' — the actual content must appear).",
+ }.Run(t, h)
+}
+
+func TestAgentWriteReadRoundtrip(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "write-read-roundtrip",
+ Prompt: "Write 'e2e_marker_42' to /tmp/aiscan_e2e_test.txt, then read it back and confirm.",
+ Steps: Steps(
+ Tool("write").ArgContains("e2e_marker_42").NoError(),
+ Tool("read").ArgContains("aiscan_e2e_test").NoError(),
+ ),
+ Ordered: true,
+ OutputContains: []string{"e2e_marker_42"},
+ NoErrors: true,
+ MaxTurns: 5,
+ JudgeCriteria: "The agent must: (1) write the exact string 'e2e_marker_42' to a file, " +
+ "(2) read it back and confirm the content matches. Both steps must succeed without errors.",
+ }.Run(t, h)
+}
+
+func TestAgentGlobAndRead(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "glob-and-read",
+ Prompt: "List .go files in /mnt/chainreactors/aiscan/pkg/agent/ using glob, then read the first line of defaults.go and tell me the package name.",
+ Steps: Steps(
+ Tool("glob").NoError(),
+ Tool("read").ArgContains("defaults.go").NoError(),
+ ),
+ Ordered: true,
+ OutputContains: []string{"agent"},
+ NoErrors: true,
+ MaxTurns: 4,
+ JudgeCriteria: "The agent must: (1) use glob to list .go files in the agent directory, " +
+ "(2) read defaults.go, (3) correctly report that the package name is 'agent'.",
+ }.Run(t, h)
+}
+
+func TestAgentMultiStepTask(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "multi-step-bash",
+ Prompt: "First run 'uname -a' in bash. After you see the result, run 'whoami' in a SEPARATE bash call. Report both results.",
+ Steps: Steps(
+ Tool("bash").ArgContains("uname").NoError(),
+ Tool("bash").ArgContains("whoami").NoError(),
+ ),
+ Ordered: true,
+ NoErrors: true,
+ MaxTurns: 6,
+ JudgeCriteria: "The agent must make TWO separate bash calls: one for 'uname -a' and one for 'whoami'. " +
+ "Both results must appear in the final output. They must NOT be combined in a single bash call.",
+ }.Run(t, h)
+}
+
+func TestAgentMultiTurn(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "multi-turn-file-ops",
+ Prompt: "Step 1: Create file /tmp/aiscan_multi.txt with content 'step1'. Step 2: Append ' step2' to it. Step 3: Read it and confirm it says 'step1 step2'.",
+ NoErrors: true,
+ MaxTurns: 8,
+ JudgeCriteria: "The agent must perform three sequential file operations: " +
+ "(1) create a file with 'step1', (2) append ' step2' to it, (3) read and confirm the content is 'step1 step2'. " +
+ "The final output must confirm the combined content.",
+ }.Run(t, h)
+}
+
+func TestAgentLargeOutput(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "large-output",
+ Prompt: "Run 'seq 1 500' in bash. Tell me the last number printed.",
+ Steps: Steps(
+ Tool("bash").ArgContains("seq").NoError(),
+ ),
+ OutputContains: []string{"500"},
+ NoErrors: true,
+ MaxTurns: 8,
+ JudgeCriteria: "The agent must run 'seq 1 500' and correctly identify that the last number is 500.",
+ }.Run(t, h)
+}
+
+func TestAgentErrorRecovery(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "error-recovery",
+ Prompt: "Run 'cat /nonexistent/file' in bash. If it fails, report the error message. Then run 'echo recovered' and report that output.",
+ Steps: Steps(
+ Tool("bash").ArgContains("nonexistent"),
+ Tool("bash").ArgContains("recovered").NoError(),
+ ),
+ Ordered: true,
+ OutputContains: []string{"recovered"},
+ MaxTurns: 5,
+ JudgeCriteria: "The agent must: (1) attempt to cat a nonexistent file, (2) recognize the error, " +
+ "(3) recover by running 'echo recovered', (4) report both the error and the recovery in the final output.",
+ }.Run(t, h)
+}
diff --git a/core/harness/cli_test.go b/core/harness/cli_test.go
new file mode 100644
index 00000000..b279a1de
--- /dev/null
+++ b/core/harness/cli_test.go
@@ -0,0 +1,75 @@
+//go:build e2e
+
+package harness
+
+import (
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestScannerHelpExitsClean(t *testing.T) {
+ h := New(t)
+ for _, name := range scannerHelpCommands() {
+ t.Run(name, func(t *testing.T) {
+ r := h.Scanner(name, "-h")
+ Verify(t, r).
+ OK().
+ OutputContains("Usage:").
+ Done()
+ })
+ }
+}
+
+func TestVersionFlag(t *testing.T) {
+ h := New(t)
+ r := h.Run("--version")
+ Verify(t, r).
+ OK().
+ OutputContains("aiscan v").
+ Done()
+}
+
+func TestScannerDirectGogo(t *testing.T) {
+ h := New(t)
+ r := h.Scanner("gogo", "-i", "127.0.0.1", "-p", "80")
+ if r.ExitCode != 0 {
+ t.Logf("gogo exit=%d stderr: %s", r.ExitCode, clip(r.Stderr, 500))
+ }
+}
+
+func TestScannerDirectSpray(t *testing.T) {
+ h := New(t)
+ r := h.Scanner("spray", "-i", "http://127.0.0.1:1", "--limit", "1")
+ if r.ExitCode != 0 {
+ t.Logf("spray exit=%d stderr: %s", r.ExitCode, clip(r.Stderr, 500))
+ }
+}
+
+func TestAgentTimeout(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(15*time.Second,
+ "agent", "-p", "Run 'sleep 60' in bash.",
+ "--timeout", "5",
+ )
+ if r.ExitCode == 0 && r.Duration < 4*time.Second {
+ t.Logf("agent completed before timeout — skipping assertion")
+ return
+ }
+ if r.Duration < 4*time.Second {
+ t.Fatalf("expected ≥4s duration, got %s", r.Duration)
+ }
+}
+
+func init() {
+ if _, err := exec.LookPath("go"); err != nil {
+ panic("go compiler not found; e2e tests require Go toolchain")
+ }
+}
+
+// helpers shared by non-AI tests
+
+func containsCount(s, substr string) int {
+ return strings.Count(s, substr)
+}
diff --git a/core/harness/e2e_test.go b/core/harness/e2e_test.go
new file mode 100644
index 00000000..5847bacd
--- /dev/null
+++ b/core/harness/e2e_test.go
@@ -0,0 +1,3 @@
+//go:build e2e
+
+package harness
diff --git a/core/harness/expect.go b/core/harness/expect.go
new file mode 100644
index 00000000..7efaf45c
--- /dev/null
+++ b/core/harness/expect.go
@@ -0,0 +1,203 @@
+//go:build e2e
+
+package harness
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// ToolPattern describes an expected tool call. Built with the Tool() function
+// and refined with chainable methods.
+//
+// Tool("bash").ArgContains("gogo").NoError()
+// Tool("subagent").Action("create").Arg("name", "worker").Arg("mode", "async")
+type ToolPattern struct {
+ tool string
+ action string
+ argChecks []argCheck
+ resultHas []string
+ resultNot []string
+ noError bool
+ isError bool
+ label string
+}
+
+type argCheck struct {
+ key string
+ contains string
+}
+
+func Tool(name string) ToolPattern {
+ return ToolPattern{tool: name, label: name}
+}
+
+func (p ToolPattern) Action(action string) ToolPattern {
+ p.action = action
+ p.label = fmt.Sprintf("%s/%s", p.tool, action)
+ return p
+}
+
+func (p ToolPattern) Arg(key, contains string) ToolPattern {
+ p.argChecks = append(p.argChecks, argCheck{key: key, contains: contains})
+ return p
+}
+
+func (p ToolPattern) ArgContains(substr string) ToolPattern {
+ p.argChecks = append(p.argChecks, argCheck{contains: substr})
+ return p
+}
+
+func (p ToolPattern) ResultHas(substr string) ToolPattern {
+ p.resultHas = append(p.resultHas, substr)
+ return p
+}
+
+func (p ToolPattern) ResultNot(substr string) ToolPattern {
+ p.resultNot = append(p.resultNot, substr)
+ return p
+}
+
+func (p ToolPattern) NoError() ToolPattern {
+ p.noError = true
+ return p
+}
+
+func (p ToolPattern) IsError() ToolPattern {
+ p.isError = true
+ return p
+}
+
+func (p ToolPattern) Label() string { return p.label }
+
+func (p ToolPattern) Match(e AgentEvent) bool {
+ if e.ToolName != p.tool {
+ return false
+ }
+ if p.action != "" && !argsContainAction(e.Args, p.action) {
+ return false
+ }
+ for _, ac := range p.argChecks {
+ if ac.key != "" {
+ if !argsFieldContains(e.Args, ac.key, ac.contains) {
+ return false
+ }
+ } else {
+ if !strings.Contains(e.Args, ac.contains) {
+ return false
+ }
+ }
+ }
+ for _, s := range p.resultHas {
+ if !strings.Contains(e.Result, s) {
+ return false
+ }
+ }
+ for _, s := range p.resultNot {
+ if strings.Contains(e.Result, s) {
+ return false
+ }
+ }
+ if p.noError && e.IsError {
+ return false
+ }
+ if p.isError && !e.IsError {
+ return false
+ }
+ return true
+}
+
+func (p ToolPattern) describe() string {
+ var parts []string
+ parts = append(parts, p.tool)
+ if p.action != "" {
+ parts = append(parts, fmt.Sprintf("action=%s", p.action))
+ }
+ for _, ac := range p.argChecks {
+ if ac.key != "" {
+ parts = append(parts, fmt.Sprintf("arg[%s]~%q", ac.key, ac.contains))
+ } else {
+ parts = append(parts, fmt.Sprintf("args~%q", ac.contains))
+ }
+ }
+ for _, s := range p.resultHas {
+ parts = append(parts, fmt.Sprintf("result~%q", s))
+ }
+ return strings.Join(parts, " ")
+}
+
+func argsContainAction(argsJSON, action string) bool {
+ return strings.Contains(argsJSON, fmt.Sprintf("%q", action))
+}
+
+func argsFieldContains(argsJSON, key, contains string) bool {
+ var m map[string]any
+ if json.Unmarshal([]byte(argsJSON), &m) != nil {
+ return strings.Contains(argsJSON, contains)
+ }
+ val, ok := m[key]
+ if !ok {
+ return false
+ }
+ s := fmt.Sprintf("%v", val)
+ return strings.Contains(s, contains)
+}
+
+// matchResult holds the result of matching expectations against actual tool calls.
+type matchResult struct {
+ matched []matchPair
+ unmatched []ToolPattern
+}
+
+type matchPair struct {
+ pattern ToolPattern
+ event AgentEvent
+ index int
+}
+
+// matchUnordered finds a matching event for each pattern (greedy, unordered).
+func matchUnordered(patterns []ToolPattern, events []AgentEvent) matchResult {
+ used := make([]bool, len(events))
+ var matched []matchPair
+ var unmatched []ToolPattern
+
+ for _, p := range patterns {
+ found := false
+ for i, e := range events {
+ if used[i] {
+ continue
+ }
+ if p.Match(e) {
+ matched = append(matched, matchPair{pattern: p, event: e, index: i})
+ used[i] = true
+ found = true
+ break
+ }
+ }
+ if !found {
+ unmatched = append(unmatched, p)
+ }
+ }
+ return matchResult{matched: matched, unmatched: unmatched}
+}
+
+// matchOrdered finds matching events in order (subsequence match).
+func matchOrdered(patterns []ToolPattern, events []AgentEvent) matchResult {
+ var matched []matchPair
+ pi := 0
+ for i, e := range events {
+ if pi >= len(patterns) {
+ break
+ }
+ if patterns[pi].Match(e) {
+ matched = append(matched, matchPair{pattern: patterns[pi], event: e, index: i})
+ pi++
+ }
+ }
+ var unmatched []ToolPattern
+ for _, p := range patterns[pi:] {
+ unmatched = append(unmatched, p)
+ }
+ return matchResult{matched: matched, unmatched: unmatched}
+}
diff --git a/core/harness/features_full_test.go b/core/harness/features_full_test.go
new file mode 100644
index 00000000..932cd3cb
--- /dev/null
+++ b/core/harness/features_full_test.go
@@ -0,0 +1,9 @@
+//go:build e2e && full
+
+package harness
+
+func buildTags() string { return "emptytemplates noembed full" }
+
+func scannerHelpCommands() []string {
+ return []string{"gogo", "spray", "katana", "zombie", "neutron", "passive", "scan"}
+}
diff --git a/core/harness/features_test.go b/core/harness/features_test.go
new file mode 100644
index 00000000..78cc3b81
--- /dev/null
+++ b/core/harness/features_test.go
@@ -0,0 +1,9 @@
+//go:build e2e && !full
+
+package harness
+
+func buildTags() string { return "emptytemplates noembed" }
+
+func scannerHelpCommands() []string {
+ return []string{"gogo", "spray", "zombie", "neutron", "scan"}
+}
diff --git a/core/harness/harness.go b/core/harness/harness.go
new file mode 100644
index 00000000..e9d8d8f3
--- /dev/null
+++ b/core/harness/harness.go
@@ -0,0 +1,249 @@
+//go:build e2e
+
+package harness
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+var (
+ cachedExe string
+ cachedExeOnce sync.Once
+ cachedExeErr error
+)
+
+type Harness struct {
+ t *testing.T
+ exe string
+ workDir string
+ baseURL string
+ apiKey string
+ model string
+ timeout time.Duration
+ monitor *Monitor
+}
+
+func (h *Harness) WithMonitor(out ...io.Writer) *Harness {
+ w := io.Writer(os.Stderr)
+ if len(out) > 0 {
+ w = out[0]
+ }
+ h.monitor = NewMonitor(w)
+ return h
+}
+
+func New(t *testing.T) *Harness {
+ t.Helper()
+
+ baseURL := os.Getenv("AISCAN_TEST_BASE_URL")
+ apiKey := os.Getenv("AISCAN_TEST_API_KEY")
+ model := os.Getenv("AISCAN_TEST_MODEL")
+
+ if apiKey == "" {
+ t.Skip("AISCAN_TEST_API_KEY not set, skipping e2e test")
+ }
+ if baseURL == "" {
+ baseURL = "https://api.deepseek.com"
+ }
+ if model == "" {
+ model = "deepseek-v4-pro"
+ }
+
+ cachedExeOnce.Do(func() {
+ cachedExe, cachedExeErr = buildOnce(t)
+ })
+ if cachedExeErr != nil {
+ t.Fatalf("build aiscan: %v", cachedExeErr)
+ }
+
+ h := &Harness{
+ t: t,
+ exe: cachedExe,
+ workDir: t.TempDir(),
+ baseURL: baseURL,
+ apiKey: apiKey,
+ model: model,
+ timeout: 180 * time.Second,
+ }
+ if os.Getenv("AISCAN_MONITOR") != "" {
+ h.monitor = NewMonitor(os.Stderr)
+ }
+ return h
+}
+
+func buildOnce(t *testing.T) (string, error) {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "aiscan-e2e-*")
+ if err != nil {
+ return "", err
+ }
+ exe := filepath.Join(dir, "aiscan-e2e")
+ args := []string{"build", "-tags", buildTags(), "-o", exe, "./cmd/aiscan"}
+ cmd := exec.Command("go", args...)
+ cmd.Dir = repoRoot(t)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("%v\n%s", err, out)
+ }
+ return exe, nil
+}
+
+func (h *Harness) llmArgs() []string {
+ return []string{
+ "--base-url", h.baseURL,
+ "--api-key", h.apiKey,
+ "--model", h.model,
+ }
+}
+
+func (h *Harness) Run(args ...string) *RunResult {
+ h.t.Helper()
+ return h.RunWithTimeout(h.timeout, args...)
+}
+
+func (h *Harness) RunWithTimeout(timeout time.Duration, args ...string) *RunResult {
+ h.t.Helper()
+
+ eventsFile := filepath.Join(h.workDir, fmt.Sprintf("events-%d.jsonl", time.Now().UnixNano()))
+
+ fullArgs := append(h.llmArgs(), "--no-color", "--quiet")
+
+ needsEvents := false
+ for _, a := range args {
+ if a == "agent" {
+ needsEvents = true
+ break
+ }
+ }
+ fullArgs = append(fullArgs, args...)
+
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, h.exe, fullArgs...)
+ cmd.Dir = h.workDir
+ if needsEvents {
+ cmd.Env = append(os.Environ(), "AISCAN_EVENTS_FILE="+eventsFile)
+ }
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ var monitorDone chan struct{}
+ if h.monitor != nil && needsEvents {
+ monitorDone = make(chan struct{})
+ go h.monitor.run(eventsFile, monitorDone)
+ }
+
+ start := time.Now()
+ err := cmd.Run()
+ duration := time.Since(start)
+
+ if monitorDone != nil {
+ close(monitorDone)
+ time.Sleep(50 * time.Millisecond)
+ }
+
+ exitCode := 0
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ exitCode = exitErr.ExitCode()
+ } else if ctx.Err() != nil {
+ exitCode = -1
+ }
+ }
+
+ result := &RunResult{
+ Stdout: stdout.String(),
+ Stderr: stderr.String(),
+ ExitCode: exitCode,
+ Duration: duration,
+ }
+
+ if needsEvents {
+ result.Events = loadEvents(eventsFile)
+ }
+
+ h.t.Logf("ran: aiscan %s (exit=%d, duration=%s, turns=%d, tools=%d)",
+ strings.Join(args, " "), exitCode, duration.Round(time.Millisecond),
+ result.Turns(), len(result.ToolCalls()))
+ if exitCode != 0 {
+ h.t.Logf("stderr: %s", clip(stderr.String(), 2000))
+ }
+
+ return result
+}
+
+func (h *Harness) WorkFile(name string) string {
+ return filepath.Join(h.workDir, name)
+}
+
+// --- convenience runners ---
+
+func (h *Harness) Agent(prompt string, extraArgs ...string) *RunResult {
+ h.t.Helper()
+ args := []string{"agent", "-p", prompt}
+ args = append(args, extraArgs...)
+ return h.Run(args...)
+}
+
+func (h *Harness) AgentWithInput(prompt string, inputs []string, extraArgs ...string) *RunResult {
+ h.t.Helper()
+ args := []string{"agent", "-p", prompt}
+ for _, input := range inputs {
+ args = append(args, "-i", input)
+ }
+ args = append(args, extraArgs...)
+ return h.Run(args...)
+}
+
+func (h *Harness) Scanner(name string, scannerArgs ...string) *RunResult {
+ h.t.Helper()
+ args := []string{name}
+ args = append(args, scannerArgs...)
+ return h.Run(args...)
+}
+
+func (h *Harness) ScannerAI(name string, scannerArgs ...string) *RunResult {
+ h.t.Helper()
+ args := []string{"--ai", name}
+ args = append(args, scannerArgs...)
+ return h.Run(args...)
+}
+
+// --- helpers ---
+
+func repoRoot(t *testing.T) string {
+ t.Helper()
+ wd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return filepath.Clean(filepath.Join(wd, "..", ".."))
+}
+
+func envOrDefault(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
+
+func clip(s string, maxLen int) string {
+ s = strings.TrimSpace(s)
+ if len(s) <= maxLen {
+ return s
+ }
+ return s[:maxLen] + "... (truncated)"
+}
diff --git a/core/harness/intent.go b/core/harness/intent.go
new file mode 100644
index 00000000..0b4a56d7
--- /dev/null
+++ b/core/harness/intent.go
@@ -0,0 +1,173 @@
+//go:build e2e
+
+package harness
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Intent describes a complete AI behavior test case declaratively.
+// Instead of writing imperative test code, define an Intent and call Run.
+//
+// Intent{
+// Name: "subagent-lifecycle",
+// Prompt: "Create a sync subagent to scan localhost.",
+// Steps: Steps(
+// Tool("subagent").Action("create").Arg("name", "scanner"),
+// ),
+// Ordered: true,
+// MaxTurns: 4,
+// NoErrors: true,
+// }.Run(t, h)
+type Intent struct {
+ Name string
+ Prompt string
+ ExtraArgs []string
+ Timeout time.Duration
+
+ // Steps describes expected tool calls.
+ Steps []ToolPattern
+
+ // Ordered requires steps to appear in sequence (subsequence match).
+ // When false, steps can appear in any order.
+ Ordered bool
+
+ // OutputContains lists substrings that must appear in stdout/stderr.
+ OutputContains []string
+
+ // OutputMissing lists substrings that must NOT appear in output.
+ OutputMissing []string
+
+ // NoErrors requires all tool calls to succeed.
+ NoErrors bool
+
+ // MaxTurns caps the number of turns (0 = no limit).
+ MaxTurns int
+
+ // MaxToolCalls caps total tool invocations (0 = no limit).
+ MaxToolCalls int
+
+ // MaxDuration caps wall-clock time (0 = no limit).
+ MaxDuration time.Duration
+
+ // JudgeCriteria, when non-empty, enables LLM-as-judge evaluation.
+ // The judge receives the intent prompt, this criteria string, and the
+ // full execution trace. It returns a pass/fail verdict.
+ // Example: "The agent must have created exactly one loop named 'scanner',
+ // listed it to confirm it exists, then deleted it."
+ JudgeCriteria string
+
+ // Check is an optional custom verification function.
+ Check func(t *testing.T, r *RunResult)
+}
+
+// Steps is a convenience constructor for []ToolPattern.
+func Steps(patterns ...ToolPattern) []ToolPattern { return patterns }
+
+// Run executes the intent against the harness and verifies all expectations.
+func (intent Intent) Run(t *testing.T, h *Harness) *RunResult {
+ t.Helper()
+
+ var r *RunResult
+ if intent.Timeout > 0 {
+ r = h.RunWithTimeout(intent.Timeout, intent.buildArgs()...)
+ } else {
+ r = h.Agent(intent.Prompt, intent.ExtraArgs...)
+ }
+ intent.verify(t, h, r)
+ return r
+}
+
+func (intent Intent) buildArgs() []string {
+ args := []string{"agent", "-p", intent.Prompt}
+ args = append(args, intent.ExtraArgs...)
+ return args
+}
+
+func (intent Intent) verify(t *testing.T, h *Harness, r *RunResult) {
+ t.Helper()
+
+ v := Verify(t, r).OK()
+
+ // structural checks
+ if len(intent.Steps) > 0 {
+ if intent.Ordered {
+ v = v.ExpectInOrder(intent.Steps...)
+ } else {
+ v = v.Expect(intent.Steps...)
+ }
+ }
+ for _, s := range intent.OutputContains {
+ v = v.OutputContains(s)
+ }
+ for _, s := range intent.OutputMissing {
+ v = v.OutputMissing(s)
+ }
+ if intent.NoErrors {
+ v = v.NoToolErrors()
+ }
+ if intent.MaxTurns > 0 {
+ v = v.MaxTurns(intent.MaxTurns)
+ }
+ if intent.MaxToolCalls > 0 {
+ v = v.MaxToolCalls(intent.MaxToolCalls)
+ }
+ if intent.MaxDuration > 0 {
+ v = v.CompletedWithin(intent.MaxDuration)
+ }
+
+ // semantic check via LLM judge
+ if intent.JudgeCriteria != "" {
+ v = v.JudgeWith(h.Judge(), intent.Prompt, intent.JudgeCriteria)
+ }
+
+ v.Done()
+
+ if intent.Check != nil {
+ intent.Check(t, r)
+ }
+}
+
+// Describe returns a human-readable summary of the intent for logging.
+func (intent Intent) Describe() string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Intent: %s\n", intent.Name))
+ sb.WriteString(fmt.Sprintf(" Prompt: %s\n", clip(intent.Prompt, 80)))
+ if len(intent.Steps) > 0 {
+ order := "any order"
+ if intent.Ordered {
+ order = "in order"
+ }
+ sb.WriteString(fmt.Sprintf(" Steps (%s):\n", order))
+ for i, s := range intent.Steps {
+ sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, s.describe()))
+ }
+ }
+ if len(intent.OutputContains) > 0 {
+ sb.WriteString(fmt.Sprintf(" Output must contain: %v\n", intent.OutputContains))
+ }
+ if intent.MaxTurns > 0 {
+ sb.WriteString(fmt.Sprintf(" Max turns: %d\n", intent.MaxTurns))
+ }
+ if intent.NoErrors {
+ sb.WriteString(" No tool errors allowed\n")
+ }
+ return sb.String()
+}
+
+// IntentSuite runs multiple intents as subtests.
+func IntentSuite(t *testing.T, h *Harness, intents ...Intent) {
+ t.Helper()
+ for _, intent := range intents {
+ name := intent.Name
+ if name == "" {
+ name = clip(intent.Prompt, 40)
+ }
+ t.Run(name, func(t *testing.T) {
+ intent.Run(t, h)
+ })
+ }
+}
diff --git a/core/harness/ioa_test.go b/core/harness/ioa_test.go
new file mode 100644
index 00000000..d95be71e
--- /dev/null
+++ b/core/harness/ioa_test.go
@@ -0,0 +1,338 @@
+//go:build e2e
+
+package harness
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/chainreactors/ioa/protocols"
+ ioaclient "github.com/chainreactors/ioa/client"
+ ioaserver "github.com/chainreactors/ioa/server"
+)
+
+func TestIOALoopReceivesTask(t *testing.T) {
+ service := ioaserver.NewService(ioaserver.NewMemoryStore(), "")
+ srv := httptest.NewServer(ioaserver.NewHandler(service))
+ defer srv.Close()
+
+ h := New(t)
+
+ go func() {
+ h.RunWithTimeout(60*time.Second,
+ "agent", "--ioa-url", "http://127.0.0.1:8765",
+ "--ioa-url", srv.URL,
+ "--space", "test-loop",
+ "-p", "I am a test worker",
+ "--timeout", "45",
+ )
+ }()
+
+ time.Sleep(3 * time.Second)
+
+ controller, err := ioaclient.NewClient(srv.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil {
+ t.Fatal(err)
+ }
+ space, err := controller.Space(ctx, "test-loop", "e2e test")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ nodes, err := controller.ListNodes(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(nodes) == 0 {
+ t.Fatal("no worker nodes registered in space")
+ }
+ workerNodeID := nodes[0].ID
+
+ _, err = controller.Send(ctx, space.ID, protocols.SendMessage{
+ Content: map[string]any{"content": "Run 'echo ioa_task_received' in bash and report the output."},
+ Refs: &protocols.Ref{Nodes: []string{workerNodeID}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ time.Sleep(30 * time.Second)
+
+ requireIOAMessageContains(t, controller, ctx, space.ID, "ioa_task_received")
+}
+
+func TestIOALoopMultipleWorkers(t *testing.T) {
+ service := ioaserver.NewService(ioaserver.NewMemoryStore(), "")
+ srv := httptest.NewServer(ioaserver.NewHandler(service))
+ defer srv.Close()
+
+ h := New(t)
+
+ for i := 1; i <= 2; i++ {
+ i := i
+ go func() {
+ h.RunWithTimeout(45*time.Second,
+ "agent", "--ioa-url", "http://127.0.0.1:8765",
+ "--ioa-url", srv.URL,
+ "--space", "multi-worker",
+ "--ioa-node-name", fmt.Sprintf("worker-%d", i),
+ "-p", fmt.Sprintf("I am worker %d", i),
+ "--timeout", "40",
+ )
+ }()
+ }
+
+ time.Sleep(4 * time.Second)
+
+ controller, err := ioaclient.NewClient(srv.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := controller.Space(ctx, "multi-worker", "e2e multi"); err != nil {
+ t.Fatal(err)
+ }
+
+ nodes, err := controller.ListNodes(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ workerCount := 0
+ for _, n := range nodes {
+ if strings.HasPrefix(n.Name, "worker-") {
+ workerCount++
+ }
+ }
+ if workerCount < 2 {
+ t.Fatalf("expected ≥2 worker nodes, got %d (total nodes: %d)", workerCount, len(nodes))
+ }
+}
+
+func TestIOALoopPeerMessage(t *testing.T) {
+ service := ioaserver.NewService(ioaserver.NewMemoryStore(), "")
+ srv := httptest.NewServer(ioaserver.NewHandler(service))
+ defer srv.Close()
+
+ h := New(t)
+
+ go func() {
+ h.RunWithTimeout(45*time.Second,
+ "agent", "--ioa-url", "http://127.0.0.1:8765",
+ "--ioa-url", srv.URL,
+ "--space", "peer-test",
+ "-p", "test worker",
+ "--timeout", "40",
+ )
+ }()
+
+ time.Sleep(3 * time.Second)
+
+ controller, err := ioaclient.NewClient(srv.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil {
+ t.Fatal(err)
+ }
+ space, err := controller.Space(ctx, "peer-test", "e2e peer")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ nodes, err := controller.ListNodes(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(nodes) == 0 {
+ t.Fatal("no worker nodes")
+ }
+ workerNodeID := nodes[0].ID
+
+ _, err = controller.Send(ctx, space.ID, protocols.SendMessage{
+ Content: map[string]any{"content": "Run echo peer_hello and report result"},
+ Refs: &protocols.Ref{Nodes: []string{workerNodeID}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = controller.Send(ctx, space.ID, protocols.SendMessage{
+ Content: map[string]any{"content": "Additional context: also run 'echo peer_context_received'"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ time.Sleep(25 * time.Second)
+
+ requireIOAMessageContains(t, controller, ctx, space.ID, "peer_hello")
+}
+
+func TestIOATaskSpawnsSubagents(t *testing.T) {
+ service := ioaserver.NewService(ioaserver.NewMemoryStore(), "")
+ srv := httptest.NewServer(ioaserver.NewHandler(service))
+ defer srv.Close()
+
+ h := New(t)
+
+ go func() {
+ h.RunWithTimeout(90*time.Second,
+ "agent", "--ioa-url", "http://127.0.0.1:8765",
+ "--ioa-url", srv.URL,
+ "--space", "subagent-fan",
+ "-p", "I am a worker that parallelizes tasks using subagents",
+ "--timeout", "80",
+ )
+ }()
+
+ time.Sleep(4 * time.Second)
+
+ controller, err := ioaclient.NewClient(srv.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil {
+ t.Fatal(err)
+ }
+ space, err := controller.Space(ctx, "subagent-fan", "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ nodes, err := controller.ListNodes(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var workerNodeID string
+ for _, n := range nodes {
+ if n.Name != "controller" {
+ workerNodeID = n.ID
+ break
+ }
+ }
+ if workerNodeID == "" {
+ t.Fatal("no worker node found")
+ }
+
+ _, err = controller.Send(ctx, space.ID, protocols.SendMessage{
+ Content: map[string]any{
+ "content": "I need you to gather system info in parallel. " +
+ "Create 2 async subagents: one runs 'echo subagent_alpha_ok' in bash, " +
+ "the other runs 'echo subagent_beta_ok' in bash. " +
+ "Wait for both results, then respond with a combined summary that includes both markers.",
+ },
+ Refs: &protocols.Ref{Nodes: []string{workerNodeID}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ time.Sleep(60 * time.Second)
+
+ requireIOAMessageContains(t, controller, ctx, space.ID, "subagent_alpha_ok")
+ requireIOAMessageContains(t, controller, ctx, space.ID, "subagent_beta_ok")
+}
+
+func TestIOATwoWorkersDispatch(t *testing.T) {
+ service := ioaserver.NewService(ioaserver.NewMemoryStore(), "")
+ srv := httptest.NewServer(ioaserver.NewHandler(service))
+ defer srv.Close()
+
+ h := New(t)
+
+ for i := 1; i <= 2; i++ {
+ i := i
+ go func() {
+ h.RunWithTimeout(75*time.Second,
+ "agent", "--ioa-url", "http://127.0.0.1:8765",
+ "--ioa-url", srv.URL,
+ "--space", "dispatch-2",
+ "--ioa-node-name", fmt.Sprintf("worker-%d", i),
+ "-p", fmt.Sprintf("I am worker %d", i),
+ "--timeout", "70",
+ )
+ }()
+ }
+
+ time.Sleep(5 * time.Second)
+
+ controller, err := ioaclient.NewClient(srv.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil {
+ t.Fatal(err)
+ }
+ space, err := controller.Space(ctx, "dispatch-2", "e2e dispatch")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ nodes, err := controller.ListNodes(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var workers []protocols.Node
+ for _, n := range nodes {
+ if strings.HasPrefix(n.Name, "worker-") {
+ workers = append(workers, n)
+ }
+ }
+ if len(workers) < 2 {
+ t.Fatalf("expected ≥2 workers, got %d", len(workers))
+ }
+
+ for i, w := range workers {
+ marker := fmt.Sprintf("dispatch_marker_%d", i+1)
+ _, err = controller.Send(ctx, space.ID, protocols.SendMessage{
+ Content: map[string]any{
+ "content": fmt.Sprintf("Run 'echo %s' in bash and report.", marker),
+ },
+ Refs: &protocols.Ref{Nodes: []string{w.ID}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ time.Sleep(45 * time.Second)
+
+ requireIOAMessageContains(t, controller, ctx, space.ID, "dispatch_marker_1")
+ requireIOAMessageContains(t, controller, ctx, space.ID, "dispatch_marker_2")
+}
+
+// requireIOAMessageContains checks that at least one message in the space contains substr.
+func requireIOAMessageContains(t *testing.T, client *ioaclient.Client, ctx context.Context, spaceID, substr string) {
+ t.Helper()
+ msgs, err := client.Read(ctx, spaceID, protocols.ReadOptions{All: true})
+ if err != nil {
+ t.Fatalf("read space: %v", err)
+ }
+ for _, m := range msgs {
+ raw, _ := json.Marshal(m.Content)
+ if strings.Contains(string(raw), substr) {
+ return
+ }
+ }
+ var summaries []string
+ for _, m := range msgs {
+ raw, _ := json.Marshal(m.Content)
+ summaries = append(summaries, clip(string(raw), 200))
+ }
+ t.Fatalf("no IOA message contains %q:\n%s", substr, strings.Join(summaries, "\n"))
+}
diff --git a/core/harness/judge.go b/core/harness/judge.go
new file mode 100644
index 00000000..8ea6eaaa
--- /dev/null
+++ b/core/harness/judge.go
@@ -0,0 +1,205 @@
+//go:build e2e
+
+package harness
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Verdict is the structured result from an LLM judge evaluation.
+type Verdict struct {
+ Pass bool `json:"pass"`
+ Score int `json:"score"`
+ Reason string `json:"reason"`
+ Issues []string `json:"issues"`
+}
+
+// Judge evaluates agent execution results using an LLM.
+type Judge struct {
+ baseURL string
+ apiKey string
+ model string
+ timeout time.Duration
+}
+
+func NewJudge(baseURL, apiKey, model string) *Judge {
+ return &Judge{
+ baseURL: strings.TrimRight(baseURL, "/"),
+ apiKey: apiKey,
+ model: model,
+ timeout: 30 * time.Second,
+ }
+}
+
+func (h *Harness) Judge() *Judge {
+ return NewJudge(h.baseURL, h.apiKey, h.model)
+}
+
+const judgeMaxRetries = 3
+
+// Evaluate sends the intent and execution trace to the LLM for judgment.
+func (j *Judge) Evaluate(intent string, criteria string, r *RunResult) (*Verdict, error) {
+ trace := buildTrace(r)
+ prompt := buildJudgePrompt(intent, criteria, trace)
+
+ var lastErr error
+ for attempt := 0; attempt < judgeMaxRetries; attempt++ {
+ v, err := j.call(prompt)
+ if err == nil {
+ return v, nil
+ }
+ lastErr = err
+ if attempt < judgeMaxRetries-1 {
+ time.Sleep(time.Duration(attempt+1) * time.Second)
+ }
+ }
+ return nil, fmt.Errorf("judge failed after %d attempts: %w", judgeMaxRetries, lastErr)
+}
+
+func buildTrace(r *RunResult) string {
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "Exit code: %d\n", r.ExitCode)
+ fmt.Fprintf(&sb, "Duration: %s\n", r.Duration.Round(time.Millisecond))
+ fmt.Fprintf(&sb, "Turns: %d\n", r.Turns())
+ fmt.Fprintf(&sb, "Tool calls: %d\n", len(r.ToolCalls()))
+
+ sb.WriteString("\nTool call trace:\n")
+ for i, e := range r.ToolCalls() {
+ fmt.Fprintf(&sb, " [%d] %s", i+1, e.ToolName)
+ if e.IsError {
+ sb.WriteString(" (ERROR)")
+ }
+ sb.WriteByte('\n')
+ if e.Args != "" {
+ fmt.Fprintf(&sb, " args: %s\n", clip(e.Args, 200))
+ }
+ if e.Result != "" {
+ fmt.Fprintf(&sb, " result: %s\n", clip(e.Result, 300))
+ }
+ }
+
+ if output := strings.TrimSpace(r.Stdout); output != "" {
+ fmt.Fprintf(&sb, "\nFinal output:\n%s\n", clip(output, 1000))
+ }
+ return sb.String()
+}
+
+const judgeSystemPrompt = `You are a strict test evaluator for an AI agent system. Given an intent (what was asked), evaluation criteria, and execution trace (what happened), determine whether the agent correctly fulfilled the intent.
+
+Respond with ONLY a JSON object:
+{"pass": true/false, "score": 0-100, "reason": "one sentence summary", "issues": ["issue1", "issue2"]}
+
+Rules:
+- pass=true only if the intent was fully and correctly completed
+- score: 100=perfect, 80+=good, 60+=acceptable, <60=fail
+- issues: list specific problems (empty if pass=true)
+- Be strict: "ran without errors" is not the same as "fulfilled the intent"
+- Check that the right tools were used with correct arguments
+- Check that results contain expected data, not just that tools were called`
+
+func buildJudgePrompt(intent, criteria, trace string) string {
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "## Intent\n%s\n\n", intent)
+ if criteria != "" {
+ fmt.Fprintf(&sb, "## Evaluation Criteria\n%s\n\n", criteria)
+ }
+ fmt.Fprintf(&sb, "## Execution Trace\n%s", trace)
+ return sb.String()
+}
+
+type chatRequest struct {
+ Model string `json:"model"`
+ Messages []chatMessage `json:"messages"`
+ MaxTokens int `json:"max_tokens"`
+ Temperature float64 `json:"temperature"`
+}
+
+type chatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type chatResponse struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+}
+
+func (j *Judge) call(userPrompt string) (*Verdict, error) {
+ body := chatRequest{
+ Model: j.model,
+ Messages: []chatMessage{
+ {Role: "system", Content: judgeSystemPrompt},
+ {Role: "user", Content: userPrompt},
+ },
+ MaxTokens: 512,
+ Temperature: 0,
+ }
+
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), j.timeout)
+ defer cancel()
+
+ url := j.baseURL + "/chat/completions"
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+j.apiKey)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("judge API call failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respData, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("judge API returned %d: %s", resp.StatusCode, clip(string(respData), 500))
+ }
+
+ var chatResp chatResponse
+ if err := json.Unmarshal(respData, &chatResp); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+ if len(chatResp.Choices) == 0 {
+ return nil, fmt.Errorf("judge returned no choices")
+ }
+
+ return parseVerdict(chatResp.Choices[0].Message.Content)
+}
+
+func parseVerdict(raw string) (*Verdict, error) {
+ raw = strings.TrimSpace(raw)
+ raw = stripJSONFences(raw)
+
+ var v Verdict
+ if err := json.Unmarshal([]byte(raw), &v); err != nil {
+ return nil, fmt.Errorf("parse verdict JSON: %w\nraw: %s", err, clip(raw, 500))
+ }
+ return &v, nil
+}
+
+func stripJSONFences(s string) string {
+ s = strings.TrimPrefix(s, "```json")
+ s = strings.TrimPrefix(s, "```")
+ s = strings.TrimSuffix(s, "```")
+ return strings.TrimSpace(s)
+}
diff --git a/core/harness/monitor.go b/core/harness/monitor.go
new file mode 100644
index 00000000..b45413c4
--- /dev/null
+++ b/core/harness/monitor.go
@@ -0,0 +1,150 @@
+//go:build e2e
+
+package harness
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/chainreactors/aiscan/pkg/agent/truncate"
+)
+
+// Monitor tails the agent events JSONL file in real-time, rendering a
+// compact live view of what the agent is doing. Attach it to a Harness
+// with h.WithMonitor(). The monitor runs in a background goroutine and
+// stops automatically when the agent process exits.
+//
+// Output goes to the provided Writer (typically os.Stderr for live
+// terminal view, or a test log adapter).
+type Monitor struct {
+ out io.Writer
+ mu sync.Mutex
+ stopped bool
+ turnSeen int
+}
+
+func NewMonitor(out io.Writer) *Monitor {
+ return &Monitor{out: out}
+}
+
+func (m *Monitor) printf(format string, args ...any) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ fmt.Fprintf(m.out, format, args...)
+}
+
+// run tails the events file until stop is called.
+func (m *Monitor) run(path string, done <-chan struct{}) {
+ for {
+ f, err := os.Open(path)
+ if err == nil {
+ m.tailFile(f, done)
+ f.Close()
+ return
+ }
+ select {
+ case <-done:
+ return
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+}
+
+func (m *Monitor) tailFile(f *os.File, done <-chan struct{}) {
+ var offset int64
+ buf := make([]byte, 64*1024)
+ var partial string
+
+ for {
+ n, _ := f.ReadAt(buf, offset)
+ if n > 0 {
+ offset += int64(n)
+ data := partial + string(buf[:n])
+ partial = ""
+
+ lines := strings.Split(data, "\n")
+ for i, line := range lines {
+ if i == len(lines)-1 && !strings.HasSuffix(data, "\n") {
+ partial = line
+ continue
+ }
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ m.renderLine(line)
+ }
+ }
+
+ select {
+ case <-done:
+ if partial != "" {
+ m.renderLine(strings.TrimSpace(partial))
+ }
+ return
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+}
+
+func (m *Monitor) renderLine(line string) {
+ var ev monitorEvent
+ if json.Unmarshal([]byte(line), &ev) != nil {
+ return
+ }
+ m.renderEvent(ev)
+}
+
+type monitorEvent struct {
+ Type string `json:"type"`
+ Turn int `json:"turn"`
+ ToolName string `json:"tool_name"`
+ Args string `json:"arguments"`
+ Result string `json:"result"`
+ IsError bool `json:"is_error"`
+ Message *monitorMsg `json:"message"`
+ Stop string `json:"stop"`
+}
+
+type monitorMsg struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+func (m *Monitor) renderEvent(ev monitorEvent) {
+ switch ev.Type {
+ case "turn_start":
+ if ev.Turn != m.turnSeen {
+ m.turnSeen = ev.Turn
+ m.printf("\n── turn %d ──\n", ev.Turn)
+ }
+
+ case "message_end":
+ if ev.Message != nil && ev.Message.Role == "assistant" && ev.Message.Content != "" {
+ m.printf(" 💬 %s\n", truncate.Clip(ev.Message.Content, 200))
+ }
+
+ case "tool_execution_start":
+ m.printf(" 🔧 %s %s\n", ev.ToolName, truncate.Clip(ev.Args, 120))
+
+ case "tool_execution_end":
+ if ev.IsError {
+ m.printf(" ❌ %s error: %s\n", ev.ToolName, truncate.Clip(ev.Result, 100))
+ } else {
+ size := len(ev.Result)
+ if size > 0 {
+ m.printf(" ✓ %s → %d bytes: %s\n", ev.ToolName, size, truncate.Clip(ev.Result, 100))
+ } else {
+ m.printf(" ✓ %s → (empty)\n", ev.ToolName)
+ }
+ }
+
+ case "agent_end":
+ m.printf("\n── agent done (stop=%s) ──\n", ev.Stop)
+ }
+}
diff --git a/core/harness/pipeline_test.go b/core/harness/pipeline_test.go
new file mode 100644
index 00000000..1a5422a4
--- /dev/null
+++ b/core/harness/pipeline_test.go
@@ -0,0 +1,139 @@
+//go:build e2e
+
+package harness
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestScannerAIGogo(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(90*time.Second, "--ai", "--timeout", "60", "gogo", "-i", "127.0.0.1", "-p", "80")
+ Verify(t, r).OK().Done()
+}
+
+func TestAgentGogoScan(t *testing.T) {
+ h := New(t)
+ r := h.Agent("Use gogo to scan 127.0.0.1 port 80. Show the raw scanner output.")
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ Done()
+}
+
+func TestAgentSprayScan(t *testing.T) {
+ h := New(t)
+ r := h.Agent("Run spray against http://127.0.0.1:1 with --limit 1 and report the result.")
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ Done()
+}
+
+func TestAgentScanWithSkill(t *testing.T) {
+ h := New(t)
+ r := h.Agent("Use the scan command to scan 127.0.0.1 with --mode quick. Summarize the results.", "-s", "aiscan")
+ Verify(t, r).OK().Done()
+}
+
+func TestAgentScanAnalyze(t *testing.T) {
+ h := New(t)
+ r := h.Agent("Run 'scan -i 127.0.0.1 --mode quick' and analyze the output. Tell me what services were found, if any.")
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ Done()
+}
+
+func TestAgentScanAndVerify(t *testing.T) {
+ h := New(t)
+ r := h.Agent(
+ "Scan 127.0.0.1 with scan --mode quick. If any services are found, " +
+ "attempt to verify them by connecting to the reported port using bash (e.g. curl or nc). " +
+ "Report: services found, verification results.",
+ )
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ Done()
+}
+
+func TestAgentScanAnalyzeVerifyPipeline(t *testing.T) {
+ h := New(t)
+ r := h.Agent(
+ "Execute this pipeline:\n" +
+ "1. Run 'scan -i 127.0.0.1 --mode quick' to scan the target.\n" +
+ "2. Parse the scan results to identify any open ports or services.\n" +
+ "3. For each service found, attempt a basic verification:\n" +
+ " - If HTTP: run 'curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:' \n" +
+ " - If SSH: run 'echo | nc -w2 127.0.0.1 ' \n" +
+ " - If no services found, report that.\n" +
+ "4. Summarize: services found, verification status for each.",
+ )
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ ToolArgMatch("bash", func(args string) bool {
+ return strings.Contains(args, "scan") && strings.Contains(args, "127.0.0.1")
+ }).
+ ToolResultMatch("bash", func(res string) bool { return res != "" }).
+ Done()
+}
+
+func TestAgentParallelTargetScan(t *testing.T) {
+ h := New(t)
+ r := h.Agent(
+ "I need to check 3 targets in parallel. Create 3 async subagents:\n" +
+ "1. Named 'target-a': run 'echo target_a_scanned' in bash and report.\n" +
+ "2. Named 'target-b': run 'echo target_b_scanned' in bash and report.\n" +
+ "3. Named 'target-c': run 'echo target_c_scanned' in bash and report.\n" +
+ "Wait for ALL subagents to complete. List the subagents to track progress. " +
+ "Once all are done, produce a consolidated report with all 3 markers.",
+ )
+ Verify(t, r).
+ OK().
+ MinSubagentCreates(3).
+ OutputContains("target_a_scanned").
+ OutputContains("target_b_scanned").
+ OutputContains("target_c_scanned").
+ Done()
+}
+
+func TestAgentBackgroundTaskDrivesFollowUp(t *testing.T) {
+ h := New(t)
+ r := h.Agent(
+ "Start a detached tmux session: tmux new -d -s scan 'sleep 1 && echo SCAN_COMPLETE port=22 service=ssh'. " +
+ "Use tmux ls to confirm it's running. " +
+ "Use tmux wait -t scan to wait for it. Use tmux capture-pane -t scan to get output. " +
+ "Then run a follow-up command 'echo VERIFY_22_OK' to simulate verification. " +
+ "Report both the scan result and the verification result.",
+ )
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ MinToolCalls(3).
+ AnyResultContains("SCAN_COMPLETE").
+ AnyResultContains("VERIFY_22_OK").
+ Done()
+}
+
+func TestAgentTmuxAndSubagentCoordination(t *testing.T) {
+ h := New(t)
+ r := h.Agent(
+ "Do these in parallel:\n" +
+ "1. Start a detached tmux session: tmux new -d -s bg 'sleep 1 && echo bg_task_done_xyz'\n" +
+ "2. Create an async subagent named 'helper' with prompt: " +
+ "'Run echo subagent_helper_done in bash and report.'\n" +
+ "Monitor both: use tmux wait/capture-pane and wait for the subagent completion notification. " +
+ "Report both results when they complete.",
+ )
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ ToolUsed("subagent").
+ AnyResultContains("bg_task_done_xyz").
+ AnyResultContains("subagent_helper_done").
+ Done()
+}
diff --git a/core/harness/realscan_test.go b/core/harness/realscan_test.go
new file mode 100644
index 00000000..534fa13e
--- /dev/null
+++ b/core/harness/realscan_test.go
@@ -0,0 +1,304 @@
+//go:build e2e
+
+package harness
+
+import (
+ "context"
+ "fmt"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/chainreactors/ioa/protocols"
+ ioaclient "github.com/chainreactors/ioa/client"
+ ioaserver "github.com/chainreactors/ioa/server"
+)
+
+func sendMessage(content, nodeID string) protocols.SendMessage {
+ return protocols.SendMessage{
+ Content: map[string]any{"content": content},
+ Refs: &protocols.Ref{Nodes: []string{nodeID}},
+ }
+}
+
+const realTarget = "101.132.149.35/28"
+const realSingleTarget = "101.132.149.35"
+
+// =====================================================================
+// Layer 1: Direct scanner (no AI) — baseline
+// =====================================================================
+
+func TestRealScanDirectGogo(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(120*time.Second, "gogo", "-i", realTarget, "-p", "top100")
+ Verify(t, r).OK().Done()
+ t.Logf("gogo output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000))
+}
+
+func TestRealScanDirectSpray(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(120*time.Second, "spray", "-i", fmt.Sprintf("http://%s", realSingleTarget), "--finger")
+ Verify(t, r).OK().Done()
+ t.Logf("spray output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000))
+}
+
+func TestRealScanDirectPipeline(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(300*time.Second, "scan", "-i", realSingleTarget, "--mode", "quick")
+ Verify(t, r).OK().Done()
+ t.Logf("scan output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 3000))
+}
+
+// =====================================================================
+// Layer 2: Scanner AI analysis and scan pipeline AI skills
+// =====================================================================
+
+func TestRealScanGogoAI(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "real-gogo-ai",
+ Prompt: "", // not used in scanner AI mode
+ Timeout: 180 * time.Second,
+ JudgeCriteria: "The scanner must have executed gogo against the target and the AI must have provided " +
+ "a meaningful analysis of discovered services. The analysis should mention specific ports, " +
+ "services, or results - not just a generic summary.",
+ }.verifyScanner(t, h, "--ai", "--timeout", "120", "gogo", "-i", realTarget, "-p", "top100")
+}
+
+func TestRealScanPipelineAISkills(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "real-scan-pipeline-ai-skills",
+ Prompt: "",
+ Timeout: 300 * time.Second,
+ JudgeCriteria: "The scan pipeline must have run against the target with explicit AI verification " +
+ "and sniper options. The output should include concrete scan findings or AI skill results, " +
+ "not just a generic completion message.",
+ }.verifyScanner(t, h, "--timeout", "240", "scan", "-i", realSingleTarget, "--mode", "quick", "--verify=high", "--sniper")
+}
+
+// =====================================================================
+// Layer 3: Agent mode — LLM decides how to scan
+// =====================================================================
+
+func TestRealAgentGogoScan(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "real-agent-gogo",
+ Prompt: fmt.Sprintf("Use gogo to scan %s with port range top100. Report all discovered services including port, protocol, and any fingerprints.", realTarget),
+ Steps: Steps(
+ Tool("bash").ArgContains("gogo").NoError(),
+ ),
+ Timeout: 300 * time.Second,
+ MaxTurns: 20,
+ JudgeCriteria: "The agent must have executed gogo against 101.132.149.35/28 with appropriate port arguments. " +
+ "The final output must list specific discovered services (port numbers, service names). " +
+ "Generic statements like 'scan completed' without specific results are a failure.",
+ }.Run(t, h)
+}
+
+func TestRealAgentSprayScan(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "real-agent-spray",
+ Prompt: fmt.Sprintf("Use spray to probe http://%s and identify web technologies and fingerprints. Report what you find.", realSingleTarget),
+ Steps: Steps(
+ Tool("bash").ArgContains("spray").NoError(),
+ ),
+ Timeout: 300 * time.Second,
+ MaxTurns: 20,
+ JudgeCriteria: "The agent must run spray against the target URL. The output must include specific web " +
+ "technology fingerprints or HTTP response information — not just 'spray completed'.",
+ }.Run(t, h)
+}
+
+func TestRealAgentFullPipeline(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "real-agent-full-pipeline",
+ Prompt: fmt.Sprintf("Perform a comprehensive scan of %s:\n"+
+ "1. Use gogo to discover open ports and services\n"+
+ "2. For any HTTP services found, use spray to fingerprint them\n"+
+ "3. Summarize all results: IPs, ports, services, web technologies", realSingleTarget),
+ Steps: Steps(
+ Tool("bash").ArgContains("gogo").NoError(),
+ ),
+ Timeout: 300 * time.Second,
+ MaxTurns: 12,
+ JudgeCriteria: "The agent must execute a multi-step scan: (1) port discovery with gogo, " +
+ "(2) web fingerprinting with spray for any HTTP services found. " +
+ "The final summary must list concrete results (specific IPs, ports, services). " +
+ "If no HTTP services are found, the agent should report that and skip spray — that's acceptable.",
+ }.Run(t, h)
+}
+
+// =====================================================================
+// Layer 4: Agent + skills - verify and analyze results
+// =====================================================================
+
+func TestRealAgentScanWithVerify(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "real-agent-scan-verify",
+ Prompt: fmt.Sprintf("Scan %s with gogo. For each service found, attempt basic verification "+
+ "(e.g. curl for HTTP, or nc for other services). Report: service, port, verification status.", realSingleTarget),
+ Steps: Steps(
+ Tool("bash").ArgContains("gogo").NoError(),
+ ),
+ Timeout: 300 * time.Second,
+ MaxTurns: 15,
+ JudgeCriteria: "The agent must: (1) run gogo to discover services, (2) attempt verification of at least one " +
+ "discovered service using curl/nc/similar. The report must show per-service verification status. " +
+ "If gogo finds no services, the agent should report that — still a pass if handled correctly.",
+ }.Run(t, h)
+}
+
+func TestRealAgentScanReport(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "real-agent-scan-report",
+ Prompt: fmt.Sprintf("Scan %s using the scan command with --mode quick. Generate a security assessment report.", realSingleTarget),
+ Steps: Steps(
+ Tool("bash").ArgContains("scan").NoError(),
+ ),
+ Timeout: 300 * time.Second,
+ MaxTurns: 10,
+ JudgeCriteria: "The agent must run the scan pipeline and produce a structured security report. " +
+ "The report must contain: target IP, discovered services, risk assessment or observations. " +
+ "A bare scan output dump without analysis is a failure.",
+ }.Run(t, h)
+}
+
+// =====================================================================
+// Layer 5: Agent + subagent fan-out — parallel scanning
+// =====================================================================
+
+func TestRealAgentParallelScan(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "real-agent-parallel-scan",
+ Prompt: fmt.Sprintf("I need to scan %s efficiently. Create 2 async subagents:\n"+
+ "1. Named 'port-scan': run gogo against the target with -p top100\n"+
+ "2. Named 'web-probe': run spray against http://%s with --finger\n"+
+ "Wait for both to complete, then produce a consolidated results report.", realSingleTarget, realSingleTarget),
+ Steps: Steps(
+ Tool("subagent").Arg("name", "port-scan"),
+ Tool("subagent").Arg("name", "web-probe"),
+ ),
+ Timeout: 300 * time.Second,
+ MaxTurns: 12,
+ JudgeCriteria: "The agent must create 2 async subagents for parallel scanning. " +
+ "Both subagents must complete. The final report must consolidate results from both " +
+ "port scanning (gogo) and web probing (spray).",
+ }.Run(t, h)
+}
+
+// =====================================================================
+// Layer 6: Agent + loop tool — recurring scan
+// =====================================================================
+
+func TestRealAgentLoopScan(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "real-agent-loop-scan",
+ Prompt: fmt.Sprintf("Set up a recurring scan for %s:\n"+
+ "1. First, run gogo -i %s -p top100 immediately and report results\n"+
+ "2. Create a loop named 'monitor' with interval '30s' and prompt 'check if any new ports opened on %s'\n"+
+ "3. List loops to confirm the monitor is active\n"+
+ "4. Delete the loop named 'monitor'\n"+
+ "Report the initial scan results.", realSingleTarget, realSingleTarget, realSingleTarget),
+ Steps: Steps(
+ Tool("bash").ArgContains("gogo").NoError(),
+ ),
+ Ordered: true,
+ Timeout: 180 * time.Second,
+ MaxTurns: 10,
+ NoErrors: true,
+ JudgeCriteria: "The agent must: (1) run an initial gogo scan and report results, " +
+ "(2) create a recurring loop for monitoring, (3) list loops to confirm, (4) delete the loop. " +
+ "All four steps must complete in order. The initial scan must produce actual results (ports/services).",
+ }.Run(t, h)
+}
+
+// =====================================================================
+// Layer 7: IOA loop mode — swarm worker receives scan task
+// =====================================================================
+
+func TestRealIOALoopScanTask(t *testing.T) {
+ service := ioaserver.NewService(ioaserver.NewMemoryStore(), "")
+ srv := httptest.NewServer(ioaserver.NewHandler(service))
+ defer srv.Close()
+
+ h := New(t)
+
+ go func() {
+ h.RunWithTimeout(180*time.Second,
+ "agent", "--ioa-url", "http://127.0.0.1:8765",
+ "--ioa-url", srv.URL,
+ "--space", "real-scan",
+ "--ioa-node-name", "scanner-worker",
+ "-p", "I am a scanner worker with gogo, spray, and neutron capabilities",
+ "--timeout", "150",
+ )
+ }()
+
+ time.Sleep(5 * time.Second)
+
+ controller, err := ioaclient.NewClient(srv.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil {
+ t.Fatal(err)
+ }
+ space, err := controller.Space(ctx, "real-scan", "real scan test")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ nodes, err := controller.ListNodes(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var workerID string
+ for _, n := range nodes {
+ if n.Name == "scanner-worker" {
+ workerID = n.ID
+ break
+ }
+ }
+ if workerID == "" {
+ t.Fatal("scanner-worker not found")
+ }
+
+ _, err = controller.Send(ctx, space.ID, sendMessage(
+ fmt.Sprintf("Run gogo against %s with -p top100 and report all discovered services with ports and fingerprints.", realSingleTarget),
+ workerID,
+ ))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ time.Sleep(120 * time.Second)
+
+ requireIOAMessageContains(t, controller, ctx, space.ID, realSingleTarget)
+}
+
+// =====================================================================
+// helpers
+// =====================================================================
+
+// verifyScanner runs a direct scanner command and uses the judge to evaluate.
+func (intent Intent) verifyScanner(t *testing.T, h *Harness, args ...string) *RunResult {
+ t.Helper()
+ r := h.RunWithTimeout(intent.Timeout, args...)
+ v := Verify(t, r).OK()
+ if intent.JudgeCriteria != "" {
+ prompt := fmt.Sprintf("Scanner command: %v", args)
+ v = v.JudgeWith(h.Judge(), prompt, intent.JudgeCriteria)
+ }
+ v.Done()
+ t.Logf("output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000))
+ return r
+}
diff --git a/core/harness/result.go b/core/harness/result.go
new file mode 100644
index 00000000..a9c5f640
--- /dev/null
+++ b/core/harness/result.go
@@ -0,0 +1,214 @@
+//go:build e2e
+
+package harness
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/chainreactors/aiscan/pkg/agent"
+)
+
+type RunResult struct {
+ Stdout string
+ Stderr string
+ ExitCode int
+ Duration time.Duration
+ Events []AgentEvent
+}
+
+type AgentEvent struct {
+ Type string `json:"type"`
+ Turn int `json:"turn,omitempty"`
+ ToolName string `json:"tool_name,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+ Args string `json:"arguments,omitempty"`
+ Result string `json:"result,omitempty"`
+ IsError bool `json:"is_error,omitempty"`
+ Error string `json:"error,omitempty"`
+ Stop string `json:"stop,omitempty"`
+ Message *agent.ChatMessage `json:"message,omitempty"`
+ ToolResults []agent.ChatMessage `json:"tool_results,omitempty"`
+ Usage *agent.Usage `json:"usage,omitempty"`
+ ContextTokens int `json:"context_tokens,omitempty"`
+ NewMessages int `json:"new_messages,omitempty"`
+ RequestModel string `json:"request_model,omitempty"`
+ RequestMessages int `json:"request_messages,omitempty"`
+ RequestTools int `json:"request_tools,omitempty"`
+}
+
+func (r *RunResult) OK() bool { return r.ExitCode == 0 }
+func (r *RunResult) Output() string { return strings.TrimSpace(r.Stdout) }
+func (r *RunResult) Combined() string { return r.Stdout + r.Stderr }
+
+func (r *RunResult) ContainsOutput(substr string) bool {
+ return strings.Contains(r.Stdout, substr) || strings.Contains(r.Stderr, substr)
+}
+
+// ToolCalls returns merged tool call events: arguments come from
+// tool_execution_start, results from tool_execution_end, joined by tool_call_id.
+func (r *RunResult) ToolCalls() []AgentEvent {
+ argsByID := make(map[string]string)
+ for _, e := range r.Events {
+ if e.Type == "tool_execution_start" && e.ToolCallID != "" {
+ argsByID[e.ToolCallID] = e.Args
+ }
+ }
+ var calls []AgentEvent
+ for _, e := range r.Events {
+ if e.Type == "tool_execution_end" {
+ if e.Args == "" && e.ToolCallID != "" {
+ e.Args = argsByID[e.ToolCallID]
+ }
+ calls = append(calls, e)
+ }
+ }
+ return calls
+}
+
+func (r *RunResult) HasToolCall(name string) bool {
+ for _, e := range r.ToolCalls() {
+ if e.ToolName == name {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *RunResult) ToolCallsNamed(name string) []AgentEvent {
+ var out []AgentEvent
+ for _, e := range r.ToolCalls() {
+ if e.ToolName == name {
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+func (r *RunResult) Turns() int {
+ max := 0
+ for _, e := range r.Events {
+ if e.Turn > max {
+ max = e.Turn
+ }
+ }
+ return max
+}
+
+func (r *RunResult) ToolCallSequence() []string {
+ var names []string
+ for _, e := range r.ToolCalls() {
+ names = append(names, e.ToolName)
+ }
+ return names
+}
+
+func (r *RunResult) ToolResultContains(toolName, substr string) bool {
+ for _, e := range r.ToolCallsNamed(toolName) {
+ if strings.Contains(e.Result, substr) {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *RunResult) ToolArgsContains(toolName, substr string) bool {
+ for _, e := range r.ToolCallsNamed(toolName) {
+ if strings.Contains(e.Args, substr) {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *RunResult) AllToolResults() string {
+ var sb strings.Builder
+ for _, e := range r.ToolCalls() {
+ sb.WriteString(e.Result)
+ sb.WriteByte('\n')
+ }
+ return sb.String()
+}
+
+func (r *RunResult) ErroredToolCalls() []AgentEvent {
+ var out []AgentEvent
+ for _, e := range r.ToolCalls() {
+ if e.IsError {
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+func (r *RunResult) StopReason() string {
+ for i := len(r.Events) - 1; i >= 0; i-- {
+ if r.Events[i].Type == "agent_end" {
+ return r.Events[i].Stop
+ }
+ }
+ return ""
+}
+
+func (r *RunResult) TotalTokens() int {
+ for i := len(r.Events) - 1; i >= 0; i-- {
+ if r.Events[i].Type == "turn_end" && r.Events[i].Usage != nil {
+ return r.Events[i].Usage.TotalTokens
+ }
+ }
+ return 0
+}
+
+// tool-specific accessors
+
+func (r *RunResult) SubagentCalls() []AgentEvent { return r.ToolCallsNamed("subagent") }
+
+func (r *RunResult) SubagentCreateCount() int {
+ n := 0
+ for _, e := range r.SubagentCalls() {
+ if !strings.Contains(e.Args, `"list"`) && !strings.Contains(e.Args, `"kill"`) && !strings.Contains(e.Args, `"message"`) {
+ n++
+ }
+ }
+ return n
+}
+
+func (r *RunResult) SubagentCreateArgs() []string {
+ var args []string
+ for _, e := range r.SubagentCalls() {
+ if !strings.Contains(e.Args, `"list"`) && !strings.Contains(e.Args, `"kill"`) && !strings.Contains(e.Args, `"message"`) {
+ args = append(args, e.Args)
+ }
+ }
+ return args
+}
+
+func (r *RunResult) SubagentResults() []string {
+ var results []string
+ for _, e := range r.SubagentCalls() {
+ if !strings.Contains(e.Args, `"list"`) && !strings.Contains(e.Args, `"kill"`) && !strings.Contains(e.Args, `"message"`) {
+ results = append(results, e.Result)
+ }
+ }
+ return results
+}
+
+func loadEvents(path string) []AgentEvent {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ var events []AgentEvent
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var e AgentEvent
+ if json.Unmarshal([]byte(line), &e) == nil {
+ events = append(events, e)
+ }
+ }
+ return events
+}
diff --git a/core/harness/subagent_test.go b/core/harness/subagent_test.go
new file mode 100644
index 00000000..2b9f9766
--- /dev/null
+++ b/core/harness/subagent_test.go
@@ -0,0 +1,163 @@
+//go:build e2e
+
+package harness
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestAgentSubagentSync(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "subagent-sync",
+ Prompt: "Use the subagent tool to create a sync subagent with prompt 'echo sub_sync_ok using bash and report the output'. Report the subagent result.",
+ Steps: Steps(
+ Tool("subagent").Action("create").NoError(),
+ ),
+ OutputContains: []string{"sub_sync_ok"},
+ MaxTurns: 4,
+ JudgeCriteria: "The agent must create a sync subagent. The subagent must execute 'echo sub_sync_ok' via bash. " +
+ "The final output must contain 'sub_sync_ok' proving the subagent completed and returned its result.",
+ }.Run(t, h)
+}
+
+func TestAgentSubagentAsync(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "subagent-async",
+ Prompt: "Create an async subagent with prompt 'Run echo async_marker_99 in bash'. Wait for its completion notification and report its result.",
+ Steps: Steps(
+ Tool("subagent").Action("create").NoError(),
+ ),
+ OutputContains: []string{"async_marker_99"},
+ MaxTurns: 8,
+ JudgeCriteria: "The agent must create an async subagent. It must then wait for the subagent completion notification " +
+ "(which arrives via inbox). The final output must contain 'async_marker_99'.",
+ }.Run(t, h)
+}
+
+func TestAgentSubagentSyncTimeout(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "subagent-sync-timeout",
+ Prompt: "Create a sync subagent with timeout '2s' and prompt 'Run sleep 30 in bash'. Report what happened (it should timeout).",
+ Steps: Steps(
+ Tool("subagent").ResultHas("timed out"),
+ ),
+ MaxTurns: 3,
+ JudgeCriteria: "The agent must create a sync subagent with a 2s timeout running 'sleep 30'. " +
+ "The subagent must timeout. The agent must report the timeout in its output.",
+ }.Run(t, h)
+}
+
+func TestAgentSubagentList(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "subagent-list",
+ Prompt: "Create an async subagent named 'worker1' with prompt 'sleep 5'. Then immediately use subagent list action to show running subagents. Report the list.",
+ Steps: Steps(
+ Tool("subagent").Arg("name", "worker1"),
+ Tool("subagent").Action("list"),
+ ),
+ MaxTurns: 6,
+ JudgeCriteria: "The agent must: (1) create an async subagent named 'worker1', " +
+ "(2) call subagent list to show running subagents, " +
+ "(3) the list result should show 'worker1' as running.",
+ }.Run(t, h)
+}
+
+func TestAgentMultiSubagentFanOut(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "subagent-fan-out",
+ Prompt: "You have 3 independent tasks. Use the subagent tool to create 3 SEPARATE async subagents, one for each:\n" +
+ "1. Subagent named 'host-info': run 'uname -a' in bash and report.\n" +
+ "2. Subagent named 'user-info': run 'whoami' in bash and report.\n" +
+ "3. Subagent named 'dir-info': run 'pwd' in bash and report.\n" +
+ "Create all 3 subagents, then wait for all completion notifications. " +
+ "Summarize all 3 results together.",
+ Steps: Steps(
+ Tool("subagent").Arg("name", "host-info"),
+ Tool("subagent").Arg("name", "user-info"),
+ Tool("subagent").Arg("name", "dir-info"),
+ ),
+ MaxTurns: 10,
+ JudgeCriteria: "The agent must create exactly 3 async subagents (host-info, user-info, dir-info). " +
+ "It must wait for all 3 completions. The final output must summarize results from all 3 subagents.",
+ }.Run(t, h)
+}
+
+func TestAgentSubagentWithBashAndReport(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "subagent-bash-report",
+ Prompt: "Create 2 async subagents:\n" +
+ "1. Named 'counter': run 'seq 1 5' in bash.\n" +
+ "2. Named 'greeter': run 'echo hello_from_subagent' in bash.\n" +
+ "Wait for both to complete. Then report both outputs in your final answer.",
+ Steps: Steps(
+ Tool("subagent").Arg("name", "counter"),
+ Tool("subagent").Arg("name", "greeter"),
+ ),
+ OutputContains: []string{"hello_from_subagent"},
+ MaxTurns: 10,
+ JudgeCriteria: "The agent must create 2 subagents and wait for both. " +
+ "The final output must include the output from both: the sequence 1-5 and 'hello_from_subagent'.",
+ }.Run(t, h)
+}
+
+func TestAgentSubagentChain(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "subagent-chain",
+ Prompt: "Step 1: Create a sync subagent that runs 'echo chain_step_1' in bash and returns the output.\n" +
+ "Step 2: After you receive the result from step 1, create another sync subagent " +
+ "that runs 'echo chain_step_2' in bash.\n" +
+ "Report both results to confirm the chain completed.",
+ MaxTurns: 8,
+ JudgeCriteria: "The agent must create 2 sync subagents sequentially (not in parallel). " +
+ "Step 2 must happen AFTER step 1 completes. " +
+ "The final output must contain both 'chain_step_1' and 'chain_step_2'.",
+ Check: func(t *testing.T, r *RunResult) {
+ results := r.SubagentResults()
+ if len(results) < 2 {
+ t.Fatalf("expected ≥2 subagent results, got %d", len(results))
+ }
+ s1, s2 := -1, -1
+ for i, res := range results {
+ if strings.Contains(res, "chain_step_1") && s1 == -1 {
+ s1 = i
+ }
+ if strings.Contains(res, "chain_step_2") && s2 == -1 {
+ s2 = i
+ }
+ }
+ if s1 >= 0 && s2 >= 0 && s1 >= s2 {
+ t.Fatalf("chain order wrong: step1 at %d, step2 at %d", s1, s2)
+ }
+ },
+ }.Run(t, h)
+}
+
+func TestAgentSubagentMessage(t *testing.T) {
+ h := New(t)
+ Intent{
+ Name: "subagent-message",
+ Prompt: "Create an async subagent named 'listener' with prompt: " +
+ "'Wait for a message. When you receive one, run echo GOT_MESSAGE in bash and report.'\n" +
+ "After creating it, use the subagent message action to send a message " +
+ "'hello from parent' to the 'listener' subagent.\n" +
+ "Wait for the listener to complete and report its result.",
+ Steps: Steps(
+ Tool("subagent").Arg("name", "listener"),
+ Tool("subagent").Action("message").Arg("name", "listener"),
+ ),
+ Ordered: true,
+ MaxTurns: 10,
+ JudgeCriteria: "The agent must: (1) create an async subagent named 'listener', " +
+ "(2) send a message to it via the subagent message action, " +
+ "(3) the listener must execute 'echo GOT_MESSAGE' after receiving the message, " +
+ "(4) the final output must contain 'GOT_MESSAGE' confirming the message was received and processed.",
+ }.Run(t, h)
+}
diff --git a/core/harness/task_test.go b/core/harness/task_test.go
new file mode 100644
index 00000000..fedcfbf1
--- /dev/null
+++ b/core/harness/task_test.go
@@ -0,0 +1,34 @@
+//go:build e2e
+
+package harness
+
+import "testing"
+
+func TestAgentBackgroundTask(t *testing.T) {
+ h := New(t)
+ r := h.Agent("Start a background shell session: tmux new -d -s bg 'sleep 1 && echo bg_done'. Then use tmux ls to list running sessions. Use tmux wait -t bg to wait for it to finish. Use tmux capture-pane -t bg to get the output. Report the final output.")
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ AnyResultContains("bg_done").
+ NoToolErrors().
+ Done()
+}
+
+func TestAgentTmuxPeek(t *testing.T) {
+ h := New(t)
+ r := h.Agent("Run 'for i in 1 2 3; do echo line_$i; sleep 0.5; done' as a detached tmux session named 'lines'. Use tmux capture-pane -t lines --new to check its output, then wait for completion and report all lines.")
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ Done()
+}
+
+func TestAgentTmuxKill(t *testing.T) {
+ h := New(t)
+ r := h.Agent("Start a detached tmux session: tmux new -d -s sleeper 'sleep 300'. Use tmux ls to confirm it's running. Kill it with tmux kill -t sleeper. List again to confirm it's killed. Report status.")
+ Verify(t, r).
+ OK().
+ ToolUsed("bash").
+ Done()
+}
diff --git a/core/harness/verify.go b/core/harness/verify.go
new file mode 100644
index 00000000..f3bbda74
--- /dev/null
+++ b/core/harness/verify.go
@@ -0,0 +1,325 @@
+//go:build e2e
+
+package harness
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Verifier provides chainable assertions on a RunResult.
+// Accumulates all failures; Done() reports them together.
+//
+// Two verification layers:
+//
+// Layer 1 — Structural (tool-level):
+//
+// Verify(t, r).
+// OK().
+// Expect(Tool("bash").ArgContains("gogo").NoError()).
+// Expect(Tool("subagent").Action("create").Arg("name", "worker")).
+// Done()
+//
+// Layer 2 — Intent (outcome-level):
+//
+// Verify(t, r).
+// OK().
+// ExpectInOrder(
+// Tool("subagent").Action("create").Arg("name", "worker"),
+// Tool("bash").ArgContains("scan"),
+// ).
+// OutputContains("worker").
+// NoToolErrors().
+// MaxTurns(5).
+// Done()
+type Verifier struct {
+ t *testing.T
+ r *RunResult
+ failures []string
+}
+
+func Verify(t *testing.T, r *RunResult) *Verifier {
+ t.Helper()
+ return &Verifier{t: t, r: r}
+}
+
+func (v *Verifier) fail(msg string) { v.failures = append(v.failures, msg) }
+
+func (v *Verifier) Done() {
+ v.t.Helper()
+ if len(v.failures) == 0 {
+ return
+ }
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("verification failed (%d issue(s)):\n", len(v.failures)))
+ for i, f := range v.failures {
+ sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, f))
+ }
+ sb.WriteString(fmt.Sprintf("\nresult: exit=%d turns=%d tools=%d duration=%s\n",
+ v.r.ExitCode, v.r.Turns(), len(v.r.ToolCalls()), v.r.Duration))
+ sb.WriteString(fmt.Sprintf("tool sequence: %v\n", v.r.ToolCallSequence()))
+ v.t.Fatal(sb.String())
+}
+
+// =====================================================================
+// Exit / Output
+// =====================================================================
+
+func (v *Verifier) OK() *Verifier {
+ if !v.r.OK() {
+ v.fail(fmt.Sprintf("exit code %d, expected 0\nstderr: %s", v.r.ExitCode, clip(v.r.Stderr, 500)))
+ }
+ return v
+}
+
+func (v *Verifier) OutputContains(substr string) *Verifier {
+ if !v.r.ContainsOutput(substr) {
+ v.fail(fmt.Sprintf("output missing %q", substr))
+ }
+ return v
+}
+
+func (v *Verifier) OutputMissing(substr string) *Verifier {
+ if v.r.ContainsOutput(substr) {
+ v.fail(fmt.Sprintf("output should not contain %q", substr))
+ }
+ return v
+}
+
+// =====================================================================
+// Constraints
+// =====================================================================
+
+func (v *Verifier) MinTurns(n int) *Verifier {
+ if v.r.Turns() < n {
+ v.fail(fmt.Sprintf("expected >= %d turns, got %d", n, v.r.Turns()))
+ }
+ return v
+}
+
+func (v *Verifier) MaxTurns(n int) *Verifier {
+ if v.r.Turns() > n {
+ v.fail(fmt.Sprintf("expected <= %d turns, got %d", n, v.r.Turns()))
+ }
+ return v
+}
+
+func (v *Verifier) MinToolCalls(n int) *Verifier {
+ if len(v.r.ToolCalls()) < n {
+ v.fail(fmt.Sprintf("expected >= %d tool calls, got %d", n, len(v.r.ToolCalls())))
+ }
+ return v
+}
+
+func (v *Verifier) MaxToolCalls(n int) *Verifier {
+ if len(v.r.ToolCalls()) > n {
+ v.fail(fmt.Sprintf("expected <= %d tool calls, got %d", n, len(v.r.ToolCalls())))
+ }
+ return v
+}
+
+func (v *Verifier) CompletedWithin(d time.Duration) *Verifier {
+ if v.r.Duration > d {
+ v.fail(fmt.Sprintf("expected completion within %s, took %s", d, v.r.Duration))
+ }
+ return v
+}
+
+func (v *Verifier) ToolCount(name string, min, max int) *Verifier {
+ n := len(v.r.ToolCallsNamed(name))
+ if n < min || n > max {
+ v.fail(fmt.Sprintf("tool %q called %d times, expected [%d, %d]", name, n, min, max))
+ }
+ return v
+}
+
+// =====================================================================
+// Expect — pattern-based tool call verification
+// =====================================================================
+
+// Expect verifies that each pattern matches at least one tool call (any order).
+func (v *Verifier) Expect(patterns ...ToolPattern) *Verifier {
+ result := matchUnordered(patterns, v.r.ToolCalls())
+ for _, p := range result.unmatched {
+ v.fail(fmt.Sprintf("expected tool call not found: %s", p.describe()))
+ }
+ return v
+}
+
+// ExpectInOrder verifies that patterns match tool calls in sequence
+// (subsequence — other calls may appear between them).
+func (v *Verifier) ExpectInOrder(patterns ...ToolPattern) *Verifier {
+ result := matchOrdered(patterns, v.r.ToolCalls())
+ if len(result.unmatched) > 0 {
+ var descs []string
+ for _, p := range result.unmatched {
+ descs = append(descs, p.describe())
+ }
+ v.fail(fmt.Sprintf("tool call sequence incomplete, unmatched: [%s]\nactual: %v",
+ strings.Join(descs, ", "), v.r.ToolCallSequence()))
+ }
+ return v
+}
+
+// ExpectNone verifies that NO tool call matches the pattern.
+func (v *Verifier) ExpectNone(patterns ...ToolPattern) *Verifier {
+ for _, p := range patterns {
+ for _, e := range v.r.ToolCalls() {
+ if p.Match(e) {
+ v.fail(fmt.Sprintf("unexpected tool call matched: %s", p.describe()))
+ break
+ }
+ }
+ }
+ return v
+}
+
+// =====================================================================
+// Legacy tool checks (still useful for simple cases)
+// =====================================================================
+
+func (v *Verifier) ToolUsed(name string) *Verifier {
+ if !v.r.HasToolCall(name) {
+ v.fail(fmt.Sprintf("tool %q was never called", name))
+ }
+ return v
+}
+
+func (v *Verifier) ToolNotUsed(name string) *Verifier {
+ if v.r.HasToolCall(name) {
+ v.fail(fmt.Sprintf("tool %q should not have been called", name))
+ }
+ return v
+}
+
+func (v *Verifier) ToolSequence(names ...string) *Verifier {
+ seq := v.r.ToolCallSequence()
+ idx := 0
+ for _, s := range seq {
+ if idx < len(names) && s == names[idx] {
+ idx++
+ }
+ }
+ if idx < len(names) {
+ v.fail(fmt.Sprintf("tool sequence %v not found in %v (matched %d/%d)",
+ names, seq, idx, len(names)))
+ }
+ return v
+}
+
+func (v *Verifier) ToolArgMatch(name string, predicate func(string) bool) *Verifier {
+ found := false
+ for _, e := range v.r.ToolCallsNamed(name) {
+ if predicate(e.Args) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ v.fail(fmt.Sprintf("no %q tool call matched arg predicate", name))
+ }
+ return v
+}
+
+func (v *Verifier) ToolResultMatch(name string, predicate func(string) bool) *Verifier {
+ found := false
+ for _, e := range v.r.ToolCallsNamed(name) {
+ if predicate(e.Result) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ v.fail(fmt.Sprintf("no %q tool result matched predicate", name))
+ }
+ return v
+}
+
+func (v *Verifier) ToolArgsContain(name, substr string) *Verifier {
+ return v.ToolArgMatch(name, func(args string) bool {
+ return strings.Contains(args, substr)
+ })
+}
+
+func (v *Verifier) ToolResultContains(name, substr string) *Verifier {
+ return v.ToolResultMatch(name, func(res string) bool {
+ return strings.Contains(res, substr)
+ })
+}
+
+func (v *Verifier) AnyResultContains(substr string) *Verifier {
+ all := v.r.AllToolResults()
+ if !strings.Contains(all, substr) && !v.r.ContainsOutput(substr) {
+ v.fail(fmt.Sprintf("no tool result or output contains %q", substr))
+ }
+ return v
+}
+
+// =====================================================================
+// Errors
+// =====================================================================
+
+func (v *Verifier) NoToolErrors() *Verifier {
+ errs := v.r.ErroredToolCalls()
+ if len(errs) > 0 {
+ names := make([]string, len(errs))
+ for i, e := range errs {
+ names[i] = fmt.Sprintf("%s(%s)", e.ToolName, clip(e.Result, 80))
+ }
+ v.fail(fmt.Sprintf("%d tool call(s) errored: %s", len(errs), strings.Join(names, ", ")))
+ }
+ return v
+}
+
+// =====================================================================
+// Subagent shortcuts (built on Expect)
+// =====================================================================
+
+func (v *Verifier) SubagentCreated(name string) *Verifier {
+ return v.Expect(Tool("subagent").Arg("name", name))
+}
+
+func (v *Verifier) MinSubagentCreates(n int) *Verifier {
+ if v.r.SubagentCreateCount() < n {
+ v.fail(fmt.Sprintf("expected >= %d subagent creates, got %d", n, v.r.SubagentCreateCount()))
+ }
+ return v
+}
+
+func (v *Verifier) SubagentResultContains(substr string) *Verifier {
+ for _, res := range v.r.SubagentResults() {
+ if strings.Contains(res, substr) {
+ return v
+ }
+ }
+ v.fail(fmt.Sprintf("no subagent result contains %q", substr))
+ return v
+}
+
+// =====================================================================
+// LLM Judge
+// =====================================================================
+
+// JudgeWith uses an LLM to evaluate whether the execution fulfilled the
+// intent. The judge receives the full tool trace and final output, and
+// returns a structured verdict.
+//
+// Verify(t, r).
+// OK().
+// JudgeWith(h.Judge(), "create a loop, list it, delete it", "").
+// Done()
+func (v *Verifier) JudgeWith(j *Judge, intent, criteria string) *Verifier {
+ verdict, err := j.Evaluate(intent, criteria, v.r)
+ if err != nil {
+ v.t.Logf("judge unavailable (degraded to warning): %s", err)
+ return v
+ }
+ v.t.Logf("judge: pass=%v score=%d reason=%q", verdict.Pass, verdict.Score, verdict.Reason)
+ if !verdict.Pass {
+ issues := strings.Join(verdict.Issues, "; ")
+ v.fail(fmt.Sprintf("judge failed (score=%d): %s [%s]", verdict.Score, verdict.Reason, issues))
+ }
+ return v
+}
diff --git a/core/harness/verify_mechanism_test.go b/core/harness/verify_mechanism_test.go
new file mode 100644
index 00000000..73e109f7
--- /dev/null
+++ b/core/harness/verify_mechanism_test.go
@@ -0,0 +1,204 @@
+//go:build e2e
+
+package harness
+
+import (
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+const verifyTarget = realSingleTarget
+
+// TestVerifyOffProducesNoAIOutput runs scan with --verify=off and confirms
+// that no AI skill output appears in the results.
+func TestVerifyOffProducesNoAIOutput(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(300*time.Second,
+ "scan", "-i", "127.0.0.1", "--mode", "quick", "--verify=off", "--timeout", "3",
+ )
+ Verify(t, r).OK().Done()
+
+ if hasAISkillOutput(r.Stdout) {
+ t.Fatalf("--verify=off should produce no AI skill output, got:\n%s", clip(r.Stdout, 2000))
+ }
+ t.Logf("verify=off output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000))
+}
+
+// TestVerifyHighWithSniperTriggersAIVerification runs scan with explicit
+// verify and sniper options and confirms that the scan pipeline completes with
+// AI skills enabled. When targets have high-priority loots, AI verify and
+// sniper skills produce output.
+func TestVerifyHighWithSniperTriggersAIVerification(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(600*time.Second,
+ "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--sniper", "--timeout", "5",
+ )
+ Verify(t, r).OK().Done()
+
+ if !hasSummaryLine(r.Stdout) {
+ t.Fatal("expected [summary] line in output")
+ }
+ t.Logf("verify+sniper output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 3000))
+}
+
+// TestVerifyExplicitModeWithoutSniper runs scan with --verify=high explicitly
+// (no --sniper) and checks that verify runs but sniper is NOT activated.
+func TestVerifyExplicitModeWithoutSniper(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(600*time.Second,
+ "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--timeout", "5",
+ )
+ Verify(t, r).OK().Done()
+
+ if hasSniperOutput(r.Stdout) {
+ t.Fatal("--verify=high without --sniper should not produce sniper output")
+ }
+ if !hasSummaryLine(r.Stdout) {
+ t.Fatal("expected [summary] line in output")
+ }
+ t.Logf("verify=high output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000))
+}
+
+// TestScanVerifySniperNoPostAnalysis verifies that the old post-analysis
+// one-shot LLM call no longer runs. Explicit scan AI skills trigger only
+// in-pipeline AI work (verify + sniper), not a separate "analysis" step.
+// The output should contain the [summary] line from the scan pipeline but
+// should not contain the "analysis" output section that runScannerPostAnalysis
+// used to produce.
+func TestScanVerifySniperNoPostAnalysis(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(600*time.Second,
+ "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--sniper", "--timeout", "5",
+ )
+ Verify(t, r).OK().Done()
+
+ if !hasSummaryLine(r.Stdout) {
+ t.Fatal("expected [summary] line from scan pipeline")
+ }
+ t.Logf("output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 3000))
+}
+
+// TestScanDefaultModeCompletes runs scan without any explicit AI skill flags.
+// The default verify mode is "auto" (mapped to "high"), which enables the
+// provider optionally. If the provider initializes, AI verify can run; if not,
+// the scan still completes successfully.
+func TestScanDefaultModeCompletes(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(600*time.Second,
+ "scan", "-i", verifyTarget, "--mode", "quick", "--timeout", "5",
+ )
+ Verify(t, r).OK().Done()
+
+ if !hasSummaryLine(r.Stdout) {
+ t.Fatal("expected [summary] line in output")
+ }
+ t.Logf("default mode output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000))
+}
+
+// TestVerifyOffDisablesAllAISkills confirms that --verify=off combined with
+// no --sniper and no --deep results in zero AI skill results in the summary.
+func TestVerifyOffDisablesAllAISkills(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(300*time.Second,
+ "scan", "-i", "127.0.0.1", "--mode", "quick", "--verify=off", "--timeout", "3",
+ )
+ Verify(t, r).OK().Done()
+
+ summary := extractSummaryLine(r.Stdout)
+ if summary == "" {
+ t.Fatal("missing [summary] line")
+ }
+ if strings.Contains(summary, "verified") {
+ parts := strings.Fields(summary)
+ for i, p := range parts {
+ if p == "verified" && i > 0 && parts[i-1] != "0" {
+ t.Fatalf("expected 0 verified in summary with --verify=off, got: %s", summary)
+ }
+ }
+ }
+ t.Logf("verify=off summary: %s", summary)
+}
+
+// TestScanVerifyWithReportIncludesVerification runs scan with explicit
+// verification and report output and verifies the report includes AI
+// verification metrics.
+func TestScanVerifyWithReportIncludesVerification(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(600*time.Second,
+ "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--sniper", "--report", "--timeout", "5",
+ )
+ Verify(t, r).OK().Done()
+
+ hasMetrics := strings.Contains(r.Stdout, "AI verifications") ||
+ strings.Contains(r.Stdout, "AI skill") ||
+ strings.Contains(r.Stdout, "verified")
+ if !hasMetrics {
+ t.Fatal("--verify=high --sniper --report should include AI verification information in output")
+ }
+ t.Logf("report output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 3000))
+}
+
+// TestAssetReportFileOutputFormats runs scan with -f and -F and verifies both
+// output formats include structured checkpoint loots.
+func TestAssetReportFileOutputFormats(t *testing.T) {
+ h := New(t)
+ r := h.RunWithTimeout(600*time.Second,
+ "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--sniper", "--timeout", "5",
+ "-f", "output.txt", "-F", "asset_report.txt",
+ )
+ Verify(t, r).OK().Done()
+
+ plainBytes, err := os.ReadFile(h.WorkFile("output.txt"))
+ if err != nil {
+ t.Fatalf("read -f output: %v", err)
+ }
+ plain := string(plainBytes)
+ t.Logf("-f output (%d bytes):\n%s", len(plain), clip(plain, 3000))
+
+ assetReportBytes, err := os.ReadFile(h.WorkFile("asset_report.txt"))
+ if err != nil {
+ if !hasAISkillOutput(r.Stdout) {
+ t.Skip("no AI output produced, skipping -F check")
+ }
+ t.Fatalf("read -F output: %v", err)
+ }
+ assetReport := string(assetReportBytes)
+ t.Logf("-F output (%d bytes):\n%s", len(assetReport), clip(assetReport, 3000))
+
+ if len(assetReport) > 0 {
+ if !strings.Contains(assetReport, "Assets:") {
+ t.Fatal("-F output should contain 'Assets:' header")
+ }
+ }
+}
+
+// --- helpers ---
+
+func hasAISkillOutput(output string) bool {
+ markers := []string{"[ai:", "[sniper:", "[ai]", "[sniper]"}
+ for _, m := range markers {
+ if strings.Contains(output, m) {
+ return true
+ }
+ }
+ return false
+}
+
+func hasSniperOutput(output string) bool {
+ return strings.Contains(output, "[sniper:") || strings.Contains(output, "[sniper]")
+}
+
+func hasSummaryLine(output string) bool {
+ return strings.Contains(output, "[summary]") || strings.Contains(output, "completed")
+}
+
+func extractSummaryLine(output string) string {
+ for _, line := range strings.Split(output, "\n") {
+ if strings.Contains(line, "[summary]") {
+ return line
+ }
+ }
+ return ""
+}
diff --git a/core/output/color.go b/core/output/color.go
new file mode 100644
index 00000000..7db72887
--- /dev/null
+++ b/core/output/color.go
@@ -0,0 +1,151 @@
+package output
+
+import (
+ "github.com/chainreactors/logs"
+ "github.com/chainreactors/parsers"
+)
+
+const (
+ ANSIReset = "\033[0m"
+ ANSIBold = "\033[1m"
+ ANSIDim = "\033[2m"
+ ANSIRed = "\033[31m"
+ ANSIGreen = "\033[32m"
+ ANSIYellow = "\033[33m"
+ ANSIBlue = "\033[34m"
+ ANSIMagenta = "\033[35m"
+ ANSICyan = "\033[36m"
+)
+
+type Color struct {
+ Enabled bool
+}
+
+func NewColor(enabled bool) Color {
+ return Color{Enabled: enabled}
+}
+
+func (c Color) Code(code string) string {
+ if !c.Enabled {
+ return ""
+ }
+ return code
+}
+
+func (c Color) Green(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return logs.Green(s)
+}
+
+func (c Color) GreenBold(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return logs.GreenBold(s)
+}
+
+func (c Color) Red(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return logs.Red(s)
+}
+
+func (c Color) RedBold(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return logs.RedBold(s)
+}
+
+func (c Color) Yellow(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return logs.Yellow(s)
+}
+
+func (c Color) YellowBold(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return logs.YellowBold(s)
+}
+
+func (c Color) Cyan(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return logs.Cyan(s)
+}
+
+func (c Color) Blue(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return logs.Blue(s)
+}
+
+func (c Color) Magenta(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return logs.Purple(s)
+}
+
+func (c Color) Bold(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return ANSIBold + s + ANSIReset
+}
+
+func (c Color) Dim(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return "\033[90m" + s + ANSIReset
+}
+
+func (c Color) Status(s string) string {
+ if !c.Enabled {
+ return s
+ }
+ return parsers.RenderStatus(s)
+}
+
+func (c Color) ForPriority(p string) func(string) string {
+ if !c.Enabled {
+ return func(s string) string { return s }
+ }
+ switch p {
+ case "low":
+ return logs.Cyan
+ case "medium":
+ return logs.Yellow
+ case "high":
+ return logs.Red
+ case "critical":
+ return logs.RedBold
+ default:
+ return c.Dim
+ }
+}
+
+func (c Color) ForStatus(status string) func(string) string {
+ if !c.Enabled {
+ return func(s string) string { return s }
+ }
+ switch status {
+ case "confirmed":
+ return logs.Green
+ case "not_confirmed", "failed":
+ return logs.Red
+ case "info":
+ return logs.Yellow
+ default:
+ return logs.Yellow
+ }
+}
diff --git a/core/output/format.go b/core/output/format.go
new file mode 100644
index 00000000..8876b0d0
--- /dev/null
+++ b/core/output/format.go
@@ -0,0 +1,156 @@
+package output
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/chainreactors/aiscan/pkg/agent/truncate"
+)
+
+var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
+
+func StripANSI(s string) string {
+ return ansiPattern.ReplaceAllString(s, "")
+}
+
+func OutputPrefix(source string, colorFn func(string) string) string {
+ return colorFn("[" + source + "]")
+}
+
+func FormatLine(prefix, body string, color Color) string {
+ body = strings.TrimSpace(body)
+ parts := []string{prefix}
+ if body != "" {
+ parts = append(parts, body)
+ }
+ return SanitizeLine(strings.Join(parts, " "), color)
+}
+
+func SanitizeLine(line string, color Color) string {
+ line = strings.TrimSpace(line)
+ if !color.Enabled {
+ line = StripANSI(line)
+ }
+ return line
+}
+
+func TruncateStr(s string, maxLen int) string {
+ return truncate.Clip(s, maxLen)
+}
+
+func FirstNonEmpty(values ...string) string {
+ for _, v := range values {
+ if strings.TrimSpace(v) != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func AssetItemDetail(item AssetItem) string {
+ for _, value := range []string{item.Detail, item.Raw} {
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
+ if value == item.Raw {
+ if parsed := ExtractQuotedMarkdown(value); parsed != "" {
+ return parsed
+ }
+ }
+ return trimmed
+ }
+ }
+ return ""
+}
+
+func ExtractQuotedMarkdown(raw string) string {
+ fields := quotedFields(raw)
+ for i := len(fields) - 1; i >= 0; i-- {
+ value := strings.TrimSpace(fields[i])
+ if value == "" {
+ continue
+ }
+ if looksLikeMarkdown(value) {
+ return value
+ }
+ }
+ return ""
+}
+
+func ExtractQuotedSummary(raw string) string {
+ fields := quotedFields(raw)
+ if len(fields) == 0 {
+ return ""
+ }
+ for _, value := range fields {
+ value = strings.TrimSpace(value)
+ if value == "" || looksLikeMarkdown(value) {
+ continue
+ }
+ return value
+ }
+ return ""
+}
+
+func quotedFields(input string) []string {
+ var values []string
+ for i := 0; i < len(input); i++ {
+ if input[i] != '"' {
+ continue
+ }
+ i++
+ var sb strings.Builder
+ for i < len(input) {
+ ch := input[i]
+ if ch == '"' {
+ break
+ }
+ if ch == '\\' && i+1 < len(input) {
+ sb.WriteString(decodeEscapedByte(input[i+1]))
+ i += 2
+ continue
+ }
+ sb.WriteByte(ch)
+ i++
+ }
+ values = append(values, sb.String())
+ }
+ return values
+}
+
+func decodeEscapedByte(ch byte) string {
+ switch ch {
+ case 'n':
+ return "\n"
+ case 'r':
+ return "\r"
+ case 't':
+ return "\t"
+ case '"':
+ return `"`
+ case '\\':
+ return `\`
+ default:
+ return string(ch)
+ }
+}
+
+func looksLikeMarkdown(value string) bool {
+ if strings.Contains(value, "\n") {
+ return true
+ }
+ for _, prefix := range []string{"#", "-", "*", "|", ">", "```"} {
+ if strings.HasPrefix(strings.TrimSpace(value), prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+func firstContentLine(value string) string {
+ for _, line := range strings.Split(value, "\n") {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ return line
+ }
+ }
+ return ""
+}
diff --git a/core/output/format_asset.go b/core/output/format_asset.go
new file mode 100644
index 00000000..25f1c3a9
--- /dev/null
+++ b/core/output/format_asset.go
@@ -0,0 +1,454 @@
+package output
+
+import (
+ "fmt"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+func FormatAssetReport(result *Result, color bool) string {
+ if result == nil {
+ return "Assets: 0 total\n"
+ }
+ c := NewColor(color)
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "Assets: %d total\n", len(result.Assets))
+ fmt.Fprintf(&sb, "Summary: %d target(s), %d service(s), %d web endpoint(s), %d probe(s), %d loot(s), %d error(s), %s\n\n",
+ result.Summary.Targets,
+ result.Summary.Services,
+ result.Summary.Webs,
+ result.Summary.Probes,
+ result.Summary.Loots,
+ result.Summary.Errors,
+ result.Summary.Duration,
+ )
+
+ if len(result.Assets) == 0 {
+ return sb.String()
+ }
+ for i, asset := range result.Assets {
+ title := FirstNonEmpty(asset.Title, asset.Target, asset.Key)
+ fmt.Fprintf(&sb, "%d. %s\n", i+1, c.GreenBold(title))
+ if asset.Target != "" && asset.Target != title {
+ fmt.Fprintf(&sb, " target: %s\n", asset.Target)
+ }
+ if asset.Status != "" {
+ fmt.Fprintf(&sb, " status: %s\n", asset.Status)
+ }
+ writeAssetTopItems(&sb, asset.Items, c)
+ writeAssetSitemap(&sb, asset, c)
+ if i < len(result.Assets)-1 {
+ sb.WriteByte('\n')
+ }
+ }
+ return sb.String()
+}
+
+func writeAssetTopItems(sb *strings.Builder, items []AssetItem, c Color) {
+ for _, item := range items {
+ switch item.Kind {
+ case AssetItemPath:
+ continue
+ case AssetItemService:
+ line := strings.Join(CompactStrings(
+ AssetDataString(item.Data, "protocol"),
+ AssetDataString(item.Data, "service"),
+ AssetDataString(item.Data, "port"),
+ ), " ")
+ if line == "" {
+ line = FirstNonEmpty(item.Title, item.Target, item.Raw)
+ }
+ fmt.Fprintf(sb, " %s %s\n", c.Cyan("service:"), line)
+ case AssetItemFingerprint:
+ name := FirstNonEmpty(item.Title, item.Summary, item.Target)
+ fmt.Fprintf(sb, " %s %s\n", c.Cyan("fingerprint:"), name)
+ case AssetItemLoot, AssetItemNote, AssetItemResponse:
+ detail := AssetItemDetail(item)
+ line := FirstNonEmpty(item.Summary, item.Title, firstContentLine(detail), item.Raw)
+ if item.Status != "" {
+ line = c.Yellow("["+item.Status+"]") + " " + line
+ }
+ label := FirstNonEmpty(item.Source, item.Kind)
+ fmt.Fprintf(sb, " %s %s\n", c.Yellow(label+":"), line)
+ if detail != "" && detail != line && !strings.Contains(line, detail) {
+ for _, dl := range strings.Split(strings.TrimSpace(detail), "\n") {
+ if dl = strings.TrimSpace(dl); dl != "" {
+ fmt.Fprintf(sb, " %s\n", c.Dim(dl))
+ }
+ }
+ }
+ case AssetItemError:
+ fmt.Fprintf(sb, " %s %s\n", c.Red("error:"), item.Summary)
+ }
+ }
+}
+
+// --- sitemap rendering ---
+
+type sitemapEntry struct {
+ path string
+ status string
+ length int
+ title string
+ fingers []string
+ validated bool
+}
+
+type sitemapNode struct {
+ segment string
+ status string
+ length int
+ title string
+ fingers []string
+ validated bool
+ isLeaf bool
+ annotations []string
+ children []*sitemapNode
+}
+
+func writeAssetSitemap(sb *strings.Builder, asset Asset, c Color) {
+ var entries []sitemapEntry
+ for _, item := range asset.Items {
+ if item.Kind != AssetItemPath {
+ continue
+ }
+ p := FirstNonEmpty(AssetDataString(item.Data, "path"), WebPath(item.Target), item.Target)
+ if p == "" {
+ continue
+ }
+ entries = append(entries, sitemapEntry{
+ path: p,
+ status: item.Status,
+ length: AssetDataInt(item.Data, "length"),
+ title: item.Title,
+ fingers: AssetDataStrings(item.Data, "fingers"),
+ validated: HasTag(item.Tags, "validated"),
+ })
+ }
+ if len(entries) == 0 {
+ return
+ }
+
+ sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path })
+
+ sb.WriteString(" sitemap:\n")
+ root := buildSitemapTree(entries)
+ attachAnnotations(root, collectAnnotations(asset))
+ renderNode(sb, root, " ", true, c)
+}
+
+func buildSitemapTree(entries []sitemapEntry) *sitemapNode {
+ root := &sitemapNode{segment: "/"}
+ for _, e := range entries {
+ parts := splitPath(e.path)
+ if len(parts) == 0 {
+ root.isLeaf = true
+ root.status = e.status
+ root.length = e.length
+ root.title = e.title
+ root.fingers = mergeStrings(root.fingers, e.fingers)
+ root.validated = root.validated || e.validated
+ continue
+ }
+ node := root
+ for i, part := range parts {
+ child := findChild(node, part)
+ if child == nil {
+ child = &sitemapNode{segment: part}
+ node.children = append(node.children, child)
+ }
+ if i == len(parts)-1 {
+ child.isLeaf = true
+ child.status = e.status
+ child.length = e.length
+ child.title = e.title
+ child.fingers = mergeStrings(child.fingers, e.fingers)
+ child.validated = child.validated || e.validated
+ }
+ node = child
+ }
+ }
+ return root
+}
+
+func collectAnnotations(asset Asset) map[string][]string {
+ out := make(map[string][]string)
+ for _, item := range asset.Items {
+ switch item.Kind {
+ case AssetItemFingerprint:
+ p := pathFromTarget(item.Target, asset.Target)
+ if p != "" {
+ out[p] = appendUniq(out[p], item.Title)
+ }
+ case AssetItemLoot, AssetItemNote, AssetItemResponse:
+ p := pathFromTarget(item.Target, asset.Target)
+ if p == "" {
+ p = "/"
+ }
+ skill := FirstNonEmpty(item.Source, item.Kind)
+ label := skill
+ if item.Status != "" {
+ label += ":" + item.Status
+ }
+ summary := FirstNonEmpty(item.Title, item.Summary)
+ if summary != "" && len(summary) <= 40 {
+ label += " " + summary
+ }
+ out[p] = appendUniq(out[p], label)
+ }
+ }
+ return out
+}
+
+func attachAnnotations(root *sitemapNode, anns map[string][]string) {
+ if a, ok := anns["/"]; ok {
+ root.annotations = append(root.annotations, a...)
+ }
+ for path, a := range anns {
+ if path == "/" {
+ continue
+ }
+ parts := splitPath(path)
+ node := root
+ for _, part := range parts {
+ child := findChild(node, part)
+ if child == nil {
+ child = &sitemapNode{segment: part, isLeaf: true}
+ node.children = append(node.children, child)
+ }
+ node = child
+ }
+ node.annotations = append(node.annotations, a...)
+ }
+}
+
+func renderNode(sb *strings.Builder, node *sitemapNode, indent string, isRoot bool, c Color) {
+ var line strings.Builder
+
+ if isRoot {
+ line.WriteString(indent)
+ } else {
+ line.WriteString(indent)
+ line.WriteString("├── ")
+ }
+
+ if node.isLeaf && node.status != "" {
+ line.WriteString(c.Status(fmt.Sprintf("[%-3s]", node.status)))
+ } else {
+ line.WriteString(" ")
+ }
+ line.WriteString(" ")
+
+ path := "/" + node.segment
+ if isRoot {
+ path = "/"
+ }
+ if node.validated {
+ line.WriteString(c.GreenBold(path))
+ } else if node.isLeaf {
+ line.WriteString(path)
+ } else {
+ line.WriteString(c.Dim(path))
+ }
+
+ if node.isLeaf && node.length > 0 {
+ line.WriteString(" " + c.YellowBold(fmt.Sprintf("%d", node.length)))
+ }
+
+ if node.title != "" && !isStaticTitle(node.title) {
+ line.WriteString(" " + c.Green(strconv.Quote(node.title)))
+ }
+
+ if len(node.fingers) > 0 {
+ line.WriteString(" " + c.Cyan("["+strings.Join(node.fingers, ",")+"]"))
+ }
+
+ for _, ann := range node.annotations {
+ line.WriteString(" " + c.Yellow("{"+ann+"}"))
+ }
+
+ sb.WriteString(line.String())
+ sb.WriteByte('\n')
+
+ for _, child := range node.children {
+ childIndent := indent
+ if !isRoot {
+ childIndent += "│ "
+ }
+ renderNode(sb, child, childIndent, false, c)
+ }
+}
+
+// --- shared helpers ---
+
+func WebPath(rawURL string) string {
+ parsed, err := url.Parse(strings.TrimSpace(rawURL))
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return FirstNonEmpty(rawURL, "/")
+ }
+ path := parsed.EscapedPath()
+ if path == "" {
+ path = "/"
+ }
+ if parsed.RawQuery != "" {
+ path += "?" + parsed.RawQuery
+ }
+ return path
+}
+
+func HasTag(tags []string, tag string) bool {
+ for _, t := range tags {
+ if strings.EqualFold(t, tag) {
+ return true
+ }
+ }
+ return false
+}
+
+func CompactStrings(values ...string) []string {
+ seen := make(map[string]struct{})
+ out := make([]string, 0, len(values))
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ continue
+ }
+ key := strings.ToLower(value)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, value)
+ }
+ return out
+}
+
+func AssetDataString(data map[string]any, key string) string {
+ if len(data) == 0 {
+ return ""
+ }
+ switch value := data[key].(type) {
+ case string:
+ return value
+ case int:
+ if value == 0 {
+ return ""
+ }
+ return strconv.Itoa(value)
+ case float64:
+ if value == 0 {
+ return ""
+ }
+ return strconv.Itoa(int(value))
+ default:
+ return ""
+ }
+}
+
+func AssetDataInt(data map[string]any, key string) int {
+ if len(data) == 0 {
+ return 0
+ }
+ switch v := data[key].(type) {
+ case int:
+ return v
+ case float64:
+ return int(v)
+ default:
+ return 0
+ }
+}
+
+func AssetDataStrings(data map[string]any, key string) []string {
+ if len(data) == 0 {
+ return nil
+ }
+ switch v := data[key].(type) {
+ case []string:
+ return v
+ case []any:
+ out := make([]string, 0, len(v))
+ for _, item := range v {
+ if s, ok := item.(string); ok && s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+ default:
+ return nil
+ }
+}
+
+func findChild(node *sitemapNode, segment string) *sitemapNode {
+ for _, c := range node.children {
+ if c.segment == segment {
+ return c
+ }
+ }
+ return nil
+}
+
+func splitPath(p string) []string {
+ p = strings.Trim(p, "/")
+ if p == "" {
+ return nil
+ }
+ parts := strings.Split(p, "/")
+ if idx := strings.Index(parts[len(parts)-1], "?"); idx >= 0 {
+ parts[len(parts)-1] = parts[len(parts)-1][:idx]
+ }
+ return parts
+}
+
+func pathFromTarget(target, assetTarget string) string {
+ if target == "" {
+ return ""
+ }
+ p := WebPath(target)
+ if p == target && assetTarget != "" {
+ if strings.HasPrefix(target, assetTarget) {
+ p = strings.TrimPrefix(target, assetTarget)
+ if p == "" {
+ p = "/"
+ }
+ }
+ }
+ return p
+}
+
+func isStaticTitle(title string) bool {
+ switch strings.ToLower(title) {
+ case "js data", "css data", "ico data", "image data":
+ return true
+ }
+ return false
+}
+
+func mergeStrings(a, b []string) []string {
+ if len(b) == 0 {
+ return a
+ }
+ seen := make(map[string]struct{}, len(a))
+ for _, s := range a {
+ seen[strings.ToLower(s)] = struct{}{}
+ }
+ for _, s := range b {
+ if _, ok := seen[strings.ToLower(s)]; !ok {
+ a = append(a, s)
+ seen[strings.ToLower(s)] = struct{}{}
+ }
+ }
+ return a
+}
+
+func appendUniq(slice []string, val string) []string {
+ for _, s := range slice {
+ if s == val {
+ return slice
+ }
+ }
+ return append(slice, val)
+}
diff --git a/core/output/format_markdown.go b/core/output/format_markdown.go
new file mode 100644
index 00000000..d124849c
--- /dev/null
+++ b/core/output/format_markdown.go
@@ -0,0 +1,61 @@
+package output
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/chainreactors/parsers"
+)
+
+// RecordsToResult converts parsed records into a Result for asset report rendering.
+func RecordsToResult(records []Record) *Result {
+ result := &Result{}
+ for _, r := range records {
+ switch r.Type {
+ case TypeService:
+ d, _ := ParseRecordData[parsers.GOGOResult](r)
+ result.Services = append(result.Services, &d)
+ case TypeWeb:
+ d, _ := ParseRecordData[parsers.SprayResult](r)
+ if d.UrlString == "" {
+ continue
+ }
+ if d.Status > 0 {
+ result.WebProbes = append(result.WebProbes, &d)
+ }
+ case TypeLoot:
+ d, _ := ParseRecordData[Loot](r)
+ result.Loots = append(result.Loots, d)
+ case TypeScanEnd:
+ d, _ := ParseRecordData[ScanEnd](r)
+ result.Summary = Summary{
+ Targets: d.Targets,
+ Services: d.Services,
+ Webs: d.Webs,
+ Loots: d.Loots,
+ Duration: fmt.Sprintf("%.1fs", d.Duration),
+ }
+ }
+ }
+
+ if result.Summary.Probes == 0 {
+ result.Summary.Probes = len(result.WebProbes)
+ }
+ return result
+}
+
+// RenderRecordFileAsAsset reads a record JSONL file and renders as an asset report.
+func RenderRecordFileAsAsset(path string, color bool, aggregate func(*Result) []Asset) (string, *Result, error) {
+ records, err := ParseRecordFile(path)
+ if err != nil {
+ return "", nil, fmt.Errorf("open record file: %w", err)
+ }
+
+ result := RecordsToResult(records)
+ if aggregate != nil {
+ result.Assets = aggregate(result)
+ }
+
+ out := FormatAssetReport(result, color)
+ return strings.TrimRight(out, "\n") + "\n", result, nil
+}
diff --git a/core/output/format_terminal.go b/core/output/format_terminal.go
new file mode 100644
index 00000000..dfb091be
--- /dev/null
+++ b/core/output/format_terminal.go
@@ -0,0 +1,80 @@
+package output
+
+import (
+ "encoding/json"
+ "strconv"
+)
+
+// serviceView handles both old record.Service format and new parsers.GOGOResult format.
+type serviceView struct {
+ Target string `json:"target"`
+ Ip string `json:"ip"`
+ Port any `json:"port"`
+ Protocol string `json:"protocol"`
+ Banner string `json:"banner"`
+ Midware string `json:"midware"`
+}
+
+func (s serviceView) displayTarget() string {
+ if s.Target != "" {
+ return s.Target
+ }
+ if s.Ip != "" {
+ if p := anyToString(s.Port); p != "" {
+ return s.Ip + ":" + p
+ }
+ return s.Ip
+ }
+ return ""
+}
+
+func (s serviceView) displayBanner() string {
+ if s.Banner != "" {
+ return s.Banner
+ }
+ return s.Midware
+}
+
+// webView handles both old record.Web format and new parsers.SprayResult format.
+type webView struct {
+ URL string `json:"url"`
+ Status int `json:"status"`
+ Title string `json:"title"`
+ Fingers []string `json:"fingers"`
+ ContentLen int `json:"content_len"`
+ BodyLength int `json:"body_length"`
+ Frameworks json.RawMessage `json:"frameworks"`
+}
+
+func (v webView) fingerNames() []string {
+ if len(v.Fingers) > 0 {
+ return v.Fingers
+ }
+ if len(v.Frameworks) == 0 {
+ return nil
+ }
+ var frames []struct {
+ Name string `json:"name"`
+ }
+ if json.Unmarshal(v.Frameworks, &frames) == nil {
+ var names []string
+ for _, f := range frames {
+ if f.Name != "" {
+ names = append(names, f.Name)
+ }
+ }
+ return names
+ }
+ return nil
+}
+
+func anyToString(v any) string {
+ switch p := v.(type) {
+ case string:
+ return p
+ case float64:
+ return strconv.Itoa(int(p))
+ default:
+ return ""
+ }
+}
diff --git a/core/output/format_test.go b/core/output/format_test.go
new file mode 100644
index 00000000..090cb66a
--- /dev/null
+++ b/core/output/format_test.go
@@ -0,0 +1,87 @@
+package output
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestLootRecordRoundTrip(t *testing.T) {
+ loot := Loot{
+ Kind: LootVuln,
+ Target: "http://10.0.0.1:8080",
+ Priority: "high",
+ Description: "CVE-2024-1234 — Remote Code Execution",
+ Tags: []string{"high", "CVE-2024-1234"},
+ Data: map[string]any{
+ "key": "http://10.0.0.1:8080|CVE-2024-1234",
+ "template_id": "CVE-2024-1234",
+ "template_name": "Remote Code Execution",
+ "severity": "high",
+ },
+ }
+ rec := NewRecord(TypeLoot, loot)
+
+ line := rec.Marshal()
+ parsed, err := ParseRecord(line)
+ if err != nil {
+ t.Fatalf("ParseRecord: %v", err)
+ }
+ if parsed.Type != TypeLoot {
+ t.Fatalf("type = %s, want loot", parsed.Type)
+ }
+
+ got, err := ParseRecordData[Loot](parsed)
+ if err != nil {
+ t.Fatalf("ParseRecordData: %v", err)
+ }
+ if got.Kind != LootVuln {
+ t.Fatalf("kind = %s, want vuln", got.Kind)
+ }
+ if got.Target != "http://10.0.0.1:8080" {
+ t.Fatalf("target = %s", got.Target)
+ }
+ if got.Priority != "high" {
+ t.Fatalf("priority = %s", got.Priority)
+ }
+ if got.Description != "CVE-2024-1234 — Remote Code Execution" {
+ t.Fatalf("description = %s", got.Description)
+ }
+ if got.Key() != "vuln|http://10.0.0.1:8080|http://10.0.0.1:8080|CVE-2024-1234" {
+ t.Fatalf("key = %s", got.Key())
+ }
+}
+
+func TestLootJSONSchema(t *testing.T) {
+ loot := Loot{
+ Kind: LootWeakpass,
+ Target: "10.0.0.1:22",
+ Priority: "high",
+ Description: "ssh root/toor",
+ Tags: []string{"ssh"},
+ Data: map[string]any{
+ "key": "ssh|10.0.0.1:22|root|toor",
+ "service": "ssh",
+ "username": "root",
+ "password": "toor",
+ },
+ }
+
+ data, err := json.Marshal(loot)
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+
+ var m map[string]any
+ if err := json.Unmarshal(data, &m); err != nil {
+ t.Fatalf("Unmarshal: %v", err)
+ }
+
+ for _, field := range []string{"kind", "target", "priority", "description", "tags", "data"} {
+ if _, ok := m[field]; !ok {
+ t.Fatalf("missing JSON field %q", field)
+ }
+ }
+ if m["kind"] != "weakpass" {
+ t.Fatalf("kind = %v", m["kind"])
+ }
+}
diff --git a/core/output/record.go b/core/output/record.go
new file mode 100644
index 00000000..edac7bfc
--- /dev/null
+++ b/core/output/record.go
@@ -0,0 +1,101 @@
+package output
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+)
+
+type RecordType string
+
+const (
+ TypeScanStart RecordType = "scan_start"
+ TypeService RecordType = "service"
+ TypeWeb RecordType = "web"
+ TypeLoot RecordType = "loot"
+ TypeScanEnd RecordType = "scan_end"
+)
+
+type Record struct {
+ Type RecordType `json:"type"`
+ Timestamp time.Time `json:"ts"`
+ Data json.RawMessage `json:"data"`
+}
+
+func NewRecord(t RecordType, data interface{}) Record {
+ raw, _ := json.Marshal(data)
+ return Record{
+ Type: t,
+ Timestamp: time.Now(),
+ Data: raw,
+ }
+}
+
+func (r Record) Marshal() []byte {
+ b, _ := json.Marshal(r)
+ return b
+}
+
+func ParseRecord(line []byte) (Record, error) {
+ var r Record
+ err := json.Unmarshal(line, &r)
+ return r, err
+}
+
+func ParseRecordData[T any](r Record) (T, error) {
+ var v T
+ err := json.Unmarshal(r.Data, &v)
+ return v, err
+}
+
+func ParseRecordFile(path string) ([]Record, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var records []Record
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 || line[0] != '{' {
+ continue
+ }
+ r, err := ParseRecord(line)
+ if err != nil {
+ continue
+ }
+ records = append(records, r)
+ }
+ return records, scanner.Err()
+}
+
+func RenderFile(path, format, outputPath string) error {
+ var w io.Writer = os.Stdout
+ if outputPath != "" {
+ outFile, err := os.Create(outputPath)
+ if err != nil {
+ return fmt.Errorf("create output file: %w", err)
+ }
+ defer outFile.Close()
+ w = outFile
+ }
+
+ entries, err := ParseTimelineFile(path)
+ if err != nil {
+ return err
+ }
+
+ switch strings.ToLower(format) {
+ case "markdown", "md":
+ return RenderTimelineMarkdown(w, entries)
+ default:
+ return RenderTimeline(w, entries)
+ }
+}
diff --git a/core/output/timeline.go b/core/output/timeline.go
new file mode 100644
index 00000000..debf1431
--- /dev/null
+++ b/core/output/timeline.go
@@ -0,0 +1,493 @@
+package output
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/charmbracelet/glamour"
+ "github.com/muesli/termenv"
+)
+
+// ---------------------------------------------------------------------------
+// Core types
+// ---------------------------------------------------------------------------
+
+// timelineItem is implemented by every data payload that can appear in a
+// TimelineEntry. Each type knows how to render itself as one or more
+// markdown lines.
+type timelineItem interface {
+ writeMarkdown(sb *strings.Builder, ctx *renderContext)
+}
+
+// TimelineEntry is one parsed JSONL line.
+type TimelineEntry struct {
+ Timestamp time.Time
+ Type string
+ Data timelineItem
+}
+
+// renderContext is threaded through the walk so items can reference
+// session-level state (e.g. start time for elapsed offsets).
+type renderContext struct {
+ startTS time.Time
+}
+
+// ---------------------------------------------------------------------------
+// Parse
+// ---------------------------------------------------------------------------
+
+// ParseTimelineFile reads a JSONL file (scan records, agent events, or mixed)
+// and returns a flat, chronological slice of entries.
+func ParseTimelineFile(path string) ([]TimelineEntry, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var entries []TimelineEntry
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 0, 256*1024), 10*1024*1024)
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 || line[0] != '{' {
+ continue
+ }
+ if e, ok := parseLine(line); ok {
+ entries = append(entries, e)
+ }
+ }
+ return entries, scanner.Err()
+}
+
+func parseLine(line []byte) (TimelineEntry, bool) {
+ var probe struct {
+ Type string `json:"type"`
+ Timestamp json.RawMessage `json:"ts"`
+ SessionID string `json:"session_id"`
+ Data json.RawMessage `json:"data"`
+ }
+ if json.Unmarshal(line, &probe) != nil || probe.Type == "" {
+ return TimelineEntry{}, false
+ }
+ ts := parseJSONTimestamp(probe.Timestamp)
+
+ // Agent event: has session_id at top level.
+ if probe.SessionID != "" {
+ var ev AgentEvent
+ if json.Unmarshal(line, &ev) != nil {
+ return TimelineEntry{}, false
+ }
+ ev.eventType = probe.Type
+ return TimelineEntry{Timestamp: ts, Type: probe.Type, Data: &ev}, true
+ }
+
+ // Scan record: has nested data field.
+ if len(probe.Data) > 0 {
+ if item := parseScanData(probe.Type, probe.Data); item != nil {
+ return TimelineEntry{Timestamp: ts, Type: probe.Type, Data: item}, true
+ }
+ }
+ return TimelineEntry{}, false
+}
+
+func parseScanData(typ string, data json.RawMessage) timelineItem {
+ switch RecordType(typ) {
+ case TypeScanStart:
+ return unmarshalItem[ScanStart](data)
+ case TypeService:
+ return unmarshalItem[serviceView](data)
+ case TypeWeb:
+ return unmarshalItem[webView](data)
+ case TypeLoot:
+ return unmarshalItem[Loot](data)
+ case TypeScanEnd:
+ return unmarshalItem[ScanEnd](data)
+ }
+ return nil
+}
+
+func unmarshalItem[T any](data json.RawMessage) *T {
+ var v T
+ if json.Unmarshal(data, &v) != nil {
+ return nil
+ }
+ return &v
+}
+
+func parseJSONTimestamp(raw json.RawMessage) time.Time {
+ if len(raw) == 0 {
+ return time.Time{}
+ }
+ var s string
+ if json.Unmarshal(raw, &s) == nil {
+ if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
+ return t
+ }
+ }
+ var t time.Time
+ _ = json.Unmarshal(raw, &t)
+ return t
+}
+
+// ---------------------------------------------------------------------------
+// Render entry points
+// ---------------------------------------------------------------------------
+
+func RenderTimeline(w io.Writer, entries []TimelineEntry) error {
+ _, err := io.WriteString(w, renderMD(BuildTimelineMarkdown(entries)))
+ return err
+}
+
+func RenderTimelineMarkdown(w io.Writer, entries []TimelineEntry) error {
+ _, err := io.WriteString(w, BuildTimelineMarkdown(entries))
+ return err
+}
+
+// BuildTimelineMarkdown produces a single markdown document from entries.
+func BuildTimelineMarkdown(entries []TimelineEntry) string {
+ var sb strings.Builder
+ sess := collectSessionMeta(entries)
+ writeHeader(&sb, &sess)
+
+ ctx := &renderContext{startTS: sess.startTS}
+ for _, e := range entries {
+ e.Data.writeMarkdown(&sb, ctx)
+ }
+ return sb.String()
+}
+
+func writeHeader(sb *strings.Builder, sess *sessionMeta) {
+ if sess.id == "" && sess.model == "" {
+ return
+ }
+ label := shortID(sess.id)
+ if sess.parentID != "" {
+ label += " ← " + shortID(sess.parentID)
+ }
+ if label != "" {
+ sb.WriteString(fmt.Sprintf("# Agent `%s`\n\n", label))
+ }
+ var meta []string
+ if sess.model != "" {
+ meta = append(meta, fmt.Sprintf("**model:** %s", sess.model))
+ }
+ if d := sess.duration(); d > 0 {
+ meta = append(meta, fmt.Sprintf("**duration:** %s", fmtDuration(d)))
+ }
+ if sess.totalTokens > 0 {
+ meta = append(meta, fmt.Sprintf("**tokens:** %d", sess.totalTokens))
+ }
+ if sess.stop != "" {
+ meta = append(meta, fmt.Sprintf("**status:** %s", sess.stop))
+ }
+ if len(meta) > 0 {
+ sb.WriteString("> " + strings.Join(meta, " · ") + "\n\n")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// AgentEvent implements timelineItem
+// ---------------------------------------------------------------------------
+
+type AgentEvent struct {
+ SessionID string `json:"session_id"`
+ ParentSessionID string `json:"parent_session_id"`
+ Turn int `json:"turn"`
+ ToolCallID string `json:"tool_call_id"`
+ ToolName string `json:"tool_name"`
+ Arguments string `json:"arguments"`
+ Result string `json:"result"`
+ IsError bool `json:"is_error"`
+ Error string `json:"error"`
+ Stop string `json:"stop"`
+ Message *AgentEventMsg `json:"message"`
+ ToolResults []AgentEventMsg `json:"tool_results"`
+ Usage *AgentEventUsage `json:"usage"`
+ ContextTokens int `json:"context_tokens"`
+ NewMessages int `json:"new_messages"`
+ RequestModel string `json:"request_model"`
+ RequestMessages int `json:"request_messages"`
+ RequestTools int `json:"request_tools"`
+
+ // eventType is set during parsing so writeMarkdown can dispatch.
+ eventType string
+}
+
+type AgentEventMsg struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []agentToolCall `json:"tool_calls"`
+ ToolCallID string `json:"tool_call_id"`
+}
+
+type AgentEventUsage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ CacheReadTokens int `json:"cache_read_tokens"`
+ CacheWriteTokens int `json:"cache_write_tokens"`
+}
+
+type agentToolCall struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+}
+
+func (ev *AgentEvent) writeMarkdown(sb *strings.Builder, _ *renderContext) {
+ switch ev.eventType {
+ case "turn_start":
+ sb.WriteString(fmt.Sprintf("## Turn %d\n\n", ev.Turn))
+
+ case "message_end":
+ if ev.Message == nil {
+ return
+ }
+ switch ev.Message.Role {
+ case "user":
+ sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(ev.Message.Content, 200)))
+ case "assistant":
+ if len(ev.Message.ToolCalls) > 0 {
+ return
+ }
+ if ev.Message.Content != "" {
+ sb.WriteString(ev.Message.Content + "\n\n")
+ }
+ }
+
+ case "tool_execution_start":
+ args := summarizeToolArgs(ev.ToolName, ev.Arguments)
+ if args != "" {
+ sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", ev.ToolName, args))
+ } else {
+ sb.WriteString(fmt.Sprintf("- **%s**\n", ev.ToolName))
+ }
+
+ case "tool_execution_end":
+ if ev.IsError || ev.Error != "" {
+ errMsg := ev.Error
+ if errMsg == "" {
+ errMsg = TruncateStr(ev.Result, 120)
+ }
+ sb.WriteString(fmt.Sprintf(" - ✗ `%s`\n", TruncateStr(errMsg, 120)))
+ } else {
+ sb.WriteString(fmt.Sprintf(" - ✓ %s\n", compactResult(ev.Result, 150)))
+ }
+
+ case "turn_end":
+ if ev.Usage != nil && ev.Usage.TotalTokens > 0 {
+ usage := fmt.Sprintf("*%d tokens", ev.Usage.TotalTokens)
+ if ev.Usage.CacheReadTokens > 0 && ev.Usage.PromptTokens > 0 {
+ pct := float64(ev.Usage.CacheReadTokens) / float64(ev.Usage.PromptTokens) * 100
+ usage += fmt.Sprintf(", cache %.0f%%", pct)
+ }
+ sb.WriteString("\n" + usage + "*\n")
+ }
+ sb.WriteString("\n")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Scan types implement timelineItem
+// ---------------------------------------------------------------------------
+
+func (d *ScanStart) writeMarkdown(sb *strings.Builder, _ *renderContext) {
+ sb.WriteString(fmt.Sprintf("- **scan** targets=%s mode=%s\n", strings.Join(d.Targets, ", "), d.Mode))
+}
+
+func (s *serviceView) writeMarkdown(sb *strings.Builder, _ *renderContext) {
+ line := fmt.Sprintf(" - **service** `%s`", s.displayTarget())
+ if s.Protocol != "" {
+ line += " " + s.Protocol
+ }
+ if b := s.displayBanner(); b != "" {
+ line += " — " + TruncateStr(b, 60)
+ }
+ sb.WriteString(line + "\n")
+}
+
+func (w *webView) writeMarkdown(sb *strings.Builder, _ *renderContext) {
+ fingers := ""
+ if names := w.fingerNames(); len(names) > 0 {
+ fingers = " [" + strings.Join(names, ", ") + "]"
+ }
+ sb.WriteString(fmt.Sprintf(" - **web** `%s` %d %s%s\n", w.URL, w.Status, w.Title, fingers))
+}
+
+func (l *Loot) writeMarkdown(sb *strings.Builder, _ *renderContext) {
+ sb.WriteString(fmt.Sprintf(" - **%s** `%s` %s\n", l.Kind, l.Target, l.Description))
+}
+
+func (d *ScanEnd) writeMarkdown(sb *strings.Builder, _ *renderContext) {
+ sb.WriteString(fmt.Sprintf("\n> **scan done** %.1fs — %d services, %d webs, %d loots\n\n",
+ d.Duration, d.Services, d.Webs, d.Loots))
+}
+
+// ---------------------------------------------------------------------------
+// Session metadata
+// ---------------------------------------------------------------------------
+
+type sessionMeta struct {
+ id, parentID, model, stop string
+ turns, totalTokens int
+ startTS, endTS time.Time
+}
+
+func (s *sessionMeta) duration() time.Duration {
+ if s.startTS.IsZero() || s.endTS.IsZero() {
+ return 0
+ }
+ return s.endTS.Sub(s.startTS)
+}
+
+func collectSessionMeta(entries []TimelineEntry) sessionMeta {
+ var m sessionMeta
+ for _, e := range entries {
+ ev, ok := e.Data.(*AgentEvent)
+ if !ok {
+ continue
+ }
+ if m.id == "" {
+ m.id = ev.SessionID
+ m.parentID = ev.ParentSessionID
+ }
+ if ev.RequestModel != "" && m.model == "" {
+ m.model = ev.RequestModel
+ }
+ switch e.Type {
+ case "agent_start":
+ m.startTS = e.Timestamp
+ case "agent_end":
+ m.endTS = e.Timestamp
+ m.stop = ev.Stop
+ case "turn_start":
+ m.turns++
+ case "turn_end":
+ if ev.Usage != nil {
+ m.totalTokens = ev.Usage.TotalTokens
+ }
+ }
+ }
+ return m
+}
+
+// ---------------------------------------------------------------------------
+// glamour renderer
+// ---------------------------------------------------------------------------
+
+var (
+ timelineRenderer *glamour.TermRenderer
+ timelineRendererErr error
+ timelineRendererOnce sync.Once
+)
+
+func getTimelineRenderer() (*glamour.TermRenderer, error) {
+ timelineRendererOnce.Do(func() {
+ timelineRenderer, timelineRendererErr = glamour.NewTermRenderer(
+ glamour.WithAutoStyle(),
+ glamour.WithColorProfile(termenv.ANSI),
+ glamour.WithEmoji(),
+ glamour.WithWordWrap(120),
+ )
+ })
+ return timelineRenderer, timelineRendererErr
+}
+
+func renderMD(md string) string {
+ r, err := getTimelineRenderer()
+ if err != nil {
+ return md
+ }
+ rendered, err := r.Render(md)
+ if err != nil {
+ return md
+ }
+ return strings.TrimRight(rendered, "\n") + "\n"
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+func fmtDuration(d time.Duration) string {
+ if d < time.Second {
+ return fmt.Sprintf("%dms", d.Milliseconds())
+ }
+ if d < time.Minute {
+ return fmt.Sprintf("%.1fs", d.Seconds())
+ }
+ return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
+}
+
+func shortID(id string) string {
+ if len(id) > 8 {
+ return id[:8]
+ }
+ return id
+}
+
+func summarizeToolArgs(name, arguments string) string {
+ if arguments == "" {
+ return ""
+ }
+ var args map[string]any
+ if json.Unmarshal([]byte(arguments), &args) != nil {
+ return TruncateStr(arguments, 80)
+ }
+ switch name {
+ case "bash", "scan", "gogo", "spray", "zombie", "neutron", "katana", "passive":
+ if cmd, ok := args["command"].(string); ok {
+ return TruncateStr(cmd, 120)
+ }
+ case "read":
+ return stringVal(args, "path")
+ case "write":
+ path := stringVal(args, "path")
+ if edits, ok := args["edits"]; ok {
+ if arr, ok := edits.([]any); ok {
+ return fmt.Sprintf("%s (%d edits)", path, len(arr))
+ }
+ }
+ return path
+ case "glob":
+ return strings.Join(CompactStrings(stringVal(args, "pattern"), stringVal(args, "path")), " in ")
+ case "subagent":
+ mode := stringVal(args, "mode")
+ prompt := TruncateStr(stringVal(args, "prompt"), 60)
+ if mode != "" {
+ return mode + ": " + prompt
+ }
+ return prompt
+ }
+ return TruncateStr(arguments, 80)
+}
+
+func stringVal(m map[string]any, key string) string {
+ if v, ok := m[key].(string); ok {
+ return v
+ }
+ return ""
+}
+
+func compactResult(result string, maxLen int) string {
+ result = strings.TrimSpace(result)
+ if result == "" {
+ return "(empty)"
+ }
+ lines := strings.Split(result, "\n")
+ if len(lines) == 1 {
+ return TruncateStr(result, maxLen)
+ }
+ first := strings.TrimSpace(lines[0])
+ return TruncateStr(first, maxLen-20) + fmt.Sprintf(" (+%d lines)", len(lines)-1)
+}
diff --git a/core/output/types.go b/core/output/types.go
new file mode 100644
index 00000000..c00c7773
--- /dev/null
+++ b/core/output/types.go
@@ -0,0 +1,110 @@
+package output
+
+import (
+ "time"
+
+ "github.com/chainreactors/parsers"
+)
+
+type Result struct {
+ Summary Summary `json:"summary"`
+ Assets []Asset `json:"assets,omitempty"`
+ Services []*parsers.GOGOResult `json:"services,omitempty"`
+ WebProbes []*parsers.SprayResult `json:"web_probes,omitempty"`
+ Loots []Loot `json:"loots,omitempty"`
+ Errors []Error `json:"errors,omitempty"`
+}
+
+type Summary struct {
+ Targets int `json:"targets"`
+ Services int `json:"services"`
+ Webs int `json:"webs"`
+ Probes int `json:"probes"`
+ Loots int `json:"loots"`
+ Errors int `json:"errors"`
+ Tasks int64 `json:"tasks"`
+ Requests int64 `json:"requests"`
+ Duration string `json:"duration"`
+ StartedAt time.Time `json:"started_at,omitempty"`
+ FinishedAt time.Time `json:"finished_at,omitempty"`
+}
+
+// Loot represents a valuable scan output — a confirmed vulnerability,
+// compromised credential, identified technology stack, or other
+// security-relevant discovery.
+type Loot struct {
+ Kind string `json:"kind"`
+ Target string `json:"target"`
+ Priority string `json:"priority"`
+ Description string `json:"description,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Data map[string]any `json:"data,omitempty"`
+}
+
+func (l Loot) Key() string {
+ key := l.Kind + "|" + l.Target
+ if id, _ := l.Data["key"].(string); id != "" {
+ key += "|" + id
+ }
+ return key
+}
+
+const (
+ LootFingerprint = "fingerprint"
+ LootWeakpass = "weakpass"
+ LootVuln = "vuln"
+)
+
+type Asset struct {
+ ID string `json:"id"`
+ Key string `json:"key"`
+ Target string `json:"target"`
+ Title string `json:"title,omitempty"`
+ Status string `json:"status,omitempty"`
+ Items []AssetItem `json:"items,omitempty"`
+}
+
+const (
+ AssetItemService = "service"
+ AssetItemPath = "path"
+ AssetItemFingerprint = "fingerprint"
+ AssetItemLoot = "loot"
+ AssetItemNote = "note"
+ AssetItemResponse = "response"
+ AssetItemError = "error"
+)
+
+type AssetItem struct {
+ Kind string `json:"kind"`
+ Source string `json:"source,omitempty"`
+ Target string `json:"target,omitempty"`
+ Status string `json:"status,omitempty"`
+ Title string `json:"title,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Data map[string]any `json:"data,omitempty"`
+ Raw string `json:"raw,omitempty"`
+}
+
+type Error struct {
+ Source string `json:"source,omitempty"`
+ Message string `json:"message"`
+}
+
+// --- Record payload types (aiscan-specific) ---
+
+type ScanStart struct {
+ Targets []string `json:"targets"`
+ Mode string `json:"mode"`
+ Flags []string `json:"flags"`
+}
+
+type ScanEnd struct {
+ Duration float64 `json:"duration_s"`
+ Targets int `json:"targets"`
+ Services int `json:"services"`
+ Webs int `json:"webs"`
+ Loots int `json:"loots"`
+ Errors int `json:"errors"`
+}
diff --git a/core/output/writer.go b/core/output/writer.go
new file mode 100644
index 00000000..2d1ba6ab
--- /dev/null
+++ b/core/output/writer.go
@@ -0,0 +1,55 @@
+package output
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "sync"
+)
+
+// TimelineWriter writes both scan records and agent events to a single
+// JSONL file. It is the unified write-side counterpart to ParseTimelineFile.
+type TimelineWriter struct {
+ mu sync.Mutex
+ file *os.File
+}
+
+func NewTimelineWriter(path string) (*TimelineWriter, error) {
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return nil, fmt.Errorf("open timeline file %s: %w", path, err)
+ }
+ return &TimelineWriter{file: f}, nil
+}
+
+func (w *TimelineWriter) Close() error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.file == nil {
+ return nil
+ }
+ err := w.file.Close()
+ w.file = nil
+ return err
+}
+
+// WriteJSON writes any JSON-marshalable value as one JSONL line.
+// Used for both Record (scan) and Event (agent) — they share the file.
+func (w *TimelineWriter) WriteJSON(v any) {
+ line, err := json.Marshal(v)
+ if err != nil {
+ return
+ }
+ line = append(line, '\n')
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.file == nil {
+ return
+ }
+ _, _ = w.file.Write(line)
+}
+
+// WriteRecord is a convenience alias for scan records.
+func (w *TimelineWriter) WriteRecord(rec Record) {
+ w.WriteJSON(rec)
+}
diff --git a/core/pidlock/pidlock.go b/core/pidlock/pidlock.go
new file mode 100644
index 00000000..51f18f21
--- /dev/null
+++ b/core/pidlock/pidlock.go
@@ -0,0 +1,123 @@
+package pidlock
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+type Logger interface {
+ Debugf(format string, args ...any)
+}
+
+type nopLogger struct{}
+
+func (nopLogger) Debugf(string, ...any) {}
+
+func AgentPIDFilePath() string {
+ return filepath.Join(os.TempDir(), "aiscan-agent.pid")
+}
+
+type Lock struct {
+ path string
+ file *os.File
+ pid int
+}
+
+func Acquire(path string, logger Logger) (*Lock, error) {
+ if logger == nil {
+ logger = nopLogger{}
+ }
+ pid := os.Getpid()
+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
+ if err != nil {
+ return nil, fmt.Errorf("open agent pidfile %s: %w", path, err)
+ }
+ if err := lockFile(f); err != nil {
+ _ = f.Close()
+ if existingPID, readErr := ReadPIDFile(path); readErr == nil && existingPID > 0 {
+ return nil, fmt.Errorf("another aiscan agent is already running (PID %d, pidfile %s); kill it first or remove the pidfile", existingPID, path)
+ }
+ return nil, fmt.Errorf("another aiscan agent is already running (pidfile %s is locked)", path)
+ }
+ locked := true
+ cleanup := func() {
+ if locked {
+ _ = unlockFile(f)
+ }
+ _ = f.Close()
+ }
+
+ if info, statErr := f.Stat(); statErr == nil && info.Size() > 0 {
+ if existingPID, readErr := ReadPIDFile(path); readErr == nil && existingPID > 0 && existingPID != pid {
+ if ProcessExists(existingPID) {
+ cleanup()
+ return nil, fmt.Errorf("another aiscan agent is already running (PID %d, pidfile %s); kill it first or remove the pidfile", existingPID, path)
+ }
+ logger.Debugf("pidfile=%s stale_pid=%d action=reclaim", path, existingPID)
+ } else if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
+ logger.Debugf("pidfile=%s action=rewrite reason=%q", path, readErr)
+ }
+ } else if statErr != nil {
+ logger.Debugf("pidfile=%s action=rewrite reason=%q", path, statErr)
+ }
+
+ if err := f.Truncate(0); err != nil {
+ cleanup()
+ return nil, fmt.Errorf("truncate agent pidfile %s: %w", path, err)
+ }
+ if _, err := f.Seek(0, 0); err != nil {
+ cleanup()
+ return nil, fmt.Errorf("seek agent pidfile %s: %w", path, err)
+ }
+ if _, err := fmt.Fprintf(f, "%d\n", pid); err != nil {
+ cleanup()
+ return nil, fmt.Errorf("write agent pidfile %s: %w", path, err)
+ }
+ if err := f.Sync(); err != nil {
+ cleanup()
+ return nil, fmt.Errorf("sync agent pidfile %s: %w", path, err)
+ }
+ locked = false
+ return &Lock{path: path, file: f, pid: pid}, nil
+}
+
+func (l *Lock) Release() {
+ if l == nil || l.file == nil {
+ return
+ }
+ _ = removeOwned(l.path, l.pid)
+ _ = unlockFile(l.file)
+ _ = l.file.Close()
+ l.file = nil
+}
+
+func removeOwned(path string, pid int) error {
+ existingPID, err := ReadPIDFile(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+ }
+ if existingPID != pid {
+ return nil
+ }
+ return os.Remove(path)
+}
+
+func ReadPIDFile(path string) (int, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return 0, err
+ }
+ pidStr := strings.TrimSpace(string(data))
+ pid, err := strconv.Atoi(pidStr)
+ if err != nil || pid <= 0 {
+ return 0, fmt.Errorf("invalid pid %q", pidStr)
+ }
+ return pid, nil
+}
diff --git a/core/pidlock/pidlock_test.go b/core/pidlock/pidlock_test.go
new file mode 100644
index 00000000..154af1e5
--- /dev/null
+++ b/core/pidlock/pidlock_test.go
@@ -0,0 +1,98 @@
+package pidlock
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestAcquireRejectsHeldLock(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "agent.pid")
+ first, err := Acquire(path, nil)
+ if err != nil {
+ t.Fatalf("first Acquire() error = %v", err)
+ }
+ defer first.Release()
+
+ _, err = Acquire(path, nil)
+ if err == nil || !strings.Contains(err.Error(), "already running") {
+ t.Fatalf("Acquire() error = %v, want already running", err)
+ }
+}
+
+func TestAcquireReclaimsInvalidPIDFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "agent.pid")
+ if err := os.WriteFile(path, []byte("not-a-pid\n"), 0644); err != nil {
+ t.Fatalf("write pidfile: %v", err)
+ }
+
+ lock, err := Acquire(path, nil)
+ if err != nil {
+ t.Fatalf("Acquire() error = %v", err)
+ }
+ defer lock.Release()
+
+ got, err := ReadPIDFile(path)
+ if err != nil {
+ t.Fatalf("ReadPIDFile() error = %v", err)
+ }
+ if got != os.Getpid() {
+ t.Fatalf("pidfile pid = %d, want %d", got, os.Getpid())
+ }
+}
+
+func TestAcquireIsAtomic(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "agent.pid")
+ const workers = 8
+ type result struct {
+ lock *Lock
+ err error
+ }
+
+ var wg sync.WaitGroup
+ results := make(chan result, workers)
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ lock, err := Acquire(path, nil)
+ results <- result{lock: lock, err: err}
+ }()
+ }
+ wg.Wait()
+ close(results)
+
+ successes := 0
+ for result := range results {
+ if result.err == nil {
+ successes++
+ result.lock.Release()
+ continue
+ }
+ if !strings.Contains(result.err.Error(), "already running") {
+ t.Fatalf("unexpected acquire error: %v", result.err)
+ }
+ }
+ if successes != 1 {
+ t.Fatalf("successful acquisitions = %d, want 1", successes)
+ }
+}
+
+func TestReleaseOnlyRemovesOwnedFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "agent.pid")
+ lock, err := Acquire(path, nil)
+ if err != nil {
+ t.Fatalf("Acquire() error = %v", err)
+ }
+ if err := os.WriteFile(path, []byte("1\n"), 0644); err != nil {
+ t.Fatalf("write pidfile: %v", err)
+ }
+
+ lock.Release()
+
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("pidfile should remain when owned by another pid: %v", err)
+ }
+}
diff --git a/core/pidlock/pidlock_unix.go b/core/pidlock/pidlock_unix.go
new file mode 100644
index 00000000..ac70be97
--- /dev/null
+++ b/core/pidlock/pidlock_unix.go
@@ -0,0 +1,26 @@
+//go:build !windows
+
+package pidlock
+
+import (
+ "errors"
+ "os"
+ "syscall"
+)
+
+func lockFile(f *os.File) error {
+ return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
+}
+
+func unlockFile(f *os.File) error {
+ return syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
+}
+
+func ProcessExists(pid int) bool {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return false
+ }
+ err = proc.Signal(syscall.Signal(0))
+ return err == nil || errors.Is(err, os.ErrPermission) || errors.Is(err, syscall.EPERM)
+}
diff --git a/core/pidlock/pidlock_windows.go b/core/pidlock/pidlock_windows.go
new file mode 100644
index 00000000..90c4566a
--- /dev/null
+++ b/core/pidlock/pidlock_windows.go
@@ -0,0 +1,41 @@
+//go:build windows
+
+package pidlock
+
+import (
+ "errors"
+ "os"
+
+ "golang.org/x/sys/windows"
+)
+
+const lockOffset = 0xfffffff0
+
+func lockFile(f *os.File) error {
+ overlapped := windows.Overlapped{Offset: lockOffset}
+ return windows.LockFileEx(
+ windows.Handle(f.Fd()),
+ windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
+ 0,
+ 1,
+ 0,
+ &overlapped,
+ )
+}
+
+func unlockFile(f *os.File) error {
+ overlapped := windows.Overlapped{Offset: lockOffset}
+ return windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, &overlapped)
+}
+
+func ProcessExists(pid int) bool {
+ if pid <= 0 {
+ return false
+ }
+ handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
+ if err == nil {
+ _ = windows.CloseHandle(handle)
+ return true
+ }
+ return errors.Is(err, windows.ERROR_ACCESS_DENIED)
+}
diff --git a/core/resources/cache.go b/core/resources/cache.go
new file mode 100644
index 00000000..692c671b
--- /dev/null
+++ b/core/resources/cache.go
@@ -0,0 +1,98 @@
+package resources
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/chainreactors/neutron/templates"
+ "github.com/chainreactors/sdk/fingers"
+ "gopkg.in/yaml.v3"
+)
+
+const (
+ cacheDirName = ".aiscan"
+ cacheTTL = 24 * time.Hour
+)
+
+func cachePath(cyberhubURL, apiKey, kind string) string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+ dir := filepath.Join(home, cacheDirName, "cache")
+ _ = os.MkdirAll(dir, 0o755)
+ h := sha256.Sum256([]byte(cyberhubURL + "|" + apiKey))
+ return filepath.Join(dir, fmt.Sprintf("%s_%s.cache", kind, hex.EncodeToString(h[:8])))
+}
+
+func cacheIsFresh(path string) bool {
+ if path == "" {
+ return false
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return false
+ }
+ return time.Since(info.ModTime()) < cacheTTL
+}
+
+// --- fingers cache ---
+
+func loadCachedFingers(path string) (fingers.FullFingers, bool) {
+ if !cacheIsFresh(path) {
+ return fingers.FullFingers{}, false
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return fingers.FullFingers{}, false
+ }
+ var items map[string]*fingers.FullFinger
+ if err := json.Unmarshal(data, &items); err != nil || len(items) == 0 {
+ return fingers.FullFingers{}, false
+ }
+ return fingers.FullFingers{Items: items}, true
+}
+
+func saveCachedFingers(path string, ff fingers.FullFingers) {
+ if path == "" {
+ return
+ }
+ data, err := json.Marshal(ff.Items)
+ if err != nil {
+ return
+ }
+ _ = os.WriteFile(path, data, 0o644)
+}
+
+// --- templates cache ---
+
+func loadCachedTemplates(path string) ([]*templates.Template, bool) {
+ if !cacheIsFresh(path) {
+ return nil, false
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, false
+ }
+ var tpls []*templates.Template
+ if err := yaml.Unmarshal(data, &tpls); err != nil || len(tpls) == 0 {
+ return nil, false
+ }
+ return tpls, true
+}
+
+func saveCachedTemplates(path string, tpls []*templates.Template) {
+ if path == "" {
+ return
+ }
+ data, err := yaml.Marshal(tpls)
+ if err != nil {
+ return
+ }
+ _ = os.WriteFile(path, data, 0o644)
+}
diff --git a/core/resources/data/extract.bin b/core/resources/data/extract.bin
new file mode 100644
index 00000000..47e4c447
Binary files /dev/null and b/core/resources/data/extract.bin differ
diff --git a/core/resources/data/fingerprinthub_service.bin b/core/resources/data/fingerprinthub_service.bin
new file mode 100644
index 00000000..9e7ba513
Binary files /dev/null and b/core/resources/data/fingerprinthub_service.bin differ
diff --git a/core/resources/data/fingerprinthub_web.bin b/core/resources/data/fingerprinthub_web.bin
new file mode 100644
index 00000000..9e7ba513
Binary files /dev/null and b/core/resources/data/fingerprinthub_web.bin differ
diff --git a/core/resources/data/http.bin b/core/resources/data/http.bin
new file mode 100644
index 00000000..8c94133e
Binary files /dev/null and b/core/resources/data/http.bin differ
diff --git a/core/resources/data/neutron.bin b/core/resources/data/neutron.bin
new file mode 100644
index 00000000..002a8d96
Binary files /dev/null and b/core/resources/data/neutron.bin differ
diff --git a/core/resources/data/port.bin b/core/resources/data/port.bin
new file mode 100644
index 00000000..5fb2633f
Binary files /dev/null and b/core/resources/data/port.bin differ
diff --git a/core/resources/data/socket.bin b/core/resources/data/socket.bin
new file mode 100644
index 00000000..3f1fbec3
Binary files /dev/null and b/core/resources/data/socket.bin differ
diff --git a/core/resources/data/spray_common.bin b/core/resources/data/spray_common.bin
new file mode 100644
index 00000000..7fb75da0
Binary files /dev/null and b/core/resources/data/spray_common.bin differ
diff --git a/core/resources/data/spray_dict.bin b/core/resources/data/spray_dict.bin
new file mode 100644
index 00000000..35d9564e
Binary files /dev/null and b/core/resources/data/spray_dict.bin differ
diff --git a/core/resources/data/spray_rule.bin b/core/resources/data/spray_rule.bin
new file mode 100644
index 00000000..aeccce18
Binary files /dev/null and b/core/resources/data/spray_rule.bin differ
diff --git a/core/resources/data/workflow.bin b/core/resources/data/workflow.bin
new file mode 100644
index 00000000..02842cdb
Binary files /dev/null and b/core/resources/data/workflow.bin differ
diff --git a/core/resources/data/zombie_common.bin b/core/resources/data/zombie_common.bin
new file mode 100644
index 00000000..3a62fc48
Binary files /dev/null and b/core/resources/data/zombie_common.bin differ
diff --git a/core/resources/data/zombie_default.bin b/core/resources/data/zombie_default.bin
new file mode 100644
index 00000000..7d9463b0
Binary files /dev/null and b/core/resources/data/zombie_default.bin differ
diff --git a/core/resources/data/zombie_rule.bin b/core/resources/data/zombie_rule.bin
new file mode 100644
index 00000000..3706fe4d
Binary files /dev/null and b/core/resources/data/zombie_rule.bin differ
diff --git a/core/resources/data/zombie_template.bin b/core/resources/data/zombie_template.bin
new file mode 100644
index 00000000..6ebac921
Binary files /dev/null and b/core/resources/data/zombie_template.bin differ
diff --git a/pkg/scanner/resources/pipeline_test.go b/core/resources/pipeline_test.go
similarity index 100%
rename from pkg/scanner/resources/pipeline_test.go
rename to core/resources/pipeline_test.go
diff --git a/pkg/scanner/resources/resources.go b/core/resources/resources.go
similarity index 66%
rename from pkg/scanner/resources/resources.go
rename to core/resources/resources.go
index d824b380..777307bc 100644
--- a/pkg/scanner/resources/resources.go
+++ b/core/resources/resources.go
@@ -1,18 +1,20 @@
package resources
-//go:generate go run ./templates_gen.go -t ../../../templates -o template.go
+//go:generate go run ./templates_gen.go -t ../../templates -o template.go -embed
import (
"context"
"encoding/json"
"fmt"
"strings"
+ "time"
fingerslib "github.com/chainreactors/fingers/fingers"
fingerresources "github.com/chainreactors/fingers/resources"
"github.com/chainreactors/neutron/templates"
"github.com/chainreactors/sdk/fingers"
"github.com/chainreactors/sdk/neutron"
+ "github.com/chainreactors/sdk/pkg/cyberhub"
"github.com/chainreactors/utils"
"gopkg.in/yaml.v3"
)
@@ -27,6 +29,7 @@ type Options struct {
CyberhubURL string
APIKey string
Mode string
+ Proxy string
}
// Set owns the scanner resource bytes and compiled SDK engines used by aiscan.
@@ -61,7 +64,8 @@ func Init(ctx context.Context, opts Options) (*Set, error) {
return nil, err
}
localFingers := append(append(fingerslib.Fingers(nil), localHTTP...), localSocket...)
- finalFingers := append(fingerslib.Fingers(nil), localFingers...)
+ localFullFingers := (fingers.FullFingers{}).Merge(localFingers, nil)
+ finalFullFingers := cloneFullFingers(localFullFingers)
finalTemplates := loadLocalTemplates()
set := &Set{
@@ -73,31 +77,48 @@ func Init(ctx context.Context, opts Options) (*Set, error) {
}
if set.RemoteEnabled {
- remoteFingers, err := loadRemoteFingers(ctx, opts.CyberhubURL, opts.APIKey)
- if err != nil {
+ fingerCache := cachePath(opts.CyberhubURL, opts.APIKey, "fingers")
+ if ff, ok := loadCachedFingers(fingerCache); ok {
+ set.RemoteFingers = ff.Len()
+ if mode == ModeOverride {
+ finalFullFingers = cloneFullFingers(ff)
+ } else {
+ finalFullFingers = mergeFullFingers(localFullFingers, ff)
+ }
+ } else if ff, err := loadRemoteFingers(ctx, opts.CyberhubURL, opts.APIKey); err != nil {
set.RemoteFingersErr = err
- } else if remoteFingers.Len() > 0 {
- set.RemoteFingers = remoteFingers.Len()
+ } else if ff.Len() > 0 {
+ set.RemoteFingers = ff.Len()
+ saveCachedFingers(fingerCache, ff)
if mode == ModeOverride {
- finalFingers = remoteFingers.Fingers()
+ finalFullFingers = cloneFullFingers(ff)
} else {
- finalFingers = mergeFingers(localFingers, remoteFingers.Fingers())
+ finalFullFingers = mergeFullFingers(localFullFingers, ff)
}
}
- remoteTemplates, err := loadRemoteTemplates(ctx, opts.CyberhubURL, opts.APIKey)
- if err != nil {
+ tplCache := cachePath(opts.CyberhubURL, opts.APIKey, "neutron")
+ if tpls, ok := loadCachedTemplates(tplCache); ok {
+ set.RemoteNeutron = len(tpls)
+ if mode == ModeOverride {
+ finalTemplates = tpls
+ } else {
+ finalTemplates = mergeTemplates(finalTemplates, tpls)
+ }
+ } else if rt, err := loadRemoteTemplates(ctx, opts.CyberhubURL, opts.APIKey); err != nil {
set.RemoteNeutronErr = err
- } else if remoteTemplates.Len() > 0 {
- set.RemoteNeutron = remoteTemplates.Len()
+ } else if rt.Len() > 0 {
+ set.RemoteNeutron = rt.Len()
+ saveCachedTemplates(tplCache, rt.Templates())
if mode == ModeOverride {
- finalTemplates = remoteTemplates.Templates()
+ finalTemplates = rt.Templates()
} else {
- finalTemplates = mergeTemplates(finalTemplates, remoteTemplates.Templates())
+ finalTemplates = mergeTemplates(finalTemplates, rt.Templates())
}
}
}
+ finalFingers := finalFullFingers.Fingers()
httpFingers, socketFingers := splitFingers(finalFingers)
set.gogoConfigs["http"] = marshalJSON(httpFingers)
set.gogoConfigs["socket"] = marshalJSON(socketFingers)
@@ -107,10 +128,11 @@ func Init(ctx context.Context, opts Options) (*Set, error) {
set.zombieConfigs["http"] = marshalJSON(httpFingers)
set.zombieConfigs["socket"] = marshalJSON(socketFingers)
- set.FingersConfig = fingers.NewConfig().WithFingers(finalFingers)
+ set.FingersConfig = fingers.NewConfig()
+ set.FingersConfig.FullFingers = finalFullFingers
set.NeutronConfig = neutron.NewConfig().WithTemplates(finalTemplates)
- set.Fingers, err = fingers.NewEngine(set.FingersConfig)
+ set.Fingers, err = fingers.NewEngineWithFingers(finalFullFingers)
if err != nil {
return nil, err
}
@@ -148,27 +170,38 @@ func defaultGogoConfigs() map[string][]byte {
}
func defaultSprayConfigs() map[string][]byte {
- return map[string][]byte{
- "http": embeddedOrDefault(loadEmbeddedConfig, "http", []byte("[]")),
- "socket": embeddedOrDefault(loadEmbeddedConfig, "socket", []byte("[]")),
- "port": embeddedOrDefault(loadEmbeddedConfig, "port", []byte("[]")),
- "spray_rule": embeddedOrDefault(loadEmbeddedConfig, "spray_rule", []byte("{}")),
- "spray_dict": embeddedOrDefault(loadEmbeddedConfig, "spray_dict", []byte("{}")),
- "spray_common": embeddedOrDefault(loadEmbeddedConfig, "spray_common", []byte("{}")),
- "extract": embeddedOrDefault(loadEmbeddedConfig, "extract", []byte("[]")),
+ m := map[string][]byte{
+ "http": embeddedOrDefault(loadEmbeddedConfig, "http", []byte("[]")),
+ "socket": embeddedOrDefault(loadEmbeddedConfig, "socket", []byte("[]")),
+ "port": embeddedOrDefault(loadEmbeddedConfig, "port", []byte("[]")),
+ "extract": embeddedOrDefault(loadEmbeddedConfig, "extract", []byte("[]")),
+ }
+ // Include spray-specific keys when generated templates provide them.
+ // When loadEmbeddedConfig returns nil (stub build), these entries are
+ // omitted so that spray falls through to its own embedded defaults.
+ for _, key := range []string{"spray_rule", "spray_dict", "spray_common"} {
+ if data := loadEmbeddedConfig(key); len(data) > 0 {
+ m[key] = data
+ }
}
+ return m
}
func defaultZombieConfigs() map[string][]byte {
- return map[string][]byte{
- "http": embeddedOrDefault(loadEmbeddedConfig, "http", []byte("[]")),
- "socket": embeddedOrDefault(loadEmbeddedConfig, "socket", []byte("[]")),
- "port": embeddedOrDefault(loadEmbeddedConfig, "port", []byte("[]")),
- "zombie_common": embeddedOrDefault(loadEmbeddedConfig, "zombie_common", []byte("{}")),
- "zombie_default": embeddedOrDefault(loadEmbeddedConfig, "zombie_default", []byte("[]")),
- "zombie_rule": embeddedOrDefault(loadEmbeddedConfig, "zombie_rule", []byte("{}")),
- "zombie_template": embeddedOrDefault(loadEmbeddedConfig, "zombie_template", []byte("[]")),
+ m := map[string][]byte{
+ "http": embeddedOrDefault(loadEmbeddedConfig, "http", []byte("[]")),
+ "socket": embeddedOrDefault(loadEmbeddedConfig, "socket", []byte("[]")),
+ "port": embeddedOrDefault(loadEmbeddedConfig, "port", []byte("[]")),
+ }
+ // Include zombie-specific keys when generated templates provide them.
+ // When loadEmbeddedConfig returns nil (stub build), these entries are
+ // omitted so that zombie falls through to its own embedded defaults.
+ for _, key := range []string{"zombie_common", "zombie_default", "zombie_rule", "zombie_template"} {
+ if data := loadEmbeddedConfig(key); len(data) > 0 {
+ m[key] = data
+ }
}
+ return m
}
func embeddedOrDefault(provider func(string) []byte, name string, fallback []byte) []byte {
@@ -222,7 +255,8 @@ func installLocalPortPreset() error {
}
func loadRemoteFingers(ctx context.Context, cyberhubURL, apiKey string) (fingers.FullFingers, error) {
- config := fingers.NewConfig().WithCyberhub(cyberhubURL, apiKey)
+ provider := cyberhub.NewProvider(cyberhubURL, apiKey).WithTimeout(60 * time.Second)
+ config := fingers.NewConfig().WithProvider(provider)
if err := config.Load(ctx); err != nil {
return fingers.FullFingers{}, err
}
@@ -230,40 +264,36 @@ func loadRemoteFingers(ctx context.Context, cyberhubURL, apiKey string) (fingers
}
func loadRemoteTemplates(ctx context.Context, cyberhubURL, apiKey string) (neutron.Templates, error) {
- config := neutron.NewConfig().WithCyberhub(cyberhubURL, apiKey)
+ provider := cyberhub.NewProvider(cyberhubURL, apiKey).WithTimeout(60 * time.Second)
+ config := neutron.NewConfig().WithProvider(provider)
if err := config.Load(ctx); err != nil {
return neutron.Templates{}, err
}
return config.Templates, nil
}
-func mergeFingers(local, remote fingerslib.Fingers) fingerslib.Fingers {
- if len(local) == 0 {
- return append(fingerslib.Fingers(nil), remote...)
+func cloneFullFingers(src fingers.FullFingers) fingers.FullFingers {
+ if src.Len() == 0 {
+ return fingers.FullFingers{}
}
- items := make(map[string]*fingerslib.Finger, len(local)+len(remote))
- for _, item := range local {
- if item == nil || item.Name == "" {
- continue
- }
- items[item.Name] = item
- }
- for _, item := range remote {
- if item == nil || item.Name == "" {
- continue
- }
- items[item.Name] = item
+ out := fingers.FullFingers{Items: make(map[string]*fingers.FullFinger, len(src.Items))}
+ for key, item := range src.Items {
+ out.Items[key] = item
}
- out := make(fingerslib.Fingers, 0, len(items))
- for _, item := range items {
- out = append(out, item)
+ return out
+}
+
+func mergeFullFingers(local, remote fingers.FullFingers) fingers.FullFingers {
+ out := cloneFullFingers(local)
+ for _, item := range remote.Items {
+ out = out.Append(item)
}
return out
}
func splitFingers(items fingerslib.Fingers) (fingerslib.Fingers, fingerslib.Fingers) {
- var httpFingers fingerslib.Fingers
- var socketFingers fingerslib.Fingers
+ httpFingers := make(fingerslib.Fingers, 0)
+ socketFingers := make(fingerslib.Fingers, 0)
for _, item := range items {
if item == nil {
continue
diff --git a/pkg/scanner/resources/resources_test.go b/core/resources/resources_test.go
similarity index 100%
rename from pkg/scanner/resources/resources_test.go
rename to core/resources/resources_test.go
diff --git a/core/resources/template.go b/core/resources/template.go
new file mode 100644
index 00000000..3b25f7ff
--- /dev/null
+++ b/core/resources/template.go
@@ -0,0 +1,103 @@
+// Code generated by templates_gen.go; DO NOT EDIT.
+
+package resources
+
+import (
+ _ "embed"
+
+ "github.com/chainreactors/utils/encode"
+)
+
+//go:embed data/http.bin
+var httpData []byte
+
+//go:embed data/socket.bin
+var socketData []byte
+
+//go:embed data/fingerprinthub_web.bin
+var fingerprinthubWebData []byte
+
+//go:embed data/fingerprinthub_service.bin
+var fingerprinthubServiceData []byte
+
+//go:embed data/port.bin
+var portData []byte
+
+//go:embed data/extract.bin
+var extractData []byte
+
+//go:embed data/workflow.bin
+var workflowData []byte
+
+//go:embed data/neutron.bin
+var neutronData []byte
+
+//go:embed data/spray_rule.bin
+var sprayRuleData []byte
+
+//go:embed data/spray_dict.bin
+var sprayDictData []byte
+
+//go:embed data/spray_common.bin
+var sprayCommonData []byte
+
+//go:embed data/zombie_common.bin
+var zombieCommonData []byte
+
+//go:embed data/zombie_default.bin
+var zombieDefaultData []byte
+
+//go:embed data/zombie_rule.bin
+var zombieRuleData []byte
+
+//go:embed data/zombie_template.bin
+var zombieTemplateData []byte
+
+func loadEmbeddedConfig(typ string) []byte {
+ if typ == "http" {
+ return encode.MustDeflateDeCompress(httpData)
+ }
+ if typ == "socket" {
+ return encode.MustDeflateDeCompress(socketData)
+ }
+ if typ == "fingerprinthub_web" {
+ return encode.MustDeflateDeCompress(fingerprinthubWebData)
+ }
+ if typ == "fingerprinthub_service" {
+ return encode.MustDeflateDeCompress(fingerprinthubServiceData)
+ }
+ if typ == "port" {
+ return encode.MustDeflateDeCompress(portData)
+ }
+ if typ == "extract" {
+ return encode.MustDeflateDeCompress(extractData)
+ }
+ if typ == "workflow" {
+ return encode.MustDeflateDeCompress(workflowData)
+ }
+ if typ == "neutron" {
+ return encode.MustDeflateDeCompress(neutronData)
+ }
+ if typ == "spray_rule" {
+ return encode.MustDeflateDeCompress(sprayRuleData)
+ }
+ if typ == "spray_dict" {
+ return encode.MustDeflateDeCompress(sprayDictData)
+ }
+ if typ == "spray_common" {
+ return encode.MustDeflateDeCompress(sprayCommonData)
+ }
+ if typ == "zombie_common" {
+ return encode.MustDeflateDeCompress(zombieCommonData)
+ }
+ if typ == "zombie_default" {
+ return encode.MustDeflateDeCompress(zombieDefaultData)
+ }
+ if typ == "zombie_rule" {
+ return encode.MustDeflateDeCompress(zombieRuleData)
+ }
+ if typ == "zombie_template" {
+ return encode.MustDeflateDeCompress(zombieTemplateData)
+ }
+ return nil
+}
diff --git a/pkg/scanner/resources/templates_gen.go b/core/resources/templates_gen.go
similarity index 65%
rename from pkg/scanner/resources/templates_gen.go
rename to core/resources/templates_gen.go
index 6ee8de6b..2420c341 100644
--- a/pkg/scanner/resources/templates_gen.go
+++ b/core/resources/templates_gen.go
@@ -18,18 +18,23 @@ import (
var (
templatePath string
resultPath string
+ embedMode bool
)
+func deflateCompress(input []byte) []byte {
+ return en.MustDeflateCompress(input)
+}
+
func encode(input []byte) string {
- return en.Base64Encode(en.MustDeflateCompress(input))
+ return en.Base64Encode(deflateCompress(input))
}
-func loadYAMLFile(filename string) string {
+func loadYAMLFile(filename string) []byte {
bs, err := os.ReadFile(filepath.Join(templatePath, filename))
if err != nil {
panic(err)
}
- return encode(bs)
+ return deflateCompress(bs)
}
func walkFiles(dir string) []string {
@@ -52,7 +57,7 @@ func walkFiles(dir string) []string {
return files
}
-func loadRawFiles(dir string) string {
+func loadRawFiles(dir string) []byte {
data := make(map[string]string)
for _, file := range walkFiles(dir) {
bs, err := os.ReadFile(file)
@@ -65,10 +70,10 @@ func loadRawFiles(dir string) string {
if err != nil {
panic(err)
}
- return encode(content)
+ return deflateCompress(content)
}
-func recuLoadPoc(dir string) string {
+func recuLoadPoc(dir string) []byte {
var pocs []interface{}
for _, file := range walkFiles(dir) {
bs, err := os.ReadFile(file)
@@ -87,14 +92,14 @@ func recuLoadPoc(dir string) string {
if err != nil {
panic(err)
}
- return encode(content)
+ return deflateCompress(content)
}
// loadZombieTemplates collects neutron POCs whose info.zombie field is non-empty
// and emits them as a yaml-encoded template list — the format expected by
// zombie/pkg/loader.go LoadTemplates which calls yaml.Unmarshal into
// []*templates.Template.
-func loadZombieTemplates(dir string) string {
+func loadZombieTemplates(dir string) []byte {
var pocs []interface{}
for _, file := range walkFiles(dir) {
bs, err := os.ReadFile(file)
@@ -122,10 +127,10 @@ func loadZombieTemplates(dir string) string {
if err != nil {
panic(err)
}
- return encode(content)
+ return deflateCompress(content)
}
-func recuLoadFinger(dir string) string {
+func recuLoadFinger(dir string) []byte {
var items []interface{}
for _, file := range walkFiles(dir) {
bs, err := os.ReadFile(file)
@@ -166,19 +171,19 @@ func recuLoadFinger(dir string) string {
if err != nil {
panic(err)
}
- return encode(content)
+ return deflateCompress(content)
}
-func parser(key string) string {
+func parser(key string) []byte {
switch key {
case "socket":
return recuLoadFinger("fingers/socket")
case "http":
return recuLoadFinger("fingers/http")
case "fingerprinthub_web":
- return encode([]byte("[]"))
+ return deflateCompress([]byte("[]"))
case "fingerprinthub_service":
- return encode([]byte("[]"))
+ return deflateCompress([]byte("[]"))
case "port":
return loadYAMLFile("port.yaml")
case "workflow":
@@ -206,10 +211,25 @@ func parser(key string) string {
}
}
+func toVarName(key string) string {
+ parts := strings.Split(key, "_")
+ for i, p := range parts {
+ if len(p) > 0 {
+ if i == 0 {
+ parts[i] = strings.ToLower(p[:1]) + p[1:]
+ } else {
+ parts[i] = strings.ToUpper(p[:1]) + p[1:]
+ }
+ }
+ }
+ return strings.Join(parts, "") + "Data"
+}
+
func main() {
flag.StringVar(&templatePath, "t", ".", "templates repo path")
flag.StringVar(&resultPath, "o", "template.go", "result filename")
need := flag.String("need", "aiscan", "aiscan or comma-separated template keys")
+ flag.BoolVar(&embedMode, "embed", false, "use go:embed for binary data (requires Go 1.16+)")
flag.Parse()
var needs []string
@@ -224,6 +244,14 @@ func main() {
needs = strings.Split(*need, ",")
}
+ if embedMode {
+ generateEmbed(needs)
+ } else {
+ generateLegacy(needs)
+ }
+}
+
+func generateLegacy(needs []string) {
var b strings.Builder
b.WriteString("// Code generated by templates_gen.go; DO NOT EDIT.\n\n")
b.WriteString("package resources\n\n")
@@ -234,8 +262,9 @@ func main() {
if key == "" {
continue
}
+ b64 := en.Base64Encode(parser(key))
b.WriteString(fmt.Sprintf("\tif typ == %q {\n", key))
- b.WriteString(fmt.Sprintf("\t\treturn encode.MustDeflateDeCompress(encode.Base64Decode(%q))\n", parser(key)))
+ b.WriteString(fmt.Sprintf("\t\treturn encode.MustDeflateDeCompress(encode.Base64Decode(%q))\n", b64))
b.WriteString("\t}\n")
}
b.WriteString("\treturn nil\n")
@@ -244,4 +273,54 @@ func main() {
if err := os.WriteFile(resultPath, []byte(b.String()), 0644); err != nil {
panic(err)
}
+ fmt.Println("generate template.go (legacy) successfully")
+}
+
+func generateEmbed(needs []string) {
+ dataDir := filepath.Join(filepath.Dir(resultPath), "data")
+ if err := os.MkdirAll(dataDir, 0755); err != nil {
+ panic(fmt.Sprintf("create data dir: %v", err))
+ }
+
+ var embedDecls strings.Builder
+ var loadBody strings.Builder
+
+ for _, key := range needs {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ continue
+ }
+ data := parser(key)
+
+ binFile := key + ".bin"
+ binPath := filepath.Join(dataDir, binFile)
+ if err := os.WriteFile(binPath, data, 0644); err != nil {
+ panic(fmt.Sprintf("write %s: %v", binPath, err))
+ }
+ fmt.Printf(" embed: %s (%d bytes)\n", binFile, len(data))
+
+ varName := toVarName(key)
+ embedDecls.WriteString(fmt.Sprintf("//go:embed data/%s\nvar %s []byte\n\n", binFile, varName))
+ loadBody.WriteString(fmt.Sprintf("\tif typ == %q {\n", key))
+ loadBody.WriteString(fmt.Sprintf("\t\treturn encode.MustDeflateDeCompress(%s)\n", varName))
+ loadBody.WriteString("\t}\n")
+ }
+
+ var b strings.Builder
+ b.WriteString("// Code generated by templates_gen.go; DO NOT EDIT.\n\n")
+ b.WriteString("package resources\n\n")
+ b.WriteString("import (\n")
+ b.WriteString("\t_ \"embed\"\n\n")
+ b.WriteString("\t\"github.com/chainreactors/utils/encode\"\n")
+ b.WriteString(")\n\n")
+ b.WriteString(embedDecls.String())
+ b.WriteString("func loadEmbeddedConfig(typ string) []byte {\n")
+ b.WriteString(loadBody.String())
+ b.WriteString("\treturn nil\n")
+ b.WriteString("}\n")
+
+ if err := os.WriteFile(resultPath, []byte(b.String()), 0644); err != nil {
+ panic(err)
+ }
+ fmt.Println("generate template.go (embed) successfully")
}
diff --git a/core/runner/app.go b/core/runner/app.go
new file mode 100644
index 00000000..616dfe0e
--- /dev/null
+++ b/core/runner/app.go
@@ -0,0 +1,335 @@
+package runner
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/agent/truncate"
+ "github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ "github.com/chainreactors/aiscan/skills"
+ ioaclient "github.com/chainreactors/ioa/client"
+ "github.com/chainreactors/ioa/protocols"
+)
+
+type App struct {
+ Provider agent.Provider
+ ProviderConfig agent.ProviderConfig
+ ProviderFallbacks []agent.ProviderEntry
+ Commands *commands.CommandRegistry
+ Engines any
+ Skills *skills.Store
+ SkillDiagnostics []skills.Diagnostic
+ IOAClient protocols.ClientAPI
+ IOAStreamClient ioaclient.StreamAPI
+ enginesReady chan struct{}
+}
+
+func NewApp(ctx context.Context, rc cfg.RuntimeConfig) (*App, error) {
+ a := &App{}
+ logger := rc.Logger
+ if logger == nil {
+ logger = telemetry.NopLogger()
+ }
+
+ store, diagnostics := skills.LoadAll(rc.CLISkillPaths)
+ a.Skills = store
+ a.SkillDiagnostics = diagnostics
+
+ if rc.Provider.Enabled {
+ llmProvider, resolved, err := initProvider(rc.Provider.Config, logger)
+ if err != nil {
+ if !rc.Provider.Optional {
+ return nil, err
+ }
+ logger.Debugf("provider not configured: %s", err)
+ } else {
+ a.Provider = llmProvider
+ a.ProviderConfig = *resolved
+ }
+ for _, fbCfg := range rc.Provider.Fallbacks {
+ fbProvider, fbResolved, err := initProvider(fbCfg, logger)
+ if err != nil {
+ logger.Warnf("fallback provider %s init failed: %s", fbCfg.Provider, err)
+ continue
+ }
+ a.ProviderFallbacks = append(a.ProviderFallbacks, agent.ProviderEntry{
+ Provider: fbProvider,
+ Model: fbResolved.Model,
+ })
+ logger.Infof("fallback provider init provider=%s model=%s", fbResolved.Provider, fbResolved.Model)
+ }
+ }
+
+ a.Commands = initCoreCommands(rc, a.Provider, a.Skills, logger)
+
+ a.enginesReady = make(chan struct{})
+ go func() {
+ if ScannerInitFunc != nil {
+ ScannerInitFunc(ctx, a, rc, logger)
+ }
+ close(a.enginesReady)
+ }()
+
+ if rc.IOA != nil {
+ if err := a.InitIOA(ctx, *rc.IOA); err != nil {
+ a.Close()
+ return nil, err
+ }
+ }
+
+ return a, nil
+}
+
+func (a *App) WaitEngines(ctx context.Context) error {
+ select {
+ case <-a.enginesReady:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func (a *App) Close() {
+ if a == nil {
+ return
+ }
+ if a.Commands != nil {
+ for _, t := range a.Commands.Tools() {
+ if closer, ok := t.(interface{ Close() }); ok {
+ closer.Close()
+ }
+ }
+ for _, cmd := range a.Commands.All() {
+ if closer, ok := cmd.(interface{ Close() }); ok {
+ closer.Close()
+ }
+ }
+ }
+ if closer, ok := a.Engines.(interface{ Close() }); ok {
+ closer.Close()
+ }
+}
+
+func initProvider(provCfg agent.ProviderConfig, logger telemetry.Logger) (agent.Provider, *agent.ProviderConfig, error) {
+ resolved, err := agent.ResolveProvider(&provCfg)
+ if err != nil {
+ return nil, nil, err
+ }
+ logger.Infof("provider init provider=%s model=%s", resolved.Provider, resolved.Model)
+ llmProvider, err := agent.NewProviderFromResolved(resolved)
+ if err != nil {
+ return nil, nil, err
+ }
+ return llmProvider, resolved, nil
+}
+
+func initCoreCommands(rc cfg.RuntimeConfig, llmProvider agent.Provider, skillStore *skills.Store, logger telemetry.Logger) *commands.CommandRegistry {
+ cmdReg := commands.NewRegistry()
+ workDir, _ := os.Getwd()
+ deps := &commands.Deps{
+ WorkDir: workDir,
+ BashTimeout: rc.Tools.BashTimeout,
+ SkillStore: skillStore,
+ Provider: llmProvider,
+ Model: rc.Provider.Config.Model,
+ Logger: logger,
+ TavilyKeys: rc.Tools.TavilyKeys,
+ }
+ commands.BuildGroup("core", deps, cmdReg)
+ commands.BuildGroup("tools", deps, cmdReg)
+ return cmdReg
+}
+
+func executeRegistryCommand(ctx context.Context, reg *commands.CommandRegistry, commandLine string, timeout time.Duration) (string, error) {
+ if timeout <= 0 {
+ return reg.Execute(ctx, commandLine)
+ }
+ stepCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ type result struct {
+ out string
+ err error
+ }
+ done := make(chan result, 1)
+ go func() {
+ out, err := reg.Execute(stepCtx, commandLine)
+ done <- result{out: out, err: err}
+ }()
+
+ select {
+ case r := <-done:
+ return r.out, r.err
+ case <-stepCtx.Done():
+ return "", fmt.Errorf("command timed out after %s: %w", timeout, stepCtx.Err())
+ }
+}
+
+func appendDeepBrowserStep(sb *strings.Builder, name, commandLine, output string, err error) {
+ sb.WriteString("\n## ")
+ sb.WriteString(name)
+ sb.WriteString("\nCommand: `")
+ sb.WriteString(commandLine)
+ sb.WriteString("`\n")
+ if err != nil {
+ sb.WriteString("Error: ")
+ sb.WriteString(err.Error())
+ sb.WriteString("\n")
+ }
+ output = strings.TrimSpace(output)
+ if output != "" {
+ if tr := truncate.Head(output, truncate.Options{}); tr.Truncated {
+ sb.WriteString(tr.Content)
+ sb.WriteString(fmt.Sprintf("\n[step truncated: %d/%d lines]", tr.OutputLines, tr.TotalLines))
+ } else {
+ sb.WriteString(tr.Content)
+ }
+ sb.WriteString("\n")
+ }
+}
+
+func quoteCommandArg(value string) string {
+ if value == "" {
+ return `""`
+ }
+ if !strings.ContainsAny(value, " \t\r\n'\"\\") {
+ return value
+ }
+ value = strings.ReplaceAll(value, `\`, `\\`)
+ value = strings.ReplaceAll(value, `"`, `\"`)
+ return `"` + value + `"`
+}
+
+func (a *App) InitIOA(ctx context.Context, ioa cfg.IOAConfig) error {
+ client, err := newIOAClient(ioa)
+ if err != nil {
+ return err
+ }
+ a.IOAClient = client
+ if streamClient, ok := client.(ioaclient.StreamAPI); ok {
+ a.IOAStreamClient = streamClient
+ }
+ if ioa.RegisterTools && a.Commands != nil {
+ deps := &commands.Deps{
+ IOAClient: client,
+ NodeName: ioa.NodeName,
+ NodeMeta: ioa.NodeMeta,
+ }
+ commands.BuildGroup("ioa", deps, a.Commands)
+ }
+ if ioa.AutoRegister && client != nil && client.NodeID() == "" {
+ type autoRegisterer interface {
+ EnsureRegistered(ctx context.Context, name, description string, meta map[string]any) error
+ }
+ if ar, ok := client.(autoRegisterer); ok {
+ if err := ar.EnsureRegistered(ctx, ioa.NodeName, "", ioa.NodeMeta); err != nil {
+ return err
+ }
+ } else {
+ if _, err := client.RegisterNode(ctx, ioa.NodeName, "", ioa.NodeMeta); err != nil {
+ return err
+ }
+ }
+ }
+ if ioa.Space != "" && client != nil && client.NodeID() != "" {
+ info, err := client.Space(ctx, ioa.Space, "aiscan agent")
+ if err == nil {
+ a.setIOASpace(info.ID)
+ }
+ }
+ return nil
+}
+
+func (a *App) setIOASpace(spaceID string) {
+ for _, cmd := range a.Commands.All() {
+ if setter, ok := cmd.(interface{ SetDefaultSpace(string) }); ok {
+ setter.SetDefaultSpace(spaceID)
+ }
+ }
+}
+
+func newIOAClient(ioa cfg.IOAConfig) (protocols.ClientAPI, error) {
+ if ioa.URL == "" {
+ return nil, nil
+ }
+ return ioaclient.NewClient(ioa.URL, ioa.NodeID)
+}
+
+func CollectDeepBrowserArtifacts(ctx context.Context, reg *commands.CommandRegistry, targetURL string, logger telemetry.Logger) (string, error) {
+ if reg == nil || !reg.Has("playwright") {
+ return "", fmt.Errorf("playwright command unavailable; rebuild web with browser tag")
+ }
+ targetURL = strings.TrimSpace(targetURL)
+ if targetURL == "" {
+ return "", fmt.Errorf("target URL is empty")
+ }
+
+ session := fmt.Sprintf("deep%d", time.Now().UnixNano())
+ closed := false
+ defer func() {
+ if closed {
+ return
+ }
+ closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _, _ = reg.Execute(closeCtx, "playwright close "+session)
+ }()
+
+ script := `(()=>JSON.stringify({url:location.href,title:document.title,forms:[...document.forms].map((f,i)=>({i,action:f.action,method:f.method,inputs:[...f.elements].map(e=>({tag:e.tagName,type:e.type,name:e.name,id:e.id,placeholder:e.placeholder}))})),buttons:[...document.querySelectorAll("button,input[type=button],input[type=submit],a")].slice(0,80).map(e=>({tag:e.tagName,text:(e.innerText||e.value||e.getAttribute("aria-label")||"").trim(),href:e.href||"",type:e.type||"",id:e.id||"",name:e.name||""})),scripts:[...document.scripts].map(s=>s.src).filter(Boolean).slice(0,50),localStorage:Object.keys(localStorage),sessionStorage:Object.keys(sessionStorage)}))()`
+ steps := []struct {
+ name string
+ command string
+ }{
+ {"open", fmt.Sprintf("playwright open %s --session %s --op-timeout 8 --record", quoteCommandArg(targetURL), session)},
+ {"network-start", "playwright network " + session + " --start"},
+ {"reload", "playwright reload " + session},
+ {"wait-idle", "playwright wait-for " + session + " --idle"},
+ {"url", "playwright url " + session},
+ {"discover", "playwright discover " + session},
+ {"text-content", "playwright text-content " + session},
+ {"storage-links-scripts", fmt.Sprintf("playwright evaluate %s %s", session, quoteCommandArg(script))},
+ {"network-dump", "playwright network " + session + " --dump"},
+ }
+
+ const stepTimeout = 12 * time.Second
+ var sb strings.Builder
+ sb.WriteString("Target: ")
+ sb.WriteString(targetURL)
+ sb.WriteString("\nSession: ")
+ sb.WriteString(session)
+ sb.WriteString("\n")
+ for _, step := range steps {
+ if err := ctx.Err(); err != nil {
+ appendDeepBrowserStep(&sb, step.name, step.command, "", err)
+ break
+ }
+ out, err := executeRegistryCommand(ctx, reg, step.command, stepTimeout)
+ appendDeepBrowserStep(&sb, step.name, step.command, out, err)
+ if err != nil && logger != nil {
+ logger.Debugf("deep browser step=%s error=%q", step.name, err)
+ }
+ if err != nil {
+ break
+ }
+ }
+
+ closeCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ out, err := executeRegistryCommand(closeCtx, reg, "playwright close "+session, 8*time.Second)
+ cancel()
+ closed = true
+ appendDeepBrowserStep(&sb, "close", "playwright close "+session, out, err)
+
+ artifact := sb.String()
+ if tr := truncate.Head(artifact, truncate.Options{}); tr.Truncated {
+ artifact = tr.Content + fmt.Sprintf(
+ "\n\n[deep browser truncated: showing %d/%d lines (%s of %s)]",
+ tr.OutputLines, tr.TotalLines, truncate.FormatSize(tr.OutputBytes), truncate.FormatSize(tr.TotalBytes))
+ }
+ return artifact, nil
+}
diff --git a/core/runner/events.go b/core/runner/events.go
new file mode 100644
index 00000000..16d439d1
--- /dev/null
+++ b/core/runner/events.go
@@ -0,0 +1,26 @@
+package runner
+
+import (
+ "github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/core/output"
+)
+
+type eventsFileSubscriber struct {
+ w *output.TimelineWriter
+}
+
+func newEventsFileSubscriber(path string) (*eventsFileSubscriber, error) {
+ tw, err := output.NewTimelineWriter(path)
+ if err != nil {
+ return nil, err
+ }
+ return &eventsFileSubscriber{w: tw}, nil
+}
+
+func (s *eventsFileSubscriber) Close() {
+ _ = s.w.Close()
+}
+
+func (s *eventsFileSubscriber) HandleEvent(event agent.Event) {
+ s.w.WriteJSON(event)
+}
diff --git a/core/runner/events_test.go b/core/runner/events_test.go
new file mode 100644
index 00000000..cf836098
--- /dev/null
+++ b/core/runner/events_test.go
@@ -0,0 +1,207 @@
+package runner
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/chainreactors/aiscan/pkg/agent"
+)
+
+func TestEventsFileSubscriberAppendsJSONL(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "events.jsonl")
+ w, err := newEventsFileSubscriber(path)
+ if err != nil {
+ t.Fatalf("newEventsFileSubscriber() error = %v", err)
+ }
+ defer w.Close()
+
+ content := "spray returned no results"
+ events := []agent.Event{
+ {Type: agent.EventAgentStart},
+ {Type: agent.EventTurnStart, Turn: 1},
+ {
+ Type: agent.EventToolExecutionStart,
+ Turn: 1,
+ ToolName: "bash",
+ Arguments: `{"command":"spray -u http://x"}`,
+ },
+ {
+ Type: agent.EventToolExecutionEnd,
+ Turn: 1,
+ Result: "ok",
+ IsError: false,
+ },
+ {
+ Type: agent.EventMessageEnd,
+ Turn: 1,
+ Message: agent.ChatMessage{
+ Role: "assistant",
+ Content: &content,
+ },
+ },
+ {Type: agent.EventAgentEnd, Turn: 1, Stop: agent.StopReasonCompleted, NewMessages: make([]agent.ChatMessage, 3)},
+ }
+ for _, e := range events {
+ w.HandleEvent(e)
+ }
+
+ f, err := os.Open(path)
+ if err != nil {
+ t.Fatalf("open events file: %v", err)
+ }
+ defer f.Close()
+ scanner := bufio.NewScanner(f)
+ var lines []map[string]any
+ for scanner.Scan() {
+ var m map[string]any
+ if err := json.Unmarshal(scanner.Bytes(), &m); err != nil {
+ t.Fatalf("invalid JSON line %q: %v", scanner.Text(), err)
+ }
+ lines = append(lines, m)
+ }
+ if err := scanner.Err(); err != nil {
+ t.Fatalf("scan events file: %v", err)
+ }
+ if got, want := len(lines), len(events); got != want {
+ t.Fatalf("line count = %d, want %d", got, want)
+ }
+
+ if lines[0]["type"] != string(agent.EventAgentStart) {
+ t.Errorf("line[0].type = %v, want %s", lines[0]["type"], agent.EventAgentStart)
+ }
+ if _, ok := lines[0]["ts"].(string); !ok {
+ t.Errorf("line[0] missing ts field")
+ }
+ if lines[2]["tool_name"] != "bash" {
+ t.Errorf("line[2].tool_name = %v, want bash", lines[2]["tool_name"])
+ }
+ if v, _ := lines[5]["new_messages"].(float64); v != 3 {
+ t.Errorf("line[5].new_messages = %v, want 3", lines[5]["new_messages"])
+ }
+ if v, _ := lines[5]["stop"].(string); v != "completed" {
+ t.Errorf("line[5].stop = %v, want completed", lines[5]["stop"])
+ }
+}
+
+func TestEventsFileSubscriberLargeFieldsPassThrough(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "events.jsonl")
+ w, err := newEventsFileSubscriber(path)
+ if err != nil {
+ t.Fatalf("newEventsFileSubscriber() error = %v", err)
+ }
+ defer w.Close()
+
+ huge := strings.Repeat("a", 20*1024)
+ w.HandleEvent(agent.Event{
+ Type: agent.EventToolExecutionEnd,
+ Result: huge,
+ })
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read file: %v", err)
+ }
+ if !strings.Contains(string(data), huge) {
+ t.Fatalf("expected full result in event log")
+ }
+}
+
+func TestEventsFileSubscriberLLMRequest(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "events.jsonl")
+ w, err := newEventsFileSubscriber(path)
+ if err != nil {
+ t.Fatalf("newEventsFileSubscriber() error = %v", err)
+ }
+ defer w.Close()
+
+ w.HandleEvent(agent.Event{
+ Type: agent.EventLLMRequest,
+ Turn: 1,
+ Request: &agent.ChatCompletionRequest{
+ Model: "deepseek-v4-pro",
+ Messages: make([]agent.ChatMessage, 5),
+ Tools: make([]agent.ToolDefinition, 3),
+ },
+ })
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read file: %v", err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(data, &m); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if v, _ := m["request_model"].(string); v != "deepseek-v4-pro" {
+ t.Errorf("request_model = %v, want deepseek-v4-pro", m["request_model"])
+ }
+ if v, _ := m["request_messages"].(float64); v != 5 {
+ t.Errorf("request_messages = %v, want 5", m["request_messages"])
+ }
+ if v, _ := m["request_tools"].(float64); v != 3 {
+ t.Errorf("request_tools = %v, want 3", m["request_tools"])
+ }
+}
+
+func TestEventsFileSubscriberToolEndNoArgs(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "events.jsonl")
+ w, err := newEventsFileSubscriber(path)
+ if err != nil {
+ t.Fatalf("newEventsFileSubscriber() error = %v", err)
+ }
+ defer w.Close()
+
+ w.HandleEvent(agent.Event{
+ Type: agent.EventToolExecutionEnd,
+ Turn: 1,
+ ToolCallID: "call-1",
+ ToolName: "bash",
+ Result: "ok",
+ })
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read file: %v", err)
+ }
+ if strings.Contains(string(data), "arguments") {
+ t.Errorf("tool_execution_end should not contain arguments field, got: %s", data)
+ }
+}
+
+func TestEventsFileSubscriberErrorField(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "events.jsonl")
+ w, err := newEventsFileSubscriber(path)
+ if err != nil {
+ t.Fatalf("newEventsFileSubscriber() error = %v", err)
+ }
+ defer w.Close()
+
+ w.HandleEvent(agent.Event{
+ Type: agent.EventToolExecutionEnd,
+ Turn: 1,
+ IsError: true,
+ Err: fmt.Errorf("connection refused"),
+ })
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read file: %v", err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(data, &m); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if v, _ := m["error"].(string); v != "connection refused" {
+ t.Errorf("error = %v, want connection refused", m["error"])
+ }
+}
diff --git a/core/runner/hooks.go b/core/runner/hooks.go
new file mode 100644
index 00000000..240608a1
--- /dev/null
+++ b/core/runner/hooks.go
@@ -0,0 +1,24 @@
+package runner
+
+import (
+ "context"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+)
+
+// ScannerInitFunc initializes scanner engines and registers scanner commands.
+// Set via init() from the package imported by cmd/aiscan.
+var ScannerInitFunc func(ctx context.Context, a *App, rc cfg.RuntimeConfig, logger telemetry.Logger)
+
+// ScannerWithAgentFunc runs a scanner command with AI agent assistance.
+// Set via init() from the package imported by cmd/aiscan.
+var ScannerWithAgentFunc func(ctx context.Context, option *cfg.Option, application *App, scannerArgs []string, logger telemetry.Logger) error
+
+// IOAServeFunc starts the IOA HTTP server.
+// Set via init() from cmd/aiscan setup.
+var IOAServeFunc func(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error
+
+// IOAClientCommandFunc dispatches IOA client CLI commands (spaces, messages, etc.).
+// Set via init() from cmd/aiscan setup.
+var IOAClientCommandFunc func(ctx context.Context, mode cfg.RunMode, option *cfg.Option, args cfg.IOAClientArgs, logger telemetry.Logger) error
diff --git a/core/runner/ioa.go b/core/runner/ioa.go
new file mode 100644
index 00000000..ddf132ff
--- /dev/null
+++ b/core/runner/ioa.go
@@ -0,0 +1,38 @@
+package runner
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "strconv"
+ "time"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+)
+
+func RunIOAServe(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error {
+ if IOAServeFunc == nil {
+ return fmt.Errorf("ioa server not available in this build")
+ }
+ return IOAServeFunc(ctx, option, logger)
+}
+
+func RunIOAClientCommand(ctx context.Context, mode cfg.RunMode, option *cfg.Option, args cfg.IOAClientArgs, logger telemetry.Logger) error {
+ if IOAClientCommandFunc == nil {
+ return fmt.Errorf("ioa commands not available in this build")
+ }
+ return IOAClientCommandFunc(ctx, mode, option, args, logger)
+}
+
+func ResolveIOANodeName(option *cfg.Option) string {
+ if option != nil && option.IOANodeName != "" {
+ return option.IOANodeName
+ }
+ var b [4]byte
+ if _, err := rand.Read(b[:]); err == nil {
+ return "aiscan-" + hex.EncodeToString(b[:])
+ }
+ return "aiscan-" + strconv.FormatInt(time.Now().UnixNano(), 36)
+}
diff --git a/core/runner/prompt.go b/core/runner/prompt.go
new file mode 100644
index 00000000..c5cbce96
--- /dev/null
+++ b/core/runner/prompt.go
@@ -0,0 +1,232 @@
+package runner
+
+import (
+ "os"
+ "runtime"
+ "strings"
+ "text/template"
+ "time"
+
+ "github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/skills"
+)
+
+type PromptConfig struct {
+ Tools *commands.CommandRegistry
+ ScannerDocs string
+ CustomPreamble string
+ Skills []skills.Skill
+ LoadedSkills []LoadedSkill // skill body 直接嵌入 prompt
+ ScannerAgentMode bool
+ ScannerName string
+ NodeName string
+ Space string
+}
+
+// LoadedSkill is a skill whose full body is embedded directly into the prompt.
+type LoadedSkill struct {
+ Name string
+ Body string
+}
+
+// SystemPromptFunc returns an agent.SystemPromptFunc that builds the system
+// prompt dynamically on each turn.
+func SystemPromptFunc(cfg *PromptConfig) agent.SystemPromptFunc {
+ return func(agentCfg *agent.Config) string {
+ return BuildSystemPrompt(cfg, agentCfg)
+ }
+}
+
+// promptData is the template context passed to the system prompt template.
+type promptData struct {
+ // Preamble
+ CustomPreamble string
+ ScannerAgentMode bool
+ ScannerName string
+
+ // Environment
+ OS string
+ Time string
+ Hostname string
+ Node string
+ Space string
+ Windows bool
+
+ // Tools
+ Tools []toolEntry
+
+ // Pseudo-commands
+ ScannerDocs string
+
+ // Skills
+ Skills []skillEntry
+
+ // Loaded skills (body embedded)
+ LoadedSkills []loadedSkillEntry
+
+ // Constraints
+ Constraints string
+}
+
+type toolEntry struct {
+ Name string
+ Description string
+}
+
+type skillEntry struct {
+ Name string
+ Description string
+ Location string
+}
+
+type loadedSkillEntry struct {
+ Name string
+ Body string
+}
+
+var systemPromptTemplate = template.Must(template.New("system").Parse(systemPromptText))
+
+const systemPromptText = `{{- if .CustomPreamble -}}
+{{.CustomPreamble}}
+{{- else if .ScannerAgentMode -}}
+You are aiscan's {{.ScannerName}} analysis agent. Execute the requested scanner command using the bash tool, analyze the results, and provide results.
+
+Run scanners with -j flag to get JSON when you need structured data. Without a specific user intent, follow the {{.ScannerName}} skill guidelines to decide what analysis to perform.
+{{- else -}}
+You are aiscan, an autonomous security assessment agent. You have access to the chainreactors scanner toolkit and supporting tools described below. Work autonomously until the user's task is complete.
+
+## Authorization Context
+
+All targets provided to you have been pre-authorized for security testing by the platform operator. You do not need to add disclaimers or ask the user to confirm authorization for each target — focus on executing the assessment and producing actionable results.
+{{- end}}
+
+## Environment
+
+Operating System: {{.OS}}
+Current Time: {{.Time}}
+{{- if .Hostname}}
+Hostname: {{.Hostname}}
+{{- end}}
+{{- if .Node}}
+Node: {{.Node}}
+{{- end}}
+{{- if .Space}}
+Space: {{.Space}}
+{{- end}}
+{{- if .Windows}}
+Shell: cmd.exe — do NOT use Unix shell syntax (2>&1, |, /dev/null). Pseudo-commands run in-process and need no shell redirections.
+{{- end}}
+{{if .Tools}}
+## Available Tools
+{{range .Tools}}
+### {{.Name}}
+{{.Description}}
+{{end}}
+{{- end}}
+{{- if .ScannerDocs}}
+## Pseudo-Commands (IMPORTANT: use the bash tool)
+
+Pseudo-commands are NOT system binaries — they are built into the bash tool. Call the bash tool with the pseudo-command as the "command" parameter.
+
+Example: bash {"command": "scan -i 192.168.1.0/24 --mode quick"}
+
+Available pseudo-commands:
+{{.ScannerDocs}}
+Read the corresponding skill file for detailed usage: ` + "`aiscan://skills//SKILL.md`" + `.
+{{end}}
+{{- if .Skills}}
+## Available Skills
+
+The following skills provide specialized instructions for specific security scanning tasks.
+Use the read tool to load a skill file when the task matches its description.
+When a skill references relative paths, resolve them relative to the skill base directory.
+
+
+{{- range .Skills}}
+
+ {{.Name}}
+ {{.Description}}
+ {{.Location}}
+
+{{- end}}
+
+{{end}}
+{{- range .LoadedSkills}}
+
+## Skill: {{.Name}}
+
+{{.Body}}
+{{- end}}
+
+## Key Principles
+
+- Scanner output is evidence, not proof. Never report "confirmed" without independent verification.
+- Read aiscan://skills/aiscan/SKILL.md for execution rules, output consumption, and triage strategy.
+- Use conservative thread counts and timeouts. When done, stop calling tools and provide results.
+{{- if .Constraints}}
+
+{{.Constraints}}
+{{- end}}
+`
+
+// BuildSystemPrompt assembles the system prompt from config.
+func BuildSystemPrompt(cfg *PromptConfig, agentCfg *agent.Config) string {
+ if cfg == nil {
+ cfg = &PromptConfig{}
+ }
+ tools := cfg.Tools
+ if tools == nil && agentCfg != nil {
+ tools = agentCfg.Tools
+ }
+ if tools == nil {
+ tools = commands.NewRegistry()
+ }
+
+ hostname, _ := os.Hostname()
+
+ data := promptData{
+ CustomPreamble: cfg.CustomPreamble,
+ ScannerAgentMode: cfg.ScannerAgentMode,
+ ScannerName: cfg.ScannerName,
+ OS: runtime.GOOS + "/" + runtime.GOARCH,
+ Time: time.Now().Format(time.RFC3339),
+ Hostname: hostname,
+ Node: cfg.NodeName,
+ Space: cfg.Space,
+ Windows: runtime.GOOS == "windows",
+ ScannerDocs: cfg.ScannerDocs,
+ }
+
+ for _, t := range tools.Tools() {
+ data.Tools = append(data.Tools, toolEntry{Name: t.Name(), Description: t.Description()})
+ }
+
+ for _, s := range cfg.Skills {
+ if !s.Internal {
+ data.Skills = append(data.Skills, skillEntry{
+ Name: s.Name,
+ Description: s.Description,
+ Location: s.Location,
+ })
+ }
+ }
+
+ for _, ls := range cfg.LoadedSkills {
+ if ls.Body != "" {
+ data.LoadedSkills = append(data.LoadedSkills, loadedSkillEntry(ls))
+ }
+ }
+
+ if cfg.ScannerAgentMode {
+ data.Constraints = "## Scanner Agent Constraints\n\n" +
+ "- Execute the scanner command provided in the task via the bash tool.\n" +
+ "- For structured data processing, re-run the scanner with `-j` flag to get JSON output."
+ }
+
+ var sb strings.Builder
+ if err := systemPromptTemplate.Execute(&sb, data); err != nil {
+ return "You are a helpful assistant."
+ }
+ return sb.String()
+}
diff --git a/core/runner/prompt_test.go b/core/runner/prompt_test.go
new file mode 100644
index 00000000..5d4446ca
--- /dev/null
+++ b/core/runner/prompt_test.go
@@ -0,0 +1,83 @@
+package runner
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/skills"
+)
+
+func TestBuildSystemPromptIncludesSkills(t *testing.T) {
+ tools := commands.NewRegistry()
+ loaded, diagnostics := skills.LoadEmbedded()
+ if len(diagnostics) != 0 {
+ t.Fatalf("diagnostics = %#v", diagnostics)
+ }
+
+ prompt := BuildSystemPrompt(&PromptConfig{
+ Tools: tools,
+ Skills: loaded,
+ }, nil)
+ for _, want := range []string{
+ "## Available Skills",
+ "",
+ "aiscan ",
+ "aiscan://skills/aiscan/SKILL.md",
+ } {
+ if !strings.Contains(prompt, want) {
+ t.Fatalf("prompt missing %q:\n%s", want, prompt)
+ }
+ }
+ for _, internal := range []string{"scan", "gogo", "spray", "katana", "fuzz", "zombie", "neutron"} {
+ if strings.Contains(prompt, ""+internal+" ") {
+ t.Fatalf("prompt includes internal skill %q:\n%s", internal, prompt)
+ }
+ }
+}
+
+func TestBuildSystemPromptAllowsNilConfig(t *testing.T) {
+ prompt := BuildSystemPrompt(nil, nil)
+ if !strings.Contains(prompt, "## Environment") {
+ t.Fatalf("prompt missing environment section:\n%s", prompt)
+ }
+ if !strings.Contains(prompt, "## Key Principles") {
+ t.Fatalf("prompt missing principles section:\n%s", prompt)
+ }
+}
+
+func TestSystemPromptFuncAdaptsToTools(t *testing.T) {
+ cfg := &PromptConfig{}
+ fn := SystemPromptFunc(cfg)
+
+ result := fn(nil)
+ if strings.Contains(result, "## Available Tools") {
+ t.Fatal("should not have tools section with empty registry")
+ }
+}
+
+func TestBuildSystemPromptLoadsSkillBody(t *testing.T) {
+ prompt := BuildSystemPrompt(&PromptConfig{
+ LoadedSkills: []LoadedSkill{
+ {Name: "scan/verify", Body: "Verify all high-priority findings with active probing."},
+ {Name: "scan/sniper", Body: "Search public CVEs for fingerprints."},
+ },
+ }, nil)
+
+ for _, want := range []string{
+ "## Skill: scan/verify",
+ "Verify all high-priority findings with active probing.",
+ "## Skill: scan/sniper",
+ "Search public CVEs for fingerprints.",
+ } {
+ if !strings.Contains(prompt, want) {
+ t.Fatalf("prompt missing %q:\n%s", want, prompt)
+ }
+ }
+ // Loaded skills should appear before Key Principles
+ skillIdx := strings.Index(prompt, "## Skill: scan/verify")
+ principlesIdx := strings.Index(prompt, "## Key Principles")
+ if skillIdx > principlesIdx {
+ t.Fatal("loaded skills should appear before principles")
+ }
+}
diff --git a/core/runner/remote_repl.go b/core/runner/remote_repl.go
new file mode 100644
index 00000000..83ec399d
--- /dev/null
+++ b/core/runner/remote_repl.go
@@ -0,0 +1,60 @@
+package runner
+
+import (
+ "context"
+ "fmt"
+ "io"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/agent/tmux"
+ "github.com/chainreactors/aiscan/pkg/tui"
+ "github.com/chainreactors/utils/pty"
+ rlterm "github.com/reeflective/readline/terminal"
+)
+
+func NewRemoteREPLOpener(rt *AgentRuntime, mgr *tmux.Manager) pty.OpenFunc {
+ return func(ctx context.Context, spec pty.OpenSpec) (pty.OpenResult, error) {
+ if rt == nil || rt.App == nil {
+ return pty.OpenResult{}, fmt.Errorf("remote repl requires an agent runtime")
+ }
+ if mgr == nil {
+ return pty.OpenResult{}, fmt.Errorf("pty manager unavailable")
+ }
+ option := rt.Option
+ if option == nil {
+ option = &cfg.Option{}
+ }
+ session := agent.NewAgent(rt.Config.
+ WithSystemPrompt(rt.SystemPrompt).
+ WithStream(tui.AgentStreamingEnabled(option)))
+ appInfo := tui.AppInfo{
+ Provider: rt.App.Provider,
+ ProviderConfig: rt.App.ProviderConfig,
+ ProviderFallbacks: rt.App.ProviderFallbacks,
+ Commands: rt.App.Commands,
+ Skills: rt.App.Skills,
+ OnProviderChange: func(provider agent.Provider, providerConfig agent.ProviderConfig) {
+ rt.App.Provider = provider
+ rt.App.ProviderConfig = providerConfig
+ rt.Config.Provider = provider
+ rt.Config.Model = providerConfig.Model
+ },
+ }
+ control := rlterm.NewControl(true, 80, 24)
+ info, err := mgr.CreateInteractiveFunc(ctx, spec.Name, "aiscan remote repl", pty.DefaultSessionTimeout, false, func(replCtx context.Context, input io.Reader, output io.Writer) error {
+ return tui.RunRemoteAgentConsoleWithControl(replCtx, option, appInfo, session, input, output, control, rt.Bus)
+ })
+ if err != nil {
+ return pty.OpenResult{}, err
+ }
+ mgr.SetKind(info.ID, "repl")
+ info.Kind = "repl"
+ return pty.OpenResult{
+ Info: info,
+ Resize: func(cols, rows int) {
+ control.SetSize(cols, rows)
+ },
+ }, nil
+ }
+}
diff --git a/core/runner/remote_repl_test.go b/core/runner/remote_repl_test.go
new file mode 100644
index 00000000..ab7c279c
--- /dev/null
+++ b/core/runner/remote_repl_test.go
@@ -0,0 +1,119 @@
+package runner
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/pkg/agent/tmux"
+ "github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ "github.com/chainreactors/utils/pty"
+)
+
+func TestRemoteREPLOpenerUsesRuntimeManagerWithoutProvider(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ t.Setenv("AISCAN_REPL", "fast")
+
+ option := &cfg.Option{}
+ rt, err := NewAgentRuntime(ctx, option, telemetry.NopLogger(), &RuntimeConfig{
+ ProviderOptional: true,
+ NoOutput: true,
+ })
+ if err != nil {
+ t.Fatalf("runtime without provider: %v", err)
+ }
+ defer rt.Close()
+
+ mgr := testRegistryPTYManager(rt.App.Commands)
+ if mgr == nil {
+ t.Fatal("pty manager unavailable")
+ }
+
+ messages := make(chan pty.Frame, 64)
+ router := pty.NewRouter(mgr, pty.WithOpener("repl", NewRemoteREPLOpener(rt, mgr)))
+ defer router.Close()
+
+ router.Handle(ctx, pty.Frame{
+ Type: pty.FrameOpen,
+ StreamID: "term-repl",
+ Kind: "repl",
+ Name: "remote-repl-test",
+ }, func(frame pty.Frame) { messages <- frame })
+ waitForFrame(t, messages, time.Second, func(frame pty.Frame) bool {
+ if frame.Type == pty.FrameError {
+ t.Fatalf("unexpected pty error: %s", frame.Error)
+ }
+ return frame.Type == pty.FrameOpened
+ })
+
+ router.Handle(ctx, pty.Frame{Type: pty.FrameInput, StreamID: "term-repl", Data: []byte("/status\n")}, func(frame pty.Frame) {
+ messages <- frame
+ })
+ waitForFrame(t, messages, 3*time.Second, func(frame pty.Frame) bool {
+ if frame.Type == pty.FrameError {
+ t.Fatalf("unexpected pty error: %s", frame.Error)
+ }
+ return frame.Type == pty.FrameOutput && strings.Contains(string(frame.Data), "not configured")
+ })
+
+ router.Handle(ctx, pty.Frame{Type: pty.FrameInput, StreamID: "term-repl", Data: []byte("!tmux new-session -d -s webtask echo tmux_remote_ok\n")}, func(frame pty.Frame) {
+ messages <- frame
+ })
+ waitForCondition(t, 3*time.Second, func() bool {
+ for _, info := range mgr.List() {
+ if info.Name == "webtask" {
+ return true
+ }
+ }
+ return false
+ })
+}
+
+func testRegistryPTYManager(reg *commands.CommandRegistry) *tmux.Manager {
+ if reg == nil {
+ return nil
+ }
+ tool, ok := reg.GetTool("bash")
+ if !ok {
+ return nil
+ }
+ manager, ok := tool.(interface {
+ Manager() *tmux.Manager
+ })
+ if !ok {
+ return nil
+ }
+ return manager.Manager()
+}
+
+func waitForCondition(t *testing.T, timeout time.Duration, predicate func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for !predicate() {
+ if time.Now().After(deadline) {
+ t.Fatalf("condition not met within %s", timeout)
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+}
+
+func waitForFrame(t *testing.T, ch <-chan pty.Frame, timeout time.Duration, match func(pty.Frame) bool) pty.Frame {
+ t.Helper()
+ deadline := time.After(timeout)
+ for {
+ select {
+ case frame := <-ch:
+ if match(frame) {
+ return frame
+ }
+ case <-deadline:
+ t.Fatalf("timeout waiting for matching frame")
+ return pty.Frame{}
+ }
+ }
+}
diff --git a/core/runner/runner.go b/core/runner/runner.go
new file mode 100644
index 00000000..241305e1
--- /dev/null
+++ b/core/runner/runner.go
@@ -0,0 +1,477 @@
+package runner
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/core/eventbus"
+ "github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/agent/evaluator"
+ inboxpkg "github.com/chainreactors/aiscan/pkg/agent/inbox"
+ tmuxpkg "github.com/chainreactors/aiscan/pkg/agent/tmux"
+ cmdpkg "github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ "github.com/chainreactors/aiscan/pkg/tools/toolargs"
+ "github.com/chainreactors/aiscan/pkg/tui"
+ "github.com/chainreactors/aiscan/skills"
+)
+
+// ---------------------------------------------------------------------------
+// AgentRuntime — unified factory for all agent execution modes
+// ---------------------------------------------------------------------------
+
+type AgentRuntime struct {
+ App *App
+ NodeName string
+ SystemPrompt string
+ Option *cfg.Option
+ Config agent.Config
+ Bus *eventbus.Bus[agent.Event]
+ Output *tui.AgentOutput
+ ConfigFile string
+ ownsApp bool
+ cleanup func()
+}
+
+type RuntimeConfig struct {
+ ExistingApp *App
+ IOA *cfg.IOAConfig
+ PromptConfig *PromptConfig
+ NoOutput bool
+ ProviderOptional bool
+}
+
+func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.Logger, rc *RuntimeConfig) (*AgentRuntime, error) {
+ rt := &AgentRuntime{}
+ if option != nil {
+ optCopy := *option
+ rt.Option = &optCopy
+ rt.ConfigFile = option.ConfigFile
+ }
+
+ if rc != nil && rc.ExistingApp != nil {
+ rt.App = rc.ExistingApp
+ } else {
+ providerOptional := rc != nil && (rc.IOA != nil || rc.ProviderOptional)
+ appCfg := cfg.AppConfig(option, cfg.RuntimeFeatures{
+ ProviderEnabled: true,
+ ProviderOptional: providerOptional,
+ ToolsEnabled: true,
+ AIEnabled: true,
+ }, logger)
+ if rc != nil && rc.IOA != nil {
+ appCfg.IOA = rc.IOA
+ }
+ application, err := NewApp(ctx, appCfg)
+ if err != nil {
+ return nil, fmt.Errorf("init app: %w", err)
+ }
+ rt.App = application
+ rt.ownsApp = true
+ cfg.ApplyResolvedProviderOptions(option, application.ProviderConfig)
+
+ for _, d := range application.SkillDiagnostics {
+ logger.Warnf("skill %s: %s", d.Path, d.Message)
+ }
+
+ if rc == nil || rc.IOA == nil {
+ if err := registerIOATools(ctx, application, option); err != nil {
+ application.Close()
+ return nil, fmt.Errorf("init ioa tools: %w", err)
+ }
+ }
+ }
+
+ nodeName := ResolveIOANodeName(option)
+ rt.NodeName = nodeName
+
+ pc := &PromptConfig{
+ Tools: rt.App.Commands,
+ ScannerDocs: rt.App.Commands.UsageDocs(),
+ Skills: rt.App.Skills.Skills,
+ NodeName: nodeName,
+ Space: option.Space,
+ }
+ for _, name := range option.Skills {
+ body := rt.App.Skills.ReadBody(name)
+ if body == "" {
+ body = skills.ReadFile("skills/" + name + ".md")
+ }
+ if body == "" {
+ body = skills.ReadFile(name)
+ }
+ if body != "" {
+ pc.LoadedSkills = append(pc.LoadedSkills, LoadedSkill{Name: name, Body: body})
+ }
+ }
+ if rc != nil && rc.PromptConfig != nil {
+ pc = rc.PromptConfig
+ }
+ rt.SystemPrompt = BuildSystemPrompt(pc, nil)
+ logger.Debugf("system prompt length: %d chars", len(rt.SystemPrompt))
+
+ if rc == nil || !rc.NoOutput {
+ rt.Output = tui.NewAgentOutput(option)
+ }
+
+ agentBus := eventbus.New[agent.Event]()
+ if rt.Output != nil {
+ agentBus.Subscribe(rt.Output.HandleEvent)
+ }
+ var eventsCloser func()
+ if eventsPath := os.Getenv("AISCAN_EVENTS_FILE"); eventsPath != "" {
+ w, err := newEventsFileSubscriber(eventsPath)
+ if err != nil {
+ logger.Warnf("events file: %s", err)
+ } else {
+ unsub := agentBus.Subscribe(w.HandleEvent)
+ eventsCloser = func() { unsub(); w.Close() }
+ }
+ }
+ rt.Bus = agentBus
+
+ ib := inboxpkg.NewBuffered(agent.DefaultInboxCapacity)
+
+ sessMgr, bashTool := bashToolAndManager(rt.App.Commands)
+ if bashTool != nil {
+ bashTool.SetInbox(ib)
+ }
+ if sessMgr != nil {
+ sessMgr.SetOnDone(func(info tmuxpkg.Info) {
+ tail := sessMgr.PeekOrEmpty(info.ID, 20)
+ msg := inboxpkg.NewMessage(inboxpkg.OriginSession, "user",
+ tmuxpkg.FormatCompletion(info, tail))
+ msg.Meta = map[string]any{
+ "session_id": info.ID,
+ "session_name": info.Name,
+ "exit_code": info.ExitCode,
+ }
+ if err := ib.Push(msg); err != nil {
+ logger.Warnf("inbox push session completion: %s", err)
+ }
+ })
+ }
+
+ scheduler := agent.NewLoopScheduler(ib, logger)
+
+ if option.Heartbeat > 0 {
+ _ = scheduler.Add(ctx, agent.LoopEntry{
+ Name: "heartbeat",
+ Interval: time.Duration(option.Heartbeat) * time.Minute,
+ Mode: agent.ModeInbox,
+ Prompt: "Heartbeat: review current context, check on any running sessions, and decide if action is needed.",
+ })
+ }
+
+ rt.Config = agent.Config{
+ Provider: rt.App.Provider,
+ Fallbacks: rt.App.ProviderFallbacks,
+ Tools: rt.App.Commands,
+ Model: option.Model,
+ Logger: logger,
+ Inbox: ib,
+ LoopScheduler: scheduler,
+ CacheRetention: agent.CacheShort,
+ Bus: agentBus,
+ }
+
+ parentAgent := agent.NewAgent(rt.Config)
+ subAgentTool := agent.NewSubAgentTool(parentAgent, ib, func(name string) (agent.AgentType, error) {
+ if rt.App.Skills == nil {
+ return agent.AgentType{}, fmt.Errorf("agent type %q not found", name)
+ }
+ s, ok := rt.App.Skills.ByName(name)
+ if !ok {
+ return agent.AgentType{}, fmt.Errorf("agent type %q not found", name)
+ }
+ if !s.Agent {
+ return agent.AgentType{}, fmt.Errorf("skill %q is not configured as an agent type", name)
+ }
+ return agent.AgentType{
+ FormattedPrompt: rt.App.Skills.FormatInvocation(s, ""),
+ Model: s.AgentModel,
+ Background: s.AgentBackground,
+ }, nil
+ })
+ rt.App.Commands.RegisterTool(subAgentTool)
+
+ rt.cleanup = func() {
+ scheduler.Stop()
+ if sessMgr != nil {
+ sessMgr.Shutdown()
+ }
+ if eventsCloser != nil {
+ eventsCloser()
+ }
+ }
+
+ return rt, nil
+}
+
+func (rt *AgentRuntime) Close() {
+ if rt.cleanup != nil {
+ rt.cleanup()
+ }
+ if rt.ownsApp && rt.App != nil {
+ rt.App.Close()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Mode dispatch
+// ---------------------------------------------------------------------------
+
+func RunAgentMode(ctx context.Context, option *cfg.Option, logger telemetry.Logger, setInterrupt ...func(func() bool)) error {
+ var si func(func() bool)
+ if len(setInterrupt) > 0 {
+ si = setInterrupt[0]
+ }
+ if !cfg.HasAgentOneShotInput(option) {
+ return runInteractiveMode(ctx, option, logger, si)
+ }
+ return runOneShotMode(ctx, option, logger)
+}
+
+// ---------------------------------------------------------------------------
+// Agent one-shot
+// ---------------------------------------------------------------------------
+
+func runOneShotMode(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error {
+ task, err := cfg.ResolveTask(option)
+ if err != nil {
+ return err
+ }
+
+ rt, err := NewAgentRuntime(ctx, option, logger, nil)
+ if err != nil {
+ return err
+ }
+ defer rt.Close()
+
+ task = skills.ExpandCommand(task, rt.App.Skills)
+ task, err = cfg.ApplySelectedSkills(task, option.Skills, rt.App.Skills)
+ if err != nil {
+ return err
+ }
+
+ rt.Output.Start("task", task)
+
+ a := agent.NewAgent(rt.Config.
+ WithSystemPrompt(rt.SystemPrompt).
+ WithStream(tui.AgentStreamingEnabled(option)))
+
+ var result *agent.Result
+ if option.EvalCriteria != "" {
+ evalCfg := buildEvalConfig(option, rt, logger, task)
+ result, _, err = evaluator.RunWithEval(ctx, a, evalCfg)
+ } else {
+ result, err = a.Run(ctx, task)
+ }
+ if err != nil {
+ return err
+ }
+ if result != nil && strings.TrimSpace(result.Output) != "" {
+ rt.Output.Final(result.Output)
+ }
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Agent interactive (REPL)
+// ---------------------------------------------------------------------------
+
+func runInteractiveMode(ctx context.Context, option *cfg.Option, logger telemetry.Logger, setInterrupt func(func() bool)) error {
+ rt, err := NewAgentRuntime(ctx, option, logger, nil)
+ if err != nil {
+ return err
+ }
+ defer rt.Close()
+
+ if _, err := cfg.ApplySelectedSkills("", option.Skills, rt.App.Skills); err != nil {
+ return err
+ }
+
+ session := agent.NewAgent(rt.Config.
+ WithSystemPrompt(rt.SystemPrompt).
+ WithStream(tui.AgentStreamingEnabled(option)))
+
+ repl := tui.NewAgentConsole(ctx, option, tui.AppInfo{
+ Provider: rt.App.Provider,
+ ProviderConfig: rt.App.ProviderConfig,
+ ProviderFallbacks: rt.App.ProviderFallbacks,
+ Commands: rt.App.Commands,
+ Skills: rt.App.Skills,
+ }, session, rt.Output, rt.Bus)
+ if setInterrupt != nil {
+ setInterrupt(repl.InterruptCurrentRun)
+ }
+ return repl.Start()
+}
+
+// ---------------------------------------------------------------------------
+// Scanner direct execution
+// ---------------------------------------------------------------------------
+
+func RunDirectScannerMode(ctx context.Context, option *cfg.Option, rest []string, logger telemetry.Logger) error {
+ features, scannerArgs, err := DirectScannerRuntimeFeatures(rest)
+ if err != nil {
+ return err
+ }
+ if features.Warning != "" && !option.Quiet {
+ fmt.Fprintf(os.Stderr, "warning: %s\n", features.Warning)
+ }
+ if option.AI || features.ScannerAI {
+ features.ProviderEnabled = true
+ features.ProviderOptional = false
+ features.ToolsEnabled = true
+ features.AIEnabled = true
+ }
+ if cfg.IsScannerHelpRequest(scannerArgs) {
+ if usage, ok := cfg.StaticScannerUsage(scannerArgs[0]); ok {
+ fmt.Print(usage)
+ if !strings.HasSuffix(usage, "\n") {
+ fmt.Println()
+ }
+ return nil
+ }
+ }
+
+ scannerLogger := logger
+ if !directScannerDebugEnabled(option, scannerArgs) {
+ scannerLogger = telemetry.ErrorOnlyLogger(logger)
+ restoreLogs := telemetry.SuppressGlobalNonErrors()
+ defer restoreLogs()
+ }
+
+ application, err := NewApp(ctx, cfg.AppConfig(option, features, scannerLogger))
+ if err != nil {
+ return fmt.Errorf("init app: %w", err)
+ }
+ defer application.Close()
+ if err := application.WaitEngines(ctx); err != nil {
+ return fmt.Errorf("engine init: %w", err)
+ }
+ cfg.ApplyResolvedProviderOptions(option, application.ProviderConfig)
+
+ if !application.Commands.Has(scannerArgs[0]) {
+ return fmt.Errorf("unknown subcommand: %s", scannerArgs[0])
+ }
+ if option.Debug && scannerCommandSupportsDebug(scannerArgs[0]) && !toolargs.BoolFlagEnabled(scannerArgs[1:], "--debug") {
+ scannerArgs = append(scannerArgs, "--debug")
+ }
+
+ if option.AI && scannerArgs[0] != "scan" {
+ if ScannerWithAgentFunc == nil {
+ return fmt.Errorf("scanner agent mode not available in this build")
+ }
+ return ScannerWithAgentFunc(ctx, option, application, scannerArgs, logger)
+ }
+
+ if option.NoColor && scannerArgs[0] == "scan" && !HasScannerFlag(scannerArgs[1:], "--no-color") {
+ scannerArgs = append(scannerArgs, "--no-color")
+ }
+ var stream io.Writer
+ streaming := ShouldStreamScannerOutput(scannerArgs)
+ if streaming {
+ stream = os.Stdout
+ }
+ out, err := application.Commands.ExecuteArgsStreaming(ctx, scannerArgs, stream)
+ if err != nil {
+ return err
+ }
+ if !streaming {
+ fmt.Print(out)
+ }
+ return nil
+}
+
+func directScannerDebugEnabled(option *cfg.Option, scannerArgs []string) bool {
+ if option != nil && option.Debug {
+ return true
+ }
+ if len(scannerArgs) == 0 || !scannerCommandSupportsDebug(scannerArgs[0]) {
+ return false
+ }
+ return toolargs.BoolFlagEnabled(scannerArgs[1:], "--debug")
+}
+
+func scannerCommandSupportsDebug(name string) bool {
+ switch name {
+ case "scan", "gogo", "spray", "zombie", "neutron":
+ return true
+ default:
+ return false
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Evaluation
+// ---------------------------------------------------------------------------
+
+func buildEvalConfig(option *cfg.Option, rt *AgentRuntime, logger telemetry.Logger, task string) evaluator.EvalLoopConfig {
+ model := option.Model
+ if option.EvalModel != "" {
+ model = option.EvalModel
+ }
+ maxRounds := option.EvalMaxRetries
+ if maxRounds <= 0 {
+ maxRounds = 3
+ }
+ return evaluator.EvalLoopConfig{
+ Evaluator: evaluator.New(evaluator.Config{
+ Provider: rt.App.Provider,
+ Model: model,
+ Logger: logger,
+ }),
+ MaxEvalRounds: maxRounds,
+ Goal: task,
+ Criteria: option.EvalCriteria,
+ Bus: rt.Bus,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+func registerIOATools(ctx context.Context, application *App, option *cfg.Option) error {
+ ioaURL := option.IOAURL
+ if ioaURL == "" {
+ return nil
+ }
+ ioaCfg := cfg.IOAConfig{
+ URL: ioaURL,
+ NodeID: option.IOANodeID,
+ NodeName: option.IOANodeName,
+ Space: option.Space,
+ RegisterTools: true,
+ AutoRegister: true,
+ NodeMeta: map[string]any{"client": "aiscan"},
+ }
+ if ioaCfg.NodeName == "" {
+ ioaCfg.NodeName = ResolveIOANodeName(option)
+ }
+ return application.InitIOA(ctx, ioaCfg)
+}
+
+func bashToolAndManager(reg interface {
+ GetTool(string) (cmdpkg.AgentTool, bool)
+}) (*tmuxpkg.Manager, *cmdpkg.BashTool) {
+ if reg == nil {
+ return nil, nil
+ }
+ tool, ok := reg.GetTool("bash")
+ if !ok {
+ return nil, nil
+ }
+ bt, ok := tool.(*cmdpkg.BashTool)
+ if !ok {
+ return nil, nil
+ }
+ return bt.Manager(), bt
+}
diff --git a/core/runner/scanner.go b/core/runner/scanner.go
new file mode 100644
index 00000000..5608e181
--- /dev/null
+++ b/core/runner/scanner.go
@@ -0,0 +1,169 @@
+package runner
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/chainreactors/aiscan/core/config"
+)
+
+func DirectScannerRuntimeFeatures(rest []string) (config.RuntimeFeatures, []string, error) {
+ if len(rest) == 0 {
+ return config.RuntimeFeatures{}, nil, fmt.Errorf("missing scanner command")
+ }
+ if rest[0] != "scan" {
+ return config.RuntimeFeatures{}, rest, nil
+ }
+ verifyMode, explicit := scannerVerifyMode(rest[1:])
+ sniperEnabled := HasScannerFlag(rest[1:], "--sniper")
+ deepEnabled := HasScannerFlag(rest[1:], "--deep")
+ aiSkillRequested := sniperEnabled || deepEnabled
+
+ features := config.RuntimeFeatures{}
+
+ if aiSkillRequested {
+ features.ProviderEnabled = true
+ features.ProviderOptional = false
+ features.AIEnabled = true
+ features.ScannerAI = true
+ }
+
+ switch verifyMode {
+ case "auto":
+ features.ProviderEnabled = true
+ if !aiSkillRequested {
+ features.ProviderOptional = true
+ }
+ features.AIEnabled = true
+ features.ScannerAI = explicit || aiSkillRequested
+ return features, removeScannerFlag(rest, "--verify"), nil
+ case "off":
+ if explicit {
+ return features, replaceOrAppendScannerFlag(rest, "--verify", "off"), nil
+ }
+ return features, rest, nil
+ case "low", "medium", "high", "critical":
+ features.ProviderEnabled = true
+ if !aiSkillRequested {
+ features.ProviderOptional = !explicit
+ }
+ features.AIEnabled = true
+ features.ScannerAI = explicit || aiSkillRequested
+ return features, rest, nil
+ default:
+ if explicit {
+ return config.RuntimeFeatures{}, nil, fmt.Errorf("invalid --verify value %q: expected auto, off, low, medium, high, or critical", verifyMode)
+ }
+ return features, rest, nil
+ }
+}
+
+func HasScannerFlag(args []string, long string) bool {
+ for _, arg := range args {
+ if arg == long || strings.HasPrefix(arg, long+"=") {
+ return true
+ }
+ }
+ return false
+}
+
+func ShouldStreamScannerOutput(rest []string) bool {
+ if len(rest) == 0 || rest[0] != "scan" {
+ return false
+ }
+ if isDirectScannerJSONOutput(rest) {
+ return false
+ }
+ for _, arg := range rest[1:] {
+ if arg == "--report" {
+ return false
+ }
+ if strings.HasPrefix(arg, "--report=") {
+ value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--report=")))
+ if value != "false" && value != "0" && value != "no" {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func isDirectScannerJSONOutput(rest []string) bool {
+ if len(rest) == 0 || !config.ScannerCommandAvailable(rest[0]) {
+ return false
+ }
+ for _, arg := range rest[1:] {
+ if arg == "-j" || arg == "--json" {
+ return true
+ }
+ if strings.HasPrefix(arg, "--json=") {
+ value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--json=")))
+ return value != "false" && value != "0" && value != "no"
+ }
+ }
+ return false
+}
+
+func scannerVerifyMode(args []string) (string, bool) {
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ key, value, hasValue := strings.Cut(arg, "=")
+ if key != "--verify" {
+ continue
+ }
+ if hasValue {
+ return strings.ToLower(strings.TrimSpace(value)), true
+ }
+ if i+1 < len(args) {
+ return strings.ToLower(strings.TrimSpace(args[i+1])), true
+ }
+ return "", true
+ }
+ return defaultVerifyMode(), false
+}
+
+func replaceOrAppendScannerFlag(args []string, flag, value string) []string {
+ out := append([]string(nil), args...)
+ for i := 1; i < len(out); i++ {
+ arg := out[i]
+ key, _, hasValue := strings.Cut(arg, "=")
+ if key != flag {
+ continue
+ }
+ if hasValue {
+ out[i] = flag + "=" + value
+ return out
+ }
+ if i+1 < len(out) {
+ out[i+1] = value
+ return out
+ }
+ out = append(out, value)
+ return out
+ }
+ return append(out, flag+"="+value)
+}
+
+func defaultVerifyMode() string {
+ value := strings.ToLower(strings.TrimSpace(config.DefaultVerify))
+ if value == "" {
+ return "off"
+ }
+ return value
+}
+
+func removeScannerFlag(args []string, flag string) []string {
+ out := make([]string, 0, len(args))
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ key, _, hasValue := strings.Cut(arg, "=")
+ if key != flag {
+ out = append(out, arg)
+ continue
+ }
+ if !hasValue && i+1 < len(args) {
+ i++
+ }
+ }
+ return out
+}
diff --git a/docs/agent.md b/docs/agent.md
new file mode 100644
index 00000000..9316bc2e
--- /dev/null
+++ b/docs/agent.md
@@ -0,0 +1,568 @@
+# Agent 模式详解
+
+本文档基于 `v0.2.2` 源码编写,是 Agent 模式的完整参考。基本用法参见 [README](../README.md),LLM Provider 配置参见 [参考手册](reference.md)。
+
+标记 ★ 的功能为 v0.2.2 新增。
+
+---
+
+## 目录
+
+- [运行模式](#运行模式)
+- [One-shot 模式](#one-shot-模式)
+- [Goal Evaluation](#goal-evaluation)
+- [交互式 REPL](#交互式-repl)
+- [Agent 工具集](#agent-工具集)
+- [--ai 模式](#--ai-模式)
+- [Skills](#skills)
+- [信号处理](#信号处理)
+- [多 Provider 降级](#多-provider-降级)
+- [适用场景](#适用场景)
+
+---
+
+## 运行模式
+
+`aiscan agent` 根据输入自动选择三种运行模式之一:
+
+| 条件 | 模式 | 行为 |
+| --- | --- | --- |
+| 提供 `-p`、`--task-file`、`-i` 或 stdin pipe | **One-shot** | 执行任务后退出 |
+| 指定 `--ioa-url` | **IOA worker** | 连接 IOA server,注册节点,监听并执行任务 |
+| 无任何输入 | **交互式 REPL** | 进入交互命令行,支持会话保持和连续追问 |
+
+判断逻辑:`--ioa-url` 优先级最高;其次检查是否存在任务输入(prompt、目标、文件、stdin);均不满足时进入 REPL。
+
+---
+
+## One-shot 模式
+
+One-shot 模式接收一次性任务,agent 执行完成后自动退出。
+
+### 输入方式
+
+| 方式 | 参数 | 说明 |
+| --- | --- | --- |
+| 自然语言 prompt | `-p, --prompt` | 任务描述 |
+| 目标 | `-i, --input` | IP、URL、IP:port、CIDR,可重复 |
+| 任务文件 | `--task-file` | 从文件读取任务描述(支持 Markdown) |
+| 指定 skill | `-s, --skill` | 加载指定 skill,可重复 |
+| stdin | 管道输入 | 从标准输入读取任务描述 |
+
+输入可以组合使用。仅提供 `-i` 时,agent 会自动生成扫描任务。
+
+### 示例
+
+```bash
+# 基本用法
+aiscan agent -p "发现 Web 服务并检查高风险漏洞,给出可复现证据" -i 192.168.1.0/24
+
+# 多个目标
+aiscan agent -p "枚举服务并输出风险摘要" -i 10.0.0.10 -i http://10.0.0.20
+
+# 从文件读取任务
+aiscan agent --task-file task.md -i 192.168.1.0/24
+
+# 仅提供目标(自动生成扫描任务)
+aiscan agent -i http://target.example
+
+# 指定 skill
+aiscan agent -s scan -s neutron -p "先做快速扫描,再分析高危 POC 命中" -i http://target.example
+
+# 从 stdin 读取任务
+echo "检查这个网段的暴露面" | aiscan agent -i 192.168.1.0/24
+```
+
+---
+
+## Goal Evaluation
+
+★ v0.2.2 新增。
+
+Goal Evaluation 让一个独立的评估 LLM 在 agent 完成任务后判定是否达成目标。如果未通过,评估反馈会被注入 agent 继续执行,直到通过或达到最大重试轮数。
+
+### 启用方式
+
+```bash
+# One-shot 模式
+aiscan agent -p "检查目标 Web 漏洞" -i http://target.example -e "必须给出至少一个可复现的漏洞证据,包含请求和响应"
+
+# 交互式 REPL
+aiscan> /eval 必须包含完整的端口列表和风险等级
+aiscan> 扫描 192.168.1.0/24
+```
+
+| 参数 | 说明 |
+| --- | --- |
+| `-e, --eval` | 指定评估标准(自然语言) |
+| `/eval ` | REPL 中设置评估标准 |
+| `/eval` | REPL 中查看当前评估标准 |
+| `/eval off` | REPL 中关闭评估 |
+
+### 机制
+
+1. Agent 完成一次运行后,评估器将执行轨迹压缩为结构化摘要(工具调用序列 + assistant 摘要 + 最终输出,最大 16KB),连同评估标准一起发送给评估 LLM
+2. 评估 LLM 通过强制工具调用(verdict tool)返回结构化判定:
+
+```json
+{
+ "pass": false,
+ "reason": "报告中缺少请求和响应的原始数据",
+ "feedback": "请补充漏洞验证的完整 HTTP 请求和响应内容"
+}
+```
+
+3. 如果 `pass=false`,feedback 被注入为新 prompt,agent 继续执行
+4. 循环直到 `pass=true` 或达到最大 3 轮
+5. 所有轮次用尽仍未通过时,返回最后一次执行结果(不报错)
+
+### 评估器容错
+
+评估 LLM 调用失败时不会中断主流程。系统降级为通用反馈:
+
+> Goal evaluation could not determine if the task is complete. Original criteria: {criteria}. Please review your work and continue if the goal is not yet fully achieved.
+
+agent 收到此反馈后继续执行,不会因为评估器问题而停止。
+
+### 事件
+
+评估过程通过 eventbus 发布以下事件:
+
+| 事件 | 时机 | 携带数据 |
+| --- | --- | --- |
+| `GoalEvalStart` | 开始评估 | `EvalRound` |
+| `GoalEvalEnd` | 评估完成 | `EvalRound`, `EvalPass`, `EvalReason` |
+| `GoalEvalError` | 评估器调用失败 | `EvalRound`, `EvalError` |
+
+### 示例
+
+```bash
+# 要求输出格式和内容的评估
+aiscan agent -p "扫描目标所有端口并识别服务" -i 10.0.0.0/24 \
+ -e "输出必须包含每个开放端口的服务名称和版本号,使用表格格式"
+
+# 要求漏洞验证深度的评估
+aiscan agent -p "检查 Web 应用漏洞" -i http://target.example \
+ -e "每个发现的漏洞必须附带可复现的 curl 命令"
+
+# REPL 中动态启用/关闭
+aiscan> /eval 扫描结果必须覆盖 top100 端口
+aiscan> 扫描 192.168.1.1
+aiscan> /eval off
+```
+
+---
+
+## 交互式 REPL
+
+无任何输入时进入交互式 REPL。支持命令历史、补全,会话上下文在 `/reset` 前保留。
+
+```bash
+aiscan agent --model gpt-4o
+```
+
+### 命令列表
+
+#### 内置命令
+
+| 命令 | 说明 |
+| --- | --- |
+| `/help` | 显示命令面板 |
+| `/status` | 查看当前模型、渲染模式、IOA 连接和已加载 skill |
+| `/reset` | 清空会话上下文 |
+| `/continue` | 不追加新 prompt,让 agent 继续当前上下文 |
+| `/stop` | 停止当前正在执行的任务 |
+| `/followup ` | 排队消息,等当前任务完成后自动发送 |
+| `/eval [criteria\|off]` | 设置/查看/关闭 Goal Evaluation ★ |
+| `/exit`, `/quit` | 退出 |
+
+#### Provider 命令 ★
+
+| 命令 | 说明 |
+| --- | --- |
+| `/provider` | 查看 LLM Provider 链状态(active/standby) |
+| `/provider list` | 列出所有配置的 provider 及其状态 |
+
+#### IOA 命令(需 `--ioa-url`)
+
+| 命令 | 说明 |
+| --- | --- |
+| `/spaces` | 列出所有 IOA 空间 |
+| `/messages ` | 列出空间中的起始消息 |
+| `/context ` | 查看消息上下文/线程 |
+| `/nodes [space]` | 列出节点 |
+
+#### Skill 命令
+
+每个已注册的非 internal skill 自动成为 REPL 命令:
+
+```text
+aiscan> /scan 检查这个网段的高危漏洞
+aiscan> /neutron 用 critical 级别 POC 检查 http://target.example
+aiscan> /report 根据上次扫描结果生成报告
+```
+
+#### `!` 直接执行 ★
+
+`!` 前缀直接执行命令,绕过 LLM。所有注册的 scanner 伪命令和 shell 命令均可使用,支持 Ctrl+C / Escape 取消。
+
+```text
+aiscan> !gogo -i 192.168.1.0/24 -p top100
+aiscan> !scan -i http://target.example
+aiscan> !cyberhub list poc --severity critical
+aiscan> !neutron -u http://target.example -s high
+```
+
+输入普通文本(非 `/` 或 `!` 开头)直接作为 prompt 发送给 agent。
+
+---
+
+## Agent 工具集
+
+Agent 在运行时可使用以下工具,由 LLM 自主选择调用。
+
+### Agent 工具(LLM 直接调用)
+
+| 工具 | 说明 | 备注 |
+| --- | --- | --- |
+| `bash` | 执行 shell 命令(通过 tmux PTY 运行) | 核心工具 |
+| `read` | 读取文件内容 | 核心工具 |
+| `write` | 写入文件内容 | 核心工具 |
+| `glob` | 文件模式匹配搜索 | 核心工具 |
+| `web_search` ★ | Web 搜索,查询 CVE/Exploit/安全情报 | 优先使用 provider 原生搜索,回退 Tavily |
+| `fetch` | 抓取 URL 内容为可读文本 | |
+| `finish` ★ | 显式终止 agent 循环(`ToolResult.Terminate`) | 终止工具 |
+| `subagent` | 创建子 agent(sync 同步 / async 异步 / fork 分支) | |
+
+### Scanner 伪命令(通过 bash 工具调用)
+
+| 命令 | 说明 |
+| --- | --- |
+| `gogo` | 主机存活、端口、服务、banner 和指纹发现 |
+| `spray` | Web 探测、HTTP 指纹、路径检查、爬取 |
+| `zombie` | 弱口令检测 |
+| `neutron` | 模板化 POC 检测 |
+| `scan` | 自动扫描流水线 |
+| `cyberhub` ★ | 指纹和 POC 关联查询(基于 SDK association index 重构,支持 `--finger`/`--cve`/`--vendor`/`--product`/`--poc` 结构化查询) |
+| `katana` | Web 爬虫(仅 full 版) |
+| `passive` | 网络空间搜索(仅 full 版) |
+
+### tmux — 后台会话管理
+
+tmux 是 agent 管理长时间运行命令的核心工具。bash 工具执行的命令如果超时会自动转入 tmux 后台会话,增量输出每 10 秒自动推送到 agent inbox。
+
+| 子命令 | 说明 |
+| --- | --- |
+| `tmux new-session [-d] [-s name] [--timeout duration] "command"` | 创建会话。`-d` 后台运行,`-s` 指定名称 |
+| `tmux ls` | 列出所有会话及状态 |
+| `tmux capture-pane -t ` | 读取新增输出(默认增量模式,仅返回上次读取后的新内容) |
+| `tmux capture-pane -t -n ` | 读取末尾 N 行 |
+| `tmux capture-pane -t -c ` | 读取末尾 N 字节 |
+| `tmux capture-pane -t --full` | 读取完整缓冲区 |
+| `tmux send-keys -t "text" Enter` | 向会话发送按键(支持 Enter、C-c、C-d、Escape、Tab 等) |
+| `tmux kill-session -t ` | 终止会话 |
+| `tmux wait-for -t [--timeout 60s]` | 阻塞等待会话结束 |
+
+增量输出机制:`capture-pane` 默认只返回上次读取后的新输出,避免重复。同时后台 goroutine 每 10 秒将新输出推送到 agent inbox,agent 不需要手动轮询。
+
+直接传命令也可以隐式创建后台会话:
+
+```bash
+tmux nmap -sV 192.168.1.0/24 # 等价于 tmux new-session -d "nmap -sV 192.168.1.0/24"
+```
+
+### proxy — 代理节点管理
+
+proxy 工具管理扫描代理,支持 Clash 订阅自动负载均衡和多协议直连。
+
+| 子命令 | 说明 |
+| --- | --- |
+| `proxy [args...]` | 通过指定代理执行命令(类似 proxychains) |
+| `proxy auto [options]` | 推荐模式:订阅 + 自适应负载均衡 |
+| `proxy subscribe ` | 拉取 Clash 订阅并列出可用节点 |
+| `proxy list` | 列出已加载的代理节点 |
+| `proxy switch ` | 切换活跃代理节点 |
+| `proxy test [name\|index]` | 测试代理节点连通性 |
+| `proxy current` | 显示当前活跃代理 |
+| `proxy clear` | 清除订阅,恢复原始代理 |
+
+支持的协议:`socks5://`、`trojan://`、`vless://`、`anytls://`、`hysteria2://`、`shadowsocks://`、`clash://`(订阅 URL)。
+
+**proxy-chain 执行**(通过代理运行扫描命令):
+
+```bash
+proxy socks5://127.0.0.1:1080 gogo -i 10.0.0.1 -p top2
+proxy trojan://pass@host:443 zombie -i 10.0.0.1 -s ssh
+proxy 6 gogo -i 10.0.0.1 -p top2 # 使用订阅节点 #6
+proxy HK gogo -i 10.0.0.1 # 使用名称匹配 "HK" 的节点
+```
+
+**auto 模式**(推荐):
+
+```bash
+proxy auto https://subscribe.example/link --type trojan,vless --country HK,JP --strategy adaptive
+```
+
+auto 模式选项:
+
+| 选项 | 说明 |
+| --- | --- |
+| `--type, -t` | 按协议类型过滤(trojan, vless 等) |
+| `--name, -n` | 按节点名关键词过滤 |
+| `--country, -c` | 按服务器 IP 国家过滤(ISO 3166-1 alpha-2) |
+| `--strategy, -s` | 负载均衡策略:adaptive(自适应)、url-test、round-robin、random |
+
+全局 `--proxy` 参数与 proxy 工具的关系:`--proxy` 设置初始扫描代理(所有 scanner 共享),proxy 工具可在运行时动态切换或通过 proxy-chain 对单次命令使用不同代理。
+
+### playwright — 无头浏览器(仅 full 版)
+
+playwright 提供 Chromium 无头浏览器,用于 JS 渲染页面、截图、网络捕获和交互式漏洞验证。
+
+**无状态命令**(直接传 URL,用完即关):
+
+| 子命令 | 说明 |
+| --- | --- |
+| `playwright goto [selector]` | 导航到 URL 并返回文本内容 |
+| `playwright content [selector]` | 导航到 URL 并返回 HTML |
+| `playwright screenshot [options]` | 截图 |
+| `playwright evaluate
+
+