# How Safe-Outputs Enable Write Operations in Read-Only Workflows in gh-aw

> Discover how gh-aw safe-outputs enable write operations in read-only workflows. Learn about this declarative permission system for temporary, bounded write capabilities. Maintain security.

- Repository: [GitHub/gh-aw](https://github.com/github/gh-aw)
- Tags: internals
- Published: 2026-02-16

---

**Safe-outputs are a declarative permission system in `gh-aw` that grants temporary, bounded write capabilities to specific workflow steps while maintaining a read-only security posture by default.**

The `github/gh-aw` repository enforces a **read-only-by-default** security model where workflows cannot write files, create artifacts, or publish results without explicit authorization. Safe-outputs provide a mechanism for workflow authors to declare precisely which write operations are required, enabling fine-grained, auditable permissions that are validated at both compile-time and runtime.

## Declaring Safe-Outputs in Workflow Front-Matter

Workflow authors specify safe-outputs in the YAML front-matter of their workflow files. The `safe-outputs` section lists the specific output types that steps are permitted to write.

The parser in `pkg/parser/...` validates this block against the Safe-Outputs schema defined in [`pkg/workflow/safe_output_validation_config.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_output_validation_config.go) and stores the configuration in a `SafeOutputsConfig` struct ([`pkg/workflow/safe_outputs_config.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_outputs_config.go)).

Example declaration:

```yaml
---
engine: copilot
safe-outputs:
  write:
    - artifact   # allow actions/upload-artifact

    - summary    # allow core.summary

    - cache      # allow actions/cache

---

```

## Runtime Enforcement Architecture

The safe-outputs system employs a Model Context Protocol (MCP) server to enforce permissions at runtime, ensuring that only declared write operations succeed.

### MCP Server Implementation

The enforcement engine resides in `actions/setup/js/safe_outputs_mcp_server.cjs`. This server registers the declared safe-outputs at workflow startup and maintains an in-memory whitelist of permitted operations. The server is complemented by handlers in `actions/setup/js/safe_outputs_handlers.cjs` that map safe-output requests to concrete GitHub Action APIs.

When a step attempts a write operation, the handler checks the request type against the whitelist. If permitted, the request forwards to the underlying action (e.g., `core.setOutput`, `actions/upload-artifact`). If not permitted, the server rejects the request with the error: `Safe-output "<type>" not permitted`.

### Runtime Checks in Generated Code

Generated Go code running inside the workflow imports the safe-outputs runtime from [`pkg/workflow/safe_outputs_app.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_outputs_app.go). Before executing any write operation, the runtime calls `safeOutputs.App.CheckWriteAllowed("<type>")`.

This function consults the in-process view of the MCP server's whitelist. If the check fails, the runtime aborts the step using `console.FormatErrorMessage`, ensuring that undeclared write operations cannot proceed even if a malicious step attempts to bypass the MCP server.

## Compilation and Message Generation

During the compilation phase, the safe-outputs builder ([`pkg/workflow/safe_output_builder.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_output_builder.go)) creates a message for each allowed write type. These messages are emitted as GitHub Actions output variables prefixed with `ghaw_safe_output_`.

The core assembly logic resides in [`pkg/workflow/safe_outputs.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_outputs.go) with environment handling in [`pkg/workflow/safe_outputs_env.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_outputs_env.go). The compiler integration in [`pkg/workflow/compiler_safe_outputs.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/compiler_safe_outputs.go) embeds these messages into the final GitHub Actions YAML, ensuring the MCP server receives the whitelist at runtime.

## Cleanup and Permission Revocation

At the end of the workflow, the MCP server automatically revokes the temporary write permissions, ensuring that subsequent runs start from a clean, read-only baseline. The cleanup logic is in `actions/setup/js/safe_outputs_mcp_server.cjs` and is covered by [`pkg/workflow/safe_outputs_mcp_bundler_integration_test.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_outputs_mcp_bundler_integration_test.go).

## Practical Implementation Examples

### Enabling Artifact Uploads

To enable artifact uploads, declare the `artifact` safe-output and conditionally execute the upload step:

