Skip to content
Merged
128 changes: 86 additions & 42 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,33 @@ Alongside the correctly generated pipeline yaml, an agent file is generated from
│ ├── ndjson.rs # NDJSON parsing utilities
│ ├── proxy.rs # Network proxy implementation
│ ├── sanitize.rs # Input sanitization for safe outputs
│ └── tools/ # MCP tool implementations
│ ├── safeoutputs/ # Safe-output MCP tool implementations (Stage 1 → NDJSON → Stage 2)
│ │ ├── mod.rs
│ │ ├── add_build_tag.rs
│ │ ├── add_pr_comment.rs
│ │ ├── comment_on_work_item.rs
│ │ ├── create_branch.rs
│ │ ├── create_git_tag.rs
│ │ ├── create_pr.rs
│ │ ├── create_wiki_page.rs
│ │ ├── create_work_item.rs
│ │ ├── link_work_items.rs
│ │ ├── missing_data.rs
│ │ ├── missing_tool.rs
│ │ ├── noop.rs
│ │ ├── queue_build.rs
│ │ ├── reply_to_pr_comment.rs
│ │ ├── report_incomplete.rs
│ │ ├── resolve_pr_thread.rs
│ │ ├── result.rs
│ │ ├── submit_pr_review.rs
│ │ ├── update_pr.rs
│ │ ├── update_wiki_page.rs
│ │ ├── update_work_item.rs
│ │ └── upload_attachment.rs
│ └── tools/ # First-class tool implementations (compiler auto-configures)
│ ├── mod.rs
│ ├── comment_on_work_item.rs
│ ├── create_pr.rs
│ ├── create_wiki_page.rs
│ ├── create_work_item.rs
│ ├── update_work_item.rs
│ ├── update_wiki_page.rs
│ ├── memory.rs
│ ├── missing_data.rs
│ ├── missing_tool.rs
│ ├── noop.rs
│ └── result.rs
│ └── cache_memory.rs
├── templates/
│ ├── base.yml # Base pipeline template for standalone
│ ├── 1es-base.yml # Base pipeline template for 1ES target
Expand Down Expand Up @@ -125,6 +139,14 @@ checkout: # optional list of repository aliases for the agent to checkout and wo
tools: # optional tool configuration
bash: ["cat", "ls", "grep"] # bash command allow-list (defaults to safe built-in list)
edit: true # enable file editing tool (default: true)
cache-memory: true # persistent memory across runs (see Cache Memory section)
# cache-memory: # Alternative object format (with options)
# allowed-extensions: [.md, .json]
azure-devops: true # first-class ADO MCP integration (see Azure DevOps MCP section)
# azure-devops: # Alternative object format (with scoping)
# toolsets: [repos, wit]
# allowed: [wit_get_work_item]
# org: myorg
# env: # RESERVED: workflow-level environment variables (not yet implemented)
# CUSTOM_VAR: "value"
mcp-servers:
Expand Down Expand Up @@ -392,6 +414,53 @@ tools:
edit: false
```

#### Cache Memory (`cache-memory:`)

Persistent memory storage across agent runs. The agent reads/writes files to a memory directory that persists between pipeline executions via pipeline artifacts.

```yaml
# Simple enablement
tools:
cache-memory: true

# With options
tools:
cache-memory:
allowed-extensions: [.md, .json, .txt]
```

When enabled, the compiler auto-generates pipeline steps to:
- Download previous memory from the last successful run's artifact
- Restore files to `/tmp/awf-tools/staging/agent_memory/`
- Append a memory prompt to the agent instructions
- Auto-inject a `clearMemory` pipeline parameter (allows clearing memory from the ADO UI)

During Stage 2 execution, memory files are validated (path safety, extension filtering, `##vso[` injection detection, 5 MB size limit) and published as a pipeline artifact.

#### Azure DevOps MCP (`azure-devops:`)

First-class Azure DevOps MCP integration. Auto-configures the ADO MCP container, token mapping, MCPG entry, and network allowlist.

```yaml
# Simple enablement (auto-infers org from git remote)
tools:
azure-devops: true

# With scoping options
tools:
azure-devops:
toolsets: [repos, wit, core] # ADO API toolset groups
allowed: [wit_get_work_item, core_list_projects] # Explicit tool allow-list
org: myorg # Optional override (inferred from git remote)
```

When enabled, the compiler:
- Generates a containerized stdio MCP entry (`node:20-slim` + `npx @azure-devops/mcp`) in the MCPG config
- Auto-maps `AZURE_DEVOPS_EXT_PAT` token passthrough when `permissions.read` is configured
- Adds ADO-specific hosts to the network allowlist
- Auto-infers org from the git remote URL at compile time (overridable via `org:` field)
- Fails compilation if org cannot be determined (no explicit override and no ADO git remote)

### Target Platforms

The `target` field in the front matter determines the output format and execution environment for the compiled pipeline.
Expand Down Expand Up @@ -1066,35 +1135,8 @@ Reports that a tool or capability needed to complete the task is not available.
- `tool_name` - Name of the tool that was expected but not found
- `context` - Optional context about why the tool was needed

#### memory
Provides persistent memory across agent runs. When enabled, the agent can read and write files to a memory directory that persists between pipeline executions.

**Configuration options (front matter):**
```yaml
safe-outputs:
memory:
allowed-extensions: # Optional: restrict file types (defaults to all)
- .md
- .json
- .txt
```

**How it works:**
1. During Stage 1 (agent execution), the agent can write files to `/tmp/awf-tools/staging/agent_memory/`
2. A prompt is automatically appended to inform the agent about its memory location
3. During Stage 2 execution, memory files are validated and sanitized:
- Path traversal attempts are blocked
- Files are checked for `##vso[` command injection
- Total size is limited to 5 MB
- File extensions can be restricted via configuration
4. Sanitized memory files are published as a pipeline artifact
5. On the next run, the previous memory is downloaded and restored to the staging directory

