Skip to content
Merged
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
3 changes: 3 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Jobs are a **trigger × target** matrix, and the triggers do not share a threat
| **AI tool** — `dispatch_run` (operator session) | Whoever can prompt-inject the operator's model | **Nothing** — it enqueues a paid run that edits a folder in place |
| **Outbox chain** — a completed job container | A completed local job's agent, after host-side validation | As the folder row above — a same-folder follow-up, no undo |
| **Priorities plan** (operator session tool or portfolio job) | Whoever can prompt-inject either | Revert in the panel. A plan starts no job: it moves spend between projects inside the operator's envelope, at most `maxStepPct` of the total per interval, never below a floor and never above the total |
| **Portfolio plan** (`/outbox/priorities.json`) | A completed portfolio cron job's agent: a cron trigger the operator flagged `run.portfolio` in the reviewed file, still flagged at pickup, at prepare and again when the plan is collected. A manual run, a chained child or a forge job is refused | Revert in the panel. The same bounds as the row above, and the same ladder; a refusal is recorded, never a failed job |

## Trust boundaries

Expand All @@ -61,6 +62,8 @@ Jobs are a **trigger × target** matrix, and the triggers do not share a threat
| The allocation envelope (`PI_ENVELOPE_FILE`) | **Operator, the same trust as the scoped-limits file** | It bounds what a priorities plan may move: the total, the floors and the step. The worker refuses to boot when the file lies inside any path a job container can see, and re-checks that at every reload, keeping the last good envelope when it fails; a local job's folder is re-judged at prepare and refused when it resolves to the envelope's folder or above it. The plan itself, which is agent text, gets no trust |
| The job container | **None** — it is the untrusted side | It runs the agent |
| A job container's `/outbox` request file | **None** — agent-authored | An agent-initiated signal channel back to the host; validated host-side before anything is enqueued. **Local jobs only** — a github job has no `/outbox` mount at all |
| A job container's `/outbox/priorities.json` | **None**, agent-authored | A portfolio job's plan. Read host-side on a `completed` exit only, `lstat`-checked, opened with `O_NOFOLLOW` and `fstat`ed, at most 16 KiB, and judged by the same parser and ladder as the operator's own plan. Its `reason` text is never logged and never enters the run record |
| A portfolio job's `/job/portfolio.json` | **Host-written, read-only** | The facts the manager plans with. Ids, digests, integers, instants, enum tokens and operator labels (project ids and member labels as projects.json names them, a folder by its basename) only: no issue text, title, plan reason or path, because the next run reads it as facts |
| A job container's `/session` transcript | **None** — agent-authored | The **second** agent-initiated channel, and this row exists because the line above used to say "only". Written by the agent, read back host-side on a `completed` exit, `lstat`-checked and regular-files-only on both edges |
| A **resurrected sandbox**'s operator shell (`pi-dispatch sandbox`) | **Operator — the same trust as a terminal on this host** | A third channel, and the first the **operator** opens rather than the agent. Not a job container: no minted token, no provider key, no agent running, started only by a keypress. It re-mounts a finished run's workspace, which for a forge job holds attacker-influenced code — the same trust shape as checking out a stranger's pull request locally. Every isolation flag still applies; ports it publishes are `127.0.0.1`-only and last only while it does |
| The **venue** a job's container is built in (`run.backend`, `PI_BACKENDS`) | **Operator — the same trust as the daemon they point the worker at** | A different axis from every row above. Those ask *who wrote this*; this asks *who is holding the execution*. Today there is one venue, the Docker daemon the worker's docker CLI resolves, which is the operator's own machine unless `DOCKER_HOST` or a docker context points it elsewhere; the worker reads that answer at boot and before each job, logs a redirect, and a floor asking `credentialTransit=enforced` refuses one. It judges the endpoint's form, so a unix socket or loopback port that is really a tunnel to another machine still reads as this host. A venue that is not this host holds the container, the job's files, the provider key and the per-job forge token, and the isolation flags in **this worker's argv do not reach it**. What bounds it is a declaration an operator reads (`docs/backends.md`), a floor they can require of every blessed venue, and a boot refusal when the two disagree. |
Expand Down
6 changes: 6 additions & 0 deletions admin/skills/operate-pi-dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ delegation off, or `portfolio-job` not in `delegation.writers`), the job is refu
before anything is spent. The operator can fire such a trigger once by hand with
`pi-dispatch run --trigger <id>` in a terminal; no tool does that.

A portfolio job reads `/job/portfolio.json` (ids, numbers and operator labels only) and may write `/outbox/priorities.json`. The
worker applies that plan after the job completes, under the same rules as an operator's own plan. Its run
record's `plan` field says what happened: `applied`, `duplicate`, or `refused` with a fixed reason
(`plan-not-portfolio`, `plan-too-soon`, `plan-stale`, `plan-invalid` and the rest). A refused plan never makes
the job failed. A snapshot too large for the job refuses it as `portfolio-snapshot-oversize`, pre-spend.

