diff --git a/docs/AI.md b/docs/AI.md index 09d5dcb4a..968c0b092 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -96,6 +96,26 @@ model-reported findings ── validated, deduplicated, stored MaybeInvestigateFinding() ── model investigation + governed fix planning/execution ``` +### Operational objectives and model-authored observers + +Patrol objectives retain an operator's desired outcome—for example, “keep +camera streams available”—instead of requiring the operator or Pulse to encode +every application-specific check. Active objectives are included as +value-oriented context in applicable Patrol runs. When an objective has no +observer, the configured model can use `patrol_propose_observer` to translate +that outcome into a bounded, versioned read-only observer proposal using the +estate context and tools available on that installation. + +The proposal boundary is intentionally not an execution boundary. Pulse owns +the observer identity, revision, SHA-256 digest, encrypted local persistence, +declared trigger kind, and read-only posture. A model cannot mark its proposal +validated, install it, give it action authority, or claim that monitoring is +active. Coverage remains `uncovered` until a core-owned validator, sandbox +installer, runtime, and health lease have actually accepted the artifact. This +keeps the intelligence in the model while keeping continuity and authority in +Pulse—and avoids calling a model repeatedly when a future cheap local observer +can handle the steady-state signal. + ### The Patrol attention queue The first thing Patrol shows is **Needs attention**, a single operator queue diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 30c84b2e2..94c86df73 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -256,6 +256,6 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern Pulse can make outbound connections when you enable specific features: -- **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. Those retained objectives are encrypted at rest in the local organization data directory; saving one does not itself call a model, and objective text is not included in Pulse usage telemetry or audit messages. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. +- **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. A provider may return a model-authored observer proposal for an uncovered objective; Pulse encrypts that artifact with the retained objective, excludes it from public objective reads and later prompt seeds, and does not include its content in usage telemetry or audit messages. Saving an objective by itself does not call a model. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. - **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable secure remote web access, Pulse Mobile pairing for handoff, and push notifications. See Settings → Remote Access. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index d8fbf0c89..83261e9c7 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -417,6 +417,20 @@ trigger kinds, version, state transitions, and machine failure code are validated and persisted by core. Observer authority never includes an infrastructure mutation capability. +During the first-party Patrol detection profile only, the core may attach the +objective store through the narrow `patrol_propose_observer` tool adapter. The +model must address an active objective at its current optimistic revision and +provide a bounded measurable interpretation, one declared trigger kind, +canonical-JSON probe proposal, wake evidence, and canonical-JSON requirements. +Core—not model arguments—assigns observer identity and version, forces read-only +posture and `proposed` state, computes the SHA-256 artifact digest, and persists +the artifact inside the encrypted objective document. Public objective reads +and later Patrol seed context omit artifact contents. The detection profile +permits this Pulse-state proposal write explicitly; interactive Assistant and +Patrol investigation profiles do not. A successful proposal remains +`uncovered/observer_proposed`: this tool has no validator, installer, execution, +health-lease, or infrastructure-action authority. + ## Canonical Files 1. `internal/ai/` diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 6438f61d6..38eeee64a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -4403,7 +4403,12 @@ declarations must also reuse that API identity contract: every registry `Tool.Definition.Name`, including `pulse_summarize` and Patrol runtime tools, must come from `internal/agentcapabilities/tool_names.go` so MCP and future external-agent adapters project the same tool identities that Assistant -executes. The manifest-backed MCP +executes. A canonical native name and invocation descriptor do not by themselves +publish an external capability: the first-party `patrol_propose_observer` name +is classified as a Pulse-state write for runtime enforcement but remains absent +from the authenticated objective API and every manifest surface tool allowlist. +External publication still requires an explicit manifest capability, governed +route, schema, and surface-contract decision. The manifest-backed MCP tool server must receive the whole manifest and derive its surface-specific tool projection, execution allowlist, and initialize surface-contract instructions from that single value; adapters must not pass only a capability diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 88d005f15..ff4f6b635 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -128,14 +128,21 @@ with bounded provenance and rollback; app-named integration code is an optimization only when repeated evidence justifies productizing it, never a prerequisite for a new objective. -The first canonical runtime slice is implemented by -`internal/ai/patrol_objectives.go` and `internal/api/patrol_objectives.go`. -It provides encrypted retained intent, optimistic revisions, resource scoping, -model seed projection, and the core-owned observer state machine. The public API -deliberately cannot attach an observer or author coverage. The monitor-building, -validation, sandbox installation, event delivery, and redesign executor remains -an extension of this lifecycle; until one records a healthy installed observer, -the saved objective truthfully remains uncovered. +The canonical retained-intent and proposal slices are implemented by +`internal/ai/patrol_objectives.go`, `internal/ai/patrol_observer_builder.go`, +`internal/ai/tools/tools_patrol.go`, and `internal/api/patrol_objectives.go`. +They provide encrypted retained intent, optimistic revisions, resource scoping, +model seed projection, the core-owned observer state machine, and the +Patrol-detection-only `patrol_propose_observer` builder. The model supplies a +bounded canonical-JSON probe proposal, measurable interpretation, wake evidence, +declared requirements, and one trigger kind; core supplies identity, version, +read-only posture, digest, encrypted persistence, and the `proposed` state. The +artifact is excluded from public objective reads and later prompt seeds. The +public API cannot attach an observer or author coverage, and the model-facing +tool cannot validate, install, execute, lease, or advance its proposal. Sandbox +validation, installation, event delivery, health leasing, and redesign +execution remain extensions of this lifecycle; until they record a healthy +installed observer, the saved objective truthfully remains uncovered. Desktop Autopilot activation consumes the server-owned acknowledgement contract through `frontend-modern/src/api/patrol.ts` and diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 8aec86504..e8548cf3f 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -58,6 +58,14 @@ counts. When Patrol is enabled, applicable active objective text becomes part of the context sent to the configured AI provider under the same local-provider and non-local provider-bound resource-policy redaction rules as other Patrol context. Merely saving an objective makes no outbound model request. +When a configured provider returns an observer proposal through the first-party +Patrol builder, the bounded probe and requirements artifact is persisted only +inside the same encrypted objective document. Public objective reads and later +Patrol prompt seeds omit the artifact, and telemetry/audit surfaces remain +content-free. The proposal is not executable and carries no infrastructure +mutation authority; any future validator or installer must preserve this +confidentiality boundary while enforcing declared secret references rather +than accepting secret values. ## Canonical Files diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index 30c84b2e2..94c86df73 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -256,6 +256,6 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern Pulse can make outbound connections when you enable specific features: -- **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. Those retained objectives are encrypted at rest in the local organization data directory; saving one does not itself call a model, and objective text is not included in Pulse usage telemetry or audit messages. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. +- **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. A provider may return a model-authored observer proposal for an uncovered objective; Pulse encrypts that artifact with the retained objective, excludes it from public objective reads and later prompt seeds, and does not include its content in usage telemetry or audit messages. Saving an objective by itself does not call a model. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. - **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable secure remote web access, Pulse Mobile pairing for handoff, and push notifications. See Settings → Remote Access. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. diff --git a/internal/agentcapabilities/invocation.go b/internal/agentcapabilities/invocation.go index 82a8e7719..a8023a2f7 100644 --- a/internal/agentcapabilities/invocation.go +++ b/internal/agentcapabilities/invocation.go @@ -232,6 +232,7 @@ var registryInvocationDescriptors = map[string]InvocationDescriptor{ PatrolActionCapabilitiesToolName: staticClass(ToolCallKindRead, MutationNone), PatrolReportFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState), PatrolResolveFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState), + PatrolProposeObserverToolName: staticClass(ToolCallKindWrite, MutationPulseState), } // Clone returns a deep copy of the descriptor so callers can never diff --git a/internal/agentcapabilities/invocation_test.go b/internal/agentcapabilities/invocation_test.go index e4774e3b6..bc88db928 100644 --- a/internal/agentcapabilities/invocation_test.go +++ b/internal/agentcapabilities/invocation_test.go @@ -97,6 +97,8 @@ func TestCanonicalDescriptorsPinSafetyCriticalClassifications(t *testing.T) { InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone}) assertClass(PatrolProposeActionToolName, map[string]interface{}{"resource_id": "vm:42"}, InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone}) + assertClass(PatrolProposeObserverToolName, map[string]interface{}{"objective_id": "objective-1"}, + InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState}) } func TestInvocationClassValidationRejectsOpenVocabulary(t *testing.T) { diff --git a/internal/agentcapabilities/tool_call_test.go b/internal/agentcapabilities/tool_call_test.go index 43af5e5df..1e00398f0 100644 --- a/internal/agentcapabilities/tool_call_test.go +++ b/internal/agentcapabilities/tool_call_test.go @@ -53,6 +53,7 @@ func TestPulseIntelligenceToolNameConstants(t *testing.T) { "PatrolAssessFindingToolName": PatrolAssessFindingToolName, "PatrolReportFindingToolName": PatrolReportFindingToolName, "PatrolResolveFindingToolName": PatrolResolveFindingToolName, + "PatrolProposeObserverToolName": PatrolProposeObserverToolName, } want := map[string]string{ "PulseQueryToolName": "pulse_query", @@ -85,6 +86,7 @@ func TestPulseIntelligenceToolNameConstants(t *testing.T) { "PatrolAssessFindingToolName": "patrol_assess_finding", "PatrolReportFindingToolName": "patrol_report_finding", "PatrolResolveFindingToolName": "patrol_resolve_finding", + "PatrolProposeObserverToolName": "patrol_propose_observer", } for name, got := range tests { @@ -132,6 +134,7 @@ func TestClassifyToolCallUsesSharedSafetyClassification(t *testing.T) { {name: "patrol findings read", toolName: "patrol_get_findings", want: ToolCallKindRead}, {name: "patrol finding assessment", toolName: "patrol_assess_finding", want: ToolCallKindWrite}, {name: "patrol report writes", toolName: "patrol_report_finding", want: ToolCallKindWrite}, + {name: "patrol observer proposals write", toolName: "patrol_propose_observer", want: ToolCallKindWrite}, {name: "unknown defaults write", toolName: "future_tool", want: ToolCallKindWrite}, {name: "generic action read", toolName: "future_tool", args: map[string]interface{}{"action": "inspect"}, want: ToolCallKindRead}, {name: "generic operation write", toolName: "future_tool", args: map[string]interface{}{"operation": "delete"}, want: ToolCallKindWrite}, diff --git a/internal/agentcapabilities/tool_names.go b/internal/agentcapabilities/tool_names.go index 96b7e1c41..3aba9b735 100644 --- a/internal/agentcapabilities/tool_names.go +++ b/internal/agentcapabilities/tool_names.go @@ -34,6 +34,10 @@ const ( PatrolAssessFindingToolName = "patrol_assess_finding" PatrolReportFindingToolName = "patrol_report_finding" PatrolResolveFindingToolName = "patrol_resolve_finding" + // PatrolProposeObserverToolName is the Patrol-detection-only monitor + // builder boundary. The model may author a bounded observer proposal, but + // core owns its identity, digest, validation, installation, and health. + PatrolProposeObserverToolName = "patrol_propose_observer" // PatrolProposeActionToolName is the side-effect-free typed action // proposal capture for Patrol investigations. Mutation-none: it // records a validated proposal in the request-local capture sink; diff --git a/internal/ai/patrol_ai.go b/internal/ai/patrol_ai.go index 6fe6e7ca0..8f0a3b717 100644 --- a/internal/ai/patrol_ai.go +++ b/internal/ai/patrol_ai.go @@ -590,6 +590,10 @@ func (p *PatrolService) runAIAnalysisState(ctx context.Context, snap patrolRunti // Set the patrol finding creator for this run executor.SetPatrolFindingCreator(adapter) defer executor.SetPatrolFindingCreator(nil) // Clear after run + if p.GetObjectiveStore() != nil { + executor.SetPatrolObserverProposer(adapter) + defer executor.SetPatrolObserverProposer(nil) + } // Execute the agentic patrol loop var inputTokens, outputTokens int @@ -1293,6 +1297,7 @@ You have access to the following tools to investigate infrastructure: - patrol_assess_finding — Record present, resolved, or uncertain for an existing finding - patrol_resolve_finding — Resolve an existing finding that is no longer an issue - patrol_get_findings — Check currently active findings (use before reporting to avoid duplicates) +- patrol_propose_observer — Propose a bounded read-only observer artifact for an uncovered operator objective; this does not install it or claim coverage ## How Patrol Works @@ -1317,6 +1322,8 @@ A direct provider-reported failed health check, failed backup, or broken replica **Step 3 — Report or assess findings.** Report new confirmed issues with patrol_report_finding. Every report call must independently include all required arguments: ` + strings.Join(tools.PatrolReportFindingRequiredArguments(), ", ") + `. This also applies when reporting several findings in parallel; do not omit a field because it is shared with another call. Call patrol_get_findings exactly once near the beginning of the run and reuse that result; do not call it again before the final summary. For every active finding it returned, call patrol_assess_finding exactly once with present, resolved, or uncertain and current evidence. Do not silently skip a known finding: omission is not evidence that it cleared. patrol_resolve_finding remains available for compatibility, but patrol_assess_finding is the complete existing-finding verdict. +**Operator objectives.** Objectives are retained outcomes, not scripts. When an active objective is explicitly marked observer_missing, use current estate context to call patrol_propose_observer once with the smallest useful read-only local observer design. Prefer event-driven evidence and use interval only for a bounded local probe, never repeated model polling. Do not re-propose an observer already marked proposed, validated, installed, or degraded unless the current evidence explicitly requires a new design. A successful proposal remains uncovered until core validates, sandboxes, installs, and leases it; never describe proposal creation as monitoring being active. + The snapshot eliminates routine data gathering. When a notable signal needs current or historical confirmation, gather enough evidence to distinguish real problems from noise before reporting it. ## Efficiency Rules diff --git a/internal/ai/patrol_objectives.go b/internal/ai/patrol_objectives.go index ec06725c3..28a164eba 100644 --- a/internal/ai/patrol_objectives.go +++ b/internal/ai/patrol_objectives.go @@ -1,9 +1,11 @@ package ai import ( + "crypto/sha256" "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "sort" @@ -19,13 +21,19 @@ import ( ) const ( - patrolObjectiveDocumentVersion = 1 - MaxPatrolObjectives = 256 - MaxActivePatrolObjectives = 64 - MaxPatrolObjectiveBriefBytes = 2 * 1024 - MaxPatrolObjectiveContextBytes = 4 * 1024 - MaxPatrolObjectiveResourceIDs = 64 - MaxPatrolObserverTriggerKinds = 8 + patrolObjectiveDocumentVersion = 1 + MaxPatrolObjectives = 256 + MaxActivePatrolObjectives = 64 + MaxPatrolObjectiveBriefBytes = 2 * 1024 + MaxPatrolObjectiveContextBytes = 4 * 1024 + MaxPatrolObjectiveResourceIDs = 64 + MaxPatrolObserverTriggerKinds = 8 + MaxPatrolObserverInterpretationBytes = 4 * 1024 + MaxPatrolObserverWakeEvidenceBytes = 4 * 1024 + MaxPatrolObserverProbeBytes = 16 * 1024 + MaxPatrolObserverRequirementsBytes = 8 * 1024 + maxPatrolObserverJSONDepth = 12 + maxPatrolObserverJSONNodes = 512 ) var ( @@ -89,6 +97,35 @@ type PatrolObserverRecord struct { FailureCode string `json:"failure_code,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + // Artifact is encrypted at rest with the objective document and is never + // projected through objectiveForRead. It is model-authored proposal input, + // not trusted executable code; only the future core validator/installer may + // consume it. + Artifact *PatrolObserverArtifact `json:"artifact,omitempty"` +} + +const PatrolObserverArtifactFormatV1 = "pulse-observer-proposal/v1" + +// PatrolObserverArtifact is the durable, versioned output of the model-facing +// observer builder. Probe and Requirements are bounded canonical JSON objects +// so later validator generations can evolve without treating prose as already +// executable authority. +type PatrolObserverArtifact struct { + Format string `json:"format"` + Interpretation string `json:"interpretation"` + Probe json.RawMessage `json:"probe"` + WakeEvidence string `json:"wake_evidence"` + Requirements json.RawMessage `json:"requirements"` +} + +type ProposePatrolObserverInput struct { + ExpectedRevision uint64 + Interpretation string + TriggerKinds []PatrolObserverTriggerKind + ProbeJSON string + WakeEvidence string + RequirementsJSON string + Actor string } type PatrolObjectiveCoverage struct { @@ -388,6 +425,99 @@ func (s *PatrolObjectiveStore) Delete(id string, expectedRevision uint64) error return nil } +// ProposeObserver records a model-authored observer artifact at the only +// lifecycle state a model may create: proposed. Core owns identity, version, +// digest, read-only posture, and every later transition. This method never +// validates, installs, or leases an observer and therefore never claims +// coverage merely because the model produced a plausible plan. +func (s *PatrolObjectiveStore) ProposeObserver(id string, input ProposePatrolObserverInput, now time.Time) (PatrolObjective, error) { + if s == nil { + return PatrolObjective{}, fmt.Errorf("%w: store unavailable", ErrPatrolObjectiveInvalid) + } + id = strings.TrimSpace(id) + now = normalizePatrolObjectiveTime(now) + if input.ExpectedRevision == 0 { + return PatrolObjective{}, fmt.Errorf("%w: expected revision is required", ErrPatrolObjectiveInvalid) + } + current, ok := s.Get(id, now) + if !ok { + return PatrolObjective{}, ErrPatrolObjectiveNotFound + } + if current.Revision != input.ExpectedRevision { + return PatrolObjective{}, ErrPatrolObjectiveConflict + } + if current.Status != PatrolObjectiveActive { + return PatrolObjective{}, fmt.Errorf("%w: observer proposals require an active objective", ErrPatrolObjectiveInvalid) + } + if current.Observer != nil && current.Observer.State != PatrolObserverDisabled { + return PatrolObjective{}, fmt.Errorf("%w: an existing observer cannot be displaced by a proposal", ErrPatrolObjectiveInvalid) + } + + interpretation, err := normalizePatrolObjectiveText(input.Interpretation, MaxPatrolObserverInterpretationBytes, false) + if err != nil { + return PatrolObjective{}, fmt.Errorf("%w: interpretation %v", ErrPatrolObjectiveInvalid, err) + } + wakeEvidence, err := normalizePatrolObjectiveText(input.WakeEvidence, MaxPatrolObserverWakeEvidenceBytes, false) + if err != nil { + return PatrolObjective{}, fmt.Errorf("%w: wake evidence %v", ErrPatrolObjectiveInvalid, err) + } + triggerKinds, err := normalizePatrolObserverTriggerKinds(input.TriggerKinds) + if err != nil { + return PatrolObjective{}, err + } + probe, err := normalizePatrolObserverJSONObject(input.ProbeJSON, MaxPatrolObserverProbeBytes, "probe") + if err != nil { + return PatrolObjective{}, err + } + requirements, err := normalizePatrolObserverJSONObject(input.RequirementsJSON, MaxPatrolObserverRequirementsBytes, "requirements") + if err != nil { + return PatrolObjective{}, err + } + artifact := &PatrolObserverArtifact{ + Format: PatrolObserverArtifactFormatV1, + Interpretation: interpretation, + Probe: probe, + WakeEvidence: wakeEvidence, + Requirements: requirements, + } + digest, err := patrolObserverArtifactDigest(artifact) + if err != nil { + return PatrolObjective{}, err + } + observerID := "observer-" + uuid.NewString() + version := uint64(1) + if current.Observer != nil { + observerID = current.Observer.ID + version = current.Observer.Version + 1 + } + return s.RecordObserver(id, input.ExpectedRevision, PatrolObserverRecord{ + ID: observerID, + Version: version, + State: PatrolObserverProposed, + ArtifactDigest: digest, + TriggerKinds: triggerKinds, + ReadOnly: true, + Artifact: artifact, + }, input.Actor, now) +} + +// GetObserverArtifact is an internal validator/installer seam. The public +// objective read model intentionally strips this data so model-authored probe +// material and declared secret/filesystem requirements do not leak through the +// settings API or back into later prompts as trusted instructions. +func (s *PatrolObjectiveStore) GetObserverArtifact(id string) (PatrolObserverArtifact, bool) { + if s == nil { + return PatrolObserverArtifact{}, false + } + s.mu.RLock() + defer s.mu.RUnlock() + objective, ok := s.objectives[strings.TrimSpace(id)] + if !ok || objective == nil || objective.Observer == nil || objective.Observer.Artifact == nil { + return PatrolObserverArtifact{}, false + } + return *clonePatrolObserverArtifact(objective.Observer.Artifact), true +} + // RecordObserver persists a core-owned observer lifecycle transition. Public // objective clients cannot call this method through HTTP. A future constrained // monitor builder and installer must use it only after validating the observer @@ -502,6 +632,9 @@ func (s *PatrolObjectiveStore) persistLocked(objectives map[string]*PatrolObject func objectiveForRead(objective *PatrolObjective, now time.Time) PatrolObjective { copy := clonePatrolObjective(objective) + if copy != nil && copy.Observer != nil { + copy.Observer.Artifact = nil + } copy.Coverage = derivePatrolObjectiveCoverage(copy, now) return *copy } @@ -676,6 +809,21 @@ func normalizePatrolObserver(observer PatrolObserverRecord, now time.Time) (Patr if !isPatrolArtifactDigest(observer.ArtifactDigest) { return PatrolObserverRecord{}, fmt.Errorf("%w: observer artifact digest must be sha256", ErrPatrolObjectiveInvalid) } + observer.Artifact = clonePatrolObserverArtifact(observer.Artifact) + if observer.Artifact != nil { + artifact, err := normalizePatrolObserverArtifact(observer.Artifact) + if err != nil { + return PatrolObserverRecord{}, err + } + observer.Artifact = artifact + digest, err := patrolObserverArtifactDigest(observer.Artifact) + if err != nil { + return PatrolObserverRecord{}, err + } + if digest != observer.ArtifactDigest { + return PatrolObserverRecord{}, fmt.Errorf("%w: observer artifact digest mismatch", ErrPatrolObjectiveInvalid) + } + } triggerKinds, err := normalizePatrolObserverTriggerKinds(observer.TriggerKinds) if err != nil { return PatrolObserverRecord{}, err @@ -892,9 +1040,128 @@ func clonePatrolObserver(observer *PatrolObserverRecord) *PatrolObserverRecord { copy.TriggerKinds = append([]PatrolObserverTriggerKind{}, observer.TriggerKinds...) copy.ValidUntil = clonePatrolTime(observer.ValidUntil) copy.LastEvidenceAt = clonePatrolTime(observer.LastEvidenceAt) + copy.Artifact = clonePatrolObserverArtifact(observer.Artifact) return © } +func clonePatrolObserverArtifact(artifact *PatrolObserverArtifact) *PatrolObserverArtifact { + if artifact == nil { + return nil + } + copy := *artifact + copy.Probe = append(json.RawMessage(nil), artifact.Probe...) + copy.Requirements = append(json.RawMessage(nil), artifact.Requirements...) + return © +} + +func normalizePatrolObserverJSONObject(raw string, maxBytes int, field string) (json.RawMessage, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("%w: observer %s is required", ErrPatrolObjectiveInvalid, field) + } + if len(raw) > maxBytes { + return nil, fmt.Errorf("%w: observer %s exceeds %d bytes", ErrPatrolObjectiveInvalid, field, maxBytes) + } + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var value interface{} + if err := decoder.Decode(&value); err != nil { + return nil, fmt.Errorf("%w: observer %s must be a JSON object: %v", ErrPatrolObjectiveInvalid, field, err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("%w: observer %s must contain one JSON object", ErrPatrolObjectiveInvalid, field) + } + if _, ok := value.(map[string]interface{}); !ok { + return nil, fmt.Errorf("%w: observer %s must be a JSON object", ErrPatrolObjectiveInvalid, field) + } + nodes := 0 + if err := validatePatrolObserverJSONShape(value, 0, &nodes); err != nil { + return nil, fmt.Errorf("%w: observer %s %v", ErrPatrolObjectiveInvalid, field, err) + } + canonical, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("%w: encode observer %s: %v", ErrPatrolObjectiveInvalid, field, err) + } + return canonical, nil +} + +func validatePatrolObserverJSONShape(value interface{}, depth int, nodes *int) error { + if depth > maxPatrolObserverJSONDepth { + return errors.New("exceeds maximum JSON depth") + } + *nodes++ + if *nodes > maxPatrolObserverJSONNodes { + return errors.New("exceeds maximum JSON nodes") + } + switch typed := value.(type) { + case map[string]interface{}: + for key, child := range typed { + if strings.TrimSpace(key) == "" || len(key) > 128 || containsUnsafePatrolText(key) { + return errors.New("contains an invalid JSON key") + } + if err := validatePatrolObserverJSONShape(child, depth+1, nodes); err != nil { + return err + } + } + case []interface{}: + for _, child := range typed { + if err := validatePatrolObserverJSONShape(child, depth+1, nodes); err != nil { + return err + } + } + case string: + if len(typed) > MaxPatrolObserverProbeBytes || containsUnsafePatrolText(typed) { + return errors.New("contains an invalid JSON string") + } + case nil, bool, json.Number: + return nil + default: + return errors.New("contains an unsupported JSON value") + } + return nil +} + +func patrolObserverArtifactDigest(artifact *PatrolObserverArtifact) (string, error) { + if artifact == nil || artifact.Format != PatrolObserverArtifactFormatV1 { + return "", fmt.Errorf("%w: unsupported observer artifact format", ErrPatrolObjectiveInvalid) + } + data, err := json.Marshal(artifact) + if err != nil { + return "", fmt.Errorf("%w: encode observer artifact: %v", ErrPatrolObjectiveInvalid, err) + } + sum := sha256.Sum256(data) + return fmt.Sprintf("sha256:%x", sum[:]), nil +} + +func normalizePatrolObserverArtifact(artifact *PatrolObserverArtifact) (*PatrolObserverArtifact, error) { + if artifact == nil || artifact.Format != PatrolObserverArtifactFormatV1 { + return nil, fmt.Errorf("%w: unsupported observer artifact format", ErrPatrolObjectiveInvalid) + } + interpretation, err := normalizePatrolObjectiveText(artifact.Interpretation, MaxPatrolObserverInterpretationBytes, false) + if err != nil { + return nil, fmt.Errorf("%w: observer interpretation %v", ErrPatrolObjectiveInvalid, err) + } + wakeEvidence, err := normalizePatrolObjectiveText(artifact.WakeEvidence, MaxPatrolObserverWakeEvidenceBytes, false) + if err != nil { + return nil, fmt.Errorf("%w: observer wake evidence %v", ErrPatrolObjectiveInvalid, err) + } + probe, err := normalizePatrolObserverJSONObject(string(artifact.Probe), MaxPatrolObserverProbeBytes, "probe") + if err != nil { + return nil, err + } + requirements, err := normalizePatrolObserverJSONObject(string(artifact.Requirements), MaxPatrolObserverRequirementsBytes, "requirements") + if err != nil { + return nil, err + } + return &PatrolObserverArtifact{ + Format: PatrolObserverArtifactFormatV1, + Interpretation: interpretation, + Probe: probe, + WakeEvidence: wakeEvidence, + Requirements: requirements, + }, nil +} + func clonePatrolTime(value *time.Time) *time.Time { if value == nil { return nil @@ -949,7 +1216,7 @@ func (p *PatrolService) seedPatrolObjectives(effectiveScopeIDs []string, scoped } scopeLabel = strings.Join(quotedIDs, ", ") } - line := fmt.Sprintf("- Objective %s [%s; scope: %s]: %q", objective.ID, objective.Coverage.State, scopeLabel, objective.Brief) + line := fmt.Sprintf("- Objective %s [revision: %d; coverage: %s/%s; scope: %s]: %q", objective.ID, objective.Revision, objective.Coverage.State, objective.Coverage.ReasonCode, scopeLabel, objective.Brief) if objective.OptionalContext != "" { line += fmt.Sprintf(" Optional context: %q", objective.OptionalContext) } diff --git a/internal/ai/patrol_objectives_test.go b/internal/ai/patrol_objectives_test.go index 48246dabe..fa10795f5 100644 --- a/internal/ai/patrol_objectives_test.go +++ b/internal/ai/patrol_objectives_test.go @@ -92,6 +92,103 @@ func TestPatrolObjectiveStorePersistsEncrypted(t *testing.T) { } } +func TestPatrolObjectiveStoreProposesEncryptedObserverWithoutClaimingCoverage(t *testing.T) { + dataDir := t.TempDir() + store, err := NewPatrolObjectiveStore(dataDir) + if err != nil { + t.Fatalf("new persistent store: %v", err) + } + now := time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC) + objective, err := store.Create(CreatePatrolObjectiveInput{Brief: "Keep camera streams available"}, now) + if err != nil { + t.Fatalf("create objective: %v", err) + } + objective, err = store.ProposeObserver(objective.ID, ProposePatrolObserverInput{ + ExpectedRevision: objective.Revision, + Interpretation: "Wake Patrol when a camera changes from reachable to unreachable.", + TriggerKinds: []PatrolObserverTriggerKind{PatrolObserverTriggerEvent}, + ProbeJSON: `{ "outputs": {"unhealthy": "camera unreachable"}, "source": "canonical resource events" }`, + WakeEvidence: "A scoped camera resource transitions to unreachable.", + RequirementsJSON: `{ "network": [], "filesystem": [], "secrets": [], "runtime": "pulse" }`, + Actor: "patrol:model", + }, now.Add(time.Minute)) + if err != nil { + t.Fatalf("propose observer: %v", err) + } + if objective.Observer == nil || objective.Observer.State != PatrolObserverProposed || !objective.Observer.ReadOnly { + t.Fatalf("observer proposal = %+v", objective.Observer) + } + if objective.Observer.Artifact != nil { + t.Fatal("public objective read leaked model-authored observer artifact") + } + if objective.Coverage.State != PatrolObjectiveUncovered || objective.Coverage.ReasonCode != "observer_proposed" { + t.Fatalf("proposal coverage = %+v", objective.Coverage) + } + if _, err := store.ProposeObserver(objective.ID, ProposePatrolObserverInput{ + ExpectedRevision: objective.Revision, + Interpretation: "Replace the proposal without a core lifecycle decision.", + TriggerKinds: []PatrolObserverTriggerKind{PatrolObserverTriggerEvent}, + ProbeJSON: `{}`, + WakeEvidence: "Any camera failure.", + RequirementsJSON: `{}`, + }, now.Add(90*time.Second)); !errorsIsPatrolObjectiveInvalid(err) { + t.Fatalf("proposal displacement error = %v", err) + } + artifact, ok := store.GetObserverArtifact(objective.ID) + if !ok || artifact.Format != PatrolObserverArtifactFormatV1 || !strings.Contains(string(artifact.Probe), "canonical resource events") { + t.Fatalf("internal observer artifact = %+v, found=%v", artifact, ok) + } + + ciphertext, err := os.ReadFile(filepath.Join(dataDir, "ai_patrol_objectives.enc")) + if err != nil { + t.Fatalf("read encrypted objective file: %v", err) + } + if strings.Contains(string(ciphertext), "canonical resource events") { + t.Fatal("observer artifact was persisted in plaintext") + } + reloaded, err := NewPatrolObjectiveStore(dataDir) + if err != nil { + t.Fatalf("reload objective store: %v", err) + } + reloadedArtifact, ok := reloaded.GetObserverArtifact(objective.ID) + if !ok || !strings.Contains(string(reloadedArtifact.Probe), "canonical resource events") { + t.Fatalf("reloaded artifact = %+v, found=%v", reloadedArtifact, ok) + } + public, ok := reloaded.Get(objective.ID, now.Add(2*time.Minute)) + if !ok || public.Observer == nil || public.Observer.Artifact != nil || public.Coverage.ReasonCode != "observer_proposed" { + t.Fatalf("reloaded public objective = %+v, found=%v", public, ok) + } +} + +func TestPatrolObjectiveStoreRejectsInvalidObserverProposalArtifact(t *testing.T) { + store := NewInMemoryPatrolObjectiveStore() + now := time.Now().UTC() + objective, err := store.Create(CreatePatrolObjectiveInput{Brief: "Keep playback smooth"}, now) + if err != nil { + t.Fatalf("create objective: %v", err) + } + base := ProposePatrolObserverInput{ + ExpectedRevision: objective.Revision, + Interpretation: "Detect sustained playback buffering.", + TriggerKinds: []PatrolObserverTriggerKind{PatrolObserverTriggerEvent}, + WakeEvidence: "Playback enters a buffering state.", + RequirementsJSON: `{}`, + } + base.ProbeJSON = `[]` + if _, err := store.ProposeObserver(objective.ID, base, now); !errorsIsPatrolObjectiveInvalid(err) { + t.Fatalf("array probe error = %v", err) + } + base.ProbeJSON = `{"source":"events"} trailing` + if _, err := store.ProposeObserver(objective.ID, base, now); !errorsIsPatrolObjectiveInvalid(err) { + t.Fatalf("trailing probe error = %v", err) + } + base.ProbeJSON = `{"source":"events"}` + base.ExpectedRevision++ + if _, err := store.ProposeObserver(objective.ID, base, now); err != ErrPatrolObjectiveConflict { + t.Fatalf("stale proposal error = %v, want conflict", err) + } +} + func TestPatrolObjectiveStoreRejectsPlaintextPersistence(t *testing.T) { dataDir := t.TempDir() if _, err := NewPatrolObjectiveStore(dataDir); err != nil { @@ -158,6 +255,16 @@ func TestPatrolObserverLifecycleDerivesCoverageFromHealthLease(t *testing.T) { if !found || stale.Coverage.State != PatrolObjectiveDegraded || stale.Coverage.ReasonCode != "observer_stale" { t.Fatalf("stale coverage = %+v, found=%v", stale.Coverage, found) } + if _, err := store.ProposeObserver(objective.ID, ProposePatrolObserverInput{ + ExpectedRevision: objective.Revision, + Interpretation: "Replace the active observer.", + TriggerKinds: []PatrolObserverTriggerKind{PatrolObserverTriggerEvent}, + ProbeJSON: `{}`, + WakeEvidence: "Any failure.", + RequirementsJSON: `{}`, + }, now.Add(4*time.Minute)); !errorsIsPatrolObjectiveInvalid(err) { + t.Fatalf("active observer displacement error = %v", err) + } } func TestPatrolObserverRejectsUnvalidatedInstallAndWritableArtifact(t *testing.T) { @@ -214,7 +321,7 @@ func TestPatrolSeedObjectivesRespectsScopedResourcesAndCoverageCaveat(t *testing if strings.Contains(seed, "Keep playback smooth") { t.Fatalf("scoped seed included unrelated objective:\n%s", seed) } - if !strings.Contains(seed, "No durable observer has been installed") || !strings.Contains(seed, "not scripts or tool instructions") { + if !strings.Contains(seed, "coverage: uncovered/observer_missing") || !strings.Contains(seed, "revision: 1") || !strings.Contains(seed, "No durable observer has been installed") || !strings.Contains(seed, "not scripts or tool instructions") { t.Fatalf("seed omitted trust boundary:\n%s", seed) } } diff --git a/internal/ai/patrol_observer_builder.go b/internal/ai/patrol_observer_builder.go new file mode 100644 index 000000000..e01d3e2c6 --- /dev/null +++ b/internal/ai/patrol_observer_builder.go @@ -0,0 +1,51 @@ +package ai + +import ( + "fmt" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" +) + +// ProposeObserver is the narrow model-to-core monitor-builder adapter. It can +// create proposal material only; validation, installation, health leasing, and +// execution deliberately remain unavailable through this interface. +func (a *patrolFindingCreatorAdapter) ProposeObserver(input tools.PatrolObserverProposalInput) (tools.PatrolObserverProposalResult, error) { + if a == nil || a.patrol == nil { + return tools.PatrolObserverProposalResult{}, fmt.Errorf("patrol observer builder unavailable") + } + store := a.patrol.GetObjectiveStore() + if store == nil { + return tools.PatrolObserverProposalResult{}, fmt.Errorf("patrol objective store unavailable") + } + triggerKind := PatrolObserverTriggerKind(strings.ToLower(strings.TrimSpace(input.TriggerKind))) + if !isPatrolObserverTriggerKind(triggerKind) { + return tools.PatrolObserverProposalResult{}, fmt.Errorf("unsupported observer trigger kind %q", input.TriggerKind) + } + objective, err := store.ProposeObserver(input.ObjectiveID, ProposePatrolObserverInput{ + ExpectedRevision: input.ExpectedRevision, + Interpretation: input.Interpretation, + TriggerKinds: []PatrolObserverTriggerKind{triggerKind}, + ProbeJSON: input.ProbeJSON, + WakeEvidence: input.WakeEvidence, + RequirementsJSON: input.RequirementsJSON, + Actor: "patrol:model", + }, time.Now().UTC()) + if err != nil { + return tools.PatrolObserverProposalResult{}, err + } + if objective.Observer == nil { + return tools.PatrolObserverProposalResult{}, fmt.Errorf("observer proposal was not retained") + } + return tools.PatrolObserverProposalResult{ + ObjectiveID: objective.ID, + Revision: objective.Revision, + ObserverID: objective.Observer.ID, + Version: objective.Observer.Version, + State: string(objective.Observer.State), + ArtifactDigest: objective.Observer.ArtifactDigest, + CoverageState: string(objective.Coverage.State), + CoverageReason: objective.Coverage.ReasonCode, + }, nil +} diff --git a/internal/ai/tools/execution_profile.go b/internal/ai/tools/execution_profile.go index 01f854d96..8a3e845d7 100644 --- a/internal/ai/tools/execution_profile.go +++ b/internal/ai/tools/execution_profile.go @@ -22,9 +22,9 @@ const ( ProfileInteractiveAssistant ExecutionProfile = iota // ProfilePatrolDetection is the scheduled Patrol detection posture: // non-interactive, no infrastructure mutations, and Pulse-state - // mutations restricted to the finding lifecycle tools + // mutations restricted to the finding lifecycle and observer-proposal tools // (patrol_report_finding / patrol_assess_finding / - // patrol_resolve_finding). + // patrol_resolve_finding / patrol_propose_observer). ProfilePatrolDetection // ProfilePatrolInvestigation is the Patrol investigation posture: // non-interactive and structurally read-only - no infrastructure and @@ -62,9 +62,10 @@ func (p ExecutionProfile) NonInteractive() bool { // writes, which detection has no business performing. func patrolDetectionPulseStateAllowlist() map[string]bool { return map[string]bool{ - agentcapabilities.PatrolAssessFindingToolName: true, - agentcapabilities.PatrolReportFindingToolName: true, - agentcapabilities.PatrolResolveFindingToolName: true, + agentcapabilities.PatrolAssessFindingToolName: true, + agentcapabilities.PatrolProposeObserverToolName: true, + agentcapabilities.PatrolReportFindingToolName: true, + agentcapabilities.PatrolResolveFindingToolName: true, } } diff --git a/internal/ai/tools/executor.go b/internal/ai/tools/executor.go index e9bd55df6..f18f7457f 100644 --- a/internal/ai/tools/executor.go +++ b/internal/ai/tools/executor.go @@ -98,6 +98,34 @@ type PatrolFindingCreator interface { GetActiveFindings(resourceID, minSeverity string) []PatrolFindingInfo } +// PatrolObserverProposer is attached only for a first-party Patrol detection +// run. It accepts model-authored observer artifacts at proposed state; it does +// not grant validation, installation, execution, or infrastructure mutation. +type PatrolObserverProposer interface { + ProposeObserver(input PatrolObserverProposalInput) (PatrolObserverProposalResult, error) +} + +type PatrolObserverProposalInput struct { + ObjectiveID string + ExpectedRevision uint64 + Interpretation string + TriggerKind string + ProbeJSON string + WakeEvidence string + RequirementsJSON string +} + +type PatrolObserverProposalResult struct { + ObjectiveID string `json:"objective_id"` + Revision uint64 `json:"revision"` + ObserverID string `json:"observer_id"` + Version uint64 `json:"observer_version"` + State string `json:"state"` + ArtifactDigest string `json:"artifact_digest"` + CoverageState string `json:"coverage_state"` + CoverageReason string `json:"coverage_reason"` +} + // PatrolFindingAssessor is the additive explicit-verdict extension implemented // by the current Patrol adapter. Keeping it separate preserves compatibility // with narrow test and extension adapters that only implement legacy finding @@ -620,8 +648,10 @@ type PulseToolExecutor struct { // Patrol finding creator — set only during a patrol run, nil otherwise. // Enables patrol_report_finding, patrol_resolve_finding, patrol_get_findings tools. - patrolFindingCreatorMu sync.RWMutex - patrolFindingCreator PatrolFindingCreator + patrolFindingCreatorMu sync.RWMutex + patrolFindingCreator PatrolFindingCreator + patrolObserverProposerMu sync.RWMutex + patrolObserverProposer PatrolObserverProposer // Report-narration providers, used by pulse_summarize when the // per-tenant AI service is configured. Absent values cause the tool @@ -780,6 +810,7 @@ func (e *PulseToolExecutor) Clone() *PulseToolExecutor { registry: e.registry, } clone.patrolFindingCreator = e.GetPatrolFindingCreator() + clone.patrolObserverProposer = e.GetPatrolObserverProposer() return clone } @@ -1036,6 +1067,18 @@ func (e *PulseToolExecutor) GetPatrolFindingCreator() PatrolFindingCreator { return e.patrolFindingCreator } +func (e *PulseToolExecutor) SetPatrolObserverProposer(proposer PatrolObserverProposer) { + e.patrolObserverProposerMu.Lock() + e.patrolObserverProposer = proposer + e.patrolObserverProposerMu.Unlock() +} + +func (e *PulseToolExecutor) GetPatrolObserverProposer() PatrolObserverProposer { + e.patrolObserverProposerMu.RLock() + defer e.patrolObserverProposerMu.RUnlock() + return e.patrolObserverProposer +} + // GetResolvedContext returns the current resolved context (may be nil) func (e *PulseToolExecutor) GetResolvedContext() ResolvedContextProvider { return e.resolvedContext @@ -1128,6 +1171,8 @@ func (e *PulseToolExecutor) isToolAvailable(name string) bool { case agentcapabilities.PatrolReportFindingToolName, agentcapabilities.PatrolAssessFindingToolName, agentcapabilities.PatrolResolveFindingToolName, agentcapabilities.PatrolGetFindingsToolName: // Always available when registered; handler checks patrolFindingCreator at runtime return e.GetPatrolFindingCreator() != nil + case agentcapabilities.PatrolProposeObserverToolName: + return e.GetPatrolObserverProposer() != nil case agentcapabilities.PatrolProposeActionToolName, agentcapabilities.PatrolActionCapabilitiesToolName: // These investigation-only tools share the request-local proposal // capture: it supplies both trusted correlation and the tenant-bound diff --git a/internal/ai/tools/executor_clone_test.go b/internal/ai/tools/executor_clone_test.go index c5a3b3b77..9da097010 100644 --- a/internal/ai/tools/executor_clone_test.go +++ b/internal/ai/tools/executor_clone_test.go @@ -4,6 +4,7 @@ import "testing" func TestPulseToolExecutorCloneIsolatesSessionState(t *testing.T) { creator := &mockPatrolFindingCreator{} + proposer := &mockPatrolObserverProposer{} resolved := &mockResolvedContext{} original := NewPulseToolExecutor(ExecutorConfig{}) @@ -11,6 +12,7 @@ func TestPulseToolExecutorCloneIsolatesSessionState(t *testing.T) { original.SetOrgID("tenant-a") original.SetResolvedContext(resolved) original.SetPatrolFindingCreator(creator) + original.SetPatrolObserverProposer(proposer) original.protectedGuests = []string{"101"} clone := original.Clone() @@ -26,6 +28,9 @@ func TestPulseToolExecutorCloneIsolatesSessionState(t *testing.T) { if clone.GetPatrolFindingCreator() != creator { t.Fatal("Clone() should retain patrol creator availability for the new run") } + if clone.GetPatrolObserverProposer() != proposer { + t.Fatal("Clone() should retain patrol observer proposer availability for the new run") + } if clone.targetType != original.targetType || clone.targetID != original.targetID || clone.isAutonomous != original.isAutonomous { t.Fatalf("Clone() lost base execution context: got %q/%q/%v", clone.targetType, clone.targetID, clone.isAutonomous) } diff --git a/internal/ai/tools/tools_patrol.go b/internal/ai/tools/tools_patrol.go index f7eb08931..8cf46482b 100644 --- a/internal/ai/tools/tools_patrol.go +++ b/internal/ai/tools/tools_patrol.go @@ -2,7 +2,11 @@ package tools import ( "context" + "encoding/json" "fmt" + "math" + "sort" + "strconv" "strings" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" @@ -216,6 +220,144 @@ Returns a list of active findings with their IDs, severity, resource, and title. Summary: "Reads active patrol findings for deduplication and investigation context.", }, }) + + // patrol_propose_observer — the model authors a bounded artifact, while + // core retains all lifecycle authority. A successful call is intentionally + // still uncovered until a separate validator and installer accept it. + e.registry.registerBuiltin(RegisteredTool{ + Definition: Tool{ + Name: agentcapabilities.PatrolProposeObserverToolName, + Description: `Propose a durable read-only observer for an active operator objective that is currently missing coverage. + +Use this only when the objective context says observer_missing, or when current evidence clearly requires a new observer version. Translate the operator's outcome into the smallest useful local observer without hard-coding an application into Pulse. The probe_json and requirements_json fields must each be one bounded JSON object. Describe what a future constrained runtime should observe; do not include mutation commands, credentials, or secret values. + +This tool records only a versioned proposed artifact. It does not validate, install, execute, or claim coverage. Core owns the observer ID, version, SHA-256 digest, read-only posture, sandboxing, installation, health lease, and any later transition. + +Returns the proposed observer identity and the truthful uncovered coverage reason.`, + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]PropertySchema{ + "objective_id": { + Type: "string", + Description: "Objective ID exactly as shown in Operator Objectives", + }, + "expected_revision": { + Type: "number", + Description: "Current objective revision exactly as shown in Operator Objectives", + }, + "interpretation": { + Type: "string", + Description: "Concise measurable interpretation of the operator's desired outcome", + }, + "trigger_kind": { + Type: "string", + Description: "Cheapest appropriate local wake source; interval means a bounded local probe, never repeated model polling", + Enum: []string{"event", "webhook", "log", "file", "socket", "api", "interval"}, + }, + "probe_json": { + Type: "string", + Description: "One JSON object describing the read-only probe, signal extraction, and health/failure outputs. It is proposal material, not executable authority.", + }, + "wake_evidence": { + Type: "string", + Description: "Concrete evidence transition that should wake Patrol for model reasoning", + }, + "requirements_json": { + Type: "string", + Description: "One JSON object declaring network, filesystem, secret-reference, runtime, timeout, and resource-budget requirements. Use empty arrays/objects when none; never include secret values.", + }, + }, + Required: []string{"objective_id", "expected_revision", "interpretation", "trigger_kind", "probe_json", "wake_evidence", "requirements_json"}, + }, + }, + Handler: handlePatrolProposeObserver, + Governance: ToolGovernance{ + ActionMode: ToolActionWrite, + ApprovalPolicy: ToolApprovalScopeOnly, + ApprovalSummary: "patrol-only; records a non-executable observer proposal", + Summary: "Records a bounded model-authored observer proposal without installing or executing it.", + }, + }) +} + +func handlePatrolProposeObserver(_ context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) { + proposer := e.GetPatrolObserverProposer() + if proposer == nil { + return NewTextResult("patrol_propose_observer is only available during a Patrol detection run with an objective store."), nil + } + objectiveID, _ := args["objective_id"].(string) + interpretation, _ := args["interpretation"].(string) + triggerKind, _ := args["trigger_kind"].(string) + probeJSON, _ := args["probe_json"].(string) + wakeEvidence, _ := args["wake_evidence"].(string) + requirementsJSON, _ := args["requirements_json"].(string) + objectiveID = strings.TrimSpace(objectiveID) + interpretation = strings.TrimSpace(interpretation) + triggerKind = strings.ToLower(strings.TrimSpace(triggerKind)) + probeJSON = strings.TrimSpace(probeJSON) + wakeEvidence = strings.TrimSpace(wakeEvidence) + requirementsJSON = strings.TrimSpace(requirementsJSON) + expectedRevision, revisionOK := patrolObserverExpectedRevision(args["expected_revision"]) + + missing := make([]string, 0, 7) + for name, value := range map[string]string{ + "objective_id": objectiveID, "interpretation": interpretation, + "trigger_kind": triggerKind, "probe_json": probeJSON, + "wake_evidence": wakeEvidence, "requirements_json": requirementsJSON, + } { + if value == "" { + missing = append(missing, name) + } + } + if !revisionOK { + missing = append(missing, "expected_revision") + } + if len(missing) > 0 { + sort.Strings(missing) + return NewErrorResult(fmt.Errorf("missing or invalid required fields: %s", strings.Join(missing, ", "))), nil + } + validTrigger := map[string]bool{"event": true, "webhook": true, "log": true, "file": true, "socket": true, "api": true, "interval": true} + if !validTrigger[triggerKind] { + return NewErrorResult(fmt.Errorf("invalid trigger_kind %q", triggerKind)), nil + } + + result, err := proposer.ProposeObserver(PatrolObserverProposalInput{ + ObjectiveID: objectiveID, ExpectedRevision: expectedRevision, + Interpretation: interpretation, TriggerKind: triggerKind, + ProbeJSON: probeJSON, WakeEvidence: wakeEvidence, + RequirementsJSON: requirementsJSON, + }) + if err != nil { + return NewErrorResult(fmt.Errorf("failed to propose observer: %w", err)), nil + } + return NewJSONResult(map[string]interface{}{ + "ok": true, "objective_id": result.ObjectiveID, "revision": result.Revision, + "observer_id": result.ObserverID, "observer_version": result.Version, + "state": result.State, "artifact_digest": result.ArtifactDigest, + "coverage_state": result.CoverageState, "coverage_reason": result.CoverageReason, + }), nil +} + +func patrolObserverExpectedRevision(value interface{}) (uint64, bool) { + switch typed := value.(type) { + case float64: + if typed <= 0 || typed != math.Trunc(typed) || typed > float64(^uint64(0)) { + return 0, false + } + return uint64(typed), true + case int: + if typed <= 0 { + return 0, false + } + return uint64(typed), true + case uint64: + return typed, typed > 0 + case json.Number: + parsed, err := strconv.ParseUint(string(typed), 10, 64) + return parsed, err == nil && parsed > 0 + default: + return 0, false + } } func handlePatrolAssessFinding(_ context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) { diff --git a/internal/ai/tools/tools_patrol_test.go b/internal/ai/tools/tools_patrol_test.go index e3368b009..56071c8fc 100644 --- a/internal/ai/tools/tools_patrol_test.go +++ b/internal/ai/tools/tools_patrol_test.go @@ -35,6 +35,17 @@ type mockPatrolFindingCreator struct { checked bool } +type mockPatrolObserverProposer struct { + input PatrolObserverProposalInput + result PatrolObserverProposalResult + err error +} + +func (m *mockPatrolObserverProposer) ProposeObserver(input PatrolObserverProposalInput) (PatrolObserverProposalResult, error) { + m.input = input + return m.result, m.err +} + func (m *mockPatrolFindingCreator) AssessFinding(input PatrolFindingAssessmentInput) error { m.assessCalls = append(m.assessCalls, input) if m.assessFindingFunc != nil { @@ -763,7 +774,7 @@ func TestPatrolToolsRegistered(t *testing.T) { var reportTool Tool var getFindingsTool Tool for _, tool := range tools { - if tool.Name == "patrol_report_finding" || tool.Name == "patrol_assess_finding" || tool.Name == "patrol_resolve_finding" || tool.Name == "patrol_get_findings" { + if tool.Name == "patrol_report_finding" || tool.Name == "patrol_assess_finding" || tool.Name == "patrol_resolve_finding" || tool.Name == "patrol_get_findings" || tool.Name == "patrol_propose_observer" { found[tool.Name] = true } if tool.Name == "patrol_resolve_finding" { @@ -781,6 +792,7 @@ func TestPatrolToolsRegistered(t *testing.T) { assert.True(t, found["patrol_assess_finding"], "patrol_assess_finding should be registered") assert.True(t, found["patrol_resolve_finding"], "patrol_resolve_finding should be registered") assert.True(t, found["patrol_get_findings"], "patrol_get_findings should be registered") + assert.True(t, found["patrol_propose_observer"], "patrol_propose_observer should be registered") require.NotEmpty(t, resolveTool.Name) assert.Contains(t, resolveTool.InputSchema.Required, agentcapabilities.FindingIDArgumentName) assert.Contains(t, resolveTool.InputSchema.Required, agentcapabilities.ReasonArgumentName) @@ -815,15 +827,46 @@ func TestPatrolToolsAvailability(t *testing.T) { assert.False(t, exec.isToolAvailable("patrol_assess_finding")) assert.False(t, exec.isToolAvailable("patrol_resolve_finding")) assert.False(t, exec.isToolAvailable("patrol_get_findings")) + assert.False(t, exec.isToolAvailable("patrol_propose_observer")) // Set creator exec.SetPatrolFindingCreator(&mockPatrolFindingCreator{}) + exec.SetPatrolObserverProposer(&mockPatrolObserverProposer{}) // Now they should be available assert.True(t, exec.isToolAvailable("patrol_report_finding")) assert.True(t, exec.isToolAvailable("patrol_assess_finding")) assert.True(t, exec.isToolAvailable("patrol_resolve_finding")) assert.True(t, exec.isToolAvailable("patrol_get_findings")) + assert.True(t, exec.isToolAvailable("patrol_propose_observer")) +} + +func TestPatrolProposeObserverRecordsProposalWithoutInstallation(t *testing.T) { + proposer := &mockPatrolObserverProposer{result: PatrolObserverProposalResult{ + ObjectiveID: "objective-1", Revision: 2, ObserverID: "observer-1", Version: 1, + State: "proposed", ArtifactDigest: "sha256:abc", CoverageState: "uncovered", CoverageReason: "observer_proposed", + }} + exec := NewPulseToolExecutor(ExecutorConfig{}) + exec.ApplyExecutionProfile(ProfilePatrolDetection) + exec.SetPatrolObserverProposer(proposer) + result, err := exec.ExecuteTool(context.Background(), agentcapabilities.PatrolProposeObserverToolName, map[string]interface{}{ + "objective_id": "objective-1", "expected_revision": float64(1), + "interpretation": "Detect buffering", "trigger_kind": "event", + "probe_json": `{"source":"playback-events"}`, + "wake_evidence": "buffering begins", "requirements_json": `{}`, + }) + if err != nil { + t.Fatalf("execute proposal tool: %v", err) + } + if result.IsError { + t.Fatalf("proposal tool returned error: %s", extractText(result)) + } + if proposer.input.ObjectiveID != "objective-1" || proposer.input.ExpectedRevision != 1 || proposer.input.TriggerKind != "event" { + t.Fatalf("proposal input = %+v", proposer.input) + } + if text := extractText(result); !strings.Contains(text, `"state":"proposed"`) || !strings.Contains(text, `"coverage_state":"uncovered"`) { + t.Fatalf("proposal result = %s", text) + } } // --- Helper --- diff --git a/internal/api/patrol_objectives_test.go b/internal/api/patrol_objectives_test.go index d98ca44f0..70724eea1 100644 --- a/internal/api/patrol_objectives_test.go +++ b/internal/api/patrol_objectives_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/ai" ) @@ -37,6 +38,31 @@ func TestPatrolObjectivesHTTPContractAndOptimisticRevision(t *testing.T) { if created.Observer != nil { t.Fatalf("public create unexpectedly accepted an observer: %+v", created.Observer) } + store := handler.patrolObjectiveStore(createRequest) + if store == nil { + t.Fatal("objective store unavailable in handler test") + } + proposed, err := store.ProposeObserver(created.ID, ai.ProposePatrolObserverInput{ + ExpectedRevision: created.Revision, + Interpretation: "Detect playback buffering from local events.", + TriggerKinds: []ai.PatrolObserverTriggerKind{ai.PatrolObserverTriggerEvent}, + ProbeJSON: `{"source":"private playback event details"}`, + WakeEvidence: "Playback begins buffering.", + RequirementsJSON: `{"secrets":["reference-only"]}`, + }, time.Now().UTC()) + if err != nil { + t.Fatalf("propose internal observer: %v", err) + } + detailRequest := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/objectives/"+created.ID, nil) + detailResponse := httptest.NewRecorder() + handler.HandlePatrolObjective(detailResponse, detailRequest) + if detailResponse.Code != http.StatusOK { + t.Fatalf("detail status = %d, body=%s", detailResponse.Code, detailResponse.Body.String()) + } + if strings.Contains(detailResponse.Body.String(), "private playback event details") || strings.Contains(detailResponse.Body.String(), `"artifact"`) { + t.Fatalf("public objective response leaked observer artifact: %s", detailResponse.Body.String()) + } + created = proposed listRequest := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/objectives", nil) listResponse := httptest.NewRecorder() @@ -50,7 +76,7 @@ func TestPatrolObjectivesHTTPContractAndOptimisticRevision(t *testing.T) { if err := json.Unmarshal(listResponse.Body.Bytes(), &listed); err != nil { t.Fatalf("decode objective list: %v", err) } - if len(listed.Objectives) != 1 || listed.Objectives[0].ID != created.ID { + if len(listed.Objectives) != 1 || listed.Objectives[0].ID != created.ID || listed.Objectives[0].Observer == nil || listed.Objectives[0].Observer.Artifact != nil { t.Fatalf("listed objectives = %+v", listed.Objectives) } @@ -65,7 +91,7 @@ func TestPatrolObjectivesHTTPContractAndOptimisticRevision(t *testing.T) { if err := json.Unmarshal(patchResponse.Body.Bytes(), &updated); err != nil { t.Fatalf("decode updated objective: %v", err) } - if updated.Revision != 2 || updated.Status != ai.PatrolObjectivePaused || updated.Coverage.ReasonCode != "objective_paused" { + if updated.Revision != created.Revision+1 || updated.Status != ai.PatrolObjectivePaused || updated.Coverage.ReasonCode != "objective_paused" { t.Fatalf("updated objective = %+v", updated) } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index aedd8e017..b89b818e9 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -715,6 +715,29 @@ func TestTelemetryPrivacyDocsDisclosePseudonymousIdentityAndIPHandling(t *testin } } +func TestTelemetryPrivacyDocsKeepObserverProposalContentLocal(t *testing.T) { + for _, relativePath := range []string{ + filepath.Join("..", "..", "docs", "PRIVACY.md"), + filepath.Join("..", "..", "frontend-modern", "public", "docs", "PRIVACY.md"), + } { + raw, err := os.ReadFile(relativePath) + if err != nil { + t.Fatalf("read %s: %v", relativePath, err) + } + content := string(raw) + for _, required := range []string{ + "model-authored observer proposal", + "encrypts that artifact with the retained objective", + "excludes it from public objective reads and later prompt seeds", + "does not include its content in usage telemetry or audit messages", + } { + if !strings.Contains(content, required) { + t.Errorf("%s must disclose %q", relativePath, required) + } + } + } +} + func TestRepositoryDoesNotClaimTelemetryIsAnonymous(t *testing.T) { repoRoot := filepath.Clean(filepath.Join("..", "..")) anonymousTelemetryClaimPattern := regexp.MustCompile(`(?i)\banonymous\b[^\n]{0,120}\btelemetry\b|\btelemetry\b[^\n]{0,120}\banonymous\b`) diff --git a/scripts/release_control/ai_runtime_docs_policy_test.py b/scripts/release_control/ai_runtime_docs_policy_test.py index e5f7e9797..d44e25e6f 100644 --- a/scripts/release_control/ai_runtime_docs_policy_test.py +++ b/scripts/release_control/ai_runtime_docs_policy_test.py @@ -60,6 +60,11 @@ class AIRuntimeDocsPolicyTest(unittest.TestCase): self.assertIn("Every active finding shown or returned to a Patrol run", content) self.assertIn("Silence is not an all-clear signal", normalized_content) self.assertIn("### The Patrol attention queue", content) + self.assertIn("### Operational objectives and model-authored observers", content) + self.assertIn("Patrol objectives retain an operator's desired outcome", normalized_content) + self.assertIn("`patrol_propose_observer`", content) + self.assertIn("The proposal boundary is intentionally not an execution boundary", normalized_content) + self.assertIn("Coverage remains `uncovered` until a core-owned validator", normalized_content) self.assertIn( "it cannot create lifecycle truth, invent action authority, or hide uncertainty", normalized_content,