```yaml
---
engine: copilot
safe-outputs:
  write:
    - artifact
    - summary
---

# Your workflow steps

- name: Build
  run: go build ./...

- name: Upload Artifact
  if: ${{ env.GHAW_SAFE_OUTPUT_ARTIFACT == 'true' }}
  uses: actions/upload-artifact@v3
  with:
    name: my-artifact
    path: ./bin/

```

### Runtime Validation in Go

When writing custom steps in Go, explicitly check permissions before writing:

```go
package main

import (
    "fmt"
    "github.com/github/gh-aw/pkg/workflow/safe_outputs"
)

func main() {
    // Verify artifact write permission before proceeding
    if err := safe_outputs.App.CheckWriteAllowed("artifact"); err != nil {
        panic(err)
    }
    fmt.Println("Artifact upload permitted")
}

```

### Adding Custom Safe-Output Types

Organizations can extend safe-outputs with custom types by adding handlers in `actions/setup/js/safe_outputs_handlers.cjs` and registering them in the MCP server. Unit tests in [`pkg/workflow/safe_outputs_custom_job_tools_test.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_outputs_custom_job_tools_test.go) validate custom implementations.

## Summary

- **Read-only by default**: `gh-aw` workflows cannot write data unless explicitly permitted through safe-outputs.
- **Declarative permissions**: Authors specify allowed write types (`artifact`, `summary`, `cache`, etc.) in YAML front-matter.
- **Schema validation**: The parser validates declarations against [`pkg/workflow/safe_output_validation_config.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_output_validation_config.go).
- **Runtime enforcement**: An MCP server (`actions/setup/js/safe_outputs_mcp_server.cjs`) maintains a whitelist and rejects unauthorized write attempts.
- **Runtime checks**: Generated Go code uses `safe_outputs.App.CheckWriteAllowed()` to verify permissions before writing.
- **Automatic cleanup**: Write permissions are revoked when the workflow completes, ensuring subsequent runs remain secure.

## Frequently Asked Questions

### What happens if a workflow tries to write without declaring safe-outputs?

Any write attempt in a workflow without the appropriate safe-output declaration is rejected at runtime. The MCP server returns an error message stating `Safe-output "<type>" not permitted`, and the workflow step fails immediately. This enforcement applies whether the write attempt comes from a GitHub Action or custom code running inside the workflow.

### Can I add custom safe-output types beyond the built-in ones?

Yes, the architecture supports custom safe-output types through the handler system in `actions/setup/js/safe_outputs_handlers.cjs`. You can implement a new handler that maps your custom type to specific GitHub Actions APIs, register it with the MCP server, and validate it using the test suite in [`pkg/workflow/safe_outputs_custom_job_tools_test.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_outputs_custom_job_tools_test.go). Once registered, workflows can declare your custom type in their front-matter safe-outputs section.

### How does safe-outputs interact with the read-only baseline security model?

Safe-outputs operate as temporary, bounded exceptions to the read-only default. The `gh-aw` compiler generates workflows that start with zero write permissions. During compilation, the safe-outputs builder ([`pkg/workflow/safe_output_builder.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_output_builder.go)) encodes declared permissions into environment variables passed to the MCP server. The server enforces this whitelist strictly, meaning the workflow remains read-only for all operations except those explicitly listed in the safe-outputs declaration. When the workflow terminates, the MCP server automatically revokes these permissions, ensuring no persistent write access carries over to subsequent runs.

### Where is the safe-outputs configuration validated during the workflow lifecycle?

Validation occurs at multiple stages to ensure security. First, the YAML parser validates the front-matter syntax against the schema defined in [`pkg/workflow/safe_output_validation_config.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/safe_output_validation_config.go) when the workflow is loaded. Second, the compiler ([`pkg/workflow/compiler_safe_outputs.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/compiler_safe_outputs.go)) verifies that the requested safe-output types have corresponding handlers registered in the system. Finally, at runtime, the MCP server (`actions/setup/js/safe_outputs_mcp_server.cjs`) validates every write request against the in-memory whitelist derived from the original declaration, rejecting any attempts to use undeclared output types.