From 3187a009f684f91d933dbb39304be44a90a2a05e Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 18 Oct 2026 09:29:02 +0000 Subject: [PATCH 1/4] Record the EXT-20 regulations provider task card Why: Track U9 base contract delivery for the regulations KB plan. Evidence: none (task record). Impact: Task record only. Refs: Agent-System-Dev .task/feature-regulations-kb.md Co-authored-by: Cursor --- .task/feature-regulations-kb-ext20.md | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .task/feature-regulations-kb-ext20.md diff --git a/.task/feature-regulations-kb-ext20.md b/.task/feature-regulations-kb-ext20.md new file mode 100644 index 0000000..eaa5383 --- /dev/null +++ b/.task/feature-regulations-kb-ext20.md @@ -0,0 +1,29 @@ +# feature/regulations-kb-ext20 任务卡 + +任务主记录:本文件随 PR 描述维护。 +状态:进行中;owner:weichen。 + +## 基线与授权 + +- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git;root_target:main;immediate_target:main +- branch_kind:standalone;跨仓总任务:Agent-System-Dev `feature/regulations-kb`([Dev PR #614](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/614)) +- R0(initial_main_sha):cac60c0;工作区:/home/weichen/projects/Agent-System-Backend-wt-feature-regulations-kb-ext20;分支:feature/regulations-kb-ext20 +- 授权与交付终点:用户 2026-10-18 /goal 指示按法规知识库计划实现全部内容;本任务交付 U9 的基座公开契约 EXT-20(提案:Documents `docs/proposals/2026-10-18-ext-20-regulations-provider.md`,分支 `feature/regulations-kb-u9-contract`),经 PR 合入 main。不含法规提供方实现(属 KB 仓)与 RDM 消费(U6)。 +- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md) + +## 目标与非目标 + +- 目标:`pkg/extension` 新增 `RegulationsProvider`/`RegulationsRegistrar`/`RegulationsHost` 与 DTO;进程内注册槽仅接受插件 `kbreg` 且只注册一次;分发 Host 实现四个方法并强制调用方身份、查询最小化(机密词拒绝、长度限制)、状态透传与无明文审计;快照、边界与契约/兼容测试。 +- 非目标:不实现搜索或语料检索;不改 `KnowledgeSearch` 语义。 + +## Agent 实施收据 + +| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | +|---|---|---|---|---|---|---|---|---|---| +| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — | + +## 结果与验证(完成后填写) + +- 结果: +- 验证: +- 影响与剩余项: From aaed108d7903ddeed314d644ba3fdd1a176e685e Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 09:38:04 +0000 Subject: [PATCH 2/4] agent-exec transport cfc2e055f212789112cf11716f5649c5/c04b36e76696 --- internal/app/app.go | 2 +- internal/app/compiled.go | 19 ++ internal/app/hostcaps.go | 1 + internal/app/regulations.go | 220 +++++++++++++++ internal/app/regulations_compat_test.go | 134 +++++++++ internal/app/regulations_test.go | 347 ++++++++++++++++++++++++ pkg/extension/extension.go | 4 +- pkg/extension/regulations.go | 209 ++++++++++++++ pkg/extension/regulations_test.go | 78 ++++++ pkg/extension/surface.txt | 46 ++++ 10 files changed, 1058 insertions(+), 2 deletions(-) create mode 100644 internal/app/regulations.go create mode 100644 internal/app/regulations_compat_test.go create mode 100644 internal/app/regulations_test.go create mode 100644 pkg/extension/regulations.go create mode 100644 pkg/extension/regulations_test.go diff --git a/internal/app/app.go b/internal/app/app.go index a6edf18..1b1ec7e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -649,7 +649,7 @@ func Run(opt Options) error { if err != nil { return err } - caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn, + caps := hostcaps{projects: projectSlot, directory: directorySlot, regulations: ®ulationsSlot{}, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn, accountGuard: func(owner string) error { valid, err := effectiveAccount(context.Background(), owner) if err != nil { diff --git a/internal/app/compiled.go b/internal/app/compiled.go index 644978a..6ae2d7a 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -127,6 +127,17 @@ func (r pluginRegistry) RegisterAccountDirectoryScopeResolver(fn extension.Accou return r.caps.directory.register(r.pluginID, fn) } +func (r compiledRegistry) RegisterRegulationsProvider(extension.RegulationsProvider) error { + return errors.New("extension: declarative plugins cannot register a regulations provider") +} + +func (r pluginRegistry) RegisterRegulationsProvider(p extension.RegulationsProvider) error { + if r.caps.regulations == nil { + return errors.New("extension: regulations provider registration unavailable") + } + return r.caps.regulations.register(r.pluginID, p) +} + // RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽 // (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。 // 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的 @@ -169,6 +180,14 @@ func (r recordRegistry) RegisterAccountDirectoryScopeResolver(fn extension.Accou return registrar.RegisterAccountDirectoryScopeResolver(fn) } +func (r recordRegistry) RegisterRegulationsProvider(p extension.RegulationsProvider) error { + registrar, ok := r.Registry.(extension.RegulationsRegistrar) + if !ok { + return errors.New("extension: regulations provider registration unavailable") + } + return registrar.RegisterRegulationsProvider(p) +} + func (r recordRegistry) AddCommand(spec json.RawMessage) error { if err := r.Registry.AddCommand(spec); err != nil { return err diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 7ffd00c..101a4a2 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -23,6 +23,7 @@ import ( type hostcaps struct { projects *projectLifecycleSlot directory *accountDirectorySlot + regulations *regulationsSlot // EXT-20 法规提供方槽(共享指针;nil/未注册=不可用) accountGuard func(string) error allowImplicitLocal bool st *store.Store diff --git a/internal/app/regulations.go b/internal/app/regulations.go new file mode 100644 index 0000000..f8e2061 --- /dev/null +++ b/internal/app/regulations.go @@ -0,0 +1,220 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "sync" + "unicode/utf8" + + "agent-system-backend/pkg/extension" +) + +// EXT-20:共享法规检索与网络证据。提供方由受信插件 kbreg 进程内注册;基座只 +// 填写调用方身份、做查询最小化校验并审计,不改写提供方返回的状态。 + +const ( + regulationsPluginID = "kbreg" + maxWebQueryRunes = 256 + + auditRegulationsSearch = "regulations.search" + auditRegulationsEvidence = "regulations.evidence" + auditWebSearch = "web.search" + auditWebCapture = "web.capture" + + regulationsAuditInvalid = "invalid" + regulationsAuditError = "error" + regulationsAuditOK = "ok" +) + +var ( + errRegulationsSchema = errors.New("extension: unsupported regulations schema version") + errWebQueryInvalid = errors.New("extension: web search query must be 1-256 characters") +) + +type regulationsSlot struct { + mu sync.RWMutex + provider extension.RegulationsProvider +} + +func (s *regulationsSlot) register(pluginID string, p extension.RegulationsProvider) error { + if p == nil || pluginID != regulationsPluginID { + return errors.New("extension: regulations provider requires trusted kbreg registration") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.provider != nil { + return errors.New("extension: regulations provider already registered") + } + s.provider = p + return nil +} + +func (s *regulationsSlot) get() extension.RegulationsProvider { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.provider +} + +// regulationsAudit is the only content written to the audit trail: never the +// query text, excerpts or captured page text. +type regulationsAudit struct { + Revision string `json:"revision,omitempty"` + Count int `json:"count"` + State string `json:"state"` + QuerySHA256 string `json:"querySha256,omitempty"` +} + +func regulationsQueryDigest(query string) string { + if query == "" { + return "" + } + sum := sha256.Sum256([]byte(query)) + return hex.EncodeToString(sum[:]) +} + +// regulations resolves the provider and the trusted caller. Without an audit +// store the capability is unavailable: calls must never run unaudited. +func (h inprocHost) regulations() (extension.RegulationsProvider, extension.RegulationsCaller, error) { + if err := h.requireEffective(); err != nil { + return nil, extension.RegulationsCaller{}, err + } + p := h.caps.regulations.get() + if p == nil || h.caps.st == nil { + return nil, extension.RegulationsCaller{}, extension.ErrRegulationsUnavailable + } + return p, extension.RegulationsCaller{PluginID: h.pluginID, Actor: h.actor, Owner: h.owner}, nil +} + +// auditRegulations appends one event per call. A call error takes precedence; +// otherwise an audit failure withholds the result. +func (h inprocHost) auditRegulations(action string, rec regulationsAudit, callErr error) error { + if callErr != nil && rec.State == "" { + rec.State = regulationsAuditError + } + evidence, err := json.Marshal(rec) + if err == nil { + err = h.Audit(action, regulationsPluginID, string(evidence)) + } + if callErr != nil { + return callErr + } + return err +} + +func (h inprocHost) SearchRegulations(ctx context.Context, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + p, caller, err := h.regulations() + if err != nil { + return extension.RegulationsSearchResult{}, err + } + rec := regulationsAudit{Revision: req.CorpusRevision, QuerySHA256: regulationsQueryDigest(req.Query)} + if req.SchemaVersion != extension.RegulationsSchemaVersion { + rec.State = regulationsAuditInvalid + return extension.RegulationsSearchResult{}, h.auditRegulations(auditRegulationsSearch, rec, errRegulationsSchema) + } + res, err := p.SearchRegulations(ctx, caller, req) + if err == nil { + rec.Revision, rec.Count, rec.State = res.CorpusRevision, len(res.Hits), res.State + } + if err := h.auditRegulations(auditRegulationsSearch, rec, err); err != nil { + return extension.RegulationsSearchResult{}, err + } + return res, nil +} + +func (h inprocHost) ResolveRegulationEvidence(ctx context.Context, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + p, caller, err := h.regulations() + if err != nil { + return extension.RegulationEvidence{}, err + } + rec := regulationsAudit{Revision: req.CorpusRevision} + if req.SchemaVersion != extension.RegulationsSchemaVersion { + rec.State = regulationsAuditInvalid + return extension.RegulationEvidence{}, h.auditRegulations(auditRegulationsEvidence, rec, errRegulationsSchema) + } + ev, err := p.ResolveRegulationEvidence(ctx, caller, req) + if err == nil { + rec.Count, rec.State = 1, regulationsAuditOK + if ev.CorpusRevision != "" { + rec.Revision = ev.CorpusRevision + } + } + if err := h.auditRegulations(auditRegulationsEvidence, rec, err); err != nil { + return extension.RegulationEvidence{}, err + } + return ev, nil +} + +// webQueryExcluded reports whether query contains any non-empty exclusion +// term, case-insensitively. +func webQueryExcluded(query string, exclusions []string) bool { + lower := strings.ToLower(query) + for _, term := range exclusions { + term = strings.ToLower(strings.TrimSpace(term)) + if term != "" && strings.Contains(lower, term) { + return true + } + } + return false +} + +func (h inprocHost) SearchWeb(ctx context.Context, req extension.WebSearchRequest) (extension.WebSearchResult, error) { + p, caller, err := h.regulations() + if err != nil { + return extension.WebSearchResult{}, err + } + req.Query = strings.TrimSpace(req.Query) + rec := regulationsAudit{QuerySHA256: regulationsQueryDigest(req.Query)} + if req.SchemaVersion != extension.RegulationsSchemaVersion { + rec.State = regulationsAuditInvalid + return extension.WebSearchResult{}, h.auditRegulations(auditWebSearch, rec, errRegulationsSchema) + } + if req.Query == "" || !utf8.ValidString(req.Query) || utf8.RuneCountInString(req.Query) > maxWebQueryRunes { + rec.State = regulationsAuditInvalid + return extension.WebSearchResult{}, h.auditRegulations(auditWebSearch, rec, errWebQueryInvalid) + } + if webQueryExcluded(req.Query, req.Exclusions) { + res := extension.WebSearchResult{State: extension.WebSearchStateRejected, Reason: "query contains an excluded term"} + rec.State = res.State + if err := h.auditRegulations(auditWebSearch, rec, nil); err != nil { + return extension.WebSearchResult{}, err + } + return res, nil + } + res, err := p.SearchWeb(ctx, caller, req) + if err == nil { + rec.Count, rec.State = len(res.Results), res.State + } + if err := h.auditRegulations(auditWebSearch, rec, err); err != nil { + return extension.WebSearchResult{}, err + } + return res, nil +} + +func (h inprocHost) CaptureWeb(ctx context.Context, req extension.WebCaptureRequest) (extension.WebCapture, error) { + p, caller, err := h.regulations() + if err != nil { + return extension.WebCapture{}, err + } + rec := regulationsAudit{QuerySHA256: regulationsQueryDigest(strings.TrimSpace(req.SearchQuery))} + if req.SchemaVersion != extension.RegulationsSchemaVersion { + rec.State = regulationsAuditInvalid + return extension.WebCapture{}, h.auditRegulations(auditWebCapture, rec, errRegulationsSchema) + } + capture, err := p.CaptureWeb(ctx, caller, req) + if err == nil { + rec.Count, rec.State = len(capture.Candidates), capture.State + } + if err := h.auditRegulations(auditWebCapture, rec, err); err != nil { + return extension.WebCapture{}, err + } + return capture, nil +} + +var _ extension.RegulationsHost = inprocHost{} diff --git a/internal/app/regulations_compat_test.go b/internal/app/regulations_compat_test.go new file mode 100644 index 0000000..e91fc18 --- /dev/null +++ b/internal/app/regulations_compat_test.go @@ -0,0 +1,134 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +// compatRegulationsPlugin stands in for the external kbreg repository: it uses +// only pkg/extension types and registers through the optional Registrar. +type compatRegulationsPlugin struct{ seen []extension.RegulationsCaller } + +func (p *compatRegulationsPlugin) Register(reg extension.Registry) error { + registrar, ok := reg.(extension.RegulationsRegistrar) + if !ok { + return errors.New("regulations registrar unavailable") + } + return registrar.RegisterRegulationsProvider(p) +} + +func (p *compatRegulationsPlugin) SearchRegulations(_ context.Context, c extension.RegulationsCaller, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + p.seen = append(p.seen, c) + return extension.RegulationsSearchResult{CorpusRevision: "rev-compat", State: extension.RegulationsStateOK, + Hits: []extension.RegulationHit{{RegulationID: "r", EditionID: "e", NodeID: "n", Disclosure: extension.RegulationDisclosureExcerpt}}}, nil +} + +func (p *compatRegulationsPlugin) ResolveRegulationEvidence(_ context.Context, c extension.RegulationsCaller, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + p.seen = append(p.seen, c) + return extension.RegulationEvidence{CorpusRevision: req.CorpusRevision, NodeID: req.NodeID, Disclosure: extension.RegulationDisclosureFull, HashVerified: true}, nil +} + +func (p *compatRegulationsPlugin) SearchWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebSearchRequest) (extension.WebSearchResult, error) { + p.seen = append(p.seen, c) + return extension.WebSearchResult{Provider: "compat", SentQuery: req.Query, State: extension.WebSearchStateEmpty}, nil +} + +func (p *compatRegulationsPlugin) CaptureWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebCaptureRequest) (extension.WebCapture, error) { + p.seen = append(p.seen, c) + return extension.WebCapture{FinalURL: req.URL, State: extension.WebCaptureStateFailed, Reason: "offline"}, nil +} + +// compatRegulationsConsumer is a second compiled plugin that reaches the +// provider only through the dispatch Host type assertion. +func compatRegulationsConsumer(reg extension.Registry) error { + return reg.AddCommandHandler(json.RawMessage(`{"id":"consumer.regs","title":"regs"}`), + func(ctx context.Context, h extension.Host, req extension.Request) (any, error) { + regs, ok := h.(extension.RegulationsHost) + if !ok { + return nil, errors.New("regulations host capability missing") + } + search, err := regs.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "注册检验"}) + if err != nil { + return nil, err + } + web, err := regs.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "注册检验 指导原则"}) + if err != nil { + return nil, err + } + return map[string]any{"revision": search.CorpusRevision, "webState": web.State}, nil + }) +} + +func newRegulationsCompatApp(t *testing.T, plugins ...extension.InProc) (*cmdbus.Bus, *store.Store, hostcaps) { + t.Helper() + extension.ResetCompiled() + t.Cleanup(extension.ResetCompiled) + for _, p := range plugins { + extension.RegisterCompiled(p) + } + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "compat.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + caps := hostcaps{st: st, regulations: ®ulationsSlot{}} + applyCompiled("", bus, caps) + return bus, st, caps +} + +func TestRegulationsCompiledPluginCompatibility(t *testing.T) { + provider := &compatRegulationsPlugin{} + impostor := &compatRegulationsPlugin{} + bus, st, caps := newRegulationsCompatApp(t, + extension.InProc{ID: "impostor", Register: impostor.Register}, + extension.InProc{ID: "kbreg", Register: provider.Register}, + extension.InProc{ID: "consumer", Register: compatRegulationsConsumer}, + ) + if caps.regulations.get() != extension.RegulationsProvider(provider) { + t.Fatal("compiled kbreg provider was not the registered provider") + } + resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "consumer.regs", Owner: "alice", Actor: "alice", Entrance: "button"}) + if resp.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("dispatch = %+v", resp.Receipt) + } + payload, _ := resp.Payload.(map[string]any) + if payload["revision"] != "rev-compat" || payload["webState"] != extension.WebSearchStateEmpty { + t.Fatalf("payload = %+v", resp.Payload) + } + want := extension.RegulationsCaller{PluginID: "consumer", Actor: "alice", Owner: "alice"} + if len(provider.seen) != 2 || provider.seen[0] != want || provider.seen[1] != want || len(impostor.seen) != 0 { + t.Fatalf("provider callers = %+v impostor = %+v", provider.seen, impostor.seen) + } + evs, err := st.ListAuditEvents("alice", 20) + if err != nil { + t.Fatal(err) + } + actions := map[string]bool{} + for _, ev := range evs { + actions[ev.Action] = true + } + if !actions[auditRegulationsSearch] || !actions[auditWebSearch] { + t.Fatalf("host audit missing: %+v", evs) + } +} + +func TestRegulationsCompiledConsumerWithoutProvider(t *testing.T) { + bus, _, _ := newRegulationsCompatApp(t, extension.InProc{ID: "consumer", Register: compatRegulationsConsumer}) + resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "consumer.regs", Owner: "alice", Actor: "alice", Entrance: "button"}) + if resp.Receipt.State == agentcommand.StateCommitted { + t.Fatalf("consumer succeeded without a regulations provider: %+v", resp.Payload) + } +} diff --git a/internal/app/regulations_test.go b/internal/app/regulations_test.go new file mode 100644 index 0000000..7d279eb --- /dev/null +++ b/internal/app/regulations_test.go @@ -0,0 +1,347 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +type fakeRegulationsProvider struct { + callers []extension.RegulationsCaller + webQueries []string + search extension.RegulationsSearchResult + evidence extension.RegulationEvidence + web extension.WebSearchResult + capture extension.WebCapture + err error +} + +func (f *fakeRegulationsProvider) SearchRegulations(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + f.callers = append(f.callers, c) + return f.search, f.err +} + +func (f *fakeRegulationsProvider) ResolveRegulationEvidence(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + f.callers = append(f.callers, c) + return f.evidence, f.err +} + +func (f *fakeRegulationsProvider) SearchWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebSearchRequest) (extension.WebSearchResult, error) { + f.callers = append(f.callers, c) + f.webQueries = append(f.webQueries, req.Query) + return f.web, f.err +} + +func (f *fakeRegulationsProvider) CaptureWeb(_ context.Context, c extension.RegulationsCaller, _ extension.WebCaptureRequest) (extension.WebCapture, error) { + f.callers = append(f.callers, c) + return f.capture, f.err +} + +func newRegulationsTestHost(t *testing.T) (inprocHost, *store.Store) { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "regulations.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + caps := hostcaps{st: st, regulations: ®ulationsSlot{}} + return inprocHost{caps: caps, pluginID: "rdm", owner: "alice", actor: "alice"}, st +} + +func registerFakeRegulations(t *testing.T, h inprocHost) *fakeRegulationsProvider { + t.Helper() + p := &fakeRegulationsProvider{} + if err := (pluginRegistry{pluginID: "kbreg", caps: h.caps}).RegisterRegulationsProvider(p); err != nil { + t.Fatal(err) + } + return p +} + +const regV = extension.RegulationsSchemaVersion + +func callAllRegulations(h inprocHost, version int) []error { + ctx := context.Background() + _, e1 := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: version, Query: "医疗器械 临床评价"}) + _, e2 := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: version, CorpusRevision: "rev-1", NodeID: "n1"}) + _, e3 := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: version, Query: "临床评价 指导原则"}) + _, e4 := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: version, URL: "https://www.nmpa.gov.cn/x", SearchQuery: "临床评价 指导原则"}) + return []error{e1, e2, e3, e4} +} + +func TestRegulationsUnavailableWithoutProvider(t *testing.T) { + h, st := newRegulationsTestHost(t) + for i, err := range callAllRegulations(h, regV) { + if !errors.Is(err, extension.ErrRegulationsUnavailable) { + t.Fatalf("method %d without provider: %v", i, err) + } + } + h.caps.regulations = nil + for i, err := range callAllRegulations(h, regV) { + if !errors.Is(err, extension.ErrRegulationsUnavailable) { + t.Fatalf("method %d without slot: %v", i, err) + } + } + if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 0 { + t.Fatalf("unavailable calls audited: %+v %v", evs, err) + } +} + +func TestRegulationsRegistrationOnlyTrustedKbregOnce(t *testing.T) { + h, _ := newRegulationsTestHost(t) + first, second := &fakeRegulationsProvider{}, &fakeRegulationsProvider{} + if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterRegulationsProvider(first); err == nil { + t.Fatal("non-kbreg plugin registered a regulations provider") + } + kbreg := pluginRegistry{pluginID: "kbreg", caps: h.caps} + if err := kbreg.RegisterRegulationsProvider(nil); err == nil { + t.Fatal("nil provider registered") + } + if err := kbreg.RegisterRegulationsProvider(first); err != nil { + t.Fatal(err) + } + if err := kbreg.RegisterRegulationsProvider(second); err == nil { + t.Fatal("duplicate registration accepted") + } + if h.caps.regulations.get() != first { + t.Fatal("duplicate registration replaced the first provider") + } + if err := (pluginRegistry{pluginID: "kbreg"}).RegisterRegulationsProvider(first); err == nil { + t.Fatal("registration without a host slot accepted") + } +} + +func TestRegulationsDeclarativeRegistrationRejected(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := &fakeRegulationsProvider{} + if err := (compiledRegistry{}).RegisterRegulationsProvider(p); err == nil { + t.Fatal("declarative registry accepted a regulations provider") + } + if err := (recordRegistry{Registry: pluginRegistry{pluginID: "kbreg", caps: h.caps}}).RegisterRegulationsProvider(p); err != nil { + t.Fatalf("record registry did not forward kbreg registration: %v", err) + } + if h.caps.regulations.get() != p { + t.Fatal("forwarded registration did not fill the slot") + } +} + +func TestRegulationsCallerFilledFromDispatchContext(t *testing.T) { + h, _ := newRegulationsTestHost(t) + h.actor = "alice-session" + p := registerFakeRegulations(t, h) + p.web.State = extension.WebSearchStateOK + for i, err := range callAllRegulations(h, regV) { + if err != nil { + t.Fatalf("method %d: %v", i, err) + } + } + want := extension.RegulationsCaller{PluginID: "rdm", Actor: "alice-session", Owner: "alice"} + if len(p.callers) != 4 { + t.Fatalf("provider calls = %d", len(p.callers)) + } + for i, got := range p.callers { + if got != want { + t.Fatalf("call %d caller = %+v, want %+v", i, got, want) + } + } +} + +func TestRegulationsRejectsOtherSchemaVersions(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + for _, version := range []int{0, regV + 1} { + for i, err := range callAllRegulations(h, version) { + if !errors.Is(err, errRegulationsSchema) { + t.Fatalf("schema %d method %d: %v", version, i, err) + } + } + } + if len(p.callers) != 0 { + t.Fatalf("provider reached with unsupported schema: %d calls", len(p.callers)) + } +} + +func TestRegulationsWebQueryBounds(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + ctx := context.Background() + for _, q := range []string{"", " \t\n", strings.Repeat("法", maxWebQueryRunes+1)} { + if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: q}); !errors.Is(err, errWebQueryInvalid) { + t.Fatalf("query of %d runes accepted: %v", len([]rune(q)), err) + } + } + if len(p.callers) != 0 { + t.Fatal("provider reached with an invalid query") + } + maxQuery := strings.Repeat("法", maxWebQueryRunes) + if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + maxQuery + " "}); err != nil { + t.Fatalf("256-rune query rejected: %v", err) + } + if len(p.webQueries) != 1 || p.webQueries[0] != maxQuery { + t.Fatalf("provider query was not trimmed: %q", p.webQueries) + } +} + +func TestRegulationsExclusionRejectedBeforeProvider(t *testing.T) { + h, st := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + ctx := context.Background() + query := "Project-Falcon 临床评价 指导原则" + res, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query, + Exclusions: []string{"", " ", "project-falcon"}}) + if err != nil || res.State != extension.WebSearchStateRejected || res.SentQuery != "" || len(res.Results) != 0 { + t.Fatalf("excluded query result = %+v %v", res, err) + } + if len(p.callers) != 0 { + t.Fatal("provider received a query containing an excluded term") + } + evs, err := st.ListAuditEvents("alice", 10) + if err != nil || len(evs) != 1 || evs[0].Action != auditWebSearch { + t.Fatalf("rejection audit: %+v %v", evs, err) + } + if strings.Contains(strings.ToLower(evs[0].Evidence), "falcon") || !strings.Contains(evs[0].Evidence, `"state":"rejected"`) { + t.Fatalf("rejection audit evidence = %s", evs[0].Evidence) + } + if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query, Exclusions: []string{"", " "}}); err != nil { + t.Fatal(err) + } + if len(p.callers) != 1 { + t.Fatal("blank exclusions blocked an allowed query") + } +} + +func TestRegulationsProviderStatesPassThroughUnchanged(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + at := time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC) + p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-7", State: extension.RegulationsStateDegraded, + DegradedReasons: []string{"vector_index_unavailable"}, + Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", + Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights"}, + {RegulationID: "r2", EditionID: "e2", NodeID: "n2", Disclosure: extension.RegulationDisclosureMetadataOnly}}} + p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-7", NodeID: "n1", + Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", HashVerified: false} + p.web = extension.WebSearchResult{Provider: "fake", SentQuery: "q", SearchedAt: at, + State: extension.WebSearchStateRateLimited, Reason: "quota"} + p.capture = extension.WebCapture{CaptureID: "c1", FetchedAt: at, Retention: extension.WebRetentionMetadataOnly, + State: extension.WebCaptureStateRejected, Reason: "robots"} + ctx := context.Background() + search, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"}) + if err != nil || !reflect.DeepEqual(search, p.search) { + t.Fatalf("search = %+v %v", search, err) + } + evidence, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-7", NodeID: "n1"}) + if err != nil || !reflect.DeepEqual(evidence, p.evidence) { + t.Fatalf("evidence = %+v %v", evidence, err) + } + web, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"}) + if err != nil || !reflect.DeepEqual(web, p.web) { + t.Fatalf("web = %+v %v", web, err) + } + p.web.State = extension.WebSearchStateRejected + web, err = h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"}) + if err != nil || web.State != extension.WebSearchStateRejected { + t.Fatalf("provider rejection rewritten: %+v %v", web, err) + } + capture, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org"}) + if err != nil || !reflect.DeepEqual(capture, p.capture) { + t.Fatalf("capture = %+v %v", capture, err) + } + p.err = errors.New("provider failure") + if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"}); !errors.Is(err, p.err) { + t.Fatalf("provider failure wrapped as success: %v", err) + } +} + +func TestRegulationsAuditEvidenceOmitsQueryAndText(t *testing.T) { + h, st := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + const ( + secretQuery = "第十七条 secret-query-text" + secretExcerpt = "SECRET-EXCERPT 原文片段" + secretPage = "SECRET-PAGE 网页全文" + ) + p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK, + Hits: []extension.RegulationHit{{NodeID: "n1", Excerpt: secretExcerpt, Disclosure: extension.RegulationDisclosureExcerpt}}} + p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-9", NodeID: "n1", Excerpt: secretExcerpt, + Disclosure: extension.RegulationDisclosureFull, HashVerified: true} + p.web = extension.WebSearchResult{SentQuery: secretQuery, State: extension.WebSearchStateOK, + Results: []extension.WebSearchHit{{Rank: 1, URL: "https://example.org", Snippet: secretExcerpt}}} + p.capture = extension.WebCapture{State: extension.WebCaptureStateCaptured, Retention: extension.WebRetentionFull, + Text: secretPage, Candidates: []extension.WebClauseCandidate{{Excerpt: secretExcerpt}}} + ctx := context.Background() + if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: secretQuery}); err != nil { + t.Fatal(err) + } + if _, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-9", NodeID: "n1"}); err != nil { + t.Fatal(err) + } + if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + secretQuery + " "}); err != nil { + t.Fatal(err) + } + if _, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org", SearchQuery: secretQuery}); err != nil { + t.Fatal(err) + } + evs, err := st.ListAuditEvents("alice", 10) + if err != nil || len(evs) != 4 { + t.Fatalf("audit events = %+v %v", evs, err) + } + sum := sha256.Sum256([]byte(secretQuery)) + digest := hex.EncodeToString(sum[:]) + want := map[string]regulationsAudit{ + auditRegulationsSearch: {Revision: "rev-9", Count: 1, State: "ok", QuerySHA256: digest}, + auditRegulationsEvidence: {Revision: "rev-9", Count: 1, State: "ok"}, + auditWebSearch: {Count: 1, State: "ok", QuerySHA256: digest}, + auditWebCapture: {Count: 1, State: "captured", QuerySHA256: digest}, + } + for _, ev := range evs { + for _, secret := range []string{"secret-query-text", "第十七条", "SECRET-EXCERPT", "原文片段", "SECRET-PAGE", "网页全文"} { + if strings.Contains(ev.Evidence, secret) || strings.Contains(ev.Target, secret) { + t.Fatalf("%s audit leaks %q: %s", ev.Action, secret, ev.Evidence) + } + } + var fields map[string]any + if err := json.Unmarshal([]byte(ev.Evidence), &fields); err != nil { + t.Fatal(err) + } + for k := range fields { + if k != "revision" && k != "count" && k != "state" && k != "querySha256" { + t.Fatalf("%s audit has unexpected field %q", ev.Action, k) + } + } + var got regulationsAudit + if err := json.Unmarshal([]byte(ev.Evidence), &got); err != nil { + t.Fatal(err) + } + if w, ok := want[ev.Action]; !ok || got != w || ev.Owner != "alice" { + t.Fatalf("%s audit = %+v owner=%s, want %+v", ev.Action, got, ev.Owner, w) + } + delete(want, ev.Action) + } +} + +func TestRegulationsRequireEffectiveAccount(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + h.caps.accountGuard = func(string) error { return store.ErrAccountForbidden } + for i, err := range callAllRegulations(h, regV) { + if !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("method %d ignored the account guard: %v", i, err) + } + } + if len(p.callers) != 0 { + t.Fatal("provider reached for an ineffective account") + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index d43a6ea..1d0e1fc 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -40,7 +40,9 @@ import ( // AccountDirectoryRegistrar for scoped, current account candidate decisions. // 2.14.0-experimental:InProc.HostOptions / CLIHook,以及 MergeHostOptions、 // RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。 -const SurfaceVersion = "2.14.0-experimental" +// 2.15.0-experimental:EXT-20 可选 RegulationsHost 与受信进程内 RegulationsRegistrar +// (共享法规检索与网络证据;仅插件 kbreg 可注册提供方)。 +const SurfaceVersion = "2.15.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") diff --git a/pkg/extension/regulations.go b/pkg/extension/regulations.go new file mode 100644 index 0000000..21bf58b --- /dev/null +++ b/pkg/extension/regulations.go @@ -0,0 +1,209 @@ +package extension + +import ( + "context" + "errors" + "time" +) + +// RegulationsSchemaVersion is the only request schema the host accepts for +// EXT-20; any other value is rejected before reaching the provider. +const RegulationsSchemaVersion = 1 + +const ( + RegulationsStateOK = "ok" + RegulationsStateEmpty = "empty" + RegulationsStateDegraded = "degraded" + RegulationsStateUnavailable = "unavailable" + + RegulationDisclosureFull = "full" + RegulationDisclosureExcerpt = "excerpt" + RegulationDisclosureMetadataOnly = "metadata_only" + RegulationDisclosureWithheld = "withheld" + + WebSearchStateOK = "ok" + WebSearchStateEmpty = "empty" + WebSearchStateUnavailable = "unavailable" + WebSearchStateRateLimited = "rate_limited" + WebSearchStateRejected = "rejected" + WebSearchStateError = "error" + + WebCaptureStateCaptured = "captured" + WebCaptureStateRejected = "rejected" + WebCaptureStateFailed = "failed" + + WebRetentionFull = "full" + WebRetentionExcerpt = "excerpt" + WebRetentionMetadataOnly = "metadata_only" +) + +// ErrRegulationsUnavailable is returned by every RegulationsHost method when no +// trusted provider is registered. Callers must not fall back to KnowledgeSearch. +var ErrRegulationsUnavailable = errors.New("extension: regulations provider unavailable") + +// RegulationsCaller is filled by the host from the trusted dispatch context. +// It deliberately has no project ID: shared regulation access neither widens +// nor narrows with a project. +type RegulationsCaller struct { + PluginID string `json:"pluginId"` + Actor string `json:"actor,omitempty"` + Owner string `json:"owner"` +} + +// RegulationsSearchRequest searches the controlled regulation corpus. An empty +// CorpusRevision means the current published revision; the result echoes the +// revision actually searched. +type RegulationsSearchRequest struct { + SchemaVersion int `json:"schemaVersion"` + Query string `json:"query"` + CorpusRevision string `json:"corpusRevision,omitempty"` + Kinds []string `json:"kinds,omitempty"` + CurrentOnly bool `json:"currentOnly,omitempty"` + DocNo string `json:"docNo,omitempty"` + ClauseNo string `json:"clauseNo,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type RegulationsSearchResult struct { + CorpusRevision string `json:"corpusRevision"` + State string `json:"state"` // ok|empty|degraded|unavailable + DegradedReasons []string `json:"degradedReasons,omitempty"` + Hits []RegulationHit `json:"hits"` +} + +type RegulationHit struct { + RegulationID string `json:"regulationId"` + EditionID string `json:"editionId"` + NodeID string `json:"nodeId"` + DocNo string `json:"docNo,omitempty"` + Title string `json:"title,omitempty"` + EditionLabel string `json:"editionLabel,omitempty"` + LegalStatus string `json:"legalStatus,omitempty"` + ClauseNo string `json:"clauseNo,omitempty"` + HeadingPath []string `json:"headingPath,omitempty"` + Excerpt string `json:"excerpt,omitempty"` + ExcerptStart int `json:"excerptStart,omitempty"` + ExcerptEnd int `json:"excerptEnd,omitempty"` + NodeSHA256 string `json:"nodeSha256,omitempty"` + SourceAssetSHA256 string `json:"sourceAssetSha256,omitempty"` + PageStart int `json:"pageStart,omitempty"` + PageEnd int `json:"pageEnd,omitempty"` + Disclosure string `json:"disclosure"` // full|excerpt|metadata_only|withheld + WithheldReason string `json:"withheldReason,omitempty"` + IsSummary bool `json:"isSummary,omitempty"` + MatchKinds []string `json:"matchKinds,omitempty"` + Score float64 `json:"score,omitempty"` +} + +// RegulationEvidenceRequest retrieves the original text span of a node pinned +// to a corpus revision. +type RegulationEvidenceRequest struct { + SchemaVersion int `json:"schemaVersion"` + CorpusRevision string `json:"corpusRevision"` + NodeID string `json:"nodeId"` + Start int `json:"start,omitempty"` + End int `json:"end,omitempty"` +} + +type RegulationEvidence struct { + CorpusRevision string `json:"corpusRevision"` + RegulationID string `json:"regulationId,omitempty"` + EditionID string `json:"editionId,omitempty"` + NodeID string `json:"nodeId"` + ClauseNo string `json:"clauseNo,omitempty"` + Excerpt string `json:"excerpt,omitempty"` + Start int `json:"start,omitempty"` + End int `json:"end,omitempty"` + NodeSHA256 string `json:"nodeSha256,omitempty"` + SourceAssetSHA256 string `json:"sourceAssetSha256,omitempty"` + PageStart int `json:"pageStart,omitempty"` + PageEnd int `json:"pageEnd,omitempty"` + Disclosure string `json:"disclosure"` + WithheldReason string `json:"withheldReason,omitempty"` + HashVerified bool `json:"hashVerified"` +} + +// WebSearchRequest performs a real web search. Exclusions are caller-declared +// confidential terms (project names, numbers, people); a query containing any +// of them is rejected by the host before the provider sees it. +type WebSearchRequest struct { + SchemaVersion int `json:"schemaVersion"` + Query string `json:"query"` + Exclusions []string `json:"exclusions,omitempty"` + PreferOfficial bool `json:"preferOfficial,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type WebSearchResult struct { + Provider string `json:"provider,omitempty"` + SentQuery string `json:"sentQuery,omitempty"` + SearchedAt time.Time `json:"searchedAt"` + State string `json:"state"` // ok|empty|unavailable|rate_limited|rejected|error + Reason string `json:"reason,omitempty"` + Results []WebSearchHit `json:"results,omitempty"` +} + +type WebSearchHit struct { + Rank int `json:"rank"` + URL string `json:"url"` + Title string `json:"title,omitempty"` + Snippet string `json:"snippet,omitempty"` + Publisher string `json:"publisher,omitempty"` + Official bool `json:"official,omitempty"` +} + +// WebCaptureRequest fetches a page found by a prior SearchWeb call. +type WebCaptureRequest struct { + SchemaVersion int `json:"schemaVersion"` + URL string `json:"url"` + SearchProvider string `json:"searchProvider,omitempty"` + SearchQuery string `json:"searchQuery,omitempty"` + SearchRank int `json:"searchRank,omitempty"` +} + +type WebCapture struct { + CaptureID string `json:"captureId,omitempty"` + FinalURL string `json:"finalUrl,omitempty"` + FetchedAt time.Time `json:"fetchedAt"` + ContentType string `json:"contentType,omitempty"` + ByteSize int64 `json:"byteSize,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Retention string `json:"retention,omitempty"` // full|excerpt|metadata_only + // Text is present only when the source rights allow full retention. + Text string `json:"text,omitempty"` + Candidates []WebClauseCandidate `json:"candidates,omitempty"` + State string `json:"state"` // captured|rejected|failed + Reason string `json:"reason,omitempty"` +} + +type WebClauseCandidate struct { + ClauseNo string `json:"clauseNo,omitempty"` + Excerpt string `json:"excerpt"` + Start int `json:"start"` + End int `json:"end"` +} + +// RegulationsProvider is implemented and registered by the trusted regulations +// knowledge plugin (plugin ID "kbreg"); the host ships no implementation. +type RegulationsProvider interface { + SearchRegulations(context.Context, RegulationsCaller, RegulationsSearchRequest) (RegulationsSearchResult, error) + ResolveRegulationEvidence(context.Context, RegulationsCaller, RegulationEvidenceRequest) (RegulationEvidence, error) + SearchWeb(context.Context, RegulationsCaller, WebSearchRequest) (WebSearchResult, error) + CaptureWeb(context.Context, RegulationsCaller, WebCaptureRequest) (WebCapture, error) +} + +// RegulationsRegistrar is optional on a compiled in-process Registry. Only +// plugin ID "kbreg" may register, and only once. +type RegulationsRegistrar interface { + RegisterRegulationsProvider(RegulationsProvider) error +} + +// RegulationsHost is optional on dispatch Host handles (obtained by type +// assertion). A missing capability means unavailable; callers must not fall +// back to KnowledgeSearch. +type RegulationsHost interface { + SearchRegulations(context.Context, RegulationsSearchRequest) (RegulationsSearchResult, error) + ResolveRegulationEvidence(context.Context, RegulationEvidenceRequest) (RegulationEvidence, error) + SearchWeb(context.Context, WebSearchRequest) (WebSearchResult, error) + CaptureWeb(context.Context, WebCaptureRequest) (WebCapture, error) +} diff --git a/pkg/extension/regulations_test.go b/pkg/extension/regulations_test.go new file mode 100644 index 0000000..f0bd49a --- /dev/null +++ b/pkg/extension/regulations_test.go @@ -0,0 +1,78 @@ +package extension_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "agent-system-backend/pkg/extension" +) + +// publicRegulationsProvider is a downstream provider built only from the +// public extension surface. +type publicRegulationsProvider struct{} + +func (publicRegulationsProvider) SearchRegulations(context.Context, extension.RegulationsCaller, extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + return extension.RegulationsSearchResult{State: extension.RegulationsStateEmpty}, nil +} + +func (publicRegulationsProvider) ResolveRegulationEvidence(context.Context, extension.RegulationsCaller, extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + return extension.RegulationEvidence{Disclosure: extension.RegulationDisclosureMetadataOnly}, nil +} + +func (publicRegulationsProvider) SearchWeb(context.Context, extension.RegulationsCaller, extension.WebSearchRequest) (extension.WebSearchResult, error) { + return extension.WebSearchResult{State: extension.WebSearchStateUnavailable}, nil +} + +func (publicRegulationsProvider) CaptureWeb(context.Context, extension.RegulationsCaller, extension.WebCaptureRequest) (extension.WebCapture, error) { + return extension.WebCapture{State: extension.WebCaptureStateFailed}, nil +} + +var _ extension.RegulationsProvider = publicRegulationsProvider{} + +func TestRegulationsContractIsOptional(t *testing.T) { + var host extension.Host + if _, ok := host.(extension.RegulationsHost); ok { + t.Fatal("nil host must not expose regulations") + } + var registry extension.Registry + if _, ok := registry.(extension.RegulationsRegistrar); ok { + t.Fatal("nil registry must not expose regulations registration") + } + if extension.RegulationsSchemaVersion != 1 { + t.Fatalf("schema version = %d", extension.RegulationsSchemaVersion) + } + if extension.ErrRegulationsUnavailable == nil { + t.Fatal("unavailable error must be defined") + } +} + +func TestRegulationsWireNames(t *testing.T) { + states := []string{extension.RegulationsStateOK, extension.RegulationsStateEmpty, extension.RegulationsStateDegraded, + extension.RegulationsStateUnavailable, extension.RegulationDisclosureFull, extension.RegulationDisclosureExcerpt, + extension.RegulationDisclosureMetadataOnly, extension.RegulationDisclosureWithheld, extension.WebSearchStateOK, + extension.WebSearchStateEmpty, extension.WebSearchStateUnavailable, extension.WebSearchStateRateLimited, + extension.WebSearchStateRejected, extension.WebSearchStateError, extension.WebCaptureStateCaptured, + extension.WebCaptureStateRejected, extension.WebCaptureStateFailed, extension.WebRetentionFull, + extension.WebRetentionExcerpt, extension.WebRetentionMetadataOnly} + want := "ok empty degraded unavailable full excerpt metadata_only withheld ok empty unavailable rate_limited " + + "rejected error captured rejected failed full excerpt metadata_only" + if got := strings.Join(states, " "); got != want { + t.Fatalf("wire states = %q", got) + } + raw, err := json.Marshal(extension.WebSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "q"}) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"schemaVersion":1,"query":"q"}` { + t.Fatalf("optional fields not omitted: %s", raw) + } + raw, err = json.Marshal(extension.RegulationsCaller{PluginID: "rdm", Owner: "alice"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(raw)), "project") { + t.Fatalf("caller must not carry a project: %s", raw) + } +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 3fe18e7..be390a3 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -30,6 +30,7 @@ Compiled DelegatedProjectProjectionHost DelegatedProjectProjectionHost.ProjectProjectionForCreator ErrNotLinked +ErrRegulationsUnavailable Handler Host Host.ArtifactContent @@ -78,6 +79,33 @@ Registry.AddSkill Registry.Note Registry.RegisterResourceAuthorizer Registry.RegisterResourceAuthorizerV2 +RegulationDisclosureExcerpt +RegulationDisclosureFull +RegulationDisclosureMetadataOnly +RegulationDisclosureWithheld +RegulationEvidence +RegulationEvidenceRequest +RegulationHit +RegulationsCaller +RegulationsHost +RegulationsHost.CaptureWeb +RegulationsHost.ResolveRegulationEvidence +RegulationsHost.SearchRegulations +RegulationsHost.SearchWeb +RegulationsProvider +RegulationsProvider.CaptureWeb +RegulationsProvider.ResolveRegulationEvidence +RegulationsProvider.SearchRegulations +RegulationsProvider.SearchWeb +RegulationsRegistrar +RegulationsRegistrar.RegisterRegulationsProvider +RegulationsSchemaVersion +RegulationsSearchRequest +RegulationsSearchResult +RegulationsStateDegraded +RegulationsStateEmpty +RegulationsStateOK +RegulationsStateUnavailable Request ResetCompiled ResourceAuthorizer @@ -93,3 +121,21 @@ SurfaceHost.SurfaceList SurfaceHost.SurfaceSave SurfaceSaveRequest SurfaceVersion +WebCapture +WebCaptureRequest +WebCaptureStateCaptured +WebCaptureStateFailed +WebCaptureStateRejected +WebClauseCandidate +WebRetentionExcerpt +WebRetentionFull +WebRetentionMetadataOnly +WebSearchHit +WebSearchRequest +WebSearchResult +WebSearchStateEmpty +WebSearchStateError +WebSearchStateOK +WebSearchStateRateLimited +WebSearchStateRejected +WebSearchStateUnavailable From 379336604810ebd8a7477ffaacdda7cd8f51f1ed Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 18 Oct 2026 09:51:50 +0000 Subject: [PATCH 3/4] Record the EXT-20 implementation receipt and verification Why: Track U9 delivery evidence. Evidence: Targeted vet/test PASS at aaed108; full suite only fails a pre-existing environment test. Impact: Card only. Refs: Agent-System-Dev feature/regulations-kb Co-authored-by: Cursor --- .task/feature-regulations-kb-ext20.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.task/feature-regulations-kb-ext20.md b/.task/feature-regulations-kb-ext20.md index eaa5383..df89105 100644 --- a/.task/feature-regulations-kb-ext20.md +++ b/.task/feature-regulations-kb-ext20.md @@ -20,10 +20,11 @@ | 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | |---|---|---|---|---|---|---|---|---|---| -| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — | +| backend-ext20-regulations-provider | pkg/extension、internal/app | implementation | cursor, antigravity, pi | 1 | cursor-agent-stable / auto(session not-observable,新进程) | success | refs/agent-exec/cfc2e055f212789112cf11716f5649c5/c04b36e76696 → aaed108(apply 快进) | 否 | /tmp/regkb-u9-receipt.json | ## 结果与验证(完成后填写) -- 结果: -- 验证: -- 影响与剩余项: +- 结果:`pkg/extension/regulations.go`(EXT-20 接口、DTO、状态常量、`ErrRegulationsUnavailable`)、`surface.txt` 与 `SurfaceVersion` 2.15.0-experimental;`internal/app/regulations.go` 注册槽(仅 kbreg、一次、非 nil;声明式拒绝)与 inprocHost 四方法(有效账户检查、调用方取自分发上下文、schema 校验、查询去空白/1–256 字符/排除词拒绝且不调用提供方、状态透传、审计仅含修订/计数/状态/查询 sha256);单元与兼容测试。 +- 验证(SELF,host 于 HEAD aaed108):`go vet ./pkg/extension/ ./internal/app/` 与 `go test ./pkg/extension/... ./internal/app/...` PASS;全量 `GOWORK=off go vet ./... && go test -count=1 ./...` 40 包 ok,仅 `pkg/identity TestDSNFileMode` 失败——在未改动基线 cac60c0 上同样失败(本机环境问题,本变更未触及 pkg/identity),以 CI(GATE)为准。 +- Review:host 主模型审阅实际 diff(base 3187a00 / head aaed108),与提案第 2、4、5 节一致,无阻塞项。 +- 影响与剩余项:新增可选能力,未注册提供方时返回不可用,既有行为不变。 From 499ad471530ccbfe81b8c8e5a9456334f55a897d Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 18 Oct 2026 09:52:33 +0000 Subject: [PATCH 4/4] Close the EXT-20 task card before merge Why: Card closes in the same PR per delivery rules. Evidence: Card only. Impact: None. Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/212 Co-authored-by: Cursor --- .task/feature-regulations-kb-ext20.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.task/feature-regulations-kb-ext20.md b/.task/feature-regulations-kb-ext20.md index df89105..7f2f349 100644 --- a/.task/feature-regulations-kb-ext20.md +++ b/.task/feature-regulations-kb-ext20.md @@ -1,7 +1,7 @@ # feature/regulations-kb-ext20 任务卡 任务主记录:本文件随 PR 描述维护。 -状态:进行中;owner:weichen。 +状态:已完成(合入前闭合);owner:weichen。PR:https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/212 ## 基线与授权