Add model-authored Patrol observer proposals

This commit is contained in:
rcourtman
2026-08-14 00:24:52 +01:00
parent ff2bfbb320
commit 71a0fa37a1
23 changed files with 816 additions and 30 deletions
+20
View File
@@ -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
+1 -1
View File
@@ -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.
@@ -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/`
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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.
+1
View File
@@ -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
@@ -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) {
@@ -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},
+4
View File
@@ -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;
+7
View File
@@ -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
+275 -8
View File
@@ -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 &copy
}
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 &copy
}
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)
}
+108 -1
View File
@@ -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)
}
}
+51
View File
@@ -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
}
+6 -5
View File
@@ -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,
}
}
+47 -2
View File
@@ -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
+5
View File
@@ -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)
}
+142
View File
@@ -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) {
+44 -1
View File
@@ -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 ---
+28 -2
View File
@@ -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)
}
+23
View File
@@ -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`)
@@ -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,