enhance(#1577): WebFetch/WebSearch injection isolation + opt-in blocking (#1585)

* fix(#1577): isolate WebFetch/WebSearch ingress + opt-in injection blocking

Split A of #1573 (security-critical). Scans WebFetch/WebSearch output (the
largest untrusted channel) in gsd-read-injection-scanner; shared
untrusted-input-boundary reference @-included by the 8 ingest agents
(randomized per-wrap delimiters, in-prompt self-scan guard, task-anchoring);
opt-in security.injection_blocking (default advisory — non-breaking).

arXiv: 2506.05739 (PPA), 2507.15219 (PromptArmor), 2504.20472 (Referencing), 2503.00061 (defense-in-depth).

* fix(#1577): address review — honest blocking docs, config key, ADR, property test, revert localized

- A1: rewrote the opt-in-blocking doc + Security changeset honestly — the PostToolUse hook is a
  circuit-breaker (halts the agent's next step), NOT a redactor; it does not scrub content already
  in the transcript. The prompt-level data/instruction boundary is the primary control.
- A2: registered security.injection_blocking in the config schema + defaults manifests (default
  false) + an e2e config-roundtrip test; the dotted setter writes the nested shape the hook reads.
- A3: reverted the 4 hand-edited localized security-model.md (canonical EN only, per convention).
- A5: ADR-1577 (untrusted-input boundary + opt-in blocking; redaction-vs-circuit-breaker rationale).
- A6: property test — scanner never crashes / only emits valid JSON on unicode/large/malformed input.
- Also: inventory (untrusted-input-boundary.md) + agent-size baseline (8 ingest agents) +
  drift-guard matcher update (Read -> Read|WebFetch|WebSearch). A7 (content<20 early-exit) left as
  the noted pre-existing follow-up.

* fix(#1577): allowlist untrusted-input-boundary.md in injection-scan CI gate

The new reference quotes injection phrases ('ignore previous instructions',
'you are now…') as examples agents must NOT comply with, tripping the repo's
own prompt-injection-scan.sh diff gate (the standalone 'security' CI job, red
on HEAD). Allowlist it alongside the other security docs (security-model.md,
TEST-EXAMPLES.md) that legitimately demonstrate injection patterns. The JS
scanner test doesn't scan references/, so only the shell gate needed it.

Verified: scan --diff origin/next -> 0 findings; scanner JS test 15/15.

* fix(#1577): cover AC #2's gsd-ui-researcher + gsd-assumptions-analyzer

trek-e Major 1: the @-included set dropped two AC #2 agents. Restore them so
no named web-ingress agent is uncovered, keeping the two justified additions
(gsd-ai-researcher, gsd-domain-researcher). Final set = AC's 8 + 2 = 10.
 - gsd-ui-researcher carries the full WebSearch/WebFetch + MCP-fetch toolset.
 - gsd-assumptions-analyzer reads 5-15 codebase source files (external/source-
   document ingress per the boundary), though it has no web tools.
INGEST_AGENTS in the isolation test now asserts all 10; size baselines
regenerated (+60 bytes each, both well under the DEFAULT cap); changeset
reworded 8 -> 10.

Verified: untrusted-input-isolation 14/14; agent-size-budget 39/39.

* docs(#1577): document security.injection_blocking + boundary seam

trek-e Major 2 + Minor:
 - docs/CONFIGURATION.md: add the top-level security.injection_blocking key to
   the Full Schema and a Security Settings subsection, distinguishing it from
   the workflow.security_* namespace; honest circuit-breaker-not-redactor
   framing matching ADR-1577 / security-model.
 - CONTEXT.md: add the 'Untrusted-input boundary' seam glossary entry.

Verified: lint:docs ok; config-field-docs + contributor-standards green.

* test(#1577): make read-injection property test git-text, not binary

trek-e nit (and more): the file embedded a raw U+FFFF AND a raw NUL byte as
degenerate-edge inputs. The NUL is what actually made git classify it binary
(git binary = NUL in first 8K). Replace both with text-safe escapes that keep
the identical runtime values: '\\x00' and String.fromCodePoint(0xFFFF). File
now diffs/blames line-by-line.

Verified: property test 2/2; no NUL/raw-noncharacter bytes remain.

* docs(#1577): align untrusted boundary docs

Name all 10 ingress agents in INVENTORY/security-model and allowlist the intentional read-injection property corpus for the prompt-injection scanner.

* docs(#1577): align ADR ingest agent count

Update ADR-1577 from 8 to 10 ingest agents so it matches the actual boundary include set and the rest of the docs.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Alex V.
2026-06-25 00:07:23 +03:00
committed by GitHub
parent 3870fafe74
commit a63684c222
29 changed files with 536 additions and 52 deletions

View File

@@ -113,6 +113,9 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
"always_confirm_destructive": true,
"always_confirm_external_services": true
},
"security": {
"injection_blocking": false
},
"project_code": null,
"agent_skills": {},
"agent_skills_security": {
@@ -832,6 +835,14 @@ These keys live under `workflow.*` — that is where the workflows and installer
| `workflow.security_asvs_level` | number (1-3) | `1` | OWASP ASVS verification level. Level 1 = opportunistic, Level 2 = standard, Level 3 = comprehensive |
| `workflow.security_block_on` | string | `"high"` | Minimum threat severity that blocks phase advancement. The auditor counts only open threats at or above this severity toward the blocking gate; `none` disables severity blocking. Options: `"critical"`, `"high"`, `"medium"`, `"low"`, `"none"` |
### Injection blocking (top-level `security.*`)
Distinct from the `workflow.security_*` keys above: the read-injection scanner reads a **top-level** `security` object (not `workflow.security`). Set it with `gsd config-set security.injection_blocking true` — it persists as a nested key (`security.injection_blocking`), never a flat dotted key.
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `security.injection_blocking` | boolean | `false` | Opt-in circuit-breaker for the read-injection scanner hook (`gsd-read-injection-scanner.js`, PostToolUse on `Read`/`WebFetch`/`WebSearch`). Default (`false`) is **advisory**: HIGH-confidence injection detections are logged but not blocked. When `true`, a HIGH detection emits `decision: "block"` to halt the agent's next step. Because the hook runs *after* the fetch, blocking does **not** retroactively redact content already in the transcript — it is a circuit-breaker, not a redactor. See the [security model](explanation/security-model.md) and [ADR-1577](adr/1577-untrusted-input-boundary-and-injection-blocking.md). |
---
## Decision Coverage Gates (`workflow.context_coverage_gate`)

View File

@@ -266,6 +266,7 @@
"thinking-partner.md",
"ui-brand.md",
"universal-anti-patterns.md",
"untrusted-input-boundary.md",
"user-profiling.md",
"user-story-template.md",
"verification-overrides.md",

View File

@@ -315,6 +315,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum
| `universal-anti-patterns.md` | Universal anti-patterns to detect and avoid. |
| `worktree-branch-check.md` | Canonical spawn-time worktree HEAD/base guard (worktree_branch_check): verify-only and fail-closed — per-agent-branch assertion, protected-ref refusal (#2924), and an exact-base assertion that halts with `exit 42` on mismatch so the orchestrator (worktree lifecycle owner) performs recovery (#48). Embedded into worktree sub-agent prompts at dispatch. |
| `worktree-path-safety.md` | Worktree guard suite: HEAD assertion, cwd-drift sentinel (step 0a, #3097), and absolute-path guard (step 0b, #3099) — loaded into executor spawn prompts via `<execution_context>`. |
| `untrusted-input-boundary.md` | Shared prompt-injection boundary (#1577) `@`-included by the 10 research/doc-ingest agents (`gsd-project-researcher`, `gsd-phase-researcher`, `gsd-ui-researcher`, `gsd-assumptions-analyzer`, `gsd-advisor-researcher`, `gsd-doc-classifier`, `gsd-doc-synthesizer`, `gsd-research-synthesizer`, `gsd-ai-researcher`, `gsd-domain-researcher`): treat fetched/read text as data-not-instructions, self-scan before use (PromptArmor 2507.15219), task-anchor (2504.20472), and fence quoted text with a fresh random delimiter per wrap (PPA 2506.05739). Prompt-level defense-in-depth (2503.00061); the hook scanner is a separate pattern pre-filter. |
| `artifact-types.md` | Planning artifact type definitions. |
| `phase-argument-parsing.md` | Phase argument parsing conventions. |
| `decimal-phase-calculation.md` | Decimal sub-phase numbering rules. |

View File

@@ -0,0 +1,27 @@
# ADR-1577: Untrusted-input boundary + opt-in injection blocking
- **Status:** Proposed
- **Issue:** [#1577](https://github.com/open-gsd/gsd-core/issues/1577)
- **Part of:** [#1573](https://github.com/open-gsd/gsd-core/issues/1573) (harden the agent layer against documented LLM failure modes)
## Context
The research/doc-ingest agents concatenate text returned by WebFetch / WebSearch / Read into their context with no data/instruction separation, and the `gsd-read-injection-scanner` hook only scanned the `Read` tool — leaving WebFetch/WebSearch (the largest untrusted channel) unscanned. Prompt injection via fetched content is a documented LLM failure mode (arXiv [2506.05739](https://arxiv.org/abs/2506.05739), [2507.15219](https://arxiv.org/abs/2507.15219), [2504.20472](https://arxiv.org/abs/2504.20472)).
Two mechanisms were considered for the **hook-level** control:
1. **Redaction** — strip the detected content before it reaches the model. This requires `hookSpecificOutput.updatedToolOutput`, which is unused anywhere in this repo and not verifiable in CI for a PostToolUse hook. Claiming redaction the code can't reliably perform would re-introduce exactly the overclaim this work set out to remove.
2. **Circuit-breaker** — a PostToolUse hook that, *after* the fetch has executed and the content is already in the transcript, emits `decision: "block"` to halt the agent's next step. It does **not** redact content already in context.
## Decision
- Extend the scanner to match `Read | WebFetch | WebSearch`, documented honestly as a **pattern-based pre-filter**, not a model-level guard.
- Make the **prompt-level boundary the primary control**: a shared `gsd-core/references/untrusted-input-boundary.md`, `@`-included by the 10 ingest agents, instructs treat-fetched-text-as-data, self-scan before use, task-anchoring, and a fresh random delimiter per quoted wrap. This is the layer that keeps an injection from being *followed* even while it sits in context.
- Ship hook-level blocking as an **opt-in circuit-breaker**: `security.injection_blocking` (a registered config key; default advisory). Documentation states plainly that enabling it halts further processing on a HIGH detection — it does not retroactively redact the already-fetched content. Redaction via `updatedToolOutput` is **deferred** until that field's behavior is verifiable in this runtime.
## Consequences
- **Non-breaking.** The default posture is advisory; no existing default changes. Blocking is reached only by an explicit opt-in.
- The strongest guarantee is prompt-level (data/instruction separation), which is unenforced at runtime — this is defense-in-depth (arXiv [2503.00061](https://arxiv.org/abs/2503.00061)), not a hard sandbox. A determined adaptive attacker or a weaker model may still be influenced.
- Localized docs are managed separately; only the canonical English `docs/explanation/security-model.md` is updated here.
- Follow-up: if/when `updatedToolOutput` redaction is confirmed supported, the circuit-breaker can be upgraded to an actual redactor without changing the opt-in surface.

View File

@@ -153,10 +153,35 @@ false-positive block on a legitimate planning write would be more disruptive
than a missed injection in a secondary scan layer.
**Runtime hook: `gsd-read-injection-scanner.js`.** This hook fires on the
output of every Read tool call. It scans the *content that was just read* for
injected instructions in untrusted content — catching cases where an attacker
has embedded instructions in a file that GSD is about to incorporate into an
agent's context.
output of every Read, WebFetch, and WebSearch tool call. It scans the *content
that was just read or fetched* for injected instructions in untrusted content —
catching cases where an attacker has embedded instructions in a file or remote
resource that GSD is about to incorporate into an agent's context. The 10
research and doc-ingest agents additionally carry a shared `<security_context>`
data/instruction boundary (defined in
`gsd-core/references/untrusted-input-boundary.md`): `gsd-project-researcher`,
`gsd-phase-researcher`, `gsd-ui-researcher`, `gsd-assumptions-analyzer`,
`gsd-advisor-researcher`, `gsd-doc-classifier`, `gsd-doc-synthesizer`,
`gsd-research-synthesizer`, `gsd-ai-researcher`, and `gsd-domain-researcher`.
Any content fetched or read by those agents is treated as data, never as
instructions, regardless of what the content claims to be.
**Opt-in blocking (`security.injection_blocking`).** By default all injection
detections are advisory-only (logged, not blocked). Setting
`security.injection_blocking = true` in `.planning/config.json` (a registered
config key — `gsd config-set security.injection_blocking true`) upgrades
HIGH-confidence detections to **blocking**. Be precise about what this does: the
scanner is a **PostToolUse** hook, so it runs *after* the Read/WebFetch/WebSearch
has already executed and the fetched content is already in the model's transcript.
Blocking does **not** retroactively redact that content — it emits
`decision: "block"`, which halts the agent's next step and feeds the detection back
as the reason, so the agent is stopped from acting further on the flagged result
instead of silently continuing. LOW detections remain advisory under this setting.
This flag is opt-in; the default (advisory-only) is preserved to avoid breaking
existing workflows. The prompt-level boundary above (treat fetched text as data,
never instructions) is the layer that keeps an injection from being *followed* even
while it sits in context; the hook is a coarse pattern pre-filter and circuit-breaker,
not a redactor.
**CI scanner.** `prompt-injection-scan.security.test.cjs` scans all agent, workflow,
and command files for embedded injection vectors as part of the test suite.
@@ -167,11 +192,14 @@ instruction.
### Read Injection Scanner vs Prompt Guard
The two hooks cover complementary surfaces. `gsd-prompt-guard.js` watches
*writes to planning artifacts* — it catches injection being planted.
`gsd-read-injection-scanner.js` watches *reads of any file* — it catches
*writes to planning artifacts* — it catches injection being planted.
`gsd-read-injection-scanner.js` watches *reads and remote fetches* — it catches
injection being ingested from external content (a dependency's README, a
third-party config file, a user-provided document). Together they bracket
the ingest → store → re-read lifecycle.
third-party config file, a user-provided document, or any URL fetched via
WebFetch or WebSearch). The in-prompt `<security_context>` boundary in research
agents provides an additional containment layer: even if an injected string
reaches an agent, it is structurally separated from the instruction region.
Together these controls bracket the ingest → store → re-read lifecycle.
---
@@ -228,10 +256,14 @@ not hard-stopping on a detection.
**What the prompt injection defences do not eliminate:** A sufficiently
creative injection that does not match known patterns, or an injection that
arrives through a channel the hooks do not cover (for example, content injected
into a dependency's published README that is read by a subagent browsing
documentation). Defence in depth means each layer makes the attack harder,
not that any single layer makes it impossible.
arrives through a channel the hooks do not cover. The previously uncovered
channel of content injected into a dependency's published README and read by a
subagent browsing documentation is now scanned at ingress by
`gsd-read-injection-scanner.js` (which covers WebFetch and WebSearch output)
and structurally isolated in-prompt by the `<security_context>` boundary in
research agents — but novel jailbreaks and low-signal injections may still pass
undetected. Defence in depth means each layer makes the attack harder, not that
any single layer makes it impossible.
**Reporting vulnerabilities.** Report via private GitHub security advisory at
`https://github.com/open-gsd/gsd-core/security/advisories/new`. Do not open