## The forge a trigger listens to — `run.kind`

A webhook trigger names its forge: `"kind"` is `github`, `gitlab`, `forgejo` or `azure`. Everything else
Expand Down
44 changes: 42 additions & 2 deletions docs/allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,44 @@ yours). Neither is retried.
A portfolio job (a cron trigger with `"portfolio": true`, see [triggers](triggers.md)) is refused as
`portfolio-no-envelope` before anything is spent when this worker has no envelope, `delegation.enabled` is false, or
`portfolio-job` is not in `delegation.writers`. Its plan could never apply there, so it is not paid for. Not retried.
A worker with no envelope gets that refusal only while the fleet has no applied split. Once a split exists
(`alloc:plan`), removing the envelope from a worker refuses every job there as `envelope-mismatch` first (see
[Several hosts](#several-hosts)).

## Portfolio jobs

A portfolio job runs a flow that reads the budget and proposes a split, with no keypress.

- **What it reads.** The worker writes `/job/portfolio.json` for it: the envelope's numbers, the applied plan (its id is
the next plan's `basis`, or `null` when no plan applied), the trigger's last attempt, and for each project its floor,
weight, allocation, what it has spent in the window, and its runs of the last 7 days. Money is in micro-dollars.
The file holds ids, numbers and operator labels (a project member as `github:acme/web` or `local:<folder name>`): no issue text, no titles, no plan reasons and no paths. Spending is read from
the counters every host shares; run counts cover the whole fleet only when `PI_WORKER_NAME` is set (the run
mirror), and `fleet.runsComplete` says which. A snapshot over 64 KiB refuses the job as
`portfolio-snapshot-oversize` before it costs anything.
- **What it writes.** `/outbox/priorities.json`, the plan format of the operator's own tool. The worker reads it
after the container completes and applies it under the same rules: the step, the interval, the floors.
- **When it is refused.** The plan is refused, and the job stays completed, when the job is not a portfolio job any
more (`plan-not-portfolio`: the flag was removed from the triggers file, or the job was a manual run or a chained
child), when the file is over 16 KiB, is a link or is not a regular file, is not JSON, or fails the plan rules
(`plan-invalid`), and for every reason the operator's own plan can be refused (`plan-stale`, `plan-too-soon`,
`plan-duplicate` and the rest). The run record's `plan` field names every refusal. A refusal of a portfolio job is
also a line in the audit file and in `alloc:log`, and the next snapshot shows it as `lastAttempt`, except
`plan-collect-error` (below), which is only in the run record and the worker log. A file left by a
job that was never a portfolio job (a manual run, a chained child, an unflagged cron job) is refused in its run
record and the worker log only, so stray files cannot push the panel's history out of `alloc:log`.
`plan-collect-error` means the worker could not tell what happened (the plan may have applied), so it writes no
row of its own: look at the panel's current plan and the audit file.
- **The job log** shows `plan_precheck` from inside the container (what the plan is likely to meet, a hint only) and
`plan_collected` from the worker (what happened).
- **The manager pays from a share too.** Its own job is governed like any other: its folder's project, or `_other`
when the folder is in no project. Give that share a floor of at least `PI_MAX_COST_USD`, or the manager itself is
refused as `allocation-cap` once a plan (or the default weights) leaves it nothing. `_other` with floor 0 and weight
0 refuses it from the first run.
- **Two plans at once.** A run fired by hand waits for a scheduled run of the same trigger to end (one job per folder
at a time), so its plan meets the first one's and is refused as `plan-too-soon`. The operator can apply a plan while
a job runs: the first plan to apply wins, and the other is refused as `plan-stale` or `plan-busy`. Nothing merges two
plans.

## Several hosts

Expand Down Expand Up @@ -167,8 +205,10 @@ The next job on each host re-bases the split.
|---|---|
| Env var | `PI_ENVELOPE_FILE` (absolute, canonical path; unset = no envelope. An EMPTY value is NOT unset: the worker keeps it and refuses to start, so fill the line in or delete it, and doctor fails on it) |
| Needs | `PI_MAX_COST_USD` |
| Refusal reasons | `allocation-cap`, `envelope-mismatch`, `portfolio-no-envelope`, `local-folder-escaped`, `local-folder-holds-envelope`, `local-folder-project-changed` |
| Refusal reasons | `allocation-cap`, `envelope-mismatch`, `portfolio-no-envelope`, `portfolio-snapshot-oversize`, `local-folder-escaped`, `local-folder-holds-envelope`, `local-folder-project-changed` |
| Plan reasons (run record `plan`) | `plan-not-portfolio`, `plan-oversize`, `plan-not-regular-file`, `plan-unreadable`, `plan-parse-error`, `plan-collect-error`, `plan-invalid`, and the apply ladder |
| Job files | `/job/portfolio.json` (in), `/outbox/priorities.json` (out) |
| Valkey | `alloc:plan`, `alloc:lock`, `alloc:log`, `alloc:envelope:expected` |
| Audit file | `PI_LOGS_DIR/allocations/YYYY-MM.jsonl` |
| Host registry | `fpEnvelope`: the envelope digest, or `none`; doctor fails for a host whose digest is not the applied split's |
| Spec | `REQ-DELEGATED-ALLOCATION`, `DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE`, `INT-ENVELOPE-FILE-CONTRACT`, `INT-PRIORITIES-PLAN-CONTRACT` |
| Spec | `REQ-DELEGATED-ALLOCATION`, `DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE`, `INT-ENVELOPE-FILE-CONTRACT`, `INT-PRIORITIES-PLAN-CONTRACT`, `INT-OUTBOX-CONTRACT`, `INT-CONTAINER-JOB-INPUTS` |
33 changes: 33 additions & 0 deletions guardrails/PORTFOLIO_PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
pi-dispatch portfolio protocol: how a portfolio job reads the budget and proposes a plan.

This is documentation, not a control surface. The host decides what happens to a plan after you exit.
Composed into the prompt ONLY when /job/portfolio.json exists (a cron trigger the operator flagged
run.portfolio), so no other job is billed for these lines.

Keep it short. Every line is paid for on every portfolio job.
PORTFOLIO-SENTINEL below is asserted by the contract tests and the image check. Do not remove it.
-->

## Proposing budget priorities (pi-dispatch)

<!-- PORTFOLIO-SENTINEL: pi-dispatch-portfolio-v1 -->

1. `/job/portfolio.json` holds the facts: the envelope (`totalMicros`, `maxStepPct`, `minIntervalHours`,
`maxPlanDays`, `planAllowedAfter`), the applied `plan` (null when none applied), your trigger's
`lastAttempt`, and per project its floor, weight, allocation, spend in the window and runs of the last
7 days. Money is in micro-dollars (1000000 is one dollar). It holds ids, numbers and
operator labels only.
2. To propose a split, write `/outbox/priorities.json`:
`{"version": 1, "basis": <plan.id, or null when plan is null>, "projects": [{"id": "<id>", "weight": <0 to 1000>}]}`.
Name every project in the snapshot, `_other` included. Optional: `"validUntil"` (a UTC instant like
`2026-10-12T00:00:00Z`), a short `"reason"` per project, and `"repos": [{"ref": "<member ref>", "weight": <n>}]`
naming every member of that project. Unknown keys are refused. At most 16 KiB.
3. You write WEIGHTS, never dollars. The host splits the money: every project keeps its floor, and the rest is
divided by weight.
4. `basis` must be the id of the plan you saw. If another plan applied since the snapshot, yours is refused as
`plan-stale`. Nothing merges two plans.
5. The host judges the file AFTER you exit. You get no confirmation. Refusals are normal and recorded: too soon
after the last plan (`plan-too-soon`), the same plan again (`plan-duplicate`), or a malformed file
(`plan-invalid`). A plan may also be clamped: one step moves at most `maxStepPct` of the total.
6. Writing no file is a valid answer. Do not retry or write a second file to get around a refusal.
1 change: 1 addition & 0 deletions image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ RUN mkdir -p /home/pi/.pi/agent && chown -R pi:pi /home/pi \
# would delete the floor from the prompt with no error. The runner reads this path explicitly.
COPY guardrails/HARD_RULES.md /opt/pi-dispatch/HARD_RULES.md
COPY guardrails/OUTBOX_PROTOCOL.md /opt/pi-dispatch/OUTBOX_PROTOCOL.md
COPY guardrails/PORTFOLIO_PROTOCOL.md /opt/pi-dispatch/PORTFOLIO_PROTOCOL.md

# The runner's own node: an EXEC-ONLY, root-owned copy (issue #545). The kernel marks a process that exec'd a binary its
# user cannot read as not dumpable, and a non-dumpable process's /proc/<pid>/mem, environ and fd are refused to the
Expand Down
7 changes: 7 additions & 0 deletions image/runner/run-job.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { createJobModelRuntime } from "./src/model-runtime.mjs";
import { countPackageResources, findShadowedSkills, isFlowLoaded, owningRoot } from "./src/packages.mjs";
import { isNestedRunner, runAsPiCli } from "./src/child-route.mjs";
import { createChildWatch } from "./src/child-watch.mjs";
import { precheckAtExit } from "./src/plan-check.mjs";
import { openSessionManager } from "./src/session.mjs";
import { attachTokenBudget } from "./src/token-budget.mjs";
import { assertExcludeToolsKnown } from "./src/tools.mjs";
Expand Down Expand Up @@ -558,6 +559,12 @@ async function main() {
// `usage` is, so an exit line with nothing to say stays byte-identical to what every existing consumer
// already parses -- and the host gate reads that absence as "no measurement" rather than as zero.
const context = Number.isFinite(contextUsage?.tokens) && Number.isFinite(contextUsage?.contextWindow) && contextUsage.contextWindow > 0 ? { tokens: contextUsage.tokens, window: contextUsage.contextWindow } : null;
// Issue #505: a read-only look at /outbox/priorities.json, logged as enum tokens so the job's log says what the host
// is likely to make of the plan. Its result is DISCARDED and it never throws: the host decides after exit, and nothing
// a plan file holds may change this exit code or this exit line.
try {
precheckAtExit({ log });
} catch {}
// capExitMessage: a provider's error body is unbounded, and the worker reads this line from a bounded
// tail, so an uncapped message can push `code` and `reason` out of what the host ever sees.
exitWriter.writeExit({ ...capExitMessage(outcome), turns: budget.state.turns, retryTurns: budget.state.retryTurns, tokens, ...(usage ? { usage } : {}), ...(context ? { context } : {}), session: { resumed: sessionResumed, reason: sessionReason } });
Expand Down
17 changes: 16 additions & 1 deletion image/runner/src/loader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ export const OUTBOX_PROTOCOL_PATH = "/opt/pi-dispatch/OUTBOX_PROTOCOL.md";
/** Read-write mount a local job receives; its presence is what makes the outbox protocol relevant. */
export const OUTBOX_MOUNT = "/outbox";

/** Where the image bakes the portfolio protocol (issue #505). Documentation for the plan a portfolio job may write. */
export const PORTFOLIO_PROTOCOL_PATH = "/opt/pi-dispatch/PORTFOLIO_PROTOCOL.md";

/**
* The snapshot the worker writes for a portfolio job only (INT-CONTAINER-JOB-INPUTS, issue #505); its presence is what
* makes the portfolio protocol relevant. On the /job:ro bind that already exists, so no mount says it.
*/
export const PORTFOLIO_SNAPSHOT_PATH = "/job/portfolio.json";

/** Read-only mount the worker materialises the project's .pi/ into, from the default-branch SHA. */
export const JOB_PI_DIR = "/job/pi";

Expand Down Expand Up @@ -517,6 +526,8 @@ export function buildResourceLoader({
triggerSkillsDir = TRIGGER_SKILLS_DIR,
outboxMount = OUTBOX_MOUNT,
outboxProtocolPath = OUTBOX_PROTOCOL_PATH,
portfolioPath = PORTFOLIO_SNAPSHOT_PATH,
portfolioProtocolPath = PORTFOLIO_PROTOCOL_PATH,
// ON, matching the runtime posture (REQ-GLOBAL-PI-OVERLAY): the operator staged that dir themselves,
// so loading it is the default and PI_GLOBAL_ALLOW_EXTENSIONS=0 is the opt-out. run-job.mjs always
// passes an explicit value, so this default is only ever seen by a directly-constructed loader --
Expand Down Expand Up @@ -544,6 +555,10 @@ export function buildResourceLoader({
// pays for the protocol. Evaluated ONCE here at loader build, not per message, so the
// assembled prompt is byte-identical across turns (CONST-PERSONA-IN-CACHED-PREFIX).
const outboxProtocol = existsSync(outboxMount) ? readIfExists(outboxProtocolPath) : undefined;
// The portfolio protocol (issue #505) the same way: only when the worker wrote /job/portfolio.json, which it does for a
// job whose cron trigger the operator flagged `run.portfolio`, and read ONCE here so the prompt stays byte-identical
// across turns. After the outbox protocol, whose channel it uses, and before the operator's and the repo's personas.
const portfolioProtocol = existsSync(portfolioPath) ? readIfExists(portfolioProtocolPath) : undefined;
// The roots a staged package may never take a skill name from. Both are listed unconditionally: a
// root that is not mounted contributes no skill to protect, so gating it would only add a way to
// forget one.
Expand Down Expand Up @@ -653,7 +668,7 @@ export function buildResourceLoader({
packageRoots: packagePaths,
protectedPrompts,
}),
appendSystemPromptOverride: () => [guardrails, outboxProtocol, globalPersona, projectPersona].filter(Boolean),
appendSystemPromptOverride: () => [guardrails, outboxProtocol, portfolioProtocol, globalPersona, projectPersona].filter(Boolean),
});
}

Expand Down
Loading
Loading