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

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 and stores the configuration in a SafeOutputsConfig struct (pkg/workflow/safe_outputs_config.go).

Example declaration:

---
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. 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) 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 with environment handling in pkg/workflow/safe_outputs_env.go. The compiler integration in 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.

Practical Implementation Examples

Enabling Artifact Uploads

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

---
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:

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 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.
  • 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. 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) 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 when the workflow is loaded. Second, the compiler (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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →