diff --git a/README.md b/README.md index d1ea3ff..16992ba 100644 --- a/README.md +++ b/README.md @@ -1,297 +1,53 @@ -# πŸ›‘οΈ Decionis Action Gate +# Decionis Govern -**Stop unauthorized deploys, migrations, and infra changes before they reach production β€” whether a teammate or an AI agent triggered them.** +**One verdict before a workflow step runs β€” a deploy, a migration, an infrastructure change, an +agent's action β€” with a signed Decision Dossier of it.** [![Marketplace](https://img.shields.io/github/v/release/decionis/govern?label=marketplace&logo=githubactions&logoColor=white&color=6D28D9)](https://github.com/marketplace/actions/decionis-action-gate) -[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/govern) +[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/agent-safe-pipeline/tree/master/govern) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) -Add **one step** to your workflow, and every deploy, migration, infra change, or AI-generated PR is checked against **your** policy β€” then **allowed, blocked, or escalated before it runs**, with a signed, verifiable record of every decision. - -**Try it in 30 seconds, risk-free.** Start in shadow mode: it records what _would_ be governed without ever failing a build β€” so you see the value before you enforce anything. - ---- - -## The question every pipeline now faces - -AI coding agents β€” **Claude Code, Copilot, Cursor, Codex, OpenHands** β€” now open PRs, edit workflows, write migrations, and trigger deploys. The hard question is no longer _"who wrote this code?"_ It's: - -> **Should this action be allowed to execute?** - -That's the Action Gate. - -## Why Decionis - -- **One verdict per governed action** β€” `allow` / `block` / `restrain` / `escalate`, evaluated against _your_ policy before anything runs. -- **Deterministic policy you own** β€” versioned rules, committed as `DECIONIS_POLICY.md` or connected from where policy already lives; every verdict cites the version that applied. -- **Microsecond verdicts** β€” the committed rules block is evaluated **in-process** by a local engine that mirrors the server, so deterministic verdicts act in ~1ms while the API notarizes off the critical path. Shadow mode is **speculative**: your command starts immediately, adding ~zero latency. -- **Cryptographic proof** β€” each decision is a signed, public-verifiable Decision Dossier: audit-ready evidence of what was authorized and why, pinned to the exact policy revision (`sha256`). -- **Zero-friction adoption** β€” start in shadow mode in 30 seconds; it never fails a build until you choose to enforce. - -## Set it up in one step - -Pick whichever is fastest β€” both drop a **shadow-mode** gate in, so nothing fails your build on day one: - -- **New repo:** [**Start from the examples β†’**](https://github.com/decionis/govern/tree/main/examples) β€” ready-wired gate workflows (deploy, Terraform, AI-agent PRs, Dependabot auto-merge) + a starter `DECIONIS_POLICY.md`. -- **Existing repo, one command:** - - ```bash - curl -fsSL https://decionis.com/govern/install.sh | sh - ``` - - Writes a shadow-mode workflow + a starter `DECIONIS_POLICY.md` (no secrets touched, idempotent). Flags go after `sh -s --`, and the hosted one-liner takes two: - - | Flag | Effect | - | --------- | ---------------------------------------------------------------------------------------------------------- | - | `--pr` | Branch (`feature/add-decionis-governance`), commit, push, and open the onboarding PR (`gh` CLI, or prints the compare URL). | - | `--force` | Overwrite files that already exist. | - - The fuller installer ships in this repo ([`install.sh`](./install.sh)) and adds `--inject` (insert an **observe-only** shadow step at the top of every job in your existing workflows β€” insert-only, `continue-on-error: true`, skips anything unsafe), `--dry-run`, `--mode`, `--org-id`, and `--workflow-key`. Run that copy directly: - - ```bash - # The full viral onboarding: gate every workflow, open the PR - curl -fsSL https://raw.githubusercontent.com/decionis/govern/v1/install.sh | sh -s -- --pr --inject - ``` - -Then add your `DECIONIS_API_KEY` / `DECIONIS_ORG_ID` secrets β€” [free keys here](https://decionis.com/quickstart?source=github_action). - -## 30-second quickstart - -**Wrap the command you want to govern.** Decionis runs it _through_ the gate, so it can't execute without an authorizing verdict: - -```yaml -- uses: decionis/govern@v1 - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - run: ./deploy.sh # ← Decionis runs this ONLY if it authorizes the action -``` - -On `allow` the command runs. On `block`/`escalate` it **never runs** and the step fails. Try it risk-free with `mode: shadow` β€” the command still runs, but Decionis only records the verdict (never fails the build): - ```yaml -- uses: decionis/govern@v1 +- uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - run: ./deploy.sh - mode: shadow # observe-only; command starts instantly, verdict records in the background - comment-pr: "true" # posts the verdict + verify link on the PR -``` - -Need keys? Create them free at **[decionis.com/quickstart?source=github_action](https://decionis.com/quickstart?source=github_action)** β€” no card, no call. - -### Cryptographic proof, verified at the target - -Set `request-grant: true` and every authorized run carries a short-lived, single-use, signed **Execution Grant** β€” `DECIONIS_EXECUTION_GRANT` in the command's environment. Your deploy target verifies it against Decionis's public keys before it acts, so authorization is proven where the action actually happens. - -```yaml -- uses: decionis/govern@v1 - with: - workflow-key: github_deploy_approval - action: production-deploy - request-grant: true - grant-audience: prod-us-east - run: ./deploy.sh # presents $DECIONIS_EXECUTION_GRANT to the target -``` - -### Zero standing credentials - -Take secrets out of CI entirely. Register a target's credential with Decionis once, and it's released only in exchange for an authorized run β€” your pipeline holds nothing to leak. See [`examples/gate-deploy-broker.yml`](./examples/gate-deploy-broker.yml), or federate **GCP Workload Identity Federation** / **Azure** so the cloud issues credentials only for a Decionis-authorized deploy. - ---- - -## What you get - -### 1. 🚦 A verdict before anything runs - -One verdict before execution β€” **`allow`**, **`block`**, or **`escalate`**. Composable into any later step via `steps..outputs.decision`. - -### 2. 🟣 A zero-risk, zero-latency way to start - -`mode: shadow` shows exactly what would be governed, without ever failing a build β€” and it's **speculative**: your `run` command starts immediately while the verdict resolves in the background, so the gate adds ~zero wall-clock time. The step's exit code is exactly your command's; evaluation problems (API down, secrets not configured yet) surface as notices, never failures. Enforce when you're ready β€” one line. - -### ⚑ Local policy engine - -The ```` ```decionis ```` rules block in your `DECIONIS_POLICY.md` is evaluated **in-process** by a local engine that faithfully mirrors the platform evaluator (same operators, same coercions, same first-match ordering). A deterministic `allow`/`block` from an explicitly matched committed rule acts in **microseconds**; the API call becomes an async notarization off the critical path, so the signed dossier, verify URL, and badge still arrive. The log shows the speedup: - -``` -⚑ Decionis local verdict 'block' via rule "Block deploys during a change freeze" in 0.41ms β€” API roundtrip moved off the critical path. -``` - -Control it with `local-eval`: - -- **`auto`** (default) β€” local verdicts act instantly; the API notarizes in the background. If the API disagrees (org-level policy can add rules), you get a `::warning::` and `verdict-mismatch=true`. -- **`strict`** β€” deterministic local verdicts skip the network entirely (offline-capable; those runs mint no dossier). -- **`off`** β€” v1.8 behavior: every verdict comes from the blocking API call. - -The engine **never guesses**: matched `escalate`/`restrain`, no matching rule, unknown operators, malformed/YAML policies, and rules that depend on server-side state always fall back to the API. `request-grant: true` always uses the blocking path (the signed grant must exist before your command's environment is built). - -### 3. 🧾 Proof you can hand an auditor - -Every verdict produces a signed, public-verifiable [Decision Dossier](https://decionis.com/dossier-example?source=github_action): **why** it happened, **who** approved it, **which policy** applied, and the **risk**. The verify link unfurls as an OG card in Slack / Teams / LinkedIn β€” paste it in an incident, a change ticket, or an audit and it holds up. - ---- - -## Your policy β€” what the gate evaluates against - -Every gated action is evaluated against **your organization's policy**: the rules that decide whether an action is **allowed, blocked, restrained, or escalated**. You own those rules. - -- **Zero config to start.** Out of the box, Decionis applies a built-in **default policy pack** for your workflow's vertical (core, finance, hospitality, and more), so the 30-second quickstart governs immediately β€” no policy authoring required. -- **Make it yours, and it's versioned.** Add or update rules at any time; Decionis **versions every change**, and each verdict's Decision Dossier records exactly **which policy version applied** β€” so an audit can trace any decision back to the rule that made it. -- **Bring policy from where it already lives.** Build rules dynamically, **upload or paste** a policy file, or **connect the source of truth** and Decionis keeps the encoded policy in sync β€” **Google Drive, GitHub, Jira, Confluence, Notion, or SAP.** - -The result: the gate isn't a generic check β€” it enforces _your_ rules, kept current with how your organization actually documents them. - -### Policy as a file: `DECIONIS_POLICY.md` - -Keep policy where developers already work β€” in the repo, in Markdown, reviewed by PR. Drop a **`DECIONIS_POLICY.md`** at your repo root and the action reads it, **content-hashes it, and injects it into every decision** β€” so the gate governs against your repo's policy and the signed Decision Dossier records exactly which policy (by `sha256`) applied. Change the file, get a new recorded revision. No dashboard step. - -```yaml -- uses: decionis/govern@v1 - with: - workflow-key: github_deploy_approval + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} action: production-deploy - # policy-file: DECIONIS_POLICY.md # default; set "" to disable - run: ./deploy.sh -``` - -Outputs `policy-sha256` + `policy-path`. A missing/unreadable file never fails the gate. **Two different things get called "enforcement":** the committed rules block is evaluated by the local engine on every run (`local-eval: auto`, on by default) and that verdict gates this step, while publishing those rules as the org's **active policy bundle** for this `workflow-key` is a separate opt-in via `policy-enforce: true`. At the default, your file is recorded and version-pinned on the dossier but does not change server-side enforcement. See the annotated [example policy](./examples/DECIONIS_POLICY.md) to copy β€” or the [DevOps/CI example](./examples/DECIONIS_POLICY.devops.md) (escalate infra destroys, restrain applies + release deploys, block during change freeze). For an **org-wide** policy, point a Git source connector at your `.github` repo's `DECIONIS_POLICY.md`. - ---- - -## Govern AI-generated changes - -When an AI agent opens a PR or triggers a deploy, gate it **before** it merges or ships: - -```yaml -- uses: decionis/govern@v1 - id: gate - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: ai_change_gate - action: ai-generated-pr - comment-pr: "true" - payload: | - { "author": "${{ github.actor }}", "agent_generated": true } -``` - -See [`examples/gate-ai-agent-pr.yml`](./examples/gate-ai-agent-pr.yml) for the full recipe (auto-detects agent authorship and requires a human verdict on risky changes). - -## Also governs - -- **Deployments** β€” production releases, blue/green cutovers -- **Infrastructure** β€” `terraform apply`, Pulumi, CDK -- **Data** β€” database migrations, destructive jobs -- **Privileged workflows** β€” release pipelines, secrets rotation, IAM changes - -## What reviewers see on the PR - -A single, **self-updating** comment β€” it stays current on every run: - -![Example Decionis PR comment β€” Blocked verdict with a signed verify link](./assets/pr-comment.svg) - -## πŸ“Œ Add the badge - -Show your pipeline is governed β€” and let other devs discover the gate. Also emitted as the `badge-markdown` output, pointing at the live verify URL **pinned to your policy revision** (`…&policy=sha256:`), so the badge cryptographically binds the decision to the exact committed policy state: - -```markdown -[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/govern) -``` - -[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/govern) - ---- - -## Recipes - -Copy-paste workflows in [`examples/`](./examples/): - -| Recipe | What it gates | -| ------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| [`gate-ai-agent-pr.yml`](./examples/gate-ai-agent-pr.yml) | AI-generated PRs (Claude Code, Copilot, Cursor…) before merge. | -| [`gate-deploy.yml`](./examples/gate-deploy.yml) | A production deploy on a `block` verdict (enforce). | -| [`gate-terraform.yml`](./examples/gate-terraform.yml) | `terraform apply` on the plan's blast radius. | -| [`gate-release.yml`](./examples/gate-release.yml) | A verdict before a tagged release ships. | -| [`auto-merge-dependabot.yml`](./examples/auto-merge-dependabot.yml) | Auto-merge a dependency PR only when the verdict is `allow`. | -| [`gate-pr-comment.yml`](./examples/gate-pr-comment.yml) | Shadow-mode evaluator that comments without failing the build. | -| [`gate-deploy-broker.yml`](./examples/gate-deploy-broker.yml) | Release deploy credentials only for an authorized run β€” no standing secrets. | -| [`gate-deploy-gcp-wif.yml`](./examples/gate-deploy-gcp-wif.yml) | GCP Workload Identity Federation β€” the cloud issues credentials only on `allow`. | -| [`gate-deploy-azure.yml`](./examples/gate-deploy-azure.yml) | Azure federated credentials β€” the cloud issues credentials only on `allow`. | - -## Inputs - -| Input | Required | Default | Description | -| -------------------- | -------- | ----------------------------- | ------------------------------------------------------------------ | -| `api-key` | yes | β€” | Decionis API key with `protocol:evaluate` scope. Pass as a secret. | -| `org-id` | yes | β€” | Decionis org id (UUID). | -| `workflow-key` | yes | β€” | Workflow key registered in Decionis policy. | -| `action` | no | β€” | Short label for what's being gated (e.g. `production-deploy`). | -| `run` | no | β€” | Command Decionis runs **only if authorized** (the enforcing path). | -| `shell` | no | `bash` | Shell for `run` β€” `bash` or `sh`. | -| `request-grant` | no | `false` | Issue a signed Execution Grant on an authorizing verdict. | -| `grant-audience` | no | β€” | Bind the grant to a target/env id (e.g. `prod-us-east`). | -| `payload` | no | _built from workflow context_ | JSON object describing the action being gated. | -| `fail-on` | no | `block` | Which verdicts fail the step β€” `block` / `escalate` / `block_or_escalate` / `never`. **Advisory path only:** when `run` is set, any non-authorizing verdict blocks the command and fails the step regardless of this value. | -| `mode` | no | `enforce` | `enforce` or `shadow`. Shadow never fails the step and starts `run` commands immediately (speculative). | -| `local-eval` | no | `auto` | Local policy engine: `auto` (act locally, notarize async), `strict` (offline), `off` (v1.8 blocking API). | -| `comment-pr` | no | `false` | Post (and update in place) the verdict as a PR comment. | -| `show-attribution` | no | `true` | Include the "Governed by Decionis" footer on the PR comment. | -| `api-base-url` | no | `https://api.decionis.com` | Override for staging / self-host. | -| `site-base-url` | no | `https://decionis.com` | Override for staging / self-host. | -| `request-timeout-ms` | no | `20000` | Timeout for the evaluate-decision call. | -| `policy-file` | no | `DECIONIS_POLICY.md` | Repo-relative policy file injected into the decision and content-hashed. Set to `""` to disable. | -| `policy-enforce` | no | `false` | Compile the file's ` ```decionis ` rules block into the **active enforced** bundle for this org + workflow-key. Left `false`, the file is recorded and versioned but does not auto-enforce. | - -## Outputs - -| Output | Description | -| ------------------ | ----------------------------------------------------------------------------- | -| `decision` | `allow` / `block` / `escalate` / `restrain` / `review` (API outcomes are normalized, e.g. `APPROVE`β†’`allow`). | -| `decision-source` | `local` (deterministic committed-rule verdict) or `api`. | -| `verdict-mismatch` | `true` when a local verdict acted but the notarizing API verdict differed. | -| `dossier-id` | Signed Decision Dossier id for this evaluation. | -| `verify-url` | Public verify URL (`?sig=` for OG unfurls, `&policy=sha256:…` pinning the policy revision). | -| `policy-version` | Policy version (string) that produced the verdict. | -| `policy-sha256` | sha256 of the injected policy file β€” the content-addressed version handle. | -| `policy-path` | Repo-relative path of the injected policy file, if one was found. | -| `policy-enforced` | `true` when `policy-enforce` was set and a policy file was found. | -| `reason-code` | Stable reason code (string), if returned. | -| `badge-markdown` | Ready-to-paste "Governed by Decionis" badge linking to the live verify URL. | -| `executed` | `true` if a `run` command was authorized and executed, `false` if blocked. | -| `execution-grant` | Signed Execution Grant (EdDSA JWT) when `request-grant: true` and authorized. | -| `grant-expires-at` | ISO timestamp when the Execution Grant expires. | - -## Permissions - -Default (`contents: read`) is enough. To enable `comment-pr: 'true'`: - -```yaml -permissions: - contents: read - pull-requests: write -``` - -## How it works - -1. The repo's `DECIONIS_POLICY.md` is read, sha256-hashed, and its ```` ```decionis ```` rules block is evaluated **in-process** (microseconds) by a local engine that mirrors the platform evaluator. -2. A deterministic local `allow`/`block` acts immediately; `POST /v1/protocol/evaluate-decision` runs as an async notarization that mints the signed dossier and cross-checks the verdict. Everything the engine can't decide falls back to the blocking API call, exactly like v1.8. -3. In `mode: shadow` with a `run` command, the command starts **first** and the entire evaluation runs in the background; after the command exits, the step waits a bounded grace window (≀10s) for the verdict, then finishes with the command's exit code. -4. Inputs are echoed into the dossier so you can audit exactly what produced it. In `enforce` mode a non-200 from the API (with no local verdict) fails the step with the status β€” no silent green builds. - -Every run logs a timing line so the speedups are visible, e.g. `Decionis timing β€” command started +3ms Β· API verdict 'allow' in 1840ms +1846ms Β· command exited code 0 +61.2s`. - -### Changed in v1.9.0 - -- **Shadow mode never fails the step, period.** Previously an API error failed the step even in shadow; now all gate/evaluation failures in shadow are `::notice::` lines and the exit code is your command's (or 0). Shadow also no longer requires credentials β€” unconfigured gates are inert. -- **`local-eval: auto` is on by default.** Repos with a ```` ```decionis ```` rules block get local verdicts + async notarization; repos without one see no change in decision flow. Set `local-eval: off` for strict v1.8 behavior. -- **API outcomes are normalized** (`APPROVE`β†’`allow`, `REJECT`β†’`block`, `REQUIRE_REVIEW`/`REVIEW`β†’`review`) so `run` gating and `fail-on` work against the current Decionis API vocabulary. - ---- - -Built by [Decionis](https://decionis.com?source=github_action_readme) Β· [Quickstart](https://decionis.com/quickstart?source=github_action) Β· [Dossier example](https://decionis.com/dossier-example?source=github_action) Β· Apache-2.0 licensed + environment: production + run: ./scripts/deploy.sh # runs only on an ALLOW whose grant this step claimed +``` + +The step becomes an execution intent, Decionis decides on exactly that intent, the command runs +only on an `ALLOW` whose single-use grant the step claimed first, and what happened is finalized +into a signed Decision Dossier. `BLOCK` ends the step with the command never started; `ESCALATE` +can hold it while Decionis orchestrates a person's approval; `mode: shadow` starts the command at +once and records the verdict beside it, never failing a build. Nothing is decided locally: a +Decionis that cannot be reached is a refusal, and the command does not run. + +## Where the gate lives + +This repository is the Marketplace address. The gate itself β€” one Go binary for GitHub Actions, +GitLab CI, Jenkins and any other runner, speaking the Decionis execution contract the +[AgentSafe runtime](https://github.com/decionis/agent-safe-pipeline) speaks β€” lives in +[`decionis/agent-safe-pipeline` under `govern/`](https://github.com/decionis/agent-safe-pipeline/tree/master/govern), +with its tests, its releases and its page, [docs/govern.md](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/govern.md). +[`action.yml`](./action.yml) here carries the listing's metadata and delegates every input and +output to that action at a pinned commit, which downloads the release binary its commit names and +verifies it before running. + +- Every input, output, exit code and runner surface: [the gate's README](https://github.com/decionis/agent-safe-pipeline/blob/master/govern/README.md). +- The recipes in [`examples/`](./examples) are copies of the gate's: a deploy, `terraform apply` + on the plan's blast radius, a release held for the release manager, shadow comments on every + pull request, agent-authored pull requests, Dependabot auto-merge. +- Starting a repository: install the binary and run `govern init`, which writes a shadow-mode + workflow and a `DECIONIS_POLICY.md` and touches nothing else. + +## v1 + +`uses: decionis/govern@v1` is the earlier node20 action, which spoke the evaluate-decision API +with `org-id` and `workflow-key` inputs and evaluated a rules block locally. It stays where its +tags point ([v1.9.3](https://github.com/decionis/govern/tree/v1.9.3)) and is not developed +further; v2 speaks the execution contract, has no local policy engine, and takes `tenant-id` in +place of `org-id` and `workflow-key`. Keys and workspaces are the same. + +Built by [Decionis](https://decionis.com?source=govern_readme) Β· Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md index d62da1e..037c485 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -Decionis takes the security of the **Decionis Action Gate** (`decionis/govern`) and the platform it talks to seriously. +Decionis takes the security of **Decionis Govern** (`decionis/govern`), the gate it delegates to, and the platform it talks to seriously. ## Reporting a vulnerability @@ -13,17 +13,18 @@ We acknowledge reports within **2 business days** and aim to provide a remediati ## Scope -- The Action source (`src/`, `action.yml`) in this repository. -- The Decionis APIs the Action calls (`api.decionis.com`), including the evaluate-decision, execution-grant, and OIDC endpoints. +- `action.yml` in this repository, which delegates to the gate at a pinned commit. +- The gate itself, `govern/` in [decionis/agent-safe-pipeline](https://github.com/decionis/agent-safe-pipeline), whose [security policy](https://github.com/decionis/agent-safe-pipeline/security/policy) covers it: report there for the binary, the installer, the release archives and the contract client. +- The Decionis APIs the gate calls (`api.decionis.com`): enforce-and-bind, the escalation status, claim-token and finalize-token. -## What the Action handles +## What the action handles -- Your `DECIONIS_API_KEY` is read only from the workflow input you provide and is sent solely to the configured `api-base-url` over HTTPS. It is never logged. -- Execution Grants are short-lived, single-use, and signed; they are verifiable against the published JWKS. -- The Action runs with zero third-party dependencies (Node 20 built-ins only), minimizing supply-chain surface. +- Your `DECIONIS_API_KEY` reaches the gate as an input and is sent solely to the configured `api-url` over HTTPS, as a bearer token. It is never logged and never given to the gated command. +- The execution grant a decision carries is claimed by the gate immediately before the command and never leaves it; the command receives the decision's identifiers and the authority's claim attestation. +- The pinned action downloads the release binary its commit names and verifies its SHA-256 before running it, or builds the same bytes from the pinned commit. ## More -- Security overview: https://decionis.com/security -- Privacy: https://decionis.com/privacy -- Terms: https://decionis.com/terms +- Security overview: +- Privacy: +- Terms: diff --git a/SUPPORT.md b/SUPPORT.md index 8471195..3d9f34c 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,11 +1,11 @@ # Support -Help with the **Decionis Action Gate** (`decionis/govern`). +Help with **Decionis Govern** (`decionis/govern`), the workflow gate. ## Get started - **Quickstart + free API keys:** https://decionis.com/quickstart?source=github_action -- **Action docs:** the [README](./README.md) and copy-paste workflows in [`examples/`](./examples/) +- **Action docs:** the [README](./README.md), copy-paste workflows in [`examples/`](./examples/), and [the gate's README](https://github.com/decionis/agent-safe-pipeline/blob/master/govern/README.md) - **GitHub Action page:** https://decionis.com/marketplace/github ## Get help @@ -13,7 +13,7 @@ Help with the **Decionis Action Gate** (`decionis/govern`). | Need | Where | | ----------------------------------- | ---------------------------------------------------------- | | Setup, policy, or verdict questions | **support@decionis.com** | -| A bug in the Action | [Open an issue](https://github.com/decionis/govern/issues) | +| A bug in the gate | [Open an issue](https://github.com/decionis/agent-safe-pipeline/issues) | | A suspected security issue | See [SECURITY.md](./SECURITY.md) β€” report privately | | API / platform status | https://decionis.com | diff --git a/action.yml b/action.yml index ca5cd6a..c5d916d 100644 --- a/action.yml +++ b/action.yml @@ -1,204 +1,225 @@ +# The name is the Marketplace listing's, kept so its address stays +# github.com/marketplace/actions/decionis-action-gate. name: "Decionis Action Gate" author: "Decionis" -description: "Runtime guardrail for autonomous AI agents & CI/CD β€” gate deploys, migrations & infra before they run, with signed proof." +description: "Govern: one Decionis verdict before a workflow step runs β€” a deploy, a migration, an infrastructure change, an agent's action β€” with a signed Decision Dossier of it." branding: icon: "shield" color: "purple" +# This is the Marketplace address of Govern. The gate itself lives in +# decionis/agent-safe-pipeline under govern/, the reference implementation of +# the Decionis execution contract; this action carries the listing's metadata +# and delegates every input and output to that action at a pinned commit. +# The pinned action downloads the release binary its commit names and +# verifies it before running, or builds the same bytes from the commit when +# no archive exists yet. To move to a newer gate, move the pin. + inputs: api-key: description: >- - Decionis API key with `protocol:evaluate` scope. Pass as a workflow secret - (`secrets.DECIONIS_API_KEY`). Never hard-code. - required: true - org-id: - description: "Decionis org id (UUID) the decision belongs to." - required: true - workflow-key: - description: >- - The workflow key registered in Decionis policy (e.g. - `github_deploy_approval`). Drives which policy bundle is evaluated. - required: true + Decionis API key for the workspace. Pass a secret (`secrets.DECIONIS_API_KEY`); never write it in the workflow β€” GitHub evaluates an expression even inside this description. + required: false + default: "" + tenant-id: + description: >- + The workspace (organization) the key belongs to, a UUID. Not a secret, but a repository variable keeps it in one place. + required: false + default: "" + mode: + description: >- + `enforce` (default): the command runs only on an ALLOW whose grant this step claimed. `shadow`: the command starts at once, the verdict is recorded beside it, and the step never fails for the gate's sake. + required: false + default: "enforce" action: description: >- - Short label for the action being gated (e.g. `production-deploy`, - `terraform-apply`, `ai-generated-pr`). Recorded on the dossier and shown - in the verdict. Optional β€” purely for readability and reporting. + The action's type as the record names it, e.g. `production-deploy`, `terraform-apply`, `database-migration` (lowercase, `[a-z][a-z0-9._:-]*`). + required: false + default: "workflow.step" + resource: + description: >- + What the action acts on, e.g. `service:api` or `env:production`. Default: the command. + required: false + default: "" + payload: + description: >- + A JSON object describing the action: the parameters the record keeps and policy reads (`{"service":"api","replicas":3}`). + required: false + default: "" + environment: + description: >- + The deployment environment the action lands in, e.g. `production`. required: false default: "" run: description: >- - Shell command(s) that Decionis executes ONLY if it authorizes the action. - The gated command runs through Decionis, so it executes on `allow` and is - held on block/escalate (the step then fails). In shadow mode the command - always runs while Decionis records the verdict. + The command to gate. It runs through this step, so it cannot run without an authorizing verdict; there is no `if:` to delete. required: false default: "" shell: - description: "Shell used to run the `run` command. `bash` (default) or `sh`." + description: >- + `bash` (default) or `sh`. required: false default: "bash" - request-grant: + fail-on: description: >- - When `true` and the verdict authorizes the action, request a short-lived, - single-use signed Execution Grant. It is exposed as the `execution-grant` - output and injected into the `run` command's environment as - `DECIONIS_EXECUTION_GRANT` so the deploy target can verify the - authorization before acting. + For a step without `run`: which verdicts fail it. `block` (default), `escalate`, `block_or_escalate`, `never`. required: false - default: "false" - grant-audience: + default: "block" + escalation: description: >- - Optional audience to bind the Execution Grant to (e.g. a target/env id - like `prod-us-east`). The target verifies the grant was minted for it. + `managed`: on an ESCALATE, Decionis orchestrates the approval and the step holds for it, at most until the intent expires (five minutes). `none` (default): an ESCALATE ends the step like `fail-on` says. required: false - default: "" - payload: + default: "none" + approver: description: >- - JSON string describing the action being gated. Free-form fields per the - workflow's policy schema (resource, actor, requested change, etc.). When - omitted, a minimal payload is built from the workflow context - (repo / ref / sha / event / actor / run id). + The principal who approves a managed escalation. required: false default: "" - fail-on: + approver-role: description: >- - Which verdicts cause this Action to fail the step. One of: - `block` (default), `escalate`, `block_or_escalate`, `never`. + Or the role that does, e.g. `RELEASE_MANAGER`. required: false - default: "block" - mode: + default: "" + policy-file: description: >- - `enforce` (default) or `shadow`. In shadow mode the Action records a - verdict but never fails the step β€” useful for week-one rollouts. Shadow - is also speculative: a `run` command starts immediately while the verdict - resolves in the background, so the gate adds ~zero latency; evaluation - failures (API errors, missing credentials) become notices, never step - failures, and the step's exit code is exactly the command's. + The repository's policy file, whose path and SHA-256 travel in the intent so the dossier records the exact revision. Default `DECIONIS_POLICY.md` (`.yaml`/`.yml` siblings are tried); `""` disables. required: false - default: "enforce" - comment-pr: + default: "DECIONIS_POLICY.md" + comment: description: >- - When `true` and this run was triggered by a pull_request event, post (and - update in place) the verdict as a PR comment with the dossier verify URL. - Requires `pull-requests: write` permission on the workflow. + `true`: post (and update in place) the verdict on the pull request. Needs `pull-requests: write`. required: false default: "false" show-attribution: description: >- - When `true` (default), the PR comment includes a small "Governed by - Decionis" footer linking back to the action. Set `false` for a bare - comment with no attribution. + `false` drops the footer from the comment. required: false default: "true" - api-base-url: - description: "Override the Decionis API base URL (for staging / self-host)." + api-url: + description: >- + The Decionis API base URL (https), for self-hosting. required: false default: "https://api.decionis.com" - site-base-url: - description: "Override the public site base URL (for staging / self-host)." - required: false - default: "https://decionis.com" - request-timeout-ms: + timeout: description: >- - Timeout for the evaluate-decision call, in milliseconds. Also budgets the - post-command grace window for background notarization (capped at 10s). + Per authority call, e.g. `20s`. required: false - default: "20000" - policy-file: + default: "20s" + intent-ttl: description: >- - Path (relative to the repo root) to a `DECIONIS_POLICY.md` policy file to - inject into the decision. The file is read, content-hashed, and carried - with the evaluation so the gate governs against β€” and the Decision Dossier - records β€” your repo's policy. The sha256 is the version handle: change the - file, get a new recorded revision. Set to an empty string to disable. - A missing or unreadable file never fails the gate. A YAML policy file is - also accepted β€” with the default path, `DECIONIS_POLICY.yaml` / `.yml` - siblings are tried too. + How long the intent stays decidable; at most `5m`. required: false - default: "DECIONIS_POLICY.md" - policy-enforce: + default: "5m" + report: description: >- - When `true`, the structured ```decionis``` rules block in the policy file - is compiled into an ACTIVE enforced policy bundle for this org + - workflow-key (true GitOps β€” committing the file changes enforcement, no - dashboard). Idempotent by content hash. Default `false` (the file is - recorded/versioned but not auto-enforcing). Prose without a rules block is - never enforced. + Where to write the JSON record of the run; empty for none. required: false - default: "false" - local-eval: - description: >- - Local policy engine mode. `auto` (default): the ```decionis``` rules - block in your policy file is evaluated in-process (microseconds); a - deterministic allow/block from an explicitly matched rule acts - immediately and the API call becomes an async notarization off the - critical path β€” the signed dossier, verify URL, and badge still arrive, - and a disagreeing API verdict sets `verdict-mismatch=true` with a - warning. `strict`: deterministic local verdicts skip the network - entirely (offline-capable; no dossier for those runs). `off`: v1.8 - behavior β€” every verdict comes from the blocking API call. - Escalate/restrain matches, no-match, unknown operators, malformed or - YAML policies, and rules that depend on server-side state ALWAYS fall - back to the API. `request-grant: true` always forces the blocking path. - required: false - default: "auto" + default: "" outputs: decision: description: >- - Verdict β€” one of: allow, block, escalate, restrain, review (the API's - REVIEW outcome). API outcomes are normalized (APPROVEβ†’allow, - REJECTβ†’block, REQUIRE_REVIEWβ†’review). - decision-source: - description: >- - Where the acting verdict came from β€” `local` (deterministic rule from - the repo's policy file, evaluated in-process) or `api`. - verdict-mismatch: + `ALLOW`, `ESCALATE` or `BLOCK`; empty when the authority was not asked. + value: ${{ steps.gate.outputs.decision }} + decision-id: description: >- - "true" when a deterministic local verdict acted but the notarizing API - call returned a different verdict (org-level policy may add rules). - Emitted with a `::warning::` β€” the signed dossier records the API - verdict. + The authority's decision id. + value: ${{ steps.gate.outputs.decision-id }} dossier-id: - description: "Signed Decision Dossier id for this evaluation." + description: >- + The signed Decision Dossier's id. + value: ${{ steps.gate.outputs.dossier-id }} + dossier-url: + description: >- + The authenticated path of the dossier. + value: ${{ steps.gate.outputs.dossier-url }} verify-url: description: >- - Public verify URL with `?sig=` β€” opens the branded verification page; - unfurls with the OG card in Slack / Teams / LinkedIn / X. + A page anyone can open to verify the decision, when the authority attached one. + value: ${{ steps.gate.outputs.verify-url }} + intent-id: + description: >- + The intent's id (the grant issuance boundary). + value: ${{ steps.gate.outputs.intent-id }} + intent-hash: + description: >- + The canonical intent's SHA-256, what every record names. + value: ${{ steps.gate.outputs.intent-hash }} + reason-codes: + description: >- + The authority's reason codes, comma separated. + value: ${{ steps.gate.outputs.reason-codes }} policy-version: - description: "Policy version (string) that produced the verdict." + description: >- + The policy version the decision was made under. + value: ${{ steps.gate.outputs.policy-version }} policy-sha256: description: >- - sha256 of the injected `DECIONIS_POLICY.md` content (empty if no policy - file was found). The content-addressed version handle for the repo policy. + The SHA-256 of the repository policy file the intent carried. + value: ${{ steps.gate.outputs.policy-sha256 }} policy-path: - description: "Repo-relative path of the injected policy file, if any." - policy-enforced: description: >- - "true" when `policy-enforce` was set and a policy file was found (the - file's rules block is published as the active enforced bundle). - reason-code: - description: "Stable reason code (string) for the verdict, if returned." - badge-markdown: + Its path. + value: ${{ steps.gate.outputs.policy-path }} + mode: description: >- - Ready-to-paste Markdown for a "Governed by Decionis" badge linking to the - signed verify URL β€” drop it in a README or release notes. When a policy - file is present the link is pinned to its sha256 (`&policy=sha256:…`), so - the badge cryptographically binds the decision to the repo policy state. + `SHADOW` or `ENFORCEMENT`. + value: ${{ steps.gate.outputs.mode }} + fail-closed: + description: >- + `true` when no authoritative decision was reached and the gate refused. + value: ${{ steps.gate.outputs.fail-closed }} executed: description: >- - `true` if a `run` command was authorized and executed, `false` if it was - blocked. Empty when no `run` command was supplied. - execution-grant: + `true` when the command ran, `false` when it did not; empty without a command. + value: ${{ steps.gate.outputs.executed }} + claimed: + description: >- + `true` when a grant was claimed for the run. + value: ${{ steps.gate.outputs.claimed }} + exit-code: + description: >- + The command's exit code. + value: ${{ steps.gate.outputs.exit-code }} + outcome: + description: >- + `COMMITTED`, `FAILED` or `INDETERMINATE`, as finalized with the authority. + value: ${{ steps.gate.outputs.outcome }} + finalization: + description: >- + `RECORDED` when the authority recorded the outcome, `PENDING` when it did not. + value: ${{ steps.gate.outputs.finalization }} + badge-markdown: description: >- - The short-lived, single-use signed Execution Grant (EdDSA JWT), when - `request-grant: true` and the verdict authorizes. A target verifies it - against the public JWKS before acting. Empty otherwise. - grant-expires-at: - description: "ISO timestamp when the Execution Grant expires." + A β€œGoverned by Decionis” badge, linking to the proof. + value: ${{ steps.gate.outputs.badge-markdown }} runs: - using: "node20" - main: "src/index.mjs" + using: "composite" + steps: + - name: Govern the step + id: gate + uses: decionis/agent-safe-pipeline/govern@a29fb021cf3fd8dd031aa66f4f841bfa0ecb6c71 # master, 2026-09-21 (#245) + with: + api-key: ${{ inputs.api-key }} + tenant-id: ${{ inputs.tenant-id }} + mode: ${{ inputs.mode }} + action: ${{ inputs.action }} + resource: ${{ inputs.resource }} + payload: ${{ inputs.payload }} + environment: ${{ inputs.environment }} + run: ${{ inputs.run }} + shell: ${{ inputs.shell }} + fail-on: ${{ inputs.fail-on }} + escalation: ${{ inputs.escalation }} + approver: ${{ inputs.approver }} + approver-role: ${{ inputs.approver-role }} + policy-file: ${{ inputs.policy-file }} + comment: ${{ inputs.comment }} + show-attribution: ${{ inputs.show-attribution }} + api-url: ${{ inputs.api-url }} + timeout: ${{ inputs.timeout }} + intent-ttl: ${{ inputs.intent-ttl }} + report: ${{ inputs.report }} diff --git a/assets/pr-comment.svg b/assets/pr-comment.svg deleted file mode 100644 index 9bca8af..0000000 --- a/assets/pr-comment.svg +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - πŸ›‘οΈ - github-actions bot commented Β· now - Β·Β·Β· - - - - πŸ›‘οΈ - DECIONIS Β· BLOCKED - - - πŸ›‘ Governed step β€” Blocked - - - - - - - - Verdict - block - - Policy - github_deploy_approval@v4 - - Reason - change_window_closed - - - πŸ”Ž Verify this decision β†’ - signed, tamper-evident proof. - - - - πŸ›‘οΈ Governed by Decionis Β· gate your own deploys with decionis/govern - - diff --git a/examples/DECIONIS_POLICY.devops.md b/examples/DECIONIS_POLICY.devops.md index 3948535..5be812d 100644 --- a/examples/DECIONIS_POLICY.devops.md +++ b/examples/DECIONIS_POLICY.devops.md @@ -1,131 +1,47 @@ -# Decionis Policy β€” DevOps & CI (example) +# Decionis Policy β€” DevOps and CI ## Scope -Applies to infrastructure changes (`terraform apply` / `destroy`, Pulumi, CDK), -production and on-chain / release deploys, and any AI-authored change that -touches those paths. +Deploys, releases, infrastructure changes and data migrations run from +GitHub Actions, GitLab CI or Jenkins in this repository. ## Rules +### Releases and production deploys + +- **Escalate** every production deploy and every tagged release to the release + manager; the pipeline holds while Decionis orchestrates the approval. +- **Block** deploys during a declared change freeze. +- **Allow** deploys to non-production environments from the default branch. + ### Infrastructure -- **Escalate** infrastructure **destroys** (`terraform destroy`, force-replace, - resource deletion) β€” these must never run unattended; require an authorized - operator. -- **Restrain** (require human review) production infrastructure **applies**. -- **Escalate** any change that touches **IAM, networking, or secrets**. +- **Escalate** an infrastructure change that destroys resources or touches IAM, + networking or secrets. +- **Allow** changes that only add or update resources within the plan's stated + blast radius. -### Deploys & releases +### Data -- **Restrain** on-chain / release deploys β€” review the release authority and - build inputs before anything ships. -- **Block** production deploys during a **change freeze**. +- **Escalate** a migration that drops a column or a table, or rewrites more + than a stated share of a table. +- **Allow** additive, reversible migrations. -### AI-authored changes +### Agent-authored changes -- **Restrain** any change authored by an **AI agent** that touches infra, - deploy, or release paths. -- **Allow** AI-authored docs / test-only changes. +- **Escalate** any change an AI agent authored on deploy, infrastructure or + migration paths; **allow** documentation and test-only changes. ## Thresholds -- Treat any change with an estimated **blast radius over $10,000** as high-risk +- Treat a change with an estimated blast radius over **$10,000** as high-risk and escalate. -- Require review for anything that modifies **secrets** or their rotation. - -## Enforced rules - -The prose above is documentation. The block below is what **enforces** when the -action runs with `policy-enforce: true` β€” and what the action's local policy -engine evaluates in-process on every run. JSON only; each rule has exactly one -of `all` / `any`, and an `action` of allow / block / restrain / escalate. -Fields are matched against the decision `payload` β€” `decision_type`, -`workflow_key`, and your own `context.*` keys. Give every rule an explicit -`priority` (higher evaluates first) and keep `"domain": "*"` so the rule -applies to every decision this repo gates. - -```decionis -{ - "version": 1, - "rules": [ - { - "name": "Escalate infrastructure destroy", - "priority": 100, - "domain": "*", - "any": [ - { "field": "decision_type", "op": "eq", "value": "infra-destroy" }, - { "field": "workflow_key", "op": "eq", "value": "infra-destroy" }, - { "field": "context.destroys_resources", "op": "eq", "value": true } - ], - "action": "escalate" - }, - { - "name": "Escalate infra changes touching IAM / networking / secrets", - "priority": 90, - "domain": "*", - "any": [ - { "field": "context.touches_iam", "op": "eq", "value": true }, - { "field": "context.touches_network", "op": "eq", "value": true }, - { "field": "context.touches_secrets", "op": "eq", "value": true } - ], - "action": "escalate" - }, - { - "name": "Block deploys during a change freeze", - "priority": 80, - "domain": "*", - "all": [{ "field": "context.change_freeze", "op": "eq", "value": true }], - "action": "block" - }, - { - "name": "Restrain production infrastructure apply", - "priority": 70, - "domain": "*", - "any": [ - { "field": "decision_type", "op": "eq", "value": "infra-apply" }, - { "field": "workflow_key", "op": "eq", "value": "infra-apply" } - ], - "action": "restrain" - }, - { - "name": "Restrain on-chain / release deploys", - "priority": 60, - "domain": "*", - "any": [ - { "field": "decision_type", "op": "eq", "value": "release-deploy" }, - { "field": "workflow_key", "op": "eq", "value": "release" }, - { "field": "workflow_key", "op": "eq", "value": "escrow-deploy" } - ], - "action": "restrain" - }, - { - "name": "Restrain AI-authored infra / deploy changes", - "priority": 50, - "domain": "*", - "all": [{ "field": "context.agent_generated", "op": "eq", "value": true }], - "action": "restrain" - } - ] -} -``` - - +- Require review for anything that modifies secrets or their rotation. diff --git a/examples/DECIONIS_POLICY.md b/examples/DECIONIS_POLICY.md index 7d05cf6..46f95e8 100644 --- a/examples/DECIONIS_POLICY.md +++ b/examples/DECIONIS_POLICY.md @@ -1,107 +1,58 @@ # Decionis Policy ## Scope Applies to deploys, infrastructure changes, database migrations, and -AI-generated pull requests in this repository. +agent-authored pull requests in this repository. ## Rules ### Production deploys - **Block** production deploys during a change freeze. -- **Escalate** any production deploy outside business hours (require a human - approver) before it runs. +- **Escalate** any production deploy outside business hours: a named approver + decides before it runs. - **Allow** production deploys that have a green CI run and an approved PR. ### Infrastructure (`terraform apply`, Pulumi, CDK) - **Escalate** changes that touch IAM, security groups, or networking. -- **Block** destructive actions (resource deletion, force-replace) without an - explicit approval label on the PR. +- **Block** destructive actions (resource deletion, forced replacement) without + an explicit approval label on the PR. ### Database migrations - **Escalate** any migration that drops a column or table. - **Allow** additive, reversible migrations. -### AI-generated changes +### Agent-authored changes -- **Restrain** (require human review) any change authored by an AI agent that - touches deploy, infra, or migration paths. -- **Allow** AI-authored docs/test-only changes. +- **Escalate** any change authored by an AI agent that touches deploy, + infrastructure, or migration paths. +- **Allow** agent-authored documentation and test-only changes. ## Thresholds -- Treat any action with an estimated blast radius over **$10,000** as high-risk - and escalate. -- Require two approvers for anything that modifies secrets or rotation. +- Treat any action with an estimated blast radius over **$10,000** as + high-risk and escalate. +- Require two approvers for anything that modifies secrets or their rotation. - -## Enforced rules - -The prose above is documentation. The block below is what actually **enforces** -when the action runs with `policy-enforce: true` β€” it compiles 1:1 into an -active policy bundle (idempotent by content hash; change the block, it -republishes) β€” and what the action's **local policy engine** evaluates -in-process on every run. JSON only; each rule has exactly one of `all` / `any`, -and an `action` of allow / block / restrain / escalate. Give every rule an -explicit `priority` (higher evaluates first) and keep `"domain": "*"` so the -rule applies to every decision this repo gates. - -```decionis -{ - "version": 1, - "rules": [ - { - "name": "Block production deploys during a change freeze", - "priority": 100, - "domain": "*", - "all": [ - { "field": "decision_type", "op": "eq", "value": "production-deploy" }, - { "field": "context.change_freeze", "op": "eq", "value": true } - ], - "action": "block" - }, - { - "name": "Escalate IAM / security-group changes", - "priority": 90, - "domain": "*", - "any": [ - { "field": "context.touches_iam", "op": "eq", "value": true }, - { "field": "context.touches_network", "op": "eq", "value": true } - ], - "action": "escalate" - }, - { - "name": "Restrain AI-authored deploy/infra/migration changes", - "priority": 80, - "domain": "*", - "all": [ - { "field": "context.agent_generated", "op": "eq", "value": true } - ], - "action": "restrain" - } - ] -} -``` diff --git a/examples/auto-merge-dependabot.yml b/examples/auto-merge-dependabot.yml index 771e0d3..f8ebaaa 100644 --- a/examples/auto-merge-dependabot.yml +++ b/examples/auto-merge-dependabot.yml @@ -1,46 +1,40 @@ -# Only auto-merge a Dependabot PR when Decionis returns `allow`. The verdict can -# weigh the update type (patch vs major), advisories, and your own policy β€” so -# low-risk bumps merge themselves while risky ones wait for a human. - -name: Dependabot auto-merge (governed) +# A dependency update merges itself only when Decionis allows it: the update's +# kind and size are the intent's parameters, and the merge is the verdict's +# consequence rather than a bot's default. +name: Dependabot auto-merge on: pull_request: - types: [opened, synchronize, reopened] permissions: contents: write pull-requests: write jobs: - govern-and-merge: - runs-on: ubuntu-latest + govern: if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest steps: - - name: Fetch update metadata - id: meta + - name: Read the update + id: metadata uses: dependabot/fetch-metadata@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Decionis verdict + - name: Decide id: gate - uses: decionis/govern@v1 + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: dependency_auto_merge - comment-pr: "true" - fail-on: never # don't fail the PR; gate the merge step instead + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} + action: dependency-merge + resource: pr:${{ github.event.pull_request.number }} + comment: "true" payload: | { - "dependency": "${{ steps.meta.outputs.dependency-names }}", - "update_type": "${{ steps.meta.outputs.update-type }}", - "ecosystem": "${{ steps.meta.outputs.package-ecosystem }}" + "dependency": "${{ steps.metadata.outputs.dependency-names }}", + "update_type": "${{ steps.metadata.outputs.update-type }}", + "ecosystem": "${{ steps.metadata.outputs.package-ecosystem }}" } - - - name: Enable auto-merge (only if allowed) - if: steps.gate.outputs.decision == 'allow' - run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" + # The merge is the gated command: it does not run on a BLOCK. + run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/examples/gate-ai-agent-pr.yml b/examples/gate-ai-agent-pr.yml index c616d08..0be4f23 100644 --- a/examples/gate-ai-agent-pr.yml +++ b/examples/gate-ai-agent-pr.yml @@ -1,66 +1,54 @@ -# Govern AI-generated changes before they merge. -# -# When an AI coding agent (Claude Code, Copilot, Cursor, Codex, OpenHands, …) -# opens or updates a PR, evaluate it against policy + risk BEFORE it can be -# merged or trigger a deploy. The verdict is posted to the PR; on `block`/ -# `escalate` the merge gate stays closed until a human signs off. -# -# Detection here is heuristic β€” adjust to how your agents are wired (a bot -# account, a label, a branch prefix, or a commit trailer). - -name: AI change gate +# A pull request an AI agent opened is decided before it merges. The gate is +# advisory here (no wrapped command), so the verdict is an output the merge +# step reads; wrapping the merge command instead makes it enforced. +name: Govern agent-authored pull requests on: pull_request: - types: [opened, synchronize, reopened, labeled] + types: [opened, synchronize, reopened] permissions: contents: read pull-requests: write jobs: - govern-ai-change: + govern: runs-on: ubuntu-latest steps: - - name: Detect AI authorship + - uses: actions/checkout@v5 + + - name: Detect an agent author id: detect env: - ACTOR: ${{ github.actor }} - HEAD_REF: ${{ github.head_ref }} - LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + AUTHOR: ${{ github.event.pull_request.user.login }} + BODY: ${{ github.event.pull_request.body }} run: | - AI=false - case "$ACTOR" in - *copilot*|*cursor*|*claude*|*codex*|*openhands*|*[bot]) AI=true ;; - esac - case "$HEAD_REF" in ai/*|agent/*|copilot/*|cursor/*) AI=true ;; esac - case ",$LABELS," in *,ai-generated,*) AI=true ;; esac - echo "ai_generated=$AI" >> "$GITHUB_OUTPUT" + agent=false + case "$AUTHOR" in *"[bot]"*|*claude*|*copilot*|*cursor*|*codex*|*openhands*) agent=true ;; esac + case "$BODY" in *"Generated with"*|*"Co-Authored-By: Claude"*|*"Co-authored-by: Copilot"*) agent=true ;; esac + echo "ai_generated=$agent" >> "$GITHUB_OUTPUT" - - name: Decionis Action Gate + - name: Decide id: gate - uses: decionis/govern@v1 + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: ai_change_gate - action: ai-generated-pr - comment-pr: "true" - # Don't fail the check itself β€” gate the merge step below on the verdict. + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} + action: agent-pull-request + resource: pr:${{ github.event.pull_request.number }} fail-on: never + comment: "true" payload: | { - "author": "${{ github.actor }}", + "author": "${{ github.event.pull_request.user.login }}", "agent_generated": ${{ steps.detect.outputs.ai_generated }}, "changed_files": ${{ github.event.pull_request.changed_files }}, "additions": ${{ github.event.pull_request.additions }}, "deletions": ${{ github.event.pull_request.deletions }} } - # Only allow auto-merge when Decionis explicitly allowed the change. - # Anything block/escalate waits for a human β€” the PR comment shows why. - - name: Mark ready (allow only) - if: steps.gate.outputs.decision == 'allow' && steps.detect.outputs.ai_generated == 'true' - run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" + - name: Merge when allowed + if: steps.gate.outputs.decision == 'ALLOW' && steps.detect.outputs.ai_generated == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" diff --git a/examples/gate-deploy-azure.yml b/examples/gate-deploy-azure.yml deleted file mode 100644 index cce02f7..0000000 --- a/examples/gate-deploy-azure.yml +++ /dev/null @@ -1,66 +0,0 @@ -# Azure federated credentials β€” the cloud issues credentials only for a -# Decionis-authorized deploy. The deploy presents a Decionis-issued OIDC token; -# Azure's federated credential matches it on issuer + subject + audience, and -# Decionis issues the token only on decision=allow. -# -# One-time Azure setup (federated credential on a user-assigned managed identity -# or app registration) β€” see -# docs/architecture/execution-authority-tier3b-oidc-federation.md Β§4: -# az identity federated-credential create \ -# --name decionis-prod --identity-name deploy-mi --resource-group rg \ -# --issuer "https://api.decionis.com/v1/oidc/execution-grants" \ -# --subject "octo/repo@" \ -# --audiences "api://AzureADTokenExchange" - -name: Deploy to Azure (governed) - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - govern-and-deploy: - runs-on: ubuntu-latest - permissions: - contents: read - env: - API: https://api.decionis.com - AZURE_AUDIENCE: api://AzureADTokenExchange - steps: - - uses: actions/checkout@v4 - - - name: Decionis verdict + grant - id: gate - uses: decionis/govern@v1 - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - request-grant: true - grant-audience: prod-azure - - - name: Exchange grant for an Azure OIDC token, then login + deploy - if: steps.gate.outputs.decision == 'allow' - env: - DECIONIS_API_KEY: ${{ secrets.DECIONIS_API_KEY }} - GRANT: ${{ steps.gate.outputs.execution-grant }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - run: | - set -euo pipefail - # Request the Decionis OIDC token (single-use, issued only on an authorized run). - ID_TOKEN=$(curl -fsS -X POST "$API/v1/protocol/execution-grants/exchange-oidc" \ - -H "Authorization: Bearer $DECIONIS_API_KEY" \ - -H 'content-type: application/json' \ - -d "{\"execution_grant\":\"$GRANT\",\"cloud_audience\":\"$AZURE_AUDIENCE\"}" \ - | jq -er '.id_token') - - # Exchange it for Azure credentials via federated identity. - az login --service-principal \ - --username "$AZURE_CLIENT_ID" \ - --tenant "$AZURE_TENANT_ID" \ - --federated-token "$ID_TOKEN" - - ./scripts/deploy.sh diff --git a/examples/gate-deploy-broker.yml b/examples/gate-deploy-broker.yml deleted file mode 100644 index 04a5dae..0000000 --- a/examples/gate-deploy-broker.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Brokered credentials β€” keep deploy secrets out of CI entirely. Decionis holds -# the registry token; the deploy step receives it only in exchange for an -# authorized run, so an unauthorized deploy has nothing to deploy with. -# -# One-time setup (org admin), registering the target's secret with Decionis: -# curl -X POST "$API/v1/protocol/targets/credentials" \ -# -H "Authorization: Bearer $DECIONIS_API_KEY" -H 'content-type: application/json' \ -# -d '{"org_id":"","audience":"prod-us-east","kind":"token","secret":""}' - -name: Deploy (brokered credentials) - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - govern-and-deploy: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - - name: Decionis verdict + grant - id: gate - uses: decionis/govern@v1 - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - request-grant: true - grant-audience: prod-us-east - - - name: Redeem grant for credentials, then deploy - if: steps.gate.outputs.decision == 'allow' - env: - DECIONIS_API_KEY: ${{ secrets.DECIONIS_API_KEY }} - GRANT: ${{ steps.gate.outputs.execution-grant }} - API: https://api.decionis.com - run: | - set -euo pipefail - # Exchange the authorized run for the registry token. - - CRED=$(curl -fsS -X POST "$API/v1/protocol/execution-grants/redeem" \ - -H "Authorization: Bearer $DECIONIS_API_KEY" \ - -H 'content-type: application/json' \ - -d "{\"execution_grant\":\"$GRANT\",\"audience\":\"prod-us-east\"}" \ - | jq -er '.credential') - ./scripts/deploy.sh --registry-token "$CRED" diff --git a/examples/gate-deploy-gcp-wif.yml b/examples/gate-deploy-gcp-wif.yml deleted file mode 100644 index d3162c0..0000000 --- a/examples/gate-deploy-gcp-wif.yml +++ /dev/null @@ -1,68 +0,0 @@ -# GCP Workload Identity Federation β€” the cloud issues credentials only for a -# Decionis-authorized deploy. The deploy presents a Decionis-issued OIDC token -# carrying decision=allow, and GCP's own STS requires it via the provider's -# attribute-condition (assertion.decision == 'allow'). -# -# One-time GCP setup: see -# docs/architecture/execution-authority-tier3b-oidc-federation.md Β§3. - -name: Deploy to GCP (governed) - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - govern-and-deploy: - runs-on: ubuntu-latest - permissions: - contents: read - env: - API: https://api.decionis.com - AUD: //iam.googleapis.com/projects/PROJECT_NUM/locations/global/workloadIdentityPools/decionis/providers/gh - steps: - - uses: actions/checkout@v4 - - - name: Decionis verdict + grant - id: gate - uses: decionis/govern@v1 - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - request-grant: true - grant-audience: prod-gcp - - - name: Exchange grant for a GCP OIDC token - if: steps.gate.outputs.decision == 'allow' - id: oidc - env: - DECIONIS_API_KEY: ${{ secrets.DECIONIS_API_KEY }} - GRANT: ${{ steps.gate.outputs.execution-grant }} - run: | - set -euo pipefail - ID_TOKEN=$(curl -fsS -X POST "$API/v1/protocol/execution-grants/exchange-oidc" \ - -H "Authorization: Bearer $DECIONIS_API_KEY" \ - -H 'content-type: application/json' \ - -d "{\"execution_grant\":\"$GRANT\",\"cloud_audience\":\"$AUD\"}" \ - | jq -er '.id_token') - echo "$ID_TOKEN" > "$RUNNER_TEMP/oidc_token" - echo "token-file=$RUNNER_TEMP/oidc_token" >> "$GITHUB_OUTPUT" - - # The federated token now exchanges for GCP creds (auth action reads the - # token file produced above). GCP rejects it unless decision == 'allow'. - - name: Authenticate to GCP via WIF - if: steps.gate.outputs.decision == 'allow' - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: projects/PROJECT_NUM/locations/global/workloadIdentityPools/decionis/providers/gh - service_account: deploy@PROJECT.iam.gserviceaccount.com - token_format: access_token - # Use the Decionis-issued OIDC token instead of GitHub's: - id_token_file: ${{ steps.oidc.outputs.token-file }} - - - name: Deploy - if: steps.gate.outputs.decision == 'allow' - run: ./scripts/deploy.sh diff --git a/examples/gate-deploy.yml b/examples/gate-deploy.yml index 2784c8d..763e03e 100644 --- a/examples/gate-deploy.yml +++ b/examples/gate-deploy.yml @@ -1,38 +1,42 @@ -# Enforce a production deploy with Decionis β€” the deploy runs THROUGH the gate. -# -# Because the deploy command is passed to `run:`, it cannot execute without an -# authorizing verdict. There is no `if:` to delete: on block/escalate the -# command never runs and the job fails. Drop into .github/workflows/. - -name: Deploy (governed) +# A production deploy that cannot run without Decionis authorizing it: the +# deploy script runs through the gate, so an ALLOW is a claimed grant and a +# BLOCK is a step that fails with the command never started. What happened is +# finalized with Decionis afterwards and joins the signed Decision Dossier. +name: Deploy to production on: push: branches: [main] - workflow_dispatch: + +permissions: + contents: read jobs: - govern-and-deploy: + deploy: runs-on: ubuntu-latest - permissions: - contents: read + environment: production steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Govern + deploy - uses: decionis/govern@v1 + - name: Govern the deploy + id: gate + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} action: production-deploy - mode: enforce + resource: service:api + environment: production payload: | { - "environment": "production", "service": "api", - "blast_radius": "high" + "ref": "${{ github.ref }}", + "sha": "${{ github.sha }}", + "actor": "${{ github.actor }}" } - # Decionis runs this ONLY if it authorizes the deploy. On block or - # escalate it never runs and the step fails β€” no skippable `if:`. - run: ./scripts/deploy.sh + run: ./scripts/deploy.sh # runs only on an ALLOW whose grant this step claimed + + - name: Record the proof + if: always() + run: | + echo "decision=${{ steps.gate.outputs.decision }} dossier=${{ steps.gate.outputs.dossier-id }} outcome=${{ steps.gate.outputs.outcome }}" diff --git a/examples/gate-pr-comment.yml b/examples/gate-pr-comment.yml index 555e13d..3bb1113 100644 --- a/examples/gate-pr-comment.yml +++ b/examples/gate-pr-comment.yml @@ -1,30 +1,34 @@ -# Run Decionis in shadow mode and post the verdict as a PR comment so the -# reviewer sees what the governed verdict would have been β€” without ever -# failing the build. -# -# Pattern: install shadow first, observe verdicts on PRs for a sprint, then -# flip `mode` to `enforce`. - -name: Decionis (shadow on PRs) +# Shadow: nothing is enforced and no build fails for the gate's sake. Every +# pull request gets the verdict Decionis would have given, as a comment that +# updates in place, so a team sees what governing would do before it does. +name: Decionis shadow on: pull_request: - types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write jobs: - shadow-verdict: + shadow: runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Decionis shadow verdict - uses: decionis/govern@v1 + - name: What would Decionis say? + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_pr_change_intent + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} mode: shadow - comment-pr: "true" + action: pull-request-change + resource: pr:${{ github.event.pull_request.number }} + payload: | + { + "title": ${{ toJSON(github.event.pull_request.title) }}, + "author": "${{ github.event.pull_request.user.login }}", + "changed_files": ${{ github.event.pull_request.changed_files }}, + "base": "${{ github.base_ref }}" + } + comment: "true" diff --git a/examples/gate-release.yml b/examples/gate-release.yml index 4597700..e718408 100644 --- a/examples/gate-release.yml +++ b/examples/gate-release.yml @@ -1,42 +1,35 @@ -# Require a Decionis verdict before a tagged release ships. The signed verify -# URL is attached to the GitHub Release notes as portable proof that the -# release passed governance. - -name: Release (governed) +# A tagged release ships only on an ALLOW. The publish command runs through +# the gate; a BLOCK never publishes; an ESCALATE waits for the release +# manager's approval, orchestrated by Decionis, within the intent's lifetime. +name: Release on: push: tags: ["v*"] +permissions: + contents: write + id-token: write + jobs: - govern-and-release: + release: runs-on: ubuntu-latest - permissions: - contents: write # to create the release steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + registry-url: https://registry.npmjs.org - - name: Decionis verdict - id: gate - uses: decionis/govern@v1 + - name: Govern the release + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: release_gate - fail-on: block + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} + action: release-publish + resource: npm:${{ github.event.repository.name }} payload: | - { - "release_tag": "${{ github.ref_name }}", - "repository": "${{ github.repository }}" - } - - - name: Create release (only if allowed) - if: steps.gate.outputs.decision == 'allow' - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create "${{ github.ref_name }}" \ - --title "${{ github.ref_name }}" \ - --notes "Governed release. ${{ steps.gate.outputs.badge-markdown }} - - Verify: ${{ steps.gate.outputs.verify-url }}" + { "tag": "${{ github.ref_name }}", "sha": "${{ github.sha }}", "actor": "${{ github.actor }}" } + escalation: managed + approver-role: RELEASE_MANAGER + run: npm publish --provenance --access public diff --git a/examples/gate-terraform.yml b/examples/gate-terraform.yml index 43649fe..7965778 100644 --- a/examples/gate-terraform.yml +++ b/examples/gate-terraform.yml @@ -1,55 +1,50 @@ -# Gate `terraform apply` on a Decionis verdict driven by the plan's blast radius. -# The plan's resource-change counts feed the policy, so a large/destructive plan -# can require escalation while a no-op or additive plan sails through. - -name: Terraform (governed apply) +# terraform apply runs through the gate with the plan's own numbers as the +# intent's parameters, so policy decides on the blast radius Terraform +# reported and the dossier records it. +name: Terraform apply on: push: branches: [main] - paths: ["infra/**", "**.tf"] - workflow_dispatch: + paths: ["infra/**"] + +permissions: + contents: read jobs: - plan-and-govern: + apply: runs-on: ubuntu-latest - permissions: - contents: read + defaults: + run: + working-directory: infra steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@v5 - uses: hashicorp/setup-terraform@v3 - - name: Terraform plan + - name: Plan id: plan - working-directory: infra run: | terraform init -input=false terraform plan -input=false -out=tfplan - # Summarize the plan into change counts the policy can evaluate. terraform show -json tfplan > plan.json - ADD=$(jq '[.resource_changes[]?|select(.change.actions|index("create"))]|length' plan.json) - DESTROY=$(jq '[.resource_changes[]?|select(.change.actions|index("delete"))]|length' plan.json) - echo "add=$ADD" >> "$GITHUB_OUTPUT" - echo "destroy=$DESTROY" >> "$GITHUB_OUTPUT" + jq -c '{ + add: [.resource_changes[]? | select(.change.actions | index("create"))] | length, + change: [.resource_changes[]? | select(.change.actions | index("update"))] | length, + destroy: [.resource_changes[]? | select(.change.actions | index("delete"))] | length, + touches_iam: ([.resource_changes[]? | select(.type | test("iam|security_group"))] | length) > 0 + }' plan.json > summary.json + echo "summary=$(cat summary.json)" >> "$GITHUB_OUTPUT" - - name: Decionis verdict - id: gate - uses: decionis/govern@v1 + - name: Govern the apply + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: terraform_apply_gate - fail-on: block_or_escalate - payload: | - { - "environment": "production", - "tool": "terraform", - "resources_created": ${{ steps.plan.outputs.add }}, - "resources_destroyed": ${{ steps.plan.outputs.destroy }} - } - - - name: Terraform apply - if: steps.gate.outputs.decision == 'allow' - working-directory: infra - run: terraform apply -input=false tfplan + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} + action: terraform-apply + resource: infra:tfplan + environment: production + payload: ${{ steps.plan.outputs.summary }} + # An ESCALATE holds the step while Decionis orchestrates the approval. + escalation: managed + approver-role: PLATFORM_LEAD + run: cd infra && terraform apply -input=false tfplan diff --git a/install.sh b/install.sh deleted file mode 100755 index 0b6e7b4..0000000 --- a/install.sh +++ /dev/null @@ -1,469 +0,0 @@ -#!/usr/bin/env sh -# Decionis Govern installer β€” canonical source for https://decionis.com/govern/install.sh -# -# curl -fsSL https://decionis.com/govern/install.sh | sh -# curl -fsSL https://decionis.com/govern/install.sh | sh -s -- --pr -# curl -fsSL https://decionis.com/govern/install.sh | sh -s -- --pr --inject -# -# Writes a shadow-mode governance workflow and a starter DECIONIS_POLICY.md, -# optionally injects an observe-only gate step into existing workflows -# (--inject), and optionally opens the onboarding pull request (--pr). -# POSIX sh β€” runs under dash, macOS bash 3.2, and busybox sh. No secrets are -# read, stored, or transmitted; the api-key is always a GitHub secrets ref. -set -eu - -INSTALLER_VERSION="1.9.0" -BRANCH_NAME="feature/add-decionis-governance" -MARKER="# decionis-govern (auto)" -WORKFLOW_FILE=".github/workflows/decionis-govern.yml" -POLICY_FILE="DECIONIS_POLICY.md" - -MODE="shadow" -WORKFLOW_KEY="github_pr_change_intent" -ORG_ID_REF='${{ secrets.DECIONIS_ORG_ID }}' -DO_INJECT=0 -DO_PR=0 -DRY_RUN=0 -FORCE=0 -CREATED="" -MODIFIED="" - -usage() { - cat <<'EOF' -Decionis Govern installer - -Usage: install.sh [options] - (no flags) Write .github/workflows/decionis-govern.yml and a - starter DECIONIS_POLICY.md (each skipped if present). - --inject Also insert an observe-only Decionis shadow step as the - first step of every job in existing workflow files. - Insert-only and conservative: files/jobs it cannot edit - safely are skipped with a reason. - --pr Create branch feature/add-decionis-governance, commit - the generated files, push, and open the PR (gh CLI if - available, otherwise prints the compare URL). - --dry-run Show every file and diff that would be written; write - nothing and run no git commands. - --force Proceed despite a dirty worktree or detached HEAD; with - --pr, use a timestamp-suffixed branch if the branch - already exists. - --mode shadow (default) or enforce β€” mode for the generated - workflow. Injected steps are always shadow. - --org-id Inline a literal Decionis org id instead of - ${{ secrets.DECIONIS_ORG_ID }} (org ids are not secret). - --workflow-key Workflow key for generated and injected steps - (default: github_pr_change_intent). - -h, --help This help. - -Exit codes: 0 ok/nothing-to-do Β· 2 not a git work tree Β· 3 detached HEAD -(--pr) Β· 4 dirty worktree (--pr) Β· 5 --pr without an origin remote Β· 64 bad flag -EOF -} - -fail() { - code=$1 - shift - echo "install.sh: $*" >&2 - exit "$code" -} - -while [ $# -gt 0 ]; do - case "$1" in - --inject) DO_INJECT=1 ;; - --pr) DO_PR=1 ;; - --dry-run) DRY_RUN=1 ;; - --force) FORCE=1 ;; - --mode) - shift - [ $# -gt 0 ] || fail 64 "--mode needs a value (shadow|enforce)" - case "$1" in - shadow | enforce) MODE=$1 ;; - *) fail 64 "--mode must be 'shadow' or 'enforce' (got '$1')" ;; - esac - ;; - --org-id) - shift - [ $# -gt 0 ] || fail 64 "--org-id needs a value" - ORG_ID_REF=$1 - ;; - --workflow-key) - shift - [ $# -gt 0 ] || fail 64 "--workflow-key needs a value" - WORKFLOW_KEY=$1 - ;; - -h | --help) - usage - exit 0 - ;; - *) - usage >&2 - fail 64 "unknown flag: $1" - ;; - esac - shift -done - -git rev-parse --is-inside-work-tree >/dev/null 2>&1 || - fail 2 "not inside a git work tree β€” run this from your repository root" -cd "$(git rev-parse --show-toplevel)" - -# Git-state gates apply only to --pr (which branches, commits, and pushes); -# plain runs merely add new files and are safe in any worktree state. -if [ "$DO_PR" -eq 1 ] && [ "$DRY_RUN" -eq 0 ]; then - if ! git symbolic-ref -q HEAD >/dev/null 2>&1; then - [ "$FORCE" -eq 1 ] || fail 3 "detached HEAD β€” check out a branch or pass --force" - fi - if [ -n "$(git status --porcelain 2>/dev/null)" ]; then - [ "$FORCE" -eq 1 ] || fail 4 "working tree is dirty β€” commit/stash first or pass --force" - fi -fi - -# ── Artifact generation ────────────────────────────────────────────────────── - -# write_artifact β€” content on stdin; skips existing files, honors --dry-run. -write_artifact() { - wa_path=$1 - if [ -e "$wa_path" ]; then - cat >/dev/null - echo "skip: $wa_path already exists" - return 0 - fi - if [ "$DRY_RUN" -eq 1 ]; then - echo "would create: $wa_path" - sed 's/^/ | /' - return 0 - fi - mkdir -p "$(dirname "$wa_path")" - cat >"$wa_path" - CREATED="$CREATED $wa_path" - echo "created: $wa_path" -} - -write_workflow() { - write_artifact "$WORKFLOW_FILE" <` - reads payload keys; `decision_type` derives from the action label. - - Verdicts: allow / block / restrain / escalate. ---> - -## Scope - -Applies to deploys, infrastructure changes, and AI-authored changes gated -through `decionis/govern` in this repository. Start in `mode: shadow`, review -the verdicts on your PRs, then switch to `enforce`. - -## Rules - -- **Block** deploys during a change freeze. -- **Escalate** anything that destroys infrastructure or touches secrets. -- **Restrain** (require human review) AI-authored changes to deploy paths. - -## Enforced rules - -```decionis -{ - "version": 1, - "rules": [ - { - "name": "Block deploys during a change freeze", - "priority": 100, - "domain": "*", - "all": [{ "field": "context.change_freeze", "op": "eq", "value": true }], - "action": "block" - }, - { - "name": "Escalate destructive or secret-touching changes", - "priority": 90, - "domain": "*", - "any": [ - { "field": "context.destroys_resources", "op": "eq", "value": true }, - { "field": "context.touches_secrets", "op": "eq", "value": true } - ], - "action": "escalate" - }, - { - "name": "Restrain AI-authored deploy changes", - "priority": 80, - "domain": "*", - "all": [{ "field": "context.agent_generated", "op": "eq", "value": true }], - "action": "restrain" - } - ] -} -``` -EOF -} - -# ── Workflow injection (--inject) ──────────────────────────────────────────── -# Single awk pass per file, insert-only: the shadow step is inserted as the -# FIRST step of each job (observe-only, no checkout dependency), re-indented to -# match the job's existing step items, with continue-on-error so it can never -# break the host job. Anything the parser is unsure about is skipped loudly. - -inject_one() { - in_file=$1 - if grep -q 'decionis/govern' "$in_file"; then - echo "skip: $in_file (already uses decionis/govern)" - return 0 - fi - if grep -q "$(printf '\t')" "$in_file"; then - echo "skip: $in_file (contains tabs β€” add the step manually)" - return 0 - fi - if ! grep -q '^jobs:' "$in_file"; then - echo "skip: $in_file (no top-level 'jobs:' key)" - return 0 - fi - - in_tmp="$in_file.decionis-tmp" - in_err="$in_file.decionis-err" - awk -v org_ref="$ORG_ID_REF" -v wk="$WORKFLOW_KEY" -v marker="$MARKER" ' - function indent_of(line, n) { - n = 0 - while (substr(line, n + 1, 1) == " ") n++ - return n - } - function pad(n, s, i) { - s = "" - for (i = 0; i < n; i++) s = s " " - return s - } - function emit_step(n, p) { - p = pad(n) - print p "- name: Decionis shadow verdict " marker - print p " uses: decionis/govern@v1" - print p " continue-on-error: true" - print p " with:" - print p " api-key: ${{ secrets.DECIONIS_API_KEY }}" - print p " org-id: " org_ref - print p " workflow-key: " wk - print p " mode: shadow" - } - BEGIN { in_jobs = 0; job_indent = -1; child_indent = -1; state = ""; steps_indent = -1 } - { - line = $0 - if (!in_jobs) { - print line - if (line ~ /^jobs:[ \t]*$/) in_jobs = 1 - next - } - if (line ~ /^[^ \t]/) { in_jobs = 0; print line; next } # jobs section over - ind = indent_of(line) - blank = (line ~ /^[ \t]*$/) - comment = (line ~ /^[ ]*#/) - - if (!blank && !comment) { - if (job_indent < 0 && line ~ /^[ ]+[^ ]/) job_indent = ind - if (ind == job_indent && line ~ /^[ ]+[A-Za-z0-9_."'"'"'-]+:[ \t]*$/) { - state = "in_job"; child_indent = -1; steps_indent = -1 - } else if (state != "" && child_indent < 0 && ind > job_indent) { - child_indent = ind - } - } - - if (state == "in_job" && !blank && !comment && ind == child_indent) { - if (line ~ /^[ ]+uses:/) { - print "note: reusable-workflow job skipped" > "/dev/stderr" - state = "skip_job" - } else if (line ~ /^[ ]+steps:[ \t]*$/) { - state = "await_item"; steps_indent = ind; print line; next - } else if (line ~ /^[ ]+steps:[ \t]*[^ \t]/) { - print "note: flow-style or anchored steps skipped" > "/dev/stderr" - state = "skip_job" - } - } - - if (state == "await_item") { - if (blank || comment) { print line; next } - if (ind > steps_indent && line ~ /^[ ]*- /) { - emit_step(ind) - state = "done_job" - print line - next - } - print "note: job with empty or unrecognized steps skipped" > "/dev/stderr" - state = "skip_job" - } - print line - } - ' "$in_file" >"$in_tmp" 2>"$in_err" || { - rm -f "$in_tmp" "$in_err" - echo "skip: $in_file (could not parse)" - return 0 - } - - while IFS= read -r note; do - [ -n "$note" ] && echo " $in_file: $note" - done <"$in_err" - rm -f "$in_err" - - added=$(grep -c "decionis-govern (auto)" "$in_tmp" || true) - if [ "$added" -eq 0 ] || cmp -s "$in_file" "$in_tmp"; then - rm -f "$in_tmp" - echo "skip: $in_file (no injectable jobs)" - return 0 - fi - if [ "$DRY_RUN" -eq 1 ]; then - echo "would inject $added step(s) into $in_file:" - diff -u "$in_file" "$in_tmp" || true - rm -f "$in_tmp" - return 0 - fi - mv "$in_tmp" "$in_file" - MODIFIED="$MODIFIED $in_file" - echo "injected $added step(s) into $in_file" -} - -inject_all() { - ia_found=0 - for wf in .github/workflows/*.yml .github/workflows/*.yaml; do - [ -e "$wf" ] || continue - case "$wf" in - "$WORKFLOW_FILE") continue ;; - esac - ia_found=1 - inject_one "$wf" - done - [ "$ia_found" -eq 1 ] || echo "inject: no existing workflow files found under .github/workflows/" -} - -# ── Pull request (--pr) ────────────────────────────────────────────────────── - -repo_web_url() { - rwu_origin=$(git remote get-url origin 2>/dev/null) || return 1 - case "$rwu_origin" in - git@*) - echo "$rwu_origin" | sed -e 's/^git@//' -e 's/:/\//' -e 's/\.git$//' -e 's/^/https:\/\//' - ;; - http://* | https://*) - echo "$rwu_origin" | sed -e 's/\.git$//' - ;; - *) return 1 ;; - esac -} - -open_pr() { - git remote get-url origin >/dev/null 2>&1 || - fail 5 "--pr needs an 'origin' remote to push to" - to_commit=$(echo "$CREATED $MODIFIED" | tr -s ' ' | sed 's/^ //;s/ $//') - if [ -z "$to_commit" ]; then - echo "--pr: nothing new to commit; skipping PR" - return 0 - fi - - base_branch=$(git rev-parse --abbrev-ref HEAD) - target_branch=$BRANCH_NAME - if git rev-parse --verify -q "$target_branch" >/dev/null 2>&1; then - [ "$FORCE" -eq 1 ] || fail 1 "branch $target_branch already exists β€” pass --force for a suffixed branch" - target_branch="$BRANCH_NAME-$(date +%s)" - fi - - pr_title="Add Decionis governance gate ($MODE mode)" - pr_body="## Governed by Decionis - -This PR adds a **$MODE-mode** [decionis/govern](https://github.com/decionis/govern) gate: - -- \`$WORKFLOW_FILE\` β€” records a signed verdict on every pull request$([ "$DO_INJECT" -eq 1 ] && printf '\n- observe-only shadow steps injected into existing workflows (`continue-on-error: true` β€” they can never break a build)' || true) -- \`$POLICY_FILE\` β€” the repo governance policy, evaluated locally in microseconds and recorded (by sha256) on every signed Decision Dossier - -### Before merging -1. Add the \`DECIONIS_API_KEY\` secret (Settings β†’ Secrets β†’ Actions).$([ "$ORG_ID_REF" = '${{ secrets.DECIONIS_ORG_ID }}' ] && printf '\n2. Add the `DECIONIS_ORG_ID` secret (your Decionis org id).' || true) - -Shadow mode **never fails a build** β€” merge, watch the verdicts on PRs, then flip \`mode: enforce\` when ready. - -[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/govern) - ---- -Generated by \`install.sh\` v$INSTALLER_VERSION." - - echo "branch: $target_branch (from $base_branch)" - git checkout -q -b "$target_branch" - # shellcheck disable=SC2086 β€” word splitting over the file list is intended - git add -- $to_commit - git commit -q -m "chore: add Decionis governance gate ($MODE mode)" \ - -m "Generated by decionis/govern install.sh v$INSTALLER_VERSION. Shadow mode records signed verdicts without failing builds." - git push -u origin "$target_branch" - - if command -v gh >/dev/null 2>&1; then - if gh pr create --title "$pr_title" --body "$pr_body" --base "$base_branch" --head "$target_branch"; then - return 0 - fi - echo "gh pr create failed β€” open it manually:" >&2 - fi - web=$(repo_web_url) || { - echo "pushed: open a PR from $target_branch β†’ $base_branch on your host" - return 0 - } - echo "Open the PR: $web/compare/$base_branch...$target_branch?expand=1" -} - -# ── Main ───────────────────────────────────────────────────────────────────── - -echo "Decionis Govern installer v$INSTALLER_VERSION (mode: $MODE$([ "$DRY_RUN" -eq 1 ] && printf ', dry run'))" -write_workflow -write_policy -[ "$DO_INJECT" -eq 1 ] && inject_all - -if [ "$DRY_RUN" -eq 1 ]; then - echo "dry run: nothing was written" - exit 0 -fi - -if [ -z "$CREATED$MODIFIED" ]; then - echo "All Decionis artifacts already present; nothing to do." - exit 0 -fi - -if [ "$DO_PR" -eq 1 ]; then - open_pr -else - echo "" - echo "Next steps:" - echo " 1. Add the DECIONIS_API_KEY repo secret (and DECIONIS_ORG_ID unless --org-id was given)." - echo " 2. Commit the changes β€” or re-run with --pr to open the onboarding pull request:" - echo " curl -fsSL https://decionis.com/govern/install.sh | sh -s -- --pr" -fi diff --git a/llms.txt b/llms.txt index 37d6ca0..1192389 100644 --- a/llms.txt +++ b/llms.txt @@ -1,31 +1,20 @@ -# Decionis Action Gate (decionis/govern) +# Decionis Govern (decionis/govern) -> GitHub Action that governs high-risk workflow steps β€” deploys, migrations, releases, infra changes, AI-generated PRs β€” before they run. One step evaluates the action against your committed policy and returns a deterministic verdict (allow / block / restrain / escalate), sealed as a signed, publicly verifiable Decision Dossier. Shadow mode records verdicts without ever failing a build; enforce mode gates for real. +> GitHub Action, the Marketplace address of Govern: one Decionis verdict before a workflow step runs β€” a deploy, a migration, an infrastructure change, a release, an agent's action β€” with a signed Decision Dossier of it. The step becomes an execution intent, Decionis decides on exactly that intent, the command runs only on an ALLOW whose single-use grant the step claimed first, and the outcome is finalized into the dossier. Nothing is decided locally. Quickstart: -- Add a workflow step: `uses: decionis/govern@v1` -- One-command install into an existing repo: `curl -fsSL https://decionis.com/govern/install.sh | sh` -- Policy is committed in-repo as `DECIONIS_POLICY.md` (YAML also accepted); the structured rules block can be evaluated in-process in ~1 ms (`local-eval: auto`) while the API notarizes the signed dossier off the critical path. -- Requires a `DECIONIS_API_KEY` workflow secret (`protocol:evaluate` scope) and your Decionis org id. +- Add a workflow step: `uses: decionis/govern@v2` with `api-key` (a secret), `tenant-id` (a variable), `action`, and `run` (the command the verdict gates). +- `mode: shadow` starts the command at once and records the verdict beside it; it never fails a build. +- The gate is one Go binary for GitHub Actions, GitLab CI, Jenkins and any runner; its source, tests and releases are in decionis/agent-safe-pipeline under govern/. -Key inputs: `api-key`, `org-id`, `workflow-key`, `mode` (enforce | shadow), `fail-on`, `run` (command executed only if authorized), `request-grant` (short-lived signed Execution Grant), `payload`, `policy-file`, `policy-enforce`, `local-eval`, `comment-pr`. +Key inputs: `api-key`, `tenant-id`, `mode` (enforce | shadow), `action`, `resource`, `payload`, `environment`, `run`, `fail-on`, `escalation` (managed), `approver`, `approver-role`, `policy-file`, `comment`. -Key outputs: `decision`, `decision-source`, `dossier-id`, `verify-url`, `policy-sha256`, `badge-markdown`, `executed`, `execution-grant`. +Key outputs: `decision`, `decision-id`, `dossier-id`, `verify-url`, `intent-hash`, `reason-codes`, `policy-version`, `executed`, `claimed`, `outcome`, `finalization`, `badge-markdown`. ## Docs -- [Quickstart](https://decionis.com/docs/quickstart?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): First verdict in minutes -- [API reference](https://decionis.com/docs/api?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): Evaluate decisions and retrieve dossiers -- [Dossier verification](https://decionis.com/verify/decision-dossiers?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): Verify signed Decision Dossiers, including offline -- [Marketplace listing](https://github.com/marketplace/actions/decionis-action-gate): Decionis Action Gate on the GitHub Marketplace - -## Try it - -- [Sandbox](https://decionis.com/sandbox?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): Run a live policy check without an account - -## Related - -- [Decionis](https://decionis.com/?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): The deterministic decision layer between intent and execution -- [Organization profile](https://github.com/decionis): All public Decionis repositories -- [Security policy](https://github.com/decionis/.github/blob/master/SECURITY.md): Vulnerability disclosure process and SLAs +- [The gate's README](https://github.com/decionis/agent-safe-pipeline/blob/master/govern/README.md): every setting, output, exit code and runner surface +- [Govern, the workflow gate](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/govern.md): what one governed step is, holding a step for a person, what the record shows +- [Quickstart](https://decionis.com/quickstart?source=github_action): a workspace and a key in minutes +- [Decision Dossier verification](https://decionis.com/verify/decision-dossiers): verify a signed dossier, including offline diff --git a/package.json b/package.json deleted file mode 100644 index 823c697..0000000 --- a/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@decionis/github-action", - "version": "1.9.0", - "description": "Govern a GitHub Actions step on a Decionis Decision Dossier β€” gate deploys, releases, infra changes, and agent-triggered actions on a signed verdict.", - "private": true, - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/decionis/govern.git" - }, - "scripts": { - "test": "node --test test/*.test.mjs", - "lint": "eslint src test", - "format": "prettier --check . --ignore-path ../../.prettierignore", - "format:fix": "prettier --write . --ignore-path ../../.prettierignore" - }, - "engines": { - "node": ">=20" - }, - "files": [ - "action.yml", - "install.sh", - "src", - "assets", - "README.md", - "LICENSE", - "SECURITY.md", - "SUPPORT.md" - ], - "license": "Apache-2.0", - "homepage": "https://decionis.com/marketplace/github" -} diff --git a/src/api-client.mjs b/src/api-client.mjs deleted file mode 100644 index 96bec6b..0000000 --- a/src/api-client.mjs +++ /dev/null @@ -1,122 +0,0 @@ -// @ts-check -// Decionis API client β€” the only module that talks to the Decionis backend. -// Extracted from index.mjs so the blocking path and the background -// (speculative / notarization) pipeline share one implementation. - -/** - * POST /v1/protocol/evaluate-decision. - * - * Returns `{ ok: true, status, data }` on 2xx and - * `{ ok: false, status, bodyText }` on any other HTTP status. - * Throws on network errors, the internal timeout, or an external abort β€” - * the caller decides fail-open vs fail-closed per mode. - * - * @param {{ apiBaseUrl: string, apiKey: string, body: object, - * timeoutMs: number, signal?: AbortSignal }} options - */ -export async function evaluateDecision({ apiBaseUrl, apiKey, body, timeoutMs, signal }) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const onExternalAbort = () => controller.abort(); - if (signal) { - if (signal.aborted) { - clearTimeout(timer); - throw new Error("Decionis evaluate-decision aborted before the request started"); - } - signal.addEventListener("abort", onExternalAbort, { once: true }); - } - try { - const res = await fetch(`${apiBaseUrl}/v1/protocol/evaluate-decision`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "content-type": "application/json", - accept: "application/json", - "x-decionis-source": "github_actions", - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - if (!res.ok) { - const bodyText = await res.text().catch(() => ""); - return { ok: false, status: res.status, bodyText }; - } - return { ok: true, status: res.status, data: await res.json() }; - } finally { - clearTimeout(timer); - if (signal) signal.removeEventListener("abort", onExternalAbort); - } -} - -/** - * Map the protocol's outcome vocabulary (APPROVE / REJECT / REVIEW / ESCALATE, - * plus rule-action forms) onto the verdict vocabulary the gate logic consumes - * (allow / block / review / escalate). Unmapped strings pass through β€” the - * downstream sets already understand deny/denied/restrain/etc. - */ -const VERDICT_ALIASES = { - approve: "allow", - approved: "allow", - auto_approve: "allow", - reject: "block", - rejected: "block", - auto_reject: "block", - require_review: "review", - request_info: "review", -}; - -export function normalizeVerdict(raw) { - const d = String(raw ?? "") - .trim() - .toLowerCase(); - return VERDICT_ALIASES[d] ?? d; -} - -/** - * Extract the verdict fields from an evaluate-decision response body. - * Pure + testable. Tolerates both top-level and dossier-nested shapes. - * `decision` is normalized (e.g. the protocol's APPROVE β†’ allow) so - * shouldExecute/shouldFail work against the live API; `rawDecision` - * preserves the wire value for logs. - */ -export function parseDecisionResponse(data) { - const rawDecision = String(data?.decision ?? data?.outcome ?? "").toLowerCase(); - const decision = normalizeVerdict(rawDecision); - const dossierId = String(data?.dossier_id ?? data?.dossier?.dossier_id ?? ""); - const signature = data?.verification?.signature ?? data?.signature ?? null; - const policyVersion = data?.policy_version ?? data?.dossier?.policy_version ?? null; - const reasonCode = - (Array.isArray(data?.reason_codes) ? data.reason_codes[0] : undefined) ?? - data?.reason_code ?? - null; - return { decision, rawDecision, dossierId, signature, policyVersion, reasonCode }; -} - -/** Request a signed execution grant from Decionis (returns null on any failure). */ -export async function fetchExecutionGrant({ apiBaseUrl, apiKey, body, timeoutMs, logNotice }) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(`${apiBaseUrl}/v1/protocol/execution-grants/issue`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "content-type": "application/json", - accept: "application/json", - "x-decionis-source": "github_actions", - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - if (!res.ok) { - logNotice(`Execution grant not issued (HTTP ${res.status}).`); - return null; - } - return await res.json(); - } catch (err) { - logNotice(`Execution grant request failed: ${err instanceof Error ? err.message : err}`); - return null; - } finally { - clearTimeout(timer); - } -} diff --git a/src/index.mjs b/src/index.mjs deleted file mode 100644 index e3485c8..0000000 --- a/src/index.mjs +++ /dev/null @@ -1,1076 +0,0 @@ -// @ts-check -// Decionis GitHub Action β€” Node 20 entrypoint. -// Zero deps: uses built-in fetch + node:fs. -// Resolves a Decionis Decision Dossier for the current workflow step and -// fails the step on a `block` (or `escalate`, configurable) verdict. - -import { spawn } from "node:child_process"; -import { appendFile, readFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import { isAbsolute, join } from "node:path"; - -import { evaluateDecision, fetchExecutionGrant, parseDecisionResponse } from "./api-client.mjs"; -import { evaluateLocalPolicy } from "./policy-engine.mjs"; - -/** Max policy-file bytes carried inline with a decision (content over this is - * referenced by hash only, never silently dropped). */ -const POLICY_FILE_INLINE_LIMIT = 131072; // 128 KiB - -const FAIL_MODES = new Set(["block", "escalate", "block_or_escalate", "never"]); -const RUN_MODES = new Set(["enforce", "shadow"]); -const LOCAL_EVAL_MODES = new Set(["auto", "off", "strict"]); - -/** Default for the `local-eval` input β€” the one-line switch for fast-path semantics. */ -export const DEFAULT_LOCAL_EVAL = "auto"; -/** Longest we wait for the background pipeline after the command exits. */ -export const SHADOW_GRACE_CAP_MS = 10_000; -/** Small allowance past the request-timeout budget for parsing/publishing. */ -export const SHADOW_GRACE_EXTRA_MS = 2_500; -/** Bound on the best-effort dossier-recording call after a local block. */ -export const BLOCK_RECORD_CAP_MS = 5_000; - -/** Read an Action input (GitHub maps inputs to env vars). */ -function getInput(name, { required = false } = {}) { - // GitHub maps input `api-key` β†’ env `INPUT_API-KEY`: it upper-cases and - // replaces spaces with `_`, but keeps hyphens (matching @actions/core). Read - // that form first, then fall back to the legacy hyphenβ†’`_` form for safety. - const upper = name.replace(/ /g, "_").toUpperCase(); - const value = process.env[`INPUT_${upper}`] || process.env[`INPUT_${upper.replace(/-/g, "_")}`]; - if (value === undefined || value === "") { - if (required) throw new Error(`Decionis: missing required input '${name}'`); - return ""; - } - return value; -} - -/** - * Strip stray wrapping quotes (straight or smart) and surrounding whitespace - * that sneak in when a secret is pasted as `"key"` β€” a credential never - * legitimately contains them, and a smart quote (U+201C…) would otherwise crash - * the Authorization header ("Cannot convert argument to a ByteString"). - */ -function sanitizeCredential(value) { - return value - .replace(/^[\s"'β€œβ€β€˜β€™]+/, "") - .replace(/[\s"'β€œβ€β€˜β€™]+$/, ""); -} - -function getBooleanInput(name, fallback = false) { - const raw = getInput(name).trim().toLowerCase(); - if (raw === "") return fallback; - return raw === "true" || raw === "1" || raw === "yes"; -} - -/** GitHub log commands β€” these render nicely in the Action log. */ -function logGroup(title, body) { - console.log(`::group::${title}`); - console.log(body); - console.log("::endgroup::"); -} -function logError(message) { - console.log(`::error::${message}`); -} -function logWarning(message) { - console.log(`::warning::${message}`); -} -function logNotice(message) { - console.log(`::notice::${message}`); -} - -/** Set a GitHub Action output (file-based, the v2 mechanism). */ -async function setOutput(name, value) { - const outputFile = process.env.GITHUB_OUTPUT; - if (!outputFile) return; // running locally, just no-op - const safe = value === null || value === undefined ? "" : String(value); - // Use heredoc syntax so multiline values are safe. - const delim = `__DECIONIS_${Date.now()}_${Math.random().toString(36).slice(2)}`; - await appendFile(outputFile, `${name}<<${delim}\n${safe}\n${delim}\n`); -} - -/** Write to the run summary (markdown rendered on the run page). */ -async function writeSummary(markdown) { - const summaryFile = process.env.GITHUB_STEP_SUMMARY; - if (!summaryFile) return; - await appendFile(summaryFile, markdown + "\n"); -} - -/** - * Build a minimal payload from the GitHub workflow context when the consumer - * didn't pass one. Honest β€” only includes fields actually present on the run. - */ -function buildPayloadFromContext() { - const env = process.env; - return { - source: "github_actions", - github: { - repository: env.GITHUB_REPOSITORY ?? null, - ref: env.GITHUB_REF ?? null, - sha: env.GITHUB_SHA ?? null, - event_name: env.GITHUB_EVENT_NAME ?? null, - actor: env.GITHUB_ACTOR ?? null, - run_id: env.GITHUB_RUN_ID ?? null, - run_attempt: env.GITHUB_RUN_ATTEMPT ?? null, - workflow: env.GITHUB_WORKFLOW ?? null, - job: env.GITHUB_JOB ?? null, - server_url: env.GITHUB_SERVER_URL ?? "https://github.com", - }, - }; -} - -/** - * Parse the consumer-provided payload string. Accepts JSON object or JSON - * object inside a heredoc-style string. Falls back to context-derived payload - * if the input is blank. - */ -export function resolvePayload(rawInput, contextBuilder = buildPayloadFromContext) { - const trimmed = (rawInput ?? "").trim(); - if (trimmed === "") return contextBuilder(); - try { - const parsed = JSON.parse(trimmed); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; - throw new Error("payload JSON must be an object"); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - throw new Error(`Decionis: input 'payload' is not valid JSON object: ${reason}`); - } -} - -/** - * Build the DECIONIS_POLICY.md descriptor that rides along with a decision. - * Pure + testable: the content hash is the version handle β€” a changed file - * yields a new sha256, so each policy revision is distinct and recorded. - * Content over POLICY_FILE_INLINE_LIMIT is referenced by hash only (with - * `truncated: true`) β€” never silently dropped. - */ -export function buildPolicySource(content, { path, ref = null } = {}) { - const text = typeof content === "string" ? content : ""; - const bytes = Buffer.byteLength(text, "utf8"); - const sha256 = createHash("sha256").update(text, "utf8").digest("hex"); - const inline = bytes <= POLICY_FILE_INLINE_LIMIT; - return { - type: "decionis_policy_md", - path: path ?? null, - ref: ref ?? null, - sha256, - bytes, - truncated: !inline, - ...(inline ? { content: text } : {}), - }; -} - -/** - * Read the repo-local policy file (default DECIONIS_POLICY.md at the workspace - * root) and return its descriptor, or null if absent/unreadable. Never throws: - * a missing or broken policy file must not fail the gate. - */ -export async function loadPolicyFile(relPath, { workspace, ref } = {}) { - const rel = (relPath ?? "").trim(); - if (!rel) return null; - const root = workspace ?? process.env.GITHUB_WORKSPACE ?? process.cwd(); - const full = isAbsolute(rel) ? rel : join(root, rel); - try { - const content = await readFile(full, "utf8"); - return buildPolicySource(content, { path: rel, ref: ref ?? null }); - } catch { - return null; - } -} - -/** - * Fold the friendly `action` label into the payload without clobbering an - * explicit `action` field the caller already set. Returns a new object. - */ -export function applyActionLabel(payload, action) { - const label = (action ?? "").trim(); - if (!label) return payload; - if (payload && typeof payload === "object" && "action" in payload) return payload; - return { ...payload, action: label }; -} - -/** - * Decide whether the wrapped `run` command may execute. - * - * This is the enforcing path β€” Decionis runs the command itself, so it cannot - * execute without an authorizing verdict (no skippable `if:`). In `shadow` - * mode the command always runs: shadow observes, it must never change behavior. - * In `enforce` mode only an `allow` verdict permits execution. - */ -export function shouldExecute(decision, runMode) { - if (runMode === "shadow") return true; - return (decision ?? "").toLowerCase() === "allow"; -} - -/** - * Start the gated command through the chosen shell, streaming its output. - * Returns immediately with the child and an `exited` promise so callers can - * run evaluation work concurrently (speculative shadow / async notarization). - */ -function startCommand(command, shell, extraEnv = {}) { - const sh = (shell || "bash").trim() === "sh" ? "sh" : "bash"; - const args = ["-e", "-c", command]; - const child = spawn(sh, args, { stdio: "inherit", env: { ...process.env, ...extraEnv } }); - const exited = new Promise((resolve) => { - child.on("close", (code) => resolve(code ?? 0)); - child.on("error", (err) => { - logError(`Decionis could not run the gated command: ${err.message}`); - resolve(1); - }); - }); - return { child, exited }; -} - -/** Run the gated command to completion (enforcing path convenience). */ -function executeCommand(command, shell, extraEnv = {}) { - return startCommand(command, shell, extraEnv).exited; -} - -/** Sleep helper for bounded grace waits. */ -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Wrap background/observe-only work: any failure becomes a `::notice::` and - * resolves to null. Shadow mode and async notarization must never be able to - * change the step's exit code. - */ -async function swallow(label, promise) { - try { - return await promise; - } catch (err) { - logNotice(`Decionis (non-fatal) ${label}: ${err instanceof Error ? err.message : err}`); - return null; - } -} - -/** - * The single decision table for how a run reaches its verdict β€” the branch - * point for the `local-eval` input. Pure + testable. - * - * act: "local" = a deterministic committed rule verdict gates the run; - * "api" = the blocking evaluate-decision call gates it (v1.8 path). - * api: what happens to the network call when act=local β€” - * "none" (strict: fully offline), "background" (notarize while the - * command runs), "bounded" (notarize with a capped wait), or - * "blocking" when act=api. - * - * `request-grant: true` always forces the blocking path: the signed grant - * must exist before the command's environment is built. - */ -export function planEvaluation({ localOutcome, localEval, requestGrant, hasRunCommand }) { - const deterministic = Boolean(localOutcome?.deterministic); - if (!deterministic || localEval === "off" || requestGrant) { - return { act: "api", api: "blocking" }; - } - if (localEval === "strict") return { act: "local", api: "none" }; - if (localOutcome.outcome === "block") return { act: "local", api: "bounded" }; - return { act: "local", api: hasRunCommand ? "background" : "bounded" }; -} - -/** - * Grace budget for the background pipeline after the command exits: whatever - * remains of the request-timeout budget plus a small parsing allowance, - * capped so a fast command never waits long on a slow API. - */ -export function computeGraceMs(pipelineStartMs, nowMs, timeoutMs) { - const remaining = Math.max(timeoutMs - (nowMs - pipelineStartMs), 0); - return Math.min(remaining + SHADOW_GRACE_EXTRA_MS, SHADOW_GRACE_CAP_MS); -} - -/** - * Timing instrumentation: the speedups from local evaluation and speculative - * execution must be visible in the log, not just real. - */ -export function createTimeline(t0 = performance.now()) { - const events = []; - const fmt = (ms) => (ms >= 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`); - return { - events, - mark(label, detail) { - events.push({ label, detail: detail ?? null, at: performance.now() - t0 }); - }, - render() { - if (events.length === 0) return "Decionis timing β€” no events recorded"; - const parts = events.map( - (e) => `${e.label}${e.detail ? ` ${e.detail}` : ""} +${fmt(e.at)}`, - ); - return `Decionis timing β€” ${parts.join(" Β· ")}`; - }, - }; -} - -/** - * Build the execution-grant issue request body. Pure + testable. The grant - * binds the authorization to this org, dossier, action, and the repo@sha that - * triggered the run, so a target can verify exactly what was authorized. - */ -export function buildGrantRequestBody({ orgId, dossierId, decision, action, audience }) { - const repo = process.env.GITHUB_REPOSITORY ?? null; - const sha = process.env.GITHUB_SHA ?? null; - const subject = repo && sha ? `${repo}@${sha}` : (repo ?? undefined); - return { - org_id: orgId, - dossier_id: dossierId, - outcome: decision, - ...(action ? { action } : {}), - ...(audience ? { audience } : {}), - ...(subject ? { subject } : {}), - }; -} - -/** Decide whether a given verdict should fail the step under the configured mode. */ -export function shouldFail(decision, failOn, runMode) { - if (runMode === "shadow") return false; - const d = (decision ?? "").toLowerCase(); - if (failOn === "never") return false; - if (failOn === "block") return d === "block" || d === "deny" || d === "denied"; - if (failOn === "escalate") return d === "escalate" || d === "review"; - if (failOn === "block_or_escalate") { - return d === "block" || d === "deny" || d === "denied" || d === "escalate" || d === "review"; - } - // Unknown fail-on values are treated as the default (block). - return d === "block" || d === "deny" || d === "denied"; -} - -/** - * Build the canonical public verify URL (with `?sig=` for the OG card). - * When a policy sha256 is provided, the link additionally pins the exact - * repo-policy revision the decision was made under (`&policy=sha256:`) - * β€” an additive query parameter the server is free to ignore. - */ -export function buildVerifyUrl(siteBase, dossierId, signature, { policySha256 } = {}) { - const base = siteBase.replace(/\/$/, ""); - const id = encodeURIComponent(dossierId); - const params = []; - if (signature) params.push(`sig=${encodeURIComponent(signature)}`); - params.push("source=github_actions"); - if (policySha256) params.push(`policy=${encodeURIComponent(`sha256:${policySha256}`)}`); - return `${base}/verify/decision-dossiers/${id}?${params.join("&")}`; -} - -// ───────────────────────── Growth / virality surface ───────────────────────── -// The PR comment, the run summary, and the embeddable badge are the surfaces -// every reviewer and downstream repo sees. They carry a signed verify link plus -// soft attribution back to the action so adoption compounds (each governed repo -// becomes a billboard). Attribution is on by default but a single input turns -// it off for teams that want a bare comment. - -const ACTION_URL = "https://github.com/decionis/govern"; -const BRAND_URL = "https://decionis.com"; -const COMMENT_MARKER = ""; - -/** Map a raw verdict to display theme + shields.io color (no leading #). */ -export function verdictTheme(decision) { - const d = (decision ?? "").toLowerCase(); - if (d === "allow") return { emoji: "βœ…", label: "Allowed", color: "2ea043" }; - if (d === "block" || d === "deny" || d === "denied") - return { emoji: "πŸ›‘", label: "Blocked", color: "d1242f" }; - if (d === "escalate" || d === "review") - return { emoji: "⚠️", label: "Escalate", color: "bf8700" }; - if (d === "restrain" || d === "restrained") - return { emoji: "βœ‹", label: "Restrained", color: "9a6700" }; - return { emoji: "πŸ›‘οΈ", label: decision || "Unknown", color: "6D28D9" }; -} - -/** Verdict shield image URL β€” rendered at the top of the PR comment + summary. */ -export function verdictBadgeUrl(decision) { - const t = verdictTheme(decision); - return `https://img.shields.io/badge/Decionis-${encodeURIComponent( - t.label, - )}-${t.color}?style=for-the-badge&logo=shield&logoColor=white`; -} - -/** - * The embeddable "Governed by Decionis" badge β€” the viral artifact. Devs drop - * it into their own README; every adopting repo links back to the action. - * When a verify URL is available it links to the live signed proof. - */ -export function governedByBadgeMarkdown(linkUrl = ACTION_URL) { - const img = - "https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white"; - return `[![Governed by Decionis](${img})](${linkUrl})`; -} - -/** - * Build the PR comment body. Pure + testable. Carries a hidden marker so the - * comment is updated in place (no comment spam across pushes). - */ -export function buildPrCommentBody({ - decision, - dossierId, - verifyUrl, - policyVersion, - reasonCode, - runMode, - failOn, - actionLabel = "", - showAttribution = true, -}) { - const t = verdictTheme(decision); - const heading = actionLabel - ? `### ${t.emoji} Action gate Β· \`${actionLabel}\` β€” ${t.label}` - : `### ${t.emoji} Governed step β€” ${t.label}`; - const rows = [ - `| **Verdict** | \`${decision || "unknown"}\` |`, - policyVersion ? `| **Policy** | \`${policyVersion}\` |` : "", - reasonCode ? `| **Reason** | \`${reasonCode}\` |` : "", - dossierId ? `| **Dossier** | \`${dossierId}\` |` : "", - ] - .filter(Boolean) - .join("\n"); - - const modeNote = - runMode === "shadow" - ? "> 🟣 **Shadow mode** β€” recorded for review only. This check never fails your build." - : `> Enforcing β€” this check fails the run on \`${failOn}\`.`; - - const lines = [ - COMMENT_MARKER, - `Decionis verdict: ${t.label}`, - "", - heading, - "", - "| | |", - "| --- | --- |", - rows, - "", - verifyUrl - ? `**[πŸ”Ž Verify this decision β†’](${verifyUrl})** β€” signed, tamper-evident proof.` - : "", - "", - modeNote, - ]; - - if (showAttribution) { - lines.push( - "", - "---", - `πŸ›‘οΈ Governed by Decionis β€” ` + - `a runtime guardrail for autonomous AI agents & CI/CD: gate deploys, migrations & ` + - `infra changes before they execute. ` + - `Start in shadow mode in 30s β†’ ` + - `(decionis/govern)`, - ); - } - - return lines.join("\n"); -} - -const GH_HEADERS = (token) => ({ - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", -}); - -/** Find a previously-posted Decionis comment on the PR (by hidden marker). */ -async function findDecionisCommentId(repoFullName, prNumber, githubToken) { - try { - const res = await fetch( - `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments?per_page=100`, - { headers: GH_HEADERS(githubToken) }, - ); - if (!res.ok) return null; - const comments = await res.json(); - if (!Array.isArray(comments)) return null; - const existing = comments.find( - (c) => typeof c?.body === "string" && c.body.includes(COMMENT_MARKER), - ); - return existing?.id ?? null; - } catch { - return null; - } -} - -/** - * Upsert the PR comment: update our existing comment in place if present, - * otherwise create one. Sticky-by-marker so re-runs never spam the thread β€” - * a noisy bot is an uninstalled bot. - */ -async function upsertPrComment(repoFullName, prNumber, githubToken, body) { - if (!repoFullName || !prNumber || !githubToken) return false; - try { - const existingId = await findDecionisCommentId(repoFullName, prNumber, githubToken); - const url = existingId - ? `https://api.github.com/repos/${repoFullName}/issues/comments/${existingId}` - : `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments`; - const res = await fetch(url, { - method: existingId ? "PATCH" : "POST", - headers: GH_HEADERS(githubToken), - body: JSON.stringify({ body }), - }); - return res.ok; - } catch { - return false; - } -} - -async function maybeCommentPr({ - enabled, - decision, - dossierId, - verifyUrl, - policyVersion, - reasonCode, - runMode, - failOn, - actionLabel, - showAttribution, -}) { - if (!enabled) return; - if (process.env.GITHUB_EVENT_NAME !== "pull_request") return; - const repoFullName = process.env.GITHUB_REPOSITORY; - const eventPath = process.env.GITHUB_EVENT_PATH; - const githubToken = process.env.GITHUB_TOKEN; - if (!repoFullName || !eventPath || !githubToken) return; - let prNumber = null; - try { - const event = JSON.parse(await readFile(eventPath, "utf8")); - prNumber = event?.pull_request?.number ?? event?.number ?? null; - } catch { - return; - } - if (!prNumber) return; - const body = buildPrCommentBody({ - decision, - dossierId, - verifyUrl, - policyVersion, - reasonCode, - runMode, - failOn, - actionLabel, - showAttribution, - }); - await upsertPrComment(repoFullName, prNumber, githubToken, body); -} - -async function main() { - const timeline = createTimeline(); - const actionLabel = getInput("action").trim(); - const failOnRaw = (getInput("fail-on") || "block").trim().toLowerCase(); - const failOn = FAIL_MODES.has(failOnRaw) ? failOnRaw : "block"; - const runModeRaw = (getInput("mode") || "enforce").trim().toLowerCase(); - const runMode = RUN_MODES.has(runModeRaw) ? runModeRaw : "enforce"; - const localEvalRaw = (getInput("local-eval") || DEFAULT_LOCAL_EVAL).trim().toLowerCase(); - const localEval = LOCAL_EVAL_MODES.has(localEvalRaw) ? localEvalRaw : DEFAULT_LOCAL_EVAL; - const commentPr = getBooleanInput("comment-pr", false); - const showAttribution = getBooleanInput("show-attribution", true); - const runCommand = getInput("run"); - const shell = getInput("shell") || "bash"; - const requestGrant = getBooleanInput("request-grant", false); - const grantAudience = getInput("grant-audience").trim(); - const apiBaseUrl = (getInput("api-base-url") || "https://api.decionis.com").replace(/\/$/, ""); - const siteBaseUrl = (getInput("site-base-url") || "https://decionis.com").replace(/\/$/, ""); - const timeoutMs = Number(getInput("request-timeout-ms") || "20000") || 20000; - - // ── Shadow credential grace ───────────────────────────────────────────── - // In shadow mode a missing api-key/org-id/workflow-key must not fail the - // step: installer-injected shadow steps stay inert until secrets exist. - let apiKey = ""; - let orgId = ""; - let workflowKey = ""; - try { - apiKey = sanitizeCredential(getInput("api-key", { required: true })); - orgId = sanitizeCredential(getInput("org-id", { required: true })); - workflowKey = sanitizeCredential(getInput("workflow-key", { required: true })); - } catch (err) { - if (runMode !== "shadow") throw err; - logNotice( - `Decionis shadow mode β€” ${err instanceof Error ? err.message : err}. ` + - "The gate is not configured yet; nothing is recorded and shadow never fails the step.", - ); - if (runCommand) { - await setOutput("executed", "true"); - process.exit(await executeCommand(runCommand, shell)); - } - process.exit(0); - } - - const payload = applyActionLabel(resolvePayload(getInput("payload")), actionLabel); - - // DECIONIS_POLICY.md convention: read the repo-local policy file (default - // `DECIONIS_POLICY.md` at the workspace root) and inject it into the decision - // so the gate evaluates with β€” and the dossier records β€” your repo's policy. - // A YAML file is also accepted: with the default path, `.yaml`/`.yml` - // siblings are tried too. Set `policy-file: ""` to disable. Missing/unreadable - // file never fails the gate. - const policyFilePath = getInput("policy-file").trim(); - const policyEnforce = getBooleanInput("policy-enforce", false); - const policyCandidates = - policyFilePath === "DECIONIS_POLICY.md" - ? ["DECIONIS_POLICY.md", "DECIONIS_POLICY.yaml", "DECIONIS_POLICY.yml"] - : policyFilePath - ? [policyFilePath] - : []; - let policySource = null; - for (const candidate of policyCandidates) { - policySource = await loadPolicyFile(candidate, { ref: process.env.GITHUB_SHA ?? null }); - if (policySource) break; - } - if (policySource) { - // Opt-in GitOps enforcement: compiles the file's structured `decionis` - // rules block into an ACTIVE enforced policy bundle server-side. - if (policyEnforce) policySource.enforce = true; - payload.decionis_policy = policySource; - await setOutput("policy-sha256", policySource.sha256); - await setOutput("policy-path", policySource.path ?? ""); - await setOutput("policy-enforced", policyEnforce ? "true" : "false"); - logGroup( - "Decionis policy file", - `path=${policySource.path} sha256=${policySource.sha256} bytes=${policySource.bytes}${policySource.truncated ? " (referenced by hash; over inline limit)" : ""}`, - ); - } - - // ── Local policy engine ───────────────────────────────────────────────── - // Evaluate the committed ```decionis rules block in-process (microseconds). - // The engine mirrors the server evaluator and yields a deterministic - // verdict only for an explicitly matched allow/block rule; everything else - // reports a fallback reason and defers to the API. - let localOutcome = null; - if (localEval !== "off" && policySource) { - if (policySource.truncated) { - logNotice("Decionis local engine skipped β€” the policy file exceeds the inline size limit."); - } else if (/\.ya?ml$/i.test(policySource.path ?? "")) { - logNotice("Decionis local engine skipped β€” YAML policy files are evaluated by the API."); - } else { - localOutcome = evaluateLocalPolicy(policySource.content, { payload, orgId, workflowKey }); - timeline.mark( - "local eval", - `${localOutcome.status}${localOutcome.outcome ? ` '${localOutcome.outcome}'` : ""} in ${localOutcome.elapsedUs}Β΅s`, - ); - logGroup( - "Decionis local policy engine", - [ - `status=${localOutcome.status} outcome=${localOutcome.outcome ?? "β€”"} deterministic=${localOutcome.deterministic}`, - `rules=${localOutcome.ruleCount} elapsed=${localOutcome.elapsedUs}Β΅s local-eval=${localEval}`, - localOutcome.matchedRule - ? `rule: #${localOutcome.matchedRule.index + 1} "${localOutcome.matchedRule.name}" β†’ ${localOutcome.matchedRule.action}` - : "", - localOutcome.fallbackReason ? `fallback: ${localOutcome.fallbackReason}` : "", - localOutcome.explanation ? `why: ${localOutcome.explanation}` : "", - ] - .filter(Boolean) - .join("\n"), - ); - } - } - - const plan = planEvaluation({ - localOutcome, - localEval, - requestGrant, - hasRunCommand: Boolean(runCommand), - }); - if (plan.act === "local") { - logNotice( - `⚑ Decionis local verdict '${localOutcome.outcome}' via rule "${localOutcome.matchedRule.name}" ` + - `in ${(localOutcome.elapsedUs / 1000).toFixed(2)}ms β€” API roundtrip ` + - `${plan.api === "none" ? "skipped (local-eval: strict)" : "moved off the critical path"}.`, - ); - } - - const requestBody = { - org_id: orgId, - workflow_key: workflowKey, - payload, - mode: runMode === "shadow" ? "SHADOW" : "ENFORCE", - source: "github_actions", - }; - - // ── Reporting state ────────────────────────────────────────────────────── - // Single-writer discipline: finalizeReport() is the only place that writes - // decision outputs, the (append-only) step summary, and the PR comment, and - // it runs exactly once per run β€” background work only mutates `report`. - const report = { - decision: "", - source: "", - dossierId: "", - signature: null, - policyVersion: null, - reasonCode: null, - mismatch: false, - finalized: false, - }; - if (plan.act === "local") { - report.decision = localOutcome.outcome; - report.source = "local"; - } - - const abortController = new AbortController(); - - const callApi = async (boundMs = timeoutMs) => { - logGroup( - "Decionis evaluate-decision request", - JSON.stringify( - { - url: `${apiBaseUrl}/v1/protocol/evaluate-decision`, - org_id: orgId, - workflow_key: workflowKey, - mode: requestBody.mode, - fail_on: failOn, - local_eval: localEval, - evaluation_plan: `${plan.act}/${plan.api}`, - payload_keys: Object.keys(payload), - }, - null, - 2, - ), - ); - const startedAt = performance.now(); - const response = await evaluateDecision({ - apiBaseUrl, - apiKey, - body: requestBody, - timeoutMs: boundMs, - signal: abortController.signal, - }); - if (!response.ok) { - const error = new Error(`Decionis evaluate-decision failed (${response.status})`); - // @ts-ignore β€” carried for the enforce-path error log - error.status = response.status; - // @ts-ignore - error.bodyText = response.bodyText; - throw error; - } - const parsed = parseDecisionResponse(response.data); - timeline.mark("API verdict", `'${parsed.decision}' in ${Math.round(performance.now() - startedAt)}ms`); - return parsed; - }; - - const adoptApiVerdict = (parsed, { authoritative }) => { - report.dossierId = parsed.dossierId; - report.signature = parsed.signature; - report.policyVersion = parsed.policyVersion; - report.reasonCode = parsed.reasonCode; - if (authoritative) { - report.decision = parsed.decision; - report.source = "api"; - } - if (localOutcome?.deterministic && parsed.decision && parsed.decision !== localOutcome.outcome) { - report.mismatch = true; - logWarning( - `Decionis verdict mismatch β€” local rule verdict '${localOutcome.outcome}'${authoritative ? "" : " (already acted on)"} ` + - `vs API verdict '${parsed.decision}'. The signed dossier records the API verdict; ` + - "review org-level policy or set local-eval: off for this workflow.", - ); - } - }; - - const finalizeReport = async () => { - if (report.finalized) return; - report.finalized = true; - const verifyUrl = report.dossierId - ? buildVerifyUrl(siteBaseUrl, report.dossierId, report.signature, { - policySha256: policySource?.sha256, - }) - : ""; - const badgeMarkdown = governedByBadgeMarkdown(verifyUrl || ACTION_URL); - await Promise.all([ - setOutput("decision", report.decision), - setOutput("decision-source", report.source), - setOutput("dossier-id", report.dossierId), - setOutput("verify-url", verifyUrl), - setOutput("policy-version", report.policyVersion ?? ""), - setOutput("reason-code", report.reasonCode ?? ""), - setOutput("badge-markdown", badgeMarkdown), - setOutput("verdict-mismatch", report.mismatch ? "true" : "false"), - ]); - - const theme = verdictTheme(report.decision); - await writeSummary( - [ - `## ${theme.emoji} Decionis Action Gate${actionLabel ? ` Β· \`${actionLabel}\`` : ""}: ${theme.label}`, - "", - `Decionis verdict: ${theme.label}`, - "", - "| | |", - "| --- | --- |", - `| **Verdict** | \`${report.decision || "unknown"}\` |`, - report.source === "local" - ? `| **Source** | ⚑ local rule "${localOutcome.matchedRule.name}" in ${localOutcome.elapsedUs}Β΅s |` - : report.source - ? `| **Source** | \`${report.source}\` |` - : "", - `| **Policy** | \`${report.policyVersion ?? "β€”"}\` |`, - report.reasonCode ? `| **Reason** | \`${report.reasonCode}\` |` : "", - report.dossierId ? `| **Dossier** | \`${report.dossierId}\` |` : "", - `| **Mode** | \`${runMode}\`${runMode === "shadow" ? " β€” never fails the build" : ` Β· fail-on \`${failOn}\``} |`, - "", - report.mismatch - ? "> ⚠️ The API verdict differed from the local rule verdict (`verdict-mismatch=true`) β€” review org-level policy." - : "", - verifyUrl - ? `**[πŸ”Ž Verify this decision β†’](${verifyUrl})** β€” signed, tamper-evident proof${policySource ? ", pinned to this repo's policy revision" : ""}.` - : "", - "", - "
πŸ“Œ Add the β€œGoverned by Decionis” badge to your README", - "", - "```markdown", - governedByBadgeMarkdown(), - "```", - "", - `Gate your own deploys, releases, and infra changes β†’ [**decionis/govern**](${ACTION_URL})`, - "
", - ] - .filter(Boolean) - .join("\n"), - ); - - if (report.decision) { - logNotice( - `Decionis verdict: ${report.decision} (${report.source}${report.dossierId ? `, dossier ${report.dossierId}` : ""})`, - ); - } - - await maybeCommentPr({ - enabled: commentPr, - decision: report.decision, - dossierId: report.dossierId, - verifyUrl, - policyVersion: report.policyVersion, - reasonCode: report.reasonCode, - runMode, - failOn, - actionLabel, - showAttribution, - }); - }; - - // ── Branch A: speculative shadow ───────────────────────────────────────── - // The verdict can never change behavior in shadow, so the command starts - // IMMEDIATELY and the whole evaluation pipeline runs in the background. - // The step's exit code is exactly the command's exit code β€” evaluation - // failures are notices, never errors. - if (runMode === "shadow" && runCommand && !requestGrant) { - const { exited } = startCommand(runCommand, shell, {}); - timeline.mark("command started"); - logNotice( - "🟣 Decionis shadow mode β€” command started immediately; the verdict resolves in the background.", - ); - await setOutput("executed", "true"); - const pipelineStart = performance.now(); - const pipeline = swallow( - "shadow evaluation", - (async () => { - if (plan.act === "local" && plan.api === "none") return; // strict: fully offline - adoptApiVerdict(await callApi(), { authoritative: true }); - })(), - ); - const code = await exited; - timeline.mark("command exited", `code ${code}`); - const graceMs = computeGraceMs(pipelineStart, performance.now(), timeoutMs); - const pending = await Promise.race([ - pipeline.then(() => false), - sleep(graceMs).then(() => true), - ]); - if (pending) { - abortController.abort(); - logNotice( - `Decionis shadow verdict still pending after the ${Math.round(graceMs)}ms grace window β€” completing with the command's exit code.`, - ); - } - await finalizeReport(); - logNotice(timeline.render()); - process.exit(code); - } - - // ── Branch B: shadow, verdict-only ─────────────────────────────────────── - if (runMode === "shadow" && !runCommand && !requestGrant) { - await swallow( - "shadow evaluation", - (async () => { - if (plan.act === "local" && plan.api === "none") return; - adoptApiVerdict(await callApi(), { authoritative: true }); - })(), - ); - await finalizeReport(); - logNotice(timeline.render()); - return; // shadow never fails β€” not even on API errors - } - - // ── Branch C/D: enforce, gated by a deterministic local verdict ────────── - if (plan.act === "local" && runMode === "enforce") { - if (localOutcome.outcome === "block") { - // Best-effort dossier recording, tightly bounded: nothing is waiting to - // run, but the block should still leave a signed audit trail. - if (plan.api === "bounded") { - const parsed = await swallow( - "dossier recording", - callApi(Math.min(timeoutMs, BLOCK_RECORD_CAP_MS)), - ); - if (parsed) adoptApiVerdict(parsed, { authoritative: false }); - } - await finalizeReport(); - logNotice(timeline.render()); - if (runCommand) { - await setOutput("executed", "false"); - logError( - `Decionis BLOCKED execution locally (rule "${localOutcome.matchedRule.name}", mode=${runMode}). ` + - `The gated command was NOT run.${report.dossierId ? ` Dossier: ${report.dossierId}` : ""}`, - ); - process.exit(1); - } - if (shouldFail("block", failOn, runMode)) { - logError( - `Decionis blocked this step locally (verdict=block, rule "${localOutcome.matchedRule.name}", fail-on=${failOn}).`, - ); - process.exit(1); - } - return; - } - - // Local allow. - if (runCommand) { - logNotice("Decionis authorized execution locally β€” running the gated command."); - if (plan.api === "none") { - await finalizeReport(); - await setOutput("executed", "true"); - timeline.mark("command started"); - const code = await executeCommand(runCommand, shell, {}); - timeline.mark("command exited", `code ${code}`); - logNotice(timeline.render()); - process.exit(code); - } - // auto: start the command now, notarize while it runs. - const { exited } = startCommand(runCommand, shell, {}); - timeline.mark("command started"); - await setOutput("executed", "true"); - const pipelineStart = performance.now(); - const pipeline = swallow( - "notarization", - (async () => { - adoptApiVerdict(await callApi(), { authoritative: false }); - })(), - ); - const code = await exited; - timeline.mark("command exited", `code ${code}`); - const graceMs = computeGraceMs(pipelineStart, performance.now(), timeoutMs); - const pending = await Promise.race([ - pipeline.then(() => false), - sleep(graceMs).then(() => true), - ]); - if (pending) { - abortController.abort(); - logNotice( - `Decionis notarization still pending after the ${Math.round(graceMs)}ms grace window β€” completing with the command's exit code.`, - ); - } - await finalizeReport(); - logNotice(timeline.render()); - process.exit(code); - } - - // Local allow, verdict-only: notarize with a bounded wait, never fail. - if (plan.api !== "none") { - const parsed = await swallow("notarization", callApi()); - if (parsed) adoptApiVerdict(parsed, { authoritative: false }); - } - await finalizeReport(); - logNotice(timeline.render()); - return; // an allow verdict never fails the step - } - - // ── Branch E: blocking API path (v1.8 semantics) ───────────────────────── - // Reached on: local-eval off, no/indeterminate local verdict, matched - // escalate/restrain, request-grant, or YAML/truncated/absent policy files. - let apiVerdict; - try { - apiVerdict = await callApi(); - } catch (err) { - if (runMode === "shadow") { - logNotice( - `Decionis shadow mode β€” evaluation unavailable (${err instanceof Error ? err.message : err}); shadow never fails the step.`, - ); - await finalizeReport(); - if (runCommand) { - await setOutput("executed", "true"); - process.exit(await executeCommand(runCommand, shell)); - } - return; - } - // @ts-ignore β€” status/bodyText attached by callApi for HTTP failures - if (err?.status) logError(`Decionis API returned ${err.status}: ${String(err.bodyText ?? "").slice(0, 600)}`); - throw err; - } - adoptApiVerdict(apiVerdict, { authoritative: true }); - await finalizeReport(); - - // ── Execution Grant (Governor Layer) ──────────────────────────────────── - // On an authorizing verdict, request a short-lived signed grant. Targets - // verify it before acting, so execution can't proceed without a Decionis - // verdict even if the workflow gate is bypassed. - const grantEnv = {}; - if (requestGrant && report.decision === "allow" && report.dossierId) { - const grant = await fetchExecutionGrant({ - apiBaseUrl, - apiKey, - timeoutMs, - logNotice, - body: buildGrantRequestBody({ - orgId, - dossierId: report.dossierId, - decision: report.decision, - action: actionLabel, - audience: grantAudience, - }), - }); - if (grant?.execution_grant) { - await setOutput("execution-grant", grant.execution_grant); - await setOutput("grant-expires-at", grant.expires_at ?? ""); - grantEnv.DECIONIS_EXECUTION_GRANT = grant.execution_grant; - grantEnv.DECIONIS_GRANT_VERIFY_URL = `${apiBaseUrl}/v1${grant.jwks_url ?? "/.well-known/decionis-execution-grant-jwks.json"}`; - logNotice(`Execution grant issued (expires ${grant.expires_at ?? "soon"}).`); - } - } - - // ── Enforcing path ────────────────────────────────────────────────────── - // When a `run` command is supplied, Decionis OWNS the execution: the command - // runs through this Action, so it cannot execute without an authorizing - // verdict. There is no skippable `if:` to delete. The grant (if any) is in - // the command's env as DECIONIS_EXECUTION_GRANT for the target to verify. - if (runCommand) { - const authorized = shouldExecute(report.decision, runMode); - await setOutput("executed", authorized ? "true" : "false"); - if (!authorized) { - logError( - `Decionis BLOCKED execution (verdict=${report.decision}, mode=${runMode}). The gated command was NOT run. Dossier: ${report.dossierId}`, - ); - process.exit(1); - } - logNotice( - runMode === "shadow" - ? "Decionis shadow mode β€” running the gated command (observe-only)." - : "Decionis authorized execution β€” running the gated command.", - ); - timeline.mark("command started"); - const code = await executeCommand(runCommand, shell, grantEnv); - timeline.mark("command exited", `code ${code}`); - logNotice(timeline.render()); - process.exit(code); - } - - // ── Advisory path (no `run`) ──────────────────────────────────────────── - // Verdict-only: sets outputs for a downstream `if:`. Note this guard is - // advisory and can be removed β€” prefer the `run` wrapper above to enforce. - if (shouldFail(report.decision, failOn, runMode)) { - logError( - `Decionis blocked this step (verdict=${report.decision}, fail-on=${failOn}, mode=${runMode}). Dossier: ${report.dossierId}`, - ); - process.exit(1); - } - logNotice(timeline.render()); -} - -const isMain = (() => { - if (!process.argv[1]) return false; - try { - return import.meta.url === new URL(process.argv[1], "file://").href; - } catch { - return false; - } -})(); - -if (isMain) { - main().catch((err) => { - logError(`Decionis Action failed: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - }); -} diff --git a/src/policy-engine.mjs b/src/policy-engine.mjs deleted file mode 100644 index cd712aa..0000000 --- a/src/policy-engine.mjs +++ /dev/null @@ -1,646 +0,0 @@ -// @ts-check -// Decionis local policy engine β€” parses the fenced ```decionis rules block of -// a DECIONIS_POLICY.md file and evaluates it against a decision payload -// entirely in-process (microseconds, no network). -// -// HOST-AGNOSTIC BY DESIGN: no process.env, no fs, no network, no node:crypto β€” -// pure functions over strings and plain objects, so the same module can serve -// the GitHub Action, the platform MCP server, or a future WASM build. -// -// FAITHFUL MIRROR: extraction, validation, fact building, field resolution, -// operator semantics (including value coercion), rule ordering, and -// first-match selection replicate the platform's compiler -// (apps/api/src/services/policyEncoding/decionisRulesBlock.ts), request -// normalizer (apps/api/src/services/protocolAdapter/normalizeEvaluateBody.ts), -// and evaluator (apps/protocol/src/app.ts β€” buildEvaluateDecisionPolicyFacts / -// resolveEvaluateDecisionPolicyFactValue / evaluateDecisionPolicyPredicate / -// evaluateDecisionPolicyGraph). Divergence here is a bug: when in doubt this -// engine must return a fallback, never a guess. -// -// SAFETY MODEL: a deterministic verdict is returned ONLY when an explicitly -// committed allow/block rule matches using facts that are fully knowable -// client-side. Facts only the server can know (decision domain taxonomy, -// rollout-effective mode, selected bundle metadata) are marked UNKNOWN; -// any rule whose outcome could hinge on them halts the scan and defers to -// the API. Rules that definitively do NOT match are skipped regardless β€” -// a non-matching rule contributes nothing on either side. - -/** Operators accepted by the platform compiler (OP_MAP keys, lowercase). */ -export const SUPPORTED_OPS = [ - "eq", - "ne", - "neq", - "gt", - "gte", - "lt", - "lte", - "in", - "not_in", - "exists", - "contains", - "matches", -]; - -/** Sentinel for facts the server derives from state we cannot see client-side. */ -export const UNKNOWN_FACT = Object.freeze({ __decionis_unknown_fact__: true }); - -const OP_MAP = { - eq: "EQ", - ne: "NEQ", - neq: "NEQ", - gt: "GT", - gte: "GTE", - lt: "LT", - lte: "LTE", - in: "IN", - not_in: "NOT_IN", - exists: "EXISTS", - contains: "CONTAINS", - matches: "MATCHES", -}; -const VALUELESS_OPS = new Set(["EXISTS"]); -const VALUES_OPS = new Set(["IN", "NOT_IN"]); -const ACTIONS = new Set(["allow", "block", "restrain", "escalate"]); - -const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v); -/** Mirror of the server's asRecord: non-objects coerce to an empty record. */ -const asRecord = (v) => (isPlainObject(v) ? v : {}); - -// ───────────────────────────── extraction ───────────────────────────── - -/** - * Extract the raw text inside the first ```decionis fenced block. Mirrors the - * platform compiler's extractDecionisRulesBlock verbatim (same regex): the - * FIRST block wins, the info string may carry an optional json/yaml tag, and - * a missing/empty block means "nothing to enforce". - * - * @param {string} markdown - * @returns {string | null} - */ -export function extractDecionisBlock(markdown) { - if (typeof markdown !== "string") return null; - const re = /```decionis(?:\s+(?:json|ya?ml))?[ \t]*\r?\n([\s\S]*?)\r?\n```/i; - const m = re.exec(markdown); - return m ? (m[1] ?? "").trim() || null : null; -} - -// ───────────────────────────── compilation ───────────────────────────── - -/** - * Parse + validate a rules block exactly like the platform compiler - * (compileDecionisRules): all-or-nothing, unknown extra keys ignored, - * `priority` / `domain` / `rationale` honored, `version` ignored entirely. - * The one client-side difference: the server parses the block with a YAML 1.2 - * reader (JSON superset). We are zero-dependency, so a block that is not - * valid JSON yields `{ok:false, reason:"not_json"}` β€” the caller must fall - * back to the API (which may still compile it as YAML), never reject it. - * - * @param {string} blockText - * @param {{workflowKey?: string | null}} [opts] - * @returns {{ok: true, rules: Array} | {ok: false, reason: string, errors?: string[]}} - * - * CompiledRule = { name: string, quantifier: "all"|"any", - * predicates: Array<{field: string, operator: string, value?: any, values?: any[]}>, - * action: "allow"|"block"|"restrain"|"escalate", - * domain: string, priority: number, originalIndex: number } - */ -export function parsePolicy(blockText, opts = {}) { - let parsed; - try { - parsed = JSON.parse(blockText); - } catch { - return { ok: false, reason: "not_json" }; - } - - const errors = []; - if (!isPlainObject(parsed)) { - return { ok: false, reason: "compile_error", errors: ["decionis block must be a JSON object"] }; - } - const rawRules = parsed.rules; - if (!Array.isArray(rawRules) || rawRules.length === 0) { - return { - ok: false, - reason: "compile_error", - errors: ['decionis block needs a non-empty "rules" array'], - }; - } - - const domainDefault = (opts.workflowKey ?? "").trim() || "github_action"; - const rules = []; - - rawRules.forEach((raw, i) => { - const path = `rules[${i}]`; - if (!isPlainObject(raw)) { - errors.push(`${path}: must be an object`); - return; - } - const r = raw; - const name = typeof r.name === "string" && r.name.trim() ? r.name.trim() : ""; - if (!name) errors.push(`${path}: needs a non-empty "name"`); - - const actionRaw = typeof r.action === "string" ? r.action.trim().toLowerCase() : ""; - const action = ACTIONS.has(actionRaw) ? actionRaw : null; - if (!action) { - errors.push( - `${path}: unknown action "${r.action}" (allowed: allow, block, restrain, escalate)`, - ); - } - - const hasAll = Array.isArray(r.all) && r.all.length > 0; - const hasAny = Array.isArray(r.any) && r.any.length > 0; - if (hasAll === hasAny) { - errors.push(`${path}: provide exactly one of "all" or "any" with at least one condition`); - } - - const groupKey = hasAll ? "all" : "any"; - const rawPredicates = hasAll ? r.all : r.any; - const predicates = []; - (Array.isArray(rawPredicates) ? rawPredicates : []).forEach((pred, j) => { - const compiled = compilePredicate(pred, `${path}.${groupKey}[${j}]`, errors); - if (compiled) predicates.push(compiled); - }); - - if (!name || !action || hasAll === hasAny || predicates.length === 0) return; - - const priority = - typeof r.priority === "number" && Number.isFinite(r.priority) - ? Math.max(0, Math.min(100000, Math.round(r.priority))) - : i; - - rules.push({ - name, - quantifier: groupKey, - predicates, - action, - domain: typeof r.domain === "string" && r.domain.trim() ? r.domain.trim() : domainDefault, - priority, - originalIndex: i, - }); - }); - - if (errors.length > 0) return { ok: false, reason: "compile_error", errors }; - return { ok: true, rules }; -} - -/** Mirror of the compiler's compilePredicate. Errors accumulate; null on failure. */ -function compilePredicate(raw, path, errors) { - if (!isPlainObject(raw)) { - errors.push(`${path}: predicate must be an object`); - return null; - } - const field = typeof raw.field === "string" ? raw.field.trim() : ""; - if (!field) { - errors.push(`${path}: predicate needs a non-empty "field"`); - return null; - } - const opRaw = typeof raw.op === "string" ? raw.op.trim().toLowerCase() : ""; - const operator = OP_MAP[opRaw]; - if (!operator) { - errors.push(`${path}: unknown operator "${raw.op}" (allowed: ${Object.keys(OP_MAP).join(", ")})`); - return null; - } - const out = { field, operator }; - if (VALUELESS_OPS.has(operator)) return out; // EXISTS: any provided value is dropped, as on the server - if (VALUES_OPS.has(operator)) { - if (!Array.isArray(raw.values) || raw.values.length === 0) { - errors.push(`${path}: operator "${opRaw}" requires a non-empty "values" array`); - return null; - } - out.values = raw.values; - return out; - } - if (!("value" in raw)) { - errors.push(`${path}: operator "${opRaw}" requires a "value"`); - return null; - } - out.value = raw.value; - return out; -} - -// ───────────────────────────── facts ───────────────────────────── - -/** - * Reconstruct the facts object the server evaluates rules against, for a - * decision submitted by this Action. Mirrors normalizeEvaluateDecisionBody - * (payload merges into context; decision_type derives from context.action β†’ - * workflow_key β†’ "action_gate"), the handler's context enrichment, and - * buildEvaluateDecisionPolicyFacts β€” including its quirk that explicit - * request-level keys (amount, risk_score, …) OVERWRITE same-named payload - * keys at the facts root with undefined when the Action didn't send them. - * - * Facts only the server can compute are set to UNKNOWN_FACT: - * - decision_domain (vertical-pack taxonomy resolution) - * - mode (org rollout policy may override the requested mode) - * - objective_profile / requested_policy_version (schema & pack defaults) - * - context.vertical_pack (resolved pack key) - * - policy (selected bundle id/version) - */ -export function buildEvaluationFacts(payload, { orgId, workflowKey } = {}) { - const p = isPlainObject(payload) ? payload : {}; - const context = { ...p, vertical_pack: UNKNOWN_FACT, workflow_key: workflowKey ?? null }; - const signals = asRecord(context.signals); - const override = asRecord(context.override); - const evidence = asRecord(context.evidence); - const attachments = Array.isArray(context.attachments) - ? context.attachments - : Array.isArray(evidence.attachments) - ? evidence.attachments - : []; - - // normalizeEvaluateDecisionBody: derive decision_type (Action never sends one). - const candidates = [ - typeof p.action === "string" ? p.action : null, - typeof workflowKey === "string" ? workflowKey : null, - ]; - const derived = candidates.find((c) => typeof c === "string" && c.trim() !== ""); - const decisionType = (derived ?? "action_gate").toString().trim().slice(0, 120); - - return { - ...context, - org_id: orgId, - decision_type: decisionType, - decision_domain: UNKNOWN_FACT, - amount: undefined, - risk_score: undefined, - channel: undefined, - objective_profile: UNKNOWN_FACT, - mode: UNKNOWN_FACT, - decision_band: undefined, - transaction_type: undefined, - workflow_key: workflowKey ?? undefined, - vertical_pack: undefined, - requested_policy_version: UNKNOWN_FACT, - context, - intake: context, - signals, - override, - evidence, - attachments, - attachment_count: attachments.length, - policy: UNKNOWN_FACT, - }; -} - -/** - * Resolve a field against the facts, mirroring the server's - * resolveEvaluateDecisionPolicyFactValue (exact root key first, then - * dot/bracket path traversal; arrays index numerically; non-objects coerce to - * empty records). Returns {kind:"unknown"} the moment traversal touches an - * UNKNOWN_FACT β€” that value exists server-side but is not knowable here. - * - * @returns {{kind: "value", value: any} | {kind: "unknown"}} - */ -export function resolveFactValue(facts, field) { - if (Object.prototype.hasOwnProperty.call(facts, field)) { - const v = facts[field]; - return v === UNKNOWN_FACT ? { kind: "unknown" } : { kind: "value", value: v }; - } - - const normalizedPath = String(field).replace(/\[(\d+)\]/g, ".$1"); - const segments = normalizedPath - .split(".") - .map((segment) => segment.trim()) - .filter(Boolean); - if (segments.length === 0) return { kind: "value", value: undefined }; - - let current = facts; - for (const segment of segments) { - if (current === UNKNOWN_FACT) return { kind: "unknown" }; - if (Array.isArray(current)) { - const index = Number.parseInt(segment, 10); - if (!Number.isFinite(index)) return { kind: "value", value: undefined }; - current = current[index]; - continue; - } - current = asRecord(current)[segment]; - } - return current === UNKNOWN_FACT ? { kind: "unknown" } : { kind: "value", value: current }; -} - -// ─────────────────────── value coercion (server-exact) ─────────────────────── - -/** Mirror of the server's asNumber (parseFloat semantics for strings). */ -function asNumber(value) { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number.parseFloat(value); - if (Number.isFinite(parsed)) return parsed; - } - return null; -} - -/** Mirror of parseEvaluateDecisionPolicyBoolean. */ -function asBooleanish(value) { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "true") return true; - if (normalized === "false") return false; - } - if (typeof value === "number") { - if (value === 1) return true; - if (value === 0) return false; - } - return null; -} - -/** Mirror of normalizeEvaluateDecisionPolicyText. */ -function asComparableText(value) { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed.toLowerCase() : null; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value).toLowerCase(); - } - return null; -} - -/** - * Semantic equality, mirroring areEvaluateDecisionPolicyValuesEqual: arrays - * match on any element (both sides, recursively), then numeric coercion, then - * boolean coercion, then case-insensitive text, then strict identity. - */ -export function valuesEqual(actual, expected) { - if (Array.isArray(actual)) { - return actual.some((item) => valuesEqual(item, expected)); - } - if (Array.isArray(expected)) { - return expected.some((item) => valuesEqual(actual, item)); - } - - const actualNumber = asNumber(actual); - const expectedNumber = asNumber(expected); - if (actualNumber != null && expectedNumber != null) return actualNumber === expectedNumber; - - const actualBoolean = asBooleanish(actual); - const expectedBoolean = asBooleanish(expected); - if (actualBoolean != null && expectedBoolean != null) return actualBoolean === expectedBoolean; - - const actualText = asComparableText(actual); - const expectedText = asComparableText(expected); - if (actualText != null && expectedText != null) return actualText === expectedText; - - return actual === expected; -} - -// ───────────────────────────── evaluation ───────────────────────────── - -/** - * Evaluate one compiled predicate, three-valued: `true` / `false` exactly as - * the server's evaluateDecisionPolicyPredicate would, or `"unknown"` when the - * referenced fact is not knowable client-side. - * - * @returns {boolean | "unknown"} - */ -export function evaluatePredicate(predicate, facts) { - const resolved = resolveFactValue(facts, predicate.field); - if (resolved.kind === "unknown") return "unknown"; - const actual = resolved.value; - const expectedValues = Array.isArray(predicate.values) - ? predicate.values - : Object.prototype.hasOwnProperty.call(predicate, "value") - ? [predicate.value] - : []; - - switch (predicate.operator) { - case "EQ": - return valuesEqual(actual, predicate.value); - case "NEQ": - return !valuesEqual(actual, predicate.value); - case "GT": { - const a = asNumber(actual); - const b = asNumber(predicate.value); - return a != null && b != null ? a > b : false; - } - case "GTE": { - const a = asNumber(actual); - const b = asNumber(predicate.value); - return a != null && b != null ? a >= b : false; - } - case "LT": { - const a = asNumber(actual); - const b = asNumber(predicate.value); - return a != null && b != null ? a < b : false; - } - case "LTE": { - const a = asNumber(actual); - const b = asNumber(predicate.value); - return a != null && b != null ? a <= b : false; - } - case "IN": - return expectedValues.some((expected) => valuesEqual(actual, expected)); - case "NOT_IN": - return expectedValues.every((expected) => !valuesEqual(actual, expected)); - case "EXISTS": - // Compiler drops any provided value for EXISTS, so this is pure existence. - return actual !== undefined && actual !== null; - case "CONTAINS": { - if (Array.isArray(actual)) { - return expectedValues.some((expected) => - actual.some((item) => valuesEqual(item, expected)), - ); - } - const actualText = asComparableText(actual); - if (!actualText) return false; - return expectedValues.some((expected) => { - const expectedText = asComparableText(expected); - return expectedText ? actualText.includes(expectedText) : false; - }); - } - case "MATCHES": { - const actualText = asComparableText(actual); - const pattern = typeof predicate.value === "string" ? predicate.value : null; - if (!actualText || !pattern) return false; - try { - return new RegExp(pattern, "i").test(actualText); - } catch { - return false; - } - } - default: - return false; // mirrors the server's default arm; unreachable for compiled rules - } -} - -/** - * Evaluate a rule's condition group under Kleene three-valued logic. - * `all`: any false β†’ false; any unknown (and no false) β†’ unknown; else true. - * `any`: any true β†’ true; any unknown (and no true) β†’ unknown; else false. - * - * @returns {boolean | "unknown"} - */ -export function evaluateRuleConditions(rule, facts) { - let sawUnknown = false; - if (rule.quantifier === "all") { - for (const predicate of rule.predicates) { - const r = evaluatePredicate(predicate, facts); - if (r === false) return false; - if (r === "unknown") sawUnknown = true; - } - return sawUnknown ? "unknown" : true; - } - for (const predicate of rule.predicates) { - const r = evaluatePredicate(predicate, facts); - if (r === true) return true; - if (r === "unknown") sawUnknown = true; - } - return sawUnknown ? "unknown" : false; -} - -/** - * Is this rule guaranteed to survive the server's decision-domain filter? - * The server keeps a rule when its domain is "*" / "ANY" or equals the - * resolved taxonomy domain β€” which we cannot compute client-side. So only - * wildcard domains are certain; anything else is uncertain and matters only - * if the rule would otherwise match. - */ -export function isDomainCertain(rule) { - const normalized = rule.domain.trim().toUpperCase(); - return normalized === "*" || normalized === "ANY"; -} - -function describePredicates(rule, facts) { - const parts = rule.predicates.map((p) => { - const state = evaluatePredicate(p, facts); - const rhs = Object.prototype.hasOwnProperty.call(p, "values") - ? JSON.stringify(p.values) - : JSON.stringify(p.value); - return `${p.field} ${p.operator.toLowerCase()} ${rhs ?? ""} β†’ ${state}`; - }); - return `${rule.quantifier}[ ${parts.join("; ")} ]`; -} - -/** - * First-match scan over the compiled rules in server order (priority DESC, - * original index ASC β€” mirroring sortEvaluateDecisionPolicyRules). - * - * - conditions false β†’ skip (a non-matching rule is inert on both sides) - * - conditions true + wildcard domain β†’ SELECTED - * - conditions true + non-wildcard domain β†’ indeterminate (the server may - * domain-filter this rule; we cannot know, and if kept it would win) - * - conditions unknown β†’ indeterminate (if it matched server-side it wins) - * - exhausted β†’ no_match (server outcome: REVIEW via no_matching_rule) - * - * @returns {{outcome: "allow"|"block"|"restrain"|"escalate"|"no_match"|"indeterminate", - * matchedRule: object|null, haltedRule: object|null, explanation: string}} - */ -export function evaluatePolicy(rules, facts) { - const ordered = [...rules].sort((left, right) => { - if (left.priority !== right.priority) return right.priority - left.priority; - return left.originalIndex - right.originalIndex; - }); - - for (const rule of ordered) { - const conditions = evaluateRuleConditions(rule, facts); - if (conditions === false) continue; - const label = `rule #${rule.originalIndex + 1} "${rule.name}"`; - if (conditions === "unknown") { - return { - outcome: "indeterminate", - matchedRule: null, - haltedRule: rule, - explanation: `${label} depends on server-side facts: ${describePredicates(rule, facts)}`, - }; - } - if (!isDomainCertain(rule)) { - return { - outcome: "indeterminate", - matchedRule: null, - haltedRule: rule, - explanation: - `${label} matches but its domain "${rule.domain}" is not "*" β€” the server's ` + - `domain filter may include or exclude it, so the verdict is not locally decidable`, - }; - } - return { - outcome: rule.action, - matchedRule: rule, - haltedRule: null, - explanation: `${label} matched: ${describePredicates(rule, facts)}`, - }; - } - return { - outcome: "no_match", - matchedRule: null, - haltedRule: null, - explanation: "no rule matched (server would resolve no_matching_rule β†’ REVIEW)", - }; -} - -// ───────────────────────────── facade ───────────────────────────── - -/** - * One-call facade: policy file content + decision inputs β†’ local evaluation. - * - * `deterministic` is true ONLY for an allow/block from an explicitly matched, - * wildcard-domain committed rule evaluated over fully client-knowable facts. - * A matched escalate/restrain is reported as a fallback with the outcome - * attached (a prediction for the API path to confirm). - * - * @param {string} content Raw DECIONIS_POLICY.md text. - * @param {{payload?: object, orgId?: string, workflowKey?: string}} [options] - * @returns {{status: "verdict"|"fallback", outcome: string|null, deterministic: boolean, - * fallbackReason: string|null, matchedRule: {index: number, name: string, action: string}|null, - * explanation: string, ruleCount: number, elapsedUs: number}} - */ -export function evaluateLocalPolicy(content, { payload, orgId, workflowKey } = {}) { - const t0 = performance.now(); - const done = (partial) => ({ - matchedRule: null, - explanation: "", - ruleCount: partial.ruleCount ?? 0, - ...partial, - elapsedUs: Math.max(1, Math.round((performance.now() - t0) * 1000)), - }); - const fallback = (reason, extra = {}) => - done({ - status: "fallback", - outcome: null, - deterministic: false, - fallbackReason: reason, - ...extra, - }); - - const blockText = extractDecionisBlock(content); - if (blockText === null) return fallback("no_rules_block"); - const compiled = parsePolicy(blockText, { workflowKey }); - if (!compiled.ok) { - const detail = compiled.errors?.[0] ? `:${compiled.errors[0]}` : ""; - return fallback(`${compiled.reason}${detail}`); - } - const facts = buildEvaluationFacts(payload, { orgId, workflowKey }); - const result = evaluatePolicy(compiled.rules, facts); - const ruleCount = compiled.rules.length; - - if (result.outcome === "indeterminate") { - return fallback(`rule_indeterminate:${result.haltedRule.name}`, { - explanation: result.explanation, - ruleCount, - }); - } - if (result.outcome === "no_match") return fallback("no_match", { ruleCount }); - - const matchedRule = { - index: result.matchedRule.originalIndex, - name: result.matchedRule.name, - action: result.matchedRule.action, - }; - if (result.outcome === "escalate" || result.outcome === "restrain") { - return fallback(`outcome_not_local:${result.outcome}`, { - outcome: result.outcome, - matchedRule, - explanation: result.explanation, - ruleCount, - }); - } - return done({ - status: "verdict", - outcome: result.outcome, - deterministic: true, - fallbackReason: null, - matchedRule, - explanation: result.explanation, - ruleCount, - }); -} diff --git a/test/helpers.test.mjs b/test/helpers.test.mjs deleted file mode 100644 index aa9a544..0000000 --- a/test/helpers.test.mjs +++ /dev/null @@ -1,310 +0,0 @@ -import { afterEach, describe, it } from "node:test"; -import assert from "node:assert/strict"; - -import { - applyActionLabel, - buildGrantRequestBody, - buildPolicySource, - buildPrCommentBody, - buildVerifyUrl, - governedByBadgeMarkdown, - loadPolicyFile, - resolvePayload, - shouldExecute, - shouldFail, - verdictBadgeUrl, - verdictTheme, -} from "../src/index.mjs"; - -describe("buildPolicySource", () => { - it("hashes content; the sha256 is the version handle", () => { - const a = buildPolicySource("# Policy\nblock prod on fridays\n", { path: "DECIONIS_POLICY.md" }); - assert.equal(a.type, "decionis_policy_md"); - assert.equal(a.path, "DECIONIS_POLICY.md"); - assert.match(a.sha256, /^[0-9a-f]{64}$/); - assert.equal(a.truncated, false); - assert.equal(a.content, "# Policy\nblock prod on fridays\n"); - const same = buildPolicySource("# Policy\nblock prod on fridays\n", {}); - const changed = buildPolicySource("# Policy\nblock prod always\n", {}); - assert.equal(a.sha256, same.sha256); - assert.notEqual(a.sha256, changed.sha256); - }); - - it("references oversized content by hash only, never silently drops it", () => { - const big = "x".repeat(200000); - const s = buildPolicySource(big, { path: "DECIONIS_POLICY.md" }); - assert.equal(s.truncated, true); - assert.equal("content" in s, false); - assert.equal(s.bytes, 200000); - assert.match(s.sha256, /^[0-9a-f]{64}$/); - }); - - it("carries the git ref when provided", () => { - const s = buildPolicySource("policy", { path: "p.md", ref: "abc123" }); - assert.equal(s.ref, "abc123"); - }); -}); - -describe("loadPolicyFile", () => { - it("returns null when the path is empty (disabled)", async () => { - assert.equal(await loadPolicyFile("", { workspace: "/tmp" }), null); - }); - it("returns null (never throws) when the file is absent", async () => { - const r = await loadPolicyFile("DECIONIS_POLICY.md", { - workspace: "/nonexistent-decionis-workspace-xyz", - }); - assert.equal(r, null); - }); -}); - -describe("shouldFail", () => { - it("fails on block when fail-on=block (default)", () => { - assert.equal(shouldFail("block", "block", "enforce"), true); - assert.equal(shouldFail("deny", "block", "enforce"), true); - assert.equal(shouldFail("denied", "block", "enforce"), true); - }); - - it("does NOT fail on escalate when fail-on=block", () => { - assert.equal(shouldFail("escalate", "block", "enforce"), false); - assert.equal(shouldFail("review", "block", "enforce"), false); - }); - - it("fails on escalate / review when fail-on=escalate", () => { - assert.equal(shouldFail("escalate", "escalate", "enforce"), true); - assert.equal(shouldFail("review", "escalate", "enforce"), true); - assert.equal(shouldFail("block", "escalate", "enforce"), false); - }); - - it("fail-on=block_or_escalate covers both", () => { - assert.equal(shouldFail("block", "block_or_escalate", "enforce"), true); - assert.equal(shouldFail("escalate", "block_or_escalate", "enforce"), true); - assert.equal(shouldFail("allow", "block_or_escalate", "enforce"), false); - }); - - it("shadow mode never fails the step regardless of verdict or fail-on", () => { - for (const decision of ["allow", "block", "escalate", "review", "deny"]) { - for (const failOn of ["block", "escalate", "block_or_escalate", "never"]) { - assert.equal(shouldFail(decision, failOn, "shadow"), false, `shadow ${decision}/${failOn}`); - } - } - }); - - it("fail-on=never opts out of all failures", () => { - assert.equal(shouldFail("block", "never", "enforce"), false); - assert.equal(shouldFail("escalate", "never", "enforce"), false); - }); - - it("never fails on an allow verdict, regardless of fail-on", () => { - for (const failOn of ["block", "escalate", "block_or_escalate", "never"]) { - assert.equal(shouldFail("allow", failOn, "enforce"), false); - } - }); -}); - -describe("buildGrantRequestBody", () => { - const prevRepo = process.env.GITHUB_REPOSITORY; - const prevSha = process.env.GITHUB_SHA; - afterEach(() => { - if (prevRepo === undefined) delete process.env.GITHUB_REPOSITORY; - else process.env.GITHUB_REPOSITORY = prevRepo; - if (prevSha === undefined) delete process.env.GITHUB_SHA; - else process.env.GITHUB_SHA = prevSha; - }); - it("binds org, dossier, outcome, action, audience, and repo@sha subject", () => { - process.env.GITHUB_REPOSITORY = "octo/repo"; - process.env.GITHUB_SHA = "deadbeef"; - const body = buildGrantRequestBody({ - orgId: "org-1", - dossierId: "dsr-1", - decision: "allow", - action: "production-deploy", - audience: "prod", - }); - assert.deepEqual(body, { - org_id: "org-1", - dossier_id: "dsr-1", - outcome: "allow", - action: "production-deploy", - audience: "prod", - subject: "octo/repo@deadbeef", - }); - }); - it("omits optional fields when absent", () => { - delete process.env.GITHUB_REPOSITORY; - delete process.env.GITHUB_SHA; - const body = buildGrantRequestBody({ orgId: "o", dossierId: "d", decision: "allow" }); - assert.deepEqual(body, { org_id: "o", dossier_id: "d", outcome: "allow" }); - }); -}); - -describe("shouldExecute (wrapped `run` command)", () => { - it("enforce: only an allow verdict permits execution", () => { - assert.equal(shouldExecute("allow", "enforce"), true); - for (const d of ["block", "deny", "escalate", "review", "restrain", ""]) { - assert.equal(shouldExecute(d, "enforce"), false, `enforce ${d}`); - } - }); - it("shadow: always executes (observe-only, never changes behavior)", () => { - for (const d of ["allow", "block", "escalate", "review", "deny"]) { - assert.equal(shouldExecute(d, "shadow"), true, `shadow ${d}`); - } - }); - it("is case-insensitive on the verdict", () => { - assert.equal(shouldExecute("ALLOW", "enforce"), true); - assert.equal(shouldExecute("BLOCK", "enforce"), false); - }); -}); - -describe("buildVerifyUrl", () => { - it("includes ?sig= and ?source=github_actions for OG-rich unfurls", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr-abc", "sha256:0123"); - assert.equal( - url, - "https://decionis.com/verify/decision-dossiers/dsr-abc?sig=sha256%3A0123&source=github_actions", - ); - }); - - it("omits ?sig= when no signature is available, still attributes the source", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr-abc", null); - assert.equal( - url, - "https://decionis.com/verify/decision-dossiers/dsr-abc?source=github_actions", - ); - }); - - it("URL-encodes dossier ids with special chars", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr/abc#1", null); - assert.match(url, /dsr%2Fabc%231/); - }); - - it("strips a trailing slash from the site base URL", () => { - const url = buildVerifyUrl("https://decionis.com/", "dsr-x", null); - assert.match(url, /^https:\/\/decionis\.com\/verify\/decision-dossiers\/dsr-x/); - }); -}); - -describe("verdictTheme", () => { - it("maps verdict synonyms to a stable label + color", () => { - assert.equal(verdictTheme("allow").label, "Allowed"); - assert.equal(verdictTheme("deny").label, "Blocked"); - assert.equal(verdictTheme("denied").label, "Blocked"); - assert.equal(verdictTheme("review").label, "Escalate"); - assert.equal(verdictTheme("restrained").label, "Restrained"); - }); - it("is case-insensitive and falls back for unknown verdicts", () => { - assert.equal(verdictTheme("BLOCK").label, "Blocked"); - assert.equal(verdictTheme("weird").label, "weird"); - assert.equal(verdictTheme("").label, "Unknown"); - }); -}); - -describe("verdictBadgeUrl", () => { - it("is a shields.io URL carrying the verdict label", () => { - assert.match(verdictBadgeUrl("block"), /^https:\/\/img\.shields\.io\/badge\/Decionis-Blocked-/); - assert.match(verdictBadgeUrl("allow"), /Decionis-Allowed-2ea043/); - }); -}); - -describe("governedByBadgeMarkdown", () => { - it("produces a clickable shields badge linking to the action by default", () => { - const md = governedByBadgeMarkdown(); - assert.match(md, /^\[!\[Governed by Decionis\]\(https:\/\/img\.shields\.io\/badge\/Governed/); - assert.match(md, /\(https:\/\/github\.com\/decionis\/govern\)$/); - }); - it("links to a custom URL (e.g. the live verify URL) when given one", () => { - assert.match( - governedByBadgeMarkdown("https://decionis.com/verify/x"), - /\(https:\/\/decionis\.com\/verify\/x\)$/, - ); - }); -}); - -describe("buildPrCommentBody", () => { - const base = { - decision: "block", - dossierId: "dsr-123", - verifyUrl: "https://decionis.com/verify/decision-dossiers/dsr-123?sig=abc", - policyVersion: "v4.3.0", - reasonCode: "velocity_ceiling_exceeded", - runMode: "enforce", - failOn: "block", - }; - it("includes the hidden sticky marker so re-runs update in place", () => { - assert.match(buildPrCommentBody(base), //); - }); - it("renders the verdict, verify link, policy, and reason", () => { - const body = buildPrCommentBody(base); - assert.match(body, /Governed step β€” Blocked/); - assert.match(body, /Verify this decision β†’/); - assert.match(body, /v4\.3\.0/); - assert.match(body, /velocity_ceiling_exceeded/); - }); - it("shows the shadow-mode note (never fails build) in shadow", () => { - const body = buildPrCommentBody({ ...base, runMode: "shadow" }); - assert.match(body, /Shadow mode/); - assert.match(body, /never fails your build/); - }); - it("includes attribution by default and omits it when disabled", () => { - assert.match(buildPrCommentBody(base), /Governed by .*Decionis/); - const bare = buildPrCommentBody({ ...base, showAttribution: false }); - assert.doesNotMatch(bare, /Governed by { - const body = buildPrCommentBody(base); - assert.match(body, /autonomous AI agents/); - assert.match(body, /shadow mode in 30s/); - assert.match(body, /quickstart\?source=gha_pr_comment/); - }); -}); - -describe("applyActionLabel", () => { - it("folds the action label into the payload", () => { - assert.deepEqual(applyActionLabel({ env: "prod" }, "production-deploy"), { - env: "prod", - action: "production-deploy", - }); - }); - it("does not clobber an explicit payload.action", () => { - assert.deepEqual(applyActionLabel({ action: "explicit" }, "production-deploy"), { - action: "explicit", - }); - }); - it("returns the payload untouched when no label is given", () => { - const p = { env: "prod" }; - assert.equal(applyActionLabel(p, ""), p); - assert.equal(applyActionLabel(p, " "), p); - }); -}); - -describe("buildPrCommentBody β€” action label", () => { - it("surfaces the action label in the heading when provided", () => { - const body = buildPrCommentBody({ - decision: "block", - runMode: "enforce", - failOn: "block", - actionLabel: "production-deploy", - }); - assert.match(body, /Action gate Β· `production-deploy` β€” Blocked/); - }); -}); - -describe("resolvePayload", () => { - it("falls back to a GitHub-context-derived payload when input is blank", () => { - const payload = resolvePayload("", () => ({ source: "github_actions_test", repo: "x/y" })); - assert.equal(payload.source, "github_actions_test"); - assert.equal(payload.repo, "x/y"); - }); - - it("parses a JSON object input as-is", () => { - const payload = resolvePayload('{"environment":"production","blast_radius":"high"}'); - assert.deepEqual(payload, { environment: "production", blast_radius: "high" }); - }); - - it("throws on invalid JSON", () => { - assert.throws(() => resolvePayload("not json"), /not valid JSON/); - }); - - it("throws if payload JSON is an array, not an object", () => { - assert.throws(() => resolvePayload("[1,2,3]"), /must be an object/); - }); -}); diff --git a/test/installer.test.mjs b/test/installer.test.mjs deleted file mode 100644 index f0241da..0000000 --- a/test/installer.test.mjs +++ /dev/null @@ -1,272 +0,0 @@ -// install.sh tests: run the real script under both `sh` and `bash` inside -// throwaway git fixtures. The gh CLI is a PATH shim; pushes go to a local -// bare "origin". PATH is pinned to /usr/bin:/bin so a real gh is never found -// unless the shim provides one. -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { mkdtemp, mkdir, readFile, writeFile, chmod, access } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { evaluateLocalPolicy } from "../src/policy-engine.mjs"; - -const INSTALLER = fileURLToPath(new URL("../install.sh", import.meta.url)); -const BASE_PATH = "/usr/bin:/bin"; - -function exec(cmd, args, { cwd, env = {} } = {}) { - return new Promise((resolve) => { - const child = spawn(cmd, args, { - cwd, - env: { PATH: BASE_PATH, HOME: cwd, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0", ...env }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (d) => (stdout += d)); - child.stderr.on("data", (d) => (stderr += d)); - child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr })); - }); -} - -async function git(cwd, ...args) { - const r = await exec("git", args, { cwd }); - assert.equal(r.code, 0, `git ${args.join(" ")} failed: ${r.stderr}`); - return r; -} - -const CI_YML = `name: CI -on: push -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: npm test - reusable: - uses: org/repo/.github/workflows/x.yml@main - deploy: - runs-on: ubuntu-latest - steps: - - name: deploy - run: ./deploy.sh -`; - -async function makeRepo({ workflows = {}, commit = true } = {}) { - const dir = await mkdtemp(join(tmpdir(), "govern-install-")); - await git(dir, "init", "-q", "-b", "main"); - await git(dir, "config", "user.email", "test@example.com"); - await git(dir, "config", "user.name", "Test"); - await writeFile(join(dir, "README.md"), "# fixture\n"); - for (const [name, content] of Object.entries(workflows)) { - await mkdir(join(dir, ".github/workflows"), { recursive: true }); - await writeFile(join(dir, ".github/workflows", name), content); - } - if (commit) { - await git(dir, "add", "-A"); - await git(dir, "commit", "-qm", "init"); - } - return dir; -} - -const exists = (p) => - access(p).then( - () => true, - () => false, - ); - -const runInstaller = (shellBin, dir, args = [], env = {}) => - exec(shellBin, [INSTALLER, ...args], { cwd: dir, env }); - -for (const shellBin of ["sh", "bash"]) { - describe(`install.sh under ${shellBin}`, () => { - it("creates the workflow and policy, and is idempotent", async () => { - const dir = await makeRepo(); - const first = await runInstaller(shellBin, dir); - assert.equal(first.code, 0, first.stderr); - assert.match(first.stdout, /created: \.github\/workflows\/decionis-govern\.yml/); - assert.match(first.stdout, /created: DECIONIS_POLICY\.md/); - - const workflow = await readFile(join(dir, ".github/workflows/decionis-govern.yml"), "utf8"); - assert.match(workflow, /uses: decionis\/govern@v1/); - assert.match(workflow, /api-key: \$\{\{ secrets\.DECIONIS_API_KEY \}\}/); - assert.match(workflow, /org-id: \$\{\{ secrets\.DECIONIS_ORG_ID \}\}/); - assert.match(workflow, /mode: shadow/); - assert.match(workflow, /workflow-key: github_pr_change_intent/); - - // Fresh worktree files only; second run is a no-op. - const second = await runInstaller(shellBin, dir); - assert.equal(second.code, 0); - assert.match(second.stdout, /already exists/); - assert.match(second.stdout, /nothing to do/); - }); - - it("generated policy template yields a deterministic local block in the engine", async () => { - const dir = await makeRepo(); - await runInstaller(shellBin, dir); - const policy = await readFile(join(dir, "DECIONIS_POLICY.md"), "utf8"); - const r = evaluateLocalPolicy(policy, { - payload: { change_freeze: true }, - orgId: "org-x", - workflowKey: "github_pr_change_intent", - }); - assert.equal(r.status, "verdict"); - assert.equal(r.outcome, "block"); - assert.equal(r.deterministic, true); - }); - - it("--dry-run writes nothing", async () => { - const dir = await makeRepo({ workflows: { "ci.yml": CI_YML } }); - const r = await runInstaller(shellBin, dir, ["--dry-run", "--inject"]); - assert.equal(r.code, 0, r.stderr); - assert.match(r.stdout, /would create: \.github\/workflows\/decionis-govern\.yml/); - assert.match(r.stdout, /would inject 2 step\(s\) into \.github\/workflows\/ci\.yml/); - assert.equal(await exists(join(dir, "DECIONIS_POLICY.md")), false); - const ci = await readFile(join(dir, ".github/workflows/ci.yml"), "utf8"); - assert.equal(ci, CI_YML); - }); - - it("--inject inserts the shadow step first in each steps job, skipping reusable jobs", async () => { - const dir = await makeRepo({ workflows: { "ci.yml": CI_YML } }); - const r = await runInstaller(shellBin, dir, ["--inject", "--workflow-key", "custom_key"]); - assert.equal(r.code, 0, r.stderr); - assert.match(r.stdout, /injected 2 step\(s\) into \.github\/workflows\/ci\.yml/); - assert.match(r.stdout, /reusable-workflow job skipped/); - - const ci = (await readFile(join(dir, ".github/workflows/ci.yml"), "utf8")).split("\n"); - const stepsIdx = ci.findIndex((l) => l === " steps:"); - assert.equal( - ci[stepsIdx + 1], - " - name: Decionis shadow verdict # decionis-govern (auto)", - "injected as the first step at the existing item indent", - ); - assert.equal(ci[stepsIdx + 2], " uses: decionis/govern@v1"); - assert.equal(ci[stepsIdx + 3], " continue-on-error: true"); - assert.match(ci.join("\n"), /workflow-key: custom_key/); - // The reusable job is untouched. - assert.match(ci.join("\n"), /uses: org\/repo\/\.github\/workflows\/x\.yml@main/); - - // Idempotent: a second --inject skips the file. - const again = await runInstaller(shellBin, dir, ["--inject"]); - assert.match(again.stdout, /skip: +\.github\/workflows\/ci\.yml \(already uses decionis\/govern\)/); - }); - - it("--inject handles 4-space indentation", async () => { - const wide = `on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n`; - const dir = await makeRepo({ workflows: { "wide.yml": wide } }); - const r = await runInstaller(shellBin, dir, ["--inject"]); - assert.equal(r.code, 0, r.stderr); - const lines = (await readFile(join(dir, ".github/workflows/wide.yml"), "utf8")).split("\n"); - const stepsIdx = lines.findIndex((l) => l === " steps:"); - assert.equal(lines[stepsIdx + 1], " - name: Decionis shadow verdict # decionis-govern (auto)"); - // Step child keys align after the "- " marker: item indent + 2. - assert.equal(lines[stepsIdx + 2], " uses: decionis/govern@v1"); - }); - - it("--inject skips flow-style steps and tabbed files while injecting healthy ones", async () => { - const flow = `on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: echo hi }]\n`; - const tabbed = `on: push\njobs:\n\tb:\n\t\truns-on: ubuntu-latest\n`; - const dir = await makeRepo({ - workflows: { "flow.yml": flow, "tabbed.yml": tabbed, "ok.yml": CI_YML }, - }); - const r = await runInstaller(shellBin, dir, ["--inject"]); - assert.equal(r.code, 0, r.stderr); - assert.match(r.stdout, /skip: +\.github\/workflows\/tabbed\.yml \(contains tabs/); - assert.match(r.stdout, /flow\.yml \(no injectable jobs\)/); - assert.match(r.stdout, /injected 2 step\(s\) into \.github\/workflows\/ok\.yml/); - assert.equal(await readFile(join(dir, ".github/workflows/flow.yml"), "utf8"), flow); - }); - - it("fails with the documented exit codes", async () => { - const bare = await mkdtemp(join(tmpdir(), "govern-notrepo-")); - assert.equal((await runInstaller(shellBin, bare)).code, 2); - - // Git-state gates apply to --pr only; a plain run in a dirty tree is fine. - const dirty = await makeRepo(); - await writeFile(join(dirty, "uncommitted.txt"), "dirt\n"); - assert.equal((await runInstaller(shellBin, dirty, ["--pr"])).code, 4); - const plainDirty = await runInstaller(shellBin, dirty); - assert.equal(plainDirty.code, 0, plainDirty.stderr); - - const detached = await makeRepo(); - await git(detached, "checkout", "-q", "--detach"); - assert.equal((await runInstaller(shellBin, detached, ["--pr"])).code, 3); - - const ok = await makeRepo(); - assert.equal((await runInstaller(shellBin, ok, ["--bogus"])).code, 64); - assert.equal((await runInstaller(shellBin, ok, ["--mode", "yolo"])).code, 64); - }); - }); -} - -describe("install.sh --pr", () => { - async function withOrigin(dir) { - const bare = await mkdtemp(join(tmpdir(), "govern-origin-")); - await git(bare, "init", "-q", "--bare"); - await git(dir, "remote", "add", "origin", bare); - return bare; - } - - it("branches, commits, pushes, and calls gh pr create", async () => { - const dir = await makeRepo({ workflows: { "ci.yml": CI_YML } }); - const bare = await withOrigin(dir); - - const shimDir = await mkdtemp(join(tmpdir(), "govern-shim-")); - const spyFile = join(shimDir, "gh-spy.txt"); - await writeFile(join(shimDir, "gh"), `#!/bin/sh\necho "$@" >> "${spyFile}"\nexit 0\n`); - await chmod(join(shimDir, "gh"), 0o755); - - const r = await exec("sh", [INSTALLER, "--pr", "--inject", "--org-id", "org-123"], { - cwd: dir, - env: { PATH: `${shimDir}:${BASE_PATH}` }, - }); - assert.equal(r.code, 0, r.stderr + r.stdout); - - const spy = await readFile(spyFile, "utf8"); - assert.match(spy, /pr create/); - assert.match(spy, /--base main --head feature\/add-decionis-governance/); - - const branches = (await git(bare, "branch", "--list")).stdout; - assert.match(branches, /feature\/add-decionis-governance/); - - const show = (await git(dir, "show", "--stat", "--oneline", "HEAD")).stdout; - assert.match(show, /add Decionis governance gate/); - assert.match(show, /decionis-govern\.yml/); - assert.match(show, /DECIONIS_POLICY\.md/); - assert.match(show, /ci\.yml/); - - const workflow = await readFile(join(dir, ".github/workflows/decionis-govern.yml"), "utf8"); - assert.match(workflow, /org-id: org-123/, "--org-id inlines a literal id"); - }); - - it("prints the compare URL when gh is unavailable", async () => { - const dir = await makeRepo(); - await git(dir, "remote", "remove", "origin").catch(() => {}); - const bare = await mkdtemp(join(tmpdir(), "govern-origin2-")); - await git(bare, "init", "-q", "--bare"); - // Use a github-style remote URL so the compare link can be derived; the - // push itself goes nowhere, so point origin at the local bare path but - // derive the URL case separately below. - await git(dir, "remote", "add", "origin", bare); - const r = await exec("sh", [INSTALLER, "--pr"], { cwd: dir }); - assert.equal(r.code, 0, r.stderr + r.stdout); - // Local-path origins can't map to a web URL β€” the fallback message appears. - assert.match(r.stdout, /open a PR from feature\/add-decionis-governance/i); - }); - - it("derives GitHub compare URLs from ssh and https remotes", async () => { - // Pure URL-derivation check via the script's sed pipeline, no push: - const ssh = await exec("sh", [ - "-c", - `echo "git@github.com:acme/widgets.git" | sed -e 's/^git@//' -e 's/:/\\//' -e 's/\\.git$//' -e 's/^/https:\\/\\//'`, - ]); - assert.equal(ssh.stdout.trim(), "https://github.com/acme/widgets"); - }); - - it("--pr without an origin remote exits 5", async () => { - const dir = await makeRepo(); - const r = await exec("sh", [INSTALLER, "--pr"], { cwd: dir }); - assert.equal(r.code, 5); - }); -}); diff --git a/test/integration.test.mjs b/test/integration.test.mjs deleted file mode 100644 index 99844f4..0000000 --- a/test/integration.test.mjs +++ /dev/null @@ -1,344 +0,0 @@ -// End-to-end tests: spawn the real action entrypoint with INPUT_* env vars -// against a local mock Decionis API, and assert exit codes, outputs, summary, -// and β€” critically β€” the timing behavior of speculative shadow mode and the -// local-verdict short-circuit. -import { describe, it, after } from "node:test"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import http from "node:http"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const INDEX = fileURLToPath(new URL("../src/index.mjs", import.meta.url)); - -const ALLOW_RESPONSE = { - outcome: "APPROVE", - dossier_id: "dsr-int", - verification: { signature: "sig-int" }, - policy_version: "pv-1", - reason_codes: ["ok"], -}; - -const openServers = []; -after(async () => { - await Promise.all(openServers.map((close) => close())); -}); - -/** Start a mock Decionis API. `routes` maps URL suffixes to {delayMs, status, body}. */ -async function startMockApi(routes) { - const requests = []; - const server = http.createServer((req, res) => { - let data = ""; - req.on("data", (chunk) => (data += chunk)); - req.on("end", () => { - const route = - Object.entries(routes).find(([suffix]) => req.url?.endsWith(suffix))?.[1] ?? routes["*"]; - requests.push({ url: req.url, body: data ? JSON.parse(data) : null, at: Date.now() }); - const { delayMs = 0, status = 200, body = {} } = route ?? {}; - setTimeout(() => { - res.writeHead(status, { "content-type": "application/json" }); - res.end(JSON.stringify(body)); - }, delayMs); - }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const close = () => - new Promise((resolve) => { - server.closeAllConnections?.(); - server.close(resolve); - }); - openServers.push(close); - return { url: `http://127.0.0.1:${server.address().port}`, requests, close }; -} - -/** Parse GITHUB_OUTPUT heredoc format into a map (last write wins). */ -function parseOutputs(text) { - const outputs = {}; - const re = /^(.+?)<<(\S+)\n([\s\S]*?)\n\2$/gm; - let m; - while ((m = re.exec(text)) !== null) outputs[m[1]] = m[3]; - return outputs; -} - -/** Run the action with the given inputs; returns exit code, logs, outputs, summary. */ -async function runAction({ inputs = {}, policy = null, timeoutMs = 15000 }) { - const dir = await mkdtemp(join(tmpdir(), "govern-int-")); - const outputFile = join(dir, "outputs.txt"); - const summaryFile = join(dir, "summary.md"); - await writeFile(outputFile, ""); - await writeFile(summaryFile, ""); - if (policy) await writeFile(join(dir, "DECIONIS_POLICY.md"), policy); - - const base = Object.fromEntries( - Object.entries(process.env).filter( - ([k]) => !k.startsWith("GITHUB_") && !k.startsWith("INPUT_"), - ), - ); - const env = { - ...base, - GITHUB_OUTPUT: outputFile, - GITHUB_STEP_SUMMARY: summaryFile, - GITHUB_WORKSPACE: dir, - ...Object.fromEntries( - Object.entries(inputs).map(([k, v]) => [ - `INPUT_${k.toUpperCase().replace(/-/g, "_")}`, - String(v), - ]), - ), - }; - - const startedAt = Date.now(); - const child = spawn(process.execPath, [INDEX], { env, stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (d) => (stdout += d)); - child.stderr.on("data", (d) => (stderr += d)); - const code = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error(`action did not exit within ${timeoutMs}ms\nstdout:\n${stdout}`)); - }, timeoutMs); - child.on("close", (c) => { - clearTimeout(timer); - resolve(c ?? 0); - }); - }); - return { - code, - stdout, - stderr, - durationMs: Date.now() - startedAt, - outputs: parseOutputs(await readFile(outputFile, "utf8")), - summary: await readFile(summaryFile, "utf8"), - }; -} - -// GitHub injects action.yml defaults as INPUT_* env vars; a bare spawn does -// not, so tests that rely on the default policy path pass it explicitly. -const CREDS = { - "api-key": "test-key", - "org-id": "org-int", - "workflow-key": "wf-int", - "policy-file": "DECIONIS_POLICY.md", -}; -const MARK = 'node -e "console.log(\'CMD_TS_\' + Date.now())"'; -const markTime = (stdout) => { - const m = /CMD_TS_(\d+)/.exec(stdout); - return m ? Number(m[1]) : null; -}; - -const POLICY_BLOCK = `# Policy\n\n\`\`\`decionis\n{"version":1,"rules":[{"name":"Block gated","all":[{"field":"context.gate","op":"eq","value":true}],"action":"block","domain":"*"}]}\n\`\`\`\n`; -const POLICY_ALLOW = POLICY_BLOCK.replace('"action":"block"', '"action":"allow"').replace( - "Block gated", - "Allow gated", -); - -describe("integration: speculative shadow (branch A)", () => { - it("(a) starts the command before the delayed API responds, exits 0, records the verdict", async () => { - const api = await startMockApi({ "*": { delayMs: 3000, body: ALLOW_RESPONSE } }); - const t0 = Date.now(); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "shadow", - run: MARK, - "api-base-url": api.url, - "request-timeout-ms": "8000", - }, - }); - assert.equal(r.code, 0); - const cmdTs = markTime(r.stdout); - assert.ok(cmdTs, "command marker printed"); - assert.ok(cmdTs - t0 < 2500, `command started fast (took ${cmdTs - t0}ms)`); - assert.ok(r.durationMs >= 2900, "step waited for the background verdict in grace"); - assert.equal(r.outputs.decision, "allow"); // APPROVE normalized - assert.equal(r.outputs["decision-source"], "api"); - assert.equal(r.outputs.executed, "true"); - assert.match(r.outputs["verify-url"], /dsr-int/); - assert.match(r.stdout, /Decionis timing β€”/); - assert.equal((r.summary.match(/Decionis Action Gate/g) ?? []).length, 1, "summary written once"); - await api.close(); - }); - - it("(b) API unreachable: step exits with the command's code and only notices", async () => { - const r = await runAction({ - inputs: { - ...CREDS, - mode: "shadow", - run: 'node -e "process.exit(7)"', - "api-base-url": "http://127.0.0.1:9", - "request-timeout-ms": "2000", - }, - }); - assert.equal(r.code, 7); - assert.match(r.stdout, /::notice::Decionis \(non-fatal\) shadow evaluation/); - assert.doesNotMatch(r.stdout, /::error::/); - assert.equal(r.outputs.decision, ""); - }); - - it("(c) shadow exit code is exactly the command's, even on success paths", async () => { - const api = await startMockApi({ "*": { body: ALLOW_RESPONSE } }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "shadow", - run: 'node -e "process.exit(3)"', - "api-base-url": api.url, - "request-timeout-ms": "3000", - }, - }); - assert.equal(r.code, 3); - assert.equal(r.outputs.decision, "allow"); - await api.close(); - }); - - it("(k) missing credentials in shadow: command still runs, step never fails", async () => { - const r = await runAction({ - inputs: { mode: "shadow", run: 'node -e "process.exit(5)"' }, - }); - assert.equal(r.code, 5); - assert.match(r.stdout, /not configured yet/); - assert.doesNotMatch(r.stdout, /::error::/); - }); -}); - -describe("integration: local verdict short-circuit (branches C/D)", () => { - it("(d) local block fails fast without waiting for a slow API, and records bounded", async () => { - const api = await startMockApi({ "*": { delayMs: 5000, body: ALLOW_RESPONSE } }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "enforce", - run: MARK, - payload: '{"gate":true}', - "api-base-url": api.url, - "request-timeout-ms": "1500", - }, - policy: POLICY_BLOCK, - }); - assert.equal(r.code, 1); - assert.ok(r.durationMs < 4000, `short-circuited (took ${r.durationMs}ms vs 5s API)`); - assert.equal(markTime(r.stdout), null, "gated command never ran"); - assert.equal(r.outputs.executed, "false"); - assert.equal(r.outputs.decision, "block"); - assert.equal(r.outputs["decision-source"], "local"); - assert.match(r.stdout, /BLOCKED execution locally \(rule "Block gated"/); - assert.match(r.stdout, /⚑ Decionis local verdict 'block'/); - assert.equal(api.requests.length, 1, "bounded dossier-recording call was attempted"); - await api.close(); - }); - - it("(e) local allow runs the command while notarizing; API disagreement flags a mismatch", async () => { - const api = await startMockApi({ - "*": { delayMs: 300, body: { ...ALLOW_RESPONSE, outcome: "REJECT" } }, - }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "enforce", - run: 'node -e "setTimeout(() => {}, 800)"', - payload: '{"gate":true}', - "api-base-url": api.url, - "request-timeout-ms": "5000", - }, - policy: POLICY_ALLOW, - }); - assert.equal(r.code, 0); - assert.equal(r.outputs.executed, "true"); - assert.equal(r.outputs.decision, "allow", "the acted local verdict is reported"); - assert.equal(r.outputs["decision-source"], "local"); - assert.equal(r.outputs["verdict-mismatch"], "true"); - assert.equal(r.outputs["dossier-id"], "dsr-int", "API dossier still recorded"); - assert.match(r.stdout, /::warning::Decionis verdict mismatch/); - await api.close(); - }); - - it("(f) strict mode: deterministic local allow makes zero network calls", async () => { - const api = await startMockApi({ "*": { body: ALLOW_RESPONSE } }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "enforce", - run: MARK, - payload: '{"gate":true}', - "local-eval": "strict", - "api-base-url": api.url, - }, - policy: POLICY_ALLOW, - }); - assert.equal(r.code, 0); - assert.ok(markTime(r.stdout), "command ran"); - assert.equal(api.requests.length, 0, "no API request was made"); - assert.equal(r.outputs.decision, "allow"); - assert.equal(r.outputs["decision-source"], "local"); - assert.match(r.stdout, /API roundtrip skipped \(local-eval: strict\)/); - await api.close(); - }); -}); - -describe("integration: legacy blocking path (branch E)", () => { - it("(g) no policy file: an API block fails the advisory step exactly like v1.8", async () => { - const api = await startMockApi({ "*": { body: { ...ALLOW_RESPONSE, outcome: "REJECT" } } }); - const r = await runAction({ - inputs: { ...CREDS, mode: "enforce", "api-base-url": api.url }, - }); - assert.equal(r.code, 1); - assert.equal(r.outputs.decision, "block"); - assert.equal(r.outputs["decision-source"], "api"); - assert.match(r.stdout, /::error::Decionis blocked this step/); - await api.close(); - }); - - it("(h) enforce fails closed on an API 500", async () => { - const api = await startMockApi({ "*": { status: 500, body: { error: "boom" } } }); - const r = await runAction({ - inputs: { ...CREDS, mode: "enforce", "api-base-url": api.url }, - }); - assert.equal(r.code, 1); - assert.match(r.stdout, /::error::Decionis API returned 500/); - await api.close(); - }); - - it("(i) shadow verdict-only swallows an API 500 and exits 0", async () => { - const api = await startMockApi({ "*": { status: 500, body: { error: "boom" } } }); - const r = await runAction({ - inputs: { ...CREDS, mode: "shadow", "api-base-url": api.url }, - }); - assert.equal(r.code, 0); - assert.match(r.stdout, /::notice::Decionis \(non-fatal\) shadow evaluation/); - assert.doesNotMatch(r.stdout, /::error::/); - assert.ok(r.summary.includes("Decionis Action Gate"), "summary still written"); - await api.close(); - }); - - it("(j) request-grant disables speculation: verdict, then grant, then the command", async () => { - const api = await startMockApi({ - "/v1/protocol/evaluate-decision": { delayMs: 400, body: ALLOW_RESPONSE }, - "/v1/protocol/execution-grants/issue": { - body: { execution_grant: "grant-jwt", expires_at: "2027-01-01T00:00:00Z" }, - }, - }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "shadow", - run: 'node -e "console.log(\'CMD_TS_\' + Date.now() + \'_GRANT=\' + (process.env.DECIONIS_EXECUTION_GRANT || \'none\'))"', - "request-grant": "true", - "api-base-url": api.url, - "request-timeout-ms": "5000", - }, - }); - assert.equal(r.code, 0); - const cmdTs = markTime(r.stdout); - assert.ok(cmdTs, "command ran"); - assert.ok( - cmdTs >= api.requests[0].at, - "the evaluate call completed before the command started (no speculation with grants)", - ); - assert.match(r.stdout, /_GRANT=grant-jwt/); - assert.equal(r.outputs["execution-grant"], "grant-jwt"); - await api.close(); - }); -}); diff --git a/test/orchestration.test.mjs b/test/orchestration.test.mjs deleted file mode 100644 index 6db7b97..0000000 --- a/test/orchestration.test.mjs +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; - -import { - DEFAULT_LOCAL_EVAL, - SHADOW_GRACE_CAP_MS, - SHADOW_GRACE_EXTRA_MS, - BLOCK_RECORD_CAP_MS, - planEvaluation, - computeGraceMs, - createTimeline, - buildVerifyUrl, - shouldFail, -} from "../src/index.mjs"; -import { normalizeVerdict, parseDecisionResponse } from "../src/api-client.mjs"; - -const localAllow = { deterministic: true, outcome: "allow" }; -const localBlock = { deterministic: true, outcome: "block" }; -const localFallback = { deterministic: false, outcome: null }; - -describe("planEvaluation (the local-eval decision table)", () => { - it("defaults to auto", () => { - assert.equal(DEFAULT_LOCAL_EVAL, "auto"); - }); - - it("routes to the blocking API without a deterministic local verdict", () => { - for (const localEval of ["auto", "strict", "off"]) { - for (const localOutcome of [null, localFallback]) { - assert.deepEqual( - planEvaluation({ localOutcome, localEval, requestGrant: false, hasRunCommand: true }), - { act: "api", api: "blocking" }, - ); - } - } - }); - - it("local-eval off ignores even a deterministic verdict", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "off", requestGrant: false, hasRunCommand: true }), - { act: "api", api: "blocking" }, - ); - }); - - it("request-grant always forces the blocking path (grants need a dossier first)", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "auto", requestGrant: true, hasRunCommand: true }), - { act: "api", api: "blocking" }, - ); - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "strict", requestGrant: true, hasRunCommand: false }), - { act: "api", api: "blocking" }, - ); - }); - - it("strict: deterministic verdicts act locally with zero network", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "strict", requestGrant: false, hasRunCommand: true }), - { act: "local", api: "none" }, - ); - assert.deepEqual( - planEvaluation({ localOutcome: localBlock, localEval: "strict", requestGrant: false, hasRunCommand: false }), - { act: "local", api: "none" }, - ); - }); - - it("auto: local block records the dossier with a bounded wait", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localBlock, localEval: "auto", requestGrant: false, hasRunCommand: true }), - { act: "local", api: "bounded" }, - ); - }); - - it("auto: local allow notarizes in the background while a command runs, bounded otherwise", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "auto", requestGrant: false, hasRunCommand: true }), - { act: "local", api: "background" }, - ); - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "auto", requestGrant: false, hasRunCommand: false }), - { act: "local", api: "bounded" }, - ); - }); -}); - -describe("computeGraceMs", () => { - it("grants the remaining timeout budget plus the parsing allowance", () => { - // Pipeline started 1s ago with a 5s timeout β†’ 4s remaining + extra. - assert.equal(computeGraceMs(0, 1000, 5000), 4000 + SHADOW_GRACE_EXTRA_MS); - }); - - it("floors at the extra allowance when the budget is exhausted", () => { - assert.equal(computeGraceMs(0, 60_000, 5000), SHADOW_GRACE_EXTRA_MS); - }); - - it("caps long budgets so fast commands never wait long", () => { - assert.equal(computeGraceMs(0, 0, 120_000), SHADOW_GRACE_CAP_MS); - assert.ok(SHADOW_GRACE_CAP_MS <= 10_000); - assert.ok(BLOCK_RECORD_CAP_MS <= SHADOW_GRACE_CAP_MS); - }); -}); - -describe("createTimeline", () => { - it("renders marks with offsets and details", () => { - const t = createTimeline(0); - t.mark("local eval", "'allow' in 412Β΅s"); - t.mark("command started"); - const rendered = t.render(); - assert.match(rendered, /^Decionis timing β€” /); - assert.match(rendered, /local eval 'allow' in 412Β΅s \+\d+(\.\d+)?m?s/); - assert.match(rendered, /command started \+/); - assert.equal(t.events.length, 2); - }); - - it("says so when nothing was recorded", () => { - assert.match(createTimeline().render(), /no events recorded/); - }); -}); - -describe("buildVerifyUrl policy pinning", () => { - it("appends the policy revision pin as an additive query param", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr-1", "sig-1", { - policySha256: "abc123", - }); - assert.equal( - url, - "https://decionis.com/verify/decision-dossiers/dsr-1?sig=sig-1&source=github_actions&policy=sha256%3Aabc123", - ); - }); - - it("pins without a signature too", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr-1", null, { policySha256: "abc" }); - assert.equal( - url, - "https://decionis.com/verify/decision-dossiers/dsr-1?source=github_actions&policy=sha256%3Aabc", - ); - }); - - it("three-argument calls stay byte-identical to v1.8", () => { - assert.equal( - buildVerifyUrl("https://decionis.com", "dsr-abc", "sha256:0123"), - "https://decionis.com/verify/decision-dossiers/dsr-abc?sig=sha256%3A0123&source=github_actions", - ); - assert.equal( - buildVerifyUrl("https://decionis.com", "dsr-abc", null), - "https://decionis.com/verify/decision-dossiers/dsr-abc?source=github_actions", - ); - }); -}); - -describe("verdict normalization (protocol outcome β†’ gate vocabulary)", () => { - it("maps the protocol outcomes onto allow/block/review", () => { - assert.equal(normalizeVerdict("APPROVE"), "allow"); - assert.equal(normalizeVerdict("REJECT"), "block"); - assert.equal(normalizeVerdict("REVIEW"), "review"); - assert.equal(normalizeVerdict("ESCALATE"), "escalate"); - assert.equal(normalizeVerdict("AUTO_APPROVE"), "allow"); - assert.equal(normalizeVerdict("AUTO_REJECT"), "block"); - assert.equal(normalizeVerdict("REQUIRE_REVIEW"), "review"); - }); - - it("passes through the legacy vocabulary untouched", () => { - for (const v of ["allow", "block", "deny", "denied", "escalate", "review", "restrain"]) { - assert.equal(normalizeVerdict(v), v); - } - }); - - it("parseDecisionResponse normalizes outcome and keeps the wire value", () => { - const parsed = parseDecisionResponse({ - outcome: "APPROVE", - dossier_id: "dsr-9", - reason_codes: ["ok"], - }); - assert.equal(parsed.decision, "allow"); - assert.equal(parsed.rawDecision, "approve"); - assert.equal(parsed.dossierId, "dsr-9"); - assert.equal(parsed.reasonCode, "ok"); - }); - - it("parseDecisionResponse falls back through reason_code and tolerates junk", () => { - assert.equal(parseDecisionResponse({ reason_code: "rc" }).reasonCode, "rc"); - assert.equal(parseDecisionResponse({ reason_codes: "nope", reason_code: "rc" }).reasonCode, "rc"); - assert.equal(parseDecisionResponse({}).reasonCode, null); - }); -}); - -describe("local verdicts obey the same gates as API verdicts", () => { - it("a local block with fail-on: never or escalate does not fail the step", () => { - assert.equal(shouldFail("block", "never", "enforce"), false); - assert.equal(shouldFail("block", "escalate", "enforce"), false); - assert.equal(shouldFail("block", "block", "enforce"), true); - }); - - it("the normalized API 'review' verdict lands in the escalate family", () => { - assert.equal(shouldFail("review", "escalate", "enforce"), true); - assert.equal(shouldFail("review", "block", "enforce"), false); - }); -}); diff --git a/test/policy-engine.test.mjs b/test/policy-engine.test.mjs deleted file mode 100644 index e515158..0000000 --- a/test/policy-engine.test.mjs +++ /dev/null @@ -1,554 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -import { - SUPPORTED_OPS, - UNKNOWN_FACT, - extractDecionisBlock, - parsePolicy, - buildEvaluationFacts, - resolveFactValue, - valuesEqual, - evaluatePredicate, - evaluateRuleConditions, - isDomainCertain, - evaluatePolicy, - evaluateLocalPolicy, -} from "../src/policy-engine.mjs"; - -const examplePath = (name) => fileURLToPath(new URL(`../examples/${name}`, import.meta.url)); - -/** Wrap a rules document in a policy markdown file. */ -const md = (rulesJson, info = "decionis") => - `# Policy\n\nProse here.\n\n\`\`\`${info}\n${rulesJson}\n\`\`\`\n`; - -const RULES = (rules) => JSON.stringify({ version: 1, rules }); - -const ALLOW_STAGING = { - name: "Allow staging", - all: [{ field: "context.environment", op: "eq", value: "staging" }], - action: "allow", - domain: "*", -}; -const BLOCK_FREEZE = { - name: "Block during freeze", - all: [{ field: "context.change_freeze", op: "eq", value: true }], - action: "block", - domain: "*", -}; - -describe("extractDecionisBlock (mirrors the platform compiler regex)", () => { - it("extracts the first block's trimmed body", () => { - const body = extractDecionisBlock(md('{"version":1,"rules":[]}')); - assert.equal(body, '{"version":1,"rules":[]}'); - }); - - it("returns null when there is no block, or input is not a string", () => { - assert.equal(extractDecionisBlock("# just prose"), null); - assert.equal(extractDecionisBlock(undefined), null); - assert.equal(extractDecionisBlock(null), null); - }); - - it("returns null for an empty block body", () => { - assert.equal(extractDecionisBlock("```decionis\n \n```"), null); - }); - - it("takes the FIRST of multiple blocks, like the server", () => { - const text = md('{"first":1}') + "\n" + md('{"second":2}'); - assert.equal(extractDecionisBlock(text), '{"first":1}'); - }); - - it("accepts an optional json/yaml info tag and CRLF line endings", () => { - assert.equal(extractDecionisBlock("```decionis json\r\n{\"a\":1}\r\n```"), '{"a":1}'); - assert.equal(extractDecionisBlock("```decionis yaml\n{\"a\":1}\n```"), '{"a":1}'); - assert.equal(extractDecionisBlock("```DECIONIS\n{\"a\":1}\n```"), '{"a":1}'); - }); - - it("returns null for an unterminated fence", () => { - assert.equal(extractDecionisBlock("```decionis\n{\"a\":1}\n"), null); - }); -}); - -describe("parsePolicy (mirrors compileDecionisRules)", () => { - it("compiles both shipped example policies cleanly (lockstep with examples/)", async () => { - for (const name of ["DECIONIS_POLICY.md", "DECIONIS_POLICY.devops.md"]) { - const content = await readFile(examplePath(name), "utf8"); - const block = extractDecionisBlock(content); - assert.ok(block, `${name} has a decionis block`); - const compiled = parsePolicy(block, { workflowKey: "github_deploy_approval" }); - assert.equal(compiled.ok, true, `${name} compiles: ${JSON.stringify(compiled)}`); - assert.ok(compiled.rules.length >= 3, `${name} has rules`); - } - }); - - it("reports not_json for YAML-style bodies (server would still parse them)", () => { - const r = parsePolicy("rules:\n - name: x\n"); - assert.deepEqual(r, { ok: false, reason: "not_json" }); - }); - - it("rejects non-object documents and missing/empty rules", () => { - assert.equal(parsePolicy("[1,2]").ok, false); - assert.equal(parsePolicy('{"version":1}').ok, false); - assert.equal(parsePolicy('{"version":1,"rules":[]}').ok, false); - }); - - it("is all-or-nothing: one bad rule rejects the whole block", () => { - const r = parsePolicy( - RULES([ALLOW_STAGING, { name: "bad", all: [{ field: "x", op: "regex", value: "y" }], action: "allow" }]), - ); - assert.equal(r.ok, false); - assert.equal(r.reason, "compile_error"); - assert.match(r.errors[0], /unknown operator "regex"/); - }); - - it("requires exactly one non-empty all/any (empty arrays count as absent)", () => { - const both = parsePolicy( - RULES([{ name: "x", all: [{ field: "a", op: "eq", value: 1 }], any: [{ field: "b", op: "eq", value: 2 }], action: "allow" }]), - ); - assert.equal(both.ok, false); - const neither = parsePolicy(RULES([{ name: "x", action: "allow" }])); - assert.equal(neither.ok, false); - // all: [] is treated as absent, so a populated any is fine β€” mirror quirk. - const emptyAll = parsePolicy( - RULES([{ name: "x", all: [], any: [{ field: "b", op: "eq", value: 2 }], action: "allow", domain: "*" }]), - ); - assert.equal(emptyAll.ok, true); - assert.equal(emptyAll.rules[0].quantifier, "any"); - }); - - it("validates action, name, and per-op value requirements", () => { - assert.equal(parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "eq", value: 1 }], action: "veto" }])).ok, false); - assert.equal(parsePolicy(RULES([{ name: " ", all: [{ field: "a", op: "eq", value: 1 }], action: "allow" }])).ok, false); - assert.equal(parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "eq" }], action: "allow" }])).ok, false); - assert.equal(parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "in", value: [1] }], action: "allow" }])).ok, false); - const okIn = parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "in", values: [1, 2] }], action: "allow" }])); - assert.equal(okIn.ok, true); - assert.deepEqual(okIn.rules[0].predicates[0].values, [1, 2]); - }); - - it("drops any provided value for exists (pure existence, like the compiler)", () => { - const r = parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "exists", value: false }], action: "allow" }])); - assert.equal(r.ok, true); - const predicate = r.rules[0].predicates[0]; - assert.equal(predicate.operator, "EXISTS"); - assert.equal("value" in predicate, false); - }); - - it("ignores unknown keys (top-level and per-rule) and the version field", () => { - const r = parsePolicy( - JSON.stringify({ - version: 99, - comment: "ignored", - rules: [{ ...ALLOW_STAGING, severity: "high", notes: "ignored" }], - }), - ); - assert.equal(r.ok, true); - }); - - it("honors priority (clamped, rounded) and falls back to the rule index", () => { - const r = parsePolicy( - RULES([ - { ...ALLOW_STAGING, priority: 2.7 }, - { ...BLOCK_FREEZE, priority: "5" }, - { ...BLOCK_FREEZE, name: "third", priority: 999999 }, - ]), - ); - assert.equal(r.ok, true); - assert.equal(r.rules[0].priority, 3); - assert.equal(r.rules[1].priority, 1); // non-number priority β†’ index - assert.equal(r.rules[2].priority, 100000); // clamped - }); - - it("defaults rule domain to the workflow key, then github_action", () => { - const rule = { name: "x", all: [{ field: "a", op: "eq", value: 1 }], action: "allow" }; - assert.equal(parsePolicy(RULES([rule]), { workflowKey: "deploy_gate" }).rules[0].domain, "deploy_gate"); - assert.equal(parsePolicy(RULES([rule])).rules[0].domain, "github_action"); - assert.equal(parsePolicy(RULES([{ ...rule, domain: " * " }])).rules[0].domain, "*"); - }); - - it("keeps op aliases: ne and neq both compile to NEQ", () => { - const r = parsePolicy( - RULES([ - { name: "a", all: [{ field: "x", op: "ne", value: 1 }], action: "allow" }, - { name: "b", all: [{ field: "x", op: "NEQ", value: 1 }], action: "allow" }, - ]), - ); - assert.equal(r.ok, true); - assert.equal(r.rules[0].predicates[0].operator, "NEQ"); - assert.equal(r.rules[1].predicates[0].operator, "NEQ"); - assert.equal(SUPPORTED_OPS.includes("neq"), true); - }); -}); - -describe("buildEvaluationFacts (mirrors normalize + handler + facts builder)", () => { - const facts = buildEvaluationFacts( - { action: "production-deploy", environment: "prod", amount: 5000, signals: { velocity: 3 } }, - { orgId: "org-1", workflowKey: "deploy_gate" }, - ); - - it("merges the payload into context and injects workflow_key", () => { - assert.equal(facts.context.environment, "prod"); - assert.equal(facts.context.workflow_key, "deploy_gate"); - assert.equal(facts.intake, facts.context); - assert.equal(facts.signals.velocity, 3); - }); - - it("derives decision_type from payload.action, then workflow_key, then action_gate", () => { - assert.equal(facts.decision_type, "production-deploy"); - assert.equal( - buildEvaluationFacts({}, { workflowKey: "deploy_gate" }).decision_type, - "deploy_gate", - ); - assert.equal(buildEvaluationFacts({}, {}).decision_type, "action_gate"); - const long = "x".repeat(300); - assert.equal(buildEvaluationFacts({ action: long }, {}).decision_type.length, 120); - }); - - it("masks request-level fields at the root exactly like the server quirk", () => { - // payload.amount is visible via context.amount but the root `amount` is the - // (absent) request field β€” the explicit key overwrites the context spread. - assert.equal(facts.amount, undefined); - assert.equal(Object.prototype.hasOwnProperty.call(facts, "amount"), true); - assert.equal(facts.context.amount, 5000); - }); - - it("marks server-only facts as UNKNOWN_FACT", () => { - assert.equal(facts.decision_domain, UNKNOWN_FACT); - assert.equal(facts.mode, UNKNOWN_FACT); - assert.equal(facts.policy, UNKNOWN_FACT); - assert.equal(facts.context.vertical_pack, UNKNOWN_FACT); - }); - - it("collects attachments from context or evidence, with a count", () => { - const a = buildEvaluationFacts({ attachments: [1, 2] }, {}); - assert.equal(a.attachment_count, 2); - const b = buildEvaluationFacts({ evidence: { attachments: [1] } }, {}); - assert.equal(b.attachment_count, 1); - assert.equal(buildEvaluationFacts({}, {}).attachment_count, 0); - }); -}); - -describe("resolveFactValue (mirrors resolveEvaluateDecisionPolicyFactValue)", () => { - const facts = buildEvaluationFacts( - { "dotted.literal": "root-hit", nested: { deep: { value: 7 } }, list: ["a", "b"] }, - { orgId: "org-1", workflowKey: "wf" }, - ); - - it("prefers an exact root key, even one containing dots", () => { - assert.deepEqual(resolveFactValue(facts, "dotted.literal"), { kind: "value", value: "root-hit" }); - }); - - it("resolves dot paths and bracket indices", () => { - assert.deepEqual(resolveFactValue(facts, "context.nested.deep.value"), { kind: "value", value: 7 }); - assert.deepEqual(resolveFactValue(facts, "nested.deep.value"), { kind: "value", value: 7 }); - assert.deepEqual(resolveFactValue(facts, "list[1]"), { kind: "value", value: "b" }); - assert.deepEqual(resolveFactValue(facts, "context.list.0"), { kind: "value", value: "a" }); - }); - - it("resolves missing paths to undefined (not unknown)", () => { - assert.deepEqual(resolveFactValue(facts, "context.absent.leaf"), { kind: "value", value: undefined }); - assert.deepEqual(resolveFactValue(facts, "list.notanumber"), { kind: "value", value: undefined }); - }); - - it("reports unknown when traversal touches a server-only fact", () => { - assert.equal(resolveFactValue(facts, "decision_domain").kind, "unknown"); - assert.equal(resolveFactValue(facts, "policy.bundle_id").kind, "unknown"); - assert.equal(resolveFactValue(facts, "context.vertical_pack").kind, "unknown"); - assert.equal(resolveFactValue(facts, "mode").kind, "unknown"); - }); -}); - -describe("valuesEqual (mirrors areEvaluateDecisionPolicyValuesEqual)", () => { - it("coerces numbers, booleans, and case-insensitive text", () => { - assert.equal(valuesEqual("1", 1), true); - assert.equal(valuesEqual(1, true), true); - assert.equal(valuesEqual("true", true), true); - assert.equal(valuesEqual("Production", "production"), true); - assert.equal(valuesEqual(" padded ", "padded"), true); - assert.equal(valuesEqual("12abc", 12), true); // parseFloat prefix quirk β€” server mirror - assert.equal(valuesEqual("1", true), false); // number stage skipped, boolean("1")=null, text differs - }); - - it("matches any element when either side is an array", () => { - assert.equal(valuesEqual(["a", "b"], "B"), true); - assert.equal(valuesEqual("a", ["x", "A"]), true); - assert.equal(valuesEqual(["a"], ["b", ["a"]]), true); - }); - - it("falls back to strict identity", () => { - assert.equal(valuesEqual(null, null), true); - assert.equal(valuesEqual(undefined, null), false); - assert.equal(valuesEqual({ a: 1 }, { a: 1 }), false); - }); -}); - -describe("evaluatePredicate (server-exact operators, three-valued only for unknowns)", () => { - const facts = buildEvaluationFacts( - { - action: "production-deploy", - environment: "Production", - blast_radius: "15000", - change_freeze: true, - branch: "release/1.2", - labels: ["infra", "ai-authored"], - }, - { orgId: "org-1", workflowKey: "deploy_gate" }, - ); - const p = (field, op, rest = {}) => { - const parsed = parsePolicy( - RULES([{ name: "t", all: [{ field, op, ...rest }], action: "allow" }]), - ); - assert.equal(parsed.ok, true, JSON.stringify(parsed)); - return parsed.rules[0].predicates[0]; - }; - - it("eq / neq with coercion", () => { - assert.equal(evaluatePredicate(p("context.environment", "eq", { value: "production" }), facts), true); - assert.equal(evaluatePredicate(p("decision_type", "eq", { value: "production-deploy" }), facts), true); - assert.equal(evaluatePredicate(p("context.change_freeze", "eq", { value: "true" }), facts), true); - assert.equal(evaluatePredicate(p("context.environment", "neq", { value: "staging" }), facts), true); - assert.equal(evaluatePredicate(p("context.missing", "eq", { value: "x" }), facts), false); - }); - - it("numeric comparisons coerce strings and fail closed on non-numbers", () => { - assert.equal(evaluatePredicate(p("context.blast_radius", "gt", { value: 10000 }), facts), true); - assert.equal(evaluatePredicate(p("context.blast_radius", "lte", { value: "15000" }), facts), true); - assert.equal(evaluatePredicate(p("context.environment", "gt", { value: 1 }), facts), false); - assert.equal(evaluatePredicate(p("context.missing", "gt", { value: 1 }), facts), false); - }); - - it("in / not_in use the values array with semantic equality", () => { - assert.equal(evaluatePredicate(p("context.environment", "in", { values: ["staging", "PRODUCTION"] }), facts), true); - assert.equal(evaluatePredicate(p("context.environment", "not_in", { values: ["staging"] }), facts), true); - assert.equal(evaluatePredicate(p("context.environment", "not_in", { values: ["production"] }), facts), false); - }); - - it("exists is pure existence; null does not exist", () => { - assert.equal(evaluatePredicate(p("context.change_freeze", "exists"), facts), true); - assert.equal(evaluatePredicate(p("context.missing", "exists"), facts), false); - const withNull = buildEvaluationFacts({ maybe: null }, {}); - assert.equal(evaluatePredicate(p("context.maybe", "exists"), withNull), false); - }); - - it("contains: any-element for arrays, case-insensitive substring for text", () => { - assert.equal(evaluatePredicate(p("context.labels", "contains", { value: "INFRA" }), facts), true); - assert.equal(evaluatePredicate(p("context.branch", "contains", { value: "RELEASE" }), facts), true); - assert.equal(evaluatePredicate(p("context.branch", "contains", { value: "hotfix" }), facts), false); - assert.equal(evaluatePredicate(p("context.change_freeze", "contains", { value: "tru" }), facts), true); - }); - - it("matches: case-insensitive regex; invalid patterns are false", () => { - assert.equal(evaluatePredicate(p("context.branch", "matches", { value: "^release/" }), facts), true); - assert.equal(evaluatePredicate(p("context.branch", "matches", { value: "(" }), facts), false); - assert.equal(evaluatePredicate(p("context.missing", "matches", { value: "x" }), facts), false); - }); - - it("returns unknown only when the fact is server-only", () => { - assert.equal(evaluatePredicate(p("decision_domain", "eq", { value: "DEVOPS" }), facts), "unknown"); - assert.equal(evaluatePredicate(p("policy.version", "exists"), facts), "unknown"); - }); -}); - -describe("evaluateRuleConditions (Kleene logic)", () => { - const facts = buildEvaluationFacts({ a: 1 }, { orgId: "o", workflowKey: "wf" }); - const rule = (quantifier, conditions) => { - const parsed = parsePolicy(RULES([{ name: "t", [quantifier]: conditions, action: "allow" }])); - assert.equal(parsed.ok, true); - return parsed.rules[0]; - }; - const T = { field: "context.a", op: "eq", value: 1 }; - const F = { field: "context.a", op: "eq", value: 2 }; - const U = { field: "decision_domain", op: "eq", value: "DEVOPS" }; - - it("all: false beats unknown; unknown beats true", () => { - assert.equal(evaluateRuleConditions(rule("all", [F, U]), facts), false); - assert.equal(evaluateRuleConditions(rule("all", [T, U]), facts), "unknown"); - assert.equal(evaluateRuleConditions(rule("all", [T, T]), facts), true); - }); - - it("any: true beats unknown; unknown beats false", () => { - assert.equal(evaluateRuleConditions(rule("any", [T, U]), facts), true); - assert.equal(evaluateRuleConditions(rule("any", [F, U]), facts), "unknown"); - assert.equal(evaluateRuleConditions(rule("any", [F, F]), facts), false); - }); -}); - -describe("evaluatePolicy (ordering, domain certainty, halt rule)", () => { - const facts = buildEvaluationFacts( - { environment: "staging", change_freeze: true }, - { orgId: "o", workflowKey: "wf" }, - ); - const compile = (rules) => { - const parsed = parsePolicy(RULES(rules), { workflowKey: "wf" }); - assert.equal(parsed.ok, true, JSON.stringify(parsed)); - return parsed.rules; - }; - - it("default priorities mirror the server quirk: the LATER rule sorts first", () => { - // The platform compiler defaults priority = rule index and the evaluator - // sorts priority DESC, so with no explicit priorities the last rule in - // the file is evaluated first. This engine mirrors that reality so local - // verdicts predict server verdicts (the ordering fix belongs upstream). - const r = evaluatePolicy(compile([BLOCK_FREEZE, ALLOW_STAGING]), facts); - assert.equal(r.outcome, "allow"); - assert.equal(r.matchedRule.name, "Allow staging"); - }); - - it("equal explicit priorities restore file order via the index tiebreak", () => { - const r = evaluatePolicy( - compile([ - { ...BLOCK_FREEZE, priority: 10 }, - { ...ALLOW_STAGING, priority: 10 }, - ]), - facts, - ); - assert.equal(r.outcome, "block"); - assert.equal(r.matchedRule.name, "Block during freeze"); - }); - - it("priority reorders ahead of file order; ties break by index", () => { - const r = evaluatePolicy( - compile([BLOCK_FREEZE, { ...ALLOW_STAGING, priority: 50 }]), - facts, - ); - assert.equal(r.outcome, "allow"); - }); - - it("non-matching rules are skipped even with uncertain domains", () => { - const noMatchUncertainDomain = { - name: "not matching", - all: [{ field: "context.environment", op: "eq", value: "prod" }], - action: "block", // no domain β†’ defaults to workflow key β†’ uncertain, but inert - }; - const r = evaluatePolicy(compile([noMatchUncertainDomain, ALLOW_STAGING]), facts); - assert.equal(r.outcome, "allow"); - }); - - it("a matching rule with a non-wildcard domain is indeterminate", () => { - const domainBound = { ...BLOCK_FREEZE, domain: "DEVOPS" }; - const r = evaluatePolicy(compile([domainBound]), facts); - assert.equal(r.outcome, "indeterminate"); - assert.match(r.explanation, /domain "DEVOPS"/); - }); - - it("an unknown-condition rule halts the scan before later-sorted definitive rules", () => { - const unknownRule = { - name: "server-only", - all: [{ field: "decision_domain", op: "eq", value: "DEVOPS" }], - action: "block", - domain: "*", - priority: 100, // sorts first - }; - const r = evaluatePolicy(compile([unknownRule, ALLOW_STAGING]), facts); - assert.equal(r.outcome, "indeterminate"); - assert.equal(r.haltedRule.name, "server-only"); - }); - - it("a definitive match that sorts ahead of an unknown rule is not halted by it", () => { - const unknownRule = { - name: "server-only", - all: [{ field: "decision_domain", op: "eq", value: "DEVOPS" }], - action: "block", - domain: "*", // priority defaults to index 0 β€” sorts after the allow rule - }; - const r = evaluatePolicy(compile([unknownRule, { ...ALLOW_STAGING, priority: 100 }]), facts); - assert.equal(r.outcome, "allow"); - }); - - it("no matching rule β†’ no_match", () => { - const r = evaluatePolicy(compile([{ ...ALLOW_STAGING, all: [{ field: "context.environment", op: "eq", value: "prod" }] }]), facts); - assert.equal(r.outcome, "no_match"); - }); - - it("isDomainCertain accepts * and ANY only", () => { - assert.equal(isDomainCertain({ domain: "*" }), true); - assert.equal(isDomainCertain({ domain: " any " }), true); - assert.equal(isDomainCertain({ domain: "DEVOPS" }), false); - assert.equal(isDomainCertain({ domain: "github_action" }), false); - }); -}); - -describe("evaluateLocalPolicy (facade)", () => { - const opts = { payload: { environment: "staging" }, orgId: "org-1", workflowKey: "wf" }; - - it("falls back when there is no rules block or no policy text", () => { - assert.equal(evaluateLocalPolicy("# prose only", opts).fallbackReason, "no_rules_block"); - assert.equal(evaluateLocalPolicy("", opts).fallbackReason, "no_rules_block"); - }); - - it("falls back on YAML-ish or malformed blocks without judging them invalid", () => { - const yaml = evaluateLocalPolicy(md("rules:\n - name: x"), opts); - assert.equal(yaml.status, "fallback"); - assert.equal(yaml.fallbackReason, "not_json"); - const bad = evaluateLocalPolicy(md('{"rules":[{"name":"x","action":"allow"}]}'), opts); - assert.equal(bad.status, "fallback"); - assert.match(bad.fallbackReason, /^compile_error:/); - }); - - it("returns a deterministic allow/block for wildcard-domain matches", () => { - const allow = evaluateLocalPolicy(md(RULES([ALLOW_STAGING])), opts); - assert.equal(allow.status, "verdict"); - assert.equal(allow.outcome, "allow"); - assert.equal(allow.deterministic, true); - assert.equal(allow.matchedRule.name, "Allow staging"); - assert.ok(allow.elapsedUs >= 1); - assert.equal(allow.ruleCount, 1); - - const block = evaluateLocalPolicy(md(RULES([BLOCK_FREEZE])), { - ...opts, - payload: { change_freeze: true }, - }); - assert.equal(block.outcome, "block"); - assert.equal(block.deterministic, true); - }); - - it("reports matched escalate/restrain as non-deterministic predictions", () => { - const r = evaluateLocalPolicy( - md(RULES([{ ...BLOCK_FREEZE, name: "Escalate freeze", action: "escalate" }])), - { ...opts, payload: { change_freeze: true } }, - ); - assert.equal(r.status, "fallback"); - assert.equal(r.outcome, "escalate"); - assert.equal(r.deterministic, false); - assert.equal(r.fallbackReason, "outcome_not_local:escalate"); - assert.equal(r.matchedRule.name, "Escalate freeze"); - }); - - it("shipped example: an infra destroy matches the escalate rule and defers to the API", async () => { - const content = await readFile(examplePath("DECIONIS_POLICY.devops.md"), "utf8"); - const r = evaluateLocalPolicy(content, { - payload: { destroys_resources: true }, - orgId: "org-1", - workflowKey: "infra-destroy", - }); - assert.equal(r.status, "fallback"); - assert.equal(r.fallbackReason, "outcome_not_local:escalate"); - assert.equal(r.outcome, "escalate"); - assert.equal(r.matchedRule.name, "Escalate infrastructure destroy"); - }); - - it("shipped example: a change freeze yields a deterministic local block", async () => { - const content = await readFile(examplePath("DECIONIS_POLICY.devops.md"), "utf8"); - const r = evaluateLocalPolicy(content, { - payload: { change_freeze: true }, - orgId: "org-1", - workflowKey: "deploy", - }); - assert.equal(r.status, "verdict"); - assert.equal(r.outcome, "block"); - assert.equal(r.deterministic, true); - }); - - it("shipped example: a non-matching payload falls through to no_match", async () => { - const content = await readFile(examplePath("DECIONIS_POLICY.md"), "utf8"); - const r = evaluateLocalPolicy(content, { - payload: { environment: "dev" }, - orgId: "org-1", - workflowKey: "docs_only", - }); - assert.equal(r.status, "fallback"); - assert.equal(r.fallbackReason, "no_match"); - }); -});