**Security validations:**
- Maximum total memory size: 5 MB
- Path validation: no `..`, `.git`, absolute paths, or null bytes
- Content validation: text files are scanned for `##vso[` commands
- Extension filtering: can restrict to specific file types
#### cache-memory (moved to `tools:`)
Memory is now configured as a first-class tool under `tools: cache-memory:` instead of `safe-outputs: memory:`. See the [Cache Memory](#cache-memory-cache-memory) section under Tools Configuration for details.

#### create-wiki-page
Creates a new Azure DevOps wiki page. The page must **not** already exist; the tool enforces an atomic create-only operation (via `If-Match: ""`). Attempting to create a page that already exists results in an explicit failure.
Expand Down Expand Up @@ -1154,7 +1196,9 @@ When extending the compiler:
2. **New compile targets**: Implement the `Compiler` trait in a new file under `src/compile/`
3. **New front matter fields**: Add fields to `FrontMatter` in `src/compile/types.rs`
4. **New template markers**: Handle replacements in the target-specific compiler (e.g., `standalone.rs` or `onees.rs`)
5. **Validation**: Add compile-time validation for safe outputs and permissions
5. **New safe-output tools**: Add to `src/safeoutputs/` — implement `ToolResult`, `Executor`, register in `mod.rs`, `mcp.rs`, `execute.rs`
6. **New first-class tools**: Add to `src/tools/` — extend `ToolsConfig` in `types.rs`, wire in compilers
7. **Validation**: Add compile-time validation for safe outputs and permissions

### Security Considerations

Expand Down
13 changes: 2 additions & 11 deletions examples/azure-devops-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,9 @@ name: "ADO Work Item Triage"
description: "Triages work items using the Azure DevOps MCP"
schedule: daily around 9:00
engine: claude-sonnet-4.5
mcp-servers:
tools:
azure-devops:
container: "node:20-slim"
entrypoint: "npx"
entrypoint-args: ["-y", "@azure-devops/mcp", "myorg", "-d", "core", "work", "work-items"]
env:
AZURE_DEVOPS_EXT_PAT: ""
toolsets: [core, work, work-items]
allowed:
- core_list_projects
- wit_my_work_items
Expand All @@ -31,11 +27,6 @@ safe-outputs:
comment-on-work-item:
max: 10
target: "*"
network:
allow:
- "dev.azure.com"
- "*.dev.azure.com"
- "*.visualstudio.com"
---

## ADO Work Item Triage Agent
Expand Down
59 changes: 42 additions & 17 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,9 +625,25 @@ pub fn generate_header_comment(input_path: &std::path::Path) -> String {
/// See: https://github.com/github/gh-aw-mcpg/releases
pub const MCPG_VERSION: &str = "0.2.18";

/// Docker image for the MCPG container.
pub const MCPG_IMAGE: &str = "ghcr.io/github/gh-aw-mcpg";

/// Default port MCPG listens on inside the container (host network mode).
pub const MCPG_PORT: u16 = 80;

/// Docker image for the Azure DevOps MCP container.
/// This is the container used when `tools: azure-devops:` is configured.
pub const ADO_MCP_IMAGE: &str = "node:20-slim";

/// Default entrypoint for the Azure DevOps MCP container.
pub const ADO_MCP_ENTRYPOINT: &str = "npx";

/// Default entrypoint args for the Azure DevOps MCP npm package.
pub const ADO_MCP_PACKAGE: &str = "@azure-devops/mcp";

/// Reserved MCPG server name for the auto-configured ADO MCP.
pub const ADO_MCP_SERVER_NAME: &str = "azure-devops";

/// Generate source path for the execute command.
///
/// Returns a path using `{{ workspace }}` as the base, which gets resolved
Expand Down Expand Up @@ -805,14 +821,11 @@ fn is_safe_tool_name(name: &str) -> bool {
/// diagnostic tools. If `safe-outputs:` is empty, returns an empty string
/// (all tools enabled for backward compatibility).
///
/// Non-MCP keys (like `memory`) are silently skipped — they are handled by the
/// executor and have no corresponding MCP route.
///
/// Tool names are validated to contain only ASCII alphanumerics and hyphens
/// to prevent shell injection when the args are embedded in bash commands.
/// Unrecognized tool names emit a compile-time warning and are skipped.
pub fn generate_enabled_tools_args(front_matter: &FrontMatter) -> String {
use crate::tools::{ALL_KNOWN_SAFE_OUTPUTS, ALWAYS_ON_TOOLS, NON_MCP_SAFE_OUTPUT_KEYS};
use crate::safeoutputs::{ALL_KNOWN_SAFE_OUTPUTS, ALWAYS_ON_TOOLS, NON_MCP_SAFE_OUTPUT_KEYS};
use std::collections::HashSet;

if front_matter.safe_outputs.is_empty() {
Expand All @@ -835,6 +848,14 @@ pub fn generate_enabled_tools_args(front_matter: &FrontMatter) -> String {
if NON_MCP_SAFE_OUTPUT_KEYS.contains(&key.as_str()) {
continue;
}
if key == "memory" {
eprintln!(
"Warning: Agent '{}': 'safe-outputs: memory:' has moved to \
'tools: cache-memory:'. Update your front matter to restore memory support.",
front_matter.name
);
continue;
}
if !ALL_KNOWN_SAFE_OUTPUTS.contains(&key.as_str()) {
eprintln!(
"Warning: unrecognized safe-output tool '{}' — skipping (no registered tool matches this name)",
Expand All @@ -849,8 +870,8 @@ pub fn generate_enabled_tools_args(front_matter: &FrontMatter) -> String {
}

if effective_mcp_tool_count == 0 {
// Every user-specified key was either invalid, unrecognized, or non-MCP
// (e.g. memory-only). Return empty to keep all tools available (backward compat).
// Every user-specified key was either invalid or unrecognized.
// Return empty to keep all tools available (backward compat).
return String::new();
}

Expand Down Expand Up @@ -878,7 +899,7 @@ pub fn generate_enabled_tools_args(front_matter: &FrontMatter) -> String {

/// Validate that write-requiring safe-outputs have a write service connection configured.
pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> {
use crate::tools::WRITE_REQUIRING_SAFE_OUTPUTS;
use crate::safeoutputs::WRITE_REQUIRING_SAFE_OUTPUTS;

let has_write_sc = front_matter
.permissions
Expand Down Expand Up @@ -1170,6 +1191,8 @@ mod tests {
fm.tools = Some(crate::compile::types::ToolsConfig {
bash: Some(vec![":*".to_string()]),
edit: None,
cache_memory: None,
azure_devops: None,
});
let params = generate_copilot_params(&fm);
assert!(params.contains("--allow-tool \"shell(:*)\""));
Expand All @@ -1181,6 +1204,8 @@ mod tests {
fm.tools = Some(crate::compile::types::ToolsConfig {
bash: Some(vec![]),
edit: None,
cache_memory: None,
azure_devops: None,
});
let params = generate_copilot_params(&fm);
assert!(!params.contains("shell("));
Expand Down Expand Up @@ -1886,26 +1911,26 @@ mod tests {
}

#[test]
fn test_generate_enabled_tools_args_skips_memory_key() {
// `memory` is a non-MCP executor-only key. It must not appear in
// --enabled-tools or it would cause real MCP tools to be filtered out.
fn test_generate_enabled_tools_args_memory_no_longer_safe_output() {
// `memory` is no longer a safe-output key — it moved to `tools: cache-memory:`.
// If someone still puts it in safe-outputs, it should be treated as unrecognized
// and the real MCP tool should still be present.
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nsafe-outputs:\n memory:\n allowed-extensions:\n - .md\n create-pull-request:\n target-branch: main\n---\n"
"---\nname: test\ndescription: test\nsafe-outputs:\n create-pull-request:\n target-branch: main\n---\n"
).unwrap();
let args = generate_enabled_tools_args(&fm);
assert!(!args.contains("--enabled-tools memory"), "Non-MCP key 'memory' should be skipped");
assert!(args.contains("--enabled-tools create-pull-request"), "Real MCP tool should be present");
}

#[test]
fn test_generate_enabled_tools_args_memory_only_does_not_filter() {
// When `memory` is the only safe-output key, no --enabled-tools args should
// be generated so all tools remain available (backward compat).
fn test_generate_enabled_tools_args_empty_safe_outputs_no_filter() {
// When safe-outputs is empty, no --enabled-tools args should be generated
// so all tools remain available.
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nsafe-outputs:\n memory:\n allowed-extensions:\n - .md\n---\n"
"---\nname: test\ndescription: test\n---\n"
).unwrap();
let args = generate_enabled_tools_args(&fm);
assert!(args.is_empty(), "memory-only safe-outputs should produce no args (all tools available)");
assert!(args.is_empty(), "empty safe-outputs should produce no args (all tools available)");
}

// ─── parameter name validation ──────────────────────────────────────────
Expand Down
6 changes: 5 additions & 1 deletion src/compile/onees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ impl Compiler for OneESCompiler {
let checkout_steps = generate_checkout_steps(&front_matter.checkout);
let checkout_self = generate_checkout_self();
let copilot_params = generate_copilot_params(front_matter);
let has_memory = front_matter.safe_outputs.contains_key("memory");
let has_memory = front_matter
.tools
.as_ref()
.and_then(|t| t.cache_memory.as_ref())
.is_some_and(|cm| cm.is_enabled());
let parameters = build_parameters(&front_matter.parameters, has_memory);
let parameters_yaml = generate_parameters(&parameters)?;

Expand Down
Loading
Loading