Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ _Avoid_: reasoning text, inferred edge, model explanation
An unresolved, citation-backed Investigation Assertion ranked for follow-up after accounting for objective relevance, missing evidence, expected information gain, target importance, cost, risk, and authorization readiness.
_Avoid_: autonomous plan, agent hunch, task queue

**Campaign Autonomy Policy**:
A versioned decision contract that controls scheduling, approval consumption, budget stops, and recovery deduplication for a research campaign without creating target authorization or approval authority.
_Avoid_: YOLO mode, blanket approval, autonomous permission

**Shared Terminal Session**:
A project/thread-scoped interactive shell session whose input, output, resize events, interrupts, approvals, and actor attribution are visible to both the researcher and approved agent automation.
_Avoid_: generic shell bridge, hidden agent shell, human terminal takeover
Expand Down Expand Up @@ -156,6 +160,7 @@ _Avoid_: hidden gold, judge assertion
- A **Research Observation** may indicate several **Knowledge Concepts** through proposed, cited Investigation Assertions without becoming a **Finding**.
- A **Research Observation** may preserve several external identifiers and versioned score assessments; each remains attributable to the Observation's citations and time.
- A **Research Observation** may cite a message from another project thread when that discussion materially supports or contextualizes it.
- A **Campaign Autonomy Policy** may consume an already matching durable approval, but it never mints one, widens target scope, extends an expired decision, overrides a denial, or blindly repeats an unknown side effect.
- An **Investigation Entity** references a canonical project record when one exists instead of copying that record into the **Investigation Graph**.
- An **Investigation Assertion** may be supported, contradicted, derived, revised, rejected, or left unresolved without changing the canonical record it discusses.
- An **Investigation Citation** identifies why an **Investigation Assertion** exists; an **Artifact** remains the durable evidence object.
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ The explicit Security Knowledge Graph remains in SQLite for versioned, reusable

The project Investigation Graph is an assertion layer over existing records, not another owner of Targets, Artifacts, Findings, Research Observations, Tasks, Attack Paths, Tool Runs, messages, memory, or reusable security knowledge. A Research Observation preserves measured or directly seen behavior, structured inputs and outputs, measurements, external identifiers, versioned scores, actor, time, and precise citations before interpretation. The user-facing Research Map projects canonical records, cited threads and messages, external sources, reusable-concept references, current Investigation Assertions, and Research Priorities through one coherent relational snapshot. The write model resolves canonical records through project-local Investigation Entities and stores append-only Assertions, coordinate-only role-bearing Citations, and rule-versioned Derivations with ordered inputs. Evidence state (`observed`, `derived`, `proposed`, `contradicted`, or `rejected`) stays separate from assertion lifecycle (`current`, `withdrawn`, or `superseded`). Revision is an optimistic, transactional replacement that retains the predecessor and its citations. SQLite and PostgreSQL relational queries define correctness. See [ADR 0001](./adr/0001-investigation-graph-as-assertion-layer.md).

Campaign autonomy is a scheduling and recovery policy, not an authorization source. Manual, bounded, and fully automated modes share the same durable target ledger and exact-intent enforcement. Only active probes, downloads, and shell commands may consume an explicitly declared exact preauthorization in a non-manual mode; credential tests, browser mutations, writes, exploit validation, and patching require a fresh exact decision. Denied, expired, mismatched, or consumed approvals stop the transition. Recovery reuses successful side effects and pauses to reconcile unknown outcomes before any replay. Every mode obeys conjunctive active-time, wall-time, cost, and action ceilings and preserves the policy/mode, normalized intent, authorization and approval references, Tool Run/effect fingerprint, budget transition, and raw redacted evidence.

A Research Priority is an unresolved, citation-backed current assertion ranked for follow-up. Its deterministic score weights objective relevance (25%), evidence gap (20%), expected information gain (20%), target importance (15%), inverse predicate cost (8%), inverse predicate risk (7%), and authorization readiness (5%). Authorization readiness comes from the durable target ledger. Deliberately turning a Research Priority into a Task uses the existing Task workflow and a unique assertion-task receipt; it never schedules work, creates an approval, runs a tool, or promotes a Finding.

The former generic security-graph repository is retired. Historical database tables may remain so existing local data is not destructively dropped, but no product path writes them and they are not authoritative. The only graph ownership boundaries are the global Security Knowledge Graph and each project's Investigation Graph.
Expand Down
250 changes: 250 additions & 0 deletions src/server/research/campaign-autonomy-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import type { TARGET_RECIPE_ACTION_CLASSES } from "../targets/target-recipe";

Check failure on line 1 in src/server/research/campaign-autonomy-policy.ts

View workflow job for this annotation

GitHub Actions / Type Check

Cannot find module '../targets/target-recipe' or its corresponding type declarations.

export const CAMPAIGN_AUTONOMY_POLICY_VERSION = "campaign-autonomy-policy-v1";

