diff --git a/docs/ADRs/0017-credential-isolation-for-sandboxed-agents.md b/docs/ADRs/0017-credential-isolation-for-sandboxed-agents.md index 60aed52173..a3b6b34a0a 100644 --- a/docs/ADRs/0017-credential-isolation-for-sandboxed-agents.md +++ b/docs/ADRs/0017-credential-isolation-for-sandboxed-agents.md @@ -18,6 +18,9 @@ Date: 2026-04-01 Accepted (credential delivery tiers extended by [ADR 0025](0025-provider-credential-delivery-for-sandboxed-agents.md)) +The default prefetch model is standardized for handled entity content by +[ADR 0107](0107-deterministic-filtered-entity-context-staging.md). + ## Context When sandboxed agents need to perform operations requiring credentials (e.g. reading or writing GitHub issues), the credential must be kept away from the agent process. A compromised agent with access to a credential can exfiltrate it — once the credential leaves the sandbox, the attacker can use it without any sandbox constraints. diff --git a/docs/ADRs/0107-deterministic-filtered-entity-context-staging.md b/docs/ADRs/0107-deterministic-filtered-entity-context-staging.md new file mode 100644 index 0000000000..661bad9704 --- /dev/null +++ b/docs/ADRs/0107-deterministic-filtered-entity-context-staging.md @@ -0,0 +1,95 @@ +--- +title: "107. Deterministic filtered entity-context staging" +status: Accepted +relates_to: + - agent-architecture + - security-threat-model +topics: + - entity-context + - security + - harness + - token-cost +--- + +# 107. Deterministic filtered entity-context staging + +Date: 2026-09-07 + +## Status + +Accepted + +## Context + +[Issue #6407](https://github.com/fullsend-ai/fullsend/issues/6407) identifies +that agents and harness scripts repeatedly fetch the issue or change proposal +they are handling, including comments, reviews, diffs, checks, and logs. Those +tool calls spend tokens, make runs depend on runtime network access, and give +each consumer a different view when the entity changes during a run. + +Forge content is also untrusted input. Fetching it directly from inside the +sandbox bypasses the deterministic point where Fullsend can bound, normalize, +redact, and label content before it reaches an agent. This decision generalizes +the preferred prefetch model from +[ADR 0017](0017-credential-isolation-for-sandboxed-agents.md) to every issue and +change-proposal run. + +## Decision + +Before the harness pre-script, `fullsend run` uses `forge.Client` to assemble +one immutable snapshot of the handled entity. The runner applies a mandatory, +deterministic content pipeline: size limits, Unicode safety normalization, +secret and sensitive-data redaction, and injection scanning. It fails closed +when required data cannot be fetched or safely represented. Filtered content +is the only copy exposed to scripts and the agent; the manifest records every +truncation, replacement, finding, and fetch error without retaining rejected +content. + +The snapshot is written outside the repository clone. Host-side pre- and +post-scripts receive `FULLSEND_CONTEXT_DIR` pointing to +an access-restricted temporary directory outside the retained run-output +tree; inside the sandbox the same variable points to +`/sandbox/workspace/context`. Fullsend uploads that directory after sandbox +creation and before repository/runtime execution. Consumers therefore use the +same variable and relative paths on both sides of the sandbox boundary, and +the context cannot be staged or committed accidentally with repository files. + +The exact tree, schemas, canonical serialization, stable record-key derivation, +filter statuses, and compatibility rules are the versioned +[entity-context v1 specification](../normative/entity-context/v1/README.md). +Content records and mutable observation state are separate: resolving or +reordering a thread changes its state/index files, never an unchanged comment +or review body. No runner-clock timestamp enters the staged tree. Given the +same forge state and filter version, implementations produce the same paths +and bytes; breaking that guarantee requires a new major specification. + +Each comment and review is a self-contained attributed record whose filename +sorts chronologically. The initial body uses the same record format and sorts +first. Whole-conversation assembly is a glob concatenation; per-thread order +files contain paths to the same records rather than copied content. New replies +append without rewriting an unchanged prefix, preserving prompt-cache +eligibility when runtimes concatenate records before mutable state. + +The pre-script may inspect the host snapshot and skip the run. It cannot mutate +the agent's view: Fullsend verifies the manifest digests before upload and +restores or rejects changed files. The sandbox copy is read-only to the agent. +Agent prompts should concatenate conversation records first and place mutable +state after that stable prefix; `summary.md` remains the navigation aid for +selective reads. Runtime forge reads are an explicit fallback for data outside +the snapshot, not the default way to obtain it. + +The host snapshot uses a mode-`0700` directory and mode-`0600` files. Fullsend +removes the sandbox copy after its last sandbox consumer and the host copy after +the post-script, on success, failure, skip, or handled cancellation; startup +also scavenges orphaned context directories after abnormal termination. Context +is excluded from retained run artifacts by construction. Diagnostics may retain +only bounded counts, digests, and filtering findings, never bodies, diffs, or +logs. + +## Consequences + +- Agents start with a consistent, filtered view of entity content and need fewer forge tool calls and prompt tokens. +- Pre-scripts, agents, validation, and post-scripts share one versioned relative-path contract without putting generated input in Git. +- Snapshot assembly adds startup latency and ephemeral storage, bounded by per-entry and total-size limits. +- A snapshot can become stale during a run, so outputs that mutate forge state must still validate relevant revisions in deterministic post-processing. +- Forge adapters must expose the snapshot inputs through `forge.Client`; platform-specific gaps are explicit manifest errors rather than silent omissions. diff --git a/docs/architecture.md b/docs/architecture.md index 60c26bca0e..8c18d0973e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,6 +170,11 @@ repo baseline and overrides) (contract: [`docs/normative/prescript-output/v1`](normative/prescript-output/v1/README.md)), replacing the inline workflow pre-checks and their scaffold script copies ([ADR 0072](ADRs/0072-pre-script-output-protocol.md)). +- Deterministic entity context: before the pre-script, the runner fetches one + bounded entity snapshot through `forge.Client`, filters untrusted content, + and stages a versioned per-record file tree outside the repository. Scripts + and the sandbox use `FULLSEND_CONTEXT_DIR` with the same relative paths + ([ADR 0107](ADRs/0107-deterministic-filtered-entity-context-staging.md)). - CEL-guarded overlays: an `overlays:` list of CEL-guarded config overlays generalizes the `forge:` block, letting harness authors condition scripts, skills, env vars, and other fields on any event diff --git a/docs/normative/entity-context/v1/README.md b/docs/normative/entity-context/v1/README.md new file mode 100644 index 0000000000..80280d61e3 --- /dev/null +++ b/docs/normative/entity-context/v1/README.md @@ -0,0 +1,171 @@ +# Entity context v1 + +This specification defines the deterministic, filtered entity snapshot adopted +by [ADR 0107](../../../ADRs/0107-deterministic-filtered-entity-context-staging.md). +It is the contract between forge adapters, `fullsend run`, harness scripts, and +agent runtimes. + +## Tree + +Entries that do not apply to an entity are omitted. JSON documents use their +linked schemas. + +```text +context/ +├── index.json +├── summary.md +├── entity/metadata.json +├── records/-.md +├── threads/.order +├── changes/ +│ ├── diff.patch +│ └── commits.json +├── checks// +│ ├── metadata.json +│ └── log.txt +└── state/ + └── threads.json +``` + +`index.json` conforms to +[`index.schema.json`](index.schema.json) and enumerates every other staged file. +`entity/metadata.json`, `changes/commits.json`, check metadata, and +`state/threads.json` conform respectively to +[`entity.schema.json`](entity.schema.json), +[`commits.schema.json`](commits.schema.json), +[`check.schema.json`](check.schema.json), and +[`thread-state.schema.json`](thread-state.schema.json). `summary.md` is a +bounded navigation view generated only from the manifest and state documents; +it must not duplicate record bodies or logs. Files under `threads/` contain +only ordered relative paths to records. + +## Stable records and mutable state + +A record key is the lowercase hexadecimal SHA-256 of these UTF-8 strings joined +by a single NUL byte, with no trailing NUL: + +```text +forge identifier, canonical repository identifier, record kind, forge record ID +``` + +The forge record ID is the platform's immutable opaque ID, not a mutable URL, +ordinal, database row position, or display number. Record kinds are `comment`, +`review`, `check`, `thread`, and `entity`. This derivation makes paths safe and +stable without requiring consumers to parse forge-specific IDs. + +Comment and review Markdown files are self-contained records with this exact +UTF-8 layout; header values are canonical JSON strings (or `null`) on one line: + +```text +Fullsend-Record: "comment" +Source-ID: "opaque-forge-id" +Source-URL: "https://forge.example/..." +Author-ID: "opaque-actor-id" +Author: "forge-login" +Created-At: "2026-09-08T10:15:30Z" + +Filtered Markdown body. +``` + +`Fullsend-Record` is `"comment"` or `"review"`. The header order and blank +line are fixed. `Author-ID` and `Author` may be `null` when the forge withholds +or has deleted the actor. All header strings are filtered before JSON-string +serialization. The file contains no update time, ordering, thread membership, +resolution, outdated, or minimized state; those properties belong in +`index.json` or `state/threads.json`. Consequently, resolving a thread or +inserting an earlier record must not rename or rewrite an unchanged record. +Changing its body or attribution fields changes that record's bytes and digest. + +The initial issue or change-proposal body uses the same layout with +`Fullsend-Record: "entity"`, the entity's stable ID and URL, and its author +attribution and creation time. This makes it the first self-contained turn. + +## Filename order and prompt caching + +An ordinary record filename is +`records/-.md`. `` is its source `created_at` +normalized to UTC as `YYYYMMDDTHHMMSSnnnnnnnnnZ`, with exactly nine fractional +second digits and no punctuation other than `T` and `Z`. The entity-body record +uses the reserved key `00000000T000000000000000Z`, so it always sorts first. +Creation time is immutable forge data; edits do not rename a record. + +Because all path components are restricted to these ASCII forms, +`LC_ALL=C cat records/*.md` concatenates the entire conversation in canonical +order without an intermediate file. Each `threads/.order` contains +the relative record path for each thread member followed by LF, in forge thread +order. From the context root, `xargs cat < threads/.order` +concatenates one thread; paths contain no whitespace or shell metacharacters. + +A normal later reply adds one lexically later file and appends one path to its +thread order file, leaving all earlier record bytes and the whole-conversation +prefix unchanged for prompt-cache reuse. A backfilled earlier record, edit, +deletion, or attribution change necessarily invalidates the assembled context +from the first affected record onward. Consumers place mutable state after the +record concatenation and never infer resolution from record content. + +Check status is observation state in `checks//metadata.json`; its +log file contains only filtered log bytes. A growing or replaced forge log is +changed content and may change `log.txt`. A new check attempt has a new forge +record ID and therefore a new record key. + +## Canonical bytes + +JSON is UTF-8 serialized with the JSON Canonicalization Scheme (RFC 8785), with +no byte-order mark or trailing newline. Arrays use the order defined below; +objects use RFC 8785 member ordering. + +Text bodies, patches, and logs are UTF-8 after the v1 filter pipeline, use LF +line endings, have no byte-order mark, and end in exactly one LF. The pipeline +applies size bounds, Unicode safety normalization, secret/sensitive-data +redaction, and injection scanning in that order. `filter.status` is: + +All attacker-controlled strings in JSON metadata pass through the same pipeline +before canonical serialization. + +- `unchanged`: emitted bytes equal normalized source bytes; +- `modified`: one or more replacements or redactions were applied; +- `truncated`: a size bound removed source bytes, whether or not other filters also changed them; +- `rejected`: no content file is emitted because the source could not be represented safely. + +Every emitted file has a manifest `sha256` over its emitted bytes. A rejected +source has a record but no content path or file entry. +Findings contain codes and counts, not rejected source text. Filters and bounds +are identified by `filter_version`; changing emitted bytes for the same input +requires a new filter version. Removing or reinterpreting a status requires v2. + +## Ordering and determinism + +Manifest record arrays and record filenames are sorted by source `created_at`, +then by the record key's ASCII byte order. The reserved entity-body order key +sorts before them. Thread arrays use thread creation time and then thread ID; +`comment_ids` and `.order` lines preserve forge thread order. Commit arrays +preserve forge history order. Other arrays state their ordering in their owning +schema before being added to v1. + +`generated_at` or another runner-clock value is forbidden anywhere under the +context root. Acquisition timing belongs in run telemetry outside the staged +tree. Source-provided timestamps, entity update time, PR head SHA, and check +attempt IDs are permitted because they describe forge state. With identical +forge responses, size bounds, and `filter_version`, the complete tree has +identical paths and bytes. + +## Lifecycle and access + +The host tree is created outside both the repository and retained run-output +tree with directory mode `0700` and file mode `0600`. The sandbox copy is +read-only. Fullsend removes the sandbox copy after the runtime's last use and +the host copy after the post-script on every controlled exit, including skip, +failure, and cancellation. Fullsend also scavenges abandoned context trees on +startup after an unclean termination. + +Artifact collectors must exclude entity-context trees. Retained diagnostics +may contain bounded record counts, content digests, filter codes/counts, and +cleanup errors, but never entity bodies, comment/review bodies, diffs, or logs. + +## Compatibility + +Consumers must reject an unsupported `schema_version` or `filter_version`; they +must ignore unknown object properties within v1. Adding an optional record kind +or property is compatible. Changing existing path derivation, canonical bytes, +required fields, field meaning, or ordering requires +`docs/normative/entity-context/v2/` and a superseding ADR. diff --git a/docs/normative/entity-context/v1/check.schema.json b/docs/normative/entity-context/v1/check.schema.json new file mode 100644 index 0000000000..b78d9ca25f --- /dev/null +++ b/docs/normative/entity-context/v1/check.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fullsend.sh/schemas/entity-context/v1/check.schema.json", + "title": "Fullsend entity-context check metadata v1", + "type": "object", + "required": ["id", "name", "status", "attempt"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string" }, + "status": { "type": "string", "minLength": 1 }, + "conclusion": { "type": ["string", "null"] }, + "attempt": { "type": "integer", "minimum": 1 }, + "source_url": { "type": "string", "format": "uri" }, + "started_at": { "type": "string", "format": "date-time" }, + "completed_at": { "type": ["string", "null"], "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/docs/normative/entity-context/v1/commits.schema.json b/docs/normative/entity-context/v1/commits.schema.json new file mode 100644 index 0000000000..da081a9af3 --- /dev/null +++ b/docs/normative/entity-context/v1/commits.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fullsend.sh/schemas/entity-context/v1/commits.schema.json", + "title": "Fullsend entity-context commits v1", + "type": "object", + "required": ["commits"], + "properties": { + "commits": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "sha", "subject", "committed_at"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "sha": { "type": "string", "pattern": "^[0-9a-fA-F]+$" }, + "subject": { "type": "string" }, + "author": { "type": "string" }, + "committed_at": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/docs/normative/entity-context/v1/entity.schema.json b/docs/normative/entity-context/v1/entity.schema.json new file mode 100644 index 0000000000..cc349eaa94 --- /dev/null +++ b/docs/normative/entity-context/v1/entity.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fullsend.sh/schemas/entity-context/v1/entity.schema.json", + "title": "Fullsend entity metadata v1", + "type": "object", + "required": ["kind", "id", "number", "title", "state", "source_url", "author_id", "author", "created_at", "updated_at", "labels"], + "properties": { + "kind": { "enum": ["issue", "change_proposal"] }, + "id": { "type": "string", "minLength": 1 }, + "number": { "type": ["integer", "string"] }, + "title": { "type": "string" }, + "state": { "type": "string", "minLength": 1 }, + "source_url": { "type": "string", "format": "uri" }, + "author_id": { "type": ["string", "null"] }, + "author": { "type": ["string", "null"] }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "head_sha": { "type": "string", "pattern": "^[0-9a-fA-F]+$" }, + "head_ref": { "type": "string" }, + "base_ref": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/docs/normative/entity-context/v1/index.schema.json b/docs/normative/entity-context/v1/index.schema.json new file mode 100644 index 0000000000..fe05929e15 --- /dev/null +++ b/docs/normative/entity-context/v1/index.schema.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fullsend.sh/schemas/entity-context/v1/index.schema.json", + "title": "Fullsend entity-context index v1", + "type": "object", + "required": ["schema_version", "filter_version", "source", "entity", "files", "records"], + "properties": { + "schema_version": { "const": 1 }, + "filter_version": { "type": "string", "minLength": 1 }, + "source": { + "type": "object", + "required": ["forge", "repository"], + "properties": { + "forge": { "type": "string", "minLength": 1 }, + "repository": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "entity": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "enum": ["issue", "change_proposal"] }, + "id": { "type": "string", "minLength": 1 }, + "updated_at": { "type": "string", "format": "date-time" }, + "head_sha": { "type": "string", "pattern": "^[0-9a-fA-F]+$" } + }, + "additionalProperties": false + }, + "files": { + "type": "array", + "items": { "$ref": "#/$defs/file" } + }, + "records": { + "type": "array", + "items": { "$ref": "#/$defs/record" } + } + }, + "additionalProperties": false, + "$defs": { + "filter": { + "type": "object", + "required": ["status", "findings"], + "properties": { + "status": { "enum": ["unchanged", "modified", "truncated", "rejected"] }, + "findings": { + "type": "array", + "items": { + "type": "object", + "required": ["code", "count"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "count": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "file": { + "type": "object", + "required": ["path", "role", "media_type", "bytes", "sha256"], + "properties": { + "path": { + "type": "string", + "pattern": "^(summary[.]md|entity/metadata[.]json|records/([0-9]{8}T[0-9]{15}Z)-[0-9a-f]{64}[.]md|threads/[0-9a-f]{64}[.]order|changes/(diff[.]patch|commits[.]json)|checks/[0-9a-f]{64}/(metadata[.]json|log[.]txt)|state/threads[.]json)$" + }, + "role": { + "enum": ["summary", "entity_metadata", "entity_record", "comment_record", "review_record", "thread_order", "diff", "commits", "check_metadata", "check_log", "thread_state"] + }, + "media_type": { "type": "string", "minLength": 1 }, + "bytes": { "type": "integer", "minimum": 0 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "filter": { "$ref": "#/$defs/filter" } + }, + "additionalProperties": false + }, + "record": { + "type": "object", + "required": ["kind", "id", "record_key", "author_id", "author", "created_at", "filter"], + "properties": { + "kind": { "enum": ["entity", "comment", "review", "check"] }, + "id": { "type": "string", "minLength": 1 }, + "record_key": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "content_path": { "type": "string", "pattern": "^records/[0-9]{8}T[0-9]{15}Z-[0-9a-f]{64}[.]md$" }, + "metadata_path": { "type": "string", "pattern": "^checks/[0-9a-f]{64}/metadata[.]json$" }, + "log_path": { "type": "string", "pattern": "^checks/[0-9a-f]{64}/log[.]txt$" }, + "source_url": { "type": "string", "format": "uri" }, + "author_id": { "type": ["string", "null"] }, + "author": { "type": ["string", "null"] }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" }, + "filter": { "$ref": "#/$defs/filter" } + }, + "allOf": [ + { + "if": { "properties": { "filter": { "properties": { "status": { "const": "rejected" } } } } }, + "then": { "not": { "anyOf": [{ "required": ["content_path"] }, { "required": ["metadata_path"] }, { "required": ["log_path"] }] } }, + "else": { + "oneOf": [ + { "required": ["content_path"] }, + { "required": ["metadata_path"] } + ] + } + } + ], + "additionalProperties": false + } + } +} diff --git a/docs/normative/entity-context/v1/thread-state.schema.json b/docs/normative/entity-context/v1/thread-state.schema.json new file mode 100644 index 0000000000..9baa9ce1c1 --- /dev/null +++ b/docs/normative/entity-context/v1/thread-state.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fullsend.sh/schemas/entity-context/v1/thread-state.schema.json", + "title": "Fullsend entity-context thread state v1", + "type": "object", + "required": ["threads"], + "properties": { + "threads": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "thread_key", "created_at", "order_path", "comment_ids", "resolved", "outdated"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "thread_key": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "created_at": { "type": "string", "format": "date-time" }, + "order_path": { "type": "string", "pattern": "^threads/[0-9a-f]{64}[.]order$" }, + "comment_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "resolved": { "type": "boolean" }, + "outdated": { "type": "boolean" }, + "minimized": { "type": "boolean" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/docs/problems/security-threat-model.md b/docs/problems/security-threat-model.md index 37fa488742..dc2fd56d65 100644 --- a/docs/problems/security-threat-model.md +++ b/docs/problems/security-threat-model.md @@ -55,6 +55,8 @@ The attack surface is the same as for visible prompt injection — PR descriptio - **Edge and proxy guardrails** — Some organizations filter or moderate **outbound** model traffic (prompts or completions) or tool invocations at a **gateway** in front of providers and MCP servers — for example policy-as-code, rate limits, or vendor moderation hooks. That can add defense in depth and consistent telemetry, but it does not remove the need for **in-agent** separation of trusted instructions from untrusted forge content; see [landscape.md](../landscape.md#agent-gateway). A compromised gateway policy is a concentrated risk, so gateway configuration should be governed like other agent infrastructure. - **Input sanitization** — strip or flag non-rendering Unicode characters before content reaches agents. Specific character classes to target: Tag characters (U+E0000–U+E007F), zero-width characters (U+200B, U+200C, U+200D, U+FEFF), bidirectional overrides (U+202A–U+202E, U+2066–U+2069), and variation selectors. This is more tractable than general prompt injection detection because the characters themselves are the signal — their mere presence in a PR description or code comment is suspicious, regardless of what they encode. However, some of these characters have legitimate uses in internationalized text, so stripping must be context-aware or at minimum flag rather than silently remove. + Deterministic filtering and finding disclosure for fetched entity content is + decided in [ADR 0107](../ADRs/0107-deterministic-filtered-entity-context-staging.md). - **Separation of data and instructions** — agent prompts should clearly delineate between "system instructions" and "untrusted input being analyzed" - **Multi-agent verification** — a reviewing agent's decision is checked by a separate security agent that specifically looks for injection patterns - **Principle of least privilege** — agents should have the minimum permissions needed. A reviewing agent doesn't need merge authority.