export type CampaignAutonomyMode = "manual" | "bounded" | "fully-automated";
export type CampaignActionClass =
| "planning"
| "reporting"
| (typeof TARGET_RECIPE_ACTION_CLASSES)[number];

export type CampaignAutonomyPolicyInput = {
policyVersion: typeof CAMPAIGN_AUTONOMY_POLICY_VERSION;
mode: CampaignAutonomyMode;
actionClass: CampaignActionClass;
targetId?: string;
targetAuthorization:
| "not-applicable"
| "active"
| "missing"
| "denied"
| "expired";
approval: {
state:
| "not-applicable"
| "absent"
| "matching"
| "mismatched"
| "denied"
| "expired"
| "consumed";
origin?: "fresh-decision" | "exact-preauthorization";
durableApprovalId?: string;
};
normalizedIntentFingerprint?: string;
sideEffect?: {
fingerprint: string;
priorState: "none" | "succeeded" | "failed" | "unknown" | "resumable";
priorToolRunId?: string;
};
recovery: boolean;
budgets: {
activeTimeRemainingMs: number;
wallTimeRemainingMs: number;
costRemainingUsd: number;
actionSlotsRemaining: number;
};
};

export type CampaignAutonomyPolicyDecision = {
policyVersion: typeof CAMPAIGN_AUTONOMY_POLICY_VERSION;
decision:
| "proceed-passive"
| "consume-matching-approval"
| "request-exact-preauthorization"
| "request-fresh-approval"
| "reuse-prior-result"
| "reconcile-before-resume"
| "stop";
executionAuthorityCreated: false;
reason: string;
approvalRequirement:
| "none"
| "fresh-exact"
| "matching-exact-preauthorization";
forensicRequirements: string[];
};

const PASSIVE_ACTIONS = new Set<CampaignActionClass>([
"planning",
"passive-review",
"reporting",
]);
const PREAUTHORIZABLE_ACTIONS = new Set<CampaignActionClass>([
"active-probe",
"download",
"shell-command",
]);

const FORENSIC_REQUIREMENTS = [
"policy-version-and-mode",
"normalized-intent-fingerprint",
"target-authorization-reference",
"approval-request-and-decision",
"tool-run-and-side-effect-fingerprint",
"budget-transition",
"raw-input-output-and-redaction-markers",
];

export function decideCampaignAutonomy(
input: CampaignAutonomyPolicyInput,
): CampaignAutonomyPolicyDecision {
if (input.policyVersion !== CAMPAIGN_AUTONOMY_POLICY_VERSION) {
return stop("Unsupported campaign autonomy policy version.");
}
if (budgetExhausted(input.budgets)) {
return stop("A conjunctive campaign ceiling has been exhausted.");
}
if (input.targetId && input.targetAuthorization !== "active") {
return stop(`Target authorization is ${input.targetAuthorization}.`);
}
if (!input.targetId && input.targetAuthorization !== "not-applicable") {
return stop(
"Target authorization state is inconsistent with an unbound action.",
);
}

const recoveryDecision = decideRecovery(input);
if (recoveryDecision) return recoveryDecision;

if (PASSIVE_ACTIONS.has(input.actionClass)) {
return decision({
decision: "proceed-passive",
reason:
"The action is passive and any target binding has active authorization.",
approvalRequirement: "none",
});
}

if (!input.normalizedIntentFingerprint?.trim()) {
return stop(
"Approval-gated work requires a normalized intent fingerprint.",
);
}

const approvalProblem = approvalBlocker(input.approval.state);
if (approvalProblem) return stop(approvalProblem);

const preauthorizationAllowed =
input.mode !== "manual" && PREAUTHORIZABLE_ACTIONS.has(input.actionClass);
if (input.approval.state === "matching") {
if (input.approval.origin === "fresh-decision") {
return consume(
"fresh-exact",
"A fresh exact-intent durable approval matches this action.",
);
}
if (
input.approval.origin === "exact-preauthorization" &&
preauthorizationAllowed
) {
return consume(
"matching-exact-preauthorization",
"The configured mode may consume this already-declared exact preauthorization.",
);
}
return decision({
decision: "request-fresh-approval",
reason: "This action class or mode does not accept preauthorization.",
approvalRequirement: "fresh-exact",
});
}

return decision({
decision: preauthorizationAllowed
? "request-exact-preauthorization"
: "request-fresh-approval",
reason:
input.mode === "fully-automated"
? "Fully automated scheduling does not mint approvals; execution pauses for an exact decision."
: "The action needs an exact durable approval before execution.",
approvalRequirement: preauthorizationAllowed
? "matching-exact-preauthorization"
: "fresh-exact",
});
}

function decideRecovery(
input: CampaignAutonomyPolicyInput,
): CampaignAutonomyPolicyDecision | undefined {
if (!input.recovery || !input.sideEffect) return undefined;
if (input.sideEffect.priorState === "succeeded") {
return decision({
decision: "reuse-prior-result",
reason: "The side-effect fingerprint already has a successful Tool Run.",
approvalRequirement: "none",
});
}
if (input.sideEffect.priorState === "unknown") {
return decision({
decision: "reconcile-before-resume",
reason:
"The prior side effect has an unknown outcome and must not be replayed blindly.",
approvalRequirement: "fresh-exact",
});
}
if (
input.sideEffect.priorState === "resumable" &&
!input.sideEffect.priorToolRunId
) {
return stop(
"A resumable side effect is missing its prior Tool Run reference.",
);
}
return undefined;
}

function approvalBlocker(
state: CampaignAutonomyPolicyInput["approval"]["state"],
) {
switch (state) {
case "mismatched":
return "The durable approval does not match the normalized action intent.";
case "denied":
return "The exact action was denied; autonomy cannot override the decision.";
case "expired":
return "The exact approval expired; autonomy cannot extend it.";
case "consumed":
return "The single-use approval was already consumed.";
default:
return undefined;
}
}

function budgetExhausted(budgets: CampaignAutonomyPolicyInput["budgets"]) {
return (
budgets.activeTimeRemainingMs <= 0 ||
budgets.wallTimeRemainingMs <= 0 ||
budgets.costRemainingUsd <= 0 ||
budgets.actionSlotsRemaining <= 0
);
}

function consume(
approvalRequirement: CampaignAutonomyPolicyDecision["approvalRequirement"],
reason: string,
) {
return decision({
decision: "consume-matching-approval",
reason,
approvalRequirement,
});
}

function stop(reason: string) {
return decision({ decision: "stop", reason, approvalRequirement: "none" });
}

function decision(
input: Pick<
CampaignAutonomyPolicyDecision,
"decision" | "reason" | "approvalRequirement"
>,
): CampaignAutonomyPolicyDecision {
return {
policyVersion: CAMPAIGN_AUTONOMY_POLICY_VERSION,
...input,
executionAuthorityCreated: false,
forensicRequirements: FORENSIC_REQUIREMENTS,
};
}
1 change: 1 addition & 0 deletions src/server/research/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./campaign-autonomy-policy";
export * from "./resource-aware-execution";
export {
type FinishResearchInput,
Expand Down
95 changes: 95 additions & 0 deletions tests/integration/campaign-autonomy-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";

import {
type CampaignAutonomyPolicyInput,
decideCampaignAutonomy,
} from "../../src/server/research";

function input(): CampaignAutonomyPolicyInput {
return {
policyVersion: "campaign-autonomy-policy-v1",
mode: "fully-automated",
actionClass: "active-probe",
targetId: "target-1",
targetAuthorization: "active",
approval: { state: "absent" },
normalizedIntentFingerprint: "intent-fingerprint",
recovery: false,
budgets: {
activeTimeRemainingMs: 60_000,
wallTimeRemainingMs: 120_000,
costRemainingUsd: 1,
actionSlotsRemaining: 4,
},
};
}

describe("campaign autonomy policy", () => {
it("does not let fully automated scheduling create approval authority", () => {
expect(decideCampaignAutonomy(input())).toMatchObject({
decision: "request-exact-preauthorization",
approvalRequirement: "matching-exact-preauthorization",
executionAuthorityCreated: false,
});
});

it("may consume declared exact preauthorization only for bounded action classes", () => {
const candidate = input();
candidate.approval = {
state: "matching",
origin: "exact-preauthorization",
durableApprovalId: "approval-1",
};
expect(decideCampaignAutonomy(candidate)).toMatchObject({
decision: "consume-matching-approval",
approvalRequirement: "matching-exact-preauthorization",
});

candidate.actionClass = "exploit-validation";
expect(decideCampaignAutonomy(candidate)).toMatchObject({
decision: "request-fresh-approval",
approvalRequirement: "fresh-exact",
});
});

it("never overrides denial, expiry, or a missing target grant", () => {
for (const state of ["denied", "expired"] as const) {
const candidate = input();
candidate.approval.state = state;
expect(decideCampaignAutonomy(candidate).decision).toBe("stop");
}
const missingTarget = input();
missingTarget.targetAuthorization = "missing";
expect(decideCampaignAutonomy(missingTarget)).toMatchObject({
decision: "stop",
});
});

it("deduplicates completed effects and reconciles unknown outcomes before recovery", () => {
const candidate = input();
candidate.recovery = true;
candidate.sideEffect = {
fingerprint: "effect-1",
priorState: "succeeded",
priorToolRunId: "run-1",
};
expect(decideCampaignAutonomy(candidate)).toMatchObject({
decision: "reuse-prior-result",
});

candidate.sideEffect.priorState = "unknown";
expect(decideCampaignAutonomy(candidate)).toMatchObject({
decision: "reconcile-before-resume",
approvalRequirement: "fresh-exact",
});
});

it("stops at the first exhausted conjunctive ceiling", () => {
const candidate = input();
candidate.budgets.actionSlotsRemaining = 0;
expect(decideCampaignAutonomy(candidate)).toMatchObject({
decision: "stop",
reason: "A conjunctive campaign ceiling has been exhausted.",
});
});
});
Loading